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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ 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.

## [1.0.0] — 2026-09-05
- **First public release** 🎉🎉🥳🎉🎉
- **Added a theme selector.** Options now has a "Theme" dropdown alongside
the existing Light/Dark toggle: the original plain look ("Default MUI")
or a new "SlothUI" theme matching the color scheme from SlothArchiver's
own website. SlothUI (dark) is now the default look for a fresh install;
existing installs keep whatever they already had.
- **Cookie handling is clearer and safer.** The warning explains what
cookies are actually for (age-restricted or members-only content, not
something to leave on for every download) and links to yt-dlp's own
cookie usage guide. A new "Save across sessions" checkbox, off by
default, controls whether a saved cookie (or cookies-from-browser choice)
is kept the next time the app opens or cleared as soon as it closes --
so an authenticated session can't accidentally outlive the run that
created it. Existing saved cookies are unaffected by this change.
- **Fixed a bug in the bulk-add queue where a retried download could show
a generic "Download failed" message** instead of the actual reason,
once an earlier failure on the same item was still being displayed.

## [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
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,17 @@ itself.

## Download

<!-- Update the version in these 5 links on every release -- currently 0.27.1.
<!-- Update the version in these 5 links on every release -- currently 1.0.0.
Each filename must match exactly what GitHub actually named the asset on
the release page -->

| Platform | Link |
|---|---|
| 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) |
| Windows (installer) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.0.0/SlothArchiver.Setup.1.0.0.exe) |
| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.0.0/SlothArchiver.1.0.0.exe) |
| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.0.0/SlothArchiver-1.0.0-arm64.dmg) |
| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.0.0/SlothArchiver-1.0.0.dmg) |
| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.0.0/SlothArchiver-1.0.0.AppImage) |

All builds are unsigned, so your OS will show a first-run security warning —
see [Installing](#installing) below.
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.1",
"version": "1.0.0",
"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
68 changes: 66 additions & 2 deletions src/electron/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { getSupportedVideoFilters, allVideoFilter } from './utils/constants.mjs'
import { getCurrentYtdlpVersion, isNewerVersion, performYtdlpUpdate } from './updater.mjs';
import { resolveLatestRelease, YTDLP_VERIFICATION_ERROR_CODE } from './ytdlpRelease.mjs';
import { writeLibraryEntry, overrideLibraryEntry, addLibraryVersion, refreshLibraryEntryMetadata, getLibraryIndex, refreshLibraryIndex, findVideoInIndex, recordLibraryDownload, swapLibraryDownload, deleteLibraryEntry, deleteLocalFiles, moveLibraryEntry, writePlaylistSnapshot, enrichPlaylistEntry, listPlaylistSnapshots, getPlaylistSnapshot, reconcilePlaylistSnapshot, undoPlaylistRefresh, deletePlaylistSnapshot, sanitizeForFilesystem, resolveInsideLibrary, libraryTagDir, DEFAULT_LIBRARY_DIR_NAME, listLibraryTags, createLibraryTag, listVideoTags, setVideoTag, addTagToVideos, removeVideosFromTags, transferVideoTags, checkAndRepairEpochFiles, PLAYLISTS_DIR_NAME, CLIPS_DIR_NAME, buildClipFilePath, recordClip, listClips, deleteClip, updateClipFile } from './library.mjs';
import { createSettingsStore, clampMaxSimultaneousDownloads, clampThumbnailSize, THUMBNAIL_SIZE_DEFAULT, clampLibrarySortField, clampLibrarySortDirection } from './settings.mjs';
import { createSettingsStore, clampMaxSimultaneousDownloads, clampThumbnailSize, THUMBNAIL_SIZE_DEFAULT, clampLibrarySortField, clampLibrarySortDirection, clampThemeName } from './settings.mjs';
import { makeCookiesArgs, looksLikeNetscapeFormat, convertHeaderCookiesToNetscape, validateNetscapeLines, SUPPORTED_COOKIE_BROWSERS, reapStaleCookieCopies } from './cookies.mjs';
import { downloadImageToFile, createThumbnailFetchers } from './thumbnails.mjs';
import { createFfmpegRunner } from './ffmpegUtils.mjs';
Expand Down Expand Up @@ -129,6 +129,46 @@ export function assertValidHttpUrl(url, label = 'URL') {
const { readSettings, writeSettings } = createSettingsStore(settingsPath);
export const cookiesArgs = makeCookiesArgs(readSettings, cookiesPath);

// Whether a saved cookie (the pasted-cookie file, or a cookies-from-browser
// choice) is allowed to outlive the app instance that saved it. Off means
// both get wiped at the start and end of every run -- so an authenticated
// session accidentally left saved can never quietly persist into a later
// launch, per the actual risk this setting exists for (a user getting into
// trouble with a platform's ToS over a cookie they forgot was still active).
//
// No explicit setting yet (a genuinely first run, or an upgrade from before
// this existed) defaults to whichever behavior keeps existing users
// unaffected: true if a cookies.txt is already sitting on disk (they were
// already relying on it persisting), false otherwise -- a fresh install
// starts on the safer, session-only default rather than inheriting the old
// always-persist behavior.
export function getCookiesPersistAcrossSessions() {
const { cookiesPersistAcrossSessions } = readSettings();
if (typeof cookiesPersistAcrossSessions === 'boolean') return cookiesPersistAcrossSessions;
return fs.existsSync(cookiesPath);
}

// Called at both startup and shutdown (see app.on('before-quit') below) --
// covers both "clean up whatever a previous session left behind" and "don't
// leave anything behind for the next one," since either point alone would
// miss the other half (a crash skips shutdown; an upgrade skips startup
// cleanup for a file already written this run).
function clearSessionOnlyCookiesIfNeeded() {
if (getCookiesPersistAcrossSessions()) return;
if (fs.existsSync(cookiesPath)) {
fs.unlinkSync(cookiesPath);
}
const settings = readSettings();
if (settings.cookiesMode || settings.cookiesBrowser) {
settings.cookiesMode = 'file';
settings.cookiesBrowser = '';
writeSettings(settings);
}
}

clearSessionOnlyCookiesIfNeeded();
app.on('before-quit', clearSessionOnlyCookiesIfNeeded);

// cookies.txt holds a live, authenticated Google/YouTube session -- fixed up
// on every startup (not just at write time, see cookies:save below) so an
// existing file from before this app started restricting permissions gets
Expand Down Expand Up @@ -494,7 +534,11 @@ ipcMain.handle('settings:setLibrarySort', async (e, { sortField, sortDirection }

ipcMain.handle('settings:getThemeMode', async () => {
const { themeMode } = readSettings();
return { themeMode: themeMode === 'dark' ? 'dark' : 'light' };
// Dark is the default for a fresh install (paired with SlothUI as the
// default theme name, see THEME_NAME_DEFAULT) -- only an explicit
// 'light' choice overrides it, so this checks for 'light' rather than
// defaulting to it.
return { themeMode: themeMode === 'light' ? 'light' : 'dark' };
});

ipcMain.handle('settings:setThemeMode', async (e, mode) => {
Expand All @@ -504,6 +548,18 @@ ipcMain.handle('settings:setThemeMode', async (e, mode) => {
return { success: true, themeMode: settings.themeMode };
});

ipcMain.handle('settings:getThemeName', async () => {
const { themeName } = readSettings();
return { themeName: clampThemeName(themeName) };
});

ipcMain.handle('settings:setThemeName', async (e, name) => {
const settings = readSettings();
settings.themeName = clampThemeName(name);
writeSettings(settings);
return { success: true, themeName: settings.themeName };
});

ipcMain.handle('settings:getMaxSimultaneousDownloads', async () => {
const { maxSimultaneousDownloads } = readSettings();
return { maxSimultaneousDownloads: clampMaxSimultaneousDownloads(maxSimultaneousDownloads ?? 1) };
Expand Down Expand Up @@ -1025,9 +1081,17 @@ ipcMain.handle('cookies:getConfig', async () => {
cookiesMode: cookiesMode === 'browser' ? 'browser' : 'file',
cookiesBrowser: cookiesBrowser || '',
supportedBrowsers: SUPPORTED_COOKIE_BROWSERS,
cookiesPersistAcrossSessions: getCookiesPersistAcrossSessions(),
};
});

ipcMain.handle('cookies:setPersistAcrossSessions', async (e, persist) => {
const settings = readSettings();
settings.cookiesPersistAcrossSessions = !!persist;
writeSettings(settings);
return { success: true, cookiesPersistAcrossSessions: settings.cookiesPersistAcrossSessions };
});

ipcMain.handle('cookies:setConfig', async (e, { cookiesMode, cookiesBrowser }) => {
// An empty cookiesBrowser is allowed through even in 'browser' mode --
// it's the explicit "Clear" state, which cookiesArgs() already treats as
Expand Down
24 changes: 24 additions & 0 deletions src/electron/main.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
rememberAppPath,
makeCookiesArgs,
reapStaleCookieCopies,
getCookiesPersistAcrossSessions,
} from './main.mjs';

fs.mkdirSync(electronMocks.mockUserDataDir, { recursive: true });
Expand Down Expand Up @@ -475,6 +476,29 @@ describe('cookiesArgs', () => {
});
});

describe('getCookiesPersistAcrossSessions', () => {
it('respects an explicit true/false setting regardless of whether a cookie file exists', () => {
resetSettingsAndCookies();
fs.writeFileSync(settingsPath, JSON.stringify({ cookiesPersistAcrossSessions: true }));
expect(getCookiesPersistAcrossSessions()).toBe(true);

fs.writeFileSync(cookiesPath, '# Netscape HTTP Cookie File\n');
fs.writeFileSync(settingsPath, JSON.stringify({ cookiesPersistAcrossSessions: false }));
expect(getCookiesPersistAcrossSessions()).toBe(false);
});

it('defaults to true (grandfathered in) when no setting exists yet but a cookie file is already saved', () => {
resetSettingsAndCookies();
fs.writeFileSync(cookiesPath, '# Netscape HTTP Cookie File\n');
expect(getCookiesPersistAcrossSessions()).toBe(true);
});

it('defaults to false (the safer default) when no setting exists yet and no cookie file is saved', () => {
resetSettingsAndCookies();
expect(getCookiesPersistAcrossSessions()).toBe(false);
});
});

describe('makeCookiesArgs (with an injected tmpDir)', () => {
it('never touches the canonical cookiesPath, only reads it', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sloth-archiver-test-cookies-'));
Expand Down
3 changes: 3 additions & 0 deletions src/electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getCookieStatus: () => ipcRenderer.invoke('cookies:status'),
getCookiesConfig: () => ipcRenderer.invoke('cookies:getConfig'),
setCookiesConfig: (payload) => ipcRenderer.invoke('cookies:setConfig', payload),
setCookiesPersistAcrossSessions: (persist) => ipcRenderer.invoke('cookies:setPersistAcrossSessions', persist),
getDownloadDir: () => ipcRenderer.invoke('settings:getDownloadDir'),
setDownloadDir: (dir) => ipcRenderer.invoke('settings:setDownloadDir', dir),
checkFileExists: (filePath) => ipcRenderer.invoke('system:pathExists', filePath),
Expand All @@ -44,6 +45,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
setLibrarySort: (payload) => ipcRenderer.invoke('settings:setLibrarySort', payload),
getThemeMode: () => ipcRenderer.invoke('settings:getThemeMode'),
setThemeMode: (mode) => ipcRenderer.invoke('settings:setThemeMode', mode),
getThemeName: () => ipcRenderer.invoke('settings:getThemeName'),
setThemeName: (name) => ipcRenderer.invoke('settings:setThemeName', name),
getCustomConvertFormats: () => ipcRenderer.invoke('settings:getCustomConvertFormats'),
setCustomConvertFormats: (formats) => ipcRenderer.invoke('settings:setCustomConvertFormats', formats),
getMaxSimultaneousDownloads: () => ipcRenderer.invoke('settings:getMaxSimultaneousDownloads'),
Expand Down
13 changes: 13 additions & 0 deletions src/electron/settings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ export function clampLibrarySortDirection(value) {
return value === 'desc' ? 'desc' : 'asc';
}

// The overall theme (color palette/typography), independent of light/dark
// mode -- 'default' is this app's original plain-MUI look, 'slothui' is the
// palette pulled from SlothArchiver-info's landing page (see theme.ts).
export const THEME_NAMES = ['default', 'slothui'];
// SlothUI is the default for a fresh install -- 'default' (plain MUI) is
// still fully supported and one toggle away, just no longer what a new user
// sees before ever touching Options.
export const THEME_NAME_DEFAULT = 'slothui';

export function clampThemeName(value) {
return THEME_NAMES.includes(value) ? value : THEME_NAME_DEFAULT;
}

// Bounds for the Library tab's thumbnail-size slider (LibraryBottomBar.tsx).
// 160px floor keeps a video card's title/quality-chip row from wrapping
// awkwardly; 360px ceiling still fits 2+ columns at typical content widths.
Expand Down
5 changes: 4 additions & 1 deletion src/types/electron-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ declare global {
saveCookie: (cookieText: string) => Promise<{ success: boolean; cookieCount: number; skipped: number }>
deleteCookie: () => Promise<{ success: boolean }>
getCookieStatus: () => Promise<{ loaded: boolean; cookieCount: number; path?: string; savedAtEpoch?: number }>
getCookiesConfig: () => Promise<{ cookiesMode: 'file' | 'browser'; cookiesBrowser: string; supportedBrowsers: string[] }>
getCookiesConfig: () => Promise<{ cookiesMode: 'file' | 'browser'; cookiesBrowser: string; supportedBrowsers: string[]; cookiesPersistAcrossSessions: boolean }>
setCookiesConfig: (payload: { cookiesMode: 'file' | 'browser'; cookiesBrowser: string }) => Promise<{ success: boolean; cookiesMode: 'file' | 'browser'; cookiesBrowser: string }>
setCookiesPersistAcrossSessions: (persist: boolean) => Promise<{ success: boolean; cookiesPersistAcrossSessions: boolean }>
getDownloadDir: () => Promise<{ downloadDir: string }>
setDownloadDir: (dir: string) => Promise<{ success: boolean; downloadDir: string }>
checkFileExists: (filePath: string) => Promise<boolean>
Expand All @@ -97,6 +98,8 @@ declare global {
setLibrarySort: (payload: { sortField: LibrarySortField; sortDirection: LibrarySortDirection }) => Promise<{ success: boolean; sortField: LibrarySortField; sortDirection: LibrarySortDirection }>
getThemeMode: () => Promise<{ themeMode: 'light' | 'dark' }>
setThemeMode: (mode: 'light' | 'dark') => Promise<{ success: boolean; themeMode: 'light' | 'dark' }>
getThemeName: () => Promise<{ themeName: 'default' | 'slothui' }>
setThemeName: (name: 'default' | 'slothui') => Promise<{ success: boolean; themeName: 'default' | 'slothui' }>
getCustomConvertFormats: () => Promise<{ customConvertFormats: string[] }>
setCustomConvertFormats: (formats: string[]) => Promise<{ success: boolean; customConvertFormats: string[] }>
getMaxSimultaneousDownloads: () => Promise<{ maxSimultaneousDownloads: number }>
Expand Down
4 changes: 2 additions & 2 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ function useRendererErrorLogging() {
// ancestor) can pick the actual MUI theme object -- ThemeModeProvider has to
// wrap this, not live inside it.
function AppContent() {
const { mode } = useThemeMode();
const { mode, themeName } = useThemeMode();

return (
<ThemeProvider theme={getTheme(mode)}>
<ThemeProvider theme={getTheme(mode, themeName)}>
<CssBaseline />
<YtdlpUpdaterProvider>
<LibraryNotificationProvider>
Expand Down
37 changes: 28 additions & 9 deletions src/ui/hooks/useThemeMode.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@ import { ThemeModeProvider, useThemeMode } from './useThemeMode';
beforeEach(() => {
window.electronAPI = {
...window.electronAPI,
getThemeMode: vi.fn().mockResolvedValue({ themeMode: 'dark' }),
setThemeMode: vi.fn().mockResolvedValue({ success: true, themeMode: 'dark' }),
// Deliberately different from the hook's own initial state (dark +
// slothui, see useThemeMode.tsx) so a test actually observes the
// resolve-and-adopt transition rather than the mock coincidentally
// matching the default already in place.
getThemeMode: vi.fn().mockResolvedValue({ themeMode: 'light' }),
setThemeMode: vi.fn().mockResolvedValue({ success: true, themeMode: 'light' }),
getThemeName: vi.fn().mockResolvedValue({ themeName: 'default' }),
setThemeName: vi.fn().mockResolvedValue({ success: true, themeName: 'default' }),
};
});

Expand All @@ -16,21 +22,34 @@ describe('useThemeMode', () => {
expect(() => renderHook(() => useThemeMode())).toThrow(/must be used within a ThemeModeProvider/);
});

it('starts at "light" and adopts the persisted mode once getThemeMode resolves', async () => {
it('starts at "dark"/"slothui" (the fresh-install defaults) and adopts the persisted values once they resolve', async () => {
const { result } = renderHook(() => useThemeMode(), { wrapper: ThemeModeProvider });

expect(result.current.mode).toBe('light');
await waitFor(() => expect(result.current.mode).toBe('dark'));
expect(result.current.mode).toBe('dark');
expect(result.current.themeName).toBe('slothui');
await waitFor(() => expect(result.current.mode).toBe('light'));
await waitFor(() => expect(result.current.themeName).toBe('default'));
expect(window.electronAPI.getThemeMode).toHaveBeenCalledTimes(1);
expect(window.electronAPI.getThemeName).toHaveBeenCalledTimes(1);
});

it('setMode updates state immediately and persists via electronAPI.setThemeMode', async () => {
const { result } = renderHook(() => useThemeMode(), { wrapper: ThemeModeProvider });
await waitFor(() => expect(result.current.mode).toBe('dark'));
await waitFor(() => expect(result.current.mode).toBe('light'));

act(() => result.current.setMode('light'));
act(() => result.current.setMode('dark'));

expect(result.current.mode).toBe('light');
expect(window.electronAPI.setThemeMode).toHaveBeenCalledWith('light');
expect(result.current.mode).toBe('dark');
expect(window.electronAPI.setThemeMode).toHaveBeenCalledWith('dark');
});

it('setThemeName updates state immediately and persists via electronAPI.setThemeName', async () => {
const { result } = renderHook(() => useThemeMode(), { wrapper: ThemeModeProvider });
await waitFor(() => expect(result.current.themeName).toBe('default'));

act(() => result.current.setThemeName('slothui'));

expect(result.current.themeName).toBe('slothui');
expect(window.electronAPI.setThemeName).toHaveBeenCalledWith('slothui');
});
});
Loading
Loading