Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { existsSync, readdirSync, statSync } from 'fs';
import { platform, homedir } from 'os';

import {
ARR_LOG_FILE_CONFIG,
LIDARR_LOG_FILE_CONFIG,
PROWLARR_LOG_FILE_CONFIG,
RADARR_LOG_FILE_CONFIG,
READARR_LOG_FILE_CONFIG,
SONARR_LOG_FILE_CONFIG,
WHISPARR_LOG_FILE_CONFIG,
} from '@logarr/provider-arr';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { FileDiscoveryService } from './file-discovery.service';
Expand Down Expand Up @@ -288,6 +297,79 @@ describe('FileDiscoveryService', () => {
});

describe('discoverLogFiles', () => {
describe('exclude patterns', () => {
const mockDir = (names: string[]): void => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(statSync).mockReturnValue({
isDirectory: () => true,
isFile: () => false,
mtimeMs: Date.now(),
} as ReturnType<typeof statSync>);
vi.mocked(readdirSync).mockReturnValue(
names.map((name) => ({
name,
isFile: () => true,
isDirectory: () => false,
})) as unknown as ReturnType<typeof readdirSync>
);
};

it('skips files matching an exclude pattern', async () => {
mockDir(['app.txt', 'app.debug.txt']);

const files = await service.discoverLogFiles(['/logs'], ['*.txt'], ['*.debug.*']);

expect(files).toEqual(['/logs/app.txt']);
});

it('does not exclude names that merely start with debug/trace', async () => {
mockDir(['foo.debugger.txt', 'foo.tracer.txt', 'foo.debug.txt']);

const files = await service.discoverLogFiles(
['/logs'],
['*.txt'],
['*.debug.*', '*.trace.*']
);

expect(files.map((f) => f.replace('/logs/', '')).sort()).toEqual([
'foo.debugger.txt',
'foo.tracer.txt',
]);
});

it.each([
['sonarr', SONARR_LOG_FILE_CONFIG],
['radarr', RADARR_LOG_FILE_CONFIG],
['prowlarr', PROWLARR_LOG_FILE_CONFIG],
['whisparr', WHISPARR_LOG_FILE_CONFIG],
['lidarr', LIDARR_LOG_FILE_CONFIG],
['readarr', READARR_LOG_FILE_CONFIG],
['arr', ARR_LOG_FILE_CONFIG],
])(
'%s config ingests main and rotated logs but not debug/trace logs',
async (app, config) => {
const name = app === 'arr' ? 'radarr' : app;
const wanted = [`${name}.txt`, `${name}.0.txt`, `${name}.1.txt`, `${name}.9.txt`];
const unwanted = [
`${name}.debug.txt`,
`${name}.debug.0.txt`,
`${name}.debug.49.txt`,
`${name}.trace.txt`,
`${name}.trace.3.txt`,
];
mockDir([...wanted, ...unwanted]);

const files = await service.discoverLogFiles(
['/logs'],
[...config.filePatterns],
[...(config.excludePatterns ?? [])]
);

expect(files.map((f) => f.replace('/logs/', '')).sort()).toEqual([...wanted].sort());
}
);
});

it('should skip non-existent paths', async () => {
vi.mocked(existsSync).mockReturnValue(false);

Expand Down
36 changes: 31 additions & 5 deletions apps/backend/src/modules/file-ingestion/file-discovery.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface LogFileConfig {
macos: readonly string[];
};
filePatterns: readonly string[];
excludePatterns?: readonly string[];
encoding?: string;
rotatesDaily?: boolean;
datePattern?: RegExp;
Expand Down Expand Up @@ -58,8 +59,13 @@ export class FileDiscoveryService {

/**
* Discover log files in the given paths matching the patterns
* (files matching any exclude pattern are skipped)
*/
async discoverLogFiles(paths: string[], patterns: string[]): Promise<string[]> {
async discoverLogFiles(
paths: string[],
patterns: string[],
excludePatterns: string[] = []
): Promise<string[]> {
const discoveredFiles: string[] = [];

for (const basePath of paths) {
Expand All @@ -75,11 +81,15 @@ export class FileDiscoveryService {

if (stats.isDirectory()) {
// Discover files in directory
const files = await this.discoverFilesInDirectory(expandedPath, patterns);
const files = await this.discoverFilesInDirectory(
expandedPath,
patterns,
excludePatterns
);
discoveredFiles.push(...files);
} else if (stats.isFile()) {
// Direct file path
if (this.matchesPatterns(expandedPath, patterns)) {
if (this.isIncluded(expandedPath, patterns, excludePatterns)) {
discoveredFiles.push(expandedPath);
}
}
Expand All @@ -96,7 +106,11 @@ export class FileDiscoveryService {
/**
* Discover files in a directory matching patterns
*/
private async discoverFilesInDirectory(dirPath: string, patterns: string[]): Promise<string[]> {
private async discoverFilesInDirectory(
dirPath: string,
patterns: string[],
excludePatterns: string[]
): Promise<string[]> {
const files: string[] = [];

try {
Expand All @@ -106,7 +120,7 @@ export class FileDiscoveryService {
const fullPath = join(dirPath, entry.name);

if (entry.isFile()) {
if (this.matchesPatterns(entry.name, patterns)) {
if (this.isIncluded(entry.name, patterns, excludePatterns)) {
files.push(fullPath);
}
} else if (entry.isDirectory()) {
Expand All @@ -121,6 +135,18 @@ export class FileDiscoveryService {
return files;
}

/**
* A file is included when it matches an include pattern and no exclude pattern.
* Patterns are matched against the base name (a direct file path is reduced to it).
*/
private isIncluded(filename: string, patterns: string[], excludePatterns: string[]): boolean {
const baseName = filename.split(/[/\\]/).pop() ?? filename;
if (excludePatterns.length > 0 && this.matchesPatterns(baseName, excludePatterns)) {
return false;
}
return this.matchesPatterns(filename, patterns);
}

/**
* Check if a filename matches any of the patterns
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,94 @@ describe('FileIngestionService', () => {
});
});

describe('exclude pattern wiring', () => {
const logFileConfig = {
filePatterns: ['radarr.txt', 'radarr.*.txt'],
excludePatterns: ['*.debug.*', '*.trace.*'],
};
const providerFor = (): Map<string, unknown> =>
new Map([['arr', { getLogFileConfig: vi.fn().mockReturnValue(logFileConfig) }]]);

const baseServer = {
id: 'server-1',
name: 'Radarr',
providerId: 'arr',
logPaths: ['/logs'],
logFilePatterns: null as string[] | null,
fileIngestionEnabled: true,
initialSyncCompleted: false,
};

beforeEach(() => {
mockFileDiscoveryService.discoverLogFiles.mockResolvedValue([]);
mockFileDiscoveryService.validatePath.mockResolvedValue({ accessible: true });
mockSettingsService.getFileIngestionSettings.mockRejectedValue(new Error('use defaults'));
mockDb.update.mockReturnValue({
set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
});
});

const mockSelectServer = (server: typeof baseServer): void => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([server]) }),
});
};

describe('startServerFileIngestion', () => {
beforeEach(() => {
service.validateLogPaths = vi.fn().mockResolvedValue({ valid: true, results: [] });
});

it('passes the provider excludePatterns to discovery', async () => {
await service.startServerFileIngestion(baseServer as any, providerFor() as any);

expect(mockFileDiscoveryService.discoverLogFiles).toHaveBeenCalledWith(
['/logs'],
['radarr.txt', 'radarr.*.txt'],
['*.debug.*', '*.trace.*']
);
});

it('passes no excludes when the server has custom logFilePatterns', async () => {
const server = { ...baseServer, logFilePatterns: ['*.txt'] };

await service.startServerFileIngestion(server as any, providerFor() as any);

expect(mockFileDiscoveryService.discoverLogFiles).toHaveBeenCalledWith(
['/logs'],
['*.txt'],
[]
);
});
});

describe('backfillFromFiles', () => {
it('passes the provider excludePatterns to discovery', async () => {
mockSelectServer(baseServer);

await service.backfillFromFiles('server-1', providerFor() as any);

expect(mockFileDiscoveryService.discoverLogFiles).toHaveBeenCalledWith(
['/logs'],
['radarr.txt', 'radarr.*.txt'],
['*.debug.*', '*.trace.*']
);
});

it('passes no excludes when the server has custom logFilePatterns', async () => {
mockSelectServer({ ...baseServer, logFilePatterns: ['*.txt'] });

await service.backfillFromFiles('server-1', providerFor() as any);

expect(mockFileDiscoveryService.discoverLogFiles).toHaveBeenCalledWith(
['/logs'],
['*.txt'],
[]
);
});
});
});

describe('error handling and edge cases', () => {
it('should handle settings loading failure', async () => {
mockSettingsService.getFileIngestionSettings.mockRejectedValue(new Error('Settings error'));
Expand Down
19 changes: 17 additions & 2 deletions apps/backend/src/modules/file-ingestion/file-ingestion.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ interface LogFileConfig {
macos: readonly string[];
};
filePatterns: readonly string[];
excludePatterns?: readonly string[];
encoding?: string;
rotatesDaily?: boolean;
datePattern?: RegExp;
Expand Down Expand Up @@ -404,6 +405,11 @@ export class FileIngestionService implements OnModuleInit, OnModuleDestroy {
server.logFilePatterns && server.logFilePatterns.length > 0
? [...server.logFilePatterns]
: [...logFileConfig.filePatterns];
// Exclusions come from the provider config; explicit user patterns are taken as-is
const excludePatterns =
server.logFilePatterns && server.logFilePatterns.length > 0
? []
: [...(logFileConfig.excludePatterns ?? [])];

this.logger.log(`Starting file ingestion for ${server.name}`);
this.logger.log(` Paths: ${paths.join(', ')}`);
Expand Down Expand Up @@ -448,7 +454,11 @@ export class FileIngestionService implements OnModuleInit, OnModuleDestroy {
}

// Discover log files
const allLogFiles = await this.fileDiscoveryService.discoverLogFiles(paths, patterns);
const allLogFiles = await this.fileDiscoveryService.discoverLogFiles(
paths,
patterns,
excludePatterns
);

if (allLogFiles.length === 0) {
this.logger.warn(
Expand Down Expand Up @@ -1258,11 +1268,16 @@ export class FileIngestionService implements OnModuleInit, OnModuleDestroy {
server.logFilePatterns !== null && server.logFilePatterns.length > 0
? [...server.logFilePatterns]
: [...logFileConfig.filePatterns];
const excludePatterns =
server.logFilePatterns !== null && server.logFilePatterns.length > 0
? []
: [...(logFileConfig.excludePatterns ?? [])];

// Discover all log files
const logFiles = await this.fileDiscoveryService.discoverLogFiles(
[...server.logPaths],
patterns
patterns,
excludePatterns
);

if (logFiles.length === 0) {
Expand Down
Loading