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
68 changes: 39 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ An automated subtitle synchronization tool that runs as a Docker container. It c
- **Configuration Dashboard** - View current settings, monitored paths, and schedule status
- **Configurable Timeouts** - Set per-engine timeout limits to prevent hung processes
- **Log Management** - Configurable retention policies with automatic trimming and deletion
- **SRT and ASS Subtitles** - Syncs SubRip (`.srt`) and Advanced SubStation Alpha (`.ass`) subtitles. Outputs keep the input's format (e.g. `movie.en.ffsubsync.ass`).
- **Non Destructive** - Creates new files for each engine so no original files are altered. Allows easy switching between engines while watching content.

## Quick Start
Expand Down Expand Up @@ -97,36 +98,36 @@ docker run -d \

### Core Configuration

| Variable | Default | Description |
| --------------------------- | ----------------------------- | -------------------------------------------------------------------------------- |
| `SCAN_PATHS` | `/scan_dir` | Comma-separated directories to scan for SRT files (must be mounted as volumes) |
| `EXCLUDE_PATHS` | _(none)_ | Comma-separated directories to exclude from scanning |
| Variable | Default | Description |
| --------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SCAN_PATHS` | `/scan_dir` | Comma-separated directories to scan for subtitle files (must be mounted as volumes) |
| `EXCLUDE_PATHS` | _(none)_ | Comma-separated directories to exclude from scanning |
| `SYNC_LANGUAGES` | _(none)_ | Comma-separated language codes to sync (e.g., `en,de`). Only syncs subtitles with matching language tags in the filename (e.g., `movie.en.srt`). If not set, all subtitles are synced. |
| `CRON_SCHEDULE` | `0 0 * * *` | Cron expression for sync schedule (daily at midnight), or `disabled` to turn off |
| `MAX_CONCURRENT_SYNC_TASKS` | `1` | Number of subtitle files to process in parallel (higher = faster but more CPU) |
| `INCLUDE_ENGINES` | `ffsubsync,autosubsync,alass` | Which sync engines to use (comma-separated) |
| `FFSUBSYNC_SUFFIX` | `ffsubsync` | Custom suffix for ffsubsync output files (e.g., `sdh` → `movie.en.sdh.srt`) |
| `AUTOSUBSYNC_SUFFIX` | `autosubsync` | Custom suffix for autosubsync output files (e.g., `cc` → `movie.en.cc.srt`) |
| `ALASS_SUFFIX` | `alass` | Custom suffix for alass output files (e.g., `forced` → `movie.en.forced.srt`) |
| `SYNC_TIMEOUT` | _(none)_ | Timeout in seconds per sync operation (overrides SYNC_ENGINE_TIMEOUT_MS) |
| `SYNC_ENGINE_TIMEOUT_MS` | `1800000` | Timeout for each sync engine in milliseconds (30 min default) |
| `NODE_OPTIONS` | `--max-old-space-size=512` | Node.js options, used here to set memory limit (in MB) |
| `WEB_PORT` | `3000` | Port for the web UI |
| `WEB_HOST` | `127.0.0.1` | Host to bind the web UI to (`0.0.0.0` to expose externally) |
| `TZ` | _(system)_ | Timezone for logging and cron scheduling (e.g., `America/New_York`) |
| `PUID` | `1000` | User ID for file permissions (run `id -u` to find yours) |
| `PGID` | `1000` | Group ID for file permissions (run `id -g` to find yours) |
| `CRON_SCHEDULE` | `0 0 * * *` | Cron expression for sync schedule (daily at midnight), or `disabled` to turn off |
| `MAX_CONCURRENT_SYNC_TASKS` | `1` | Number of subtitle files to process in parallel (higher = faster but more CPU) |
| `INCLUDE_ENGINES` | `ffsubsync,autosubsync,alass` | Which sync engines to use (comma-separated) |
| `FFSUBSYNC_SUFFIX` | `ffsubsync` | Custom suffix for ffsubsync output files (e.g., `sdh` → `movie.en.sdh.srt`) |
| `AUTOSUBSYNC_SUFFIX` | `autosubsync` | Custom suffix for autosubsync output files (e.g., `cc` → `movie.en.cc.srt`) |
| `ALASS_SUFFIX` | `alass` | Custom suffix for alass output files (e.g., `forced` → `movie.en.forced.srt`) |
| `SYNC_TIMEOUT` | _(none)_ | Timeout in seconds per sync operation (overrides SYNC_ENGINE_TIMEOUT_MS) |
| `SYNC_ENGINE_TIMEOUT_MS` | `1800000` | Timeout for each sync engine in milliseconds (30 min default) |
| `NODE_OPTIONS` | `--max-old-space-size=512` | Node.js options, used here to set memory limit (in MB) |
| `WEB_PORT` | `3000` | Port for the web UI |
| `WEB_HOST` | `127.0.0.1` | Host to bind the web UI to (`0.0.0.0` to expose externally) |
| `TZ` | _(system)_ | Timezone for logging and cron scheduling (e.g., `America/New_York`) |
| `PUID` | `1000` | User ID for file permissions (run `id -u` to find yours) |
| `PGID` | `1000` | Group ID for file permissions (run `id -g` to find yours) |

### Database & Log Configuration

| Variable | Default | Description |
| ---------------------------------- | ------------------------------ | ------------------------------------------- |
| Variable | Default | Description |
| ---------------------------------- | ------------------------- | ------------------------------------------- |
| `DB_PATH` | `/app/data/subsyncarr.db` | SQLite database location |
| `LOG_BUFFER_SIZE` | `1000` | Ring buffer size for in-memory logs |
| `RETENTION_KEEP_RUNS_DAYS` | `30` | Keep complete runs for N days |
| `RETENTION_TRIM_LOGS_DAYS` | `7` | Trim logs after N days (keeps summary only) |
| `RETENTION_MAX_LOG_SIZE` | `10000` | Max size for trimmed logs in bytes |
| `RETENTION_CLEANUP_INTERVAL_HOURS` | `24` | How often to run cleanup (in hours) |
| `LOG_BUFFER_SIZE` | `1000` | Ring buffer size for in-memory logs |
| `RETENTION_KEEP_RUNS_DAYS` | `30` | Keep complete runs for N days |
| `RETENTION_TRIM_LOGS_DAYS` | `7` | Trim logs after N days (keeps summary only) |
| `RETENTION_MAX_LOG_SIZE` | `10000` | Max size for trimmed logs in bytes |
| `RETENTION_CLEANUP_INTERVAL_HOURS` | `24` | How often to run cleanup (in hours) |

### Timeout Configuration

Expand All @@ -139,7 +140,6 @@ environment:
- SYNC_ENGINE_TIMEOUT_MS=3600000 # 60 minutes for large files
```


### Directory Structure

Your media directory should be organized with video files and their corresponding subtitle files using matching names:
Expand All @@ -159,6 +159,16 @@ Your media directory should be organized with video files and their correspondin
│ │ └── Show.S01E01.srt
```

`.ass` subtitles are handled the same way and produce `.ass` outputs (e.g. `Movie Title (2024).en.ass` → `Movie Title (2024).en.ffsubsync.ass`). Generated outputs are never picked up as inputs.

**Engine support by format:**

| Engine | `.srt` | `.ass` |
| ----------- | ------ | ------------------------------------------ |
| ffsubsync | Yes | Yes |
| alass | Yes | Yes |
| autosubsync | Yes | No (skipped for `.ass` with a log message) |

The app follows standard naming conventions compatible with Plex, Jellyfin, Emby, and Bazarr.

## Web UI
Expand Down Expand Up @@ -272,15 +282,15 @@ If the container is being killed due to OOM (Out Of Memory):
2. Increase memory limit in `NODE_OPTIONS` (e.g., `--max-old-space-size=1024`)
3. Increase memory limit in docker-compose.yaml
4. Reduce `SYNC_ENGINE_TIMEOUT_MS` for faster timeouts
4. Exclude large files or problematic directories with `EXCLUDE_PATHS`
5. Exclude large files or problematic directories with `EXCLUDE_PATHS`

### Files Not Being Processed

Check that:

1. Your subtitle files are named to match video files (e.g., `movie.mkv` and `movie.srt`)
1. Your subtitle files are named to match video files (e.g., `movie.mkv` and `movie.srt` or `movie.ass`)
2. `SCAN_PATHS` matches your mounted volumes
3. Files haven't already been synced (check for `.ffsubsync.srt` files)
3. Files haven't already been synced (check for `.ffsubsync.srt` / `.ffsubsync.ass` files)
4. Files aren't being auto-skipped due to repeated failures (check skip status in Web UI)

## Docker Hub
Expand Down
43 changes: 43 additions & 0 deletions src/__tests__/findAllSrtFiles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { findAllSrtFiles } from '../findAllSrtFiles';

describe('findAllSrtFiles', () => {
let dir: string;
const originalEngines = process.env.INCLUDE_ENGINES;

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'find-subs-test-'));
jest.spyOn(console, 'log').mockImplementation(() => undefined);
});

afterEach(() => {
rmSync(dir, { recursive: true, force: true });
jest.restoreAllMocks();
if (originalEngines === undefined) delete process.env.INCLUDE_ENGINES;
else process.env.INCLUDE_ENGINES = originalEngines;
});

const touch = (...names: string[]) => names.forEach((name) => writeFileSync(join(dir, name), 'x'));
const scan = () => findAllSrtFiles({ includePaths: [dir], excludePaths: [] });

it('finds .srt and .ass files but never generated outputs', async () => {
process.env.INCLUDE_ENGINES = 'ffsubsync,alass';
touch('a.en.srt', 'b.en.ass', 'b.en.ffsubsync.ass', 'c.en.alass.ass', 'd.vtt');
const { files } = await scan();
expect(files.sort()).toEqual([join(dir, 'a.en.srt'), join(dir, 'b.en.ass')]);
});

it('treats .ass as synced once every engine that supports it has output', async () => {
process.env.INCLUDE_ENGINES = 'ffsubsync,autosubsync,alass';
touch('movie.en.ass', 'movie.en.ffsubsync.ass', 'movie.en.alass.ass');
expect(await scan()).toEqual({ files: [], skippedCount: 1 });
});

it('ignores .ass files when only unsupported engines are enabled', async () => {
process.env.INCLUDE_ENGINES = 'autosubsync';
touch('movie.en.ass', 'movie.en.srt');
expect(await scan()).toEqual({ files: [join(dir, 'movie.en.srt')], skippedCount: 0 });
});
});
26 changes: 26 additions & 0 deletions src/__tests__/findMatchingVideoFile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { findMatchingVideoFile } from '../findMatchingVideoFile';

describe('findMatchingVideoFile', () => {
let dir: string;

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'video-match-test-'));
writeFileSync(join(dir, 'movie.mkv'), 'x');
});

afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});

it('matches .ass subtitles to their video', () => {
expect(findMatchingVideoFile(join(dir, 'movie.ass'))).toBe(join(dir, 'movie.mkv'));
expect(findMatchingVideoFile(join(dir, 'movie.en.ass'))).toBe(join(dir, 'movie.mkv'));
});

it('still matches .srt subtitles', () => {
expect(findMatchingVideoFile(join(dir, 'movie.en.srt'))).toBe(join(dir, 'movie.mkv'));
});
});
13 changes: 13 additions & 0 deletions src/__tests__/generateAutosubsyncSubtitles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { execPromise } from '../execPromise';
import { generateAutosubsyncSubtitles } from '../generateAutosubsyncSubtitles';

jest.mock('../execPromise', () => ({ execPromise: jest.fn() }));

describe('generateAutosubsyncSubtitles', () => {
it('skips .ass subtitles without running autosubsync', async () => {
const result = await generateAutosubsyncSubtitles('/no/such/movie.en.ass', '/no/such/movie.mkv');
expect(result.skipped).toBe(true);
expect(result.message).toContain('does not support .ass');
expect(execPromise).not.toHaveBeenCalled();
});
});
19 changes: 19 additions & 0 deletions src/__tests__/generateFfsubsyncSubtitles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { execPromise } from '../execPromise';
import { generateFfsubsyncSubtitles } from '../generateFfsubsyncSubtitles';

jest.mock('../execPromise', () => ({ execPromise: jest.fn() }));

describe('generateFfsubsyncSubtitles', () => {
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation(() => undefined);
jest.mocked(execPromise).mockResolvedValue({ stdout: '', stderr: '' });
});

it('syncs .ass subtitles to an .ass output', async () => {
const result = await generateFfsubsyncSubtitles('/no/such/movie.en.ass', '/no/such/movie.mkv');
expect(result.success).toBe(true);
expect(execPromise).toHaveBeenCalledWith(
'ffsubsync "/no/such/movie.mkv" -i "/no/such/movie.en.ass" -o "/no/such/movie.en.ffsubsync.ass"',
);
});
});
16 changes: 16 additions & 0 deletions src/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { buildOutputPath } from '../helpers';

describe('buildOutputPath', () => {
it('keeps the .srt extension', () => {
expect(buildOutputPath('/m/movie.en.srt', 'ffsubsync')).toBe('/m/movie.en.ffsubsync.srt');
});

it('keeps the .ass extension', () => {
expect(buildOutputPath('/m/movie.en.ass', 'ffsubsync')).toBe('/m/movie.en.ffsubsync.ass');
expect(buildOutputPath('/m/movie.en.ass', 'alass')).toBe('/m/movie.en.alass.ass');
});

it('does not double up an existing suffix', () => {
expect(buildOutputPath('/m/movie.en.ffsubsync.ass', 'ffsubsync')).toBe('/m/movie.en.ffsubsync.ass');
});
});
38 changes: 38 additions & 0 deletions src/__tests__/subtitleFormats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
engineSupportsSubtitle,
getSubtitleExtension,
isSubtitleFile,
stripSubtitleExtension,
unsupportedFormatResult,
} from '../subtitleFormats';

describe('subtitleFormats', () => {
it('recognises .srt and .ass case-insensitively', () => {
expect(getSubtitleExtension('/m/movie.en.srt')).toBe('.srt');
expect(getSubtitleExtension('/m/movie.en.ASS')).toBe('.ass');
expect(isSubtitleFile('movie.vtt')).toBe(false);
expect(isSubtitleFile('movie.mkv')).toBe(false);
});

it('strips the directory and subtitle extension', () => {
expect(stripSubtitleExtension('/m/movie.en.ass')).toBe('movie.en');
expect(stripSubtitleExtension('/m/movie.en.srt')).toBe('movie.en');
expect(stripSubtitleExtension('/m/movie.txt')).toBe('movie.txt');
});

it('knows which engines support .ass', () => {
expect(engineSupportsSubtitle('ffsubsync', 'a.ass')).toBe(true);
expect(engineSupportsSubtitle('alass', 'a.ass')).toBe(true);
expect(engineSupportsSubtitle('autosubsync', 'a.ass')).toBe(false);
expect(engineSupportsSubtitle('autosubsync', 'a.srt')).toBe(true);
});

it('returns a skip result only for unsupported formats', () => {
expect(unsupportedFormatResult('ffsubsync', '/m/movie.en.ass')).toBeNull();
expect(unsupportedFormatResult('autosubsync', '/m/movie.en.ass')).toEqual({
success: true,
skipped: true,
message: 'Skipping autosubsync for movie.en.ass - autosubsync does not support .ass subtitles',
});
});
});
Loading