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`, + }; +}