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:
TheLegendTubaGuy
2025-09-10 22:01:48 -05:00
committed by GitHub
parent 3217417d31
commit e2a032e7da
15 changed files with 1476 additions and 519 deletions

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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
View 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',
],
}
);

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}
}
}

View File

@@ -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;

View File

@@ -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)}`);
}
}

View File

@@ -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;
}

View File

@@ -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);
});
});
});
});

View File

@@ -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 }
});
});
});
});
});

View File

@@ -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
View File

@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*",
"tests/**/*",
"*.config.js",
"*.config.mjs",
"*.config.ts"
],
"exclude": ["node_modules", "dist"]
}