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.4.0] — 2026-09-22
- **A compact list view for the library.** The flat "by video" list now has
a grid/list toggle next to the thumbnail-size slider: list view shows each
video as a dense row (thumbnail, title, platform/quality chips, labels,
upload date, version/clip counts) instead of a card, with the same
selection and "Add to queue" support as the grid. A second slider — discrete
1/2/3 stops, replacing the thumbnail-size slider while list view is active
— lets you choose how many columns of rows to show, defaulting to one.
- **Platform label cleanup on the by-video list.** The platform name no
longer shows up twice on a card (once as its own chip, once again as plain
text where a YouTube channel name would go) — that plain-text line is
YouTube-channel-only now. The platform and quality/downloaded chips moved
down to sit next to it, left-aligned, so the title gets the full width of
the card instead of squeezing against them.
- **Fixed the thumbnail sometimes showing as a generic placeholder** when
bulk-downloading videos already in your library (Library view → select
some → Download selected) — it now carries over the video's own cached
thumbnail instead of guessing a YouTube-only URL that didn't apply.

## [1.3.0] — 2026-09-22
- **Add videos from virtually any yt-dlp-supported site to your library, not
just YouTube.** SoundCloud, Dailymotion, archive.org, PeerTube, and the rest
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,17 @@ itself.

## Download

<!-- Update the version in these 5 links on every release, currently 1.3.0.
<!-- Update the version in these 5 links on every release, currently 1.4.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/v1.3.0/SlothArchiver.Setup.1.3.0.exe) |
| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.3.0/SlothArchiver.1.3.0.exe) |
| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.3.0/SlothArchiver-1.3.0-arm64.dmg) |
| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.3.0/SlothArchiver-1.3.0.dmg) |
| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.3.0/SlothArchiver-1.3.0.AppImage) |
| Windows (installer) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.4.0/SlothArchiver.Setup.1.4.0.exe) |
| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.4.0/SlothArchiver.1.4.0.exe) |
| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.4.0/SlothArchiver-1.4.0-arm64.dmg) |
| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.4.0/SlothArchiver-1.4.0.dmg) |
| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.4.0/SlothArchiver-1.4.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": "1.3.0",
"version": "1.4.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
26 changes: 25 additions & 1 deletion 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, savePlaybackPosition, findVideoThumbnailPath, deleteLibraryEntry, deleteLocalFiles, moveLibraryEntry, writePlaylistSnapshot, enrichPlaylistEntry, listPlaylistSnapshots, getPlaylistSnapshot, reconcilePlaylistSnapshot, setPlaylistManualThumbnail, 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, firstAvailablePlaylistThumbnail, resolvePlaylistThumbnailUrl, findPlaylistThumbnailPath } from './library.mjs';
import { createSettingsStore, clampMaxSimultaneousDownloads, clampThumbnailSize, THUMBNAIL_SIZE_DEFAULT, clampLibrarySortField, clampLibrarySortDirection, clampThemeName, clampResumeTrackingMode, RESUME_TRACKING_MODE_DEFAULT, clampResumeMinDurationSeconds, RESUME_MIN_DURATION_SECONDS_DEFAULT, clampEmbedMetadataByDefault, EMBED_METADATA_BY_DEFAULT_DEFAULT } from './settings.mjs';
import { createSettingsStore, clampMaxSimultaneousDownloads, clampThumbnailSize, THUMBNAIL_SIZE_DEFAULT, clampLibrarySortField, clampLibrarySortDirection, clampLibraryDisplayMode, clampLibraryListColumns, clampThemeName, clampResumeTrackingMode, RESUME_TRACKING_MODE_DEFAULT, clampResumeMinDurationSeconds, RESUME_MIN_DURATION_SECONDS_DEFAULT, clampEmbedMetadataByDefault, EMBED_METADATA_BY_DEFAULT_DEFAULT } 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 @@ -548,6 +548,30 @@ ipcMain.handle('settings:setLibrarySort', async (e, { sortField, sortDirection }
return { success: true, sortField: settings.librarySortField, sortDirection: settings.librarySortDirection };
});

ipcMain.handle('settings:getLibraryDisplayMode', async () => {
const { libraryDisplayMode } = readSettings();
return { libraryDisplayMode: clampLibraryDisplayMode(libraryDisplayMode) };
});

ipcMain.handle('settings:setLibraryDisplayMode', async (e, mode) => {
const settings = readSettings();
settings.libraryDisplayMode = clampLibraryDisplayMode(mode);
writeSettings(settings);
return { success: true, libraryDisplayMode: settings.libraryDisplayMode };
});

ipcMain.handle('settings:getLibraryListColumns', async () => {
const { libraryListColumns } = readSettings();
return { libraryListColumns: clampLibraryListColumns(libraryListColumns) };
});

ipcMain.handle('settings:setLibraryListColumns', async (e, columns) => {
const settings = readSettings();
settings.libraryListColumns = clampLibraryListColumns(columns);
writeSettings(settings);
return { success: true, libraryListColumns: settings.libraryListColumns };
});

ipcMain.handle('settings:getThemeMode', async () => {
const { themeMode } = readSettings();
// Dark is the default for a fresh install (paired with SlothUI as the
Expand Down
4 changes: 4 additions & 0 deletions src/electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
setLibraryViewMode: (mode) => ipcRenderer.invoke('settings:setLibraryViewMode', mode),
getLibrarySort: () => ipcRenderer.invoke('settings:getLibrarySort'),
setLibrarySort: (payload) => ipcRenderer.invoke('settings:setLibrarySort', payload),
getLibraryDisplayMode: () => ipcRenderer.invoke('settings:getLibraryDisplayMode'),
setLibraryDisplayMode: (mode) => ipcRenderer.invoke('settings:setLibraryDisplayMode', mode),
getLibraryListColumns: () => ipcRenderer.invoke('settings:getLibraryListColumns'),
setLibraryListColumns: (columns) => ipcRenderer.invoke('settings:setLibraryListColumns', columns),
getThemeMode: () => ipcRenderer.invoke('settings:getThemeMode'),
setThemeMode: (mode) => ipcRenderer.invoke('settings:setThemeMode', mode),
getThemeName: () => ipcRenderer.invoke('settings:getThemeName'),
Expand Down
23 changes: 23 additions & 0 deletions src/electron/settings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,29 @@ export function clampLibrarySortDirection(value) {
return value === 'desc' ? 'desc' : 'asc';
}

// The flat video view's card-grid vs. compact-list layout -- kept separate
// from libraryViewMode (channel vs. video grouping) since the two are
// orthogonal.
export const LIBRARY_DISPLAY_MODES = ['grid', 'list'];
export const LIBRARY_DISPLAY_MODE_DEFAULT = 'grid';

export function clampLibraryDisplayMode(value) {
return LIBRARY_DISPLAY_MODES.includes(value) ? value : LIBRARY_DISPLAY_MODE_DEFAULT;
}

// How many columns the flat video view's compact list lays its rows out in
// -- only meaningful once libraryDisplayMode is 'list' (grid mode uses
// thumbnailSize for its own column count instead, via thumbnailGridTemplateColumns).
// A small closed set (not a free-form number) since a list row is
// information-dense -- past 3 columns it stops reading as a "list" at all.
export const LIBRARY_LIST_COLUMNS = [1, 2, 3];
export const LIBRARY_LIST_COLUMNS_DEFAULT = 1;

export function clampLibraryListColumns(value) {
const n = Number(value);
return LIBRARY_LIST_COLUMNS.includes(n) ? n : LIBRARY_LIST_COLUMNS_DEFAULT;
}

// 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).
Expand Down
4 changes: 4 additions & 0 deletions src/types/electron-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ declare global {
setLibraryViewMode: (mode: 'channel' | 'video') => Promise<{ success: boolean; libraryViewMode: 'channel' | 'video' }>
getLibrarySort: () => Promise<{ sortField: LibrarySortField; sortDirection: LibrarySortDirection }>
setLibrarySort: (payload: { sortField: LibrarySortField; sortDirection: LibrarySortDirection }) => Promise<{ success: boolean; sortField: LibrarySortField; sortDirection: LibrarySortDirection }>
getLibraryDisplayMode: () => Promise<{ libraryDisplayMode: 'grid' | 'list' }>
setLibraryDisplayMode: (mode: 'grid' | 'list') => Promise<{ success: boolean; libraryDisplayMode: 'grid' | 'list' }>
getLibraryListColumns: () => Promise<{ libraryListColumns: number }>
setLibraryListColumns: (columns: number) => Promise<{ success: boolean; libraryListColumns: number }>
getThemeMode: () => Promise<{ themeMode: 'light' | 'dark' }>
setThemeMode: (mode: 'light' | 'dark') => Promise<{ success: boolean; themeMode: 'light' | 'dark' }>
getThemeName: () => Promise<{ themeName: 'default' | 'slothui' }>
Expand Down
96 changes: 78 additions & 18 deletions src/ui/components/LibraryBottomBar.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,52 @@
import { Box, Button, Paper, Slider, Stack, Typography } from '@mui/material';
import { Box, Button, Paper, Slider, Stack, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material';
import PhotoSizeSelectSmallIcon from '@mui/icons-material/PhotoSizeSelectSmall';
import PhotoSizeSelectLargeIcon from '@mui/icons-material/PhotoSizeSelectLarge';
import ViewColumnIcon from '@mui/icons-material/ViewColumn';
import DownloadIcon from '@mui/icons-material/Download';
import FolderDeleteIcon from '@mui/icons-material/FolderDelete';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import DriveFileMoveIcon from '@mui/icons-material/DriveFileMove';
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
import ViewListIcon from '@mui/icons-material/ViewList';

export const THUMBNAIL_SIZE_MIN = 160;
export const THUMBNAIL_SIZE_MAX = 360;
export const THUMBNAIL_SIZE_STEP = 10;

// Mirrors settings.mjs's own LIBRARY_LIST_COLUMNS -- kept as plain numbers
// here (not imported from the Node module) since this is renderer code.
const LIST_COLUMNS_MIN = 1;
const LIST_COLUMNS_MAX = 3;

// Purely presentational/controlled -- the caller owns the state and any
// settings IPC round-trip. Thumbnail-size props are optional: the Playlists
// view has no thumbnail grid to size, so omitting them hides that section
// entirely rather than showing an irrelevant control.
export default function LibraryBottomBar({
displayMode, onDisplayModeChange,
thumbnailSize, onThumbnailSizeChange, onThumbnailSizeCommit,
listColumns, onListColumnsChange,
selectedCount, canBulkDownload, onDownloadSelected,
canDeleteLocalFiles, onDeleteLocalFiles, onDeleteFromLibrary,
canMove, onMoveSelected,
canTag, onTagSelected,
}: {
// Optional, same reason as thumbnailSize below -- only the flat video
// view has a grid/list layout to switch between; the Playlists view (and
// a single channel's own video grid) omit these, which hides the toggle.
displayMode?: 'grid' | 'list';
onDisplayModeChange?: (mode: 'grid' | 'list') => void;
thumbnailSize?: number;
onThumbnailSizeChange?: (size: number) => void;
onThumbnailSizeCommit?: (size: number) => void;
// Same slot as the thumbnail-size slider above, swapped in instead of it
// whenever displayMode is 'list' (see the render logic below) -- the
// thumbnail size has nothing to size once the grid isn't rendered at all.
// Optional for the same reason as thumbnailSize -- omitted wherever
// displayMode itself is omitted.
listColumns?: number;
onListColumnsChange?: (columns: number) => void;
selectedCount: number;
canBulkDownload: boolean;
onDownloadSelected: () => void;
Expand Down Expand Up @@ -79,24 +101,62 @@ export default function LibraryBottomBar({
</Button>
</>}
</Box>
{thumbnailSize !== undefined && onThumbnailSizeChange && onThumbnailSizeCommit &&
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ width: 220 }}>
<PhotoSizeSelectSmallIcon fontSize="small" color="action" />
<Slider
<Stack direction="row" spacing={1.5} alignItems="center">
{displayMode !== undefined && onDisplayModeChange &&
<ToggleButtonGroup
value={displayMode}
exclusive
size="small"
value={thumbnailSize}
min={THUMBNAIL_SIZE_MIN}
max={THUMBNAIL_SIZE_MAX}
step={THUMBNAIL_SIZE_STEP}
// SAFETY: this Slider has a single scalar `value`, never a
// [min, max] range, so MUI's value callback is always a number.
onChange={(_e, value) => onThumbnailSizeChange(value as number)}
// SAFETY: same single-scalar `value` as onChange above.
onChangeCommitted={(_e, value) => onThumbnailSizeCommit(value as number)}
aria-label="Thumbnail size"
/>
<PhotoSizeSelectLargeIcon fontSize="small" color="action" />
</Stack>}
onChange={(_e, value: 'grid' | 'list' | null) => value && onDisplayModeChange(value)}
aria-label="Library display mode"
>
<ToggleButton value="grid" aria-label="Grid view">
<Tooltip title="Grid view">
<ViewModuleIcon fontSize="small" />
</Tooltip>
</ToggleButton>
<ToggleButton value="list" aria-label="List view">
<Tooltip title="List view">
<ViewListIcon fontSize="small" />
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>}
{displayMode === 'list' && listColumns !== undefined && onListColumnsChange ? (
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ width: 220 }}>
<ViewColumnIcon fontSize="small" color="action" />
<Slider
size="small"
value={listColumns}
min={LIST_COLUMNS_MIN}
max={LIST_COLUMNS_MAX}
step={1}
marks
// SAFETY: this Slider has a single scalar `value`, never a
// [min, max] range, so MUI's value callback is always a number.
onChange={(_e, value) => onListColumnsChange(value as number)}
aria-label="List columns"
/>
</Stack>
) : thumbnailSize !== undefined && onThumbnailSizeChange && onThumbnailSizeCommit && (
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ width: 220 }}>
<PhotoSizeSelectSmallIcon fontSize="small" color="action" />
<Slider
size="small"
value={thumbnailSize}
min={THUMBNAIL_SIZE_MIN}
max={THUMBNAIL_SIZE_MAX}
step={THUMBNAIL_SIZE_STEP}
// SAFETY: this Slider has a single scalar `value`, never a
// [min, max] range, so MUI's value callback is always a number.
onChange={(_e, value) => onThumbnailSizeChange(value as number)}
// SAFETY: same single-scalar `value` as onChange above.
onChangeCommitted={(_e, value) => onThumbnailSizeCommit(value as number)}
aria-label="Thumbnail size"
/>
<PhotoSizeSelectLargeIcon fontSize="small" color="action" />
</Stack>
)}
</Stack>
</Paper>
);
}
24 changes: 19 additions & 5 deletions src/ui/hooks/useBulkAddQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@
// BulkAddDialog tags each expanded playlist's own entries with its own
// playlistId rather than assuming the whole batch came from one playlist.
playlistId?: string;
// Already-known thumbnail for an entry that's already in the library (the
// Library tab's "Download selected" action) -- start() below prefers this
// over its own videoId-based guess, since that guess is a YouTube-only CDN
// URL pattern that's wrong for any other platform, and this flow's items
// skip the processItem fetch step entirely (see videoDir/epoch/resolution/
// kind above), so nothing would ever correct a wrong guess later. Never
// set by the URL/playlist-paste flow (BulkAddDialog), which relies on that
// guess (or the later fetch) instead.
thumbnailUrl?: string;
};

export type BulkAddItem = {
Expand Down Expand Up @@ -65,7 +74,7 @@
// Derived from the item's own stashed fields (not a separate flag, which
// could drift) -- 'download' means the library entry already exists and
// only the download needs retrying; 'fetch' means starting over.
export function getRetryStage(item: BulkAddItem): 'fetch' | 'download' {

Check warning on line 77 in src/ui/hooks/useBulkAddQueue.tsx

View workflow job for this annotation

GitHub Actions / validate

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
return item.videoDir && item.epoch && item.resolution ? 'download' : 'fetch';
}

Expand Down Expand Up @@ -434,11 +443,16 @@
resolution: entry.resolution,
kind: entry.kind,
playlistId: entry.playlistId,
// YouTube's thumbnail CDN URL is a stable, public, unauthenticated
// pattern keyed on videoId -- free to construct for playlist-sourced
// entries with no extra fetch; list-sourced entries pick this up once
// processItem fetches their info.
thumbnailUrl: entry.videoId ? `https://i.ytimg.com/vi/${entry.videoId}/mqdefault.jpg` : undefined,
// entry.thumbnailUrl (set by the Library tab's "Download selected"
// action -- see BulkAddEntry's own comment) wins outright: those items
// never reach processItem's fetch step, so this is the only chance to
// get it right, and the YouTube CDN guess below is wrong for anything
// but YouTube anyway. Otherwise, YouTube's thumbnail CDN URL is a
// stable, public, unauthenticated pattern keyed on videoId -- free to
// construct for playlist-sourced entries with no extra fetch;
// list-sourced entries pick up a real one once processItem fetches
// their info.
thumbnailUrl: entry.thumbnailUrl ?? (entry.videoId ? `https://i.ytimg.com/vi/${entry.videoId}/mqdefault.jpg` : undefined),
}));
itemsRef.current = [...itemsRef.current, ...newItems];
setItems(itemsRef.current);
Expand Down
Loading
Loading