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
28 changes: 24 additions & 4 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ class SubsyncarrPlusClient {

renderFiles() {
const processing = this.state.files.filter((f) => f.status === 'processing');
const completed = this.state.files.filter((f) => ['completed', 'skipped', 'error'].includes(f.status));
const completed = this.state.files.filter((f) => ['completed', 'skipped', 'error', 'not_fitting'].includes(f.status));

// Render processing files
const progressHtml = processing
Expand Down Expand Up @@ -467,9 +467,12 @@ class SubsyncarrPlusClient {
const completedHtml = completed
.map((file) => {
const engines = JSON.parse(file.engines);
const statusLabel = file.status === 'not_fitting' ? '⚠ Not Fitting' :
file.status === 'error' ? '✗ Error' :
file.status === 'skipped' ? '⊘ Skipped' : '✓ Completed';
return `
<div class="file-card ${file.status}">
<div class="file-name">${this.basename(file.file_path)}</div>
<div class="file-name">${this.basename(file.file_path)} <span class="status-label">${statusLabel}</span></div>
${this.renderEngineResults(engines)}
</div>
`;
Expand All @@ -487,9 +490,17 @@ class SubsyncarrPlusClient {
const className = result.success ? 'success' : 'error';
const duration = (result.duration / 1000).toFixed(1);

let extra = '';
if (result.notFitting) {
extra = ' ⚠ Not fitting';
} else if (result.offsetMs !== undefined && result.offsetMs !== null) {
const offsetSec = (result.offsetMs / 1000).toFixed(1);
extra = ` (${offsetSec}s)`;
}

return `
<div class="engine-result ${className}">
<span>${icon} ${name}</span>
<span>${icon} ${name}${extra}</span>
<span class="duration">${duration}s</span>
</div>
`;
Expand Down Expand Up @@ -559,6 +570,10 @@ class SubsyncarrPlusClient {
run.failed > 0
? `<span class="stat-clickable stat-failed" onclick="client.showFileList('${run.id}', 'failed')">${run.failed}</span>`
: run.failed;
const notFittingCell =
run.not_fitting > 0
? `<span class="stat-clickable" style="color:#5b21b6" onclick="client.showFileList('${run.id}', 'not_fitting')">${run.not_fitting}</span>`
: run.not_fitting || 0;

return `
<tr>
Expand All @@ -568,6 +583,7 @@ class SubsyncarrPlusClient {
<td>${completedCell}</td>
<td>${skippedCell}</td>
<td>${failedCell}</td>
<td>${notFittingCell}</td>
${this.renderEngineCell(engineStats.ffsubsync)}
${this.renderEngineCell(engineStats.autosubsync)}
${this.renderEngineCell(engineStats.alass)}
Expand All @@ -583,7 +599,7 @@ class SubsyncarrPlusClient {
.join('');

document.getElementById('historyBody').innerHTML =
html || '<tr><td colspan="11" class="no-data">No runs yet</td></tr>';
html || '<tr><td colspan="12" class="no-data">No runs yet</td></tr>';
}

basename(path) {
Expand Down Expand Up @@ -641,6 +657,10 @@ class SubsyncarrPlusClient {
files = run.files.filter((f) => f.status === 'error');
title = `Failed Files (${files.length})`;
break;
case 'not_fitting':
files = run.files.filter((f) => f.status === 'not_fitting');
title = `Not Fitting Files (${files.length})`;
break;
default:
return;
}
Expand Down
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ <h2>Run History</h2>
<th>Completed</th>
<th>Skipped</th>
<th>Failed</th>
<th>Not Fit</th>
<th>F</th>
<th>Au</th>
<th>Al</th>
Expand Down
16 changes: 16 additions & 0 deletions public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,17 @@ h2 {
border-left-color: var(--warning);
}

.file-card.not_fitting {
border-left-color: #8b5cf6;
}

.status-label {
font-size: 12px;
font-weight: 500;
opacity: 0.8;
margin-left: 8px;
}

.file-header {
display: flex;
justify-content: space-between;
Expand Down Expand Up @@ -370,6 +381,11 @@ td {
color: #92400e;
}

.status-badge.not_fitting {
background: #ede9fe;
color: #5b21b6;
}

.no-data {
color: var(--text-secondary);
text-align: center;
Expand Down
145 changes: 145 additions & 0 deletions src/__tests__/subtitleOffsetCalculator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { calculateSubtitleOffset } from '../subtitleOffsetCalculator';

describe('calculateSubtitleOffset', () => {
it('should return 0 for identical content', () => {
const content = `1
00:00:20,000 --> 00:00:24,400
Hello world

2
00:01:00,000 --> 00:01:04,000
Goodbye world`;

expect(calculateSubtitleOffset(content, content)).toBe(0);
});

it('should calculate positive offset when synced subtitles are later', () => {
const original = `1
00:00:20,000 --> 00:00:24,400
Hello world

2
00:01:00,000 --> 00:01:04,000
Goodbye world`;

const synced = `1
00:00:25,000 --> 00:00:29,400
Hello world

2
00:01:05,000 --> 00:01:09,000
Goodbye world`;

// Both entries shifted by 5000ms (5 seconds)
expect(calculateSubtitleOffset(original, synced)).toBe(5000);
});

it('should calculate negative offset when synced subtitles are earlier', () => {
const original = `1
00:00:25,000 --> 00:00:29,400
Hello world

2
00:01:05,000 --> 00:01:09,000
Goodbye world`;

const synced = `1
00:00:20,000 --> 00:00:24,400
Hello world

2
00:01:00,000 --> 00:01:04,000
Goodbye world`;

// Both entries shifted by -5000ms
expect(calculateSubtitleOffset(original, synced)).toBe(-5000);
});

it('should use median offset to handle outliers', () => {
const original = `1
00:00:20,000 --> 00:00:24,400
First

2
00:01:00,000 --> 00:01:04,000
Second

3
00:02:00,000 --> 00:02:04,000
Third`;

const synced = `1
00:00:25,000 --> 00:00:29,400
First

2
00:01:05,000 --> 00:01:09,000
Second

3
00:02:30,000 --> 00:02:34,000
Third`;

// Offsets: 5000, 5000, 30000
// Median of [5000, 5000, 30000] = 5000
expect(calculateSubtitleOffset(original, synced)).toBe(5000);
});

it('should return 0 for empty content', () => {
const content = `1
00:00:20,000 --> 00:00:24,400
Hello world`;

expect(calculateSubtitleOffset('', content)).toBe(0);
expect(calculateSubtitleOffset(content, '')).toBe(0);
expect(calculateSubtitleOffset('', '')).toBe(0);
});

it('should handle sync marker header lines', () => {
const original = `1
00:00:20,000 --> 00:00:24,400
Hello world`;

const syncedWithMarker = `# synced:ffsubsync 1234567890
1
00:00:25,000 --> 00:00:29,400
Hello world`;

// Should ignore the sync marker line and calculate offset correctly
expect(calculateSubtitleOffset(original, syncedWithMarker)).toBe(5000);
});

it('should handle large offset (30+ seconds)', () => {
const original = `1
00:00:20,000 --> 00:00:24,400
Hello world

2
00:01:00,000 --> 00:01:04,000
Goodbye world`;

const synced = `1
00:00:50,000 --> 00:00:54,400
Hello world

2
00:01:30,000 --> 00:01:34,000
Goodbye world`;

// Both entries shifted by 30000ms (30 seconds)
expect(calculateSubtitleOffset(original, synced)).toBe(30000);
});

it('should handle millisecond precision', () => {
const original = `1
00:00:20,000 --> 00:00:24,400
Hello world`;

const synced = `1
00:00:20,500 --> 00:00:24,900
Hello world`;

// Offset of 500ms
expect(calculateSubtitleOffset(original, synced)).toBe(500);
});
});
16 changes: 16 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@ export interface ScanConfig {
excludePaths: string[];
}

export interface SyncRetryConfig {
/** Offset threshold in ms that triggers a retry (default: 5000 = 5 seconds) */
thresholdMs: number;
/** Maximum number of retry attempts after the first sync (default: 1) */
maxRetries: number;
}

export function getSyncRetryConfig(): SyncRetryConfig {
const thresholdMs = parseInt(process.env.SYNC_RETRY_THRESHOLD_MS || '5000', 10);
const maxRetries = parseInt(process.env.SYNC_MAX_RETRIES || '1', 10);
return {
thresholdMs: isNaN(thresholdMs) || thresholdMs <= 0 ? 5000 : thresholdMs,
maxRetries: isNaN(maxRetries) || maxRetries < 0 ? 1 : maxRetries,
};
}

export interface RetentionConfig {
keepRunsDays: number; // Keep complete runs for N days
trimLogsDays: number; // Trim logs after N days
Expand Down
Loading