├── .dockerignore ├── .github └── workflows │ └── docker-publish.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc.js ├── Dockerfile ├── README.md ├── babel.config.js ├── bin └── alass ├── docker-compose.yaml ├── eslint.config.mjs ├── jest.config.mjs ├── package-lock.json ├── package.json ├── src ├── config.ts ├── findAllSrtFiles.ts ├── findMatchingVideoFile.ts ├── generateAlassSubtitles.ts ├── generateAutosubsyncSubtitles.ts ├── generateFfsubsyncSubtitles.ts ├── helpers.ts ├── index.ts └── processSrtFile.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | # .dockerignore 2 | node_modules 3 | vendor 4 | .git 5 | test-srts 6 | *.log 7 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Docker Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | packages: write 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: '18' 23 | cache: 'npm' 24 | 25 | - name: Docker meta 26 | id: meta 27 | uses: docker/metadata-action@v5 28 | with: 29 | images: mrorbitman/subsyncarr 30 | tags: | 31 | type=semver,pattern={{version}} 32 | type=semver,pattern={{major}}.{{minor}} 33 | type=semver,pattern={{major}} 34 | type=raw,value=latest,enable=${{ github.ref == format('refs/tags/{0}', github.event.repository.default_branch) }} 35 | 36 | - name: Set up QEMU 37 | uses: docker/setup-qemu-action@v3 38 | 39 | - name: Set up Docker Buildx 40 | uses: docker/setup-buildx-action@v3 41 | 42 | - name: Login to Docker Hub 43 | uses: docker/login-action@v3 44 | with: 45 | username: ${{ secrets.DOCKERHUB_USERNAME }} 46 | password: ${{ secrets.DOCKERHUB_TOKEN }} 47 | 48 | - name: Build and push 49 | uses: docker/build-push-action@v5 50 | with: 51 | context: . 52 | push: true 53 | platforms: linux/amd64,linux/arm64 54 | tags: ${{ steps.meta.outputs.tags }} 55 | labels: ${{ steps.meta.outputs.labels }} 56 | cache-from: type=gha 57 | cache-to: type=gha,mode=max 58 | - name: Create GitHub Release 59 | uses: softprops/action-gh-release@v1 60 | with: 61 | name: Release ${{ github.ref_name }} 62 | body: | 63 | ## Docker Images 64 | 65 | Pull the image using: 66 | ```bash 67 | docker pull mrorbitman/subsyncarr:${{ github.ref_name }} 68 | # or 69 | docker pull mrorbitman/subsyncarr:latest 70 | ``` 71 | 72 | Example docker-compose.yaml: 73 | 74 | ``` 75 | name: subsyncarr 76 | 77 | services: 78 | subsyncarr: 79 | image: mrorbitman/subsyncarr:latest 80 | container_name: subsyncarr 81 | volumes: 82 | # Any path configured with SCAN_PATHS env var must be mounted 83 | # If no scan paths are specified, it defaults to scan `/scan_dir` like example below. 84 | # - ${MEDIA_PATH:-/path/to/your/media}:/scan_dir 85 | - /path/to/movies:/movies 86 | - /path/to/tv:/tv 87 | - /path/to/anime:/anime 88 | restart: unless-stopped 89 | environment: 90 | - TZ=${TZ:-UTC} 91 | - CRON_SCHEDULE=0 0 * * * # Runs every day at midnight by default 92 | - SCAN_PATHS=/movies,/tv,/anime # Remember to mount these as volumes. Must begin with /. Default valus is `/scan_dir` 93 | - EXCLUDE_PATHS=/movies/temp,/tv/downloads # Exclude certain sub-directories from the scan 94 | - MAX_CONCURRENT_SYNC_TASKS=1 95 | - INCLUDE_ENGINES=ffsubsync,autosubsync # If not set, all engines are used by default 96 | ``` 97 | 98 | Docker Hub URL: https://hub.docker.com/r/mrorbitman/subsyncarr/tags 99 | draft: false 100 | prerelease: false 101 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build 3 | /dist 4 | /coverage 5 | .eslintcache 6 | .env 7 | /test-srts -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | node_modules 5 | dist 6 | .gitignore 7 | .prettierignore 8 | .husky 9 | Dockerfile 10 | .env* 11 | /app -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | }; 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Node.js LTS (Long Term Support) as base image 2 | FROM node:20-bullseye 3 | 4 | # Create app user and group with configurable UID/GID 5 | ENV PUID=1000 6 | ENV PGID=1000 7 | 8 | RUN mkdir -p /app 9 | RUN chown node:node /app 10 | 11 | # Modify existing node user instead of creating new one 12 | RUN groupmod -g ${PGID} node && \ 13 | usermod -u ${PUID} -g ${PGID} node && \ 14 | chown -R node:node /home/node 15 | RUN apt-get clean 16 | 17 | # Install system dependencies including ffmpeg, Python, and cron 18 | RUN apt-get update && apt-get install -y \ 19 | ffmpeg \ 20 | python3 \ 21 | python3-pip \ 22 | python3-venv \ 23 | cron \ 24 | && rm -rf /var/lib/apt/lists/* 25 | 26 | USER node 27 | # Set working directory 28 | WORKDIR /app 29 | 30 | # Copy package.json and package-lock.json (if available) 31 | COPY --chown=node:node package*.json ./ 32 | 33 | # Install Node.js dependencies while skipping husky installation 34 | ENV HUSKY=0 35 | RUN npm install --ignore-scripts 36 | 37 | # Copy the rest of your application 38 | COPY --chown=node:node . . 39 | RUN mkdir -p /home/node/.local/bin/ 40 | RUN cp bin/* /home/node/.local/bin/ 41 | 42 | # Build TypeScript 43 | RUN npm run build 44 | 45 | # Create startup script 46 | # Set default cron schedule (if not provided by environment variable) 47 | ENV CRON_SCHEDULE="0 0 * * *" 48 | 49 | # Install pipx 50 | RUN python3 -m pip install --user pipx \ 51 | && python3 -m pipx ensurepath 52 | 53 | # Add pipx to PATH 54 | ENV PATH="/home/node/.local/bin:$PATH" 55 | 56 | # Install ffsubsync and autosubsync using pipx 57 | RUN pipx install ffsubsync \ 58 | && pipx install autosubsync 59 | 60 | 61 | # Create startup script with proper permissions 62 | RUN echo '#!/bin/bash\n\ 63 | # Add cron job to user crontab\n\ 64 | crontab - <> /var/log/subsyncarr/cron.log 2>&1\n\ 66 | EOF\n\ 67 | \n\ 68 | # Run the initial instance of the app\n\ 69 | node dist/index.js\n\ 70 | mkdir -p /app/logs/\n\ 71 | touch /app/logs/app.log\n\ 72 | tail -f /app/logs/app.log' > /app/startup.sh 73 | 74 | # Make startup script executable 75 | RUN chmod +x /app/startup.sh 76 | 77 | # Use startup script as entrypoint 78 | CMD ["/app/startup.sh"] 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Subsyncarr 2 | 3 | An automated subtitle synchronization tool that runs as a Docker container. It watches a directory for video files with matching subtitles and automatically synchronizes them using both ffsubsync and autosubsync. 4 | 5 | ## Features 6 | 7 | - Automatically scans directory for video files and their corresponding subtitles 8 | - Uses both ffsubsync and autosubsync for maximum compatibility 9 | - Runs on a schedule (daily at midnight) and on container startup 10 | - Supports common video formats (mkv, mp4, avi, mov) 11 | - Docker-based for easy deployment 12 | - Generates synchronized subtitle files with `.ffsubsync.srt` and `.autosubsync.srt` extensions 13 | 14 | ## Quick Start 15 | 16 | ### Using Docker Compose (Recommended) 17 | 18 | #### 1. Create a new directory for your project 19 | 20 | ```bash 21 | mkdir subsyncarr && cd subsyncarr 22 | ``` 23 | 24 | #### 2. Download the docker-compose.yml file 25 | 26 | ```bash 27 | curl -O https://raw.githubusercontent.com/johnpc/subsyncarr/refs/heads/main/docker-compose.yaml 28 | ``` 29 | 30 | #### 3. Edit the docker-compose.yml file with your timezone and paths 31 | 32 | ```bash 33 | TZ=America/New_York # Adjust to your timezone 34 | ``` 35 | 36 | #### 4. Start the container 37 | 38 | ```bash 39 | docker compose up -d 40 | ``` 41 | 42 | ## Configuration 43 | 44 | The container is configured to: 45 | 46 | - Scan for subtitle files in the mounted directory 47 | - Run synchronization at container startup 48 | - Run daily at midnight (configurable via cron) 49 | - Generate synchronized subtitle versions using different tools (currently ffsubsync and autosubsync) 50 | 51 | ### Directory Structure 52 | 53 | Your media directory should be organized as follows: 54 | 55 | ```txt 56 | /media 57 | ├── movie1.mkv 58 | ├── movie1.srt 59 | ├── movie2.mp4 60 | └── movie2.srt 61 | ``` 62 | 63 | It should follow the naming conventions expected by other services like Bazarr and Jellyfin. 64 | 65 | ## Logs 66 | 67 | View container logs: 68 | 69 | ```bash 70 | docker logs -f subsyncarr 71 | ``` 72 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'], 3 | }; 4 | -------------------------------------------------------------------------------- /bin/alass: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/subsyncarr/3499221137ea4287f2dc46f07b1333c0e8eab102/bin/alass -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | name: subsyncarr 2 | 3 | services: 4 | subsyncarr: 5 | image: mrorbitman/subsyncarr:latest 6 | container_name: subsyncarr 7 | volumes: 8 | # Any path configured with SCAN_PATHS env var must be mounted 9 | # If no scan paths are specified, it defaults to scan `/scan_dir` like example below. 10 | # - /path/to/your/media:/scan_dir 11 | - /path/to/movies:/movies 12 | - /path/to/tv:/tv 13 | - /path/to/anime:/anime 14 | restart: unless-stopped 15 | environment: 16 | - TZ=Etc/UTC # Replace with your own timezone 17 | - CRON_SCHEDULE=0 0 * * * # Runs every day at midnight by default 18 | - SCAN_PATHS=/movies,/tv,/anime # Remember to mount these as volumes. Must begin with /. Default valus is `/scan_dir` 19 | - EXCLUDE_PATHS=/movies/temp,/tv/downloads # Exclude certain sub-directories from the scan 20 | - MAX_CONCURRENT_SYNC_TASKS=1 # Defaults to 1 if not set. Higher number will consume more CPU but sync your library faster 21 | - INCLUDE_ENGINES=ffsubsync,autosubsync # If not set, all engines are used by default 22 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import tseslint from 'typescript-eslint'; 3 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 4 | 5 | export default [ 6 | { files: ['**/*.{js,mjs,cjs,ts}'] }, 7 | { ignores: ['dist', 'node_modules'] }, 8 | { 9 | languageOptions: { 10 | globals: { 11 | ...globals.node, 12 | ...globals.jest, 13 | }, 14 | }, 15 | }, 16 | ...tseslint.configs.recommended, 17 | { 18 | rules: { 19 | '@typescript-eslint/no-var-requires': ['off'], 20 | }, 21 | }, 22 | eslintPluginPrettierRecommended, 23 | ]; 24 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | testEnvironment: 'node', 3 | testMatch: ['**/__tests__/**.test.ts'], 4 | coverageReporters: ['cobertura', 'html', 'text'], 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@johnpc/subsyncarr", 3 | "version": "0.1.0", 4 | "license": "UNLICENSED", 5 | "engines": { 6 | "node": ">=18" 7 | }, 8 | "scripts": { 9 | "clean": "rm -rf dist; rm -rf node_modules; rm -rf build/* && rm -rf build", 10 | "build": "tsc", 11 | "watch": "tsc -w", 12 | "lint": "eslint .", 13 | "lint:fix": "prettier --write \"**/*\" && npm run lint -- --fix", 14 | "test": "echo \"todo: write tests\"", 15 | "prepare": "husky" 16 | }, 17 | "main": "./dist/index.js", 18 | "exports": "./dist/index.js", 19 | "files": [ 20 | "dist/", 21 | "!**/__tests__/**" 22 | ], 23 | "devDependencies": { 24 | "@babel/core": "^7.24.7", 25 | "@babel/preset-env": "^7.24.7", 26 | "@babel/preset-typescript": "^7.24.7", 27 | "@tsconfig/node18": "^18.2.4", 28 | "@types/jest": "^29.5.12", 29 | "@types/node": "^22.10.10", 30 | "@types/uuid": "^10.0.0", 31 | "babel-jest": "^29.7.0", 32 | "eslint": "^8.2.0", 33 | "eslint-config-airbnb-base": "^15.0.0", 34 | "eslint-config-prettier": "^9.1.0", 35 | "eslint-plugin-import": "^2.25.2", 36 | "eslint-plugin-prettier": "^5.2.1", 37 | "globals": "^15.4.0", 38 | "jest": "^29.7.0", 39 | "jest-mock": "^29.7.0", 40 | "lint-staged": "^15.2.10", 41 | "prettier": "3.3.2", 42 | "typescript": "~5.4.5", 43 | "typescript-eslint": "^7.13.0" 44 | }, 45 | "lint-staged": { 46 | "*.ts": [ 47 | "prettier --write", 48 | "eslint --cache --fix" 49 | ], 50 | "*.json": "prettier --write", 51 | "*.yml": "prettier --write", 52 | "*.md": "prettier --write" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export interface ScanConfig { 2 | includePaths: string[]; 3 | excludePaths: string[]; 4 | } 5 | 6 | function validatePath(path: string): boolean { 7 | // Add any path validation logic you need 8 | return path.startsWith('/') && !path.includes('..'); 9 | } 10 | 11 | export function getScanConfig(): ScanConfig { 12 | const scanPaths = process.env.SCAN_PATHS?.split(',').filter(Boolean) || ['/scan_dir']; 13 | const excludePaths = process.env.EXCLUDE_PATHS?.split(',').filter(Boolean) || []; 14 | 15 | // Validate paths 16 | const validIncludePaths = scanPaths.filter((path) => { 17 | const isValid = validatePath(path); 18 | if (!isValid) { 19 | console.warn(`${new Date().toLocaleString()} Invalid include path: ${path}`); 20 | } 21 | return isValid; 22 | }); 23 | 24 | const validExcludePaths = excludePaths.filter((path) => { 25 | const isValid = validatePath(path); 26 | if (!isValid) { 27 | console.warn(`${new Date().toLocaleString()} Invalid exclude path: ${path}`); 28 | } 29 | return isValid; 30 | }); 31 | 32 | if (validIncludePaths.length === 0) { 33 | console.warn(`${new Date().toLocaleString()} No valid scan paths provided, defaulting to /scan_dir`); 34 | validIncludePaths.push('/scan_dir'); 35 | } 36 | 37 | console.log(`${new Date().toLocaleString()} Scan configuration:`, { 38 | includePaths: validIncludePaths, 39 | excludePaths: validExcludePaths, 40 | }); 41 | 42 | return { 43 | includePaths: validIncludePaths, 44 | excludePaths: validExcludePaths, 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/findAllSrtFiles.ts: -------------------------------------------------------------------------------- 1 | import { readdir } from 'fs/promises'; 2 | import { extname, join } from 'path'; 3 | import { ScanConfig } from './config'; 4 | 5 | export async function findAllSrtFiles(config: ScanConfig): Promise { 6 | const files: string[] = []; 7 | 8 | async function scan(directory: string): Promise { 9 | // Check if this directory should be excluded 10 | if (config.excludePaths.some((excludePath) => directory.startsWith(excludePath))) { 11 | return; 12 | } 13 | 14 | const entries = await readdir(directory, { withFileTypes: true }); 15 | 16 | for (const entry of entries) { 17 | const fullPath = join(directory, entry.name); 18 | 19 | if (entry.isDirectory()) { 20 | await scan(fullPath); 21 | } else if ( 22 | entry.isFile() && 23 | extname(entry.name).toLowerCase() === '.srt' && 24 | !entry.name.includes('.ffsubsync.') && 25 | !entry.name.includes('.alass.') && 26 | !entry.name.includes('.autosubsync.') 27 | ) { 28 | files.push(fullPath); 29 | } 30 | } 31 | } 32 | 33 | // Scan all included paths 34 | for (const includePath of config.includePaths) { 35 | await scan(includePath); 36 | } 37 | 38 | return files; 39 | } 40 | -------------------------------------------------------------------------------- /src/findMatchingVideoFile.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'fs'; 2 | import { basename, dirname, join } from 'path'; 3 | 4 | type VideoExtension = '.mkv' | '.mp4' | '.avi' | '.mov'; 5 | const VIDEO_EXTENSIONS: VideoExtension[] = ['.mkv', '.mp4', '.avi', '.mov']; 6 | 7 | export function findMatchingVideoFile(srtPath: string): string | null { 8 | const directory = dirname(srtPath); 9 | const srtBaseName = basename(srtPath, '.srt'); 10 | 11 | // Try exact match first 12 | for (const ext of VIDEO_EXTENSIONS) { 13 | const possibleVideoPath = join(directory, `${srtBaseName}${ext}`); 14 | if (existsSync(possibleVideoPath)) { 15 | return possibleVideoPath; 16 | } 17 | } 18 | 19 | // Progressive tag removal - split by dots and try removing one segment at a time 20 | const segments = srtBaseName.split('.'); 21 | while (segments.length > 1) { 22 | segments.pop(); // Remove the last segment 23 | const baseNameToTry = segments.join('.'); 24 | 25 | for (const ext of VIDEO_EXTENSIONS) { 26 | const possibleVideoPath = join(directory, `${baseNameToTry}${ext}`); 27 | if (existsSync(possibleVideoPath)) { 28 | return possibleVideoPath; 29 | } 30 | } 31 | } 32 | 33 | return null; 34 | } 35 | -------------------------------------------------------------------------------- /src/generateAlassSubtitles.ts: -------------------------------------------------------------------------------- 1 | import { basename, dirname, join } from 'path'; 2 | import { execPromise, ProcessingResult } from './helpers'; 3 | import { existsSync } from 'fs'; 4 | 5 | export async function generateAlassSubtitles(srtPath: string, videoPath: string): Promise { 6 | const directory = dirname(srtPath); 7 | const srtBaseName = basename(srtPath, '.srt'); 8 | const outputPath = join(directory, `${srtBaseName}.alass.srt`); 9 | 10 | const exists = existsSync(outputPath); 11 | if (exists) { 12 | return { 13 | success: true, 14 | message: `Skipping ${outputPath} - already processed`, 15 | }; 16 | } 17 | 18 | try { 19 | const command = `alass "${videoPath}" "${srtPath}" "${outputPath}"`; 20 | console.log(`${new Date().toLocaleString()} Processing: ${command}`); 21 | await execPromise(command); 22 | return { 23 | success: true, 24 | message: `Successfully processed: ${outputPath}`, 25 | }; 26 | } catch (error) { 27 | const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 28 | return { 29 | success: false, 30 | message: `Error processing ${outputPath}: ${errorMessage}`, 31 | }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/generateAutosubsyncSubtitles.ts: -------------------------------------------------------------------------------- 1 | import { basename, dirname, join } from 'path'; 2 | import { execPromise, ProcessingResult } from './helpers'; 3 | import { existsSync } from 'fs'; 4 | 5 | export async function generateAutosubsyncSubtitles(srtPath: string, videoPath: string): Promise { 6 | const directory = dirname(srtPath); 7 | const srtBaseName = basename(srtPath, '.srt'); 8 | const outputPath = join(directory, `${srtBaseName}.autosubsync.srt`); 9 | 10 | const exists = existsSync(outputPath); 11 | if (exists) { 12 | return { 13 | success: true, 14 | message: `Skipping ${outputPath} - already processed`, 15 | }; 16 | } 17 | 18 | try { 19 | const command = `autosubsync "${videoPath}" "${srtPath}" "${outputPath}"`; 20 | console.log(`${new Date().toLocaleString()} Processing: ${command}`); 21 | await execPromise(command); 22 | return { 23 | success: true, 24 | message: `Successfully processed: ${outputPath}`, 25 | }; 26 | } catch (error) { 27 | const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 28 | return { 29 | success: false, 30 | message: `Error processing ${outputPath}: ${errorMessage}`, 31 | }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/generateFfsubsyncSubtitles.ts: -------------------------------------------------------------------------------- 1 | import { basename, dirname, join } from 'path'; 2 | import { execPromise, ProcessingResult } from './helpers'; 3 | import { existsSync } from 'fs'; 4 | 5 | export async function generateFfsubsyncSubtitles(srtPath: string, videoPath: string): Promise { 6 | const directory = dirname(srtPath); 7 | const srtBaseName = basename(srtPath, '.srt'); 8 | const outputPath = join(directory, `${srtBaseName}.ffsubsync.srt`); 9 | 10 | // Check if synced subtitle already exists 11 | const exists = existsSync(outputPath); 12 | if (exists) { 13 | return { 14 | success: true, 15 | message: `Skipping ${outputPath} - already processed`, 16 | }; 17 | } 18 | 19 | try { 20 | const command = `ffsubsync "${videoPath}" -i "${srtPath}" -o "${outputPath}"`; 21 | console.log(`${new Date().toLocaleString()} Processing: ${command}`); 22 | await execPromise(command); 23 | return { 24 | success: true, 25 | message: `Successfully processed: ${outputPath}`, 26 | }; 27 | } catch (error) { 28 | const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 29 | return { 30 | success: false, 31 | message: `Error processing ${outputPath}: ${errorMessage}`, 32 | }; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { promisify } from 'util'; 2 | import { exec } from 'child_process'; 3 | 4 | export interface ProcessingResult { 5 | success: boolean; 6 | message: string; 7 | } 8 | 9 | export const execPromise = promisify(exec); 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { findAllSrtFiles } from './findAllSrtFiles'; 2 | import { getScanConfig } from './config'; 3 | import { processSrtFile } from './processSrtFile'; 4 | 5 | async function main(): Promise { 6 | try { 7 | // Find all .srt files 8 | const scanConfig = getScanConfig(); 9 | const srtFiles = await findAllSrtFiles(scanConfig); 10 | console.log(`${new Date().toLocaleString()} Found ${srtFiles.length} SRT files`); 11 | 12 | const maxConcurrentSyncTasks = process.env.MAX_CONCURRENT_SYNC_TASKS 13 | ? parseInt(process.env.MAX_CONCURRENT_SYNC_TASKS) 14 | : 1; 15 | 16 | for (let i = 0; i < srtFiles.length; i += maxConcurrentSyncTasks) { 17 | const chunk = srtFiles.slice(i, i + maxConcurrentSyncTasks); 18 | await Promise.all(chunk.map((srtFile) => processSrtFile(srtFile))); 19 | } 20 | } catch (error) { 21 | const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 22 | console.error(`${new Date().toLocaleString()} Error:`, errorMessage); 23 | } finally { 24 | console.log(`${new Date().toLocaleString()} subsyncarr completed.`); 25 | } 26 | } 27 | 28 | main(); 29 | -------------------------------------------------------------------------------- /src/processSrtFile.ts: -------------------------------------------------------------------------------- 1 | import { basename } from 'path'; 2 | import { findMatchingVideoFile } from './findMatchingVideoFile'; 3 | import { generateAutosubsyncSubtitles } from './generateAutosubsyncSubtitles'; 4 | import { generateFfsubsyncSubtitles } from './generateFfsubsyncSubtitles'; 5 | import { generateAlassSubtitles } from './generateAlassSubtitles'; 6 | 7 | export const processSrtFile = async (srtFile: string) => { 8 | const videoFile = findMatchingVideoFile(srtFile); 9 | const includeEngines = process.env.INCLUDE_ENGINES?.split(',') || ['ffsubsync', 'autosubsync', 'alass']; 10 | 11 | if (videoFile) { 12 | if (includeEngines.includes('ffsubsync')) { 13 | const ffsubsyncResult = await generateFfsubsyncSubtitles(srtFile, videoFile); 14 | console.log(`${new Date().toLocaleString()} ffsubsync result: ${ffsubsyncResult.message}`); 15 | } 16 | if (includeEngines.includes('autosubsync')) { 17 | const autosubsyncResult = await generateAutosubsyncSubtitles(srtFile, videoFile); 18 | console.log(`${new Date().toLocaleString()} autosubsync result: ${autosubsyncResult.message}`); 19 | } 20 | if (includeEngines.includes('alass')) { 21 | const alassResult = await generateAlassSubtitles(srtFile, videoFile); 22 | console.log(`${new Date().toLocaleString()} alass result: ${alassResult.message}`); 23 | } 24 | } else { 25 | console.log(`${new Date().toLocaleString()} No matching video file found for: ${basename(srtFile)}`); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "outDir": "dist", 5 | "module": "commonjs", 6 | "strict": true, 7 | "strictNullChecks": true, 8 | "esModuleInterop": true, 9 | "noImplicitReturns": false, 10 | "noImplicitThis": false, 11 | "incremental": true, 12 | "typeRoots": ["./node_modules/@types"], 13 | "types": ["node", "jest"], 14 | "resolveJsonModule": true 15 | }, 16 | "exclude": ["node_modules", "build"] 17 | } 18 | --------------------------------------------------------------------------------