From 027b0a9bef97cb7aa028230ede4edea20874f756 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 7 Nov 2026 09:40:08 -0500 Subject: [PATCH] feat: support .ass subtitle files Discover .ass subtitles alongside .srt and sync them with engines that support the format. Outputs keep the input extension (e.g. movie.en.ffsubsync.ass) and generated .ass outputs are never reprocessed. ffsubsync and alass handle .ass natively; autosubsync only reads SRT, so it is skipped for .ass files with a clear log message instead of failing. Closes #24 --- README.md | 68 +++++++++++-------- src/__tests__/findAllSrtFiles.test.ts | 43 ++++++++++++ src/__tests__/findMatchingVideoFile.test.ts | 26 +++++++ .../generateAutosubsyncSubtitles.test.ts | 13 ++++ .../generateFfsubsyncSubtitles.test.ts | 19 ++++++ src/__tests__/helpers.test.ts | 16 +++++ src/__tests__/subtitleFormats.test.ts | 38 +++++++++++ src/findAllSrtFiles.ts | 23 +++++-- src/findMatchingVideoFile.ts | 5 +- src/generateAlassSubtitles.ts | 4 ++ src/generateAutosubsyncSubtitles.ts | 4 ++ src/generateFfsubsyncSubtitles.ts | 4 ++ src/helpers.ts | 17 +++-- src/index.ts | 6 +- src/processingEngine.ts | 4 +- src/subtitleFormats.ts | 46 +++++++++++++ 16 files changed, 288 insertions(+), 48 deletions(-) create mode 100644 src/__tests__/findAllSrtFiles.test.ts create mode 100644 src/__tests__/findMatchingVideoFile.test.ts create mode 100644 src/__tests__/generateAutosubsyncSubtitles.test.ts create mode 100644 src/__tests__/generateFfsubsyncSubtitles.test.ts create mode 100644 src/__tests__/helpers.test.ts create mode 100644 src/__tests__/subtitleFormats.test.ts create mode 100644 src/subtitleFormats.ts diff --git a/README.md b/README.md index 782133c..4774b18 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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: @@ -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 @@ -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 diff --git a/src/__tests__/findAllSrtFiles.test.ts b/src/__tests__/findAllSrtFiles.test.ts new file mode 100644 index 0000000..f4586a6 --- /dev/null +++ b/src/__tests__/findAllSrtFiles.test.ts @@ -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 }); + }); +}); diff --git a/src/__tests__/findMatchingVideoFile.test.ts b/src/__tests__/findMatchingVideoFile.test.ts new file mode 100644 index 0000000..1473f86 --- /dev/null +++ b/src/__tests__/findMatchingVideoFile.test.ts @@ -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')); + }); +}); diff --git a/src/__tests__/generateAutosubsyncSubtitles.test.ts b/src/__tests__/generateAutosubsyncSubtitles.test.ts new file mode 100644 index 0000000..34f98f9 --- /dev/null +++ b/src/__tests__/generateAutosubsyncSubtitles.test.ts @@ -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(); + }); +}); diff --git a/src/__tests__/generateFfsubsyncSubtitles.test.ts b/src/__tests__/generateFfsubsyncSubtitles.test.ts new file mode 100644 index 0000000..1f41933 --- /dev/null +++ b/src/__tests__/generateFfsubsyncSubtitles.test.ts @@ -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"', + ); + }); +}); diff --git a/src/__tests__/helpers.test.ts b/src/__tests__/helpers.test.ts new file mode 100644 index 0000000..4768836 --- /dev/null +++ b/src/__tests__/helpers.test.ts @@ -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'); + }); +}); diff --git a/src/__tests__/subtitleFormats.test.ts b/src/__tests__/subtitleFormats.test.ts new file mode 100644 index 0000000..c814dd3 --- /dev/null +++ b/src/__tests__/subtitleFormats.test.ts @@ -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', + }); + }); +}); diff --git a/src/findAllSrtFiles.ts b/src/findAllSrtFiles.ts index 61db3b3..3d478b2 100644 --- a/src/findAllSrtFiles.ts +++ b/src/findAllSrtFiles.ts @@ -1,9 +1,11 @@ import { readdir } from 'fs/promises'; -import { basename, extname, join } from 'path'; +import { join } from 'path'; import { existsSync } from 'fs'; import { getSuffixConfig, ScanConfig } from './config'; import { buildOutputPath } from './helpers'; +import { engineSupportsSubtitle, isSubtitleFile, stripSubtitleExtension } from './subtitleFormats'; +// Only engines that support the file's format are expected to produce an output function isAlreadySynced(srtPath: string, engines: string[]): boolean { const suffixConfig = getSuffixConfig(); @@ -17,8 +19,8 @@ function isAlreadySynced(srtPath: string, engines: string[]): boolean { function matchesLanguageFilter(fileName: string, languages: string[]): boolean { if (languages.length === 0) return true; - // Extract language tag from filename like "movie.en.srt" or "movie.eng.srt" - const parts = basename(fileName, '.srt').split('.'); + // Extract language tag from filename like "movie.en.srt" or "movie.eng.ass" + const parts = stripSubtitleExtension(fileName).split('.'); if (parts.length < 2) return false; const langTag = parts[parts.length - 1].toLowerCase(); @@ -38,6 +40,7 @@ export async function findAllSrtFiles(config: ScanConfig): Promise { .filter(Boolean) || []; const files: string[] = []; let skippedCount = 0; + let unsupportedCount = 0; if (languages.length > 0) { console.log(`${new Date().toLocaleString()} Language filter active: ${languages.join(', ')}`); @@ -61,11 +64,14 @@ export async function findAllSrtFiles(config: ScanConfig): Promise { await scan(fullPath); } else if ( entry.isFile() && - extname(entry.name).toLowerCase() === '.srt' && + isSubtitleFile(entry.name) && !syncedSuffixes.some((suffix) => entry.name.includes(suffix)) && matchesLanguageFilter(entry.name, languages) ) { - if (isAlreadySynced(fullPath, engines)) { + const supportedEngines = engines.filter((engine) => engineSupportsSubtitle(engine, fullPath)); + if (supportedEngines.length === 0) { + unsupportedCount++; + } else if (isAlreadySynced(fullPath, supportedEngines)) { skippedCount++; } else { files.push(fullPath); @@ -80,7 +86,12 @@ export async function findAllSrtFiles(config: ScanConfig): Promise { } if (skippedCount > 0) { - console.log(`${new Date().toLocaleString()} Skipped ${skippedCount} already-synced SRT files`); + console.log(`${new Date().toLocaleString()} Skipped ${skippedCount} already-synced subtitle files`); + } + if (unsupportedCount > 0) { + console.log( + `${new Date().toLocaleString()} Ignored ${unsupportedCount} subtitle files whose format no enabled engine supports`, + ); } return { files, skippedCount }; diff --git a/src/findMatchingVideoFile.ts b/src/findMatchingVideoFile.ts index 250c553..340d47b 100644 --- a/src/findMatchingVideoFile.ts +++ b/src/findMatchingVideoFile.ts @@ -1,12 +1,13 @@ import { existsSync } from 'fs'; -import { basename, dirname, join } from 'path'; +import { dirname, join } from 'path'; +import { stripSubtitleExtension } from './subtitleFormats'; type VideoExtension = '.mkv' | '.mp4' | '.avi' | '.mov'; const VIDEO_EXTENSIONS: VideoExtension[] = ['.mkv', '.mp4', '.avi', '.mov']; export function findMatchingVideoFile(srtPath: string): string | null { const directory = dirname(srtPath); - const srtBaseName = basename(srtPath, '.srt'); + const srtBaseName = stripSubtitleExtension(srtPath); // Try exact match first for (const ext of VIDEO_EXTENSIONS) { diff --git a/src/generateAlassSubtitles.ts b/src/generateAlassSubtitles.ts index 2f1d148..a0204d0 100644 --- a/src/generateAlassSubtitles.ts +++ b/src/generateAlassSubtitles.ts @@ -1,8 +1,12 @@ import { buildOutputPath, execPromise, ProcessingResult } from './helpers'; import { existsSync } from 'fs'; import { getSuffixConfig } from './config'; +import { unsupportedFormatResult } from './subtitleFormats'; export async function generateAlassSubtitles(srtPath: string, videoPath: string): Promise { + const unsupported = unsupportedFormatResult('alass', srtPath); + if (unsupported) return unsupported; + const outputPath = buildOutputPath(srtPath, getSuffixConfig().alass); const exists = existsSync(outputPath); diff --git a/src/generateAutosubsyncSubtitles.ts b/src/generateAutosubsyncSubtitles.ts index dd64199..93f8083 100644 --- a/src/generateAutosubsyncSubtitles.ts +++ b/src/generateAutosubsyncSubtitles.ts @@ -1,8 +1,12 @@ import { buildOutputPath, execPromise, ProcessingResult } from './helpers'; import { existsSync } from 'fs'; import { getSuffixConfig } from './config'; +import { unsupportedFormatResult } from './subtitleFormats'; export async function generateAutosubsyncSubtitles(srtPath: string, videoPath: string): Promise { + const unsupported = unsupportedFormatResult('autosubsync', srtPath); + if (unsupported) return unsupported; + const outputPath = buildOutputPath(srtPath, getSuffixConfig().autosubsync); const exists = existsSync(outputPath); diff --git a/src/generateFfsubsyncSubtitles.ts b/src/generateFfsubsyncSubtitles.ts index 80c7050..4c22406 100644 --- a/src/generateFfsubsyncSubtitles.ts +++ b/src/generateFfsubsyncSubtitles.ts @@ -1,8 +1,12 @@ import { buildOutputPath, execPromise, ProcessingResult } from './helpers'; import { existsSync } from 'fs'; import { getSuffixConfig } from './config'; +import { unsupportedFormatResult } from './subtitleFormats'; export async function generateFfsubsyncSubtitles(srtPath: string, videoPath: string): Promise { + const unsupported = unsupportedFormatResult('ffsubsync', srtPath); + if (unsupported) return unsupported; + const outputPath = buildOutputPath(srtPath, getSuffixConfig().ffsubsync); // Check if synced subtitle already exists diff --git a/src/helpers.ts b/src/helpers.ts index 0097777..d2b3db5 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,12 +1,15 @@ -import { basename, dirname, join } from 'path'; +import { dirname, join } from 'path'; +import { getSubtitleExtension, stripSubtitleExtension } from './subtitleFormats'; -export function buildOutputPath(srtPath: string, suffix: string): string { - const directory = dirname(srtPath); - let srtBaseName = basename(srtPath, '.srt'); - if (srtBaseName.endsWith(`.${suffix}`)) { - srtBaseName = srtBaseName.slice(0, -(suffix.length + 1)); +/** Builds e.g. movie.en.ffsubsync.srt / movie.en.ffsubsync.ass, keeping the input's subtitle format. */ +export function buildOutputPath(subtitlePath: string, suffix: string): string { + const directory = dirname(subtitlePath); + const ext = getSubtitleExtension(subtitlePath) ?? '.srt'; + let baseName = stripSubtitleExtension(subtitlePath); + if (baseName.endsWith(`.${suffix}`)) { + baseName = baseName.slice(0, -(suffix.length + 1)); } - return join(directory, `${srtBaseName}.${suffix}.srt`); + return join(directory, `${baseName}.${suffix}${ext}`); } export interface ProcessingResult { diff --git a/src/index.ts b/src/index.ts index 970b7e8..620c513 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,10 +4,12 @@ import { processSrtFile } from './processSrtFile'; async function main(): Promise { try { - // Find all .srt files + // Find all subtitle files (.srt, .ass) const scanConfig = getScanConfig(); const { files: srtFiles, skippedCount } = await findAllSrtFiles(scanConfig); - console.log(`${new Date().toLocaleString()} Found ${srtFiles.length} SRT files (${skippedCount} already synced)`); + console.log( + `${new Date().toLocaleString()} Found ${srtFiles.length} subtitle files (${skippedCount} already synced)`, + ); const maxConcurrentSyncTasks = process.env.MAX_CONCURRENT_SYNC_TASKS ? parseInt(process.env.MAX_CONCURRENT_SYNC_TASKS) diff --git a/src/processingEngine.ts b/src/processingEngine.ts index fff944f..ddfa8c0 100644 --- a/src/processingEngine.ts +++ b/src/processingEngine.ts @@ -142,9 +142,9 @@ export class ProcessingEngine extends EventEmitter { const duration = Date.now() - startTime; - // If this engine was skipped (already processed), log and continue + // If this engine was skipped (already processed or unsupported format), log and continue if (result.skipped) { - this.log(`[${new Date().toISOString()}] ⊘ ${engine} skipped (already processed): ${fileName}`); + this.log(`[${new Date().toISOString()}] ⊘ ${engine} skipped: ${result.message}`); this.emit('file:engine_completed', { srtPath, engine, diff --git a/src/subtitleFormats.ts b/src/subtitleFormats.ts new file mode 100644 index 0000000..0ac5b73 --- /dev/null +++ b/src/subtitleFormats.ts @@ -0,0 +1,46 @@ +import { basename, extname } from 'path'; +import type { ProcessingResult } from './helpers'; + +export const SUBTITLE_EXTENSIONS = ['.srt', '.ass'] as const; +export type SubtitleExtension = (typeof SUBTITLE_EXTENSIONS)[number]; + +// Formats each sync engine can read and write. +// autosubsync only parses SubRip, so .ass files are skipped for it. +const ENGINE_FORMATS: Record = { + ffsubsync: ['.srt', '.ass'], + autosubsync: ['.srt'], + alass: ['.srt', '.ass'], +}; + +export function getSubtitleExtension(filePath: string): SubtitleExtension | null { + const ext = extname(filePath).toLowerCase(); + return SUBTITLE_EXTENSIONS.find((supported) => supported === ext) ?? null; +} + +export function isSubtitleFile(filePath: string): boolean { + return getSubtitleExtension(filePath) !== null; +} + +/** Returns the file name without its directory and subtitle extension. */ +export function stripSubtitleExtension(filePath: string): string { + const name = basename(filePath); + const ext = getSubtitleExtension(name); + return ext ? name.slice(0, -ext.length) : name; +} + +export function engineSupportsSubtitle(engine: string, filePath: string): boolean { + const ext = getSubtitleExtension(filePath); + const formats = ENGINE_FORMATS[engine] ?? ['.srt']; + return ext !== null && formats.includes(ext); +} + +/** Skip result for an engine that cannot handle the subtitle's format, or null if it can. */ +export function unsupportedFormatResult(engine: string, filePath: string): ProcessingResult | null { + if (engineSupportsSubtitle(engine, filePath)) return null; + const ext = extname(filePath).toLowerCase() || 'this'; + return { + success: true, + skipped: true, + message: `Skipping ${engine} for ${basename(filePath)} - ${engine} does not support ${ext} subtitles`, + }; +}