Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.sh text eol=lf
45 changes: 35 additions & 10 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ name: Build and Publish Docker Image

on:
push:
tags:
- 'v*.*.*'
branches:
- bridgemill-ch

jobs:
docker:
Expand All @@ -24,15 +24,38 @@ jobs:
node-version: '18'
cache: 'npm'

- name: Determine next version
id: version
run: |
LATEST_TAG=$(git tag --sort=-version:refname | head -1)
if [ -z "$LATEST_TAG" ]; then
echo "version=1.0.0" >> $GITHUB_OUTPUT
else
# Strip leading 'v' if present
CLEAN_TAG="${LATEST_TAG#v}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$CLEAN_TAG"
# If patch isn't a number, restart at .0
if ! [[ "$PATCH" =~ ^[0-9]+$ ]]; then
PATCH=0
fi
PATCH=$((PATCH + 1))
echo "version=${MAJOR}.${MINOR}.${PATCH}" >> $GITHUB_OUTPUT
fi

- name: Create tag
run: |
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git tag ${{ steps.version.outputs.version }}
git push origin ${{ steps.version.outputs.version }}

- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: mrorbitman/subsyncarr
images: bridgemill/subsyncarr
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=${{ steps.version.outputs.version }}
type=raw,value=latest

- name: Set up QEMU
Expand All @@ -57,6 +80,7 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Generate changelog
id: changelog
run: |
Expand All @@ -73,7 +97,8 @@ jobs:
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
name: Release ${{ github.ref_name }}
name: Release ${{ steps.version.outputs.version }}
tag_name: ${{ steps.version.outputs.version }}
body: |
## What's Changed
${{ steps.changelog.outputs.changelog }}
Expand All @@ -82,11 +107,11 @@ jobs:

Pull the image using:
```bash
docker pull mrorbitman/subsyncarr:${{ github.ref_name }}
docker pull bridgemill/subsyncarr:${{ steps.version.outputs.version }}
# or
docker pull mrorbitman/subsyncarr:latest
docker pull bridgemill/subsyncarr:latest
```

Docker Hub URL: https://hub.docker.com/r/mrorbitman/subsyncarr/tags
Docker Hub URL: https://hub.docker.com/r/bridgemill/subsyncarr/tags
draft: false
prerelease: false
14 changes: 6 additions & 8 deletions src/coordinator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { ProcessingEngine } from './processingEngine';
import { StateManager } from './stateManager';
import { ScanConfig } from './config';
import { findMatchingVideoFile } from './findMatchingVideoFile';
import { Run } from './database';
import { once } from 'events';

Expand Down Expand Up @@ -33,16 +32,15 @@ export class ProcessingCoordinator {
this.engine.on('run:files_found', (files: string[]) => {
this.currentRunId = this.stateManager.startRun(files.length, this.enabledEngines);

// Add all files to database as pending
files.forEach((filePath) => {
const videoPath = findMatchingVideoFile(filePath);
this.stateManager.addFile(this.currentRunId!, filePath, videoPath);
});
// Add all files to database as pending (video matching happens during processing)
for (const filePath of files) {
this.stateManager.addFile(this.currentRunId!, filePath, null);
}
});

this.engine.on('file:started', ({ srtPath }: { srtPath: string }) => {
this.engine.on('file:started', ({ srtPath, videoPath }: { srtPath: string; videoPath: string | null }) => {
if (this.currentRunId) {
this.stateManager.updateFileStatus(this.currentRunId, srtPath, 'processing', null);
this.stateManager.updateFileStatus(this.currentRunId, srtPath, 'processing', null, videoPath);
}
});

Expand Down
16 changes: 16 additions & 0 deletions src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,22 @@ export class SubsyncarrPlusDatabase {
.all(runId) as FileResult[];
}

getFileResult(runId: string, filePath: string): FileResult | null {
return this.db
.prepare('SELECT * FROM file_results WHERE run_id = ? AND file_path = ?')
.get(runId, filePath) as FileResult | null;
}

getFileResultsPaginated(runId: string, limit: number, offset: number): { files: FileResult[]; total: number } {
const total = (
this.db.prepare('SELECT COUNT(*) as count FROM file_results WHERE run_id = ?').get(runId) as { count: number }
).count;
const files = this.db
.prepare('SELECT * FROM file_results WHERE run_id = ? ORDER BY created_at ASC LIMIT ? OFFSET ?')
.all(runId, limit, offset) as FileResult[];
return { files, total };
}

// Engine failure tracking methods
getEngineFailureTracking(filePath: string, engine: string): EngineFailureTracking | null {
return this.db
Expand Down
19 changes: 12 additions & 7 deletions src/findAllSrtFiles.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import { readdir } from 'fs/promises';
import { basename, dirname, extname, join } from 'path';
import { extname, join } from 'path';
import { existsSync } from 'fs';
import { ScanConfig } from './config';
import { getOutputPath, getSubtitleFormat } from './helpers';

function isAlreadySynced(srtPath: string, engines: string[]): boolean {
const directory = dirname(srtPath);
const srtBaseName = basename(srtPath, '.srt');
const format = getSubtitleFormat();
if (format === 'overwrite') return false; // Checked lazily in processFile

return engines.every((engine) => {
const outputPath = join(directory, `${srtBaseName}.${engine}.srt`);
const outputPath = getOutputPath(srtPath, engine);
return existsSync(outputPath);
});
}

const ALL_KNOWN_ENGINES = ['ffsubsync', 'autosubsync', 'alass'];

function isEngineOutput(filename: string): boolean {
return ALL_KNOWN_ENGINES.some((engine) => filename.includes(`.${engine}.`));
}

export async function findAllSrtFiles(config: ScanConfig): Promise<string[]> {
const engines = process.env.INCLUDE_ENGINES?.split(',') || ['ffsubsync', 'autosubsync', 'alass'];
const files: string[] = [];
Expand All @@ -34,9 +41,7 @@ export async function findAllSrtFiles(config: ScanConfig): Promise<string[]> {
} else if (
entry.isFile() &&
extname(entry.name).toLowerCase() === '.srt' &&
!entry.name.includes('.ffsubsync.') &&
!entry.name.includes('.alass.') &&
!entry.name.includes('.autosubsync.')
!isEngineOutput(entry.name)
) {
if (isAlreadySynced(fullPath, engines)) {
skippedCount++;
Expand Down
23 changes: 11 additions & 12 deletions src/generateAlassSubtitles.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import { basename, dirname, join } from 'path';
import { execPromise, ProcessingResult } from './helpers';
import { execPromise, ProcessingResult, getOutputPath, getSubtitleFormat } from './helpers';
import { existsSync } from 'fs';

export async function generateAlassSubtitles(srtPath: string, videoPath: string): Promise<ProcessingResult> {
const directory = dirname(srtPath);
const srtBaseName = basename(srtPath, '.srt');
const outputPath = join(directory, `${srtBaseName}.alass.srt`);
const outputPath = getOutputPath(srtPath, 'alass');

const exists = existsSync(outputPath);
if (exists) {
return {
success: true,
message: `Skipping ${outputPath} - already processed`,
skipped: true,
};
if (getSubtitleFormat() !== 'overwrite') {
const exists = existsSync(outputPath);
if (exists) {
return {
success: true,
message: `Skipping ${outputPath} - already processed`,
skipped: true,
};
}
}

try {
Expand Down
23 changes: 11 additions & 12 deletions src/generateAutosubsyncSubtitles.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import { basename, dirname, join } from 'path';
import { execPromise, ProcessingResult } from './helpers';
import { execPromise, ProcessingResult, getOutputPath, getSubtitleFormat } from './helpers';
import { existsSync } from 'fs';

export async function generateAutosubsyncSubtitles(srtPath: string, videoPath: string): Promise<ProcessingResult> {
const directory = dirname(srtPath);
const srtBaseName = basename(srtPath, '.srt');
const outputPath = join(directory, `${srtBaseName}.autosubsync.srt`);
const outputPath = getOutputPath(srtPath, 'autosubsync');

const exists = existsSync(outputPath);
if (exists) {
return {
success: true,
message: `Skipping ${outputPath} - already processed`,
skipped: true,
};
if (getSubtitleFormat() !== 'overwrite') {
const exists = existsSync(outputPath);
if (exists) {
return {
success: true,
message: `Skipping ${outputPath} - already processed`,
skipped: true,
};
}
}

try {
Expand Down
24 changes: 11 additions & 13 deletions src/generateFfsubsyncSubtitles.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import { basename, dirname, join } from 'path';
import { execPromise, ProcessingResult } from './helpers';
import { execPromise, ProcessingResult, getOutputPath, getSubtitleFormat } from './helpers';
import { existsSync } from 'fs';

export async function generateFfsubsyncSubtitles(srtPath: string, videoPath: string): Promise<ProcessingResult> {
const directory = dirname(srtPath);
const srtBaseName = basename(srtPath, '.srt');
const outputPath = join(directory, `${srtBaseName}.ffsubsync.srt`);
const outputPath = getOutputPath(srtPath, 'ffsubsync');

// Check if synced subtitle already exists
const exists = existsSync(outputPath);
if (exists) {
return {
success: true,
message: `Skipping ${outputPath} - already processed`,
skipped: true,
};
if (getSubtitleFormat() !== 'overwrite') {
const exists = existsSync(outputPath);
if (exists) {
return {
success: true,
message: `Skipping ${outputPath} - already processed`,
skipped: true,
};
}
}

try {
Expand Down
36 changes: 36 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,40 @@
import { exec } from 'child_process';
import { basename, dirname, join } from 'path';
import { writeFileSync } from 'fs';
import { open } from 'fs/promises';

export type SubtitleFormat = 'standard' | 'overwrite';

export function getSubtitleFormat(): SubtitleFormat {
const format = process.env.SUBTITLE_FORMAT || 'standard';
if (format === 'overwrite' || process.env.OVERWRITE_SUBTITLES === 'true') return 'overwrite';
return 'standard';
}

export function getOutputPath(srtPath: string, engine: string): string {
const directory = dirname(srtPath);
const srtBaseName = basename(srtPath, '.srt');
return join(directory, `${srtBaseName}.${engine}.srt`);
}

const SYNC_MARKER = '# synced:';

export async function isSyncedSrt(srtPath: string): Promise<boolean> {
try {
const fd = await open(srtPath, 'r');
const buf = Buffer.alloc(100);
await fd.read(buf, 0, 100, 0);
await fd.close();
return buf.toString('utf8').startsWith(SYNC_MARKER);
} catch {
return false;
}
}

export function markSrtAsSynced(srtPath: string, engine: string, content: string): void {
const marker = `${SYNC_MARKER}${engine} ${Date.now()}\n`;
writeFileSync(srtPath, marker + content, 'utf8');
}

export interface ProcessingResult {
success: boolean;
Expand Down
29 changes: 27 additions & 2 deletions src/processingEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { generateFfsubsyncSubtitles } from './generateFfsubsyncSubtitles';
import { generateAutosubsyncSubtitles } from './generateAutosubsyncSubtitles';
import { generateAlassSubtitles } from './generateAlassSubtitles';
import { StateManager } from './stateManager';
import { readFileSync, unlinkSync } from 'fs';
import { getSubtitleFormat, getOutputPath, markSrtAsSynced, isSyncedSrt } from './helpers';

export class ProcessingEngine extends EventEmitter {
private cancelledFiles: Set<string> = new Set();
Expand All @@ -22,6 +24,10 @@ export class ProcessingEngine extends EventEmitter {
this.maxLogBufferSize = parseInt(process.env.LOG_BUFFER_SIZE || '1000', 10);
}

private get subtitleFormat(): string {
return getSubtitleFormat();
}

private log(message: string): void {
console.log(message);

Expand Down Expand Up @@ -49,7 +55,6 @@ export class ProcessingEngine extends EventEmitter {

const srtFiles = await findAllSrtFiles(scanConfig);
this.log(`[${new Date().toISOString()}] Found ${srtFiles.length} subtitle files`);

this.emit('run:files_found', srtFiles);

// Process in batches
Expand All @@ -69,7 +74,13 @@ export class ProcessingEngine extends EventEmitter {

private async processFile(srtPath: string): Promise<void> {
const fileName = srtPath.split('/').pop();
this.log(`[${new Date().toISOString()}] Processing: ${fileName}`);

// Skip already-synced files (overwrite mode)
if (this.subtitleFormat === 'overwrite' && (await isSyncedSrt(srtPath))) {
this.log(`[${new Date().toISOString()}] ⊘ Already synced (header): ${fileName}`);
this.emit('file:skipped', { srtPath, reason: 'already_synced' });
return;
}

// Check if cancelled
if (this.cancelledFiles.has(srtPath)) {
Expand Down Expand Up @@ -168,6 +179,20 @@ export class ProcessingEngine extends EventEmitter {

if (result.success) {
anyEngineSucceeded = true;

if (this.subtitleFormat === 'overwrite') {
const engineOutputPath = getOutputPath(srtPath, engine);
const engineContent = readFileSync(engineOutputPath, 'utf8');
markSrtAsSynced(srtPath, engine, engineContent);
unlinkSync(engineOutputPath);
this.log(`[${new Date().toISOString()}] ✓ Synced (header-marked): ${fileName}`);
this.emit('file:engine_completed', {
srtPath,
engine,
result: { ...result, duration },
});
break;
}
}

this.emit('file:engine_completed', {
Expand Down
Loading