33 Commits
v1.2.2 ... main

Author SHA1 Message Date
dependabot[bot]
064158323c Bump @typescript-eslint/parser from 8.49.0 to 8.50.0 (#93)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.49.0 to 8.50.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.50.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.50.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TheLegendTubaGuy <95944177+thelegendtubaguy@users.noreply.github.com>
2025-12-15 23:27:36 +00:00
TheLegendTubaGuy
4f1c44517e Squash em 2025-12-15 17:25:58 -06:00
TheLegendTubaGuy
4eb6e50ec3 Allow minor updates 2025-12-15 17:24:32 -06:00
TheLegendTubaGuy
d64d8ce20e Fix yaml 2025-12-15 17:19:08 -06:00
TheLegendTubaGuy
2e7c93dca5 Fixed workflows? (#96)
* Fixed workflows?

* One to rule them all
2025-12-15 17:17:35 -06:00
TheLegendTubaGuy
7fa8f457b2 Automerge (#95)
* Attempting to get automerge working

* Fix syntax
2025-12-15 16:58:52 -06:00
TheLegendTubaGuy
7ff36eb2e5 Dependabot automerge (#94)
* Testing auto merge

* Update v4 action

* Short circuit when only .github touches
2025-12-15 16:48:12 -06:00
dependabot[bot]
0319dbf407 Bump typescript-eslint from 8.49.0 to 8.50.0 (#92)
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.49.0 to 8.50.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.50.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.50.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TheLegendTubaGuy <95944177+thelegendtubaguy@users.noreply.github.com>
2025-12-15 16:14:18 -06:00
dependabot[bot]
a49b42227f Bump @types/node from 24.10.4 to 25.0.2 (#91)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.10.4 to 25.0.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TheLegendTubaGuy <95944177+thelegendtubaguy@users.noreply.github.com>
2025-12-15 16:12:51 -06:00
dependabot[bot]
1cb0d9874f Bump actions/checkout from 5 to 6 (#81)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TheLegendTubaGuy <95944177+thelegendtubaguy@users.noreply.github.com>
2025-12-15 16:09:39 -06:00
TheLegendTubaGuy
2973b0421c Update deps (#90)
* Updated deps

* Bump version
2025-12-15 09:47:31 -06:00
TheLegendTubaGuy
bd7c049ca2 Update deps (#80) 2025-11-20 05:05:49 -06:00
TheLegendTubaGuy
582f73b549 Add rule for eipsode count file mismatch (#75)
* Added rule for episode count mismatch

Close #70

* Updated deps
2025-11-10 16:12:00 -06:00
TheLegendTubaGuy
da36319c89 Updates deps (#69) 2025-11-04 11:58:16 -06:00
TheLegendTubaGuy
cdb6004759 Updated deps (#62) 2025-10-27 23:57:36 -05:00
TheLegendTubaGuy
c041d9eb4f Fix misspelling and allow previous spelled env var (#61)
Close #58
2025-10-27 23:56:18 -05:00
TheLegendTubaGuy
ca69e01d85 Adds support for multiple sonarr hosts (#60)
* Close #57 Adds support for multiple sonarr hosts

* Address potential logging of sensitive info
2025-10-27 23:49:07 -05:00
dependabot[bot]
9ada6bd6ac Bump axios from 1.12.2 to 1.13.0 (#59)
Bumps [axios](https://github.com/axios/axios) from 1.12.2 to 1.13.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.12.2...v1.13.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 23:09:57 -05:00
dependabot[bot]
10dc538893 Bump @typescript-eslint/eslint-plugin from 8.46.0 to 8.46.1 (#55)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.46.0 to 8.46.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.1/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.46.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-26 12:43:35 -05:00
dependabot[bot]
5a4f54104f Bump @typescript-eslint/parser from 8.46.1 to 8.46.2 (#53)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.46.1 to 8.46.2.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.2/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.46.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-26 03:40:34 -05:00
dependabot[bot]
32faed4c39 Bump typescript-eslint from 8.46.1 to 8.46.2 (#51)
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.46.1 to 8.46.2.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.2/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.46.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-25 01:12:39 -05:00
dependabot[bot]
d40fb49e1e Bump @types/node from 24.7.2 to 24.9.0 (#50)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.7.2 to 24.9.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-24 22:12:00 -05:00
dependabot[bot]
c624aff0e0 Bump eslint from 9.37.0 to 9.38.0 (#49)
Bumps [eslint](https://github.com/eslint/eslint) from 9.37.0 to 9.38.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.37.0...v9.38.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.38.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-24 22:11:40 -05:00
dependabot[bot]
3b85e6a527 Bump node from 24-alpine to 25-alpine (#54)
Bumps node from 24-alpine to 25-alpine.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 25-alpine
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-24 00:26:26 -05:00
dependabot[bot]
a2e43d9bca Bump actions/setup-node from 5 to 6 (#52)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-24 00:26:11 -05:00
dependabot[bot]
ebe3b20399 Bump @eslint/js from 9.37.0 to 9.38.0 (#56)
Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.37.0 to 9.38.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v9.38.0/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.38.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-24 00:25:54 -05:00
dependabot[bot]
48963a9701 Bump github/codeql-action from 3 to 4 (#48)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-16 09:54:51 -05:00
dependabot[bot]
8c9644cc61 Bump typescript-eslint from 8.46.0 to 8.46.1 (#47)
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.46.0 to 8.46.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.46.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-16 09:54:34 -05:00
dependabot[bot]
1001ca45b1 Bump @typescript-eslint/parser from 8.46.0 to 8.46.1 (#46)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.46.0 to 8.46.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.1/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.46.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-15 09:20:30 -05:00
dependabot[bot]
97b4598c12 Bump ts-jest from 29.4.4 to 29.4.5 (#45)
Bumps [ts-jest](https://github.com/kulshekhar/ts-jest) from 29.4.4 to 29.4.5.
- [Release notes](https://github.com/kulshekhar/ts-jest/releases)
- [Changelog](https://github.com/kulshekhar/ts-jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/kulshekhar/ts-jest/compare/v29.4.4...v29.4.5)

---
updated-dependencies:
- dependency-name: ts-jest
  dependency-version: 29.4.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 13:54:36 -05:00
dependabot[bot]
8696214a56 Bump @types/node from 24.7.1 to 24.7.2 (#44)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.7.1 to 24.7.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 12:14:46 -05:00
TheLegendTubaGuy
f92f62dd5f Updated version in package (#43) 2025-10-09 15:10:17 -05:00
TheLegendTubaGuy
78c4c3c0e6 Added a rule for "not an upgrade" (#42) 2025-10-09 15:06:10 -05:00
18 changed files with 1492 additions and 806 deletions

View File

@@ -9,8 +9,11 @@ REMOVE_ARCHIVE_BLOCKED=false
BLOCK_REMOVED_ARCHIVE_RELEASES=false
REMOVE_NO_FILES_RELEASES=false
BLOCK_REMOVED_NO_FILES_RELEASES=false
REMOVE_NOT_AN_UPGRADE=false
REMOVE_SERIES_ID_MISMATCH=false
BLOCK_REMOVED_SERIES_ID_MISMATCH_RELEASES=false
REMOVE_EPISODE_COUNT_MISMATCH=false
BLOCK_REMOVED_EPISODE_COUNT_MISMATCH_RELEASES=false
REMOVE_UNDETERMINED_SAMPLE=false
BLOCK_REMOVED_UNDETERMIND_SAMPLE=false

3
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,3 @@
* @thelegendtubaguy
package.json
pnpm-lock.yaml

View File

@@ -3,36 +3,49 @@ name: CI
on:
pull_request:
branches: [main]
paths-ignore:
- '.github/**'
push:
branches: [main]
paths-ignore:
- '.github/**'
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- id: changes
uses: dorny/paths-filter@v3
with:
filters: |
run_ci:
- '!**/.github/**'
- name: Skip (only .github changes)
if: steps.changes.outputs.run_ci != 'true'
run: echo "Skipping CI for .github-only change."
- uses: pnpm/action-setup@v4
if: steps.changes.outputs.run_ci == 'true'
with:
version: latest
- uses: actions/setup-node@v5
- uses: actions/setup-node@v6
if: steps.changes.outputs.run_ci == 'true'
with:
node-version: '22'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
if: steps.changes.outputs.run_ci == 'true'
- name: Lint
if: steps.changes.outputs.run_ci == 'true'
run: pnpm lint
- name: Test
if: steps.changes.outputs.run_ci == 'true'
run: pnpm test
- name: Build
if: steps.changes.outputs.run_ci == 'true'
run: pnpm build

View File

@@ -3,10 +3,6 @@ name: CodeQL
on:
pull_request:
branches: [ main ]
paths:
- 'src/**'
- 'package.json'
- 'pnpm-lock.yaml'
schedule:
- cron: '0 2 * * 1' # Weekly on Mondays
@@ -19,15 +15,30 @@ jobs:
security-events: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript
- id: changes
if: github.event_name == 'pull_request'
uses: dorny/paths-filter@v3
with:
filters: |
run_analysis:
- '!**/.github/**'
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Skip (only .github changes)
if: github.event_name == 'pull_request' && steps.changes.outputs.run_analysis != 'true'
run: echo "Skipping CodeQL for .github-only change."
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
- name: Initialize CodeQL
if: github.event_name != 'pull_request' || steps.changes.outputs.run_analysis == 'true'
uses: github/codeql-action/init@v4
with:
languages: javascript
- name: Autobuild
if: github.event_name != 'pull_request' || steps.changes.outputs.run_analysis == 'true'
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
if: github.event_name != 'pull_request' || steps.changes.outputs.run_analysis == 'true'
uses: github/codeql-action/analyze@v4

34
.github/workflows/dependabot-auto.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Dependabot auto-approve and merge
on:
pull_request:
permissions:
contents: write
pull-requests: write
jobs:
dependabot:
name: Auto approve and merge Dependabot dev updates
runs-on: ubuntu-latest
if: github.event.pull_request.user.login == 'dependabot[bot]'
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@d7267f607e9d3fb96fc2fbe83e0af444713e90b7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Approve dev dependency PR
if: contains(steps.metadata.outputs.dependency-type, 'development')
run: gh pr review --approve "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Enable auto-merge for dev dependency patch/minor updates
if: contains(steps.metadata.outputs.dependency-type, 'development') && (steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor')
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -14,7 +14,7 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3

View File

@@ -3,16 +3,8 @@ name: Security
on:
push:
branches: [ main ]
paths:
- 'src/**'
- 'package.json'
- 'pnpm-lock.yaml'
pull_request:
branches: [ main ]
paths:
- 'src/**'
- 'package.json'
- 'pnpm-lock.yaml'
schedule:
- cron: '0 6 * * 1' # Weekly on Mondays
@@ -20,20 +12,36 @@ jobs:
dependency-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '22'
- id: changes
if: github.event_name != 'schedule'
uses: dorny/paths-filter@v3
with:
filters: |
run_scan:
- '!**/.github/**'
- name: Skip (only .github changes)
if: github.event_name != 'schedule' && steps.changes.outputs.run_scan != 'true'
run: echo "Skipping security audit for .github-only change."
- name: Setup Node.js
if: github.event_name == 'schedule' || steps.changes.outputs.run_scan == 'true'
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Setup pnpm
if: github.event_name == 'schedule' || steps.changes.outputs.run_scan == 'true'
uses: pnpm/action-setup@v4
with:
version: latest
- name: Install dependencies
run: pnpm install
- name: Install dependencies
if: github.event_name == 'schedule' || steps.changes.outputs.run_scan == 'true'
run: pnpm install
- name: Run security audit
run: pnpm audit --audit-level moderate
- name: Run security audit
if: github.event_name == 'schedule' || steps.changes.outputs.run_scan == 'true'
run: pnpm audit --audit-level moderate

View File

@@ -1,4 +1,4 @@
FROM node:24-alpine
FROM node:25-alpine
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \

View File

@@ -15,24 +15,56 @@ Automated queue cleaner for Sonarr that removes stuck downloads based on configu
| Variable | Default | Description |
|----------|---------|-------------|
| `SONARR_HOST` | `http://localhost:8989` | Sonarr instance URL |
| `SONARR_API_KEY` | *required* | Sonarr API key |
| `SONARR_INSTANCES` | | JSON array of Sonarr instances (see below) |
| `SONARR_INSTANCES_FILE` | | Path to JSON/YAML file containing Sonarr instances |
| `SONARR_HOST` | `http://localhost:8989` | Legacy single-instance Sonarr URL (ignored when instances are provided) |
| `SONARR_API_KEY` | *required for legacy mode* | Legacy single-instance Sonarr API key |
| `REMOVE_QUALITY_BLOCKED` | `false` | Remove items blocked by quality rules |
| `BLOCK_REMOVED_QUALITY_RELEASES` | `false` | Add quality-blocked items to blocklist |
| `REMOVE_ARCHIVE_BLOCKED` | `false` | Remove items stuck due to archive files |
| `BLOCK_REMOVED_ARCHIVE_RELEASES` | `false` | Add archive-blocked items to blocklist |
| `REMOVE_NO_FILES_RELEASES` | `false` | Remove items with no eligible files |
| `BLOCK_REMOVED_NO_FILES_RELEASES` | `false` | Add no-files items to blocklist |
| `REMOVE_NOT_AN_UPGRADE` | `false` | Remove items flagged as "Not an upgrade" |
| `REMOVE_SERIES_ID_MISMATCH` | `false` | Remove items with series ID matching conflicts |
| `BLOCK_REMOVED_SERIES_ID_MISMATCH_RELEASES` | `false` | Add series ID mismatch items to blocklist |
| `REMOVE_EPISODE_COUNT_MISMATCH` | `false` | Remove items where the on-disk file spans more episodes than the release |
| `BLOCK_REMOVED_EPISODE_COUNT_MISMATCH_RELEASES` | `false` | Add episode-count mismatch items to blocklist |
| `REMOVE_UNDETERMINED_SAMPLE` | `false` | Remove items unable to determine if file is a sample |
| `BLOCK_REMOVED_UNDETERMIND_SAMPLE` | `false` | Add undetermined sample items to blocklist |
| `BLOCK_REMOVED_UNDETERMINED_SAMPLE` | `false` | Add undetermined sample items to blocklist |
| `DRY_RUN` | `false` | Log actions without actually removing/blocking items |
| `SCHEDULE` | `*/5 * * * *` | Cron schedule (every 5 minutes) |
| `LOG_LEVEL` | `info` | Logging level |
**Note:** No rules are configured by default for safety, you must opt in to using them.
### Multiple Sonarr Instances
ArrQueueCleaner can cycle through multiple Sonarr instances in a single process. Configure instances using either:
1. A structured environment variable:
```bash
export SONARR_INSTANCES='[
{"name":"HD Shows","host":"http://sonarr-hd:8989","apiKey":"hd-key"},
{"name":"4K Shows","host":"http://sonarr-4k:8989","apiKey":"4k-key","rules":{"removeNotAnUpgrade":true}}
]'
```
2. A JSON or YAML file referenced by `SONARR_INSTANCES_FILE`:
```bash
export SONARR_INSTANCES_FILE=/config/sonarr-instances.json
```
Each entry requires `host` and `apiKey`, and supports optional fields:
- `name`: Friendly identifier used in logs; defaults to `Sonarr {index}` when omitted.
- `enabled`: Toggle an instance without removing it (defaults to `true`).
- `rules`: Partial rule overrides merged with the global rule settings for just that instance.
If neither `SONARR_INSTANCES` nor `SONARR_INSTANCES_FILE` is supplied, ArrQueueCleaner falls back to the existing `SONARR_HOST` / `SONARR_API_KEY` variables for single-instance deployments.
## Quick Start
1. Copy `.env.example` to `.env` and configure
@@ -48,6 +80,7 @@ pnpm start
## Docker Compose Example
Here we've set some recommended rules to true for those copy/pasting this config.
```yaml
version: '3.8'
@@ -55,21 +88,27 @@ services:
arr-queue-cleaner:
image: ghcr.io/thelegendtubaguy/arrqueuecleaner:latest
environment:
- SONARR_HOST=http://sonarr:8989
- SONARR_API_KEY=your_api_key_here
- REMOVE_QUALITY_BLOCKED=false
- BLOCK_REMOVED_QUALITY_RELEASES=false
- REMOVE_ARCHIVE_BLOCKED=false
- BLOCK_REMOVED_ARCHIVE_RELEASES=false
- REMOVE_NO_FILES_RELEASES=false
- BLOCK_REMOVED_NO_FILES_RELEASES=false
- REMOVE_SERIES_ID_MISMATCH=false
- BLOCK_REMOVED_SERIES_ID_MISMATCH_RELEASES=false
- REMOVE_UNDETERMINED_SAMPLE=false
- BLOCK_REMOVED_UNDETERMIND_SAMPLE=false
- DRY_RUN=false
- SCHEDULE=*/5 * * * *
- LOG_LEVEL=info
SONARR_INSTANCES: >-
[
{"name":"HD Shows","host":"http://sonarr:8989","apiKey":"your_hd_api_key"},
{"name":"4K Shows","host":"http://sonarr-4k:8989","apiKey":"your_4k_api_key","rules":{"removeNotAnUpgrade":true}}
]
REMOVE_QUALITY_BLOCKED: 'true'
BLOCK_REMOVED_QUALITY_RELEASES: 'false'
REMOVE_ARCHIVE_BLOCKED: 'true'
BLOCK_REMOVED_ARCHIVE_RELEASES: 'false'
REMOVE_NO_FILES_RELEASES: 'true'
BLOCK_REMOVED_NO_FILES_RELEASES: 'true'
REMOVE_NOT_AN_UPGRADE: 'true'
REMOVE_SERIES_ID_MISMATCH: 'true'
BLOCK_REMOVED_SERIES_ID_MISMATCH_RELEASES: 'false'
REMOVE_EPISODE_COUNT_MISMATCH: 'false'
BLOCK_REMOVED_EPISODE_COUNT_MISMATCH_RELEASES: 'false'
REMOVE_UNDETERMINED_SAMPLE: 'false'
BLOCK_REMOVED_UNDETERMINED_SAMPLE: 'false'
DRY_RUN: 'false'
SCHEDULE: '*/5 * * * *'
LOG_LEVEL: 'info'
restart: unless-stopped
```

View File

@@ -1,6 +1,6 @@
{
"name": "arr-queue-cleaner",
"version": "1.2.2",
"version": "1.4.2",
"description": "Automated queue cleaner for Sonarr and Radarr",
"main": "dist/index.js",
"scripts": {
@@ -13,22 +13,23 @@
"lint:fix": "eslint . --fix"
},
"dependencies": {
"axios": "^1.12.2",
"cron": "^4.3.3",
"dotenv": "^17.2.3"
"axios": "^1.13.2",
"cron": "^4.4.0",
"dotenv": "^17.2.3",
"yaml": "^2.8.2"
},
"devDependencies": {
"@eslint/js": "^9.37.0",
"@eslint/js": "^9.39.2",
"@types/jest": "^30.0.0",
"@types/node": "^24.7.0",
"@typescript-eslint/eslint-plugin": "^8.46.0",
"@typescript-eslint/parser": "^8.46.0",
"eslint": "^9.37.0",
"@types/node": "^25.0.2",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.50.0",
"eslint": "^9.39.2",
"jest": "^30.2.0",
"ts-jest": "^29.4.4",
"tsx": "^4.20.6",
"ts-jest": "^29.4.6",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.0"
"typescript-eslint": "^8.50.0"
},
"engines": {
"node": ">=22"

1242
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,44 @@
import { SonarrClient } from './sonarr';
import { Config, QueueItem, RuleMatch } from './types';
import { QueueItem, RuleMatch, RuleConfig, SonarrInstanceConfig } from './types';
export interface QueueCleanerOptions {
instance: SonarrInstanceConfig;
rules: RuleConfig;
dryRun: boolean;
logLevel: string;
}
export class QueueCleaner {
private config: Config;
private sonarr: SonarrClient;
private readonly instance: SonarrInstanceConfig;
private readonly rules: RuleConfig;
private readonly dryRun: boolean;
private readonly logLevel: string;
private readonly sonarr: SonarrClient;
constructor(config: Config) {
this.config = config;
this.sonarr = new SonarrClient(config.sonarr.host, config.sonarr.apiKey, config.logLevel);
constructor(options: QueueCleanerOptions) {
this.instance = options.instance;
this.rules = options.rules;
this.dryRun = options.dryRun;
this.logLevel = options.logLevel;
this.sonarr = new SonarrClient(this.instance.host, this.instance.apiKey, this.logLevel);
}
private log(level: string, message: string, data?: unknown): void {
if (level === 'debug' && this.config.logLevel !== 'debug') { return; }
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: unknown): void {
if (level === 'debug' && this.logLevel !== 'debug') { return; }
const output = data ? `${message}: ${JSON.stringify(data)}` : message;
console.log(`[${level.toUpperCase()}] ${output}`);
const prefix = `[${level.toUpperCase()}] [${this.instance.name}] ${output}`;
if (level === 'error') {
console.error(prefix);
} else if (level === 'warn') {
console.warn(prefix);
} else {
console.log(prefix);
}
}
async cleanQueue(): Promise<void> {
if (!this.config.sonarr.enabled) { return; }
if (!this.instance.enabled) { return; }
try {
const queue = await this.sonarr.getQueue();
@@ -44,10 +65,10 @@ export class QueueCleaner {
}
if (downloadGroups.size > 0) {
console.log(`Processed ${downloadGroups.size} downloads (${itemsToProcess.length} queue items)`);
this.log('info', `Processed ${downloadGroups.size} downloads (${itemsToProcess.length} queue items)`);
}
} catch (error) {
console.error('Error cleaning queue:', (error as Error).message);
this.log('error', 'Error cleaning queue', (error as Error).message);
}
}
@@ -71,29 +92,39 @@ export class QueueCleaner {
if (!msg.messages?.length) { continue; }
for (const message of msg.messages) {
if (this.config.rules.removeQualityBlocked && message.includes('upgrade for existing episode')) {
if (this.rules.removeQualityBlocked && message.includes('upgrade for existing episode')) {
this.log('debug', 'Item matched quality rule', item.title);
return { type: 'quality', shouldBlock: this.config.rules.blockRemovedQualityReleases };
return { type: 'quality', shouldBlock: this.rules.blockRemovedQualityReleases };
}
if (this.config.rules.removeArchiveBlocked && message.includes('archive file')) {
if (this.rules.removeArchiveBlocked && message.includes('archive file')) {
this.log('debug', 'Item matched archive rule', item.title);
return { type: 'archive', shouldBlock: this.config.rules.blockRemovedArchiveReleases };
return { type: 'archive', shouldBlock: this.rules.blockRemovedArchiveReleases };
}
if (this.config.rules.removeNoFilesReleases && message.includes('No files found are eligible')) {
if (this.rules.removeNoFilesReleases && message.includes('No files found are eligible')) {
this.log('debug', 'Item matched no files rule', item.title);
return { type: 'noFiles', shouldBlock: this.config.rules.blockRemovedNoFilesReleases };
return { type: 'noFiles', shouldBlock: this.rules.blockRemovedNoFilesReleases };
}
if (this.config.rules.removeSeriesIdMismatch && message.includes('Found matching series via grab history, but release was matched to series by ID')) {
if (this.rules.removeNotAnUpgrade && message.includes('Not an upgrade')) {
this.log('debug', 'Item matched not an upgrade rule', item.title);
return { type: 'notAnUpgrade', shouldBlock: false };
}
if (this.rules.removeSeriesIdMismatch && message.includes('Found matching series via grab history, but release was matched to series by ID')) {
this.log('debug', 'Item matched series ID mismatch rule', item.title);
return { type: 'seriesIdMismatch', shouldBlock: this.config.rules.blockRemovedSeriesIdMismatchReleases };
return { type: 'seriesIdMismatch', shouldBlock: this.rules.blockRemovedSeriesIdMismatchReleases };
}
if (this.config.rules.removeUndeterminedSample && message.includes('Unable to determine if file is a sample')) {
if (this.rules.removeEpisodeCountMismatch && message.includes('Episode file on disk contains more episodes than this file contains')) {
this.log('debug', 'Item matched episode count mismatch rule', item.title);
return { type: 'episodeCountMismatch', shouldBlock: this.rules.blockRemovedEpisodeCountMismatchReleases };
}
if (this.rules.removeUndeterminedSample && message.includes('Unable to determine if file is a sample')) {
this.log('debug', 'Item matched undetermined sample rule', item.title);
return { type: 'undeterminedSample', shouldBlock: this.config.rules.blockRemovedUndeterminedSampleReleases };
return { type: 'undeterminedSample', shouldBlock: this.rules.blockRemovedUndeterminedSampleReleases };
}
}
}
@@ -103,24 +134,24 @@ export class QueueCleaner {
private async processItem(item: QueueItem, rule: RuleMatch): Promise<void> {
try {
if (this.config.dryRun) {
if (this.dryRun) {
if (rule.shouldBlock) {
console.log(`[DRY RUN] Would block and remove (${rule.type}): ${item.title}`);
this.log('info', `[DRY RUN] Would block and remove (${rule.type}): ${item.title}`);
} else {
console.log(`[DRY RUN] Would remove (${rule.type}): ${item.title}`);
this.log('info', `[DRY RUN] Would remove (${rule.type}): ${item.title}`);
}
return;
}
if (rule.shouldBlock) {
await this.sonarr.blockRelease(item.id);
console.log(`Blocked and removed (${rule.type}): ${item.title}`);
this.log('info', `Blocked and removed (${rule.type}): ${item.title}`);
} else {
await this.sonarr.removeFromQueue(item.id);
console.log(`Removed (${rule.type}): ${item.title}`);
this.log('info', `Removed (${rule.type}): ${item.title}`);
}
} catch (error) {
console.error(`Error processing ${item.title}:`, (error as Error).message);
this.log('error', `Error processing ${item.title}`, (error as Error).message);
}
}
}

View File

@@ -1,34 +1,224 @@
import fs from 'fs';
import path from 'path';
import { config as dotenvConfig } from 'dotenv';
import { Config } from './types';
import { Config, RuleConfig, SonarrInstanceConfig } from './types';
dotenvConfig();
const parseBooleanEnv = (key: string): boolean => process.env[key] === 'true';
const getNormalizedEnvBoolean = (keys: string[]): boolean => {
for (const key of keys) {
if (process.env[key] !== undefined) {
return parseBooleanEnv(key);
}
}
return false;
};
const rulesFromEnv: RuleConfig = {
removeQualityBlocked: parseBooleanEnv('REMOVE_QUALITY_BLOCKED'),
blockRemovedQualityReleases: parseBooleanEnv('BLOCK_REMOVED_QUALITY_RELEASES'),
removeArchiveBlocked: parseBooleanEnv('REMOVE_ARCHIVE_BLOCKED'),
blockRemovedArchiveReleases: parseBooleanEnv('BLOCK_REMOVED_ARCHIVE_RELEASES'),
removeNoFilesReleases: parseBooleanEnv('REMOVE_NO_FILES_RELEASES'),
blockRemovedNoFilesReleases: parseBooleanEnv('BLOCK_REMOVED_NO_FILES_RELEASES'),
removeNotAnUpgrade: parseBooleanEnv('REMOVE_NOT_AN_UPGRADE'),
removeSeriesIdMismatch: parseBooleanEnv('REMOVE_SERIES_ID_MISMATCH'),
blockRemovedSeriesIdMismatchReleases: parseBooleanEnv('BLOCK_REMOVED_SERIES_ID_MISMATCH_RELEASES'),
removeEpisodeCountMismatch: parseBooleanEnv('REMOVE_EPISODE_COUNT_MISMATCH'),
blockRemovedEpisodeCountMismatchReleases: parseBooleanEnv('BLOCK_REMOVED_EPISODE_COUNT_MISMATCH_RELEASES'),
removeUndeterminedSample: parseBooleanEnv('REMOVE_UNDETERMINED_SAMPLE'),
// Keep legacy misspelling for backward compatibility with existing deployments.
blockRemovedUndeterminedSampleReleases: getNormalizedEnvBoolean([
'BLOCK_REMOVED_UNDETERMINED_SAMPLE',
'BLOCK_REMOVED_UNDETERMIND_SAMPLE'
])
};
const config: Config = {
sonarr: {
host: process.env.SONARR_HOST || 'http://localhost:8989',
apiKey: process.env.SONARR_API_KEY || '',
enabled: !!(process.env.SONARR_HOST && process.env.SONARR_HOST.trim() !== '')
},
rules: {
removeQualityBlocked: process.env.REMOVE_QUALITY_BLOCKED === 'true',
blockRemovedQualityReleases: process.env.BLOCK_REMOVED_QUALITY_RELEASES === 'true',
removeArchiveBlocked: process.env.REMOVE_ARCHIVE_BLOCKED === 'true',
blockRemovedArchiveReleases: process.env.BLOCK_REMOVED_ARCHIVE_RELEASES === 'true',
removeNoFilesReleases: process.env.REMOVE_NO_FILES_RELEASES === 'true',
blockRemovedNoFilesReleases: process.env.BLOCK_REMOVED_NO_FILES_RELEASES === 'true',
removeSeriesIdMismatch: process.env.REMOVE_SERIES_ID_MISMATCH === 'true',
blockRemovedSeriesIdMismatchReleases: process.env.BLOCK_REMOVED_SERIES_ID_MISMATCH_RELEASES === 'true',
removeUndeterminedSample: process.env.REMOVE_UNDETERMINED_SAMPLE === 'true',
blockRemovedUndeterminedSampleReleases: process.env.BLOCK_REMOVED_UNDETERMIND_SAMPLE === 'true'
},
dryRun: process.env.DRY_RUN === 'true',
sonarrInstances: resolveSonarrInstances(),
rules: rulesFromEnv,
dryRun: parseBooleanEnv('DRY_RUN'),
schedule: process.env.SCHEDULE || '*/5 * * * *',
logLevel: process.env.LOG_LEVEL || 'info'
};
if (!config.sonarr.apiKey) {
console.error('SONARR_API_KEY is required');
process.exit(1);
}
validateInstances(config.sonarrInstances);
export default config;
function resolveSonarrInstances(): SonarrInstanceConfig[] {
try {
const instancesFromEnv = loadInstancesFromEnv();
if (instancesFromEnv?.length) {
return instancesFromEnv;
}
const instancesFromFile = loadInstancesFromFile();
if (instancesFromFile?.length) {
return instancesFromFile;
}
return loadLegacyInstance();
} catch (error) {
console.error((error as Error).message);
process.exit(1);
return [];
}
}
function loadInstancesFromEnv(): SonarrInstanceConfig[] | undefined {
const raw = process.env.SONARR_INSTANCES;
if (!raw || !raw.trim()) {
return undefined;
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error('SONARR_INSTANCES must be a JSON array');
}
return parsed.map((instance, index) => normalizeInstance(instance, index));
} catch (error) {
throw new Error(`Failed to parse SONARR_INSTANCES: ${(error as Error).message}`);
}
}
function loadInstancesFromFile(): SonarrInstanceConfig[] | undefined {
const filePath = process.env.SONARR_INSTANCES_FILE;
if (!filePath || !filePath.trim()) {
return undefined;
}
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`SONARR_INSTANCES_FILE not found at ${resolvedPath}`);
}
const fileContents = fs.readFileSync(resolvedPath, 'utf8');
const extension = path.extname(resolvedPath).toLowerCase();
let parsed: unknown;
try {
if (extension === '.yaml' || extension === '.yml') {
parsed = parseYaml(fileContents);
} else {
parsed = JSON.parse(fileContents);
}
} catch (error) {
throw new Error(`Failed to parse ${resolvedPath}: ${(error as Error).message}`);
}
if (!Array.isArray(parsed)) {
throw new Error('SONARR_INSTANCES_FILE must define a JSON/YAML array of instances');
}
return parsed.map((instance, index) => normalizeInstance(instance, index));
}
function parseYaml(contents: string): unknown {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
const yaml = require('yaml') as { parse: (input: string) => unknown };
return yaml.parse(contents);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'MODULE_NOT_FOUND') {
throw new Error('Parsing YAML requires the optional "yaml" dependency. Install it or provide JSON.');
}
throw error;
}
}
function loadLegacyInstance(): SonarrInstanceConfig[] {
const host = process.env.SONARR_HOST?.trim() || 'http://localhost:8989';
const hostProvided = !!(process.env.SONARR_HOST && process.env.SONARR_HOST.trim() !== '');
return [{
name: 'Primary Sonarr',
host,
apiKey: process.env.SONARR_API_KEY?.trim() || '',
enabled: hostProvided
}];
}
function normalizeInstance(instance: unknown, index: number): SonarrInstanceConfig {
if (!instance || typeof instance !== 'object') {
throw new Error(`Sonarr instance at index ${index} must be an object`);
}
const data = instance as Record<string, unknown>;
const host = typeof data.host === 'string' && data.host.trim() ? data.host.trim() : '';
if (!host) {
throw new Error(`Sonarr instance at index ${index} is missing a host value`);
}
const enabledRaw = data.enabled;
const enabled = enabledRaw === undefined ? true : coerceBoolean(enabledRaw);
const apiKey = typeof data.apiKey === 'string' ? data.apiKey.trim() : '';
const name = typeof data.name === 'string' && data.name.trim()
? data.name.trim()
: `Sonarr ${index + 1}`;
const rules = normalizeRuleOverrides(data.rules);
return {
name,
host,
apiKey,
enabled,
...(rules ? { rules } : {})
};
}
function normalizeRuleOverrides(overrides: unknown): Partial<RuleConfig> | undefined {
if (!overrides || typeof overrides !== 'object') {
return undefined;
}
const result: Partial<RuleConfig> = {};
const keys = Object.keys(rulesFromEnv) as (keyof RuleConfig)[];
for (const key of keys) {
const value = (overrides as Record<string, unknown>)[key];
if (value !== undefined) {
result[key] = coerceBoolean(value);
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
function coerceBoolean(value: unknown): boolean {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) {
return true;
}
if (['false', '0', 'no', 'n', 'off'].includes(normalized)) {
return false;
}
}
if (typeof value === 'number') {
return value !== 0;
}
return Boolean(value);
}
function validateInstances(instances: SonarrInstanceConfig[]): void {
const missingApiKey = instances.filter(instance => instance.enabled && !instance.apiKey);
if (missingApiKey.length > 0) {
console.error('Sonarr API key is required for one or more enabled instances. Update your configuration to supply keys for all enabled Sonarr instances.');
process.exit(1);
}
}

View File

@@ -1,15 +1,44 @@
import { CronJob } from 'cron';
import config from './config';
import { QueueCleaner } from './cleaner';
import { RuleConfig } from './types';
const cleaner = new QueueCleaner(config);
const enabledInstances = config.sonarrInstances.filter(instance => instance.enabled);
const cleaners = enabledInstances.map(instance => new QueueCleaner({
instance,
rules: mergeRules(config.rules, instance.rules),
dryRun: config.dryRun,
logLevel: config.logLevel
}));
console.log('ArrQueueCleaner starting...');
console.log(`Schedule: ${config.schedule}`);
console.log(`Sonarr: ${config.sonarr.host}`);
const job = new CronJob(config.schedule, () => cleaner.cleanQueue());
if (config.sonarrInstances.length) {
const summary = config.sonarrInstances
.map(instance => `${instance.name} (${instance.host})${instance.enabled ? '' : ' [disabled]'}`)
.join(', ');
console.log(`Sonarr instances: ${summary}`);
} else {
console.log('No Sonarr instances configured.');
}
const runAllCleaners = async (): Promise<void> => {
for (const cleaner of cleaners) {
await cleaner.cleanQueue();
}
};
const job = new CronJob(config.schedule, () => {
void runAllCleaners();
});
job.start();
// Run once on startup
cleaner.cleanQueue();
void runAllCleaners();
function mergeRules(baseRules: RuleConfig, overrides?: Partial<RuleConfig>): RuleConfig {
return {
...baseRules,
...(overrides || {})
};
}

View File

@@ -1,21 +1,30 @@
export interface RuleConfig {
removeQualityBlocked: boolean;
blockRemovedQualityReleases: boolean;
removeArchiveBlocked: boolean;
blockRemovedArchiveReleases: boolean;
removeNoFilesReleases: boolean;
blockRemovedNoFilesReleases: boolean;
removeNotAnUpgrade: boolean;
removeSeriesIdMismatch: boolean;
blockRemovedSeriesIdMismatchReleases: boolean;
removeEpisodeCountMismatch: boolean;
blockRemovedEpisodeCountMismatchReleases: boolean;
removeUndeterminedSample: boolean;
blockRemovedUndeterminedSampleReleases: boolean;
}
export interface SonarrInstanceConfig {
name: string;
host: string;
apiKey: string;
enabled: boolean;
rules?: Partial<RuleConfig>;
}
export interface Config {
sonarr: {
host: string;
apiKey: string;
enabled: boolean;
};
rules: {
removeQualityBlocked: boolean;
blockRemovedQualityReleases: boolean;
removeArchiveBlocked: boolean;
blockRemovedArchiveReleases: boolean;
removeNoFilesReleases: boolean;
blockRemovedNoFilesReleases: boolean;
removeSeriesIdMismatch: boolean;
blockRemovedSeriesIdMismatchReleases: boolean;
removeUndeterminedSample: boolean;
blockRemovedUndeterminedSampleReleases: boolean;
};
sonarrInstances: SonarrInstanceConfig[];
rules: RuleConfig;
dryRun: boolean;
schedule: string;
logLevel: string;
@@ -36,7 +45,7 @@ export interface StatusMessage {
messages?: string[];
}
export type RuleType = 'quality' | 'archive' | 'noFiles' | 'seriesIdMismatch' | 'undeterminedSample';
export type RuleType = 'quality' | 'archive' | 'noFiles' | 'notAnUpgrade' | 'seriesIdMismatch' | 'episodeCountMismatch' | 'undeterminedSample';
export interface RuleMatch {
type: RuleType;

View File

@@ -1,6 +1,6 @@
import { QueueCleaner } from '../src/cleaner';
import { QueueCleaner, QueueCleanerOptions } from '../src/cleaner';
import { SonarrClient } from '../src/sonarr';
import { createMockConfig, createMockQueueItem, createQualityBlockedItem, createArchiveBlockedItem, createNoFilesBlockedItem, createSeriesIdMismatchItem, createUndeterminedSampleItem } from './test-utils';
import { createMockInstance, createRuleConfig, createMockQueueItem, createQualityBlockedItem, createArchiveBlockedItem, createNoFilesBlockedItem, createNotAnUpgradeItem, createSeriesIdMismatchItem, createEpisodeCountMismatchItem, createUndeterminedSampleItem } from './test-utils';
jest.mock('../src/sonarr');
const MockedSonarrClient = SonarrClient as jest.MockedClass<typeof SonarrClient>;
@@ -8,6 +8,13 @@ const MockedSonarrClient = SonarrClient as jest.MockedClass<typeof SonarrClient>
describe('QueueCleaner', () => {
let mockSonarrClient: jest.Mocked<SonarrClient>;
const createCleaner = (overrides: Partial<QueueCleanerOptions> = {}): QueueCleaner => new QueueCleaner({
instance: overrides.instance ?? createMockInstance(),
rules: overrides.rules ?? createRuleConfig(),
dryRun: overrides.dryRun ?? false,
logLevel: overrides.logLevel ?? 'info'
});
beforeEach(() => {
mockSonarrClient = {
getQueue: jest.fn(),
@@ -17,6 +24,7 @@ describe('QueueCleaner', () => {
MockedSonarrClient.mockImplementation(() => mockSonarrClient);
jest.spyOn(console, 'log').mockImplementation();
jest.spyOn(console, 'warn').mockImplementation();
jest.spyOn(console, 'error').mockImplementation();
});
@@ -26,8 +34,9 @@ describe('QueueCleaner', () => {
describe('cleanQueue', () => {
it('should not process when sonarr is disabled', async () => {
const config = createMockConfig({ sonarr: { host: '', enabled: false } });
const cleaner = new QueueCleaner(config);
const cleaner = createCleaner({
instance: createMockInstance({ enabled: false })
});
await cleaner.cleanQueue();
@@ -35,8 +44,9 @@ describe('QueueCleaner', () => {
});
it('should skip non-completed items', async () => {
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
const cleaner = new QueueCleaner(config);
const cleaner = createCleaner({
rules: createRuleConfig({ removeQualityBlocked: true })
});
const items = [createMockQueueItem({ status: 'downloading' })];
mockSonarrClient.getQueue.mockResolvedValue(items);
@@ -48,8 +58,9 @@ describe('QueueCleaner', () => {
});
it('should skip items without warning status', async () => {
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
const cleaner = new QueueCleaner(config);
const cleaner = createCleaner({
rules: createRuleConfig({ removeQualityBlocked: true })
});
const items = [createMockQueueItem({ trackedDownloadStatus: 'ok' })];
mockSonarrClient.getQueue.mockResolvedValue(items);
@@ -61,8 +72,9 @@ describe('QueueCleaner', () => {
});
it('should skip items not in importPending state', async () => {
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
const cleaner = new QueueCleaner(config);
const cleaner = createCleaner({
rules: createRuleConfig({ removeQualityBlocked: true })
});
const items = [createMockQueueItem({ trackedDownloadState: 'downloading' })];
mockSonarrClient.getQueue.mockResolvedValue(items);
@@ -74,8 +86,9 @@ describe('QueueCleaner', () => {
});
it('should skip items without status messages', async () => {
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
const cleaner = new QueueCleaner(config);
const cleaner = createCleaner({
rules: createRuleConfig({ removeQualityBlocked: true })
});
const items = [createMockQueueItem({ statusMessages: [] })];
mockSonarrClient.getQueue.mockResolvedValue(items);
@@ -88,8 +101,9 @@ describe('QueueCleaner', () => {
describe('quality blocked items', () => {
it('should remove quality blocked items when enabled', async () => {
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
const cleaner = new QueueCleaner(config);
const cleaner = createCleaner({
rules: createRuleConfig({ removeQualityBlocked: true })
});
const items = [createQualityBlockedItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);
@@ -101,13 +115,12 @@ describe('QueueCleaner', () => {
});
it('should block quality blocked items when blocking enabled', async () => {
const config = createMockConfig({
rules: {
const cleaner = createCleaner({
rules: createRuleConfig({
removeQualityBlocked: true,
blockRemovedQualityReleases: true
}
})
});
const cleaner = new QueueCleaner(config);
const items = [createQualityBlockedItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);
@@ -119,8 +132,9 @@ describe('QueueCleaner', () => {
});
it('should skip quality blocked items when disabled', async () => {
const config = createMockConfig({ rules: { removeQualityBlocked: false } });
const cleaner = new QueueCleaner(config);
const cleaner = createCleaner({
rules: createRuleConfig({ removeQualityBlocked: false })
});
const items = [createQualityBlockedItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);
@@ -134,8 +148,9 @@ describe('QueueCleaner', () => {
describe('archive blocked items', () => {
it('should remove archive blocked items when enabled', async () => {
const config = createMockConfig({ rules: { removeArchiveBlocked: true } });
const cleaner = new QueueCleaner(config);
const cleaner = createCleaner({
rules: createRuleConfig({ removeArchiveBlocked: true })
});
const items = [createArchiveBlockedItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);
@@ -147,13 +162,12 @@ describe('QueueCleaner', () => {
});
it('should block archive blocked items when blocking enabled', async () => {
const config = createMockConfig({
rules: {
const cleaner = createCleaner({
rules: createRuleConfig({
removeArchiveBlocked: true,
blockRemovedArchiveReleases: true
}
})
});
const cleaner = new QueueCleaner(config);
const items = [createArchiveBlockedItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);
@@ -165,8 +179,9 @@ describe('QueueCleaner', () => {
});
it('should skip archive blocked items when disabled', async () => {
const config = createMockConfig({ rules: { removeArchiveBlocked: false } });
const cleaner = new QueueCleaner(config);
const cleaner = createCleaner({
rules: createRuleConfig({ removeArchiveBlocked: false })
});
const items = [createArchiveBlockedItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);
@@ -180,8 +195,9 @@ describe('QueueCleaner', () => {
describe('no files blocked items', () => {
it('should remove no files blocked items when enabled', async () => {
const config = createMockConfig({ rules: { removeNoFilesReleases: true } });
const cleaner = new QueueCleaner(config);
const cleaner = createCleaner({
rules: createRuleConfig({ removeNoFilesReleases: true })
});
const items = [createNoFilesBlockedItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);
@@ -193,13 +209,12 @@ describe('QueueCleaner', () => {
});
it('should block no files blocked items when blocking enabled', async () => {
const config = createMockConfig({
rules: {
const cleaner = createCleaner({
rules: createRuleConfig({
removeNoFilesReleases: true,
blockRemovedNoFilesReleases: true
}
})
});
const cleaner = new QueueCleaner(config);
const items = [createNoFilesBlockedItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);
@@ -211,8 +226,9 @@ describe('QueueCleaner', () => {
});
it('should skip no files blocked items when disabled', async () => {
const config = createMockConfig({ rules: { removeNoFilesReleases: false } });
const cleaner = new QueueCleaner(config);
const cleaner = createCleaner({
rules: createRuleConfig({ removeNoFilesReleases: false })
});
const items = [createNoFilesBlockedItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);
@@ -224,15 +240,91 @@ describe('QueueCleaner', () => {
});
});
describe('not an upgrade items', () => {
it('should remove not an upgrade items when enabled', async () => {
const cleaner = createCleaner({
rules: createRuleConfig({ removeNotAnUpgrade: true })
});
const items = [createNotAnUpgradeItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);
await cleaner.cleanQueue();
expect(mockSonarrClient.removeFromQueue).toHaveBeenCalledWith(123);
expect(mockSonarrClient.blockRelease).not.toHaveBeenCalled();
});
it('should skip not an upgrade items when disabled', async () => {
const cleaner = createCleaner({
rules: createRuleConfig({ removeNotAnUpgrade: false })
});
const items = [createNotAnUpgradeItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);
await cleaner.cleanQueue();
expect(mockSonarrClient.removeFromQueue).not.toHaveBeenCalled();
expect(mockSonarrClient.blockRelease).not.toHaveBeenCalled();
});
});
describe('episode count mismatch items', () => {
it('should remove episode count mismatch items when enabled', async () => {
const cleaner = createCleaner({
rules: createRuleConfig({ removeEpisodeCountMismatch: true })
});
const items = [createEpisodeCountMismatchItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);
await cleaner.cleanQueue();
expect(mockSonarrClient.removeFromQueue).toHaveBeenCalledWith(123);
expect(mockSonarrClient.blockRelease).not.toHaveBeenCalled();
});
it('should block episode count mismatch items when blocking enabled', async () => {
const cleaner = createCleaner({
rules: createRuleConfig({
removeEpisodeCountMismatch: true,
blockRemovedEpisodeCountMismatchReleases: true
})
});
const items = [createEpisodeCountMismatchItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);
await cleaner.cleanQueue();
expect(mockSonarrClient.blockRelease).toHaveBeenCalledWith(123);
expect(mockSonarrClient.removeFromQueue).not.toHaveBeenCalled();
});
it('should skip episode count mismatch items when disabled', async () => {
const cleaner = createCleaner({
rules: createRuleConfig({ removeEpisodeCountMismatch: false })
});
const items = [createEpisodeCountMismatchItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);
await cleaner.cleanQueue();
expect(mockSonarrClient.removeFromQueue).not.toHaveBeenCalled();
expect(mockSonarrClient.blockRelease).not.toHaveBeenCalled();
});
});
it('should process multiple matching items', async () => {
const config = createMockConfig({
rules: {
const cleaner = createCleaner({
rules: createRuleConfig({
removeQualityBlocked: true,
removeArchiveBlocked: true,
removeNoFilesReleases: true
}
})
});
const cleaner = new QueueCleaner(config);
const items = [
createQualityBlockedItem(),
@@ -249,21 +341,21 @@ describe('QueueCleaner', () => {
});
it('should handle errors gracefully', async () => {
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
const cleaner = new QueueCleaner(config);
const cleaner = createCleaner({
rules: createRuleConfig({ removeQualityBlocked: true })
});
mockSonarrClient.getQueue.mockRejectedValue(new Error('API Error'));
await cleaner.cleanQueue();
expect(console.error).toHaveBeenCalledWith('Error cleaning queue:', 'API Error');
expect(console.error).toHaveBeenCalledWith('[ERROR] [Test Sonarr] Error cleaning queue: "API Error"');
});
it('should group season pack episodes by downloadId and process only once', async () => {
const config = createMockConfig({
rules: { removeSeriesIdMismatch: true }
const cleaner = createCleaner({
rules: createRuleConfig({ removeSeriesIdMismatch: true })
});
const cleaner = new QueueCleaner(config);
const sharedDownloadId = 'season_pack_download_123';
const items = [
@@ -293,10 +385,9 @@ describe('QueueCleaner', () => {
});
it('should remove undetermined sample items when enabled', async () => {
const config = createMockConfig({
rules: { removeUndeterminedSample: true }
const cleaner = createCleaner({
rules: createRuleConfig({ removeUndeterminedSample: true })
});
const cleaner = new QueueCleaner(config);
const items = [createUndeterminedSampleItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);
@@ -307,13 +398,12 @@ describe('QueueCleaner', () => {
});
it('should block undetermined sample items when configured', async () => {
const config = createMockConfig({
rules: {
const cleaner = createCleaner({
rules: createRuleConfig({
removeUndeterminedSample: true,
blockRemovedUndeterminedSampleReleases: true
}
})
});
const cleaner = new QueueCleaner(config);
const items = [createUndeterminedSampleItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);
@@ -355,8 +445,9 @@ describe('QueueCleaner', () => {
testCases.forEach(({ name, config: rules, expectProcessed }) => {
it(`should handle ${name}`, async () => {
const config = createMockConfig({ rules });
const cleaner = new QueueCleaner(config);
const cleaner = createCleaner({
rules: createRuleConfig(rules)
});
const items = [createQualityBlockedItem(), createArchiveBlockedItem(), createNoFilesBlockedItem()];
mockSonarrClient.getQueue.mockResolvedValue(items);

129
tests/config.test.ts Normal file
View File

@@ -0,0 +1,129 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
jest.mock('dotenv', () => ({ config: jest.fn() }));
const loadConfig = async () => (await import('../src/config')).default;
describe('config', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
delete process.env.SONARR_INSTANCES;
delete process.env.SONARR_INSTANCES_FILE;
delete process.env.SONARR_HOST;
delete process.env.SONARR_API_KEY;
jest.spyOn(console, 'error').mockImplementation();
});
afterEach(() => {
process.env = { ...originalEnv };
jest.restoreAllMocks();
});
it('loads multiple Sonarr instances from SONARR_INSTANCES env', async () => {
process.env.SONARR_INSTANCES = JSON.stringify([
{ name: 'HD Shows', host: 'http://hd-sonarr:8989', apiKey: 'hd-key' },
{ name: 'Anime', host: 'http://anime-sonarr:8989', apiKey: 'anime-key', enabled: false }
]);
const config = await loadConfig();
expect(config.sonarrInstances).toHaveLength(2);
expect(config.sonarrInstances[0]).toMatchObject({
name: 'HD Shows',
host: 'http://hd-sonarr:8989',
apiKey: 'hd-key',
enabled: true
});
expect(config.sonarrInstances[1]).toMatchObject({
name: 'Anime',
host: 'http://anime-sonarr:8989',
enabled: false
});
});
it('loads Sonarr instances from SONARR_INSTANCES_FILE', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'arrqueuecleaner-'));
const filePath = path.join(tmpDir, 'instances.json');
fs.writeFileSync(filePath, JSON.stringify([
{ name: '4K Shows', host: 'http://4k-sonarr:8989', apiKey: '4k-key' }
]));
process.env.SONARR_INSTANCES_FILE = filePath;
try {
const config = await loadConfig();
expect(config.sonarrInstances).toHaveLength(1);
expect(config.sonarrInstances[0]).toMatchObject({
name: '4K Shows',
host: 'http://4k-sonarr:8989',
apiKey: '4k-key',
enabled: true
});
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('loads Sonarr instances from YAML file when dependency is available', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'arrqueuecleaner-'));
const filePath = path.join(tmpDir, 'instances.yaml');
const yamlContent = [
'- name: Anime',
' host: http://anime-sonarr:8989',
' apiKey: anime-key',
' enabled: true'
].join('\n');
fs.writeFileSync(filePath, yamlContent);
process.env.SONARR_INSTANCES_FILE = filePath;
try {
const config = await loadConfig();
expect(config.sonarrInstances).toHaveLength(1);
expect(config.sonarrInstances[0]).toMatchObject({
name: 'Anime',
host: 'http://anime-sonarr:8989',
apiKey: 'anime-key',
enabled: true
});
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('falls back to legacy environment variables when no structured config provided', async () => {
process.env.SONARR_HOST = 'http://legacy-sonarr:8989';
process.env.SONARR_API_KEY = 'legacy-key';
const config = await loadConfig();
expect(config.sonarrInstances).toHaveLength(1);
expect(config.sonarrInstances[0]).toMatchObject({
name: 'Primary Sonarr',
host: 'http://legacy-sonarr:8989',
apiKey: 'legacy-key'
});
});
it('exits when an enabled instance is missing an API key', async () => {
process.env.SONARR_INSTANCES = JSON.stringify([
{ name: 'Broken', host: 'http://broken:8989' }
]);
const exitMock = jest.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit called');
}) as never);
await expect(loadConfig()).rejects.toThrow('process.exit called');
expect(console.error).toHaveBeenCalledWith('Sonarr API key is required for one or more enabled instances. Update your configuration to supply keys for all enabled Sonarr instances.');
exitMock.mockRestore();
});
});

View File

@@ -1,32 +1,32 @@
import { Config, QueueItem } from '../src/types';
import { QueueItem, RuleConfig, SonarrInstanceConfig } from '../src/types';
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
export const createMockConfig = (overrides: DeepPartial<Config> = {}): Config => ({
sonarr: {
host: 'http://localhost:8989',
apiKey: 'test-api-key',
enabled: true,
...overrides.sonarr
},
rules: {
removeQualityBlocked: false,
blockRemovedQualityReleases: false,
removeArchiveBlocked: false,
blockRemovedArchiveReleases: false,
removeNoFilesReleases: false,
blockRemovedNoFilesReleases: false,
removeSeriesIdMismatch: false,
blockRemovedSeriesIdMismatchReleases: false,
removeUndeterminedSample: false,
blockRemovedUndeterminedSampleReleases: false,
...overrides.rules
},
dryRun: overrides.dryRun || false,
schedule: overrides.schedule || '*/5 * * * *',
logLevel: overrides.logLevel || 'info'
export const createRuleConfig = (overrides: Partial<RuleConfig> = {}): RuleConfig => ({
removeQualityBlocked: false,
blockRemovedQualityReleases: false,
removeArchiveBlocked: false,
blockRemovedArchiveReleases: false,
removeNoFilesReleases: false,
blockRemovedNoFilesReleases: false,
removeNotAnUpgrade: false,
removeSeriesIdMismatch: false,
blockRemovedSeriesIdMismatchReleases: false,
removeEpisodeCountMismatch: false,
blockRemovedEpisodeCountMismatchReleases: false,
removeUndeterminedSample: false,
blockRemovedUndeterminedSampleReleases: false,
...overrides
});
export const createMockInstance = (overrides: DeepPartial<SonarrInstanceConfig> = {}): SonarrInstanceConfig => ({
name: overrides.name ?? 'Test Sonarr',
host: overrides.host ?? 'http://localhost:8989',
apiKey: overrides.apiKey ?? 'test-api-key',
enabled: overrides.enabled ?? true,
rules: overrides.rules
});
export const createMockQueueItem = (overrides: Partial<QueueItem> = {}): QueueItem => ({
@@ -61,6 +61,13 @@ export const createNoFilesBlockedItem = (): QueueItem =>
}]
});
export const createNotAnUpgradeItem = (): QueueItem =>
createMockQueueItem({
statusMessages: [{
messages: ['Not an upgrade']
}]
});
export const createSeriesIdMismatchItem = (overrides: Partial<QueueItem> = {}): QueueItem =>
createMockQueueItem({
trackedDownloadState: 'importBlocked',
@@ -70,6 +77,14 @@ export const createSeriesIdMismatchItem = (overrides: Partial<QueueItem> = {}):
...overrides
});
export const createEpisodeCountMismatchItem = (overrides: Partial<QueueItem> = {}): QueueItem =>
createMockQueueItem({
statusMessages: [{
messages: ['Episode file on disk contains more episodes than this file contains']
}],
...overrides
});
export const createUndeterminedSampleItem = (overrides: Partial<QueueItem> = {}): QueueItem =>
createMockQueueItem({
statusMessages: [{