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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,34 @@ 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.2.0] — 2026-09-18
- **Play anything in the background, and queue up more.** A new mini-player
bar at the bottom of the app lets you keep listening (or watching) while
you browse the library, start a download, or adjust Options — without
losing your place. Add a video to the queue from a hover button on the
player, the library grid, or a playlist's own entries; a "Play all" button
queues an entire playlist back-to-back. Queued items play through
automatically, with previous/next controls right in the mini bar.
- Click the queue counter to open the full side panel: see everything
queued, remove an item on hover, click a row to jump to it, or click a
thumbnail to open that video's own page. It defaults to audio-only, with
a toggle to switch to watching the video, and the panel itself is
resizable.
- Works for videos that need extra processing to play (like MKV
downloads) too — the queue prepares them the same way the regular
player already does, automatically.
- Adding something that's already in the queue is now blocked with a
quick heads-up instead of silently duplicating it.
- **Playlist view improvements.**
- Search within an open playlist, not just across your saved playlists.
- A playlist's thumbnail no longer gets stuck on a video that's since
gone private or been deleted, and you can manually pick which video's
thumbnail represents the playlist.
- Refreshing a playlist now actually pulls in the full data for any newly
found videos, not just their title and thumbnail.
- **A small icon in the header now shows when cookies are loaded** for
sending to yt-dlp, with a quick way to clear them right from there.

## [1.1.1] — 2026-09-11
- **Fixed the Windows build failing to fetch its bundled Deno runtime.**
Deno's own release pipeline generates the checksum file for its Windows
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,17 @@ itself.

## Download

<!-- Update the version in these 5 links on every release, currently 1.1.1.
<!-- Update the version in these 5 links on every release, currently 1.2.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.1.1/SlothArchiver.Setup.1.1.1.exe) |
| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.1.1/SlothArchiver.1.1.1.exe) |
| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.1.1/SlothArchiver-1.1.1-arm64.dmg) |
| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.1.1/SlothArchiver-1.1.1.dmg) |
| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.1.1/SlothArchiver-1.1.1.AppImage) |
| Windows (installer) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.2.0/SlothArchiver.Setup.1.2.0.exe) |
| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.2.0/SlothArchiver.1.2.0.exe) |
| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.2.0/SlothArchiver-1.2.0-arm64.dmg) |
| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.2.0/SlothArchiver-1.2.0.dmg) |
| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.2.0/SlothArchiver-1.2.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.1.1",
"version": "1.2.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
103 changes: 95 additions & 8 deletions src/electron/library.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ export function transferVideoTags(libraryDir, sourceTag, targetTag, videoIds) {
// notice -- see LibraryVideoDetail.tsx/PlaylistsSection.tsx's own duplicated
// copy of these two numbers.
export const CURRENT_VIDEO_SCHEMA_VERSION = 4;
export const CURRENT_PLAYLIST_SCHEMA_VERSION = 1;
export const CURRENT_PLAYLIST_SCHEMA_VERSION = 2;

// One cross-platform sanitizer using Windows' illegal-character set as the
// superset (rather than branching per-OS) -- keeps folder names identical if
Expand Down Expand Up @@ -1181,6 +1181,10 @@ export function writePlaylistSnapshot({ libraryDir, libraryTag = DEFAULT_LIBRARY
unavailable: isDeadTitle(e.title, e.videoId),
})),
localFiles,
// Set only via setPlaylistManualThumbnail below -- null means
// "automatic" (follow the first available entry, see
// resolvePlaylistThumbnailUrl).
manualThumbnailVideoId: null,
};
fs.writeFileSync(path.join(epochDir, 'metadata.json'), JSON.stringify(metadata, null, 2), 'utf-8');
return { playlistDir, epochDir, epoch: String(addedEpoch), metadata };
Expand Down Expand Up @@ -1245,17 +1249,49 @@ function resolvePlaylistEpochDir(playlistDir) {

// Playlist-level (not per-epoch), a sibling of the epoch folders -- same
// pattern as channel-icon.*/video-thumbnail.*. Cached by
// ensurePlaylistThumbnail (main.mjs) as a fallback for when the live first
// entry has no thumbnailUrl of its own (empty playlist, or a dead first
// entry) -- the renderer always prefers the live entries[0].thumbnailUrl
// when available.
function findPlaylistThumbnailPath(playlistDir) {
// ensurePlaylistThumbnail (main.mjs) as a fallback for when the live
// resolved thumbnail (see resolvePlaylistThumbnailUrl below) has no
// thumbnailUrl of its own (empty playlist, or every entry dead) -- the
// renderer always prefers the live thumbnailUrl when available. Exported so
// main.mjs can re-read it after calling ensurePlaylistThumbnail itself
// (that function lives there, not here -- it needs the thumbnails.mjs
// fetcher instance main.mjs already owns).
export function findPlaylistThumbnailPath(playlistDir) {
if (!fs.existsSync(playlistDir)) return null;
const entry = fs.readdirSync(playlistDir, { withFileTypes: true })
.find((e) => e.isFile() && e.name.startsWith('playlist-thumbnail.'));
return entry ? path.join(playlistDir, entry.name) : null;
}

// Skips unavailable/thumbnail-less entries rather than blindly trusting
// entries[0] -- a dead or thumbnail-less first entry used to leave the
// playlist showing a broken/missing image even though later entries had a
// perfectly good one. Falls back to entries[0]'s own thumbnailUrl (possibly
// null) only once every entry has been checked and none qualify, so this
// never returns worse than the old unconditional behavior.
export function firstAvailablePlaylistThumbnail(entries) {
const available = (entries || []).find((e) => !e.unavailable && e.thumbnailUrl);
if (available) return available.thumbnailUrl;
return entries?.[0]?.thumbnailUrl || null;
}

// The single source of truth for "what should this playlist's thumbnail be
// right now" -- used by both listPlaylistSnapshots and getPlaylistSnapshot
// so list and detail views never disagree, and by main.mjs whenever it
// needs to refresh the cached fallback file. A manual override
// (setPlaylistManualThumbnail below) wins only while its entry still exists
// and isn't unavailable -- a picked entry that later goes dead or drops out
// of the playlist on refresh silently reverts to automatic, same as B's own
// "never show known-dead content" rule, rather than needing reconcile to
// special-case it.
export function resolvePlaylistThumbnailUrl(metadata) {
if (metadata.manualThumbnailVideoId) {
const manual = (metadata.entries || []).find((e) => e.videoId === metadata.manualThumbnailVideoId);
if (manual && !manual.unavailable && manual.thumbnailUrl) return manual.thumbnailUrl;
}
return firstAvailablePlaylistThumbnail(metadata.entries);
}

// Summary list for the Library tab's new Playlists section -- nothing before
// this read a saved playlist snapshot back into the renderer at all.
export function listPlaylistSnapshots({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME }) {
Expand All @@ -1282,7 +1318,7 @@ export function listPlaylistSnapshots({ libraryDir, libraryTag = DEFAULT_LIBRARY
addedEpoch: metadata.addedEpoch,
lastRefreshedEpoch: metadata.lastRefreshedEpoch || null,
hasPreviousMetadata: fs.existsSync(path.join(epochDir, 'previousMetadata.json')),
thumbnailUrl: metadata.entries?.[0]?.thumbnailUrl || null,
thumbnailUrl: resolvePlaylistThumbnailUrl(metadata),
thumbnailPath: findPlaylistThumbnailPath(playlistDir),
});
}
Expand Down Expand Up @@ -1342,6 +1378,7 @@ export async function getPlaylistSnapshot({ libraryDir, libraryTag = DEFAULT_LIB
localFiles,
hasPreviousMetadata: previousMetadataSavedEpoch !== null,
previousMetadataSavedEpoch,
thumbnailUrl: resolvePlaylistThumbnailUrl(metadata),
thumbnailPath: findPlaylistThumbnailPath(playlistDir),
};
}
Expand Down Expand Up @@ -1420,9 +1457,27 @@ export function reconcilePlaylistSnapshot({ libraryDir, libraryTag = DEFAULT_LIB
localFiles[entry.videoId] = match ? match.video.videoDir : null;
}

// Every live entry with no library match right now -- not just ones
// brand-new to this playlist. An entry can predate this feature, or have
// been in the playlist for a while without ever actually getting added
// (a failed/skipped bulk-add, or simply never triggered), and a refresh
// should still notice and pull its full data in either case, not only
// for entries the reconcile above happens to consider "new."
const missingFromLibraryEntries = reconciledEntries.filter((e) => !e.unavailable && !localFiles[e.videoId]);

const lastRefreshedEpoch = Date.now();
const newMetadata = {
...oldMetadata,
// A refresh brings the saved shape up to date with whatever
// writePlaylistSnapshot would produce today -- without this, an
// "Outdated data" playlist (schemaVersion behind current) stayed
// flagged outdated forever, since oldMetadata's own stale
// schemaVersion was just carried through unchanged by the spread
// above, no matter how many times it was refreshed. Falls back the
// same way writePlaylistSnapshot's own initial write does, for
// fields (manualThumbnailVideoId) that predate this schema version.
schemaVersion: CURRENT_PLAYLIST_SCHEMA_VERSION,
manualThumbnailVideoId: oldMetadata.manualThumbnailVideoId ?? null,
title: freshTitle || oldMetadata.title,
uploader: freshUploader || oldMetadata.uploader,
lastRefreshedEpoch,
Expand All @@ -1434,7 +1489,39 @@ export function reconcilePlaylistSnapshot({ libraryDir, libraryTag = DEFAULT_LIB
fs.writeFileSync(tempPath, JSON.stringify(newMetadata, null, 2), 'utf-8');
fs.renameSync(tempPath, metadataPath);

return { success: true, added, removed, updated, lastRefreshedEpoch, entries: reconciledEntries };
return { success: true, added, removed, updated, lastRefreshedEpoch, entries: reconciledEntries, missingFromLibraryEntries, manualThumbnailVideoId: newMetadata.manualThumbnailVideoId ?? null };
}

// Sets (or, with videoId null, clears) which entry's thumbnail this
// playlist's own thumbnail should follow -- see resolvePlaylistThumbnailUrl
// for how this interacts with automatic (first-available-entry) selection.
// Refuses to point at an entry that doesn't exist or is already flagged
// unavailable, rather than silently accepting a pick that resolvePlaylistThumbnailUrl
// would just ignore anyway -- the caller (the UI's hover button) never
// offers this for an unavailable entry, but the backend shouldn't trust that.
// Doesn't itself touch the cached fallback file (ensurePlaylistThumbnail) --
// that lives in main.mjs, which calls it right after this succeeds.
export function setPlaylistManualThumbnail({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME, playlistId, videoId }) {
const playlistDir = path.join(libraryTagDir(libraryDir, libraryTag), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId));
const epochDir = resolvePlaylistEpochDir(playlistDir);
if (!epochDir) return { success: false, message: 'This playlist has no saved snapshot.' };

const metadataPath = path.join(epochDir, 'metadata.json');
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));

if (videoId) {
const target = metadata.entries.find((e) => e.videoId === videoId);
if (!target || target.unavailable) {
return { success: false, message: 'That entry is not available to use as a thumbnail.' };
}
}

metadata.manualThumbnailVideoId = videoId || null;
const tempPath = `${metadataPath}.new`;
fs.writeFileSync(tempPath, JSON.stringify(metadata, null, 2), 'utf-8');
fs.renameSync(tempPath, metadataPath);

return { success: true, manualThumbnailVideoId: metadata.manualThumbnailVideoId, thumbnailUrl: resolvePlaylistThumbnailUrl(metadata) };
}

// One-shot undo -- reverts to previousMetadata.json (written by the most
Expand Down
Loading
Loading