├── .gitignore ├── .golangci.yml ├── internal ├── mocks │ ├── generate.go │ └── par2_executor_mock.go ├── repairnzb │ ├── segment.go │ ├── utils.go │ ├── nttp_article.go │ ├── par2.go │ ├── par2_test.go │ ├── repair_nzb_test.go │ └── repair_nzb.go ├── config │ └── config.go ├── scanner │ ├── scanner.go │ └── scanner_test.go ├── queue │ └── queue.go └── app │ └── app.go ├── main.go ├── .github ├── dependabot.yml ├── actions │ └── test │ │ └── action.yml └── workflows │ ├── release.yml │ └── pull-request.yml ├── config.example.yml ├── CONTRIBUTING.md ├── .gorelease-dev.yml ├── LICENSE ├── Makefile ├── .goreleaser.yml ├── README.md ├── cmd └── nzbrepair │ └── nzb-repair.go ├── pkg └── par2exedownloader │ └── par2exe_downloader.go ├── go.mod └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | par2cmd 2 | .vscode 3 | test.nzb 4 | config.yml -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | run: 3 | timeout: 5m 4 | modules-download-mode: readonly 5 | 6 | linters: 7 | default: standard 8 | -------------------------------------------------------------------------------- /internal/mocks/generate.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | //go:generate mockgen -source=../repairnzb/par2.go -destination=./par2_executor_mock.go -package=mocks 4 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/javi11/nzb-repair/cmd/nzbrepair" 5 | ) 6 | 7 | func main() { 8 | nzbrepair.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /internal/repairnzb/segment.go: -------------------------------------------------------------------------------- 1 | package repairnzb 2 | 3 | import "github.com/Tensai75/nzbparser" 4 | 5 | type brokenSegment struct { 6 | segment *nzbparser.NzbSegment 7 | file *nzbparser.NzbFile 8 | } 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for more information: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | # https://containers.dev/guide/dependabot 6 | 7 | version: 2 8 | updates: 9 | - package-ecosystem: "devcontainers" 10 | directory: "/" 11 | schedule: 12 | interval: weekly 13 | -------------------------------------------------------------------------------- /.github/actions/test/action.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | description: Run tests 3 | inputs: 4 | os: 5 | description: 'OS to run the tests on' 6 | required: true 7 | default: 'ubuntu-latest' 8 | runs: 9 | using: composite 10 | steps: 11 | - uses: actions/setup-go@v5 12 | with: 13 | go-version: '1.24.0' # Pinned version 14 | - name: Go info 15 | shell: bash 16 | run: | 17 | go version 18 | go env 19 | # test 20 | - name: Tests 21 | shell: bash 22 | run: | 23 | make test 24 | -------------------------------------------------------------------------------- /internal/repairnzb/utils.go: -------------------------------------------------------------------------------- 1 | package repairnzb 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | func generateRandomMessageID() string { 9 | return generateRandomString(32) + "@" + generateRandomString(8) + "." + generateRandomString(3) 10 | } 11 | 12 | func generateRandomString(size int) string { 13 | const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" 14 | seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) 15 | 16 | // Create the random string 17 | result := make([]byte, size) 18 | for i := range result { 19 | result[i] = charset[seededRand.Intn(len(charset))] 20 | } 21 | return string(result) 22 | } 23 | -------------------------------------------------------------------------------- /config.example.yml: -------------------------------------------------------------------------------- 1 | download_providers: 2 | - host: news.example.com 3 | port: 119 4 | username: user 5 | password: pass 6 | ssl: true 7 | max_connections: 10 8 | max_connection_idle_time_in_seconds: 2400 9 | 10 | upload_providers: 11 | - host: upload.example.com 12 | port: 119 13 | username: user 14 | password: pass 15 | ssl: true 16 | max_connections: 5 17 | max_connection_idle_time_in_seconds: 2400 18 | # Scan interval for the directory watcher in duration string like "40s" "5m", "1h" 19 | scan_interval: 5m 20 | 21 | # Maximum number of retries for a failed download 22 | max_retries: 3 23 | 24 | # Folder to move broken files to 25 | broken_folder: broken 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Submitting a Pull Request 4 | 5 | A typical workflow is: 6 | 7 | 1. Create a topic branch. 8 | 2. Add tests for your change. 9 | 3. Add the new code. 10 | 4. Run `make` to ensure it passes all the checks. 11 | 5. Add, commit and push your changes. 12 | 6. Submit a pull request. 13 | 14 | ## Testing 15 | 16 | No assert library is used in the codebase. Instead, use the standard library `testing` package. 17 | 18 | Ensure that your tests have descriptive names and are easily understandable. Make sure that failure messages from tests offer sufficient information for understanding the cause of failure. Utilize tables and Fuzz testing when appropriate. 19 | 20 | Take inspiration from the numerous examples of tests available in the codebase. Aim for achieving 100% test coverage whenever feasible. 21 | 22 | The project aims to follow Google's [testing guidelines](https://google.github.io/styleguide/go/decisions.html#useful-test-failures). 23 | -------------------------------------------------------------------------------- /.gorelease-dev.yml: -------------------------------------------------------------------------------- 1 | # https://goreleaser.com 2 | project_name: nzb-repair 3 | 4 | builds: 5 | - goos: 6 | - linux 7 | goarch: 8 | - amd64 9 | main: ./main.go 10 | ldflags: 11 | - -s -w 12 | - -X "main.Version={{ .Version }}" 13 | - -X "main.GitCommit={{ .ShortCommit }}" 14 | - -X "main.Timestamp={{ .Timestamp }}" 15 | flags: 16 | - -trimpath 17 | env: 18 | - CGO_ENABLED=1 19 | archives: 20 | - format: tar.gz 21 | # this name template makes the OS and Arch compatible with the results of uname. 22 | name_template: '{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 23 | 24 | # Checksum 25 | checksum: 26 | name_template: 'checksums.txt' 27 | algorithm: sha512 28 | 29 | # Changelog 30 | changelog: 31 | filters: 32 | exclude: 33 | - '^docs:' 34 | - '^test:' 35 | - '^Merge branch' 36 | 37 | snapshot: 38 | name_template: 'pr-{{ .Env.BRANCH_NAME }}' 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 javi11 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO ?= go 2 | 3 | .DEFAULT_GOAL := check 4 | 5 | .PHONY: generate 6 | generate: 7 | go generate ./... 8 | 9 | .PHONY: govulncheck 10 | govulncheck: 11 | go tool govulncheck ./... 12 | 13 | .PHONY: tidy go-mod-tidy 14 | tidy: go-mod-tidy 15 | go-mod-tidy: 16 | $(GO) mod tidy 17 | 18 | .PHONY: golangci-lint golangci-lint-fix 19 | golangci-lint-fix: ARGS=--fix 20 | golangci-lint-fix: golangci-lint 21 | golangci-lint: 22 | go tool golangci-lint run $(ARGS) 23 | 24 | .PHONY: junit 25 | junit: | $(JUNIT) 26 | mkdir -p ./test-results && $(GO) test -v 2>&1 ./... | go tool go-junit-report -set-exit-code > ./test-results/report.xml 27 | 28 | .PHONY: coverage 29 | coverage: 30 | $(GO) test -v -coverprofile=coverage.out ./... 31 | 32 | .PHONY: coverage-html 33 | coverage-html: coverage 34 | $(GO) tool cover -html=coverage.out -o coverage.html 35 | 36 | .PHONY: coverage-func 37 | coverage-func: coverage 38 | $(GO) tool cover -func=coverage.out 39 | 40 | .PHONY: lint 41 | lint: go-mod-tidy golangci-lint 42 | 43 | .PHONY: test test-race 44 | test-race: ARGS=-race 45 | test-race: test 46 | test: 47 | $(GO) test $(ARGS) ./... 48 | 49 | .PHONY: check 50 | check: generate go-mod-tidy golangci-lint test-race 51 | 52 | .PHONY: git-hooks 53 | git-hooks: 54 | @echo '#!/bin/sh\nmake' > .git/hooks/pre-commit 55 | @chmod +x .git/hooks/pre-commit -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | env: 9 | # Use docker.io for Docker Hub if empty 10 | REGISTRY: ghcr.io 11 | # github.repository as / 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | jobs: 15 | test: 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, macos-latest, windows-latest] 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | # checkout 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | - uses: ./.github/actions/test 27 | with: 28 | os: ${{ matrix.os }} 29 | build-artifacts: 30 | runs-on: macos-13 31 | needs: test 32 | permissions: 33 | contents: write 34 | packages: write 35 | issues: write 36 | steps: 37 | # dependencies 38 | - uses: mlugg/setup-zig@v1 39 | # Include go and NodeJS 40 | - uses: actions/setup-go@v5 41 | with: 42 | go-version: 1.24.0 43 | 44 | # Include latest bun 45 | - uses: oven-sh/setup-bun@v2 46 | 47 | # checkout 48 | - name: Checkout 49 | uses: actions/checkout@v4 50 | with: 51 | fetch-depth: 0 52 | 53 | # git status 54 | - name: Git status 55 | run: git status 56 | 57 | # build 58 | - name: Release 59 | uses: goreleaser/goreleaser-action@v6 60 | with: 61 | version: latest 62 | args: release --clean 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # https://goreleaser.com 2 | project_name: nzb-repair 3 | 4 | builds: 5 | - goos: 6 | - darwin 7 | - linux 8 | - windows 9 | goarch: 10 | - amd64 11 | - arm64 12 | main: ./main.go 13 | ldflags: 14 | - -s -w 15 | - -X "main.Version={{ .Version }}" 16 | - -X "main.GitCommit={{ .ShortCommit }}" 17 | - -X "main.Timestamp={{ .Timestamp }}" 18 | ignore: 19 | - goos: windows 20 | goarch: arm64 21 | flags: 22 | - -trimpath 23 | env: 24 | - CGO_ENABLED=1 25 | - >- 26 | {{- if eq .Os "linux" }} 27 | {{- if eq .Arch "amd64" }}CC=zig cc -target x86_64-linux-musl{{- end }} 28 | {{- if eq .Arch "arm64"}}CC=zig cc -target aarch64-linux-musl{{- end }} 29 | {{- end }} 30 | - >- 31 | {{- if eq .Os "windows" }} 32 | {{- if eq .Arch "amd64" }}CC=zig cc -target x86_64-windows-gnu{{- end }} 33 | {{- end }} 34 | universal_binaries: 35 | - replace: true 36 | archives: 37 | - format: tar.gz 38 | # this name template makes the OS and Arch compatible with the results of uname. 39 | name_template: '{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 40 | 41 | # use zip for windows archives 42 | format_overrides: 43 | - goos: windows 44 | format: zip 45 | 46 | # Checksum 47 | checksum: 48 | name_template: 'checksums.txt' 49 | algorithm: sha512 50 | 51 | # Changelog 52 | changelog: 53 | filters: 54 | exclude: 55 | - '^docs:' 56 | - '^test:' 57 | - '^Merge branch' 58 | -------------------------------------------------------------------------------- /internal/mocks/par2_executor_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: ../repairnzb/par2.go 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -source=../repairnzb/par2.go -destination=./par2_executor_mock.go -package=mocks 7 | // 8 | 9 | // Package mocks is a generated GoMock package. 10 | package mocks 11 | 12 | import ( 13 | context "context" 14 | reflect "reflect" 15 | 16 | gomock "go.uber.org/mock/gomock" 17 | ) 18 | 19 | // MockPar2Executor is a mock of Par2Executor interface. 20 | type MockPar2Executor struct { 21 | ctrl *gomock.Controller 22 | recorder *MockPar2ExecutorMockRecorder 23 | isgomock struct{} 24 | } 25 | 26 | // MockPar2ExecutorMockRecorder is the mock recorder for MockPar2Executor. 27 | type MockPar2ExecutorMockRecorder struct { 28 | mock *MockPar2Executor 29 | } 30 | 31 | // NewMockPar2Executor creates a new mock instance. 32 | func NewMockPar2Executor(ctrl *gomock.Controller) *MockPar2Executor { 33 | mock := &MockPar2Executor{ctrl: ctrl} 34 | mock.recorder = &MockPar2ExecutorMockRecorder{mock} 35 | return mock 36 | } 37 | 38 | // EXPECT returns an object that allows the caller to indicate expected use. 39 | func (m *MockPar2Executor) EXPECT() *MockPar2ExecutorMockRecorder { 40 | return m.recorder 41 | } 42 | 43 | // Repair mocks base method. 44 | func (m *MockPar2Executor) Repair(ctx context.Context, tmpPath string) error { 45 | m.ctrl.T.Helper() 46 | ret := m.ctrl.Call(m, "Repair", ctx, tmpPath) 47 | ret0, _ := ret[0].(error) 48 | return ret0 49 | } 50 | 51 | // Repair indicates an expected call of Repair. 52 | func (mr *MockPar2ExecutorMockRecorder) Repair(ctx, tmpPath any) *gomock.Call { 53 | mr.mock.ctrl.T.Helper() 54 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Repair", reflect.TypeOf((*MockPar2Executor)(nil).Repair), ctx, tmpPath) 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "main" 7 | 8 | env: 9 | # Use docker.io for Docker Hub if empty 10 | REGISTRY: ghcr.io 11 | # github.repository as / 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | jobs: 15 | test: 16 | if: ${{ !contains(github.event.head_commit.message, 'docs:') }} 17 | runs-on: ubuntu-latest 18 | steps: 19 | # checkout 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - uses: ./.github/actions/test 25 | build-artifacts: 26 | if: ${{ !contains(github.event.head_commit.message, 'docs:') || github.event.pull_request.head.repo.fork == false }} 27 | runs-on: ubuntu-latest 28 | needs: test 29 | permissions: 30 | contents: write 31 | packages: write 32 | issues: write 33 | steps: 34 | # dependencies 35 | # Include go 36 | - uses: actions/setup-go@v5 37 | with: 38 | go-version: 1.24.0 39 | 40 | # checkout 41 | - name: Checkout 42 | uses: actions/checkout@v4 43 | with: 44 | fetch-depth: 0 45 | 46 | # git status 47 | - name: Git status 48 | run: git status 49 | 50 | - name: Determine Branch 51 | id: branch 52 | uses: transferwise/sanitize-branch-name@v1 53 | 54 | # build 55 | - name: build artifacts 56 | id: build_artifacts 57 | uses: goreleaser/goreleaser-action@v5 58 | with: 59 | version: latest 60 | args: release --snapshot --clean --config .gorelease-dev.yml 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | BRANCH_NAME: ${{ steps.branch.outputs.sanitized-branch-name }} 64 | 65 | # artifacts 66 | - name: Push linux artifact 67 | uses: actions/upload-artifact@v4 68 | with: 69 | name: build_linux 70 | path: dist/*linux* 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nzb-repair 2 | 3 | 4 | 5 | A tool to repair incomplete NZB files by reuploading the missing articles using the par2 repair. 6 | 7 | ## Usage 8 | 9 | 1. Create a config file `config.yaml` with your Usenet provider details: 10 | 11 | ```yaml 12 | download_providers: 13 | - host: news.example.com 14 | port: 563 15 | username: your_username 16 | password: your_password 17 | connections: 10 18 | tls: true 19 | upload_providers: 20 | - host: upload.example.com 21 | port: 563 22 | username: your_username 23 | password: your_password 24 | connections: 5 25 | tls: true 26 | ``` 27 | 28 | 2. Run the tool: 29 | 30 | **Single File Repair:** 31 | 32 | ```sh 33 | nzb-repair -c config.yaml path/to/your.nzb 34 | ``` 35 | 36 | **Watch Mode (Monitor a directory):** 37 | 38 | It will scan a directory in configurable interval for files to repair. 39 | 40 | ```sh 41 | nzb-repair watch -c config.yaml -d /path/to/watch/directory 42 | ``` 43 | 44 | **Options:** 45 | 46 | _Flags applicable to both modes:_ 47 | 48 | - `-c, --config`: Config file path (required) 49 | - `-o, --output`: Output file path or directory for repaired nzb files (optional, defaults vary by mode: next to input file for single repair, `repaired/` subdirectory for watch mode) 50 | - `--tmp-dir`: Temporary directory for processing files (optional, defaults to system temp dir) 51 | - `-v, --verbose`: Enable verbose logging (optional) 52 | 53 | _Flags specific to Watch Mode:_ 54 | 55 | - `-d, --dir`: Directory to watch for nzb files (required for watch mode) 56 | - `-b, --db`: Path to the sqlite database file for the queue (optional, defaults to `queue.db`) 57 | 58 | ## Development Setup 59 | 60 | To set up the project for development, follow these steps: 61 | 62 | 1. Clone the repository: 63 | 64 | ```sh 65 | git clone https://github.com/javi11/nzb-repair.git 66 | cd nntpcli 67 | ``` 68 | 69 | 2. Install dependencies: 70 | 71 | ```sh 72 | go mod download 73 | ``` 74 | 75 | 3. Run tests: 76 | 77 | ```sh 78 | make test 79 | ``` 80 | 81 | 4. Lint the code: 82 | 83 | ```sh 84 | make lint 85 | ``` 86 | 87 | 5. Generate mocks and other code: 88 | 89 | ```sh 90 | make generate 91 | ``` 92 | 93 | ## Contributing 94 | 95 | Contributions are welcome! Please open an issue or submit a pull request. See the [CONTRIBUTING.md](CONTRIBUTING.md) file for details. 96 | 97 | ## License 98 | 99 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 100 | -------------------------------------------------------------------------------- /internal/repairnzb/nttp_article.go: -------------------------------------------------------------------------------- 1 | package repairnzb 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "hash/crc32" 7 | "io" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | type Encoder interface { 13 | Encode(p []byte) []byte 14 | } 15 | 16 | type articleData struct { 17 | PartNum int64 `json:"part_num"` 18 | PartTotal int64 `json:"part_total"` 19 | PartSize int64 `json:"part_size"` 20 | PartBegin int64 `json:"part_begin"` 21 | PartEnd int64 `json:"part_end"` 22 | FileNum int `json:"file_num"` 23 | FileTotal int `json:"file_total"` 24 | FileSize int64 `json:"file_size"` 25 | Subject string `json:"subject"` 26 | Poster string `json:"poster"` 27 | Groups []string `json:"groups"` 28 | MsgId string `json:"msg_id"` 29 | XNxgHeader string `json:"x_nxg_header"` 30 | Filename string `json:"filename"` 31 | CustomHeaders map[string]string `json:"custom_headers"` 32 | Date *time.Time `json:"date"` 33 | body []byte 34 | } 35 | 36 | func (a *articleData) EncodeBytes(encoder Encoder) (io.Reader, error) { 37 | headers := make(map[string]string) 38 | 39 | if a.CustomHeaders != nil { 40 | for k, v := range a.CustomHeaders { 41 | headers[k] = v 42 | } 43 | } 44 | 45 | headers["Subject"] = a.Subject 46 | headers["From"] = a.Poster 47 | headers["Newsgroups"] = strings.Join(a.Groups, ",") 48 | headers["Message-ID"] = fmt.Sprintf("<%s>", a.MsgId) 49 | 50 | if a.XNxgHeader != "" { 51 | headers["X-Nxg"] = a.XNxgHeader 52 | } 53 | 54 | if a.Date == nil { 55 | headers["Date"] = time.Now().UTC().Format(time.RFC1123) 56 | } else { 57 | headers["Date"] = a.Date.UTC().Format(time.RFC1123) 58 | } 59 | 60 | header := "" 61 | for k, v := range headers { 62 | header += fmt.Sprintf("%s: %s\r\n", k, v) 63 | } 64 | 65 | header += fmt.Sprintf("\r\n=ybegin part=%d total=%d line=128 size=%d name=%s\r\n=ypart begin=%d end=%d\r\n", 66 | a.PartNum, a.PartTotal, a.FileSize, a.Filename, a.PartBegin+1, a.PartEnd) 67 | 68 | // Encoded data 69 | encoded := encoder.Encode(a.body) 70 | 71 | // yEnc end line 72 | h := crc32.NewIEEE() 73 | _, err := h.Write(a.body) 74 | if err != nil { 75 | return nil, err 76 | } 77 | footer := fmt.Sprintf("\r\n=yend size=%d part=%d pcrc32=%08X\r\n", a.PartSize, a.PartNum, h.Sum32()) 78 | 79 | size := len(header) + len(encoded) + len(footer) 80 | buf := bytes.NewBuffer(make([]byte, 0, size)) 81 | 82 | _, err = buf.WriteString(header) 83 | if err != nil { 84 | return nil, err 85 | } 86 | _, err = buf.Write(encoded) 87 | if err != nil { 88 | return nil, err 89 | } 90 | _, err = buf.WriteString(footer) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | return buf, nil 96 | } 97 | -------------------------------------------------------------------------------- /cmd/nzbrepair/nzb-repair.go: -------------------------------------------------------------------------------- 1 | package nzbrepair 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "github.com/javi11/nzb-repair/internal/app" 11 | "github.com/javi11/nzb-repair/internal/config" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var ( 16 | configFile string 17 | outputFileOrDir string 18 | verbose bool 19 | watchDir string 20 | dbPath string 21 | tmpDir string 22 | rootCmd = &cobra.Command{ 23 | Use: "nzbrepair [nzb file]", 24 | Short: "NZB Repair tool", 25 | Long: `A command line tool to repair NZB files`, 26 | Args: cobra.ExactArgs(1), 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | cfg, err := config.NewFromFile(configFile) 29 | if err != nil { 30 | slog.Error("Failed to load config", "error", err) 31 | return err 32 | } 33 | 34 | effectiveTmpDir := tmpDir 35 | if effectiveTmpDir == "" { 36 | effectiveTmpDir = os.TempDir() 37 | } 38 | 39 | return app.RunSingleRepair(cmd.Context(), cfg, args[0], outputFileOrDir, effectiveTmpDir, verbose) 40 | }, 41 | } 42 | watchCmd = &cobra.Command{ 43 | Use: "watch", 44 | Short: "Scan a directory for NZB files and repair them", 45 | Long: `Periodically scans a specified directory for .nzb files and queues them for repair. The scan interval can be configured in the config file.`, 46 | RunE: func(cmd *cobra.Command, args []string) error { 47 | cfg, err := config.NewFromFile(configFile) 48 | if err != nil { 49 | slog.Error("Failed to load config", "error", err) 50 | return err 51 | } 52 | 53 | effectiveTmpDir := tmpDir 54 | if effectiveTmpDir == "" { 55 | effectiveTmpDir = os.TempDir() 56 | } 57 | 58 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 59 | defer stop() 60 | 61 | return app.RunWatcher(ctx, cfg, watchDir, dbPath, outputFileOrDir, effectiveTmpDir, verbose) 62 | }, 63 | } 64 | ) 65 | 66 | func init() { 67 | rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "config file path") 68 | rootCmd.PersistentFlags().StringVarP(&outputFileOrDir, "output", "o", "", "output file path or directory for repaired nzb files (default: next to input / repaired/ dir for watch)") 69 | rootCmd.PersistentFlags().StringVar(&tmpDir, "tmp-dir", os.TempDir(), "temporary directory for processing files") 70 | rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose logging") 71 | _ = rootCmd.MarkPersistentFlagRequired("config") 72 | 73 | watchCmd.Flags().StringVarP(&watchDir, "dir", "d", "", "directory to watch for nzb files") 74 | watchCmd.Flags().StringVarP(&dbPath, "db", "b", "queue.db", "path to the sqlite database file") 75 | _ = watchCmd.MarkFlagRequired("dir") 76 | 77 | rootCmd.AddCommand(watchCmd) 78 | } 79 | 80 | func Execute() { 81 | if err := rootCmd.Execute(); err != nil { 82 | slog.Error("Command execution failed", "error", err) 83 | os.Exit(1) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "time" 7 | 8 | "github.com/javi11/nntppool" 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | // Logger interface compatible with slog.Logger 13 | type Logger interface { 14 | Debug(msg string, args ...any) 15 | Info(msg string, args ...any) 16 | Warn(msg string, args ...any) 17 | Error(msg string, args ...any) 18 | DebugContext(ctx context.Context, msg string, args ...any) 19 | InfoContext(ctx context.Context, msg string, args ...any) 20 | WarnContext(ctx context.Context, msg string, args ...any) 21 | ErrorContext(ctx context.Context, msg string, args ...any) 22 | } 23 | 24 | type Config struct { 25 | // By default the number of connections for download providers is the sum of all MaxConnections 26 | DownloadWorkers int `yaml:"download_workers"` 27 | UploadWorkers int `yaml:"upload_workers"` 28 | DownloadFolder string `yaml:"download_folder"` 29 | DownloadProviders []nntppool.UsenetProviderConfig `yaml:"download_providers"` 30 | UploadProviders []nntppool.UsenetProviderConfig `yaml:"upload_providers"` 31 | Par2Exe string `yaml:"par2_exe"` 32 | Upload UploadConfig `yaml:"upload"` 33 | ScanInterval time.Duration `yaml:"scan_interval"` // duration string like "5m", "1h" 34 | MaxRetries int64 `yaml:"max_retries"` // maximum number of retries before moving to broken folder 35 | BrokenFolder string `yaml:"broken_folder"` // folder to move broken files to 36 | } 37 | 38 | type UploadConfig struct { 39 | ObfuscationPolicy ObfuscationPolicy `yaml:"obfuscation_policy"` 40 | } 41 | 42 | type ObfuscationPolicy string 43 | 44 | const ( 45 | ObfuscationPolicyNone ObfuscationPolicy = "none" 46 | ObfuscationPolicyFull ObfuscationPolicy = "full" 47 | ) 48 | 49 | type Option func(*Config) 50 | 51 | var ( 52 | providerConfigDefault = nntppool.Provider{ 53 | MaxConnections: 10, 54 | MaxConnectionIdleTimeInSeconds: 2400, 55 | } 56 | downloadWorkersDefault = 10 57 | uploadWorkersDefault = 10 58 | scanIntervalDefault = 5 * time.Minute 59 | maxRetriesDefault = int64(3) 60 | brokenFolderDefault = "broken" 61 | ) 62 | 63 | func mergeWithDefault(config ...Config) Config { 64 | if len(config) == 0 { 65 | return Config{ 66 | DownloadProviders: []nntppool.UsenetProviderConfig{}, 67 | UploadProviders: []nntppool.UsenetProviderConfig{}, 68 | DownloadWorkers: downloadWorkersDefault, 69 | UploadWorkers: uploadWorkersDefault, 70 | DownloadFolder: "./", 71 | ScanInterval: scanIntervalDefault, 72 | MaxRetries: maxRetriesDefault, 73 | BrokenFolder: brokenFolderDefault, 74 | } 75 | } 76 | 77 | cfg := config[0] 78 | 79 | downloadWorkers := 0 80 | for i, p := range cfg.DownloadProviders { 81 | if p.MaxConnections == 0 { 82 | p.MaxConnections = providerConfigDefault.MaxConnections 83 | } 84 | 85 | if p.MaxConnectionIdleTimeInSeconds == 0 { 86 | p.MaxConnectionIdleTimeInSeconds = providerConfigDefault.MaxConnectionIdleTimeInSeconds 87 | } 88 | 89 | cfg.DownloadProviders[i] = p 90 | downloadWorkers += p.MaxConnections 91 | } 92 | 93 | if cfg.DownloadWorkers == 0 { 94 | cfg.DownloadWorkers = downloadWorkers 95 | } 96 | 97 | uploadWorkers := 0 98 | for i, p := range cfg.UploadProviders { 99 | if p.MaxConnections == 0 { 100 | p.MaxConnections = providerConfigDefault.MaxConnections 101 | } 102 | 103 | if p.MaxConnectionIdleTimeInSeconds == 0 { 104 | p.MaxConnectionIdleTimeInSeconds = providerConfigDefault.MaxConnectionIdleTimeInSeconds 105 | } 106 | 107 | cfg.UploadProviders[i] = p 108 | uploadWorkers += p.MaxConnections 109 | } 110 | 111 | if cfg.UploadWorkers == 0 { 112 | cfg.UploadWorkers = uploadWorkers 113 | } 114 | 115 | if cfg.ScanInterval == 0 { 116 | cfg.ScanInterval = scanIntervalDefault 117 | } 118 | 119 | if cfg.MaxRetries == 0 { 120 | cfg.MaxRetries = maxRetriesDefault 121 | } 122 | 123 | if cfg.BrokenFolder == "" { 124 | cfg.BrokenFolder = brokenFolderDefault 125 | } 126 | 127 | return cfg 128 | } 129 | 130 | func NewFromFile(path string) (Config, error) { 131 | data, err := os.ReadFile(path) 132 | if err != nil { 133 | return Config{}, err 134 | } 135 | 136 | var cfg Config 137 | if err := yaml.Unmarshal(data, &cfg); err != nil { 138 | return Config{}, err 139 | } 140 | 141 | return mergeWithDefault(cfg), nil 142 | } 143 | -------------------------------------------------------------------------------- /internal/scanner/scanner.go: -------------------------------------------------------------------------------- 1 | package scanner 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io/fs" 8 | "log/slog" 9 | "path/filepath" 10 | "strings" 11 | "time" 12 | 13 | "github.com/javi11/nzb-repair/internal/queue" 14 | "github.com/opencontainers/selinux/pkg/pwalkdir" 15 | ) 16 | 17 | // Scanner periodically scans directories for .nzb files. 18 | type Scanner struct { 19 | dir string 20 | queue queue.Queuer 21 | log *slog.Logger 22 | scanInterval time.Duration 23 | isScanning bool 24 | } 25 | 26 | // NewScanner creates a new Scanner instance. 27 | func New(dir string, q queue.Queuer, logger *slog.Logger, scanInterval time.Duration) *Scanner { 28 | absDir, err := filepath.Abs(dir) 29 | if err != nil { 30 | logger.Warn("Failed to get absolute path for scan directory, relative paths might be inconsistent.", "directory", dir, "error", err) 31 | absDir = dir 32 | } 33 | 34 | return &Scanner{ 35 | dir: absDir, 36 | queue: q, 37 | log: logger.With("component", "scanner", "directory", absDir), 38 | scanInterval: scanInterval, 39 | } 40 | } 41 | 42 | // Run starts the periodic scanning process. 43 | // It blocks until the context is canceled. 44 | func (s *Scanner) Run(ctx context.Context) error { 45 | s.log.InfoContext(ctx, "Starting scanner", "interval", s.scanInterval) 46 | 47 | ticker := time.NewTicker(s.scanInterval) 48 | defer ticker.Stop() 49 | 50 | // Perform initial scan 51 | if err := s.scanDirectory(ctx, s.dir); err != nil { 52 | s.log.ErrorContext(ctx, "Initial scan failed", "error", err) 53 | } 54 | 55 | for { 56 | select { 57 | case <-ctx.Done(): 58 | s.log.InfoContext(ctx, "Stopping scanner due to context cancellation") 59 | return ctx.Err() 60 | case <-ticker.C: 61 | if s.isScanning { 62 | s.log.DebugContext(ctx, "Skipping scan as previous scan is still in progress") 63 | continue 64 | } 65 | 66 | if err := s.scanDirectory(ctx, s.dir); err != nil { 67 | s.log.ErrorContext(ctx, "Scan failed", "error", err) 68 | } 69 | } 70 | } 71 | } 72 | 73 | // scanDirectory recursively scans a directory for .nzb files and adds them to the queue. 74 | func (s *Scanner) scanDirectory(ctx context.Context, dirPath string) error { 75 | s.isScanning = true 76 | defer func() { s.isScanning = false }() 77 | 78 | s.log.InfoContext(ctx, "Starting directory scan", "directory", dirPath) 79 | startTime := time.Now() 80 | 81 | err := pwalkdir.Walk(dirPath, func(path string, info fs.DirEntry, walkErr error) error { 82 | // Check for context cancellation 83 | select { 84 | case <-ctx.Done(): 85 | return ctx.Err() 86 | default: 87 | } 88 | 89 | // Handle errors during walking 90 | if walkErr != nil { 91 | s.log.WarnContext(ctx, "Error accessing path during scan", "path", path, "error", walkErr) 92 | if info != nil && info.IsDir() { 93 | return filepath.SkipDir 94 | } 95 | return nil 96 | } 97 | 98 | // Process NZB files 99 | if !info.IsDir() && strings.ToLower(filepath.Ext(info.Name())) == ".nzb" { 100 | s.log.DebugContext(ctx, "Found NZB file during scan", "path", path) 101 | s.addFileToQueue(ctx, path) 102 | } 103 | 104 | return nil 105 | }) 106 | 107 | duration := time.Since(startTime) 108 | if err != nil && !errors.Is(err, context.Canceled) { 109 | s.log.ErrorContext(ctx, "Error during directory scan", "directory", dirPath, "duration", duration, "error", err) 110 | return fmt.Errorf("scan failed: %w", err) 111 | } 112 | 113 | s.log.InfoContext(ctx, "Finished scanning directory", "directory", dirPath, "duration", duration) 114 | return nil 115 | } 116 | 117 | // addFileToQueue handles the logic of validating and adding a file path to the queue. 118 | func (s *Scanner) addFileToQueue(ctx context.Context, filePath string) { 119 | s.log.InfoContext(ctx, "Adding detected NZB file to queue", "path", filePath) 120 | 121 | absPath, err := filepath.Abs(filePath) 122 | if err != nil { 123 | s.log.WarnContext(ctx, "Failed to get absolute path, using original", "path", filePath, "error", err) 124 | absPath = filePath 125 | } 126 | 127 | relPath, err := filepath.Rel(s.dir, absPath) 128 | if err != nil { 129 | s.log.ErrorContext(ctx, "Failed to calculate relative path, using base filename as fallback", "base_dir", s.dir, "file_path", absPath, "error", err) 130 | relPath = filepath.Base(absPath) 131 | } 132 | 133 | err = s.queue.AddJob(absPath, relPath) 134 | if err != nil { 135 | s.log.ErrorContext(ctx, "Failed to add job to queue", "path", absPath, "relative_path", relPath, "error", err) 136 | } else { 137 | s.log.InfoContext(ctx, "Successfully added job to queue", "path", absPath, "relative_path", relPath) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /pkg/par2exedownloader/par2exe_downloader.go: -------------------------------------------------------------------------------- 1 | package par2exedownloader 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "net/http" 9 | "os" 10 | "runtime" 11 | "strings" 12 | ) 13 | 14 | // Release represents the structure of the GitHub release JSON response 15 | type Release struct { 16 | TagName string `json:"tag_name"` 17 | Assets []struct { 18 | Name string `json:"name"` 19 | BrowserDownloadURL string `json:"browser_download_url"` 20 | } `json:"assets"` 21 | } 22 | 23 | // DownloadPar2Cmd downloads the latest par2cmd executable from GitHub releases 24 | // for the current operating system and architecture. 25 | // 26 | // It performs the following steps: 27 | // 1. Fetches latest release information from GitHub 28 | // 2. Determines system OS and architecture 29 | // 3. Finds appropriate release asset for the system 30 | // 4. Downloads the executable file 31 | // 32 | // Returns: 33 | // - string: The name of the downloaded executable ("par2cmd") 34 | // - error: Any error encountered during the download process 35 | func DownloadPar2Cmd() (string, error) { 36 | executable := "par2cmd" 37 | 38 | // Fetch the latest release information from GitHub API 39 | release, err := fetchLatestRelease() 40 | if err != nil { 41 | slog.With("err", err).Error("Error fetching latest release") 42 | 43 | return "", err 44 | } 45 | 46 | // Determine the system's OS and architecture 47 | goos := runtime.GOOS 48 | goarch := runtime.GOARCH 49 | 50 | // Map system details to the appropriate asset 51 | asset, err := findAssetForSystem(release, goos, goarch) 52 | if err != nil { 53 | slog.With("err", err).Error("Error finding asset for system") 54 | 55 | return "", err 56 | } 57 | 58 | // Download the asset 59 | err = downloadFile("par2cmd", asset.BrowserDownloadURL) 60 | if err != nil { 61 | slog.With("err", err).Error("Error downloading file") 62 | 63 | return "", err 64 | } 65 | 66 | slog.Info(fmt.Sprintf("Downloaded %s successfully.\n", asset.Name)) 67 | 68 | return executable, nil 69 | } 70 | 71 | // fetchLatestRelease retrieves the latest release information from GitHub 72 | func fetchLatestRelease() (*Release, error) { 73 | url := "https://api.github.com/repos/animetosho/par2cmdline-turbo/releases/latest" 74 | resp, err := http.Get(url) 75 | if err != nil { 76 | return nil, err 77 | } 78 | defer func() { 79 | _ = resp.Body.Close() 80 | }() 81 | 82 | if resp.StatusCode != http.StatusOK { 83 | return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 84 | } 85 | 86 | var release Release 87 | err = json.NewDecoder(resp.Body).Decode(&release) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | return &release, nil 93 | } 94 | 95 | // findAssetForSystem matches the system's OS and architecture to an asset in the release 96 | func findAssetForSystem(release *Release, goos, goarch string) (*struct { 97 | Name string `json:"name"` 98 | BrowserDownloadURL string `json:"browser_download_url"` 99 | }, error) { 100 | var assetName string 101 | switch goos { 102 | case "linux": 103 | switch goarch { 104 | case "amd64": 105 | assetName = "linux-amd64.xz" 106 | case "arm64": 107 | assetName = "linux-arm64.xz" 108 | case "arm": 109 | assetName = "linux-armhf.xz" 110 | default: 111 | return nil, fmt.Errorf("unsupported architecture: %s", goarch) 112 | } 113 | case "darwin": 114 | switch goarch { 115 | case "amd64": 116 | assetName = "macos-x64.xz" 117 | case "arm64": 118 | assetName = "macos-arm64.xz" 119 | default: 120 | return nil, fmt.Errorf("unsupported architecture: %s", goarch) 121 | } 122 | case "windows": 123 | switch goarch { 124 | case "amd64": 125 | assetName = "win-x64.zip" 126 | case "386": 127 | assetName = "win-x86.zip" 128 | case "arm64": 129 | assetName = "win-arm64.zip" 130 | default: 131 | return nil, fmt.Errorf("unsupported architecture: %s", goarch) 132 | } 133 | default: 134 | return nil, fmt.Errorf("unsupported operating system: %s", goos) 135 | } 136 | 137 | for _, asset := range release.Assets { 138 | if strings.HasSuffix(asset.Name, assetName) { 139 | return &asset, nil 140 | } 141 | } 142 | 143 | return nil, fmt.Errorf("no asset found for %s/%s", goos, goarch) 144 | } 145 | 146 | // downloadFile downloads a file from the specified URL 147 | func downloadFile(filename, url string) error { 148 | out, err := os.Create(filename) 149 | if err != nil { 150 | return err 151 | } 152 | 153 | defer func() { 154 | _ = out.Close() 155 | }() 156 | 157 | resp, err := http.Get(url) 158 | if err != nil { 159 | return err 160 | } 161 | defer func() { 162 | _ = resp.Body.Close() 163 | }() 164 | 165 | if resp.StatusCode != http.StatusOK { 166 | return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 167 | } 168 | 169 | _, err = io.Copy(out, resp.Body) 170 | if err != nil { 171 | return err 172 | } 173 | 174 | // Add execute permissions to the downloaded file 175 | err = os.Chmod(filename, 0755) 176 | if err != nil { 177 | return fmt.Errorf("error setting execute permission for %s: %w", filename, err) 178 | } 179 | 180 | return nil 181 | } 182 | -------------------------------------------------------------------------------- /internal/scanner/scanner_test.go: -------------------------------------------------------------------------------- 1 | package scanner 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "path/filepath" 9 | "sync" 10 | "testing" 11 | "time" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | // mockQueue implements queue.Queuer for testing 18 | type mockQueue struct { 19 | mu sync.Mutex 20 | jobs []struct { 21 | absPath string 22 | relPath string 23 | } 24 | } 25 | 26 | func (m *mockQueue) AddJob(absPath, relPath string) error { 27 | m.mu.Lock() 28 | defer m.mu.Unlock() 29 | 30 | m.jobs = append(m.jobs, struct { 31 | absPath string 32 | relPath string 33 | }{absPath, relPath}) 34 | return nil 35 | } 36 | 37 | func TestNewScanner(t *testing.T) { 38 | // Create a temporary directory for testing 39 | tempDir, err := os.MkdirTemp("", "scanner-test-*") 40 | require.NoError(t, err) 41 | defer func() { 42 | _ = os.RemoveAll(tempDir) 43 | }() 44 | 45 | mockQ := &mockQueue{} 46 | logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) 47 | 48 | tests := []struct { 49 | name string 50 | dir string 51 | scanInterval time.Duration 52 | wantErr bool 53 | }{ 54 | { 55 | name: "valid directory", 56 | dir: tempDir, 57 | scanInterval: time.Second, 58 | wantErr: false, 59 | }, 60 | { 61 | name: "non-existent directory", 62 | dir: "/non/existent/path", 63 | scanInterval: time.Second, 64 | wantErr: false, // Should not error, just log warning 65 | }, 66 | } 67 | 68 | for _, tt := range tests { 69 | t.Run(tt.name, func(t *testing.T) { 70 | scanner := New(tt.dir, mockQ, logger, tt.scanInterval) 71 | assert.NotNil(t, scanner) 72 | assert.Equal(t, tt.scanInterval, scanner.scanInterval) 73 | }) 74 | } 75 | } 76 | 77 | func TestScanner_ScanDirectory(t *testing.T) { 78 | // Create a temporary directory for testing 79 | tempDir, err := os.MkdirTemp("", "scanner-test-*") 80 | require.NoError(t, err) 81 | defer func() { 82 | _ = os.RemoveAll(tempDir) 83 | }() 84 | 85 | // Create some test files 86 | testFiles := []string{ 87 | "test1.nzb", 88 | "test2.nzb", 89 | "test3.txt", 90 | "subdir/test4.nzb", 91 | } 92 | 93 | for _, f := range testFiles { 94 | path := filepath.Join(tempDir, f) 95 | err := os.MkdirAll(filepath.Dir(path), 0755) 96 | require.NoError(t, err) 97 | _, err = os.Create(path) 98 | require.NoError(t, err) 99 | } 100 | 101 | mockQ := &mockQueue{} 102 | logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) 103 | scanner := New(tempDir, mockQ, logger, time.Second) 104 | 105 | ctx := context.Background() 106 | err = scanner.scanDirectory(ctx, tempDir) 107 | require.NoError(t, err) 108 | 109 | // Should have found 3 .nzb files 110 | assert.Equal(t, 3, len(mockQ.jobs)) 111 | 112 | // Verify the files were found 113 | foundFiles := make(map[string]bool) 114 | for _, job := range mockQ.jobs { 115 | foundFiles[filepath.Base(job.absPath)] = true 116 | } 117 | 118 | assert.True(t, foundFiles["test1.nzb"]) 119 | assert.True(t, foundFiles["test2.nzb"]) 120 | assert.True(t, foundFiles["test4.nzb"]) 121 | assert.False(t, foundFiles["test3.txt"]) 122 | } 123 | 124 | func TestScanner_Run(t *testing.T) { 125 | // Create a temporary directory for testing 126 | tempDir, err := os.MkdirTemp("", "scanner-test-*") 127 | require.NoError(t, err) 128 | defer func() { 129 | _ = os.RemoveAll(tempDir) 130 | }() 131 | 132 | mockQ := &mockQueue{} 133 | logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) 134 | scanner := New(tempDir, mockQ, logger, 100*time.Millisecond) 135 | 136 | ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) 137 | defer cancel() 138 | 139 | // Create a test file after a short delay 140 | go func() { 141 | time.Sleep(50 * time.Millisecond) 142 | path := filepath.Join(tempDir, "test.nzb") 143 | _, err := os.Create(path) 144 | require.NoError(t, err) 145 | }() 146 | 147 | // Run the scanner 148 | err = scanner.Run(ctx) 149 | assert.ErrorIs(t, err, context.DeadlineExceeded) 150 | 151 | // Should have found the file 152 | assert.GreaterOrEqual(t, len(mockQ.jobs), 1) 153 | if len(mockQ.jobs) > 0 { 154 | assert.Equal(t, "test.nzb", filepath.Base(mockQ.jobs[0].absPath)) 155 | } 156 | } 157 | 158 | func TestScanner_NestedFolders(t *testing.T) { 159 | // Create a temporary directory for testing 160 | tempDir, err := os.MkdirTemp("", "scanner-test-*") 161 | require.NoError(t, err) 162 | defer func() { 163 | _ = os.RemoveAll(tempDir) 164 | }() 165 | 166 | // Create a complex nested directory structure with NZB files 167 | testFiles := []string{ 168 | "root.nzb", 169 | "level1/level1.nzb", 170 | "level1/level2/level2.nzb", 171 | } 172 | 173 | // Create the directory structure and files 174 | for _, f := range testFiles { 175 | path := filepath.Join(tempDir, f) 176 | err := os.MkdirAll(filepath.Dir(path), 0755) 177 | require.NoError(t, err) 178 | _, err = os.Create(path) 179 | require.NoError(t, err) 180 | } 181 | 182 | mockQ := &mockQueue{} 183 | logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) 184 | scanner := New(tempDir, mockQ, logger, time.Second) 185 | 186 | ctx := context.Background() 187 | err = scanner.scanDirectory(ctx, tempDir) 188 | require.NoError(t, err) 189 | 190 | // Should have found all 20 NZB files 191 | assert.Equal(t, 3, len(mockQ.jobs)) 192 | 193 | // Verify all files were found 194 | foundFiles := make(map[string]bool) 195 | for _, job := range mockQ.jobs { 196 | foundFiles[filepath.Base(job.absPath)] = true 197 | } 198 | 199 | // Check that all expected files were found 200 | for i := 1; i <= 2; i++ { 201 | expectedFile := fmt.Sprintf("level%d.nzb", i) 202 | if i == 1 { 203 | expectedFile = "root.nzb" 204 | } 205 | assert.True(t, foundFiles[expectedFile], "Expected to find %s", expectedFile) 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /internal/repairnzb/par2.go: -------------------------------------------------------------------------------- 1 | package repairnzb 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "errors" 8 | "fmt" 9 | "log/slog" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "regexp" 14 | "strconv" 15 | "strings" 16 | "sync" 17 | "time" 18 | 19 | "github.com/Tensai75/nzbparser" 20 | "github.com/schollz/progressbar/v3" 21 | ) 22 | 23 | // Allow mocking exec.CommandContext in tests 24 | var execCommand = exec.CommandContext 25 | 26 | // Par2Executor defines the interface for executing par2 commands. 27 | type Par2Executor interface { 28 | Repair(ctx context.Context, tmpPath string) error 29 | } 30 | 31 | // Par2CmdExecutor implements Par2Executor using the command line. 32 | type Par2CmdExecutor struct { 33 | ExePath string 34 | } 35 | 36 | var ( 37 | parregexp = regexp.MustCompile(`(?i)(\.vol\d+\+(\d+))?\.par2$`) 38 | 39 | // par2 exit codes 40 | par2ExitCodes = map[int]string{ 41 | 0: "Success", 42 | 1: "Repair possible", 43 | 2: "Repair not possible", 44 | 3: "Invalid command line arguments", 45 | 4: "Insufficient critical data to verify", 46 | 5: "Repair failed", 47 | 6: "FileIO Error", 48 | 7: "Logic Error", 49 | 8: "Out of memory", 50 | } 51 | ) 52 | 53 | func splitParWithRest(nfile *nzbparser.Nzb) (parFiles []nzbparser.NzbFile, restFiles []nzbparser.NzbFile) { 54 | parFiles = make([]nzbparser.NzbFile, 0) 55 | restFiles = make([]nzbparser.NzbFile, 0) 56 | 57 | for _, f := range nfile.Files { 58 | if parregexp.MatchString(f.Filename) { 59 | parFiles = append(parFiles, f) 60 | } else { 61 | restFiles = append(restFiles, f) 62 | } 63 | } 64 | 65 | return 66 | } 67 | 68 | // Repair executes the par2 command to repair files in the target folder. 69 | func (p *Par2CmdExecutor) Repair(ctx context.Context, tmpPath string) error { 70 | slog.InfoContext(ctx, "Starting repair process", "executor", "Par2CmdExecutor") 71 | 72 | var ( 73 | par2FileName string 74 | parameters []string 75 | parProgressBar *progressbar.ProgressBar 76 | err error 77 | ) 78 | 79 | par2Exe := p.ExePath 80 | if par2Exe == "" { 81 | par2Exe = "par2" // Default if path is empty 82 | slog.WarnContext(ctx, "Par2 executable path is empty, defaulting to 'par2'") 83 | } 84 | 85 | exp, _ := regexp.Compile(`^.+\.par2`) 86 | if err = filepath.Walk(tmpPath, func(file string, info os.FileInfo, err error) error { 87 | if err != nil { 88 | return err 89 | } 90 | if !info.IsDir() && exp.MatchString(filepath.Base(info.Name())) { 91 | // Use the first .par2 file found as the main input for par2 92 | // par2 should automatically find related files. 93 | if par2FileName == "" { 94 | par2FileName = info.Name() 95 | } 96 | } 97 | 98 | return nil 99 | }); err != nil { 100 | return fmt.Errorf("error finding .par2 file in %s: %w", tmpPath, err) 101 | } 102 | 103 | if par2FileName == "" { 104 | slog.WarnContext(ctx, "No .par2 file found in the temporary directory, skipping repair.", "path", tmpPath) 105 | // Depending on requirements, this might be an error or just a skip condition. 106 | // For now, assume it's okay to skip if no par2 file exists. 107 | return nil 108 | } 109 | 110 | slog.InfoContext(ctx, "Found par2 file for repair", "file", par2FileName) 111 | 112 | // set parameters 113 | parameters = append(parameters, "r", "-q") 114 | // Delete par2 after repair 115 | parameters = append(parameters, "-p") 116 | // The filename of the par2 file 117 | parameters = append(parameters, filepath.Join(tmpPath, par2FileName)) 118 | 119 | // Use the package-level variable instead of calling exec.CommandContext directly 120 | cmd := execCommand(ctx, par2Exe, parameters...) 121 | cmd.Dir = tmpPath // Important: Run the command in the directory containing the files 122 | slog.DebugContext(ctx, fmt.Sprintf("Par command: %s in dir %s", cmd.String(), cmd.Dir)) 123 | 124 | cmdErr, err := cmd.StderrPipe() 125 | if err != nil { 126 | return fmt.Errorf("failed to get stderr pipe for par2: %w", err) 127 | } 128 | 129 | // create a pipe for the output of the program 130 | cmdReader, err := cmd.StdoutPipe() 131 | if err != nil { 132 | return fmt.Errorf("failed to get stdout pipe for par2: %w", err) 133 | } 134 | 135 | scanner := bufio.NewScanner(cmdReader) 136 | scanner.Split(scanLines) 137 | 138 | errScanner := bufio.NewScanner(cmdErr) 139 | errScanner.Split(scanLines) 140 | 141 | var stderrOutput strings.Builder 142 | 143 | mu := sync.Mutex{} 144 | 145 | wg := sync.WaitGroup{} 146 | wg.Add(1) 147 | go func() { 148 | defer wg.Done() 149 | for errScanner.Scan() { 150 | line := strings.TrimSpace(errScanner.Text()) 151 | if line != "" { 152 | slog.DebugContext(ctx, "PAR2 STDERR:", "line", line) 153 | mu.Lock() 154 | stderrOutput.WriteString(line + "\n") 155 | mu.Unlock() 156 | } 157 | } 158 | }() 159 | 160 | wg.Add(1) 161 | go func() { 162 | defer wg.Done() 163 | // Ensure parProgressBar is initialized before use 164 | parProgressBar = progressbar.NewOptions(100, // Use 100 as max for percentage 165 | progressbar.OptionSetDescription("INFO: Repairing files "), 166 | progressbar.OptionSetRenderBlankState(true), 167 | progressbar.OptionThrottle(time.Millisecond*100), 168 | progressbar.OptionShowElapsedTimeOnFinish(), 169 | progressbar.OptionClearOnFinish(), 170 | progressbar.OptionOnCompletion(func() { 171 | // new line after progress bar 172 | fmt.Println() 173 | }), 174 | ) 175 | defer func() { 176 | _ = parProgressBar.Close() // Close the progress bar when done 177 | }() 178 | 179 | for scanner.Scan() { 180 | output := strings.Trim(scanner.Text(), " \r\n") 181 | if output != "" && !strings.Contains(output, "%") { 182 | slog.DebugContext(ctx, fmt.Sprintf("PAR2 STDOUT: %v", output)) 183 | } 184 | 185 | exp := regexp.MustCompile(`(\d+)\.?\d*%`) 186 | if output != "" && exp.MatchString(output) { 187 | percentStr := exp.FindStringSubmatch(output) 188 | if len(percentStr) > 1 { 189 | percentInt, err := strconv.Atoi(percentStr[1]) 190 | if err == nil { 191 | _ = parProgressBar.Set(percentInt) 192 | } 193 | } 194 | } 195 | } 196 | 197 | }() 198 | 199 | if err = cmd.Run(); err != nil { 200 | mu.Lock() 201 | output := stderrOutput.String() 202 | mu.Unlock() 203 | 204 | if exitError, ok := err.(*exec.ExitError); ok { 205 | if parProgressBar != nil { 206 | _ = parProgressBar.Close() // Attempt to close/clear on error too 207 | } 208 | 209 | if errMsg, ok := par2ExitCodes[exitError.ExitCode()]; ok { 210 | // Specific known error codes from par2 211 | fullErrMsg := fmt.Sprintf("par2 exited with code %d: %s. Stderr: %s", exitError.ExitCode(), errMsg, output) 212 | slog.ErrorContext(ctx, fullErrMsg) 213 | // Treat specific codes as potentially non-fatal or requiring different handling 214 | // For now, return all as errors, but could customize (e.g., ignore exit code 1 if repair was possible) 215 | return errors.New(fullErrMsg) 216 | } 217 | // Unknown exit code 218 | unknownErrMsg := fmt.Sprintf("par2 exited with unknown code %d. Stderr: %s", exitError.ExitCode(), output) 219 | slog.ErrorContext(ctx, unknownErrMsg) 220 | return errors.New(unknownErrMsg) 221 | } 222 | // Error not related to exit code (e.g., command not found) 223 | return fmt.Errorf("failed to run par2 command '%s': %w. Stderr: %s", cmd.String(), err, output) 224 | } 225 | 226 | if parProgressBar != nil { 227 | _ = parProgressBar.Finish() // Ensure finish is called on success 228 | } 229 | 230 | wg.Wait() 231 | 232 | slog.InfoContext(ctx, "Par2 repair completed successfully") 233 | 234 | return nil 235 | } 236 | 237 | // scanLines is a helper for bufio.Scanner to split lines correctly 238 | func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) { 239 | if atEOF && len(data) == 0 { 240 | return 0, nil, nil 241 | } 242 | 243 | if i := bytes.IndexAny(data, "\r\n"); i >= 0 { 244 | if data[i] == '\n' { 245 | // We have a line terminated by single newline. 246 | return i + 1, data[0:i], nil 247 | } 248 | 249 | advance = i + 1 250 | if len(data) > i+1 && data[i+1] == '\n' { 251 | advance += 1 252 | } 253 | 254 | return advance, data[0:i], nil 255 | } 256 | 257 | // If we're at EOF, we have a final, non-terminated line. Return it. 258 | if atEOF { 259 | return len(data), data, nil 260 | } 261 | 262 | // Request more data. 263 | return 0, nil, nil 264 | } 265 | -------------------------------------------------------------------------------- /internal/repairnzb/par2_test.go: -------------------------------------------------------------------------------- 1 | package repairnzb 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "strconv" 12 | "testing" 13 | 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | func TestMain(m *testing.M) { 19 | // Check if we are running as the helper process 20 | if os.Getenv("GO_TEST_HELPER_PROCESS") == "1" { 21 | // We need a testing.T instance, but this part of the code 22 | // is only run in the helper process, not during the main test execution. 23 | // So, we can pass nil or a dummy T. This code won't actually run 24 | // test functions. The real test execution happens in the parent process. 25 | testHelperProcess(nil) 26 | return // Important: Exit after acting as helper 27 | } 28 | 29 | // Run the actual tests 30 | os.Exit(m.Run()) 31 | } 32 | 33 | type contextKey string 34 | 35 | const loggerKey contextKey = "logger" 36 | 37 | func TestPar2CmdExecutor_Repair(t *testing.T) { 38 | // Backup and restore execCommand 39 | originalExecCommand := execCommand 40 | execCommand = mockExecCommand 41 | defer func() { execCommand = originalExecCommand }() 42 | 43 | // Setup default logger 44 | slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))) 45 | 46 | t.Run("Success", func(t *testing.T) { 47 | tmpDir := t.TempDir() 48 | par2File := filepath.Join(tmpDir, "test.par2") 49 | f, err := os.Create(par2File) 50 | require.NoError(t, err) 51 | _ = f.Close() 52 | 53 | // Set env vars for the mock command 54 | _ = os.Setenv("TEST_PAR2_EXIT_CODE", "0") 55 | _ = os.Setenv("TEST_PAR2_STDOUT", `Verifying files... 56 | Repair complete. 57 | 100%`) 58 | _ = os.Setenv("TEST_PAR2_STDERR", "") 59 | defer func() { 60 | _ = os.Unsetenv("TEST_PAR2_EXIT_CODE") 61 | _ = os.Unsetenv("TEST_PAR2_STDOUT") 62 | _ = os.Unsetenv("TEST_PAR2_STDERR") 63 | }() 64 | 65 | executor := &Par2CmdExecutor{ExePath: "par2"} // ExePath is used by mock indirectly 66 | ctx := context.Background() 67 | err = executor.Repair(ctx, tmpDir) 68 | assert.NoError(t, err) 69 | }) 70 | 71 | t.Run("No Par2 File", func(t *testing.T) { 72 | tmpDir := t.TempDir() 73 | // No .par2 file created 74 | 75 | executor := &Par2CmdExecutor{ExePath: "par2"} 76 | ctx := context.Background() 77 | err := executor.Repair(ctx, tmpDir) 78 | assert.NoError(t, err, "Should not return error if no par2 file is found") 79 | }) 80 | 81 | t.Run("Repair Possible Exit Code 1", func(t *testing.T) { 82 | tmpDir := t.TempDir() 83 | par2File := filepath.Join(tmpDir, "test.par2") 84 | f, err := os.Create(par2File) 85 | require.NoError(t, err) 86 | _ = f.Close() 87 | 88 | _ = os.Setenv("TEST_PAR2_EXIT_CODE", "1") 89 | _ = os.Setenv("TEST_PAR2_STDOUT", `Verifying files... 90 | Need to repair 5 blocks. 91 | Repair possible. 92 | 100%`) 93 | _ = os.Setenv("TEST_PAR2_STDERR", "Some warnings maybe") 94 | defer func() { 95 | _ = os.Unsetenv("TEST_PAR2_EXIT_CODE") 96 | _ = os.Unsetenv("TEST_PAR2_STDOUT") 97 | _ = os.Unsetenv("TEST_PAR2_STDERR") 98 | }() 99 | 100 | executor := &Par2CmdExecutor{ExePath: "par2"} 101 | ctx := context.Background() 102 | err = executor.Repair(ctx, tmpDir) 103 | require.Error(t, err) 104 | assert.Contains(t, err.Error(), "par2 exited with code 1: Repair possible") 105 | }) 106 | 107 | t.Run("Repair Not Possible Exit Code 2", func(t *testing.T) { 108 | tmpDir := t.TempDir() 109 | par2File := filepath.Join(tmpDir, "test.par2") 110 | f, err := os.Create(par2File) 111 | require.NoError(t, err) 112 | _ = f.Close() 113 | 114 | _ = os.Setenv("TEST_PAR2_EXIT_CODE", "2") 115 | _ = os.Setenv("TEST_PAR2_STDOUT", `Verifying files... 116 | Need 10 recovery blocks, only 5 available.`) 117 | _ = os.Setenv("TEST_PAR2_STDERR", "Not enough data") 118 | defer func() { 119 | _ = os.Unsetenv("TEST_PAR2_EXIT_CODE") 120 | _ = os.Unsetenv("TEST_PAR2_STDOUT") 121 | _ = os.Unsetenv("TEST_PAR2_STDERR") 122 | }() 123 | 124 | executor := &Par2CmdExecutor{ExePath: "par2"} 125 | ctx := context.Background() 126 | err = executor.Repair(ctx, tmpDir) 127 | require.Error(t, err) 128 | assert.Contains(t, err.Error(), "par2 exited with code 2: Repair not possible") 129 | }) 130 | 131 | t.Run("Unknown Exit Code", func(t *testing.T) { 132 | tmpDir := t.TempDir() 133 | par2File := filepath.Join(tmpDir, "test.par2") 134 | f, err := os.Create(par2File) 135 | require.NoError(t, err) 136 | _ = f.Close() 137 | 138 | _ = os.Setenv("TEST_PAR2_EXIT_CODE", "99") 139 | _ = os.Setenv("TEST_PAR2_STDOUT", "") 140 | _ = os.Setenv("TEST_PAR2_STDERR", "Something weird happened") 141 | defer func() { 142 | _ = os.Unsetenv("TEST_PAR2_EXIT_CODE") 143 | _ = os.Unsetenv("TEST_PAR2_STDOUT") 144 | _ = os.Unsetenv("TEST_PAR2_STDERR") 145 | }() 146 | 147 | executor := &Par2CmdExecutor{ExePath: "par2"} 148 | ctx := context.Background() 149 | err = executor.Repair(ctx, tmpDir) 150 | require.Error(t, err) 151 | assert.Contains(t, err.Error(), "par2 exited with unknown code 99") 152 | }) 153 | 154 | t.Run("Command Not Found", func(t *testing.T) { 155 | tmpDir := t.TempDir() 156 | par2File := filepath.Join(tmpDir, "test.par2") 157 | f, err := os.Create(par2File) 158 | require.NoError(t, err) 159 | _ = f.Close() 160 | 161 | // Using a high, unmapped exit code might simulate an execution environment issue 162 | _ = os.Setenv("TEST_PAR2_EXIT_CODE", "127") // Often used for command not found by shells 163 | _ = os.Setenv("TEST_PAR2_STDOUT", "") 164 | _ = os.Setenv("TEST_PAR2_STDERR", "par2: command not found") // Simulate typical stderr 165 | defer func() { 166 | _ = os.Unsetenv("TEST_PAR2_EXIT_CODE") 167 | _ = os.Unsetenv("TEST_PAR2_STDOUT") 168 | _ = os.Unsetenv("TEST_PAR2_STDERR") 169 | }() 170 | 171 | executor := &Par2CmdExecutor{ExePath: "/non/existent/par2"} // Use a clearly invalid path 172 | ctx := context.Background() 173 | err = executor.Repair(ctx, tmpDir) 174 | require.Error(t, err) 175 | assert.Contains(t, err.Error(), "par2 exited with unknown code 127") 176 | }) 177 | 178 | t.Run("Progress Bar Handling", func(t *testing.T) { 179 | tmpDir := t.TempDir() 180 | par2File := filepath.Join(tmpDir, "test.par2") 181 | f, err := os.Create(par2File) 182 | require.NoError(t, err) 183 | _ = f.Close() 184 | 185 | // Simulate stdout with progress percentage 186 | _ = os.Setenv("TEST_PAR2_EXIT_CODE", "0") 187 | _ = os.Setenv("TEST_PAR2_STDOUT", `Verifying files... 188 | 50% complete 189 | Repairing... 190 | 100.00% 191 | Done.`) 192 | _ = os.Setenv("TEST_PAR2_STDERR", "") 193 | defer func() { 194 | _ = os.Unsetenv("TEST_PAR2_EXIT_CODE") 195 | _ = os.Unsetenv("TEST_PAR2_STDOUT") 196 | _ = os.Unsetenv("TEST_PAR2_STDERR") 197 | }() 198 | 199 | executor := &Par2CmdExecutor{ExePath: "par2"} 200 | ctx := context.Background() 201 | // Capture log output to check progress bar messages indirectly 202 | var logBuf bytes.Buffer 203 | handler := slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}) 204 | ctx = context.WithValue(ctx, loggerKey, slog.New(handler)) 205 | 206 | err = executor.Repair(ctx, tmpDir) 207 | assert.NoError(t, err) 208 | }) 209 | 210 | t.Run("Empty ExePath uses default", func(t *testing.T) { 211 | tmpDir := t.TempDir() 212 | par2File := filepath.Join(tmpDir, "test.par2") 213 | f, err := os.Create(par2File) 214 | require.NoError(t, err) 215 | _ = f.Close() 216 | 217 | _ = os.Setenv("TEST_PAR2_EXIT_CODE", "0") 218 | _ = os.Setenv("TEST_PAR2_STDOUT", "Success") 219 | _ = os.Setenv("TEST_PAR2_STDERR", "") 220 | defer func() { 221 | _ = os.Unsetenv("TEST_PAR2_EXIT_CODE") 222 | _ = os.Unsetenv("TEST_PAR2_STDOUT") 223 | _ = os.Unsetenv("TEST_PAR2_STDERR") 224 | }() 225 | 226 | // Mock execCommand checks the command name passed to it 227 | execCommand = func(ctx context.Context, command string, args ...string) *exec.Cmd { 228 | assert.Equal(t, "par2", command, "Expected default 'par2' command when ExePath is empty") 229 | cs := []string{"-test.run=TestHelperProcess", "--", command} 230 | cs = append(cs, args...) 231 | cmd := exec.CommandContext(ctx, os.Args[0], cs...) 232 | cmd.Env = append(os.Environ(), "GO_TEST_HELPER_PROCESS=1") // Pass env vars 233 | return cmd 234 | } 235 | defer func() { execCommand = originalExecCommand }() // Restore original 236 | 237 | executor := &Par2CmdExecutor{ExePath: ""} // Empty ExePath 238 | ctx := context.Background() 239 | err = executor.Repair(ctx, tmpDir) 240 | assert.NoError(t, err) 241 | }) 242 | } 243 | 244 | // testHelperProcess is run when the test binary is executed with a specific env var. 245 | // It simulates the behavior of the par2 command based on environment variables. 246 | func testHelperProcess(t *testing.T) { 247 | if os.Getenv("GO_TEST_HELPER_PROCESS") != "1" { 248 | return 249 | } 250 | defer os.Exit(0) // Exit cleanly after simulation 251 | 252 | args := os.Args 253 | for len(args) > 0 { 254 | if args[0] == "--" { 255 | args = args[1:] 256 | break 257 | } 258 | args = args[1:] 259 | } 260 | if len(args) == 0 { 261 | fmt.Fprintf(os.Stderr, "No command provided to test helper\n") 262 | os.Exit(1) // Should have command args 263 | } 264 | 265 | // Simulate par2 behavior based on env vars 266 | exitCodeStr := os.Getenv("TEST_PAR2_EXIT_CODE") 267 | stdout := os.Getenv("TEST_PAR2_STDOUT") 268 | stderr := os.Getenv("TEST_PAR2_STDERR") 269 | exitCode := 0 // Default to success 270 | 271 | if exitCodeStr != "" { 272 | var err error 273 | exitCode, err = strconv.Atoi(exitCodeStr) 274 | if err != nil { 275 | fmt.Fprintf(os.Stderr, "Invalid TEST_PAR2_EXIT_CODE: %v\n", err) 276 | os.Exit(7) // Simulate Logic Error 277 | } 278 | } 279 | 280 | _, _ = fmt.Fprint(os.Stdout, stdout) 281 | _, _ = fmt.Fprint(os.Stderr, stderr) 282 | os.Exit(exitCode) 283 | } 284 | 285 | // Override the exec.CommandContext function for testing 286 | func mockExecCommand(ctx context.Context, command string, args ...string) *exec.Cmd { 287 | cs := []string{"-test.run=TestHelperProcess", "--", command} 288 | cs = append(cs, args...) 289 | cmd := exec.CommandContext(ctx, os.Args[0], cs...) 290 | // Set environment variables for the helper process 291 | cmd.Env = append(os.Environ(), 292 | "GO_TEST_HELPER_PROCESS=1", 293 | fmt.Sprintf("TEST_PAR2_EXIT_CODE=%s", os.Getenv("TEST_PAR2_EXIT_CODE")), 294 | fmt.Sprintf("TEST_PAR2_STDOUT=%s", os.Getenv("TEST_PAR2_STDOUT")), 295 | fmt.Sprintf("TEST_PAR2_STDERR=%s", os.Getenv("TEST_PAR2_STDERR")), 296 | ) 297 | return cmd 298 | } 299 | -------------------------------------------------------------------------------- /internal/repairnzb/repair_nzb_test.go: -------------------------------------------------------------------------------- 1 | package repairnzb 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "testing" 11 | 12 | "github.com/Tensai75/nzbparser" 13 | "github.com/javi11/nntppool" 14 | "github.com/javi11/nzb-repair/internal/config" 15 | "github.com/javi11/nzb-repair/internal/mocks" // Import the generated mocks 16 | "github.com/stretchr/testify/assert" 17 | "github.com/stretchr/testify/require" 18 | "go.uber.org/mock/gomock" 19 | ) 20 | 21 | func TestRepairNzb(t *testing.T) { 22 | // Setup 23 | ctrl := gomock.NewController(t) 24 | defer ctrl.Finish() 25 | 26 | ctx := context.Background() 27 | cfg := config.Config{ 28 | DownloadWorkers: 1, // Use 1 worker for easier expectation setting 29 | UploadWorkers: 1, 30 | // Par2Exe is not used directly here as we mock the executor 31 | Upload: config.UploadConfig{ 32 | ObfuscationPolicy: config.ObfuscationPolicyNone, 33 | }, 34 | } 35 | 36 | mockDownloadPool := nntppool.NewMockUsenetConnectionPool(ctrl) 37 | mockUploadPool := nntppool.NewMockUsenetConnectionPool(ctrl) 38 | mockPar2Executor := mocks.NewMockPar2Executor(ctrl) // Instantiate the mock executor 39 | 40 | // Create a temporary directory for testing 41 | inputDir := t.TempDir() 42 | tmpDir := t.TempDir() 43 | outputDir := t.TempDir() 44 | outputFile := filepath.Join(outputDir, "output.nzb") 45 | nzbFile := filepath.Join(inputDir, "input.nzb") 46 | 47 | // Define file/segment names for clarity 48 | dataFileName := "test.mkv" 49 | par2FileName := "test.mkv.par2" 50 | brokenSegmentID := "segment1@test" 51 | goodSegmentID := "segment2@test" 52 | parSegmentID := "parSegment1@test" 53 | repairedDataContent := "repaired data for segment 1 and 2 combined" 54 | originalDataFileContentSegment2 := "test data segment 2" 55 | 56 | // Create a dummy NZB file for testing 57 | nzbContent := fmt.Sprintf(` 58 | 59 | 60 | 61 | TV > HD 62 | Test Release 63 | 64 | 65 | 66 | alt.binaries.test 67 | 68 | 69 | %s 70 | %s 71 | 72 | 73 | 74 | 75 | alt.binaries.test 76 | 77 | 78 | %s 79 | 80 | 81 | `, dataFileName, len(repairedDataContent)/2, brokenSegmentID, len(originalDataFileContentSegment2), goodSegmentID, par2FileName, parSegmentID) 82 | err := os.WriteFile(nzbFile, []byte(nzbContent), 0644) 83 | require.NoError(t, err) 84 | 85 | // --- Mock Expectations --- 86 | 87 | // Download Expectations: 88 | // Segment 1 (broken) - Not Found 89 | mockDownloadPool.EXPECT().Body(gomock.Any(), brokenSegmentID, gomock.Any(), gomock.Any()). 90 | Return(int64(0), nntppool.ErrArticleNotFoundInProviders) 91 | // Segment 2 (good) - Found & Written 92 | mockDownloadPool.EXPECT().Body(gomock.Any(), goodSegmentID, gomock.Any(), gomock.Any()). 93 | DoAndReturn(func(_ context.Context, _ string, writer io.Writer, _ []string) (int64, error) { 94 | if writer != nil { 95 | // Simulate writing segment 2 content to the correct offset in the temp file 96 | // Note: This write happens *before* par2 repair in the actual code flow. 97 | // We assume downloadWorker creates the file. 98 | filePath := filepath.Join(tmpDir, dataFileName) 99 | file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE, 0644) 100 | require.NoError(t, err) // Test fails if we can't open file 101 | defer func() { 102 | _ = file.Close() 103 | }() 104 | // Write at offset (segment number - 1) * segment size 105 | // Segment size is tricky here, use fixed size from NZB for segment 2 106 | _, err = file.WriteAt([]byte(originalDataFileContentSegment2), int64(len(repairedDataContent)/2)) 107 | require.NoError(t, err) 108 | // Simulate the io.Copy happening in the actual Body call 109 | _, err = writer.Write([]byte(originalDataFileContentSegment2)) 110 | require.NoError(t, err) 111 | } 112 | 113 | return int64(len(originalDataFileContentSegment2)), nil 114 | }).Times(1) 115 | // Par2 Segment - Found & Written 116 | mockDownloadPool.EXPECT().Body(gomock.Any(), parSegmentID, gomock.Any(), gomock.Any()). 117 | DoAndReturn(func(_ context.Context, _ string, writer io.Writer, _ []string) (int64, error) { 118 | // Simulate writing the par2 file content 119 | parFilePath := filepath.Join(tmpDir, par2FileName) 120 | parContent := []byte("dummy par2 data") 121 | err := os.WriteFile(parFilePath, parContent, 0644) 122 | require.NoError(t, err) 123 | // Simulate io.Copy 124 | if writer != nil { 125 | _, err = writer.Write(parContent) 126 | require.NoError(t, err) 127 | } 128 | 129 | return int64(len(parContent)), nil 130 | }).Times(1) 131 | 132 | // Par2 Repair Expectation: 133 | mockPar2Executor.EXPECT().Repair(gomock.Any(), tmpDir). 134 | DoAndReturn(func(ctx context.Context, path string) error { 135 | // Simulate the outcome of par2 repair: the broken file is now complete. 136 | fullFilePath := filepath.Join(path, dataFileName) 137 | // Write the complete, "repaired" content. 138 | err := os.WriteFile(fullFilePath, []byte(repairedDataContent), 0644) 139 | require.NoError(t, err) // Ensure simulation is successful 140 | 141 | return nil // Simulate successful repair 142 | }).Times(1) 143 | 144 | // Upload Expectation: 145 | // Expect Post to be called once for the repaired segment (segment 1) 146 | var postedArticle bytes.Buffer 147 | mockUploadPool.EXPECT().Post(gomock.Any(), gomock.AssignableToTypeOf(&postedArticle)). 148 | DoAndReturn(func(ctx context.Context, article io.Reader) error { 149 | // Capture the posted article content if needed for assertion 150 | _, err := io.Copy(&postedArticle, article) 151 | assert.NoError(t, err) 152 | // TODO: Optionally assert content of postedArticle (yenc encoded segment 1) 153 | return nil // Simulate successful upload 154 | }).Times(1) 155 | 156 | // --- Call the function --- 157 | err = RepairNzb(ctx, cfg, mockDownloadPool, mockUploadPool, mockPar2Executor, nzbFile, outputFile, tmpDir) 158 | require.NoError(t, err) 159 | 160 | // --- Assertions --- 161 | 162 | // 1. Check if the output NZB file exists 163 | _, err = os.Stat(outputFile) 164 | assert.NoError(t, err, "Output NZB file should exist") 165 | 166 | // 2. Check the content of the output NZB file 167 | outputNzbBytes, err := os.ReadFile(outputFile) 168 | require.NoError(t, err) 169 | outputNzb, err := nzbparser.Parse(bytes.NewReader(outputNzbBytes)) 170 | require.NoError(t, err) 171 | 172 | // Find the data file in the output NZB 173 | var foundDataFile *nzbparser.NzbFile 174 | for i := range outputNzb.Files { 175 | if outputNzb.Files[i].Filename == dataFileName { 176 | foundDataFile = &outputNzb.Files[i] 177 | break 178 | } 179 | } 180 | require.NotNil(t, foundDataFile, "Data file should be present in output NZB") 181 | 182 | // Assert that segment 1's ID has changed (was brokenSegmentID) 183 | require.Len(t, foundDataFile.Segments, 2, "Should still have 2 segments") 184 | assert.NotEqual(t, brokenSegmentID, foundDataFile.Segments[0].Id, "Segment 1 ID should have changed after repair and upload") 185 | // Assert that segment 2's ID is unchanged (was goodSegmentID) 186 | assert.Equal(t, goodSegmentID, foundDataFile.Segments[1].Id, "Segment 2 ID should remain unchanged") 187 | 188 | // 3. Check tmp directory state (e.g., par files removed if -p was simulated) 189 | _, err = os.Stat(filepath.Join(tmpDir, par2FileName)) 190 | assert.True(t, os.IsNotExist(err), "Par2 file should have been deleted by repair process (-p flag simulation)") 191 | } 192 | 193 | func TestRepairNzb_NoPar2Files(t *testing.T) { 194 | // Setup 195 | ctrl := gomock.NewController(t) 196 | defer ctrl.Finish() 197 | 198 | ctx := context.Background() 199 | cfg := config.Config{ 200 | DownloadWorkers: 1, 201 | UploadWorkers: 1, 202 | } 203 | 204 | mockDownloadPool := nntppool.NewMockUsenetConnectionPool(ctrl) 205 | mockUploadPool := nntppool.NewMockUsenetConnectionPool(ctrl) 206 | mockPar2Executor := mocks.NewMockPar2Executor(ctrl) 207 | 208 | inputDir := t.TempDir() 209 | tmpDir := t.TempDir() 210 | outputDir := t.TempDir() 211 | outputFile := filepath.Join(outputDir, "output.nzb") 212 | nzbFile := filepath.Join(inputDir, "input_no_par2.nzb") 213 | 214 | dataFileName := "test_data.mkv" 215 | segmentID := "dataSegment@test" 216 | 217 | // Create an NZB file with only a data file 218 | nzbContent := fmt.Sprintf(` 219 | 220 | 221 | 222 | Misc 223 | Test Release No Par2 224 | 225 | 226 | 227 | alt.binaries.test 228 | 229 | 230 | %s 231 | 232 | 233 | `, dataFileName, segmentID) 234 | err := os.WriteFile(nzbFile, []byte(nzbContent), 0644) 235 | require.NoError(t, err) 236 | 237 | // --- Mock Expectations --- 238 | // No downloads, repairs, or uploads should be attempted as there are no par files. 239 | // We expect the function to return early. 240 | mockDownloadPool.EXPECT().Body(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) // No downloads expected 241 | mockPar2Executor.EXPECT().Repair(gomock.Any(), gomock.Any()).Times(0) // No repair expected 242 | mockUploadPool.EXPECT().Post(gomock.Any(), gomock.Any()).Times(0) // No uploads expected 243 | 244 | // --- Call the function --- 245 | err = RepairNzb(ctx, cfg, mockDownloadPool, mockUploadPool, mockPar2Executor, nzbFile, outputFile, tmpDir) 246 | require.NoError(t, err) // Expecting graceful exit with no error 247 | 248 | // --- Assertions --- 249 | // 1. Check that the output NZB file was NOT created 250 | _, err = os.Stat(outputFile) 251 | assert.True(t, os.IsNotExist(err), "Output NZB file should NOT exist when no par2 files are present") 252 | } 253 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/javi11/nzb-repair 2 | 3 | go 1.24.0 4 | 5 | tool ( 6 | github.com/golangci/golangci-lint/v2/cmd/golangci-lint 7 | github.com/jstemmer/go-junit-report/v2 8 | go.uber.org/mock/mockgen 9 | golang.org/x/vuln/cmd/govulncheck 10 | ) 11 | 12 | require ( 13 | github.com/Tensai75/nzbparser v0.1.0 14 | github.com/javi11/nntppool v0.2.1 15 | github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 16 | github.com/mattn/go-sqlite3 v1.14.27 17 | github.com/mnightingale/rapidyenc v0.0.0-20240809192858-2494683cdd67 18 | github.com/opencontainers/selinux v1.12.0 19 | github.com/schollz/progressbar/v3 v3.18.0 20 | github.com/sourcegraph/conc v0.3.0 21 | github.com/spf13/cobra v1.9.1 22 | github.com/stretchr/testify v1.10.0 23 | go.uber.org/mock v0.5.0 24 | golang.org/x/sync v0.13.0 25 | gopkg.in/yaml.v3 v3.0.1 26 | ) 27 | 28 | require ( 29 | 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 30 | 4d63.com/gochecknoglobals v0.2.2 // indirect 31 | github.com/4meepo/tagalign v1.4.2 // indirect 32 | github.com/Abirdcfly/dupword v0.1.3 // indirect 33 | github.com/Antonboom/errname v1.1.0 // indirect 34 | github.com/Antonboom/nilnil v1.1.0 // indirect 35 | github.com/Antonboom/testifylint v1.6.1 // indirect 36 | github.com/BurntSushi/toml v1.5.0 // indirect 37 | github.com/Crocmagnon/fatcontext v0.7.2 // indirect 38 | github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect 39 | github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect 40 | github.com/Masterminds/semver/v3 v3.3.1 // indirect 41 | github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect 42 | github.com/Tensai75/subjectparser v0.1.0 // indirect 43 | github.com/alecthomas/chroma/v2 v2.16.0 // indirect 44 | github.com/alecthomas/go-check-sumtype v0.3.1 // indirect 45 | github.com/alexkohler/nakedret/v2 v2.0.6 // indirect 46 | github.com/alexkohler/prealloc v1.0.0 // indirect 47 | github.com/alingse/asasalint v0.0.11 // indirect 48 | github.com/alingse/nilnesserr v0.2.0 // indirect 49 | github.com/ashanbrown/forbidigo v1.6.0 // indirect 50 | github.com/ashanbrown/makezero v1.2.0 // indirect 51 | github.com/avast/retry-go v3.0.0+incompatible // indirect 52 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 53 | github.com/beorn7/perks v1.0.1 // indirect 54 | github.com/bkielbasa/cyclop v1.2.3 // indirect 55 | github.com/blizzy78/varnamelen v0.8.0 // indirect 56 | github.com/bombsimon/wsl/v4 v4.7.0 // indirect 57 | github.com/breml/bidichk v0.3.3 // indirect 58 | github.com/breml/errchkjson v0.4.1 // indirect 59 | github.com/butuzov/ireturn v0.4.0 // indirect 60 | github.com/butuzov/mirror v1.3.0 // indirect 61 | github.com/catenacyber/perfsprint v0.9.1 // indirect 62 | github.com/ccojocar/zxcvbn-go v1.0.2 // indirect 63 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 64 | github.com/charithe/durationcheck v0.0.10 // indirect 65 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 66 | github.com/charmbracelet/lipgloss v1.1.0 // indirect 67 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 68 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 69 | github.com/charmbracelet/x/term v0.2.1 // indirect 70 | github.com/chavacava/garif v0.1.0 // indirect 71 | github.com/ckaznocha/intrange v0.3.1 // indirect 72 | github.com/curioswitch/go-reassign v0.3.0 // indirect 73 | github.com/daixiang0/gci v0.13.6 // indirect 74 | github.com/dave/dst v0.27.3 // indirect 75 | github.com/davecgh/go-spew v1.1.1 // indirect 76 | github.com/denis-tingaikin/go-header v0.5.0 // indirect 77 | github.com/dlclark/regexp2 v1.11.5 // indirect 78 | github.com/ettle/strcase v0.2.0 // indirect 79 | github.com/fatih/color v1.18.0 // indirect 80 | github.com/fatih/structtag v1.2.0 // indirect 81 | github.com/firefart/nonamedreturns v1.0.6 // indirect 82 | github.com/fsnotify/fsnotify v1.9.0 // indirect 83 | github.com/fzipp/gocyclo v0.6.0 // indirect 84 | github.com/ghostiam/protogetter v0.3.13 // indirect 85 | github.com/go-critic/go-critic v0.13.0 // indirect 86 | github.com/go-toolsmith/astcast v1.1.0 // indirect 87 | github.com/go-toolsmith/astcopy v1.1.0 // indirect 88 | github.com/go-toolsmith/astequal v1.2.0 // indirect 89 | github.com/go-toolsmith/astfmt v1.1.0 // indirect 90 | github.com/go-toolsmith/astp v1.1.0 // indirect 91 | github.com/go-toolsmith/strparse v1.1.0 // indirect 92 | github.com/go-toolsmith/typep v1.1.0 // indirect 93 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 94 | github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect 95 | github.com/gobwas/glob v0.2.3 // indirect 96 | github.com/gofrs/flock v0.12.1 // indirect 97 | github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect 98 | github.com/golangci/go-printf-func-name v0.1.0 // indirect 99 | github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect 100 | github.com/golangci/golangci-lint/v2 v2.1.1 // indirect 101 | github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 // indirect 102 | github.com/golangci/misspell v0.6.0 // indirect 103 | github.com/golangci/plugin-module-register v0.1.1 // indirect 104 | github.com/golangci/revgrep v0.8.0 // indirect 105 | github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect 106 | github.com/google/go-cmp v0.7.0 // indirect 107 | github.com/gordonklaus/ineffassign v0.1.0 // indirect 108 | github.com/gostaticanalysis/analysisutil v0.7.1 // indirect 109 | github.com/gostaticanalysis/comment v1.5.0 // indirect 110 | github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect 111 | github.com/gostaticanalysis/nilerr v0.1.1 // indirect 112 | github.com/hashicorp/errwrap v1.0.0 // indirect 113 | github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect 114 | github.com/hashicorp/go-multierror v1.1.1 // indirect 115 | github.com/hashicorp/go-version v1.7.0 // indirect 116 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 117 | github.com/hexops/gotextdiff v1.0.3 // indirect 118 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 119 | github.com/jackc/puddle/v2 v2.2.2 // indirect 120 | github.com/javi11/nntpcli v0.5.0 // indirect 121 | github.com/jgautheron/goconst v1.8.1 // indirect 122 | github.com/jingyugao/rowserrcheck v1.1.1 // indirect 123 | github.com/jjti/go-spancheck v0.6.4 // indirect 124 | github.com/jstemmer/go-junit-report/v2 v2.1.0 // indirect 125 | github.com/julz/importas v0.2.0 // indirect 126 | github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect 127 | github.com/kisielk/errcheck v1.9.0 // indirect 128 | github.com/kkHAIKE/contextcheck v1.1.6 // indirect 129 | github.com/kulti/thelper v0.6.3 // indirect 130 | github.com/kunwardeep/paralleltest v1.0.14 // indirect 131 | github.com/lasiar/canonicalheader v1.1.2 // indirect 132 | github.com/ldez/exptostd v0.4.2 // indirect 133 | github.com/ldez/gomoddirectives v0.6.1 // indirect 134 | github.com/ldez/grignotin v0.9.0 // indirect 135 | github.com/ldez/tagliatelle v0.7.1 // indirect 136 | github.com/ldez/usetesting v0.4.2 // indirect 137 | github.com/leonklingele/grouper v1.1.2 // indirect 138 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 139 | github.com/macabu/inamedparam v0.2.0 // indirect 140 | github.com/manuelarte/funcorder v0.2.1 // indirect 141 | github.com/maratori/testableexamples v1.0.0 // indirect 142 | github.com/maratori/testpackage v1.1.1 // indirect 143 | github.com/matoous/godox v1.1.0 // indirect 144 | github.com/mattn/go-colorable v0.1.14 // indirect 145 | github.com/mattn/go-isatty v0.0.20 // indirect 146 | github.com/mattn/go-runewidth v0.0.16 // indirect 147 | github.com/mgechev/revive v1.9.0 // indirect 148 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 149 | github.com/mitchellh/go-homedir v1.1.0 // indirect 150 | github.com/moricho/tparallel v0.3.2 // indirect 151 | github.com/muesli/termenv v0.16.0 // indirect 152 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 153 | github.com/nakabonne/nestif v0.3.1 // indirect 154 | github.com/nishanths/exhaustive v0.12.0 // indirect 155 | github.com/nishanths/predeclared v0.2.2 // indirect 156 | github.com/nunnatsa/ginkgolinter v0.19.1 // indirect 157 | github.com/olekukonko/tablewriter v0.0.5 // indirect 158 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 159 | github.com/pmezard/go-difflib v1.0.0 // indirect 160 | github.com/polyfloyd/go-errorlint v1.8.0 // indirect 161 | github.com/prometheus/client_golang v1.21.1 // indirect 162 | github.com/prometheus/client_model v0.6.1 // indirect 163 | github.com/prometheus/common v0.63.0 // indirect 164 | github.com/prometheus/procfs v0.16.0 // indirect 165 | github.com/quasilyte/go-ruleguard v0.4.4 // indirect 166 | github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect 167 | github.com/quasilyte/gogrep v0.5.0 // indirect 168 | github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect 169 | github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect 170 | github.com/raeperd/recvcheck v0.2.0 // indirect 171 | github.com/rivo/uniseg v0.4.7 // indirect 172 | github.com/rogpeppe/go-internal v1.14.1 // indirect 173 | github.com/ryancurrah/gomodguard v1.4.1 // indirect 174 | github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect 175 | github.com/sagikazarmark/locafero v0.7.0 // indirect 176 | github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect 177 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect 178 | github.com/sashamelentyev/interfacebloat v1.1.0 // indirect 179 | github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect 180 | github.com/securego/gosec/v2 v2.22.3 // indirect 181 | github.com/sirupsen/logrus v1.9.3 // indirect 182 | github.com/sivchari/containedctx v1.0.3 // indirect 183 | github.com/sonatard/noctx v0.1.0 // indirect 184 | github.com/sourcegraph/go-diff v0.7.0 // indirect 185 | github.com/spf13/afero v1.14.0 // indirect 186 | github.com/spf13/cast v1.7.1 // indirect 187 | github.com/spf13/pflag v1.0.6 // indirect 188 | github.com/spf13/viper v1.20.0 // indirect 189 | github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect 190 | github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect 191 | github.com/stretchr/objx v0.5.2 // indirect 192 | github.com/subosito/gotenv v1.6.0 // indirect 193 | github.com/tdakkota/asciicheck v0.4.1 // indirect 194 | github.com/tetafro/godot v1.5.0 // indirect 195 | github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect 196 | github.com/timonwong/loggercheck v0.11.0 // indirect 197 | github.com/tomarrell/wrapcheck/v2 v2.11.0 // indirect 198 | github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect 199 | github.com/ultraware/funlen v0.2.0 // indirect 200 | github.com/ultraware/whitespace v0.2.0 // indirect 201 | github.com/uudashr/gocognit v1.2.0 // indirect 202 | github.com/uudashr/iface v1.3.1 // indirect 203 | github.com/xen0n/gosmopolitan v1.3.0 // indirect 204 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 205 | github.com/yagipy/maintidx v1.0.0 // indirect 206 | github.com/yeya24/promlinter v0.3.0 // indirect 207 | github.com/ykadowak/zerologlint v0.1.5 // indirect 208 | gitlab.com/bosi/decorder v0.4.2 // indirect 209 | go-simpler.org/musttag v0.13.0 // indirect 210 | go-simpler.org/sloglint v0.11.0 // indirect 211 | go.uber.org/automaxprocs v1.6.0 // indirect 212 | go.uber.org/multierr v1.11.0 // indirect 213 | go.uber.org/zap v1.27.0 // indirect 214 | golang.org/x/exp/typeparams v0.0.0-20250305212735-054e65f0b394 // indirect 215 | golang.org/x/mod v0.24.0 // indirect 216 | golang.org/x/net v0.39.0 // indirect 217 | golang.org/x/sys v0.32.0 // indirect 218 | golang.org/x/telemetry v0.0.0-20250310203348-fdfaad844314 // indirect 219 | golang.org/x/term v0.31.0 // indirect 220 | golang.org/x/text v0.24.0 // indirect 221 | golang.org/x/tools v0.32.0 // indirect 222 | golang.org/x/vuln v1.1.4 // indirect 223 | google.golang.org/protobuf v1.36.6 // indirect 224 | gopkg.in/yaml.v2 v2.4.0 // indirect 225 | honnef.co/go/tools v0.6.1 // indirect 226 | mvdan.cc/gofumpt v0.7.0 // indirect 227 | mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 // indirect 228 | ) 229 | -------------------------------------------------------------------------------- /internal/repairnzb/repair_nzb.go: -------------------------------------------------------------------------------- 1 | package repairnzb 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/rand" 7 | "errors" 8 | "fmt" 9 | "log/slog" 10 | "os" 11 | "path/filepath" 12 | "sync" 13 | "sync/atomic" 14 | "time" 15 | 16 | "github.com/Tensai75/nzbparser" 17 | "github.com/javi11/nntppool" 18 | "github.com/javi11/nzb-repair/internal/config" 19 | "github.com/k0kubun/go-ansi" 20 | "github.com/mnightingale/rapidyenc" 21 | "github.com/schollz/progressbar/v3" 22 | "github.com/sourcegraph/conc/pool" 23 | ) 24 | 25 | func RepairNzb( 26 | ctx context.Context, 27 | cfg config.Config, 28 | downloadPool nntppool.UsenetConnectionPool, 29 | uploadPool nntppool.UsenetConnectionPool, 30 | par2Executor Par2Executor, 31 | nzbFile string, 32 | outputFile string, 33 | tmpDir string, 34 | ) error { 35 | content, err := os.Open(nzbFile) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | nzb, err := nzbparser.Parse(content) 41 | if err != nil { 42 | _ = content.Close() 43 | 44 | return err 45 | } 46 | 47 | _ = content.Close() 48 | 49 | parFiles, restFiles := splitParWithRest(nzb) 50 | if len(parFiles) == 0 { 51 | slog.InfoContext(ctx, "No par2 files found in NZB, stopping repair.") 52 | return nil 53 | } 54 | 55 | brokenSegments := make(map[*nzbparser.NzbFile][]brokenSegment, 0) 56 | brokenSegmentCh := make(chan brokenSegment, 100) 57 | 58 | bswg := &sync.WaitGroup{} 59 | // goroutine to listen for broken segments 60 | bswg.Add(1) 61 | go func() { 62 | defer bswg.Done() 63 | for { 64 | select { 65 | case <-ctx.Done(): 66 | return 67 | case s, ok := <-brokenSegmentCh: 68 | if !ok { 69 | return 70 | } 71 | 72 | if _, ok := brokenSegments[s.file]; !ok { 73 | brokenSegments[s.file] = make([]brokenSegment, 0) 74 | } 75 | 76 | brokenSegments[s.file] = append(brokenSegments[s.file], s) 77 | } 78 | } 79 | }() 80 | 81 | if len(restFiles) == 0 { 82 | slog.InfoContext(ctx, "No files to repair, stopping repair.") 83 | 84 | return nil 85 | } 86 | 87 | firstFile := restFiles[0] 88 | if err := os.MkdirAll(tmpDir, 0755); err != nil { 89 | if !errors.Is(err, os.ErrExist) { 90 | slog.With("err", err).ErrorContext(ctx, "failed to ensure temp folder exists") 91 | return err 92 | } 93 | } 94 | 95 | defer func() { 96 | slog.InfoContext(ctx, "Cleaning up temporary directory", "path", tmpDir) 97 | if err := os.RemoveAll(tmpDir); err != nil { 98 | slog.ErrorContext(ctx, "Failed to clean up temporary directory", "path", tmpDir, "error", err) 99 | } 100 | }() 101 | 102 | // Download files 103 | startTime := time.Now() 104 | for _, f := range restFiles { 105 | if ctx.Err() != nil { 106 | slog.With("err", err).ErrorContext(ctx, "repair canceled") 107 | 108 | return nil 109 | } 110 | 111 | err := downloadWorker(ctx, cfg, downloadPool, f, brokenSegmentCh, tmpDir) 112 | if err != nil { 113 | slog.With("err", err).ErrorContext(ctx, "failed to download file") 114 | } 115 | 116 | } 117 | 118 | close(brokenSegmentCh) 119 | bswg.Wait() 120 | 121 | if ctx.Err() != nil { 122 | slog.With("err", err).ErrorContext(ctx, "repair canceled") 123 | 124 | return nil 125 | } 126 | 127 | elapsed := time.Since(startTime) 128 | 129 | slog.InfoContext(ctx, fmt.Sprintf("%d files downloaded in %s", len(restFiles), elapsed)) 130 | 131 | if len(brokenSegments) == 0 { 132 | slog.InfoContext(ctx, "No broken segments found, stopping repair.") 133 | 134 | return nil 135 | } 136 | 137 | // Download par2 files 138 | slog.InfoContext(ctx, fmt.Sprintf("%d broken segments found. Downloading par2 files", len(brokenSegments))) 139 | for _, f := range parFiles { 140 | if ctx.Err() != nil { 141 | return nil 142 | } 143 | 144 | err := downloadWorker(ctx, cfg, downloadPool, f, nil, tmpDir) 145 | if err != nil { 146 | slog.With("err", err).InfoContext(ctx, "failed to download par2 file, cancelling repair") 147 | } 148 | } 149 | 150 | err = par2Executor.Repair(ctx, tmpDir) 151 | if err != nil { 152 | slog.With("err", err).ErrorContext(ctx, "failed to repair files") 153 | } 154 | 155 | // Upload repaired files 156 | startTime = time.Now() 157 | 158 | err = replaceBrokenSegments(ctx, brokenSegments, tmpDir, cfg, uploadPool, nzb) 159 | if err != nil { 160 | slog.With("err", err).ErrorContext(ctx, "failed to upload repaired files") 161 | 162 | return err 163 | } 164 | 165 | // write the repaired nzb file 166 | var nzbFileName string 167 | if outputFile != "" { 168 | nzbFileName = outputFile 169 | } else { 170 | inputFileFolder := filepath.Dir(nzbFile) 171 | nzbFileName = filepath.Join(inputFileFolder, fmt.Sprintf("%s.repaired.nzb", firstFile.Basefilename)) 172 | } 173 | 174 | // Ensure output directory exists 175 | outputDirPath := filepath.Dir(nzbFileName) 176 | if err := os.MkdirAll(outputDirPath, 0755); err != nil { 177 | if !errors.Is(err, os.ErrExist) { 178 | slog.With("err", err).ErrorContext(ctx, "failed to create output directory") 179 | return err 180 | } 181 | } 182 | 183 | b, err := nzbparser.Write(nzb) 184 | if err != nil { 185 | slog.With("err", err).ErrorContext(ctx, "failed to write repaired nzb file") 186 | 187 | return err 188 | } 189 | 190 | nzbFileHandle, err := os.Create(nzbFileName) 191 | if err != nil { 192 | slog.With("err", err).ErrorContext(ctx, "failed to create repaired nzb file") 193 | 194 | return err 195 | } 196 | 197 | defer func() { 198 | _ = nzbFileHandle.Close() 199 | }() 200 | 201 | if _, err := nzbFileHandle.Write(b); err != nil { 202 | slog.With("err", err).ErrorContext(ctx, "failed to write repaired nzb file") 203 | 204 | return err 205 | } 206 | 207 | slog.InfoContext(ctx, fmt.Sprintf("Repaired nzb file written to %s", nzbFileName)) 208 | slog.InfoContext(ctx, fmt.Sprintf("%d broken segments uploaded in %s", len(brokenSegments), time.Since(startTime))) 209 | slog.InfoContext(ctx, "Repair completed successfully") 210 | 211 | return nil 212 | } 213 | 214 | func replaceBrokenSegments( 215 | ctx context.Context, 216 | brokenSegments map[*nzbparser.NzbFile][]brokenSegment, 217 | tmpFolder string, 218 | cfg config.Config, 219 | uploadPool nntppool.UsenetConnectionPool, 220 | nzb *nzbparser.Nzb, 221 | ) error { 222 | encoder := rapidyenc.NewEncoder() 223 | 224 | for nzbFile, bs := range brokenSegments { 225 | if ctx.Err() != nil { 226 | slog.ErrorContext(ctx, "repair canceled") 227 | 228 | return nil 229 | } 230 | 231 | tmpFile, err := os.Open(filepath.Join(tmpFolder, nzbFile.Filename)) 232 | if err != nil { 233 | slog.With("err", err).ErrorContext(ctx, "failed to open file") 234 | 235 | return err 236 | } 237 | 238 | fs, err := tmpFile.Stat() 239 | if err != nil { 240 | slog.With("err", err).ErrorContext(ctx, "failed to get file info") 241 | _ = tmpFile.Close() 242 | 243 | return err 244 | } 245 | 246 | fileSize := fs.Size() 247 | 248 | p := pool.New().WithContext(ctx). 249 | WithMaxGoroutines(cfg.UploadWorkers). 250 | WithCancelOnError() 251 | 252 | for _, s := range bs { 253 | p.Go(func(ctx context.Context) error { 254 | if ctx.Err() != nil { 255 | slog.With("err", err).ErrorContext(ctx, "repair canceled") 256 | 257 | return nil 258 | } 259 | 260 | // Get the segment from the file 261 | buff := make([]byte, s.segment.Bytes) 262 | _, err := tmpFile.ReadAt(buff, int64((s.segment.Number-1)*s.segment.Bytes)) 263 | if err != nil { 264 | slog.With("err", err).ErrorContext(ctx, "failed to read segment") 265 | 266 | return err 267 | } 268 | 269 | partSize := int64(s.segment.Bytes) 270 | date := time.UnixMilli(int64(nzbFile.Date)) 271 | 272 | subject := fmt.Sprintf("[%v/%v] %v - \"\" yEnc (%v/%v)", s.file.Number, nzb.TotalFiles, s.file.Filename, int64(s.segment.Number), s.file.TotalSegments) 273 | 274 | var fName string 275 | 276 | if cfg.Upload.ObfuscationPolicy == config.ObfuscationPolicyNone { 277 | fName = s.file.Filename 278 | } else { 279 | fName = rand.Text() 280 | subject = rand.Text() 281 | } 282 | 283 | msgId := generateRandomMessageID() 284 | 285 | ar := articleData{ 286 | PartNum: int64(s.segment.Number), 287 | PartTotal: fileSize / partSize, 288 | PartSize: partSize, 289 | PartBegin: int64((s.segment.Number - 1) * s.segment.Bytes), 290 | PartEnd: int64(s.segment.Number * s.segment.Bytes), 291 | FileNum: s.file.Number, 292 | FileTotal: 1, 293 | FileSize: fileSize, 294 | Subject: subject, 295 | Poster: nzbFile.Poster, 296 | Groups: nzbFile.Groups, 297 | Filename: fName, 298 | Date: &date, 299 | body: buff, 300 | MsgId: msgId, 301 | } 302 | 303 | r, err := ar.EncodeBytes(encoder) 304 | if err != nil { 305 | slog.With("err", err).ErrorContext(ctx, "failed to encode segment") 306 | 307 | return err 308 | } 309 | 310 | // Upload the segment 311 | err = uploadPool.Post(ctx, r) 312 | if err != nil { 313 | slog.With("err", err).ErrorContext(ctx, "failed to upload segment") 314 | 315 | return err 316 | } 317 | 318 | slog.InfoContext(ctx, fmt.Sprintf("Uploaded segment %s", s.segment.Id)) 319 | nzbFile.Segments[s.segment.Number-1].Id = msgId 320 | 321 | return nil 322 | }) 323 | } 324 | 325 | if err := p.Wait(); err != nil { 326 | slog.With("err", err).ErrorContext(ctx, "failed to upload segments") 327 | _ = tmpFile.Close() 328 | 329 | return err 330 | } 331 | 332 | _ = tmpFile.Close() 333 | slog.InfoContext(ctx, fmt.Sprintf("Uploaded %d segments for file %s", len(bs), nzbFile.Filename)) 334 | 335 | // Replace the original broken file in the nzb with the repaired version 336 | for i, f := range nzb.Files { 337 | if f.Filename == nzbFile.Filename { 338 | nzb.Files[i] = *nzbFile 339 | break 340 | } 341 | } 342 | } 343 | 344 | return nil 345 | } 346 | 347 | func downloadWorker( 348 | ctx context.Context, 349 | config config.Config, 350 | downloadPool nntppool.UsenetConnectionPool, 351 | file nzbparser.NzbFile, 352 | brokenSegmentCh chan<- brokenSegment, 353 | tmpFolder string, 354 | ) error { 355 | brokenSegmentCounter := atomic.Int64{} 356 | 357 | p := pool.New().WithContext(ctx). 358 | WithMaxGoroutines(config.DownloadWorkers). 359 | WithCancelOnError() 360 | 361 | slog.InfoContext(ctx, fmt.Sprintf("Starting downloading file %s", file.Filename)) 362 | 363 | filePath := filepath.Join(tmpFolder, file.Filename) 364 | 365 | // Check if file exists 366 | if _, err := os.Stat(filePath); err == nil { 367 | slog.InfoContext(ctx, fmt.Sprintf("File %s already exists, skipping download", file.Filename)) 368 | return nil 369 | } 370 | 371 | fileWriter, err := os.Create(filePath) 372 | if err != nil { 373 | slog.With("err", err).ErrorContext(ctx, "failed to create file: %v") 374 | 375 | return fmt.Errorf("failed to create file: %w", err) 376 | } 377 | 378 | defer func() { 379 | _ = fileWriter.Close() 380 | }() 381 | 382 | bar := progressbar.NewOptions(int(file.Bytes), 383 | progressbar.OptionSetWriter(ansi.NewAnsiStdout()), 384 | progressbar.OptionEnableColorCodes(true), 385 | progressbar.OptionSetWidth(15), 386 | progressbar.OptionShowBytes(true), 387 | progressbar.OptionShowTotalBytes(true), 388 | progressbar.OptionSetTheme(progressbar.Theme{ 389 | Saucer: "[green]=[reset]", 390 | SaucerHead: "[green]>[reset]", 391 | SaucerPadding: " ", 392 | BarStart: "[", 393 | BarEnd: "]", 394 | })) 395 | 396 | c, cancel := context.WithCancel(context.Background()) 397 | defer cancel() 398 | 399 | once := sync.Once{} 400 | 401 | for _, s := range file.Segments { 402 | select { 403 | case <-c.Done(): 404 | return nil 405 | case <-ctx.Done(): 406 | return nil 407 | default: 408 | p.Go(func(c context.Context) error { 409 | buff := bytes.NewBuffer(make([]byte, 0)) 410 | if _, err := downloadPool.Body(c, s.Id, buff, file.Groups); err != nil { 411 | if errors.Is(err, nntppool.ErrArticleNotFoundInProviders) { 412 | if brokenSegmentCh != nil { 413 | slog.DebugContext(ctx, fmt.Sprintf("segment %s not found, sending for repair: %v", s.Id, err)) 414 | 415 | brokenSegmentCh <- brokenSegment{ 416 | segment: &s, 417 | file: &file, 418 | } 419 | brokenSegmentCounter.Add(1) 420 | 421 | // Recalculate segment size for wrong segment sizes 422 | once.Do(func() { 423 | for _, s := range file.Segments { 424 | s.Bytes = buff.Len() 425 | } 426 | }) 427 | } else if !errors.Is(err, context.Canceled) { 428 | return fmt.Errorf("segment %v not found", s.Id) 429 | } 430 | 431 | return nil 432 | } 433 | 434 | if errors.Is(err, context.Canceled) { 435 | return nil 436 | } 437 | 438 | slog.ErrorContext(ctx, fmt.Sprintf("failed to download segment %s canceling the repair: %v", s.Id, err)) 439 | cancel() 440 | 441 | return err 442 | } 443 | 444 | start := (s.Number - 1) * buff.Len() 445 | 446 | _, err = fileWriter.WriteAt(buff.Bytes(), int64(start)) 447 | if err != nil { 448 | slog.With("err", err).ErrorContext(ctx, "failed to write segment") 449 | 450 | return err 451 | } 452 | 453 | _ = bar.Add(s.Bytes) 454 | 455 | return nil 456 | }) 457 | } 458 | } 459 | 460 | if err := p.Wait(); err != nil { 461 | return err 462 | } 463 | 464 | return nil 465 | } 466 | -------------------------------------------------------------------------------- /internal/queue/queue.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | _ "github.com/mattn/go-sqlite3" // Import the sqlite3 driver 15 | ) 16 | 17 | type JobStatus string 18 | 19 | const ( 20 | StatusPending JobStatus = "pending" 21 | StatusProcessing JobStatus = "processing" 22 | StatusCompleted JobStatus = "completed" 23 | StatusFailed JobStatus = "failed" 24 | StatusMoved JobStatus = "moved" 25 | ) 26 | 27 | // ErrDuplicateJob can be used by mock implementations. 28 | // Note: The actual Queue implementation handles duplicates internally 29 | // and doesn't currently return a specific exported error type for this. 30 | var ErrDuplicateJob = errors.New("job already exists or is being processed") 31 | 32 | type Job struct { 33 | ID int64 34 | FilePath string 35 | RelativePath string 36 | Status JobStatus 37 | ErrorMsg sql.NullString 38 | RetryCount int64 39 | CreatedAt time.Time 40 | UpdatedAt time.Time 41 | } 42 | 43 | // Queuer defines the interface for adding jobs, primarily used for dependency injection. 44 | type Queuer interface { 45 | // AddJob adds a new job to the queue. Implementations should handle 46 | // path normalization and duplicate checks as needed. 47 | AddJob(absPath, relPath string) error 48 | // Potentially add other methods needed by consumers like Watcher later 49 | } 50 | 51 | // Ensure Queue implements Queuer 52 | var _ Queuer = (*Queue)(nil) 53 | 54 | type Queue struct { 55 | db *sql.DB 56 | mu sync.Mutex 57 | } 58 | 59 | // NewQueue initializes the SQLite database and creates/updates the jobs table. 60 | func NewQueue(dbPath string) (*Queue, error) { 61 | db, err := sql.Open("sqlite3", dbPath) 62 | if err != nil { 63 | return nil, fmt.Errorf("failed to open database: %w", err) 64 | } 65 | 66 | // Create the jobs table if it doesn't exist 67 | query := ` 68 | CREATE TABLE IF NOT EXISTS jobs ( 69 | id INTEGER PRIMARY KEY AUTOINCREMENT, 70 | filepath TEXT NOT NULL UNIQUE, 71 | relative_path TEXT NOT NULL DEFAULT '', 72 | status TEXT NOT NULL DEFAULT 'pending', 73 | error_msg TEXT, 74 | retry_count INTEGER NOT NULL DEFAULT 0, 75 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 76 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 77 | ); 78 | ` 79 | _, err = db.Exec(query) 80 | if err != nil { 81 | // Close DB if table creation fails 82 | _ = db.Close() 83 | return nil, fmt.Errorf("failed to create jobs table: %w", err) 84 | } 85 | 86 | // Attempt to add the retry_count column if it doesn't exist (migration for older dbs) 87 | alterQuery := `ALTER TABLE jobs ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0` 88 | _, err = db.Exec(alterQuery) 89 | if err != nil { 90 | // Ignore error if the column already exists 91 | if !strings.Contains(err.Error(), "duplicate column name") { 92 | // Log other alteration errors but don't fail initialization 93 | slog.Warn("failed to add retry_count column (might already exist)", "error", err) 94 | } 95 | } 96 | 97 | // Attempt to add the relative_path column if it doesn't exist (migration for older dbs) 98 | // This avoids errors if the table already exists without the column. 99 | alterQuery = `ALTER TABLE jobs ADD COLUMN relative_path TEXT NOT NULL DEFAULT ''` 100 | _, err = db.Exec(alterQuery) 101 | if err != nil { 102 | // Ignore error if the column already exists 103 | if !strings.Contains(err.Error(), "duplicate column name") { 104 | // Log other alteration errors but don't fail initialization 105 | slog.Warn("failed to add relative_path column (might already exist)", "error", err) 106 | } 107 | } 108 | 109 | // Add indexes 110 | indexQueries := []string{ 111 | `CREATE INDEX IF NOT EXISTS idx_jobs_status_created_at ON jobs (status, created_at);`, 112 | // No need to index relative_path unless we plan to query by it frequently 113 | // `CREATE INDEX IF NOT EXISTS idx_jobs_relative_path ON jobs (relative_path);`, 114 | } 115 | for _, iq := range indexQueries { 116 | _, err = db.Exec(iq) 117 | if err != nil { 118 | // Log index creation errors but don't fail initialization 119 | slog.Warn("failed to create index", "query", iq, "error", err) 120 | } 121 | } 122 | 123 | return &Queue{db: db, mu: sync.Mutex{}}, nil 124 | } 125 | 126 | // AddJob adds a new NZB file path (absolute and relative) to the queue with pending status. 127 | // It ignores duplicates based on the absolute filepath unless the existing job is failed, 128 | // in which case it resets the status to pending and updates the relative path. 129 | func (q *Queue) AddJob(filePath string, relativePath string) error { 130 | q.mu.Lock() 131 | defer q.mu.Unlock() 132 | 133 | tx, err := q.db.Begin() 134 | if err != nil { 135 | return fmt.Errorf("failed to begin transaction: %w", err) 136 | } 137 | defer func() { 138 | _ = tx.Rollback() // Rollback if anything fails 139 | }() 140 | 141 | var currentStatus JobStatus 142 | var jobID int64 // We don't strictly need the ID here, but scanning into it avoids error if row exists 143 | // Select based on absolute filepath 144 | selectQuery := `SELECT id, status FROM jobs WHERE filepath = ?` 145 | err = tx.QueryRow(selectQuery, filePath).Scan(&jobID, ¤tStatus) 146 | 147 | now := time.Now() 148 | 149 | if err != nil { 150 | if errors.Is(err, sql.ErrNoRows) { 151 | // Job doesn't exist, insert as pending with relative path 152 | insertQuery := `INSERT INTO jobs (filepath, relative_path, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)` 153 | _, err = tx.Exec(insertQuery, filePath, relativePath, StatusPending, now, now) 154 | if err != nil { 155 | return fmt.Errorf("failed to insert new job: %w", err) 156 | } 157 | } else { 158 | // Other error during select 159 | return fmt.Errorf("failed to check for existing job: %w", err) 160 | } 161 | } else { 162 | // Job exists 163 | if currentStatus == StatusFailed || currentStatus == StatusCompleted { 164 | // Job failed or completed, reset to pending and update relative path just in case 165 | updateQuery := `UPDATE jobs SET status = ?, error_msg = NULL, updated_at = ?, relative_path = ? WHERE filepath = ?` 166 | _, err = tx.Exec(updateQuery, StatusPending, now, relativePath, filePath) 167 | if err != nil { 168 | return fmt.Errorf("failed to reset existing job to pending: %w", err) 169 | } 170 | slog.Debug("Resetting existing job to pending", "filepath", filePath, "relative_path", relativePath) 171 | } else { 172 | // Job exists with status pending or processing - ignore 173 | slog.Debug("Ignoring add job request for existing non-failed/non-completed job", "filepath", filePath, "status", currentStatus) 174 | // No action needed, transaction will be committed harmlessly if update wasn't needed. 175 | } 176 | } 177 | 178 | if err = tx.Commit(); err != nil { 179 | return fmt.Errorf("failed to commit transaction for AddJob: %w", err) 180 | } 181 | 182 | return nil 183 | } 184 | 185 | // GetNextJob retrieves the oldest pending job, marks it as processing, and returns it. 186 | // Returns sql.ErrNoRows if no pending jobs are available. 187 | func (q *Queue) GetNextJob() (*Job, error) { 188 | q.mu.Lock() 189 | defer q.mu.Unlock() 190 | 191 | tx, err := q.db.Begin() 192 | if err != nil { 193 | return nil, fmt.Errorf("failed to begin transaction: %w", err) 194 | } 195 | defer func() { 196 | _ = tx.Rollback() // Rollback if anything fails 197 | }() 198 | 199 | // Select the oldest pending job, including relative_path 200 | selectQuery := `SELECT id, filepath, relative_path, status, error_msg, created_at, updated_at FROM jobs WHERE status = ? ORDER BY created_at ASC LIMIT 1` 201 | row := tx.QueryRow(selectQuery, StatusPending) 202 | 203 | job := &Job{} 204 | // Scan relative_path into the job struct 205 | err = row.Scan(&job.ID, &job.FilePath, &job.RelativePath, &job.Status, &job.ErrorMsg, &job.CreatedAt, &job.UpdatedAt) 206 | if err != nil { 207 | if errors.Is(err, sql.ErrNoRows) { 208 | return nil, sql.ErrNoRows // Specific error for no pending jobs 209 | } 210 | // Potential error if relative_path column doesn't exist yet (if ALTER TABLE failed silently) 211 | // Log the specific scan error for debugging 212 | slog.Error("Failed to scan job row", "error", err) 213 | return nil, fmt.Errorf("failed to scan job row: %w", err) 214 | } 215 | 216 | // Update the job status to processing 217 | updateQuery := `UPDATE jobs SET status = ?, updated_at = ? WHERE id = ?` 218 | _, err = tx.Exec(updateQuery, StatusProcessing, time.Now(), job.ID) 219 | if err != nil { 220 | return nil, fmt.Errorf("failed to update job status to processing: %w", err) 221 | } 222 | 223 | if err = tx.Commit(); err != nil { 224 | return nil, fmt.Errorf("failed to commit transaction: %w", err) 225 | } 226 | 227 | job.Status = StatusProcessing // Update status in the returned struct 228 | return job, nil 229 | } 230 | 231 | // UpdateJobStatus updates the status and optionally the error message for a given job ID. 232 | // If the status is being set to failed, it will increment the retry count. 233 | func (q *Queue) UpdateJobStatus(jobID int64, status JobStatus, errorMsg string) error { 234 | q.mu.Lock() 235 | defer q.mu.Unlock() 236 | 237 | var errMsg sql.NullString 238 | if errorMsg != "" { 239 | errMsg = sql.NullString{String: errorMsg, Valid: true} 240 | } 241 | 242 | var query string 243 | var args []interface{} 244 | 245 | if status == StatusFailed { 246 | // Increment retry count when status is set to failed 247 | query = `UPDATE jobs SET status = ?, error_msg = ?, updated_at = ?, retry_count = retry_count + 1 WHERE id = ?` 248 | args = []interface{}{status, errMsg, time.Now(), jobID} 249 | } else { 250 | query = `UPDATE jobs SET status = ?, error_msg = ?, updated_at = ? WHERE id = ?` 251 | args = []interface{}{status, errMsg, time.Now(), jobID} 252 | } 253 | 254 | _, err := q.db.Exec(query, args...) 255 | if err != nil { 256 | return fmt.Errorf("failed to update job status: %w", err) 257 | } 258 | return nil 259 | } 260 | 261 | // Close closes the database connection. 262 | func (q *Queue) Close() error { 263 | if q.db != nil { 264 | return q.db.Close() 265 | } 266 | return nil 267 | } 268 | 269 | // CleanupProcessingJobs finds all jobs marked as processing and sets their status to failed. 270 | // This is typically called on application startup to handle jobs interrupted by a previous crash. 271 | func (q *Queue) CleanupProcessingJobs() (int64, error) { 272 | query := `UPDATE jobs SET status = ?, updated_at = ? WHERE status = ?` 273 | now := time.Now() 274 | result, err := q.db.Exec(query, StatusPending, now, StatusProcessing) 275 | if err != nil { 276 | return 0, fmt.Errorf("failed to update processing jobs to failed: %w", err) 277 | } 278 | 279 | rowsAffected, err := result.RowsAffected() 280 | if err != nil { 281 | // Log the error but don't fail the operation if we can't get rows affected 282 | slog.Warn("failed to get rows affected after cleaning up processing jobs", "error", err) 283 | return 0, nil // Return 0 rows affected, but no error for the main operation 284 | } 285 | 286 | if rowsAffected > 0 { 287 | slog.Info("Cleaned up interrupted jobs", "count", rowsAffected) 288 | } 289 | 290 | return rowsAffected, nil 291 | } 292 | 293 | // MoveFailedFiles moves files that have exceeded the maximum number of retries 294 | // to the broken folder. Returns the number of files moved and any error encountered. 295 | func (q *Queue) MoveFailedFiles(maxRetries int64, brokenFolder string) (int64, error) { 296 | q.mu.Lock() 297 | defer q.mu.Unlock() 298 | 299 | // Create broken folder if it doesn't exist 300 | if err := os.MkdirAll(brokenFolder, 0755); err != nil { 301 | return 0, fmt.Errorf("failed to create broken folder: %w", err) 302 | } 303 | 304 | // Get all failed jobs that have exceeded max retries 305 | query := ` 306 | SELECT id, filepath, relative_path 307 | FROM jobs 308 | WHERE status = ? AND retry_count >= ? 309 | ` 310 | rows, err := q.db.Query(query, StatusFailed, maxRetries) 311 | if err != nil { 312 | return 0, fmt.Errorf("failed to query failed jobs: %w", err) 313 | } 314 | defer func() { 315 | _ = rows.Close() 316 | }() 317 | 318 | var movedCount int64 319 | for rows.Next() { 320 | var job Job 321 | if err := rows.Scan(&job.ID, &job.FilePath, &job.RelativePath); err != nil { 322 | return movedCount, fmt.Errorf("failed to scan job row: %w", err) 323 | } 324 | 325 | // Get the filename from the path 326 | _, filename := filepath.Split(job.FilePath) 327 | if filename == "" { 328 | slog.Warn("Skipping file with empty filename", "filepath", job.FilePath) 329 | continue 330 | } 331 | 332 | // Create destination path in broken folder 333 | destPath := filepath.Join(brokenFolder, filename) 334 | 335 | // Move the file 336 | if err := os.Rename(job.FilePath, destPath); err != nil { 337 | slog.Error("Failed to move file to broken folder", 338 | "filepath", job.FilePath, 339 | "dest", destPath, 340 | "error", err) 341 | continue 342 | } 343 | 344 | // Update job status to indicate it was moved 345 | updateQuery := `UPDATE jobs SET status = 'moved', updated_at = datetime('now') WHERE id = ?` 346 | if _, err := q.db.Exec(updateQuery, job.ID); err != nil { 347 | slog.Error("Failed to update job status after move", 348 | "job_id", job.ID, 349 | "error", err) 350 | continue 351 | } 352 | 353 | movedCount++ 354 | slog.Info("Moved failed file to broken folder", 355 | "filepath", job.FilePath, 356 | "dest", destPath, 357 | "retry_count", job.RetryCount) 358 | } 359 | 360 | if err := rows.Err(); err != nil { 361 | return movedCount, fmt.Errorf("error iterating failed jobs: %w", err) 362 | } 363 | 364 | return movedCount, nil 365 | } 366 | -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "log/slog" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | 14 | "github.com/javi11/nntppool" 15 | "github.com/javi11/nzb-repair/internal/config" 16 | "github.com/javi11/nzb-repair/internal/queue" 17 | "github.com/javi11/nzb-repair/internal/repairnzb" 18 | "github.com/javi11/nzb-repair/internal/scanner" 19 | "github.com/javi11/nzb-repair/pkg/par2exedownloader" 20 | "golang.org/x/sync/errgroup" 21 | ) 22 | 23 | const ( 24 | defaultPar2Exe = "./par2cmd" 25 | defaultWatcherOutputDir = "./repaired" 26 | defaultWorkerInterval = 5 * time.Second 27 | ) 28 | 29 | // RunSingleRepair executes the repair process for a single NZB file. 30 | func RunSingleRepair(ctx context.Context, cfg config.Config, nzbFile string, outputFileOrDir string, tmpDir string, verbose bool) error { 31 | logger := setupLogging(verbose) 32 | 33 | absTmpDir, err := prepareTmpDir(ctx, tmpDir, logger) 34 | if err != nil { 35 | return fmt.Errorf("failed to prepare temporary directory: %w", err) 36 | } 37 | 38 | // Ensure par2 executable exists and get its path 39 | par2ExePath, err := ensurePar2Executable(ctx, cfg, logger) 40 | if err != nil { 41 | return fmt.Errorf("failed to ensure par2 executable: %w", err) 42 | } 43 | // Create the par2 executor 44 | par2Executor := &repairnzb.Par2CmdExecutor{ExePath: par2ExePath} 45 | 46 | uploadPool, downloadPool, err := createPools(cfg) 47 | if err != nil { 48 | return err // Error already contains context 49 | } 50 | // Ensure pools are closed properly 51 | defer func() { 52 | logger.DebugContext(ctx, "Closing download pool") 53 | downloadPool.Quit() 54 | // TODO: Add uploadPool.Quit() when fixed in nntppool 55 | // logger.DebugContext(ctx, "Closing upload pool") 56 | // uploadPool.Quit() 57 | }() 58 | 59 | outputFile, err := getSingleOutputFilePath(nzbFile, outputFileOrDir) 60 | if err != nil { 61 | return fmt.Errorf("failed to determine output file path: %w", err) 62 | } 63 | logger.InfoContext(ctx, "Starting repair", "input", nzbFile, "output", outputFile, "temp", absTmpDir) 64 | 65 | err = repairnzb.RepairNzb( 66 | ctx, 67 | cfg, 68 | downloadPool, 69 | uploadPool, 70 | par2Executor, // Pass the executor instance 71 | nzbFile, 72 | outputFile, 73 | absTmpDir, 74 | ) 75 | if err != nil { 76 | logger.ErrorContext(ctx, "Repair failed", "input", nzbFile, "error", err) 77 | return fmt.Errorf("repair process failed for %q: %w", nzbFile, err) 78 | } 79 | 80 | logger.InfoContext(ctx, "Repair successful", "input", nzbFile, "output", outputFile) 81 | return nil 82 | } 83 | 84 | // RunWatcher starts the directory scanner and the repair worker goroutines. 85 | func RunWatcher(ctx context.Context, cfg config.Config, watchDir string, dbPath string, outputBaseDirFlag string, tmpDir string, verbose bool) error { 86 | logger := setupLogging(verbose) 87 | 88 | logger.InfoContext(ctx, "Initializing database...", "path", dbPath) 89 | dbQueue, err := queue.NewQueue(dbPath) 90 | if err != nil { 91 | return fmt.Errorf("failed to initialize queue: %w", err) 92 | } 93 | 94 | defer func() { 95 | logger.InfoContext(ctx, "Closing database queue") 96 | if cErr := dbQueue.Close(); cErr != nil { 97 | logger.ErrorContext(ctx, "Error closing database queue", "error", cErr) 98 | } 99 | }() 100 | 101 | // Cleanup interrupted jobs from previous runs 102 | logger.InfoContext(ctx, "Cleaning up any jobs marked as 'processing' from previous runs") 103 | cleanedCount, err := dbQueue.CleanupProcessingJobs() 104 | if err != nil { 105 | logger.ErrorContext(ctx, "Failed to cleanup processing jobs, continuing...", "error", err) 106 | } else { 107 | logger.InfoContext(ctx, "Cleaned up processing jobs", "count", cleanedCount) 108 | } 109 | 110 | absTmpDir, err := prepareTmpDir(ctx, tmpDir, logger) 111 | if err != nil { 112 | return fmt.Errorf("failed to prepare temporary directory: %w", err) 113 | } 114 | 115 | // Note: Tmp dir is prepared once at the start for the watcher. 116 | // Determine and prepare the base output directory. 117 | outputBaseDir := outputBaseDirFlag 118 | if outputBaseDir == "" { 119 | outputBaseDir = defaultWatcherOutputDir 120 | logger.InfoContext(ctx, "No output directory specified (-o), using default", "path", outputBaseDir) 121 | } 122 | // Ensure the base output directory exists and get its absolute path. 123 | if err := os.MkdirAll(outputBaseDir, 0750); err != nil { 124 | return fmt.Errorf("failed to create base output directory %q: %w", outputBaseDir, err) 125 | } 126 | 127 | outputBaseDir, err = filepath.Abs(outputBaseDir) 128 | if err != nil { 129 | return fmt.Errorf("failed to get absolute path for output directory %q: %w", outputBaseDir, err) 130 | } 131 | 132 | logger.InfoContext(ctx, "Using output directory", "path", outputBaseDir) 133 | 134 | // Ensure par2 executable exists and get its path 135 | par2ExePath, err := ensurePar2Executable(ctx, cfg, logger) 136 | if err != nil { 137 | return fmt.Errorf("failed to ensure par2 executable: %w", err) 138 | } 139 | // Create the par2 executor 140 | par2Executor := &repairnzb.Par2CmdExecutor{ExePath: par2ExePath} 141 | 142 | uploadPool, downloadPool, err := createPools(cfg) 143 | if err != nil { 144 | return err 145 | } 146 | 147 | defer func() { 148 | logger.DebugContext(ctx, "Closing download pool") 149 | downloadPool.Quit() 150 | // TODO: Add uploadPool.Quit() when fixed in nntppool 151 | // logger.DebugContext(ctx, "Closing upload pool") 152 | // uploadPool.Quit() 153 | }() 154 | 155 | fileScanner := scanner.New(watchDir, dbQueue, logger, cfg.ScanInterval) 156 | eg, gCtx := errgroup.WithContext(ctx) 157 | 158 | // Goroutine for the directory scanner 159 | eg.Go(func() error { 160 | logger.InfoContext(gCtx, "Starting directory scanner...", "directory", watchDir, "interval", cfg.ScanInterval) 161 | err := fileScanner.Run(gCtx) 162 | if err != nil && !errors.Is(err, context.Canceled) { 163 | logger.ErrorContext(gCtx, "Directory scanner failed", "error", err) 164 | return fmt.Errorf("directory scanner error: %w", err) // Return error to errgroup 165 | } 166 | logger.InfoContext(gCtx, "Directory scanner stopped") 167 | return nil 168 | }) 169 | 170 | // Goroutine for the repair worker 171 | eg.Go(func() error { 172 | logger.InfoContext(gCtx, "Starting repair worker...") 173 | workerTicker := time.NewTicker(defaultWorkerInterval) 174 | defer workerTicker.Stop() 175 | 176 | for { 177 | select { 178 | case <-gCtx.Done(): 179 | logger.InfoContext(gCtx, "Repair worker stopping due to context cancellation.") 180 | return gCtx.Err() 181 | case <-workerTicker.C: 182 | job, err := dbQueue.GetNextJob() 183 | if err != nil { 184 | if errors.Is(err, sql.ErrNoRows) { 185 | continue // No jobs available, wait for next tick 186 | } 187 | 188 | logger.ErrorContext(gCtx, "Failed to get next job from queue", "error", err) 189 | time.Sleep(defaultWorkerInterval) // Add a small delay before retrying 190 | continue 191 | } 192 | 193 | logger.InfoContext(gCtx, "Processing job", "job_id", job.ID, "filepath", job.FilePath, "relative_path", job.RelativePath) 194 | 195 | // Calculate output path and handle potential errors 196 | outputFilePath, pathErr := calculateJobOutputPath(outputBaseDir, job, logger, gCtx, dbQueue) 197 | if pathErr != nil { 198 | // Error already logged and status updated in calculateJobOutputPath 199 | continue 200 | } 201 | 202 | // Process the job 203 | err = repairnzb.RepairNzb( 204 | gCtx, 205 | cfg, 206 | downloadPool, 207 | uploadPool, 208 | par2Executor, 209 | job.FilePath, 210 | outputFilePath, 211 | absTmpDir, 212 | ) 213 | 214 | if err != nil { 215 | logger.ErrorContext(gCtx, "Repair failed", "job_id", job.ID, "filepath", job.FilePath, "error", err) 216 | if updateErr := dbQueue.UpdateJobStatus(job.ID, queue.StatusFailed, err.Error()); updateErr != nil { 217 | logger.ErrorContext(gCtx, "Failed to update job status to failed", "job_id", job.ID, "error", updateErr) 218 | } 219 | continue 220 | } 221 | 222 | logger.InfoContext(gCtx, "Repair successful", "job_id", job.ID, "filepath", job.FilePath, "output", outputFilePath) 223 | if updateErr := dbQueue.UpdateJobStatus(job.ID, queue.StatusCompleted, ""); updateErr != nil { 224 | logger.ErrorContext(gCtx, "Failed to update job status to completed", "job_id", job.ID, "error", updateErr) 225 | } 226 | } 227 | } 228 | }) 229 | 230 | // Goroutine for moving failed files 231 | eg.Go(func() error { 232 | logger.InfoContext(gCtx, "Starting failed files mover...", "max_retries", cfg.MaxRetries, "broken_folder", cfg.BrokenFolder) 233 | moverTicker := time.NewTicker(cfg.ScanInterval) 234 | defer moverTicker.Stop() 235 | 236 | for { 237 | select { 238 | case <-gCtx.Done(): 239 | logger.InfoContext(gCtx, "Failed files mover stopping due to context cancellation.") 240 | return gCtx.Err() 241 | case <-moverTicker.C: 242 | movedCount, err := dbQueue.MoveFailedFiles(cfg.MaxRetries, cfg.BrokenFolder) 243 | if err != nil { 244 | logger.ErrorContext(gCtx, "Failed to move failed files", "error", err) 245 | continue 246 | } 247 | if movedCount > 0 { 248 | logger.InfoContext(gCtx, "Moved failed files to broken folder", "count", movedCount) 249 | } 250 | } 251 | } 252 | }) 253 | 254 | logger.InfoContext(ctx, "Watcher and worker started. Waiting for jobs or termination signal (Ctrl+C)...") 255 | // Wait for all goroutines to complete 256 | if err := eg.Wait(); err != nil { 257 | return fmt.Errorf("watcher error: %w", err) 258 | } 259 | 260 | logger.InfoContext(ctx, "Application shut down gracefully.") 261 | return nil 262 | } 263 | 264 | // setupLogging configures the global logger based on the verbosity level. 265 | func setupLogging(verbose bool) *slog.Logger { 266 | var level slog.Level 267 | if verbose { 268 | level = slog.LevelDebug 269 | } else { 270 | level = slog.LevelInfo 271 | } 272 | logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) 273 | slog.SetDefault(logger) 274 | return logger 275 | } 276 | 277 | // prepareTmpDir ensures the temporary directory exists, is clean, and returns its absolute path. 278 | func prepareTmpDir(ctx context.Context, tmpDir string, logger *slog.Logger) (string, error) { 279 | absTmpDir, err := filepath.Abs(tmpDir) 280 | if err != nil { 281 | return "", fmt.Errorf("failed to get absolute path for temporary directory %q: %w", tmpDir, err) 282 | } 283 | 284 | logger.DebugContext(ctx, "Cleaning up and preparing temporary directory...", "path", absTmpDir) 285 | // Attempt to remove existing contents first. Log error but continue. 286 | if err := os.RemoveAll(absTmpDir); err != nil { 287 | logger.WarnContext(ctx, "Failed to remove existing temporary directory contents, attempting to continue", "path", absTmpDir, "error", err) 288 | } 289 | 290 | // Create the directory structure. 291 | if err := os.MkdirAll(absTmpDir, 0750); err != nil { 292 | return "", fmt.Errorf("failed to create temporary directory %q: %w", absTmpDir, err) 293 | } 294 | 295 | return absTmpDir, nil 296 | } 297 | 298 | // ensurePar2Executable checks if a par2 executable is configured, downloads one if necessary, 299 | // and returns the final path to the executable. 300 | func ensurePar2Executable(ctx context.Context, cfg config.Config, logger *slog.Logger) (string, error) { 301 | if cfg.Par2Exe != "" { 302 | logger.DebugContext(ctx, "Using configured Par2 executable", "path", cfg.Par2Exe) 303 | // Verify it exists? 304 | if _, err := os.Stat(cfg.Par2Exe); err == nil { 305 | return cfg.Par2Exe, nil 306 | } else { 307 | logger.WarnContext(ctx, "Configured Par2 executable not found, proceeding to check default/download", "path", cfg.Par2Exe, "error", err) 308 | // Fall through to check default/download 309 | } 310 | } 311 | 312 | // Check default path 313 | if _, err := os.Stat(defaultPar2Exe); err == nil { 314 | logger.InfoContext(ctx, "Par2 executable found in default path, using it.", "path", defaultPar2Exe) 315 | // Update the config in memory if we found it here? Might not be necessary if only path is returned. 316 | // cfg.Par2Exe = defaultPar2Exe // Avoid modifying cfg directly here, just return the path 317 | return defaultPar2Exe, nil 318 | } else if !os.IsNotExist(err) { 319 | // Log unexpected error checking default path, but proceed to download 320 | logger.WarnContext(ctx, "Unexpected error checking for par2 executable at default path", "path", defaultPar2Exe, "error", err) 321 | } 322 | 323 | // Download if not configured and not found in default path 324 | logger.InfoContext(ctx, "No par2 executable configured or found, downloading animetosho/par2cmdline-turbo...") 325 | execPath, err := par2exedownloader.DownloadPar2Cmd() 326 | if err != nil { 327 | return "", fmt.Errorf("failed to download par2cmd: %w", err) 328 | } 329 | logger.InfoContext(ctx, "Downloaded Par2 executable", "path", execPath) 330 | // Update the config in memory? Again, maybe just return the path. 331 | // cfg.Par2Exe = execPath 332 | return execPath, nil 333 | } 334 | 335 | // createPools initializes and returns the NNTP connection pools. 336 | func createPools(cfg config.Config) (uploadPool, downloadPool nntppool.UsenetConnectionPool, err error) { 337 | uploadPool, err = nntppool.NewConnectionPool(nntppool.Config{Providers: cfg.UploadProviders}) 338 | if err != nil { 339 | return nil, nil, fmt.Errorf("failed to create upload pool: %w", err) 340 | } 341 | 342 | downloadPool, err = nntppool.NewConnectionPool(nntppool.Config{Providers: cfg.DownloadProviders}) 343 | if err != nil { 344 | // Make sure to quit the already created uploadPool if downloadPool fails 345 | if uploadPool != nil { 346 | uploadPool.Quit() 347 | } 348 | return nil, nil, fmt.Errorf("failed to create download pool: %w", err) 349 | } 350 | 351 | return uploadPool, downloadPool, nil 352 | } 353 | 354 | // getSingleOutputFilePath determines the output path for a single file repair. 355 | // If outputFileOrDir is empty, it defaults to appending "_repaired" to the input filename. 356 | // If outputFileOrDir is a directory, it places the repaired file inside it. 357 | // If outputFileOrDir is a file path, it uses that path. 358 | func getSingleOutputFilePath(inputFile string, outputFileOrDir string) (string, error) { 359 | if outputFileOrDir == "" { 360 | ext := filepath.Ext(inputFile) 361 | return fmt.Sprintf("%s_repaired%s", strings.TrimSuffix(inputFile, ext), ext), nil 362 | } 363 | 364 | // Check if the output path exists 365 | info, err := os.Stat(outputFileOrDir) 366 | if err != nil { 367 | if os.IsNotExist(err) { 368 | // Doesn't exist. Check if parent directory exists. 369 | parentDir := filepath.Dir(outputFileOrDir) 370 | _, parentErr := os.Stat(parentDir) 371 | if os.IsNotExist(parentErr) { 372 | // Parent dir doesn't exist either, return error. 373 | return "", fmt.Errorf("output directory %q does not exist", parentDir) 374 | } else if parentErr != nil { 375 | // Other error stating parent directory. 376 | return "", fmt.Errorf("failed to stat output directory %q: %w", parentDir, parentErr) 377 | } 378 | // Parent exists, assume outputFileOrDir is the intended full file path. 379 | return outputFileOrDir, nil 380 | } 381 | // Other error stating the output path itself. 382 | return "", fmt.Errorf("failed to stat output path %q: %w", outputFileOrDir, err) 383 | } 384 | 385 | // Output path exists. 386 | if info.IsDir() { 387 | // It's a directory, join with the base name of the input file. 388 | base := filepath.Base(inputFile) 389 | return filepath.Join(outputFileOrDir, base), nil 390 | } 391 | 392 | // It exists and is a file, use it directly. 393 | return outputFileOrDir, nil 394 | } 395 | 396 | // calculateJobOutputPath determines the final path for a repaired file within the watcher's output directory. 397 | // It ensures the relative path is safe and creates necessary subdirectories. 398 | func calculateJobOutputPath(outputBaseDir string, job *queue.Job, logger *slog.Logger, gCtx context.Context, dbQueue *queue.Queue) (string, error) { 399 | // Clean the relative path to prevent path traversal issues (e.g., ../../..) 400 | cleanRelativePath := filepath.Clean(job.RelativePath) 401 | if strings.HasPrefix(cleanRelativePath, "..") || cleanRelativePath == "." || cleanRelativePath == "" || filepath.IsAbs(cleanRelativePath) { 402 | errMsg := fmt.Sprintf("invalid relative path calculated: %q", job.RelativePath) 403 | logger.ErrorContext(gCtx, errMsg, "job_id", job.ID) 404 | if uerr := dbQueue.UpdateJobStatus(job.ID, queue.StatusFailed, errMsg); uerr != nil { 405 | logger.ErrorContext(gCtx, "Failed to update job status to failed after invalid relative path error", "job_id", job.ID, "update_error", uerr) 406 | } 407 | 408 | return "", errors.New(errMsg) 409 | } 410 | outputFilePath := filepath.Join(outputBaseDir, cleanRelativePath) 411 | 412 | // Ensure the subdirectory structure exists within the output directory 413 | outputSubDir := filepath.Dir(outputFilePath) 414 | if err := os.MkdirAll(outputSubDir, 0750); err != nil { 415 | errMsg := fmt.Sprintf("failed to create output subdirectory %q: %v", outputSubDir, err) 416 | logger.ErrorContext(gCtx, errMsg, "job_id", job.ID) 417 | if uerr := dbQueue.UpdateJobStatus(job.ID, queue.StatusFailed, errMsg); uerr != nil { 418 | logger.ErrorContext(gCtx, "Failed to update job status to failed after output subdir error", "job_id", job.ID, "update_error", uerr) 419 | } 420 | 421 | return "", errors.New(errMsg) 422 | } 423 | 424 | return outputFilePath, nil 425 | } 426 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | 4d63.com/gocheckcompilerdirectives v1.3.0 h1:Ew5y5CtcAAQeTVKUVFrE7EwHMrTO6BggtEj8BZSjZ3A= 2 | 4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY= 3 | 4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU= 4 | 4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0= 5 | github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E= 6 | github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI= 7 | github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE= 8 | github.com/Abirdcfly/dupword v0.1.3/go.mod h1:8VbB2t7e10KRNdwTVoxdBaxla6avbhGzb8sCTygUMhw= 9 | github.com/Antonboom/errname v1.1.0 h1:A+ucvdpMwlo/myWrkHEUEBWc/xuXdud23S8tmTb/oAE= 10 | github.com/Antonboom/errname v1.1.0/go.mod h1:O1NMrzgUcVBGIfi3xlVuvX8Q/VP/73sseCaAppfjqZw= 11 | github.com/Antonboom/nilnil v1.1.0 h1:jGxJxjgYS3VUUtOTNk8Z1icwT5ESpLH/426fjmQG+ng= 12 | github.com/Antonboom/nilnil v1.1.0/go.mod h1:b7sAlogQjFa1wV8jUW3o4PMzDVFLbTux+xnQdvzdcIE= 13 | github.com/Antonboom/testifylint v1.6.1 h1:6ZSytkFWatT8mwZlmRCHkWz1gPi+q6UBSbieji2Gj/o= 14 | github.com/Antonboom/testifylint v1.6.1/go.mod h1:k+nEkathI2NFjKO6HvwmSrbzUcQ6FAnbZV+ZRrnXPLI= 15 | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 16 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 17 | github.com/Crocmagnon/fatcontext v0.7.2 h1:BY5/dUhs2kuD3sDn7vZrgOneRib5EHk9GOiyK8Vg+14= 18 | github.com/Crocmagnon/fatcontext v0.7.2/go.mod h1:OAZCUteH59eiddbJZ9/bF4ppC140jYD/hepU2FDkFk4= 19 | github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 h1:sHglBQTwgx+rWPdisA5ynNEsoARbiCBOyGcJM4/OzsM= 20 | github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= 21 | github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 h1:Sz1JIXEcSfhz7fUi7xHnhpIE0thVASYjvosApmHuD2k= 22 | github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1/go.mod h1:n/LSCXNuIYqVfBlVXyHfMQkZDdp1/mmxfSjADd3z1Zg= 23 | github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= 24 | github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 25 | github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= 26 | github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= 27 | github.com/Tensai75/nzbparser v0.1.0 h1:6RppAuWFahqu/kKjWO5Br0xuEYcxGz+XBTxYc+qvPo4= 28 | github.com/Tensai75/nzbparser v0.1.0/go.mod h1:IUIIaeGaYp2dLAAF29BWYeKTfI4COvXaeQAzQiTOfMY= 29 | github.com/Tensai75/subjectparser v0.1.0 h1:6fEWnRov8lDHxJS2EWqY6VonwYfrIRN+k8h8H7fFwHA= 30 | github.com/Tensai75/subjectparser v0.1.0/go.mod h1:PNBFBnkOGbVDfX+56ZmC4GKSpqoRMCF1Y44xYd7NLGI= 31 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 32 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 33 | github.com/alecthomas/chroma/v2 v2.16.0 h1:QC5ZMizk67+HzxFDjQ4ASjni5kWBTGiigRG1u23IGvA= 34 | github.com/alecthomas/chroma/v2 v2.16.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 35 | github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= 36 | github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= 37 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 38 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 39 | github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ= 40 | github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q= 41 | github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw= 42 | github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE= 43 | github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw= 44 | github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= 45 | github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= 46 | github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= 47 | github.com/ashanbrown/forbidigo v1.6.0 h1:D3aewfM37Yb3pxHujIPSpTf6oQk9sc9WZi8gerOIVIY= 48 | github.com/ashanbrown/forbidigo v1.6.0/go.mod h1:Y8j9jy9ZYAEHXdu723cUlraTqbzjKF1MUyfOKL+AjcU= 49 | github.com/ashanbrown/makezero v1.2.0 h1:/2Lp1bypdmK9wDIq7uWBlDF1iMUpIIS4A+pF6C9IEUU= 50 | github.com/ashanbrown/makezero v1.2.0/go.mod h1:dxlPhHbDMC6N6xICzFBSK+4njQDdK8euNO0qjQMtGY4= 51 | github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= 52 | github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= 53 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 54 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 55 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 56 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 57 | github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w= 58 | github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= 59 | github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= 60 | github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= 61 | github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ= 62 | github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg= 63 | github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE= 64 | github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE= 65 | github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg= 66 | github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s= 67 | github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E= 68 | github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70= 69 | github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= 70 | github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI= 71 | github.com/catenacyber/perfsprint v0.9.1 h1:5LlTp4RwTooQjJCvGEFV6XksZvWE7wCOUvjD2z0vls0= 72 | github.com/catenacyber/perfsprint v0.9.1/go.mod h1:q//VWC2fWbcdSLEY1R3l8n0zQCDPdE4IjZwyY1HMunM= 73 | github.com/ccojocar/zxcvbn-go v1.0.2 h1:na/czXU8RrhXO4EZme6eQJLR4PzcGsahsBOAwU6I3Vg= 74 | github.com/ccojocar/zxcvbn-go v1.0.2/go.mod h1:g1qkXtUSvHP8lhHp5GrSmTz6uWALGRMQdw6Qnz/hi60= 75 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 76 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 77 | github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iyoHGPf5w4= 78 | github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ= 79 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 80 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 81 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 82 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 83 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 84 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 85 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 86 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 87 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 88 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 89 | github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc= 90 | github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww= 91 | github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= 92 | github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= 93 | github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs= 94 | github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk= 95 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 96 | github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= 97 | github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= 98 | github.com/daixiang0/gci v0.13.6 h1:RKuEOSkGpSadkGbvZ6hJ4ddItT3cVZ9Vn9Rybk6xjl8= 99 | github.com/daixiang0/gci v0.13.6/go.mod h1:12etP2OniiIdP4q+kjUGrC/rUagga7ODbqsom5Eo5Yk= 100 | github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY= 101 | github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= 102 | github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= 103 | github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= 104 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 105 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 106 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 107 | github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8= 108 | github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= 109 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 110 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 111 | github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= 112 | github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= 113 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 114 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 115 | github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= 116 | github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= 117 | github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E= 118 | github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo= 119 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 120 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 121 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 122 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 123 | github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= 124 | github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= 125 | github.com/ghostiam/protogetter v0.3.13 h1:T4qt1JU0xvx8+jO30+JaA49fngUd6YNajqwk0Rn3t1s= 126 | github.com/ghostiam/protogetter v0.3.13/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= 127 | github.com/go-critic/go-critic v0.13.0 h1:kJzM7wzltQasSUXtYyTl6UaPVySO6GkaR1thFnJ6afY= 128 | github.com/go-critic/go-critic v0.13.0/go.mod h1:M/YeuJ3vOCQDnP2SU+ZhjgRzwzcBW87JqLpMJLrZDLI= 129 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 130 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 131 | github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= 132 | github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= 133 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 134 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 135 | github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8= 136 | github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU= 137 | github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s= 138 | github.com/go-toolsmith/astcopy v1.1.0/go.mod h1:hXM6gan18VA1T/daUEHCFcYiW8Ai1tIwIzHY6srfEAw= 139 | github.com/go-toolsmith/astequal v1.0.3/go.mod h1:9Ai4UglvtR+4up+bAD4+hCj7iTo4m/OXVTSLnCyTAx4= 140 | github.com/go-toolsmith/astequal v1.1.0/go.mod h1:sedf7VIdCL22LD8qIvv7Nn9MuWJruQA/ysswh64lffQ= 141 | github.com/go-toolsmith/astequal v1.2.0 h1:3Fs3CYZ1k9Vo4FzFhwwewC3CHISHDnVUPC4x0bI2+Cw= 142 | github.com/go-toolsmith/astequal v1.2.0/go.mod h1:c8NZ3+kSFtFY/8lPso4v8LuJjdJiUFVnSuU3s0qrrDY= 143 | github.com/go-toolsmith/astfmt v1.1.0 h1:iJVPDPp6/7AaeLJEruMsBUlOYCmvg0MoCfJprsOmcco= 144 | github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlNMV634mhwuQ4= 145 | github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA= 146 | github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA= 147 | github.com/go-toolsmith/pkgload v1.2.2 h1:0CtmHq/02QhxcF7E9N5LIFcYFsMR5rdovfqTtRKkgIk= 148 | github.com/go-toolsmith/pkgload v1.2.2/go.mod h1:R2hxLNRKuAsiXCo2i5J6ZQPhnPMOVtU+f0arbFPWCus= 149 | github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8= 150 | github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw= 151 | github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= 152 | github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus= 153 | github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig= 154 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 155 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 156 | github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY= 157 | github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= 158 | github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 159 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 160 | github.com/gofiber/storage/bbolt v1.3.5 h1:9ZDMTbeah5tfj3eX+hFu3F1AHiBO117ce3Gel7tkxlk= 161 | github.com/gofiber/storage/bbolt v1.3.5/go.mod h1:GibrOAQTFOzzzWWVCgq+V+gS8dUbaPeAMGI4FNZ32sI= 162 | github.com/gofiber/utils v1.0.1 h1:knct4cXwBipWQqFrOy1Pv6UcgPM+EXo9jDgc66V1Qio= 163 | github.com/gofiber/utils v1.0.1/go.mod h1:pacRFtghAE3UoknMOUiXh2Io/nLWSUHtQCi/3QASsOc= 164 | github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= 165 | github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= 166 | github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw= 167 | github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= 168 | github.com/golangci/go-printf-func-name v0.1.0 h1:dVokQP+NMTO7jwO4bwsRwLWeudOVUPPyAKJuzv8pEJU= 169 | github.com/golangci/go-printf-func-name v0.1.0/go.mod h1:wqhWFH5mUdJQhweRnldEywnR5021wTdZSNgwYceV14s= 170 | github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE= 171 | github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY= 172 | github.com/golangci/golangci-lint/v2 v2.1.1 h1:h0S97dn3HpK12QzzPjoPxgMUqweM7m34j/QNV6iVg5g= 173 | github.com/golangci/golangci-lint/v2 v2.1.1/go.mod h1:zJJo4g7aGiCv5cRYa+YUaWVazNBRzn4Kg7hdEKV0iXU= 174 | github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 h1:AkK+w9FZBXlU/xUmBtSJN1+tAI4FIvy5WtnUnY8e4p8= 175 | github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95/go.mod h1:k9mmcyWKSTMcPPvQUCfRWWQ9VHJ1U9Dc0R7kaXAgtnQ= 176 | github.com/golangci/misspell v0.6.0 h1:JCle2HUTNWirNlDIAUO44hUsKhOFqGPoC4LZxlaSXDs= 177 | github.com/golangci/misspell v0.6.0/go.mod h1:keMNyY6R9isGaSAu+4Q8NMBwMPkh15Gtc8UCVoDtAWo= 178 | github.com/golangci/plugin-module-register v0.1.1 h1:TCmesur25LnyJkpsVrupv1Cdzo+2f7zX0H6Jkw1Ol6c= 179 | github.com/golangci/plugin-module-register v0.1.1/go.mod h1:TTpqoB6KkwOJMV8u7+NyXMrkwwESJLOkfl9TxR1DGFc= 180 | github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s= 181 | github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k= 182 | github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM= 183 | github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= 184 | github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= 185 | github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= 186 | github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 187 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 188 | github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 189 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 190 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 191 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 192 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 193 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= 194 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 195 | github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= 196 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 197 | github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= 198 | github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= 199 | github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= 200 | github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= 201 | github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado= 202 | github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= 203 | github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8= 204 | github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc= 205 | github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk= 206 | github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY= 207 | github.com/gostaticanalysis/nilerr v0.1.1 h1:ThE+hJP0fEp4zWLkWHWcRyI2Od0p7DlgYG3Uqrmrcpk= 208 | github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW0HU0GPE3+5PWN4A= 209 | github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= 210 | github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= 211 | github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= 212 | github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= 213 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 214 | github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= 215 | github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= 216 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 217 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 218 | github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= 219 | github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 220 | github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 221 | github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= 222 | github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 223 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 224 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 225 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 226 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 227 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 228 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 229 | github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 230 | github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 231 | github.com/javi11/nntp-server-mock v0.0.1 h1:05KbUlFSnzk82CG/sHWLp9s6Vg8exL3wvdAetX8Bwhc= 232 | github.com/javi11/nntp-server-mock v0.0.1/go.mod h1:+QBpPor0q44ttab8kWo08+/t5C26IHhUpBTC1lZqujA= 233 | github.com/javi11/nntpcli v0.5.0 h1:AS5p/at3i21H7ULsxRMBDSjzgUoID4U6jZJ8yMtZSt8= 234 | github.com/javi11/nntpcli v0.5.0/go.mod h1:dMqGOavcFyeGKQZei+6QYUfn2BF29S1t36B0dA7vnFA= 235 | github.com/javi11/nntppool v0.2.1 h1:cPSgjbK7bCEpoH+nyPFMNcVslxN/SyfOEoEs6ss3qZc= 236 | github.com/javi11/nntppool v0.2.1/go.mod h1:MQUzmyt//R7jK8uc4Ga7puO4N/E1udXefurM7y1e7lE= 237 | github.com/jgautheron/goconst v1.8.1 h1:PPqCYp3K/xlOj5JmIe6O1Mj6r1DbkdbLtR3AJuZo414= 238 | github.com/jgautheron/goconst v1.8.1/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako= 239 | github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= 240 | github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= 241 | github.com/jjti/go-spancheck v0.6.4 h1:Tl7gQpYf4/TMU7AT84MN83/6PutY21Nb9fuQjFTpRRc= 242 | github.com/jjti/go-spancheck v0.6.4/go.mod h1:yAEYdKJ2lRkDA8g7X+oKUHXOWVAXSBJRv04OhF+QUjk= 243 | github.com/jstemmer/go-junit-report/v2 v2.1.0 h1:X3+hPYlSczH9IMIpSC9CQSZA0L+BipYafciZUWHEmsc= 244 | github.com/jstemmer/go-junit-report/v2 v2.1.0/go.mod h1:mgHVr7VUo5Tn8OLVr1cKnLuEy0M92wdRntM99h7RkgQ= 245 | github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= 246 | github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= 247 | github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg= 248 | github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= 249 | github.com/karamaru-alpha/copyloopvar v1.2.1 h1:wmZaZYIjnJ0b5UoKDjUHrikcV0zuPyyxI4SVplLd2CI= 250 | github.com/karamaru-alpha/copyloopvar v1.2.1/go.mod h1:nFmMlFNlClC2BPvNaHMdkirmTJxVCY0lhxBtlfOypMM= 251 | github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M= 252 | github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= 253 | github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE= 254 | github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= 255 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 256 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 257 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 258 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 259 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 260 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 261 | github.com/kulti/thelper v0.6.3 h1:ElhKf+AlItIu+xGnI990no4cE2+XaSu1ULymV2Yulxs= 262 | github.com/kulti/thelper v0.6.3/go.mod h1:DsqKShOvP40epevkFrvIwkCMNYxMeTNjdWL4dqWHZ6I= 263 | github.com/kunwardeep/paralleltest v1.0.14 h1:wAkMoMeGX/kGfhQBPODT/BL8XhK23ol/nuQ3SwFaUw8= 264 | github.com/kunwardeep/paralleltest v1.0.14/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk= 265 | github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4= 266 | github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI= 267 | github.com/ldez/exptostd v0.4.2 h1:l5pOzHBz8mFOlbcifTxzfyYbgEmoUqjxLFHZkjlbHXs= 268 | github.com/ldez/exptostd v0.4.2/go.mod h1:iZBRYaUmcW5jwCR3KROEZ1KivQQp6PHXbDPk9hqJKCQ= 269 | github.com/ldez/gomoddirectives v0.6.1 h1:Z+PxGAY+217f/bSGjNZr/b2KTXcyYLgiWI6geMBN2Qc= 270 | github.com/ldez/gomoddirectives v0.6.1/go.mod h1:cVBiu3AHR9V31em9u2kwfMKD43ayN5/XDgr+cdaFaKs= 271 | github.com/ldez/grignotin v0.9.0 h1:MgOEmjZIVNn6p5wPaGp/0OKWyvq42KnzAt/DAb8O4Ow= 272 | github.com/ldez/grignotin v0.9.0/go.mod h1:uaVTr0SoZ1KBii33c47O1M8Jp3OP3YDwhZCmzT9GHEk= 273 | github.com/ldez/tagliatelle v0.7.1 h1:bTgKjjc2sQcsgPiT902+aadvMjCeMHrY7ly2XKFORIk= 274 | github.com/ldez/tagliatelle v0.7.1/go.mod h1:3zjxUpsNB2aEZScWiZTHrAXOl1x25t3cRmzfK1mlo2I= 275 | github.com/ldez/usetesting v0.4.2 h1:J2WwbrFGk3wx4cZwSMiCQQ00kjGR0+tuuyW0Lqm4lwA= 276 | github.com/ldez/usetesting v0.4.2/go.mod h1:eEs46T3PpQ+9RgN9VjpY6qWdiw2/QmfiDeWmdZdrjIQ= 277 | github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY= 278 | github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= 279 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 280 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 281 | github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE= 282 | github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U= 283 | github.com/manuelarte/funcorder v0.2.1 h1:7QJsw3qhljoZ5rH0xapIvjw31EcQeFbF31/7kQ/xS34= 284 | github.com/manuelarte/funcorder v0.2.1/go.mod h1:BQQ0yW57+PF9ZpjpeJDKOffEsQbxDFKW8F8zSMe/Zd0= 285 | github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI= 286 | github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE= 287 | github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04= 288 | github.com/maratori/testpackage v1.1.1/go.mod h1:s4gRK/ym6AMrqpOa/kEbQTV4Q4jb7WeLZzVhVVVOQMc= 289 | github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= 290 | github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= 291 | github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= 292 | github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 293 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 294 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 295 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 296 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 297 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 298 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 299 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 300 | github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= 301 | github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 302 | github.com/mgechev/revive v1.9.0 h1:8LaA62XIKrb8lM6VsBSQ92slt/o92z5+hTw3CmrvSrM= 303 | github.com/mgechev/revive v1.9.0/go.mod h1:LAPq3+MgOf7GcL5PlWIkHb0PT7XH4NuC2LdWymhb9Mo= 304 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 305 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 306 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 307 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 308 | github.com/mnightingale/rapidyenc v0.0.0-20240809192858-2494683cdd67 h1:fcZS/5xYaIiRVsuPclGYNhVhX1X2Nd2KtGz5ftt6Mcs= 309 | github.com/mnightingale/rapidyenc v0.0.0-20240809192858-2494683cdd67/go.mod h1:SKYCyJoeawOD2xjCqz8pUeW6mMWzouhs8RbbSvEtQws= 310 | github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= 311 | github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= 312 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 313 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 314 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 315 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 316 | github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= 317 | github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= 318 | github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= 319 | github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= 320 | github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= 321 | github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= 322 | github.com/nunnatsa/ginkgolinter v0.19.1 h1:mjwbOlDQxZi9Cal+KfbEJTCz327OLNfwNvoZ70NJ+c4= 323 | github.com/nunnatsa/ginkgolinter v0.19.1/go.mod h1:jkQ3naZDmxaZMXPWaS9rblH+i+GWXQCaS/JFIWcOH2s= 324 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 325 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 326 | github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0= 327 | github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= 328 | github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= 329 | github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= 330 | github.com/opencontainers/selinux v1.12.0 h1:6n5JV4Cf+4y0KNXW48TLj5DwfXpvWlxXplUkdTrmPb8= 331 | github.com/opencontainers/selinux v1.12.0/go.mod h1:BTPX+bjVbWGXw7ZZWUbdENt8w0htPSrlgOOysQaU62U= 332 | github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= 333 | github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= 334 | github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= 335 | github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= 336 | github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= 337 | github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= 338 | github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= 339 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 340 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 341 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 342 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 343 | github.com/polyfloyd/go-errorlint v1.8.0 h1:DL4RestQqRLr8U4LygLw8g2DX6RN1eBJOpa2mzsrl1Q= 344 | github.com/polyfloyd/go-errorlint v1.8.0/go.mod h1:G2W0Q5roxbLCt0ZQbdoxQxXktTjwNyDbEaj3n7jvl4s= 345 | github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= 346 | github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= 347 | github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= 348 | github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= 349 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 350 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 351 | github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= 352 | github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= 353 | github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= 354 | github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= 355 | github.com/quasilyte/go-ruleguard v0.4.4 h1:53DncefIeLX3qEpjzlS1lyUmQoUEeOWPFWqaTJq9eAQ= 356 | github.com/quasilyte/go-ruleguard v0.4.4/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE= 357 | github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE= 358 | github.com/quasilyte/go-ruleguard/dsl v0.3.22/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= 359 | github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo= 360 | github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng= 361 | github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU= 362 | github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0= 363 | github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs= 364 | github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= 365 | github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= 366 | github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU= 367 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 368 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 369 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 370 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 371 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 372 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 373 | github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g= 374 | github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I= 375 | github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU= 376 | github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ= 377 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 378 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 379 | github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= 380 | github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= 381 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= 382 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= 383 | github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= 384 | github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= 385 | github.com/sashamelentyev/usestdlibvars v1.28.0 h1:jZnudE2zKCtYlGzLVreNp5pmCdOxXUzwsMDBkR21cyQ= 386 | github.com/sashamelentyev/usestdlibvars v1.28.0/go.mod h1:9nl0jgOfHKWNFS43Ojw0i7aRoS4j6EBye3YBhmAIRF8= 387 | github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA= 388 | github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= 389 | github.com/securego/gosec/v2 v2.22.3 h1:mRrCNmRF2NgZp4RJ8oJ6yPJ7G4x6OCiAXHd8x4trLRc= 390 | github.com/securego/gosec/v2 v2.22.3/go.mod h1:42M9Xs0v1WseinaB/BmNGO8AVqG8vRfhC2686ACY48k= 391 | github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= 392 | github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 393 | github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= 394 | github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= 395 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 396 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 397 | github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= 398 | github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= 399 | github.com/sonatard/noctx v0.1.0 h1:JjqOc2WN16ISWAjAk8M5ej0RfExEXtkEyExl2hLW+OM= 400 | github.com/sonatard/noctx v0.1.0/go.mod h1:0RvBxqY8D4j9cTTTWE8ylt2vqj2EPI8fHmrxHdsaZ2c= 401 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 402 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 403 | github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0= 404 | github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= 405 | github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= 406 | github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= 407 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 408 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 409 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 410 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 411 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 412 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 413 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 414 | github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY= 415 | github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 416 | github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= 417 | github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= 418 | github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4= 419 | github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk= 420 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 421 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 422 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 423 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 424 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 425 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 426 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 427 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 428 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 429 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 430 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 431 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 432 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 433 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 434 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 435 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 436 | github.com/tdakkota/asciicheck v0.4.1 h1:bm0tbcmi0jezRA2b5kg4ozmMuGAFotKI3RZfrhfovg8= 437 | github.com/tdakkota/asciicheck v0.4.1/go.mod h1:0k7M3rCfRXb0Z6bwgvkEIMleKH3kXNz9UqJ9Xuqopr8= 438 | github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= 439 | github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= 440 | github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= 441 | github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= 442 | github.com/tetafro/godot v1.5.0 h1:aNwfVI4I3+gdxjMgYPus9eHmoBeJIbnajOyqZYStzuw= 443 | github.com/tetafro/godot v1.5.0/go.mod h1:2oVxTBSftRTh4+MVfUaUXR6bn2GDXCaMcOG4Dk3rfio= 444 | github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk= 445 | github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460= 446 | github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= 447 | github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8= 448 | github.com/tomarrell/wrapcheck/v2 v2.11.0 h1:BJSt36snX9+4WTIXeJ7nvHBQBcm1h2SjQMSlmQ6aFSU= 449 | github.com/tomarrell/wrapcheck/v2 v2.11.0/go.mod h1:wFL9pDWDAbXhhPZZt+nG8Fu+h29TtnZ2MW6Lx4BRXIU= 450 | github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= 451 | github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= 452 | github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI= 453 | github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA= 454 | github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g= 455 | github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8= 456 | github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYRA= 457 | github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU= 458 | github.com/uudashr/iface v1.3.1 h1:bA51vmVx1UIhiIsQFSNq6GZ6VPTk3WNMZgRiCe9R29U= 459 | github.com/uudashr/iface v1.3.1/go.mod h1:4QvspiRd3JLPAEXBQ9AiZpLbJlrWWgRChOKDJEuQTdg= 460 | github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM= 461 | github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4= 462 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 463 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 464 | github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= 465 | github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk= 466 | github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs= 467 | github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4= 468 | github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw= 469 | github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg= 470 | github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 471 | github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 472 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 473 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 474 | github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 475 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 476 | gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= 477 | gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= 478 | go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= 479 | go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= 480 | go-simpler.org/musttag v0.13.0 h1:Q/YAW0AHvaoaIbsPj3bvEI5/QFP7w696IMUpnKXQfCE= 481 | go-simpler.org/musttag v0.13.0/go.mod h1:FTzIGeK6OkKlUDVpj0iQUXZLUO1Js9+mvykDQy9C5yM= 482 | go-simpler.org/sloglint v0.11.0 h1:JlR1X4jkbeaffiyjLtymeqmGDKBDO1ikC6rjiuFAOco= 483 | go-simpler.org/sloglint v0.11.0/go.mod h1:CFDO8R1i77dlciGfPEPvYke2ZMx4eyGiEIWkyeW2Pvw= 484 | go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI= 485 | go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE= 486 | go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= 487 | go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= 488 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 489 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 490 | go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= 491 | go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= 492 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 493 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 494 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 495 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 496 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 497 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 498 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 499 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 500 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 501 | golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= 502 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= 503 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= 504 | golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= 505 | golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= 506 | golang.org/x/exp/typeparams v0.0.0-20250305212735-054e65f0b394 h1:VI4qDpTkfFaCXEPrbojidLgVQhj2x4nzTccG0hjaLlU= 507 | golang.org/x/exp/typeparams v0.0.0-20250305212735-054e65f0b394/go.mod h1:LKZHyeOpPuZcMgxeHjJp4p5yvxrCX1xDvH10zYHhjjQ= 508 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 509 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 510 | golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 511 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 512 | golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 513 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 514 | golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 515 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 516 | golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 517 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 518 | golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 519 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 520 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 521 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 522 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 523 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 524 | golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 525 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 526 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 527 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 528 | golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 529 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 530 | golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 531 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 532 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 533 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 534 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 535 | golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 536 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 537 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 538 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 539 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 540 | golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 541 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 542 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 543 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 544 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 545 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 546 | golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 547 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 548 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 549 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 550 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 551 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 552 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 553 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 554 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 555 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 556 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 557 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 558 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 559 | golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 560 | golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 561 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 562 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 563 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 564 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 565 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 566 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 567 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 568 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 569 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 570 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 571 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 572 | golang.org/x/telemetry v0.0.0-20250310203348-fdfaad844314 h1:UY+gQAskx5vohcvUlJDKkJPt9lALCgtZs3rs8msRatU= 573 | golang.org/x/telemetry v0.0.0-20250310203348-fdfaad844314/go.mod h1:16eI1RtbPZAEm3u7hpIh7JM/w5AbmlDtnrdKYaREic8= 574 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 575 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 576 | golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 577 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 578 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 579 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 580 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 581 | golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= 582 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 583 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 584 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 585 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 586 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 587 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 588 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 589 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 590 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 591 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 592 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 593 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 594 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 595 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 596 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 597 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 598 | golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= 599 | golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= 600 | golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 601 | golang.org/x/tools v0.0.0-20200820010801-b793a1359eac/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= 602 | golang.org/x/tools v0.0.0-20201023174141-c8cfbd0f21e6/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 603 | golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= 604 | golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= 605 | golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 606 | golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 607 | golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 608 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 609 | golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= 610 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 611 | golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= 612 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 613 | golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= 614 | golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= 615 | golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= 616 | golang.org/x/vuln v1.1.4 h1:Ju8QsuyhX3Hk8ma3CesTbO8vfJD9EvUBgHvkxHBzj0I= 617 | golang.org/x/vuln v1.1.4/go.mod h1:F+45wmU18ym/ca5PLTPLsSzr2KppzswxPP603ldA67s= 618 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 619 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 620 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 621 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 622 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 623 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 624 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 625 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 626 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 627 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 628 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 629 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 630 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 631 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 632 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 633 | honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= 634 | honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= 635 | mvdan.cc/gofumpt v0.7.0 h1:bg91ttqXmi9y2xawvkuMXyvAA/1ZGJqYAEGjXuP0JXU= 636 | mvdan.cc/gofumpt v0.7.0/go.mod h1:txVFJy/Sc/mvaycET54pV8SW8gWxTlUuGHVEcncmNUo= 637 | mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 h1:WjUu4yQoT5BHT1w8Zu56SP8367OuBV5jvo+4Ulppyf8= 638 | mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4/go.mod h1:rthT7OuvRbaGcd5ginj6dA2oLE7YNlta9qhBNNdCaLE= 639 | --------------------------------------------------------------------------------