Skip to content
Merged
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ This is the first documented entry — a snapshot of what SlothArchiver could
do as of this release, not a history of every change that got it here.
Future releases will log what actually changed from the previous one.

## [0.27.1] — 2026-09-04
- **Fixed a bug where downloading a very long video could get stuck looping
forever.** The step that joins the downloaded video and audio together
doesn't report progress while it runs, and on a long enough recording that
silence was being mistaken for a stalled connection — killing the merge
partway through and restarting the whole download from scratch, repeatedly,
with no way out short of force-quitting the app.
- **The postprocessing step now shows a proper loading animation instead of a
progress bar stuck at 50%**, since that stage genuinely has no percentage
to report — the old fixed value looked broken on anything that took more
than a few seconds.
- **Downloading a video over 3 hours long now shows a heads-up** that the
postprocessing step may take a while with no visible progress, so it's
clear that's expected rather than a sign something's wrong.

## [0.27.0] — 2026-09-03
- **Split your library into separate sublibraries.** Create as many as you
want from the Library tab, switch between them, and pick which one a new
Expand Down
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,22 @@ itself.

## Download

<!-- Update the version in these 5 links on every release -- currently 0.27.0.
<!-- Update the version in these 5 links on every release -- currently 0.27.1.
Each filename must match exactly what GitHub actually named the asset on
the release page, NOT the local build's own filename -- GitHub's release
upload sanitizes spaces in asset names to dots, so the two Windows .exe
files differ from what "npm run dist" produces locally (e.g. the local
"SlothArchiver Setup 0.27.0.exe" becomes the asset
"SlothArchiver.Setup.0.27.0.exe"). Check the release's actual assets
"SlothArchiver Setup 0.27.1.exe" becomes the asset
"SlothArchiver.Setup.0.27.1.exe"). Check the release's actual assets
list if unsure, don't assume the local build's naming carries over. -->

| Platform | Link |
|---|---|
| Windows (installer) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.0/SlothArchiver.Setup.0.27.0.exe) |
| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.0/SlothArchiver.0.27.0.exe) |
| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.0/SlothArchiver-0.27.0-arm64.dmg) |
| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.0/SlothArchiver-0.27.0.dmg) |
| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.0/SlothArchiver-0.27.0.AppImage) |
| Windows (installer) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.1/SlothArchiver.Setup.0.27.1.exe) |
| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.1/SlothArchiver.0.27.1.exe) |
| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.1/SlothArchiver-0.27.1-arm64.dmg) |
| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.1/SlothArchiver-0.27.1.dmg) |
| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.1/SlothArchiver-0.27.1.AppImage) |

All builds are unsigned, so your OS will show a first-run security warning —
see [Installing](#installing) below.
Expand Down Expand Up @@ -223,19 +223,19 @@ that, it opens normally.

Step 1 — the Gatekeeper block you'll see on first launch:

<img width="520" height="570" alt="macOS: cannot be opened, unidentified developer" src="https://github.com/user-attachments/assets/d06a069a-381b-42ea-ac71-a1a2461e2542" />
<img width="260" alt="macOS: cannot be opened, unidentified developer" src="https://github.com/user-attachments/assets/d06a069a-381b-42ea-ac71-a1a2461e2542" />

If macOS still blocks it, go to **System Settings → Privacy & Security**,
scroll down to the security section, and click **Open Anyway** next to the
message about SlothArchiver.

Step 2 — find the security section in **Privacy & Security**:

<img width="1304" height="326" alt="macOS: Privacy & Security section" src="https://github.com/user-attachments/assets/65999064-803b-4cd3-9b11-08fb45778469" />
<img width="652" alt="macOS: Privacy & Security section" src="https://github.com/user-attachments/assets/65999064-803b-4cd3-9b11-08fb45778469" />

Step 3 — click **Open Anyway**:

<img width="700" height="601" alt="macOS: click Open Anyway" src="https://github.com/user-attachments/assets/131abfdb-eb96-445e-b1d4-fdb836b9b9a4" />
<img width="700" alt="macOS: click Open Anyway" src="https://github.com/user-attachments/assets/131abfdb-eb96-445e-b1d4-fdb836b9b9a4" />

**Linux (AppImage)** — make it executable first, then run it directly:
```
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "sloth-archiver",
"private": true,
"version": "0.27.0",
"version": "0.27.1",
"description": "A desktop app for downloading and archiving videos, with a built-in library and playlist tracking.",
"author": "Sloth <sloth.software.dev@protonmail.com>",
"license": "GPL-3.0-or-later",
Expand Down
30 changes: 26 additions & 4 deletions src/electron/ffmpegUtils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,24 +44,30 @@ export function summarizeFfmpegError(stderr, code) {
// different callers (the main download pipeline's postprocess step, and the
// Library view's standalone ffmpeg utilities) that report progress over two
// different IPC channels.
export function createFfmpegRunner({ ffmpegBinaryPath, ffprobeBinaryPath }) {
export function createFfmpegRunner({ ffmpegBinaryPath, ffprobeBinaryPath, onLog, isDev = false }) {
function getMediaDurationSeconds(filePath) {
return new Promise((resolve, reject) => {
onLog?.(`[ffmpeg] probing duration: ${filePath}`);
const proc = spawn(ffprobeBinaryPath, ['-v', 'quiet', '-print_format', 'json', '-show_format', filePath]);
let stdout = '';
let stderr = '';
proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
proc.on('error', reject);
proc.on('error', (err) => {
onLog?.(`[ffmpeg] ffprobe failed to start for ${filePath}: ${err.message}`);
reject(err);
});
proc.on('close', (code) => {
if (code !== 0) {
onLog?.(`[ffmpeg] ffprobe exited with code ${code} for ${filePath}: ${stderr}`);
reject(new Error(stderr || `ffprobe exited with code ${code}`));
return;
}
try {
const duration = parseFloat(JSON.parse(stdout).format.duration);
resolve(Number.isFinite(duration) ? duration : 0);
} catch {
onLog?.(`[ffmpeg] failed to parse ffprobe output for ${filePath}`);
reject(new Error('Failed to parse ffprobe output'));
}
});
Expand All @@ -75,9 +81,12 @@ export function createFfmpegRunner({ ffmpegBinaryPath, ffprobeBinaryPath }) {
function runFfmpegWithProgress({ inputPath, outputPath, codecArgs, totalDurationSeconds, onProgress, extraInputArgs = [], preInputArgs = [] }) {
return new Promise((resolve, reject) => {
const args = [...preInputArgs, '-i', inputPath, ...extraInputArgs, ...codecArgs, '-progress', 'pipe:1', '-y', outputPath];
onLog?.(`[ffmpeg] running: ${ffmpegBinaryPath} ${args.join(' ')}`);
const proc = spawn(ffmpegBinaryPath, args);
let stderr = '';
let buffer = '';
let lastDevProgressLogAt = 0;
const DEV_PROGRESS_LOG_INTERVAL_MS = 30 * 1000;

proc.stdout.on('data', (chunk) => {
buffer += chunk.toString();
Expand All @@ -88,13 +97,25 @@ export function createFfmpegRunner({ ffmpegBinaryPath, ffprobeBinaryPath }) {
if (match && totalDurationSeconds > 0) {
const percent = Math.min(100, (Number(match[1]) / (totalDurationSeconds * 1_000_000)) * 100);
onProgress?.(percent);
// Same rationale as main.mjs's own download-progress
// throttle: -progress pipe:1 emits several lines per
// second, dev-only and sampled to keep main.log readable.
if (isDev && Date.now() - lastDevProgressLogAt >= DEV_PROGRESS_LOG_INTERVAL_MS) {
lastDevProgressLogAt = Date.now();
onLog?.(`[ffmpeg] ${outputPath}: ${percent.toFixed(1)}%`);
}
}
}
});
proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
proc.on('error', reject);
proc.on('error', (err) => {
onLog?.(`[ffmpeg] failed to start: ${err.message}`);
reject(err);
});
proc.on('close', (code) => {
onLog?.(`[ffmpeg] exited with code ${code}: ${outputPath}`);
if (code !== 0) {
onLog?.(`[ffmpeg] stderr for ${outputPath}: ${stderr}`);
reject(new Error(summarizeFfmpegError(stderr, code)));
} else {
resolve();
Expand Down Expand Up @@ -136,7 +157,8 @@ export function createFfmpegRunner({ ffmpegBinaryPath, ffprobeBinaryPath }) {
}
try {
await runFfmpegWithProgress({ inputPath, outputPath, codecArgs: ['-c', 'copy'], totalDurationSeconds, onProgress });
} catch {
} catch (err) {
onLog?.(`[ffmpeg] remux failed for ${outputPath}, falling back to re-encode: ${err instanceof Error ? err.message : String(err)}`);
await runFfmpegWithProgress({ inputPath, outputPath, codecArgs: reencodeCodecArgs, totalDurationSeconds, onProgress });
}
}
Expand Down
Loading
Loading