mirror of
https://github.com/thelegendtubaguy/ArrQueueCleaner.git
synced 2026-02-07 10:49:54 -06:00
Add nofiles releases rule (#9)
* Added No files rule * Spacing was way off :( * Added eslint and pr workflow for eslint * Removed test only pr workflow pr, replaced with lint and test
This commit is contained in:
@@ -7,6 +7,8 @@ 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
|
||||
|
||||
# Schedule (cron format)
|
||||
SCHEDULE=*/5 * * * *
|
||||
|
||||
34
.github/workflows/ci.yml
vendored
Normal file
34
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
35
.github/workflows/test.yml
vendored
35
.github/workflows/test.yml
vendored
@@ -1,35 +0,0 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'tests/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'tsconfig.json'
|
||||
- 'jest.config.js'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
@@ -21,6 +21,8 @@ Automated queue cleaner for Sonarr that removes stuck downloads based on configu
|
||||
| `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 |
|
||||
| `SCHEDULE` | `*/5 * * * *` | Cron schedule (every 5 minutes) |
|
||||
| `LOG_LEVEL` | `info` | Logging level |
|
||||
|
||||
@@ -54,6 +56,8 @@ services:
|
||||
- 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
|
||||
- SCHEDULE=*/5 * * * *
|
||||
- LOG_LEVEL=info
|
||||
restart: unless-stopped
|
||||
|
||||
88
eslint.config.mjs
Normal file
88
eslint.config.mjs
Normal file
@@ -0,0 +1,88 @@
|
||||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
// Apply recommended rules
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.strict,
|
||||
...tseslint.configs.stylistic,
|
||||
|
||||
// Global configuration
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: './tsconfig.eslint.json',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// File-specific configurations
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
rules: {
|
||||
// TypeScript-specific rules
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-var-requires': 'error',
|
||||
'@typescript-eslint/no-inferrable-types': 'error',
|
||||
|
||||
// Code quality
|
||||
'no-console': 'off', // Allow console for this CLI app
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error',
|
||||
'eqeqeq': ['error', 'always'],
|
||||
'curly': ['error', 'all'],
|
||||
|
||||
// Style consistency
|
||||
'indent': ['error', 4],
|
||||
'quotes': ['error', 'single'],
|
||||
'semi': ['error', 'always'],
|
||||
'comma-dangle': ['error', 'never'],
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'array-bracket-spacing': ['error', 'never'],
|
||||
},
|
||||
},
|
||||
|
||||
// Test files configuration
|
||||
{
|
||||
files: ['**/*.test.ts', '**/*.spec.ts', 'tests/**/*.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Configuration files
|
||||
{
|
||||
files: ['*.config.js', '*.config.mjs', '*.config.ts'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
module: 'readonly',
|
||||
require: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
process: 'readonly',
|
||||
},
|
||||
parserOptions: {
|
||||
project: null,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Ignore patterns
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'node_modules/**',
|
||||
'coverage/**',
|
||||
'*.d.ts',
|
||||
],
|
||||
}
|
||||
);
|
||||
61
package.json
61
package.json
@@ -1,29 +1,36 @@
|
||||
{
|
||||
"name": "arr-queue-cleaner",
|
||||
"version": "1.0.0",
|
||||
"description": "Automated queue cleaner for Sonarr and Radarr",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx --watch src/index.ts",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"cron": "^3.1.0",
|
||||
"dotenv": "^16.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/jest": "^29.5.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
"name": "arr-queue-cleaner",
|
||||
"version": "1.0.0",
|
||||
"description": "Automated queue cleaner for Sonarr and Radarr",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx --watch src/index.ts",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"cron": "^3.1.0",
|
||||
"dotenv": "^16.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.43.0",
|
||||
"@typescript-eslint/parser": "^8.43.0",
|
||||
"eslint": "^9.35.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.43.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
|
||||
770
pnpm-lock.yaml
generated
770
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
181
src/cleaner.ts
181
src/cleaner.ts
@@ -1,99 +1,98 @@
|
||||
import { SonarrClient } from './sonarr';
|
||||
import { Config, QueueItem } from './types';
|
||||
import { Config, QueueItem, RuleMatch } from './types';
|
||||
|
||||
export class QueueCleaner {
|
||||
private config: Config;
|
||||
private sonarr: SonarrClient;
|
||||
private config: Config;
|
||||
private sonarr: SonarrClient;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
this.sonarr = new SonarrClient(config.sonarr.host, config.sonarr.apiKey, config.logLevel);
|
||||
}
|
||||
|
||||
private log(level: string, message: string, data?: any): void {
|
||||
if (level === 'debug' && this.config.logLevel !== 'debug') return;
|
||||
const output = data ? `${message}: ${JSON.stringify(data)}` : message;
|
||||
console.log(`[${level.toUpperCase()}] ${output}`);
|
||||
}
|
||||
|
||||
async cleanQueue(): Promise<void> {
|
||||
if (!this.config.sonarr.enabled) return;
|
||||
|
||||
try {
|
||||
const queue = await this.sonarr.getQueue();
|
||||
const itemsToProcess = queue.filter(item => this.shouldRemoveItem(item));
|
||||
|
||||
for (const item of itemsToProcess) {
|
||||
await this.processItem(item);
|
||||
}
|
||||
|
||||
if (itemsToProcess.length > 0) {
|
||||
console.log(`Processed ${itemsToProcess.length} queue items`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cleaning queue:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
shouldRemoveItem(item: QueueItem): boolean {
|
||||
if (item.status !== 'completed') {
|
||||
this.log('debug', 'Item not completed yet', item.title);
|
||||
return false;
|
||||
}
|
||||
if (item.trackedDownloadStatus !== 'warning') {
|
||||
this.log('debug', 'Item not in download warning status', item.title);
|
||||
return false;
|
||||
}
|
||||
if (item.trackedDownloadState !== 'importPending') {
|
||||
this.log('debug', 'Item not stuck in importing', item.title);
|
||||
return false;
|
||||
}
|
||||
if (!item.statusMessages?.length) {
|
||||
this.log('info', 'Item has no status messages', item.title);
|
||||
return false;
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
this.sonarr = new SonarrClient(config.sonarr.host, config.sonarr.apiKey, config.logLevel);
|
||||
}
|
||||
|
||||
this.log('debug', 'Got item to check', {
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
trackedDownloadStatus: item.trackedDownloadStatus,
|
||||
trackedDownloadState: item.trackedDownloadState,
|
||||
statusMessages: item.statusMessages
|
||||
});
|
||||
|
||||
return item.statusMessages.some(msg => {
|
||||
const hasQualityIssue = this.config.rules.removeQualityBlocked &&
|
||||
msg.messages?.some(m => m.includes('upgrade for existing episode'));
|
||||
|
||||
const hasArchiveIssue = this.config.rules.removeArchiveBlocked &&
|
||||
msg.messages?.some(m => m.includes('archive file'));
|
||||
|
||||
if (hasQualityIssue) this.log('debug', 'Item has quality issue', item.title);
|
||||
if (hasArchiveIssue) this.log('debug', 'Item has archive issue', item.title);
|
||||
|
||||
return hasQualityIssue || hasArchiveIssue;
|
||||
});
|
||||
}
|
||||
|
||||
async processItem(item: QueueItem): Promise<void> {
|
||||
try {
|
||||
const isArchiveIssue = item.statusMessages?.some(msg =>
|
||||
msg.messages?.some(m => m.includes('archive file'))
|
||||
);
|
||||
|
||||
const shouldBlock = isArchiveIssue ?
|
||||
this.config.rules.blockRemovedArchiveReleases :
|
||||
this.config.rules.blockRemovedQualityReleases;
|
||||
|
||||
if (shouldBlock) {
|
||||
await this.sonarr.blockRelease(item.id);
|
||||
console.log(`Blocked and removed: ${item.title}`);
|
||||
} else {
|
||||
await this.sonarr.removeFromQueue(item.id);
|
||||
console.log(`Removed: ${item.title}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${item.title}:`, (error as Error).message);
|
||||
private log(level: string, message: string, data?: unknown): void {
|
||||
if (level === 'debug' && this.config.logLevel !== 'debug') {return;}
|
||||
const output = data ? `${message}: ${JSON.stringify(data)}` : message;
|
||||
console.log(`[${level.toUpperCase()}] ${output}`);
|
||||
}
|
||||
|
||||
async cleanQueue(): Promise<void> {
|
||||
if (!this.config.sonarr.enabled) {return;}
|
||||
|
||||
try {
|
||||
const queue = await this.sonarr.getQueue();
|
||||
const itemsToProcess: { item: QueueItem; rule: RuleMatch }[] = [];
|
||||
|
||||
for (const item of queue) {
|
||||
const rule = this.evaluateRules(item);
|
||||
if (rule) {
|
||||
itemsToProcess.push({ item, rule });
|
||||
}
|
||||
}
|
||||
|
||||
for (const { item, rule } of itemsToProcess) {
|
||||
await this.processItem(item, rule);
|
||||
}
|
||||
|
||||
if (itemsToProcess.length > 0) {
|
||||
console.log(`Processed ${itemsToProcess.length} queue items`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cleaning queue:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateRules(item: QueueItem): RuleMatch | null {
|
||||
if (item.status !== 'completed' ||
|
||||
item.trackedDownloadStatus !== 'warning' ||
|
||||
item.trackedDownloadState !== 'importPending' ||
|
||||
!item.statusMessages?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.log('debug', 'Evaluating rules for item', {
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
trackedDownloadStatus: item.trackedDownloadStatus,
|
||||
trackedDownloadState: item.trackedDownloadState,
|
||||
statusMessages: item.statusMessages
|
||||
});
|
||||
|
||||
for (const msg of item.statusMessages) {
|
||||
if (!msg.messages?.length) {continue;}
|
||||
|
||||
for (const message of msg.messages) {
|
||||
if (this.config.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 };
|
||||
}
|
||||
|
||||
if (this.config.rules.removeArchiveBlocked && message.includes('archive file')) {
|
||||
this.log('debug', 'Item matched archive rule', item.title);
|
||||
return { type: 'archive', shouldBlock: this.config.rules.blockRemovedArchiveReleases };
|
||||
}
|
||||
|
||||
if (this.config.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 null;
|
||||
}
|
||||
|
||||
private async processItem(item: QueueItem, rule: RuleMatch): Promise<void> {
|
||||
try {
|
||||
if (rule.shouldBlock) {
|
||||
await this.sonarr.blockRelease(item.id);
|
||||
console.log(`Blocked and removed (${rule.type}): ${item.title}`);
|
||||
} else {
|
||||
await this.sonarr.removeFromQueue(item.id);
|
||||
console.log(`Removed (${rule.type}): ${item.title}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${item.title}:`, (error as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,24 +4,26 @@ import { Config } from './types';
|
||||
dotenvConfig();
|
||||
|
||||
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'
|
||||
},
|
||||
schedule: process.env.SCHEDULE || '*/5 * * * *',
|
||||
logLevel: process.env.LOG_LEVEL || 'info'
|
||||
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'
|
||||
},
|
||||
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);
|
||||
console.error('SONARR_API_KEY is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -2,44 +2,44 @@ import axios, { AxiosInstance } from 'axios';
|
||||
import { QueueItem } from './types';
|
||||
|
||||
export class SonarrClient {
|
||||
private client: AxiosInstance;
|
||||
private host: string;
|
||||
private logLevel: string;
|
||||
private client: AxiosInstance;
|
||||
private host: string;
|
||||
private logLevel: string;
|
||||
|
||||
constructor(host: string, apiKey: string, logLevel: string = 'info') {
|
||||
this.host = host;
|
||||
this.logLevel = logLevel;
|
||||
this.client = axios.create({
|
||||
baseURL: `${host}/api/v3`,
|
||||
headers: { 'X-Api-Key': apiKey }
|
||||
});
|
||||
}
|
||||
constructor(host: string, apiKey: string, logLevel = 'info') {
|
||||
this.host = host;
|
||||
this.logLevel = logLevel;
|
||||
this.client = axios.create({
|
||||
baseURL: `${host}/api/v3`,
|
||||
headers: { 'X-Api-Key': apiKey }
|
||||
});
|
||||
}
|
||||
|
||||
private log(level: string, message: string): void {
|
||||
if (level === 'debug' && this.logLevel !== 'debug') return;
|
||||
console.log(`[${level.toUpperCase()}] ${message}`);
|
||||
}
|
||||
private log(level: string, message: string): void {
|
||||
if (level === 'debug' && this.logLevel !== 'debug') {return;}
|
||||
console.log(`[${level.toUpperCase()}] ${message}`);
|
||||
}
|
||||
|
||||
async getQueue(): Promise<QueueItem[]> {
|
||||
const { data } = await this.client.get('/queue');
|
||||
this.log('debug', `Successfully contacted Sonarr API at ${this.host}/api/v3/queue`);
|
||||
this.log('debug', `Queue response: ${JSON.stringify(data, null, 2)}`);
|
||||
return data.records || data;
|
||||
}
|
||||
async getQueue(): Promise<QueueItem[]> {
|
||||
const { data } = await this.client.get('/queue');
|
||||
this.log('debug', `Successfully contacted Sonarr API at ${this.host}/api/v3/queue`);
|
||||
this.log('debug', `Queue response: ${JSON.stringify(data, null, 2)}`);
|
||||
return data.records || data;
|
||||
}
|
||||
|
||||
async removeFromQueue(id: number): Promise<void> {
|
||||
const response = await this.client.delete(`/queue/${id}`, {
|
||||
params: { removeFromClient: true, blocklist: false }
|
||||
});
|
||||
this.log('debug', `Successfully removed queue item ${id} from Sonarr`);
|
||||
this.log('debug', `Remove response: ${JSON.stringify(response.data, null, 2)}`);
|
||||
}
|
||||
async removeFromQueue(id: number): Promise<void> {
|
||||
const response = await this.client.delete(`/queue/${id}`, {
|
||||
params: { removeFromClient: true, blocklist: false }
|
||||
});
|
||||
this.log('debug', `Successfully removed queue item ${id} from Sonarr`);
|
||||
this.log('debug', `Remove response: ${JSON.stringify(response.data, null, 2)}`);
|
||||
}
|
||||
|
||||
async blockRelease(id: number): Promise<void> {
|
||||
const response = await this.client.delete(`/queue/${id}`, {
|
||||
params: { removeFromClient: true, blocklist: true }
|
||||
});
|
||||
this.log('debug', `Successfully blocked and removed queue item ${id} from Sonarr`);
|
||||
this.log('debug', `Block response: ${JSON.stringify(response.data, null, 2)}`);
|
||||
}
|
||||
async blockRelease(id: number): Promise<void> {
|
||||
const response = await this.client.delete(`/queue/${id}`, {
|
||||
params: { removeFromClient: true, blocklist: true }
|
||||
});
|
||||
this.log('debug', `Successfully blocked and removed queue item ${id} from Sonarr`);
|
||||
this.log('debug', `Block response: ${JSON.stringify(response.data, null, 2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
51
src/types.ts
51
src/types.ts
@@ -1,29 +1,38 @@
|
||||
export interface Config {
|
||||
sonarr: {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
rules: {
|
||||
removeQualityBlocked: boolean;
|
||||
blockRemovedQualityReleases: boolean;
|
||||
removeArchiveBlocked: boolean;
|
||||
blockRemovedArchiveReleases: boolean;
|
||||
};
|
||||
schedule: string;
|
||||
logLevel: string;
|
||||
sonarr: {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
rules: {
|
||||
removeQualityBlocked: boolean;
|
||||
blockRemovedQualityReleases: boolean;
|
||||
removeArchiveBlocked: boolean;
|
||||
blockRemovedArchiveReleases: boolean;
|
||||
removeNoFilesReleases: boolean;
|
||||
blockRemovedNoFilesReleases: boolean;
|
||||
};
|
||||
schedule: string;
|
||||
logLevel: string;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
trackedDownloadStatus: string;
|
||||
trackedDownloadState: string;
|
||||
statusMessages: StatusMessage[];
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
trackedDownloadStatus: string;
|
||||
trackedDownloadState: string;
|
||||
statusMessages: StatusMessage[];
|
||||
}
|
||||
|
||||
export interface StatusMessage {
|
||||
title?: string;
|
||||
messages?: string[];
|
||||
title?: string;
|
||||
messages?: string[];
|
||||
}
|
||||
|
||||
export type RuleType = 'quality' | 'archive' | 'noFiles';
|
||||
|
||||
export interface RuleMatch {
|
||||
type: RuleType;
|
||||
shouldBlock: boolean;
|
||||
}
|
||||
|
||||
@@ -1,249 +1,306 @@
|
||||
import { QueueCleaner } from '../src/cleaner';
|
||||
import { SonarrClient } from '../src/sonarr';
|
||||
import { createMockConfig, createMockQueueItem, createQualityBlockedItem, createArchiveBlockedItem } from './test-utils';
|
||||
import { createMockConfig, createMockQueueItem, createQualityBlockedItem, createArchiveBlockedItem, createNoFilesBlockedItem } from './test-utils';
|
||||
|
||||
jest.mock('../src/sonarr');
|
||||
const MockedSonarrClient = SonarrClient as jest.MockedClass<typeof SonarrClient>;
|
||||
|
||||
describe('QueueCleaner', () => {
|
||||
let mockSonarrClient: jest.Mocked<SonarrClient>;
|
||||
let mockSonarrClient: jest.Mocked<SonarrClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSonarrClient = {
|
||||
getQueue: jest.fn(),
|
||||
removeFromQueue: jest.fn(),
|
||||
blockRelease: jest.fn()
|
||||
} as any;
|
||||
MockedSonarrClient.mockImplementation(() => mockSonarrClient);
|
||||
|
||||
jest.spyOn(console, 'log').mockImplementation();
|
||||
jest.spyOn(console, 'error').mockImplementation();
|
||||
});
|
||||
beforeEach(() => {
|
||||
mockSonarrClient = {
|
||||
getQueue: jest.fn(),
|
||||
removeFromQueue: jest.fn(),
|
||||
blockRelease: jest.fn()
|
||||
} as any;
|
||||
MockedSonarrClient.mockImplementation(() => mockSonarrClient);
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('shouldRemoveItem', () => {
|
||||
it('should return false for non-completed items', () => {
|
||||
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const item = createMockQueueItem({ status: 'downloading' });
|
||||
|
||||
expect(cleaner.shouldRemoveItem(item)).toBe(false);
|
||||
jest.spyOn(console, 'log').mockImplementation();
|
||||
jest.spyOn(console, 'error').mockImplementation();
|
||||
});
|
||||
|
||||
it('should return false for items without warning status', () => {
|
||||
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const item = createMockQueueItem({ trackedDownloadStatus: 'ok' });
|
||||
|
||||
expect(cleaner.shouldRemoveItem(item)).toBe(false);
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return false for items not in importPending state', () => {
|
||||
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const item = createMockQueueItem({ trackedDownloadState: 'downloading' });
|
||||
describe('cleanQueue', () => {
|
||||
it('should not process when sonarr is disabled', async () => {
|
||||
const config = createMockConfig({ sonarr: { host: '', enabled: false } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
|
||||
expect(cleaner.shouldRemoveItem(item)).toBe(false);
|
||||
});
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
it('should return false for items without status messages', () => {
|
||||
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const item = createMockQueueItem({ statusMessages: [] });
|
||||
|
||||
expect(cleaner.shouldRemoveItem(item)).toBe(false);
|
||||
});
|
||||
|
||||
describe('quality blocked items', () => {
|
||||
it('should return true when removeQualityBlocked is enabled and item has quality issue', () => {
|
||||
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const item = createQualityBlockedItem();
|
||||
|
||||
expect(cleaner.shouldRemoveItem(item)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when removeQualityBlocked is disabled', () => {
|
||||
const config = createMockConfig({ rules: { removeQualityBlocked: false } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const item = createQualityBlockedItem();
|
||||
|
||||
expect(cleaner.shouldRemoveItem(item)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('archive blocked items', () => {
|
||||
it('should return true when removeArchiveBlocked is enabled and item has archive issue', () => {
|
||||
const config = createMockConfig({ rules: { removeArchiveBlocked: true } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const item = createArchiveBlockedItem();
|
||||
|
||||
expect(cleaner.shouldRemoveItem(item)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when removeArchiveBlocked is disabled', () => {
|
||||
const config = createMockConfig({ rules: { removeArchiveBlocked: false } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const item = createArchiveBlockedItem();
|
||||
|
||||
expect(cleaner.shouldRemoveItem(item)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('processItem', () => {
|
||||
describe('quality blocked items', () => {
|
||||
it('should remove without blocking when blockRemovedQualityReleases is false', async () => {
|
||||
const config = createMockConfig({
|
||||
rules: {
|
||||
removeQualityBlocked: true,
|
||||
blockRemovedQualityReleases: false
|
||||
}
|
||||
expect(mockSonarrClient.getQueue).not.toHaveBeenCalled();
|
||||
});
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const item = createQualityBlockedItem();
|
||||
|
||||
await cleaner.processItem(item);
|
||||
it('should skip non-completed items', async () => {
|
||||
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const items = [createMockQueueItem({ status: 'downloading' })];
|
||||
|
||||
expect(mockSonarrClient.removeFromQueue).toHaveBeenCalledWith(123);
|
||||
expect(mockSonarrClient.blockRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
mockSonarrClient.getQueue.mockResolvedValue(items);
|
||||
|
||||
it('should block when blockRemovedQualityReleases is true', async () => {
|
||||
const config = createMockConfig({
|
||||
rules: {
|
||||
removeQualityBlocked: true,
|
||||
blockRemovedQualityReleases: true
|
||||
}
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
expect(mockSonarrClient.removeFromQueue).not.toHaveBeenCalled();
|
||||
expect(mockSonarrClient.blockRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const item = createQualityBlockedItem();
|
||||
|
||||
await cleaner.processItem(item);
|
||||
it('should skip items without warning status', async () => {
|
||||
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const items = [createMockQueueItem({ trackedDownloadStatus: 'ok' })];
|
||||
|
||||
expect(mockSonarrClient.blockRelease).toHaveBeenCalledWith(123);
|
||||
expect(mockSonarrClient.removeFromQueue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
mockSonarrClient.getQueue.mockResolvedValue(items);
|
||||
|
||||
describe('archive blocked items', () => {
|
||||
it('should remove without blocking when blockRemovedArchiveReleases is false', async () => {
|
||||
const config = createMockConfig({
|
||||
rules: {
|
||||
removeArchiveBlocked: true,
|
||||
blockRemovedArchiveReleases: false
|
||||
}
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
expect(mockSonarrClient.removeFromQueue).not.toHaveBeenCalled();
|
||||
expect(mockSonarrClient.blockRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const item = createArchiveBlockedItem();
|
||||
|
||||
await cleaner.processItem(item);
|
||||
it('should skip items not in importPending state', async () => {
|
||||
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const items = [createMockQueueItem({ trackedDownloadState: 'downloading' })];
|
||||
|
||||
expect(mockSonarrClient.removeFromQueue).toHaveBeenCalledWith(123);
|
||||
expect(mockSonarrClient.blockRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
mockSonarrClient.getQueue.mockResolvedValue(items);
|
||||
|
||||
it('should block when blockRemovedArchiveReleases is true', async () => {
|
||||
const config = createMockConfig({
|
||||
rules: {
|
||||
removeArchiveBlocked: true,
|
||||
blockRemovedArchiveReleases: true
|
||||
}
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
expect(mockSonarrClient.removeFromQueue).not.toHaveBeenCalled();
|
||||
expect(mockSonarrClient.blockRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const item = createArchiveBlockedItem();
|
||||
|
||||
await cleaner.processItem(item);
|
||||
it('should skip items without status messages', async () => {
|
||||
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const items = [createMockQueueItem({ statusMessages: [] })];
|
||||
|
||||
expect(mockSonarrClient.blockRelease).toHaveBeenCalledWith(123);
|
||||
expect(mockSonarrClient.removeFromQueue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
mockSonarrClient.getQueue.mockResolvedValue(items);
|
||||
|
||||
describe('cleanQueue', () => {
|
||||
it('should not process when sonarr host is empty', async () => {
|
||||
const config = createMockConfig({ sonarr: { host: '', enabled: false } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
await cleaner.cleanQueue();
|
||||
expect(mockSonarrClient.removeFromQueue).not.toHaveBeenCalled();
|
||||
expect(mockSonarrClient.blockRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockSonarrClient.getQueue).not.toHaveBeenCalled();
|
||||
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 items = [createQualityBlockedItem()];
|
||||
|
||||
mockSonarrClient.getQueue.mockResolvedValue(items);
|
||||
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
expect(mockSonarrClient.removeFromQueue).toHaveBeenCalledWith(123);
|
||||
expect(mockSonarrClient.blockRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block quality blocked items when blocking enabled', async () => {
|
||||
const config = createMockConfig({
|
||||
rules: {
|
||||
removeQualityBlocked: true,
|
||||
blockRemovedQualityReleases: true
|
||||
}
|
||||
});
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const items = [createQualityBlockedItem()];
|
||||
|
||||
mockSonarrClient.getQueue.mockResolvedValue(items);
|
||||
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
expect(mockSonarrClient.blockRelease).toHaveBeenCalledWith(123);
|
||||
expect(mockSonarrClient.removeFromQueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip quality blocked items when disabled', async () => {
|
||||
const config = createMockConfig({ rules: { removeQualityBlocked: false } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const items = [createQualityBlockedItem()];
|
||||
|
||||
mockSonarrClient.getQueue.mockResolvedValue(items);
|
||||
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
expect(mockSonarrClient.removeFromQueue).not.toHaveBeenCalled();
|
||||
expect(mockSonarrClient.blockRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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 items = [createArchiveBlockedItem()];
|
||||
|
||||
mockSonarrClient.getQueue.mockResolvedValue(items);
|
||||
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
expect(mockSonarrClient.removeFromQueue).toHaveBeenCalledWith(123);
|
||||
expect(mockSonarrClient.blockRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block archive blocked items when blocking enabled', async () => {
|
||||
const config = createMockConfig({
|
||||
rules: {
|
||||
removeArchiveBlocked: true,
|
||||
blockRemovedArchiveReleases: true
|
||||
}
|
||||
});
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const items = [createArchiveBlockedItem()];
|
||||
|
||||
mockSonarrClient.getQueue.mockResolvedValue(items);
|
||||
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
expect(mockSonarrClient.blockRelease).toHaveBeenCalledWith(123);
|
||||
expect(mockSonarrClient.removeFromQueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip archive blocked items when disabled', async () => {
|
||||
const config = createMockConfig({ rules: { removeArchiveBlocked: false } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const items = [createArchiveBlockedItem()];
|
||||
|
||||
mockSonarrClient.getQueue.mockResolvedValue(items);
|
||||
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
expect(mockSonarrClient.removeFromQueue).not.toHaveBeenCalled();
|
||||
expect(mockSonarrClient.blockRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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 items = [createNoFilesBlockedItem()];
|
||||
|
||||
mockSonarrClient.getQueue.mockResolvedValue(items);
|
||||
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
expect(mockSonarrClient.removeFromQueue).toHaveBeenCalledWith(123);
|
||||
expect(mockSonarrClient.blockRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block no files blocked items when blocking enabled', async () => {
|
||||
const config = createMockConfig({
|
||||
rules: {
|
||||
removeNoFilesReleases: true,
|
||||
blockRemovedNoFilesReleases: true
|
||||
}
|
||||
});
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const items = [createNoFilesBlockedItem()];
|
||||
|
||||
mockSonarrClient.getQueue.mockResolvedValue(items);
|
||||
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
expect(mockSonarrClient.blockRelease).toHaveBeenCalledWith(123);
|
||||
expect(mockSonarrClient.removeFromQueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip no files blocked items when disabled', async () => {
|
||||
const config = createMockConfig({ rules: { removeNoFilesReleases: false } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
const items = [createNoFilesBlockedItem()];
|
||||
|
||||
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: {
|
||||
removeQualityBlocked: true,
|
||||
removeArchiveBlocked: true,
|
||||
removeNoFilesReleases: true
|
||||
}
|
||||
});
|
||||
const cleaner = new QueueCleaner(config);
|
||||
|
||||
const items = [
|
||||
createQualityBlockedItem(),
|
||||
createArchiveBlockedItem(),
|
||||
createNoFilesBlockedItem(),
|
||||
createMockQueueItem({ status: 'downloading' }) // Should be ignored
|
||||
];
|
||||
|
||||
mockSonarrClient.getQueue.mockResolvedValue(items);
|
||||
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
expect(mockSonarrClient.removeFromQueue).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
|
||||
mockSonarrClient.getQueue.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith('Error cleaning queue:', 'API Error');
|
||||
});
|
||||
});
|
||||
|
||||
it('should process all matching items', async () => {
|
||||
const config = createMockConfig({
|
||||
rules: {
|
||||
removeQualityBlocked: true,
|
||||
removeArchiveBlocked: true
|
||||
}
|
||||
});
|
||||
const cleaner = new QueueCleaner(config);
|
||||
|
||||
const items = [
|
||||
createQualityBlockedItem(),
|
||||
createArchiveBlockedItem(),
|
||||
createMockQueueItem({ status: 'downloading' }) // Should be ignored
|
||||
];
|
||||
|
||||
mockSonarrClient.getQueue.mockResolvedValue(items);
|
||||
describe('configuration combinations', () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'all rules disabled',
|
||||
config: { removeQualityBlocked: false, removeArchiveBlocked: false, removeNoFilesReleases: false },
|
||||
expectProcessed: 0
|
||||
},
|
||||
{
|
||||
name: 'only quality removal enabled',
|
||||
config: { removeQualityBlocked: true, removeArchiveBlocked: false, removeNoFilesReleases: false },
|
||||
expectProcessed: 1
|
||||
},
|
||||
{
|
||||
name: 'only archive removal enabled',
|
||||
config: { removeQualityBlocked: false, removeArchiveBlocked: true, removeNoFilesReleases: false },
|
||||
expectProcessed: 1
|
||||
},
|
||||
{
|
||||
name: 'only no files removal enabled',
|
||||
config: { removeQualityBlocked: false, removeArchiveBlocked: false, removeNoFilesReleases: true },
|
||||
expectProcessed: 1
|
||||
},
|
||||
{
|
||||
name: 'all removals enabled',
|
||||
config: { removeQualityBlocked: true, removeArchiveBlocked: true, removeNoFilesReleases: true },
|
||||
expectProcessed: 3
|
||||
}
|
||||
];
|
||||
|
||||
await cleaner.cleanQueue();
|
||||
testCases.forEach(({ name, config: rules, expectProcessed }) => {
|
||||
it(`should handle ${name}`, async () => {
|
||||
const config = createMockConfig({ rules });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
|
||||
expect(mockSonarrClient.removeFromQueue).toHaveBeenCalledTimes(2);
|
||||
const items = [createQualityBlockedItem(), createArchiveBlockedItem(), createNoFilesBlockedItem()];
|
||||
mockSonarrClient.getQueue.mockResolvedValue(items);
|
||||
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
expect(mockSonarrClient.removeFromQueue).toHaveBeenCalledTimes(expectProcessed);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const config = createMockConfig({ rules: { removeQualityBlocked: true } });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
|
||||
mockSonarrClient.getQueue.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith('Error cleaning queue:', 'API Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration combinations', () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'all rules disabled',
|
||||
config: { removeQualityBlocked: false, removeArchiveBlocked: false },
|
||||
expectProcessed: 0
|
||||
},
|
||||
{
|
||||
name: 'only quality removal enabled',
|
||||
config: { removeQualityBlocked: true, removeArchiveBlocked: false },
|
||||
expectProcessed: 1
|
||||
},
|
||||
{
|
||||
name: 'only archive removal enabled',
|
||||
config: { removeQualityBlocked: false, removeArchiveBlocked: true },
|
||||
expectProcessed: 1
|
||||
},
|
||||
{
|
||||
name: 'both removals enabled',
|
||||
config: { removeQualityBlocked: true, removeArchiveBlocked: true },
|
||||
expectProcessed: 2
|
||||
}
|
||||
];
|
||||
|
||||
testCases.forEach(({ name, config: rules, expectProcessed }) => {
|
||||
it(`should handle ${name}`, async () => {
|
||||
const config = createMockConfig({ rules });
|
||||
const cleaner = new QueueCleaner(config);
|
||||
|
||||
const items = [createQualityBlockedItem(), createArchiveBlockedItem()];
|
||||
mockSonarrClient.getQueue.mockResolvedValue(items);
|
||||
|
||||
await cleaner.cleanQueue();
|
||||
|
||||
expect(mockSonarrClient.removeFromQueue).toHaveBeenCalledTimes(expectProcessed);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,68 +5,68 @@ jest.mock('axios');
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
describe('SonarrClient', () => {
|
||||
let client: SonarrClient;
|
||||
let mockAxiosInstance: any;
|
||||
let client: SonarrClient;
|
||||
let mockAxiosInstance: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockAxiosInstance = {
|
||||
get: jest.fn(),
|
||||
delete: jest.fn()
|
||||
};
|
||||
mockedAxios.create.mockReturnValue(mockAxiosInstance);
|
||||
|
||||
// Mock console.log to avoid test output
|
||||
jest.spyOn(console, 'log').mockImplementation();
|
||||
|
||||
client = new SonarrClient('http://localhost:8989', 'test-key', 'info');
|
||||
});
|
||||
beforeEach(() => {
|
||||
mockAxiosInstance = {
|
||||
get: jest.fn(),
|
||||
delete: jest.fn()
|
||||
};
|
||||
mockedAxios.create.mockReturnValue(mockAxiosInstance);
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
// Mock console.log to avoid test output
|
||||
jest.spyOn(console, 'log').mockImplementation();
|
||||
|
||||
describe('getQueue', () => {
|
||||
it('should return queue records', async () => {
|
||||
const mockData = { records: [{ id: 1, title: 'test' }] };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: mockData });
|
||||
|
||||
const result = await client.getQueue();
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/queue');
|
||||
expect(result).toEqual(mockData.records);
|
||||
client = new SonarrClient('http://localhost:8989', 'test-key', 'info');
|
||||
});
|
||||
|
||||
it('should return data directly if no records property', async () => {
|
||||
const mockData = [{ id: 1, title: 'test' }];
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: mockData });
|
||||
|
||||
const result = await client.getQueue();
|
||||
|
||||
expect(result).toEqual(mockData);
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFromQueue', () => {
|
||||
it('should call delete with correct parameters', async () => {
|
||||
mockAxiosInstance.delete.mockResolvedValue({ data: {} });
|
||||
describe('getQueue', () => {
|
||||
it('should return queue records', async () => {
|
||||
const mockData = { records: [{ id: 1, title: 'test' }] };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: mockData });
|
||||
|
||||
await client.removeFromQueue(123);
|
||||
const result = await client.getQueue();
|
||||
|
||||
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/queue/123', {
|
||||
params: { removeFromClient: true, blocklist: false }
|
||||
});
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/queue');
|
||||
expect(result).toEqual(mockData.records);
|
||||
});
|
||||
|
||||
it('should return data directly if no records property', async () => {
|
||||
const mockData = [{ id: 1, title: 'test' }];
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: mockData });
|
||||
|
||||
const result = await client.getQueue();
|
||||
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('blockRelease', () => {
|
||||
it('should call delete with blocklist true', async () => {
|
||||
mockAxiosInstance.delete.mockResolvedValue({ data: {} });
|
||||
describe('removeFromQueue', () => {
|
||||
it('should call delete with correct parameters', async () => {
|
||||
mockAxiosInstance.delete.mockResolvedValue({ data: {} });
|
||||
|
||||
await client.blockRelease(123);
|
||||
await client.removeFromQueue(123);
|
||||
|
||||
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/queue/123', {
|
||||
params: { removeFromClient: true, blocklist: true }
|
||||
});
|
||||
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/queue/123', {
|
||||
params: { removeFromClient: true, blocklist: false }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('blockRelease', () => {
|
||||
it('should call delete with blocklist true', async () => {
|
||||
mockAxiosInstance.delete.mockResolvedValue({ data: {} });
|
||||
|
||||
await client.blockRelease(123);
|
||||
|
||||
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/queue/123', {
|
||||
params: { removeFromClient: true, blocklist: true }
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,47 +1,56 @@
|
||||
import { Config, QueueItem } from '../src/types';
|
||||
|
||||
type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
|
||||
[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,
|
||||
...overrides.rules
|
||||
},
|
||||
schedule: overrides.schedule || '*/5 * * * *',
|
||||
logLevel: overrides.logLevel || 'info'
|
||||
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,
|
||||
...overrides.rules
|
||||
},
|
||||
schedule: overrides.schedule || '*/5 * * * *',
|
||||
logLevel: overrides.logLevel || 'info'
|
||||
});
|
||||
|
||||
export const createMockQueueItem = (overrides: Partial<QueueItem> = {}): QueueItem => ({
|
||||
id: 123,
|
||||
title: 'Test.Show.S01E01',
|
||||
status: 'completed',
|
||||
trackedDownloadStatus: 'warning',
|
||||
trackedDownloadState: 'importPending',
|
||||
statusMessages: [],
|
||||
...overrides
|
||||
id: 123,
|
||||
title: 'Test.Show.S01E01',
|
||||
status: 'completed',
|
||||
trackedDownloadStatus: 'warning',
|
||||
trackedDownloadState: 'importPending',
|
||||
statusMessages: [],
|
||||
...overrides
|
||||
});
|
||||
|
||||
export const createQualityBlockedItem = (): QueueItem =>
|
||||
createMockQueueItem({
|
||||
statusMessages: [{
|
||||
messages: ['upgrade for existing episode']
|
||||
}]
|
||||
});
|
||||
export const createQualityBlockedItem = (): QueueItem =>
|
||||
createMockQueueItem({
|
||||
statusMessages: [{
|
||||
messages: ['upgrade for existing episode']
|
||||
}]
|
||||
});
|
||||
|
||||
export const createArchiveBlockedItem = (): QueueItem =>
|
||||
createMockQueueItem({
|
||||
statusMessages: [{
|
||||
messages: ['Found archive file, might need to be extracted']
|
||||
}]
|
||||
});
|
||||
export const createArchiveBlockedItem = (): QueueItem =>
|
||||
createMockQueueItem({
|
||||
statusMessages: [{
|
||||
messages: ['Found archive file, might need to be extracted']
|
||||
}]
|
||||
});
|
||||
|
||||
export const createNoFilesBlockedItem = (): QueueItem =>
|
||||
createMockQueueItem({
|
||||
statusMessages: [{
|
||||
messages: ['No files found are eligible for import']
|
||||
}]
|
||||
});
|
||||
|
||||
11
tsconfig.eslint.json
Normal file
11
tsconfig.eslint.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"tests/**/*",
|
||||
"*.config.js",
|
||||
"*.config.mjs",
|
||||
"*.config.ts"
|
||||
],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user