mirror of
https://github.com/thelegendtubaguy/ArrQueueCleaner.git
synced 2025-12-16 18:14:44 -06:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
064158323c | ||
|
|
4f1c44517e | ||
|
|
4eb6e50ec3 | ||
|
|
d64d8ce20e | ||
|
|
2e7c93dca5 | ||
|
|
7fa8f457b2 | ||
|
|
7ff36eb2e5 | ||
|
|
0319dbf407 | ||
|
|
a49b42227f | ||
|
|
1cb0d9874f | ||
|
|
2973b0421c | ||
|
|
bd7c049ca2 | ||
|
|
582f73b549 | ||
|
|
da36319c89 | ||
|
|
cdb6004759 | ||
|
|
c041d9eb4f | ||
|
|
ca69e01d85 | ||
|
|
9ada6bd6ac | ||
|
|
10dc538893 | ||
|
|
5a4f54104f | ||
|
|
32faed4c39 | ||
|
|
d40fb49e1e | ||
|
|
c624aff0e0 | ||
|
|
3b85e6a527 | ||
|
|
a2e43d9bca | ||
|
|
ebe3b20399 | ||
|
|
48963a9701 | ||
|
|
8c9644cc61 | ||
|
|
1001ca45b1 | ||
|
|
97b4598c12 | ||
|
|
8696214a56 | ||
|
|
f92f62dd5f | ||
|
|
78c4c3c0e6 |
@@ -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
3
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
* @thelegendtubaguy
|
||||
package.json
|
||||
pnpm-lock.yaml
|
||||
25
.github/workflows/ci.yml
vendored
25
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
37
.github/workflows/codeql.yml
vendored
37
.github/workflows/codeql.yml
vendored
@@ -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
34
.github/workflows/dependabot-auto.yml
vendored
Normal 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 }}
|
||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -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
|
||||
|
||||
50
.github/workflows/security.yml
vendored
50
.github/workflows/security.yml
vendored
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24-alpine
|
||||
FROM node:25-alpine
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
|
||||
75
README.md
75
README.md
@@ -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
|
||||
```
|
||||
|
||||
|
||||
25
package.json
25
package.json
@@ -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
1242
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
236
src/config.ts
236
src/config.ts
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
39
src/index.ts
39
src/index.ts
@@ -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 || {})
|
||||
};
|
||||
}
|
||||
|
||||
45
src/types.ts
45
src/types.ts
@@ -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;
|
||||
|
||||
@@ -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
129
tests/config.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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: [{
|
||||
|
||||
Reference in New Issue
Block a user