diff --git a/CHANGELOG.md b/CHANGELOG.md
index b6ea8e9..2629ce5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
+## [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
+ download or bulk add lands in. Move any video — every saved version, its
+ clips, and its channel's icon if needed — to a different sublibrary at any
+ time, individually or several at once from the bulk-selection bar. This is
+ the feature the `DefaultLibrary` groundwork from 0.26.2 was preparing for.
+- **Tag your videos, and filter the library by tag.** Add one or more tags to
+ a video from its detail view (next to its quality badge), or tag several
+ videos at once from the bulk-selection bar. A filter icon next to the sort
+ control lets you narrow the library down to videos carrying every tag you
+ select, layered on top of your current search and sort.
+- **A video whose downloaded file goes missing — moved, renamed, or deleted
+ outside the app — now recovers automatically where possible.** Opening it
+ triggers a quick check that repairs the stored file link if a matching
+ file is found nearby; if nothing's found, a clear warning tells you to
+ re-download it or locate it yourself instead of the player just failing
+ silently.
+
## [0.26.2] — 2026-09-02
- **Library thumbnails and channel icons now scale with your window, not
just a fixed size.** Both grids used to cap out at a flat pixel size no
diff --git a/README.md b/README.md
index 7262d2e..f66ee42 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,18 @@
# SlothArchiver
-[](https://github.com/lltrash94/Sloth-Archiver/releases/latest)
-[](https://github.com/lltrash94/Sloth-Archiver/actions/workflows/ci.yml)
+[](https://github.com/SlothSoftworks/Sloth-Archiver/releases/latest)
+[](https://github.com/SlothSoftworks/Sloth-Archiver/actions/workflows/ci.yml)
[](LICENSE)
[](#download)
-**Your own personal, offline video archive — for people too lazy to fight with
+**Your own personal, offline video archive — for people who like to keep their video archiving sweet and simple and not fight with
Docker, servers, or a complicated setup to get one.**
+
+
+
+
+
*Free and open source — no subscriptions, no accounts, no server to run.*
SlothArchiver is a free desktop app for downloading and organizing videos and audio
@@ -20,22 +25,22 @@ itself.
## Download
-
| Platform | Link |
|---|---|
-| Windows (installer) | [Download](https://github.com/lltrash94/Sloth-Archiver/releases/download/v0.26.2/SlothArchiver.Setup.0.26.2.exe) |
-| Windows (portable, no install) | [Download](https://github.com/lltrash94/Sloth-Archiver/releases/download/v0.26.2/SlothArchiver.0.26.2.exe) |
-| macOS (Apple Silicon) | [Download](https://github.com/lltrash94/Sloth-Archiver/releases/download/v0.26.2/SlothArchiver-0.26.2-arm64.dmg) |
-| macOS (Intel) | [Download](https://github.com/lltrash94/Sloth-Archiver/releases/download/v0.26.2/SlothArchiver-0.26.2.dmg) |
-| Linux (AppImage) | [Download](https://github.com/lltrash94/Sloth-Archiver/releases/download/v0.26.2/SlothArchiver-0.26.2.AppImage) |
+| 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) |
All builds are unsigned, so your OS will show a first-run security warning —
see [Installing](#installing) below.
@@ -52,12 +57,12 @@ your own machine.
## Why SlothArchiver?
-Most tools in this space make you work for a real archive. Self-hosted servers give
+Most tools in this space seem make you work for a real archive. Self-hosted servers give
you one, but only if you're willing to run Docker, manage a server, and keep a
machine on around the clock. Lightweight desktop downloaders skip all of that
setup, but most of them just drop files in a folder and call it done — no real
organization, nothing actually archived, just a pile of files you have to manage
-yourself — or eventually ask you to pay once you want more than that.
+yourself and the ones that do have some sort of paywall. So I got tired of waiting for the solution and started to develop it myself.
SlothArchiver is built to be the lazy option in the best sense: a normal app you
install like any other, with zero server to babysit and zero config to get right.
@@ -82,46 +87,13 @@ that.
No other tool in this space currently checks every one of those boxes at once —
that combination is the whole point of SlothArchiver; A simple catalogued library, organized in one place and fully local.
-## Installing
-
-SlothArchiver's builds aren't code-signed (that requires a paid certificate
-this project doesn't have set up) — so the first time you open one, your OS
-will warn you it's from an unidentified/unrecognized developer. This is
-expected for any unsigned app, not a sign anything's actually wrong.
-Here's how to get past each OS's warning:
-
-**Windows** — you'll see "Windows protected your PC" (SmartScreen). Click
-**More info**, then **Run anyway**.
-
-
-
-**macOS** — you'll see a message that the app "cannot be opened because it is
-from an unidentified developer," and just double-clicking won't offer a way
-past it. Instead: right-click (or Control-click) the app → **Open** → confirm
-**Open** in the dialog that appears. You only need to do this once — after
-that, it opens normally. If macOS still blocks it, go to **System Settings →
-Privacy & Security**, scroll down, and click **Open Anyway** next to the
-message about SlothArchiver.
-
-
-
-**Linux (AppImage)** — make it executable first, then run it directly:
-```
-chmod +x SlothArchiver-.AppImage
-./SlothArchiver-.AppImage
-```
-If it fails to launch at all with a FUSE-related error, your distro likely
-needs `libfuse2` installed (common on newer distros that dropped FUSE2 by
-default, e.g. recent Ubuntu/Fedora releases) — install it via your package
-manager and try again.
-
## Features
- **No paywall, ever** — every feature below is free today and will stay free in
future updates. No tiers, no locked resolutions, no daily download caps.
-- **Download video or audio** from YouTube and a growing list of other platforms
- (SoundCloud, TikTok, Instagram, Facebook, Dailymotion, and more), in the quality
- you choose.
+- **Download video or audio** from YouTube and virtually any other site yt-dlp
+ supports — 1800+ platforms, including SoundCloud, TikTok, Instagram, Facebook,
+ Dailymotion, and more — in the quality you choose.
- **A real library, not just a downloads folder** — every video is organized by
channel, searchable, and keeps a history of versions if you ever re-fetch it.
Most downloaders stop at "file saved somewhere"; SlothArchiver actually keeps
@@ -197,13 +169,13 @@ all straight from the library view, no other software required.

-### Download from more than just YouTube
+### Download from virtually any site yt-dlp supports
-SoundCloud, TikTok, Instagram, Facebook, Dailymotion, and more — paste a link
-from any of them and SlothArchiver figures out the platform automatically and
-gets you a download, no separate tool needed for each site. This list keeps
-growing as development continues, so expect more platforms to be supported
-over time.
+Not just YouTube — SlothArchiver works with virtually any of the
+[1800+ sites yt-dlp itself supports](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md),
+including SoundCloud, TikTok, Instagram, Facebook, Dailymotion, and more.
+Paste a link from any of them and SlothArchiver figures out the platform
+automatically and gets you a download, no separate tool needed for each site.

@@ -215,6 +187,42 @@ per-item progress, retry, and skip, so one bad link never holds up the rest.

+
+
+## Installing
+
+SlothArchiver's builds aren't code-signed (that requires a paid certificate
+this project doesn't have set up) — so the first time you open one, your OS
+will warn you it's from an unidentified/unrecognized developer. This is
+expected for any unsigned app, not a sign anything's actually wrong.
+Here's how to get past each OS's warning:
+
+**Windows** — you'll see "Windows protected your PC" (SmartScreen). Click
+**More info**, then **Run anyway**.
+
+
+
+**macOS** — you'll see a message that the app "cannot be opened because it is
+from an unidentified developer," and just double-clicking won't offer a way
+past it. Instead: right-click (or Control-click) the app → **Open** → confirm
+**Open** in the dialog that appears. You only need to do this once — after
+that, it opens normally. If macOS still blocks it, go to **System Settings →
+Privacy & Security**, scroll down, and click **Open Anyway** next to the
+message about SlothArchiver.
+
+
+
+**Linux (AppImage)** — make it executable first, then run it directly:
+```
+chmod +x SlothArchiver-.AppImage
+./SlothArchiver-.AppImage
+```
+If it fails to launch at all with a FUSE-related error, your distro likely
+needs `libfuse2` installed (common on newer distros that dropped FUSE2 by
+default, e.g. recent Ubuntu/Fedora releases) — install it via your package
+manager and try again.
+
+
## How it works
SlothArchiver runs everything locally on your computer — there's no server, no
@@ -246,7 +254,7 @@ SlothArchiver is a personal-use, local tool — everything it downloads stays in
>TL;DR: It's a gray area as far as YouTube is concerned but not illegal. This is a personal, local-only archiving tool — staying within your platform's ToS and your local copyright law is on you.
#### What platforms does SlothArchiver support downloading from?
-At time of writing we support YouTube for downloads, library, and playlists. We support TikTok, Instagram, Twitter, Facebook, SoundCloud, and Dailymotion for pure downloads.
+At time of writing we support YouTube for downloads, library, and playlists. For pure downloads, we support virtually any site [yt-dlp itself supports](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) — 1800+ platforms — including TikTok, Instagram, Twitter, Facebook, SoundCloud, and Dailymotion, to name a few with dedicated recognition in the app's UI.
#### What operating systems does SlothArchiver run on?
Currently we support Mac, Linux, and Windows. This is a solo dev operation and I manually create the executables per system, so I expect there could be OS issues as more people try out the software on their systems. If you experience any issues, let me know by raising an issue and detailing it there.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 08c0ab5..e607693 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -2,8 +2,9 @@
sloth-archiver is a desktop application (built on Electron, with a React/TypeScript
interface) for downloading and archiving video and audio content — primarily from
-YouTube, with support for several other platforms — into a personal, local library
-on the user's own machine. It wraps the well-known `yt-dlp` tool for the actual
+YouTube, with download support for virtually any other site yt-dlp itself
+supports (1800+ platforms) — into a personal, local library on the user's own
+machine. It wraps the well-known `yt-dlp` tool for the actual
extraction/download work and `ffmpeg` for local media processing, and adds a
persistent, browsable library on top: versioned entries, playlists, search, and a
built-in player.
diff --git a/package-lock.json b/package-lock.json
index 195ea3f..6788fe9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sloth-archiver",
- "version": "0.26.2",
+ "version": "0.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sloth-archiver",
- "version": "0.26.2",
+ "version": "0.27.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@emotion/react": "^11.14.0",
diff --git a/package.json b/package.json
index 5bab6d7..0516d63 100644
--- a/package.json
+++ b/package.json
@@ -1,14 +1,14 @@
{
"name": "sloth-archiver",
"private": true,
- "version": "0.26.2",
+ "version": "0.27.0",
"description": "A desktop app for downloading and archiving videos, with a built-in library and playlist tracking.",
"author": "Sloth ",
"license": "GPL-3.0-or-later",
"type": "module",
"main": "dist/electron/main.mjs",
"build": {
- "appId": "com.lltrash.slotharchiver.app",
+ "appId": "com.slothsoftworks.slotharchiver.app",
"productName": "SlothArchiver",
"files": [
"dist/electron/**/*",
diff --git a/src/electron/library.mjs b/src/electron/library.mjs
index c7f7df8..7e53e8c 100644
--- a/src/electron/library.mjs
+++ b/src/electron/library.mjs
@@ -26,15 +26,15 @@ export const PLAYLISTS_DIR_NAME = 'playlists';
// root).
export const CLIPS_DIR_NAME = 'clips';
-// Prep step for the future "SubLibrary" / tag library feature (see
+// SubLibrary / tag library feature (see
// SlothArchiver-dossier/futureSpecsFeedback.md's decided design): every tag,
// including the default untagged case, is a same-filesystem subfolder
// directly under libraryDir -- ////
-// , uniform for every tag. The actual tag-switching/creation feature
-// isn't built yet, so DEFAULT_LIBRARY_DIR_NAME is the only tag folder that
-// exists today; every channel/playlist write and scanLibrary's own walk goes
-// through defaultLibraryDir() below rather than libraryDir directly, so this
-// is the only place a future multi-tag resolver needs to change.
+// , uniform for every tag. DEFAULT_LIBRARY_DIR_NAME is just the one
+// tag every library starts with; every channel/playlist write and
+// scanLibrary's own walk goes through libraryTagDir() below rather than
+// libraryDir directly, so switching/adding tags is just a different tagName
+// argument, not a different code path.
//
// Deliberately NOT retroactive: a library populated before this layer
// existed (flat channel folders directly under libraryDir) is not migrated
@@ -42,28 +42,227 @@ export const CLIPS_DIR_NAME = 'clips';
// complexity this early in development.
export const DEFAULT_LIBRARY_DIR_NAME = 'DefaultLibrary';
-// Per-tag manifest living inside each tag folder (currently only ever
-// DEFAULT_LIBRARY_DIR_NAME) -- date created, its tag name, and whatever else
-// a future tag-management UI ends up needing, without inferring any of it
-// from the folder name alone.
+// Per-tag manifest living inside each tag folder -- date created, its tag
+// name, and whatever else a future tag-management UI ends up needing,
+// without inferring any of it from the folder name alone. Also what
+// listLibraryTags() below uses to tell a real tag folder from an unrelated
+// one that happens to sit alongside it in libraryDir.
const LIBRARY_METADATA_FILE_NAME = 'library.json';
-export function defaultLibraryDir(libraryDir) {
- return path.join(libraryDir, DEFAULT_LIBRARY_DIR_NAME);
+export function libraryTagDir(libraryDir, tagName = DEFAULT_LIBRARY_DIR_NAME) {
+ return path.join(libraryDir, tagName);
}
-// Lazily creates the default tag folder + its library.json manifest the
-// first time something is actually about to be written into it -- never
-// eagerly (e.g. at app start or when libraryDir is first configured), and
-// never as a migration of anything that predates this layer. A no-op past
-// the first call for a given libraryDir.
-function ensureDefaultLibraryMetadata(libraryDir) {
- const dir = defaultLibraryDir(libraryDir);
+function libraryTagManifestPath(libraryDir, tagName) {
+ return path.join(libraryTagDir(libraryDir, tagName), LIBRARY_METADATA_FILE_NAME);
+}
+
+// Read-only counterpart to ensureLibraryTagMetadata below -- returns null
+// (never creates anything) so the video-tag functions further down can tell
+// "no manifest yet" apart from "manifest exists but has no tags field".
+function readLibraryTagManifest(libraryDir, tagName) {
+ try {
+ return JSON.parse(fs.readFileSync(libraryTagManifestPath(libraryDir, tagName), 'utf-8'));
+ } catch {
+ return null;
+ }
+}
+
+function writeLibraryTagManifest(libraryDir, tagName, metadata) {
+ fs.writeFileSync(libraryTagManifestPath(libraryDir, tagName), JSON.stringify(metadata, null, 2), 'utf-8');
+}
+
+// Lazily creates a tag folder + its library.json manifest the first time
+// something is actually about to be written into it. For DEFAULT_LIBRARY_DIR_NAME
+// specifically this is never eager (not at app start, not when libraryDir is
+// first configured) and never a migration of anything that predates this
+// layer -- callers rely on that. A no-op past the first call for a given
+// (libraryDir, tagName) pair, which also makes it safe to call unconditionally
+// from createLibraryTag()'s own eager creation path below.
+//
+// The manifest's own name field was renamed tagName -> sublibraryName (to
+// stop colliding with the unrelated per-video "tags" concept below) without
+// a migration step -- a sublibrary folder written before this rename still
+// only has `tagName` on disk, so every reader falls back to it. New writes
+// only ever produce sublibraryName; the on-disk key is the only thing that
+// changed, the JS-facing shape returned to callers/the renderer is untouched.
+function ensureLibraryTagMetadata(libraryDir, tagName = DEFAULT_LIBRARY_DIR_NAME) {
+ const dir = libraryTagDir(libraryDir, tagName);
const metadataPath = path.join(dir, LIBRARY_METADATA_FILE_NAME);
- if (fs.existsSync(metadataPath)) return;
+ if (fs.existsSync(metadataPath)) {
+ try {
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
+ if (metadata.sublibraryName) return metadata;
+ return { ...metadata, sublibraryName: metadata.tagName };
+ } catch {
+ // Falls through to rewrite a fresh one below if the existing
+ // file is somehow corrupt.
+ }
+ }
fs.mkdirSync(dir, { recursive: true });
- const metadata = { tagName: DEFAULT_LIBRARY_DIR_NAME, createdEpoch: Date.now() };
+ const metadata = { sublibraryName: tagName, createdEpoch: Date.now() };
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
+ return metadata;
+}
+
+// Enumerates every real tag/sublibrary folder directly under libraryDir --
+// "real" meaning it has a parseable library.json, same test a random
+// unrelated folder a user happens to keep alongside their library would
+// fail. Returns [] for a not-yet-existing/empty libraryDir rather than
+// throwing, same tolerant stance scanLibrary takes.
+export function listLibraryTags(libraryDir) {
+ if (!libraryDir || !fs.existsSync(libraryDir)) return [];
+
+ const tags = [];
+ let entries;
+ try {
+ entries = fs.readdirSync(libraryDir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ const metadataPath = path.join(libraryDir, entry.name, LIBRARY_METADATA_FILE_NAME);
+ try {
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
+ const sublibraryName = metadata.sublibraryName || metadata.tagName;
+ if (!sublibraryName) continue;
+ tags.push({ tagName: sublibraryName, folderName: entry.name, createdEpoch: metadata.createdEpoch || null });
+ } catch {
+ continue;
+ }
+ }
+ tags.sort((a, b) => (a.createdEpoch || 0) - (b.createdEpoch || 0));
+ return tags;
+}
+
+// Eagerly creates a brand-new sublibrary -- unlike DEFAULT_LIBRARY_DIR_NAME's
+// own lazy creation, this is a deliberate, explicit user action ("Add new
+// sublibrary"), so the folder + library.json are created immediately, not on
+// first write. requestedName goes through the same sanitizer channel names
+// already use, and creation is refused outright if anything -- a real tag or
+// just an unrelated file/folder -- already exists at that path, rather than
+// silently reusing or clobbering it.
+export function createLibraryTag(libraryDir, requestedName) {
+ if (!libraryDir) {
+ throw new Error('No library folder is configured -- set one in Options first.');
+ }
+ const folderName = sanitizeForFilesystem(requestedName);
+ const dir = libraryTagDir(libraryDir, folderName);
+ if (fs.existsSync(dir)) {
+ throw new Error(`"${folderName}" already exists in your library folder -- pick a different name.`);
+ }
+ const metadata = ensureLibraryTagMetadata(libraryDir, folderName);
+ return { tagName: metadata.sublibraryName, folderName, createdEpoch: metadata.createdEpoch };
+}
+
+// Video tags -- a small map living in the SAME per-sublibrary library.json
+// manifest as sublibraryName/createdEpoch (not a per-video file), keyed by
+// tag name to an array of videoIds carrying that tag within this
+// sublibrary. Piggybacking on this file (already read once per sublibrary,
+// not once per video) avoids scanLibrary needing a second per-video file
+// read it doesn't already do. Deliberately named videoTag(s) everywhere,
+// never bare "tag", to stay unambiguous against the unrelated sublibrary
+// concept above.
+export function listVideoTags(libraryDir, tagName = DEFAULT_LIBRARY_DIR_NAME) {
+ const manifest = readLibraryTagManifest(libraryDir, tagName);
+ return (manifest && manifest.tags) || {};
+}
+
+// Single video/single tag toggle, used by the video-detail popover (the
+// only place a tag is ever removed) and, with applied:true, its "create a
+// new tag" field -- creating the tag key on first use needs no separate
+// function. Removing the last videoId under a tag deletes that key
+// entirely, so unchecking a video's last tag doesn't leave a permanent
+// empty entry cluttering the picker.
+export function setVideoTag({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME, tagName, videoId, applied }) {
+ ensureLibraryTagMetadata(libraryDir, libraryTag);
+ const manifest = readLibraryTagManifest(libraryDir, libraryTag) || {};
+ const tags = { ...(manifest.tags || {}) };
+ const current = tags[tagName] || [];
+ if (applied) {
+ if (!current.includes(videoId)) tags[tagName] = [...current, videoId];
+ } else if (current.includes(videoId)) {
+ const next = current.filter((id) => id !== videoId);
+ if (next.length === 0) delete tags[tagName];
+ else tags[tagName] = next;
+ }
+ writeLibraryTagManifest(libraryDir, libraryTag, { ...manifest, tags });
+ return { tags };
+}
+
+// Bulk add for "Tag selected" -- one read-modify-write appending every
+// given videoId into one tag's array (deduped), regardless of how many
+// videos were selected. Add-only by design (removal only ever happens
+// per-video, via setVideoTag above).
+export function addTagToVideos({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME, tagName, videoIds }) {
+ ensureLibraryTagMetadata(libraryDir, libraryTag);
+ const manifest = readLibraryTagManifest(libraryDir, libraryTag) || {};
+ const tags = { ...(manifest.tags || {}) };
+ const current = new Set(tags[tagName] || []);
+ for (const id of videoIds) current.add(id);
+ tags[tagName] = [...current];
+ writeLibraryTagManifest(libraryDir, libraryTag, { ...manifest, tags });
+ return { tags };
+}
+
+// Called once after a whole bulk-delete loop finishes (not per video, see
+// library:deleteEntries in main.mjs) -- prunes every deleted videoId out of
+// every tag in this sublibrary in one read-modify-write, dropping a tag
+// entirely if removing these ids empties it. A no-op write is skipped
+// entirely when nothing in the manifest actually referenced any of them.
+export function removeVideosFromTags(libraryDir, libraryTag, videoIds) {
+ if (!videoIds || videoIds.length === 0) return;
+ const manifest = readLibraryTagManifest(libraryDir, libraryTag);
+ if (!manifest || !manifest.tags) return;
+ const remove = new Set(videoIds);
+ let changed = false;
+ const tags = {};
+ for (const [name, ids] of Object.entries(manifest.tags)) {
+ const kept = ids.filter((id) => !remove.has(id));
+ if (kept.length !== ids.length) changed = true;
+ if (kept.length > 0) tags[name] = kept;
+ }
+ if (!changed) return;
+ writeLibraryTagManifest(libraryDir, libraryTag, { ...manifest, tags });
+}
+
+// Called once after a whole bulk-move loop finishes (see
+// library:moveEntries in main.mjs) -- each moved video keeps whatever tag
+// names it already had, only which sublibrary's manifest holds the
+// association changes. Exactly two writes total (one per manifest file)
+// regardless of how many videos moved or how many tags were involved.
+export function transferVideoTags(libraryDir, sourceTag, targetTag, videoIds) {
+ if (!videoIds || videoIds.length === 0) return;
+ const sourceManifest = readLibraryTagManifest(libraryDir, sourceTag);
+ if (!sourceManifest || !sourceManifest.tags) return;
+
+ const moving = new Set(videoIds);
+ const movedByTag = {};
+ let sourceChanged = false;
+ const sourceTags = {};
+ for (const [name, ids] of Object.entries(sourceManifest.tags)) {
+ const kept = [];
+ const moved = [];
+ for (const id of ids) (moving.has(id) ? moved : kept).push(id);
+ if (moved.length > 0) {
+ movedByTag[name] = moved;
+ sourceChanged = true;
+ }
+ if (kept.length > 0) sourceTags[name] = kept;
+ }
+ if (!sourceChanged) return;
+ writeLibraryTagManifest(libraryDir, sourceTag, { ...sourceManifest, tags: sourceTags });
+
+ ensureLibraryTagMetadata(libraryDir, targetTag);
+ const targetManifest = readLibraryTagManifest(libraryDir, targetTag) || {};
+ const targetTags = { ...(targetManifest.tags || {}) };
+ for (const [name, ids] of Object.entries(movedByTag)) {
+ const current = new Set(targetTags[name] || []);
+ for (const id of ids) current.add(id);
+ targetTags[name] = [...current];
+ }
+ writeLibraryTagManifest(libraryDir, targetTag, { ...targetManifest, tags: targetTags });
}
// Bumped whenever buildEpochMetadata's/writePlaylistSnapshot's own written
@@ -278,7 +477,7 @@ function buildEpochMetadata(videoMetaData, addedEpoch) {
};
}
-export function writeLibraryEntry({ libraryDir, videoMetaData }) {
+export function writeLibraryEntry({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME, videoMetaData }) {
const { id, uploader } = videoMetaData;
if (!id) {
throw new Error('videoMetaData.id is required to add a library entry');
@@ -287,8 +486,8 @@ export function writeLibraryEntry({ libraryDir, videoMetaData }) {
throw new Error('No library folder is configured -- set one in Options first.');
}
- ensureDefaultLibraryMetadata(libraryDir);
- const channelDir = path.join(defaultLibraryDir(libraryDir), channelFolderName(uploader));
+ ensureLibraryTagMetadata(libraryDir, libraryTag);
+ const channelDir = path.join(libraryTagDir(libraryDir, libraryTag), channelFolderName(uploader));
const videoDir = path.join(channelDir, videoFolderName(id));
const addedEpoch = Date.now();
const epochDir = path.join(videoDir, String(addedEpoch));
@@ -409,6 +608,28 @@ export function swapLibraryDownload({ libraryDir, videoDir, epoch, tempFilePath,
return metadata;
}
+// Every epoch of one video shares the same videoId, so reading it off any
+// single one is enough -- used right before a whole-video delete removes
+// the folder these live in.
+function findAnyEpochVideoId(videoDir) {
+ let entries;
+ try {
+ entries = fs.readdirSync(videoDir, { withFileTypes: true });
+ } catch {
+ return null;
+ }
+ for (const entry of entries) {
+ if (!entry.isDirectory() || entry.name === CLIPS_DIR_NAME) continue;
+ try {
+ const metadata = JSON.parse(fs.readFileSync(path.join(videoDir, entry.name, 'metadata.json'), 'utf-8'));
+ if (metadata.videoId) return metadata.videoId;
+ } catch {
+ continue;
+ }
+ }
+ return null;
+}
+
// Guard-railed against libraryDir even though videoDir always originates
// from our own index -- deleting is destructive enough to be worth defense
// in depth. epoch, when given, deletes just that one version instead of the
@@ -425,8 +646,14 @@ export function deleteLibraryEntry({ libraryDir, videoDir, epoch }) {
}
if (!epoch) {
+ // Read before the rmSync below removes it -- every epoch's
+ // metadata.json shares the same videoId, so any one of them will
+ // do. Callers use this to prune the video out of the sublibrary's
+ // tag map in one batched write after a whole bulk-delete finishes,
+ // rather than per video (see library:deleteEntries in main.mjs).
+ const videoId = findAnyEpochVideoId(resolvedVideoDir);
fs.rmSync(resolvedVideoDir, { recursive: true, force: true });
- return { videoDeleted: true };
+ return { videoDeleted: true, videoId };
}
fs.rmSync(path.join(resolvedVideoDir, epoch), { recursive: true, force: true });
@@ -492,17 +719,215 @@ export function deleteLocalFiles({ libraryDir, videoDir }) {
return { filesDeleted };
}
+// Every epoch's metadata.json under videoDir stores downloadedFilePath/
+// downloadedAudioFilePath as absolute paths (see checkAndRepairEpochFiles'
+// own comment on why that's inherently fragile) -- moveLibraryEntry below
+// just renamed the whole folder tree out from under those paths, so unlike
+// the reactive, best-effort repair checkAndRepairEpochFiles does (searching
+// for a similarly-named file when a path merely turns out to be stale),
+// this one knows *exactly* what changed: every epoch's own two path fields
+// get the old videoDir prefix swapped for the new one, deterministically.
+// clips.json is untouched on purpose -- it only ever stores clip fileNames,
+// resolved against videoDir fresh at read time, so a folder move can't make
+// those stale in the first place.
+//
+// Also returns the video's videoId (every epoch shares the same one, so the
+// first metadata.json parsed is enough) -- moveLibraryEntry passes it back
+// up so callers can transfer the video's tag membership in one batched
+// write after a whole bulk-move finishes, rather than per video (see
+// library:moveEntries in main.mjs).
+function repairMovedEpochPaths(oldVideoDir, newVideoDir) {
+ let epochEntries;
+ try {
+ epochEntries = fs.readdirSync(newVideoDir, { withFileTypes: true });
+ } catch {
+ return { videoId: null };
+ }
+ let videoId = null;
+ for (const entry of epochEntries) {
+ if (!entry.isDirectory() || entry.name === CLIPS_DIR_NAME) continue;
+ const metadataPath = path.join(newVideoDir, entry.name, 'metadata.json');
+ let metadata;
+ try {
+ metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
+ } catch {
+ continue;
+ }
+ if (!videoId && metadata.videoId) videoId = metadata.videoId;
+ let changed = false;
+ if (metadata.downloadedFilePath && metadata.downloadedFilePath.startsWith(oldVideoDir)) {
+ metadata.downloadedFilePath = newVideoDir + metadata.downloadedFilePath.slice(oldVideoDir.length);
+ changed = true;
+ }
+ if (metadata.downloadedAudioFilePath && metadata.downloadedAudioFilePath.startsWith(oldVideoDir)) {
+ metadata.downloadedAudioFilePath = newVideoDir + metadata.downloadedAudioFilePath.slice(oldVideoDir.length);
+ changed = true;
+ }
+ if (changed) {
+ fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
+ }
+ }
+ return { videoId };
+}
+
+// Moves one video (every epoch, its clips/ folder, its own video-thumbnail.*
+// -- the whole videoDir tree as one unit) into a different sublibrary tag,
+// under the same channel folder name it already had. Every tag lives on the
+// same filesystem under the same libraryDir by design (see
+// DEFAULT_LIBRARY_DIR_NAME's own comment), so this is a plain, atomic
+// fs.renameSync -- no cross-filesystem copy+verify+delete needed.
+//
+// Channel data is copied "if needed" only: if the target sublibrary doesn't
+// already have a folder for this channel, it's created and the source's
+// channel-icon.* (if any) is copied into it; if the target channel folder
+// already exists, it's left completely alone. Deliberately does NOT touch
+// the *source* channel folder afterward, even if this was its last video --
+// no cleanup, no re-counting, on purpose (a decided scope cut: moving and
+// cleanup are separate responsibilities; an orphaned source channel
+// folder -- just a channel-icon.* with no videos left under it -- is left
+// for a future dedicated cleanup pass, not this function).
+export function moveLibraryEntry({ libraryDir, videoDir, targetTag }) {
+ const resolvedVideoDir = resolveInsideLibrary(libraryDir, videoDir);
+ if (!resolvedVideoDir) {
+ throw new Error('Refusing to move a path outside the configured library folder.');
+ }
+ if (!targetTag) {
+ throw new Error('No target sublibrary given.');
+ }
+
+ const sourceChannelDir = path.dirname(resolvedVideoDir);
+ const channelDirName = path.basename(sourceChannelDir);
+ const videoDirName = path.basename(resolvedVideoDir);
+
+ ensureLibraryTagMetadata(libraryDir, targetTag);
+ const targetChannelDir = path.join(libraryTagDir(libraryDir, targetTag), channelDirName);
+ const targetVideoDir = path.join(targetChannelDir, videoDirName);
+
+ if (fs.existsSync(targetVideoDir)) {
+ throw new Error('This video already exists in the target sublibrary.');
+ }
+
+ const targetChannelDirExisted = fs.existsSync(targetChannelDir);
+ fs.mkdirSync(targetChannelDir, { recursive: true });
+ if (!targetChannelDirExisted) {
+ const iconEntry = fs.existsSync(sourceChannelDir)
+ && fs.readdirSync(sourceChannelDir, { withFileTypes: true }).find((e) => e.isFile() && e.name.startsWith('channel-icon.'));
+ if (iconEntry) {
+ fs.copyFileSync(path.join(sourceChannelDir, iconEntry.name), path.join(targetChannelDir, iconEntry.name));
+ }
+ }
+
+ fs.renameSync(resolvedVideoDir, targetVideoDir);
+ const { videoId } = repairMovedEpochPaths(resolvedVideoDir, targetVideoDir);
+
+ return { videoDir: targetVideoDir, videoId };
+}
+
// "Override" means replace the tracked entry, not add another version.
// Deletes existingVideoDir exactly as given (from an earlier
// findVideoInIndex lookup) rather than re-deriving it from videoMetaData --
// if the title or channel display name drifted, writeLibraryEntry could land
// on a different path than the one being replaced, missing the real old
// folder.
-export function overrideLibraryEntry({ libraryDir, videoMetaData, existingVideoDir }) {
+export function overrideLibraryEntry({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME, videoMetaData, existingVideoDir }) {
if (existingVideoDir && fs.existsSync(existingVideoDir)) {
fs.rmSync(existingVideoDir, { recursive: true, force: true });
}
- return writeLibraryEntry({ libraryDir, videoMetaData });
+ return writeLibraryEntry({ libraryDir, libraryTag, videoMetaData });
+}
+
+// downloadedFilePath/downloadedAudioFilePath are absolute paths, captured
+// once at download time and never recomputed -- if the library folder tree
+// ever moves (a user reorganizing by hand, or this project's own DefaultLibrary
+// migration landing under an already-populated library folder), every
+// stored path silently goes stale: playback, "open file location", and every
+// ffmpeg action all read this same field directly. Deliberately NOT checked
+// during scanLibrary -- that would mean a stat() per downloaded file on every
+// single library scan, most of which nobody's about to look at. Instead this
+// is called on demand, scoped to one video's one epoch, when the video
+// detail view actually opens it (see checkAndRepairEpochFiles below) --
+// "repair the one thing the user is looking at right now," not "audit the
+// whole library eagerly."
+//
+// Only ever *repairs* a confirmed-missing path to a confirmed-present one at
+// the file's own current, correct epoch folder (matched by the deterministic
+// 'video.'/'audio.' naming swapLibraryDownload always writes
+// under) -- never invents a path, never nulls one out just because it's
+// missing (that could just as easily be removable/network media that's
+// temporarily unmounted, not a real deletion).
+//
+// NOTE for the future "move between libraries" (SubLibrary tag-switch) work:
+// moving a video's folder between tags will hit this exact same staleness
+// unless that feature also rewrites these two fields itself -- don't rely on
+// this on-demand repair alone for that case, since it only fires when a user
+// actually opens the affected video, not proactively on the move itself.
+function repairStaleDownloadedPath(epochDir, storedPath, expectedPrefix) {
+ if (!storedPath || fs.existsSync(storedPath)) return storedPath;
+ let entries;
+ try {
+ entries = fs.readdirSync(epochDir, { withFileTypes: true });
+ } catch {
+ return storedPath;
+ }
+ // Anchored, single-extension match only -- deliberately excludes
+ // swapLibraryDownload's own transient 'video.new.' temp file, which
+ // can briefly coexist with the real one mid-swap and must never be
+ // mistaken for it.
+ const pattern = new RegExp(`^${expectedPrefix}\\.[A-Za-z0-9]+$`);
+ const match = entries.find((e) => e.isFile() && pattern.test(e.name));
+ return match ? path.join(epochDir, match.name) : storedPath;
+}
+
+// The on-demand entry point itself -- called once when the video detail view
+// opens a given epoch (LibraryVideoDetail.tsx), not as part of any bulk
+// scan. Checks whichever of downloadedFilePath/downloadedAudioFilePath are
+// actually set, repairs what it can, and reports back what's still missing
+// so the UI can warn the user (re-download, or restore the file manually)
+// rather than silently failing on the first play/open attempt.
+export function checkAndRepairEpochFiles({ libraryDir, videoDir, epoch }) {
+ const resolvedVideoDir = resolveInsideLibrary(libraryDir, videoDir);
+ if (!resolvedVideoDir) {
+ throw new Error('Refusing to check files outside the configured library folder.');
+ }
+ const epochDir = path.join(resolvedVideoDir, epoch);
+ const metadataPath = path.join(epochDir, 'metadata.json');
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
+
+ let changed = false;
+ let videoRepaired = false;
+ let audioRepaired = false;
+
+ if (metadata.downloadedFilePath) {
+ const repaired = repairStaleDownloadedPath(epochDir, metadata.downloadedFilePath, 'video');
+ if (repaired !== metadata.downloadedFilePath) {
+ metadata.downloadedFilePath = repaired;
+ changed = true;
+ videoRepaired = true;
+ }
+ }
+ if (metadata.downloadedAudioFilePath) {
+ const repaired = repairStaleDownloadedPath(epochDir, metadata.downloadedAudioFilePath, 'audio');
+ if (repaired !== metadata.downloadedAudioFilePath) {
+ metadata.downloadedAudioFilePath = repaired;
+ changed = true;
+ audioRepaired = true;
+ }
+ }
+
+ if (changed) {
+ fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
+ }
+
+ return {
+ metadata,
+ videoRepaired,
+ audioRepaired,
+ // Still broken even after the repair attempt above -- distinct from
+ // "was never downloaded" (the field is simply null/absent), which
+ // isn't something to warn about at all.
+ videoMissing: !!metadata.downloadedFilePath && !fs.existsSync(metadata.downloadedFilePath),
+ audioMissing: !!metadata.downloadedAudioFilePath && !fs.existsSync(metadata.downloadedAudioFilePath),
+ };
}
// Bounded 3-level walk (channel/video/epoch), tolerant of partial or corrupt
@@ -511,17 +936,17 @@ export function overrideLibraryEntry({ libraryDir, videoMetaData, existingVideoD
// Collects every valid epoch into `epochs` (newest first) for the
// version-control UI; `latestEpoch`/`metadata` stay pointed at the newest
// valid one, which every other consumer reads.
-export async function scanLibrary(libraryDir) {
+export async function scanLibrary(libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME) {
const index = { channels: [] };
if (!libraryDir) {
return index;
}
- // Scans the default tag folder, not libraryDir itself -- see
- // defaultLibraryDir's own comment. Not fs.existsSync(libraryDir) either:
+ // Scans the given tag's own folder, not libraryDir itself -- see
+ // libraryTagDir's own comment. Not fs.existsSync(libraryDir) either:
// a freshly-configured libraryDir with nothing written into it yet is
- // exactly the same "empty index" case as one whose DefaultLibrary
- // subfolder hasn't been created lazily yet.
- const scanRoot = defaultLibraryDir(libraryDir);
+ // exactly the same "empty index" case as one whose tag subfolder hasn't
+ // been created (lazily, for DEFAULT_LIBRARY_DIR_NAME) yet.
+ const scanRoot = libraryTagDir(libraryDir, libraryTag);
if (!fs.existsSync(scanRoot)) {
return index;
}
@@ -631,23 +1056,30 @@ export async function scanLibrary(libraryDir) {
}
// getLibraryIndex reuses whatever scan is already in flight (or already
-// resolved) for the current libraryDir, rather than kicking off a redundant
-// scan on every call -- so an app-start background scan and a Library-tab
-// mount asking for the index at roughly the same time share one walk.
+// resolved) for the current (libraryDir, libraryTag) pair, rather than
+// kicking off a redundant scan on every call -- so an app-start background
+// scan and a Library-tab mount asking for the index at roughly the same time
+// share one walk. Keyed on BOTH libraryDir and libraryTag, not libraryDir
+// alone -- libraryDir stays constant while switching sublibraries, so a
+// cache keyed only on it would keep serving the previously-active
+// sublibrary's stale index after a switch.
let indexPromise = null;
let indexPromiseDir = null;
+let indexPromiseTag = null;
-export function getLibraryIndex(libraryDir) {
- if (!indexPromise || indexPromiseDir !== libraryDir) {
- indexPromise = scanLibrary(libraryDir);
+export function getLibraryIndex(libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME) {
+ if (!indexPromise || indexPromiseDir !== libraryDir || indexPromiseTag !== libraryTag) {
+ indexPromise = scanLibrary(libraryDir, libraryTag);
indexPromiseDir = libraryDir;
+ indexPromiseTag = libraryTag;
}
return indexPromise;
}
-export function refreshLibraryIndex(libraryDir) {
- indexPromise = scanLibrary(libraryDir);
+export function refreshLibraryIndex(libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME) {
+ indexPromise = scanLibrary(libraryDir, libraryTag);
indexPromiseDir = libraryDir;
+ indexPromiseTag = libraryTag;
return indexPromise;
}
@@ -687,9 +1119,9 @@ function isDeadTitle(title, videoId) {
// bare videoId/url, since most won't have a localFiles match yet at
// save-time (nothing's downloaded) and this is the fallback display data
// for those.
-export function writePlaylistSnapshot({ libraryDir, playlistId, title, uploader, originalUrl, entries, index }) {
- ensureDefaultLibraryMetadata(libraryDir);
- const playlistDir = path.join(defaultLibraryDir(libraryDir), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId));
+export function writePlaylistSnapshot({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME, playlistId, title, uploader, originalUrl, entries, index }) {
+ ensureLibraryTagMetadata(libraryDir, libraryTag);
+ const playlistDir = path.join(libraryTagDir(libraryDir, libraryTag), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId));
if (fs.existsSync(playlistDir) && fs.readdirSync(playlistDir, { withFileTypes: true }).some((e) => e.isDirectory())) {
return { playlistDir, epochDir: null, epoch: null, metadata: null, skipped: true };
@@ -744,8 +1176,8 @@ export function writePlaylistSnapshot({ libraryDir, playlistId, title, uploader,
// already captured. Silently no-ops if the playlist was never saved, or
// doesn't have this entry -- the common case for a video not part of any
// known playlist.
-export function enrichPlaylistEntry({ libraryDir, playlistId, videoId, title, uploadDate, thumbnailUrl }) {
- const playlistDir = path.join(defaultLibraryDir(libraryDir), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId));
+export function enrichPlaylistEntry({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME, playlistId, videoId, title, uploadDate, thumbnailUrl }) {
+ const playlistDir = path.join(libraryTagDir(libraryDir, libraryTag), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId));
if (!fs.existsSync(playlistDir)) return null;
// Only ever one epoch today (writePlaylistSnapshot), but read whichever
@@ -807,8 +1239,8 @@ function findPlaylistThumbnailPath(playlistDir) {
// 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 }) {
- const playlistsRoot = path.join(defaultLibraryDir(libraryDir), PLAYLISTS_DIR_NAME);
+export function listPlaylistSnapshots({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME }) {
+ const playlistsRoot = path.join(libraryTagDir(libraryDir, libraryTag), PLAYLISTS_DIR_NAME);
if (!fs.existsSync(playlistsRoot)) return [];
const summaries = [];
@@ -855,8 +1287,8 @@ export function listPlaylistSnapshots({ libraryDir }) {
// playlist *refresh* just discovered, whose video already existed in the
// library, could otherwise still show no link. A playlist detail view is
// opened rarely enough that a full rescan here is cheap insurance.
-export async function getPlaylistSnapshot({ libraryDir, playlistId, index }) {
- const playlistDir = path.join(defaultLibraryDir(libraryDir), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId));
+export async function getPlaylistSnapshot({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME, playlistId, index }) {
+ const playlistDir = path.join(libraryTagDir(libraryDir, libraryTag), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId));
const epochDir = resolvePlaylistEpochDir(playlistDir);
if (!epochDir) return null;
@@ -879,7 +1311,7 @@ export async function getPlaylistSnapshot({ libraryDir, playlistId, index }) {
// No previousMetadata.json -- stays null.
}
- const resolvedIndex = index || await refreshLibraryIndex(libraryDir);
+ const resolvedIndex = index || await refreshLibraryIndex(libraryDir, libraryTag);
const localFiles = {};
for (const entry of metadata.entries || []) {
const match = findVideoInIndex(resolvedIndex, entry.videoId);
@@ -911,8 +1343,8 @@ export async function getPlaylistSnapshot({ libraryDir, playlistId, index }) {
// undoPlaylistRefresh can revert it. The live metadata.json is only touched
// via the same temp-then-rename pattern swapLibraryDownload uses, so a crash
// mid-refresh never leaves it partially written.
-export function reconcilePlaylistSnapshot({ libraryDir, playlistId, freshEntries, freshTitle, freshUploader, index }) {
- const playlistDir = path.join(defaultLibraryDir(libraryDir), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId));
+export function reconcilePlaylistSnapshot({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME, playlistId, freshEntries, freshTitle, freshUploader, index }) {
+ const playlistDir = path.join(libraryTagDir(libraryDir, libraryTag), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId));
const epochDir = resolvePlaylistEpochDir(playlistDir);
if (!epochDir) {
throw new Error('This playlist has no saved snapshot to refresh.');
@@ -989,8 +1421,8 @@ export function reconcilePlaylistSnapshot({ libraryDir, playlistId, freshEntries
// One-shot undo -- reverts to previousMetadata.json (written by the most
// recent reconcilePlaylistSnapshot call) and then deletes it, so a second
// Undo click has nothing left to act on rather than toggling back and forth.
-export function undoPlaylistRefresh({ libraryDir, playlistId }) {
- const playlistDir = path.join(defaultLibraryDir(libraryDir), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId));
+export function undoPlaylistRefresh({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME, playlistId }) {
+ 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.' };
@@ -1012,8 +1444,8 @@ export function undoPlaylistRefresh({ libraryDir, playlistId }) {
// it references, which live in their own channel/video folders independent
// of any playlist pointing at them. Same containment check every other
// destructive library operation in this file uses.
-export function deletePlaylistSnapshot({ libraryDir, playlistId }) {
- const playlistDir = path.join(defaultLibraryDir(path.resolve(libraryDir || '')), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId));
+export function deletePlaylistSnapshot({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME, playlistId }) {
+ const playlistDir = path.join(libraryTagDir(path.resolve(libraryDir || ''), libraryTag), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId));
const resolvedPlaylistDir = resolveInsideLibrary(libraryDir, playlistDir);
if (!resolvedPlaylistDir) {
throw new Error('Refusing to delete a path outside the configured library folder.');
diff --git a/src/electron/library.test.mjs b/src/electron/library.test.mjs
index 23d5d4f..7be51ff 100644
--- a/src/electron/library.test.mjs
+++ b/src/electron/library.test.mjs
@@ -13,6 +13,7 @@ import {
swapLibraryDownload,
deleteLibraryEntry,
deleteLocalFiles,
+ moveLibraryEntry,
overrideLibraryEntry,
scanLibrary,
getLibraryIndex,
@@ -23,7 +24,15 @@ import {
PLAYLISTS_DIR_NAME,
CLIPS_DIR_NAME,
DEFAULT_LIBRARY_DIR_NAME,
- defaultLibraryDir,
+ libraryTagDir,
+ listLibraryTags,
+ createLibraryTag,
+ listVideoTags,
+ setVideoTag,
+ addTagToVideos,
+ removeVideosFromTags,
+ transferVideoTags,
+ checkAndRepairEpochFiles,
buildClipFilePath,
recordClip,
listClips,
@@ -152,20 +161,20 @@ describe('writeLibraryEntry', () => {
expect(metadata.resolutions).toEqual([]);
});
- it('lazily creates DefaultLibrary/library.json on first write, with tagName/createdEpoch', () => {
- const metadataPath = path.join(defaultLibraryDir(libraryDir), 'library.json');
+ it('lazily creates DefaultLibrary/library.json on first write, with sublibraryName/createdEpoch', () => {
+ const metadataPath = path.join(libraryTagDir(libraryDir), 'library.json');
expect(fs.existsSync(metadataPath)).toBe(false);
writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() });
const written = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
- expect(written.tagName).toBe(DEFAULT_LIBRARY_DIR_NAME);
+ expect(written.sublibraryName).toBe(DEFAULT_LIBRARY_DIR_NAME);
expect(typeof written.createdEpoch).toBe('number');
});
it('does not overwrite library.json on a later write', () => {
writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() });
- const metadataPath = path.join(defaultLibraryDir(libraryDir), 'library.json');
+ const metadataPath = path.join(libraryTagDir(libraryDir), 'library.json');
const first = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData({ id: 'def456', uploader: 'Other Channel' }) });
@@ -270,6 +279,12 @@ describe('deleteLibraryEntry', () => {
expect(fs.existsSync(videoDir)).toBe(false);
});
+ it('returns the deleted video\'s videoId, read before the folder is removed', () => {
+ const { videoDir } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData({ id: 'tagged-vid' }) });
+ const result = deleteLibraryEntry({ libraryDir, videoDir });
+ expect(result.videoId).toBe('tagged-vid');
+ });
+
it('deletes just one epoch and keeps the video when other epochs remain', () => {
const first = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() });
const second = addLibraryVersion({ libraryDir, videoDir: first.videoDir, videoMetaData: baseVideoMetaData() });
@@ -540,7 +555,7 @@ describe('scanLibrary', () => {
});
it('skips the reserved playlists directory', async () => {
- fs.mkdirSync(path.join(defaultLibraryDir(libraryDir), PLAYLISTS_DIR_NAME), { recursive: true });
+ fs.mkdirSync(path.join(libraryTagDir(libraryDir), PLAYLISTS_DIR_NAME), { recursive: true });
const index = await scanLibrary(libraryDir);
expect(index.channels).toEqual([]);
});
@@ -600,6 +615,111 @@ describe('scanLibrary', () => {
expect(index.channels[0].videos[0].clipCount).toBe(2);
});
+
+});
+
+describe('checkAndRepairEpochFiles', () => {
+ // Simulates exactly what happened when the DefaultLibrary migration
+ // landed on top of an already-populated library: metadata.json still
+ // points at the file's old, pre-move location, but the real file is
+ // sitting right there in the epoch's own current, correct folder.
+ function writeStaleMetadataFile(epochDir, patch) {
+ const metadataPath = path.join(epochDir, 'metadata.json');
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
+ Object.assign(metadata, patch);
+ fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
+ }
+
+ it('refuses to check a videoDir outside the configured library folder', () => {
+ expect(() => checkAndRepairEpochFiles({ libraryDir, videoDir: '/etc', epoch: '1' }))
+ .toThrow(/outside the configured library folder/);
+ });
+
+ it('repairs a stale downloadedFilePath to the real file sitting in the current epoch folder', () => {
+ const { epochDir, videoDir, metadata } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() });
+ const epoch = String(metadata.addedEpoch);
+ const realPath = path.join(epochDir, 'video.mp4');
+ fs.writeFileSync(realPath, 'fake video bytes');
+ const staleOldPath = '/some/old/location/that/no/longer/exists/video.mp4';
+ writeStaleMetadataFile(epochDir, { downloadedFilePath: staleOldPath, downloadedResolution: '1080', downloadedFormat: 'mp4' });
+
+ const result = checkAndRepairEpochFiles({ libraryDir, videoDir, epoch });
+
+ expect(result.videoRepaired).toBe(true);
+ expect(result.videoMissing).toBe(false);
+ expect(result.metadata.downloadedFilePath).toBe(realPath);
+ // Repaired on disk too, not just in the returned result -- any other
+ // consumer reading metadata.json directly must see the fix as well.
+ expect(readMetadata(videoDir, epoch).downloadedFilePath).toBe(realPath);
+ });
+
+ it('repairs a stale downloadedAudioFilePath the same way', () => {
+ const { epochDir, videoDir, metadata } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() });
+ const epoch = String(metadata.addedEpoch);
+ const realPath = path.join(epochDir, 'audio.mp3');
+ fs.writeFileSync(realPath, 'fake audio bytes');
+ writeStaleMetadataFile(epochDir, { downloadedAudioFilePath: '/old/audio.mp3' });
+
+ const result = checkAndRepairEpochFiles({ libraryDir, videoDir, epoch });
+
+ expect(result.audioRepaired).toBe(true);
+ expect(result.audioMissing).toBe(false);
+ expect(result.metadata.downloadedAudioFilePath).toBe(realPath);
+ });
+
+ it('reports videoMissing (and leaves the stored path untouched) when no matching file exists', () => {
+ const { epochDir, videoDir, metadata } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() });
+ const epoch = String(metadata.addedEpoch);
+ const staleOldPath = '/genuinely/gone/video.mp4';
+ writeStaleMetadataFile(epochDir, { downloadedFilePath: staleOldPath });
+
+ const result = checkAndRepairEpochFiles({ libraryDir, videoDir, epoch });
+
+ // Never invents a path and never nulls one out just because it
+ // couldn't find it -- could just as easily be temporarily-unmounted
+ // removable media, not a real deletion.
+ expect(result.videoRepaired).toBe(false);
+ expect(result.videoMissing).toBe(true);
+ expect(result.metadata.downloadedFilePath).toBe(staleOldPath);
+ expect(readMetadata(videoDir, epoch).downloadedFilePath).toBe(staleOldPath);
+ });
+
+ it('does not mistake swapLibraryDownload\'s transient video.new. temp file for the real one', () => {
+ const { epochDir, videoDir, metadata } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() });
+ const epoch = String(metadata.addedEpoch);
+ // Mid-swap temp file only -- no real video. exists yet.
+ fs.writeFileSync(path.join(epochDir, 'video.new.mp4'), 'in-progress swap bytes');
+ const staleOldPath = '/old/video.mp4';
+ writeStaleMetadataFile(epochDir, { downloadedFilePath: staleOldPath });
+
+ const result = checkAndRepairEpochFiles({ libraryDir, videoDir, epoch });
+
+ expect(result.videoRepaired).toBe(false);
+ expect(result.videoMissing).toBe(true);
+ });
+
+ it('leaves an already-valid downloadedFilePath alone (no unnecessary write, no false repair flag)', () => {
+ const { epochDir, videoDir, metadata } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() });
+ const epoch = String(metadata.addedEpoch);
+ const realPath = path.join(epochDir, 'video.mp4');
+ fs.writeFileSync(realPath, 'fake video bytes');
+ recordLibraryDownload({ videoDir, epoch, filePath: realPath, resolution: '1080', format: 'mp4' });
+
+ const result = checkAndRepairEpochFiles({ libraryDir, videoDir, epoch });
+
+ expect(result.videoRepaired).toBe(false);
+ expect(result.videoMissing).toBe(false);
+ expect(result.metadata.downloadedFilePath).toBe(realPath);
+ });
+
+ it('reports neither missing nor repaired when nothing was ever downloaded', () => {
+ const { videoDir, metadata } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() });
+ const epoch = String(metadata.addedEpoch);
+
+ const result = checkAndRepairEpochFiles({ libraryDir, videoDir, epoch });
+
+ expect(result).toMatchObject({ videoRepaired: false, audioRepaired: false, videoMissing: false, audioMissing: false });
+ });
});
describe('getLibraryIndex / refreshLibraryIndex caching', () => {
@@ -627,6 +747,244 @@ describe('getLibraryIndex / refreshLibraryIndex caching', () => {
// A subsequent getLibraryIndex call for the same dir now reuses the refreshed one.
expect(getLibraryIndex(libraryDir)).toBe(refreshed);
});
+
+ it('starts a new scan when only the libraryTag changes, same libraryDir', async () => {
+ createLibraryTag(libraryDir, 'Music');
+ writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData({ uploader: 'Default Channel' }) });
+ writeLibraryEntry({ libraryDir, libraryTag: 'Music', videoMetaData: baseVideoMetaData({ uploader: 'Music Channel' }) });
+
+ const defaultIndex = await getLibraryIndex(libraryDir, DEFAULT_LIBRARY_DIR_NAME);
+ const musicIndex = await getLibraryIndex(libraryDir, 'Music');
+
+ expect(defaultIndex).not.toBe(musicIndex);
+ expect(defaultIndex.channels.map((c) => c.displayName)).toEqual(['Default Channel']);
+ expect(musicIndex.channels.map((c) => c.displayName)).toEqual(['Music Channel']);
+ // Re-requesting the first tag still hits the cache rather than re-scanning.
+ expect(getLibraryIndex(libraryDir, DEFAULT_LIBRARY_DIR_NAME)).toBe(getLibraryIndex(libraryDir, DEFAULT_LIBRARY_DIR_NAME));
+ });
+});
+
+describe('listLibraryTags / createLibraryTag', () => {
+ it('returns [] for a libraryDir with nothing written yet', () => {
+ expect(listLibraryTags(libraryDir)).toEqual([]);
+ });
+
+ it('lists DefaultLibrary once something has been written to it', () => {
+ writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() });
+ const tags = listLibraryTags(libraryDir);
+ expect(tags).toHaveLength(1);
+ expect(tags[0]).toMatchObject({ tagName: DEFAULT_LIBRARY_DIR_NAME, folderName: DEFAULT_LIBRARY_DIR_NAME });
+ expect(typeof tags[0].createdEpoch).toBe('number');
+ });
+
+ it('ignores a sibling folder with no library.json', () => {
+ fs.mkdirSync(path.join(libraryDir, 'Not A Library'), { recursive: true });
+ expect(listLibraryTags(libraryDir)).toEqual([]);
+ });
+
+ it('createLibraryTag creates the folder + library.json eagerly, sorted oldest-first', () => {
+ const tag = createLibraryTag(libraryDir, 'Music');
+ expect(tag.folderName).toBe('Music');
+ expect(fs.existsSync(path.join(libraryDir, 'Music', 'library.json'))).toBe(true);
+
+ createLibraryTag(libraryDir, 'Later Tag');
+ const tags = listLibraryTags(libraryDir);
+ expect(tags.map((t) => t.folderName)).toEqual(['Music', 'Later Tag']);
+ });
+
+ it('createLibraryTag sanitizes the requested name the same way channel names are', () => {
+ const tag = createLibraryTag(libraryDir, 'My/Tag');
+ expect(tag.folderName).toBe('My_Tag');
+ });
+
+ it('createLibraryTag refuses a name that already exists, valid tag or not', () => {
+ createLibraryTag(libraryDir, 'Music');
+ expect(() => createLibraryTag(libraryDir, 'Music')).toThrow(/already exists/);
+
+ fs.mkdirSync(path.join(libraryDir, 'Random Folder'), { recursive: true });
+ expect(() => createLibraryTag(libraryDir, 'Random Folder')).toThrow(/already exists/);
+ });
+
+ it('createLibraryTag throws when no libraryDir is configured', () => {
+ expect(() => createLibraryTag('', 'Music')).toThrow(/No library folder is configured/);
+ });
+
+ it('reads a pre-rename manifest (old tagName field, no sublibraryName) the same as a new one', () => {
+ const dir = libraryTagDir(libraryDir, 'Legacy');
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, 'library.json'), JSON.stringify({ tagName: 'Legacy', createdEpoch: 123 }), 'utf-8');
+
+ const tags = listLibraryTags(libraryDir);
+ expect(tags).toContainEqual({ tagName: 'Legacy', folderName: 'Legacy', createdEpoch: 123 });
+ });
+});
+
+describe('video tags (listVideoTags / setVideoTag / addTagToVideos)', () => {
+ it('listVideoTags returns {} for a sublibrary with no tags yet', () => {
+ writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() });
+ expect(listVideoTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME)).toEqual({});
+ });
+
+ it('setVideoTag applies a new tag, creating the key on first use', () => {
+ const { tags } = setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'TVshows', videoId: 'vid1', applied: true });
+ expect(tags).toEqual({ TVshows: ['vid1'] });
+ expect(listVideoTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME)).toEqual({ TVshows: ['vid1'] });
+ });
+
+ it('setVideoTag applying an already-applied tag does not duplicate the videoId', () => {
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'TVshows', videoId: 'vid1', applied: true });
+ const { tags } = setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'TVshows', videoId: 'vid1', applied: true });
+ expect(tags.TVshows).toEqual(['vid1']);
+ });
+
+ it('setVideoTag removes a videoId, keeping the tag key when other videos remain under it', () => {
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'TVshows', videoId: 'vid1', applied: true });
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'TVshows', videoId: 'vid2', applied: true });
+
+ const { tags } = setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'TVshows', videoId: 'vid1', applied: false });
+ expect(tags).toEqual({ TVshows: ['vid2'] });
+ });
+
+ it('setVideoTag removing the last videoId under a tag deletes the tag key entirely', () => {
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'TVshows', videoId: 'vid1', applied: true });
+ const { tags } = setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'TVshows', videoId: 'vid1', applied: false });
+ expect(tags).toEqual({});
+ });
+
+ it('addTagToVideos appends every given videoId into one tag, deduping against what is already there', () => {
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'games', videoId: 'vid1', applied: true });
+ const { tags } = addTagToVideos({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'games', videoIds: ['vid1', 'vid2', 'vid3'] });
+ expect(tags.games).toEqual(['vid1', 'vid2', 'vid3']);
+ });
+
+ it('addTagToVideos creates the tag key if it does not exist yet', () => {
+ const { tags } = addTagToVideos({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'brandNew', videoIds: ['vid1', 'vid2'] });
+ expect(tags.brandNew).toEqual(['vid1', 'vid2']);
+ });
+
+ it('setVideoTag also lazily creates the sublibrary folder, same as writeLibraryEntry does', () => {
+ expect(fs.existsSync(libraryTagDir(libraryDir))).toBe(false);
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'games', videoId: 'vid1', applied: true });
+ expect(fs.existsSync(libraryTagDir(libraryDir))).toBe(true);
+ });
+
+ it('the tags map is read via the sublibraryName-keyed manifest and does not disturb it', () => {
+ createLibraryTag(libraryDir, 'Music');
+ setVideoTag({ libraryDir, libraryTag: 'Music', tagName: 'games', videoId: 'vid1', applied: true });
+ const manifest = JSON.parse(fs.readFileSync(path.join(libraryTagDir(libraryDir, 'Music'), 'library.json'), 'utf-8'));
+ expect(manifest.sublibraryName).toBe('Music');
+ expect(manifest.tags).toEqual({ games: ['vid1'] });
+ });
+});
+
+describe('removeVideosFromTags', () => {
+ it('prunes the given videoIds out of every tag, in one write', () => {
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'TVshows', videoId: 'vid1', applied: true });
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'TVshows', videoId: 'vid2', applied: true });
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'games', videoId: 'vid1', applied: true });
+
+ removeVideosFromTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME, ['vid1']);
+
+ expect(listVideoTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME)).toEqual({ TVshows: ['vid2'] });
+ });
+
+ it('deletes a tag entirely once removing the given ids empties it', () => {
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'TVshows', videoId: 'vid1', applied: true });
+ removeVideosFromTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME, ['vid1']);
+ expect(listVideoTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME)).toEqual({});
+ });
+
+ it('is a no-op (no write) when no tag references any of the given ids', () => {
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'TVshows', videoId: 'vid1', applied: true });
+ const manifestPath = path.join(libraryTagDir(libraryDir), 'library.json');
+ const before = fs.statSync(manifestPath).mtimeMs;
+
+ removeVideosFromTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME, ['unrelated-id']);
+
+ expect(fs.statSync(manifestPath).mtimeMs).toBe(before);
+ expect(listVideoTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME)).toEqual({ TVshows: ['vid1'] });
+ });
+
+ it('is a no-op when given an empty id list or a sublibrary with no manifest yet', () => {
+ expect(() => removeVideosFromTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME, [])).not.toThrow();
+ expect(() => removeVideosFromTags(libraryDir, 'NeverCreated', ['vid1'])).not.toThrow();
+ });
+});
+
+describe('transferVideoTags', () => {
+ it('moves matching tag entries from the source manifest to the target, keeping tag names', () => {
+ createLibraryTag(libraryDir, 'Music');
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'TVshows', videoId: 'vid1', applied: true });
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'TVshows', videoId: 'vid2', applied: true });
+
+ transferVideoTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME, 'Music', ['vid1']);
+
+ expect(listVideoTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME)).toEqual({ TVshows: ['vid2'] });
+ expect(listVideoTags(libraryDir, 'Music')).toEqual({ TVshows: ['vid1'] });
+ });
+
+ it('creates the target tag key if it does not already have that tag', () => {
+ createLibraryTag(libraryDir, 'Music');
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'games', videoId: 'vid1', applied: true });
+
+ transferVideoTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME, 'Music', ['vid1']);
+
+ expect(listVideoTags(libraryDir, 'Music')).toEqual({ games: ['vid1'] });
+ });
+
+ it('merges into an existing target tag rather than clobbering it', () => {
+ createLibraryTag(libraryDir, 'Music');
+ setVideoTag({ libraryDir, libraryTag: 'Music', tagName: 'games', videoId: 'already-there', applied: true });
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'games', videoId: 'vid1', applied: true });
+
+ transferVideoTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME, 'Music', ['vid1']);
+
+ expect(listVideoTags(libraryDir, 'Music').games.sort()).toEqual(['already-there', 'vid1']);
+ });
+
+ it('lazily creates the target sublibrary folder if it does not exist yet', () => {
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'games', videoId: 'vid1', applied: true });
+ expect(fs.existsSync(libraryTagDir(libraryDir, 'Music'))).toBe(false);
+
+ transferVideoTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME, 'Music', ['vid1']);
+
+ expect(fs.existsSync(libraryTagDir(libraryDir, 'Music'))).toBe(true);
+ });
+
+ it('removes an emptied tag key from the source manifest entirely', () => {
+ createLibraryTag(libraryDir, 'Music');
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'games', videoId: 'vid1', applied: true });
+
+ transferVideoTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME, 'Music', ['vid1']);
+
+ const sourceManifest = JSON.parse(fs.readFileSync(path.join(libraryTagDir(libraryDir), 'library.json'), 'utf-8'));
+ expect(sourceManifest.tags).toEqual({});
+ });
+
+ it('does not disturb untagged videos left behind in the source', () => {
+ createLibraryTag(libraryDir, 'Music');
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'games', videoId: 'vid1', applied: true });
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'games', videoId: 'vid2', applied: true });
+
+ transferVideoTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME, 'Music', ['vid1']);
+
+ expect(listVideoTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME)).toEqual({ games: ['vid2'] });
+ });
+
+ it('is a no-op when given an empty id list or a source sublibrary with no manifest yet', () => {
+ expect(() => transferVideoTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME, 'Music', [])).not.toThrow();
+ expect(() => transferVideoTags(libraryDir, 'NeverCreated', 'Music', ['vid1'])).not.toThrow();
+ });
+
+ it('is a no-op when none of the given ids are actually tagged in the source', () => {
+ setVideoTag({ libraryDir, libraryTag: DEFAULT_LIBRARY_DIR_NAME, tagName: 'games', videoId: 'vid1', applied: true });
+ expect(fs.existsSync(libraryTagDir(libraryDir, 'Music'))).toBe(false);
+
+ transferVideoTags(libraryDir, DEFAULT_LIBRARY_DIR_NAME, 'Music', ['unrelated-id']);
+
+ // No target manifest should have been created for a transfer that moved nothing.
+ expect(fs.existsSync(libraryTagDir(libraryDir, 'Music'))).toBe(false);
+ });
});
describe('findVideoInIndex', () => {
@@ -703,3 +1061,129 @@ describe('writePlaylistSnapshot / enrichPlaylistEntry', () => {
expect(reEnriched.entries.find((e) => e.videoId === 'v2').title).toBe('Real Title');
});
});
+
+describe('moveLibraryEntry', () => {
+ it('refuses to move a videoDir outside the configured library folder', () => {
+ expect(() => moveLibraryEntry({ libraryDir, videoDir: '/etc', targetTag: 'Music' }))
+ .toThrow(/outside the configured library folder/);
+ });
+
+ it('moves the whole video folder into the target tag, under the same channel folder name', () => {
+ const { videoDir, channelDir } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData({ uploader: 'Some Channel' }) });
+ createLibraryTag(libraryDir, 'Music');
+
+ const result = moveLibraryEntry({ libraryDir, videoDir, targetTag: 'Music' });
+
+ const expectedVideoDir = path.join(libraryTagDir(libraryDir, 'Music'), path.basename(channelDir), path.basename(videoDir));
+ expect(result.videoDir).toBe(expectedVideoDir);
+ expect(fs.existsSync(videoDir)).toBe(false);
+ expect(fs.existsSync(result.videoDir)).toBe(true);
+ });
+
+ it('returns the moved video\'s videoId, read from the moved metadata.json', () => {
+ const { videoDir } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData({ id: 'tagged-vid' }) });
+ const result = moveLibraryEntry({ libraryDir, videoDir, targetTag: 'Music' });
+ expect(result.videoId).toBe('tagged-vid');
+ });
+
+ it('lazily creates the target tag folder if it does not already exist', () => {
+ const { videoDir } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() });
+ expect(listLibraryTags(libraryDir).map((t) => t.folderName)).not.toContain('Music');
+
+ const result = moveLibraryEntry({ libraryDir, videoDir, targetTag: 'Music' });
+
+ expect(fs.existsSync(result.videoDir)).toBe(true);
+ expect(listLibraryTags(libraryDir).map((t) => t.folderName)).toContain('Music');
+ });
+
+ it('repairs downloadedFilePath/downloadedAudioFilePath in every moved epoch to the new location', () => {
+ const { videoDir, epochDir, metadata } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() });
+ const epoch = String(metadata.addedEpoch);
+ const videoFile = path.join(epochDir, 'video.mp4');
+ const audioFile = path.join(epochDir, 'audio.mp3');
+ fs.writeFileSync(videoFile, 'fake video bytes');
+ fs.writeFileSync(audioFile, 'fake audio bytes');
+ recordLibraryDownload({ videoDir, epoch, filePath: videoFile, resolution: '1080', format: 'mp4' });
+ recordLibraryDownload({ videoDir, epoch, filePath: audioFile, kind: 'audio' });
+
+ const result = moveLibraryEntry({ libraryDir, videoDir, targetTag: 'Music' });
+
+ const moved = readMetadata(result.videoDir, epoch);
+ expect(moved.downloadedFilePath).toBe(path.join(result.videoDir, epoch, 'video.mp4'));
+ expect(moved.downloadedAudioFilePath).toBe(path.join(result.videoDir, epoch, 'audio.mp3'));
+ // The bytes moved with the folder, at the now-repaired path.
+ expect(fs.readFileSync(moved.downloadedFilePath, 'utf-8')).toBe('fake video bytes');
+ });
+
+ it('leaves an epoch with nothing downloaded (both fields null) untouched by the repair', () => {
+ const { videoDir, epochDir, metadata } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() });
+ const epoch = String(metadata.addedEpoch);
+
+ const result = moveLibraryEntry({ libraryDir, videoDir, targetTag: 'Music' });
+
+ const moved = readMetadata(result.videoDir, epoch);
+ expect(moved.downloadedFilePath).toBeNull();
+ expect(moved.downloadedAudioFilePath).toBeNull();
+ expect(fs.existsSync(epochDir)).toBe(false);
+ });
+
+ it('preserves clips -- clips.json resolves against the new videoDir with no repair needed', () => {
+ const { videoDir } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() });
+ const clipPath = buildClipFilePath(videoDir, 'My Clip', 'mp4');
+ fs.mkdirSync(path.dirname(clipPath), { recursive: true });
+ fs.writeFileSync(clipPath, 'fake clip bytes');
+ const clip = recordClip({ libraryDir, videoDir, fileName: path.basename(clipPath), title: 'My Clip', durationSeconds: 5 });
+
+ const result = moveLibraryEntry({ libraryDir, videoDir, targetTag: 'Music' });
+
+ expect(listClips({ libraryDir, videoDir: result.videoDir })).toEqual([clip]);
+ });
+
+ it('copies the channel icon into the target only when the target channel folder does not already exist', () => {
+ const { videoDir, channelDir } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData({ uploader: 'Some Channel' }) });
+ fs.writeFileSync(path.join(channelDir, 'channel-icon.jpg'), 'source icon bytes');
+
+ const result = moveLibraryEntry({ libraryDir, videoDir, targetTag: 'Music' });
+
+ const targetChannelDir = path.dirname(result.videoDir);
+ expect(fs.readFileSync(path.join(targetChannelDir, 'channel-icon.jpg'), 'utf-8')).toBe('source icon bytes');
+ });
+
+ it('does not overwrite an existing icon already sitting in the target channel folder', () => {
+ const { videoDir: firstVideoDir, channelDir } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData({ id: 'vid1', uploader: 'Some Channel' }) });
+ fs.writeFileSync(path.join(channelDir, 'channel-icon.jpg'), 'source icon bytes');
+ // First move creates the target channel folder + copies the icon.
+ moveLibraryEntry({ libraryDir, videoDir: firstVideoDir, targetTag: 'Music' });
+ const targetChannelDir = path.join(libraryTagDir(libraryDir, 'Music'), path.basename(channelDir));
+ fs.writeFileSync(path.join(targetChannelDir, 'channel-icon.jpg'), 'a different, already-there icon');
+
+ const { videoDir: secondVideoDir } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData({ id: 'vid2', uploader: 'Some Channel' }) });
+ moveLibraryEntry({ libraryDir, videoDir: secondVideoDir, targetTag: 'Music' });
+
+ expect(fs.readFileSync(path.join(targetChannelDir, 'channel-icon.jpg'), 'utf-8')).toBe('a different, already-there icon');
+ });
+
+ it('refuses when the target sublibrary already has this exact video', () => {
+ const { videoDir, channelDir } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData({ uploader: 'Some Channel' }) });
+ const targetChannelDir = path.join(libraryTagDir(libraryDir, 'Music'), path.basename(channelDir));
+ fs.mkdirSync(path.join(targetChannelDir, path.basename(videoDir)), { recursive: true });
+
+ expect(() => moveLibraryEntry({ libraryDir, videoDir, targetTag: 'Music' }))
+ .toThrow(/already exists in the target sublibrary/);
+ // Refused before anything was touched -- the source is still intact.
+ expect(fs.existsSync(videoDir)).toBe(true);
+ });
+
+ it('does not clean up the source channel folder even when this was its only video', () => {
+ const { videoDir, channelDir } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData({ uploader: 'Some Channel' }) });
+ fs.writeFileSync(path.join(channelDir, 'channel-icon.jpg'), 'source icon bytes');
+
+ moveLibraryEntry({ libraryDir, videoDir, targetTag: 'Music' });
+
+ // Deliberately left as-is (per decided scope: moving and cleanup are
+ // separate responsibilities) -- the now-video-less source channel
+ // folder, and its icon, both still exist.
+ expect(fs.existsSync(channelDir)).toBe(true);
+ expect(fs.existsSync(path.join(channelDir, 'channel-icon.jpg'))).toBe(true);
+ });
+});
diff --git a/src/electron/main.mjs b/src/electron/main.mjs
index 802336f..b477211 100644
--- a/src/electron/main.mjs
+++ b/src/electron/main.mjs
@@ -10,7 +10,7 @@ import os from 'node:os';
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, writePlaylistSnapshot, enrichPlaylistEntry, listPlaylistSnapshots, getPlaylistSnapshot, reconcilePlaylistSnapshot, undoPlaylistRefresh, deletePlaylistSnapshot, sanitizeForFilesystem, resolveInsideLibrary, defaultLibraryDir, PLAYLISTS_DIR_NAME, CLIPS_DIR_NAME, buildClipFilePath, recordClip, listClips, deleteClip, updateClipFile } from './library.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 { makeCookiesArgs, looksLikeNetscapeFormat, convertHeaderCookiesToNetscape, validateNetscapeLines, SUPPORTED_COOKIE_BROWSERS, reapStaleCookieCopies } from './cookies.mjs';
import { downloadImageToFile, createThumbnailFetchers } from './thumbnails.mjs';
@@ -260,6 +260,23 @@ function startRendererServer() {
// video.seekable.end() stays 0 and the scrub bar silently does nothing. So
// the Range math is done here ourselves; net.fetch is only ever asked for
// the exact byte range already decided.
+// NO_STORE_HEADERS goes on every single response this handler returns,
+// success or failure. Chromium treats a protocol.handle response as a
+// genuine, cacheable network response (see the file-level comment on why
+// this handler already can't rely on real file:// navigation's own
+// behavior) -- without an explicit no-store, a request that 404s once (e.g.
+// a library entry's stored path going stale, see checkAndRepairEpochFiles/
+// library.mjs) can get served straight back out of cache on every later
+// request for that exact same URL, even after the file genuinely reappears
+// on disk and this handler itself would now answer differently. Bumping the
+// player's own cacheBustKey works around this for an already-mounted
+// player (a new URL was never cached), but a *fresh* one (e.g. after
+// navigating away and back to the same video) resets that counter back to
+// its initial value and requests the identical URL that failed before --
+// with no-store, the handler is guaranteed to be asked fresh every time
+// either way, rather than depending on the query string alone.
+const NO_STORE_HEADERS = { 'Cache-Control': 'no-store' };
+
async function handleAppVideoRequest(request) {
const url = new URL(request.url);
const filePath = decodeURIComponent(url.pathname.slice(1));
@@ -267,14 +284,14 @@ async function handleAppVideoRequest(request) {
const { libraryDir } = readSettings();
const resolvedFilePath = resolveInsideLibrary(libraryDir, filePath);
if (!resolvedFilePath) {
- return new Response('Forbidden', { status: 403 });
+ return new Response('Forbidden', { status: 403, headers: NO_STORE_HEADERS });
}
let stat;
try {
stat = fs.statSync(resolvedFilePath);
} catch {
- return new Response('Not Found', { status: 404 });
+ return new Response('Not Found', { status: 404, headers: NO_STORE_HEADERS });
}
const fileSize = stat.size;
@@ -289,7 +306,7 @@ async function handleAppVideoRequest(request) {
if (!match || (!hasStart && !hasEnd)) {
return new Response('Range Not Satisfiable', {
status: 416,
- headers: { 'Content-Range': `bytes */${fileSize}` },
+ headers: { 'Content-Range': `bytes */${fileSize}`, ...NO_STORE_HEADERS },
});
}
if (hasStart) {
@@ -305,7 +322,7 @@ async function handleAppVideoRequest(request) {
if (start > end || start < 0 || end >= fileSize) {
return new Response('Range Not Satisfiable', {
status: 416,
- headers: { 'Content-Range': `bytes */${fileSize}` },
+ headers: { 'Content-Range': `bytes */${fileSize}`, ...NO_STORE_HEADERS },
});
}
status = 206;
@@ -321,6 +338,7 @@ async function handleAppVideoRequest(request) {
'Content-Type': innerResponse.headers.get('Content-Type') || 'application/octet-stream',
'Accept-Ranges': 'bytes',
'Content-Length': String(end - start + 1),
+ ...NO_STORE_HEADERS,
};
if (status === 206) {
headers['Content-Range'] = `bytes ${start}-${end}/${fileSize}`;
@@ -329,7 +347,7 @@ async function handleAppVideoRequest(request) {
return new Response(innerResponse.body, { status, headers });
} catch (err) {
log('[app-video] fetch error', String(err));
- return new Response('Internal Error', { status: 500 });
+ return new Response('Internal Error', { status: 500, headers: NO_STORE_HEADERS });
}
}
@@ -366,13 +384,86 @@ ipcMain.handle('settings:getLibraryDir', async () => {
ipcMain.handle('settings:setLibraryDir', async (e, dir) => {
const settings = readSettings();
settings.libraryDir = dir;
+ // A different library root may not even have the previously-active
+ // tag's folder -- reset to the one tag every library can always resolve.
+ settings.activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME;
writeSettings(settings);
// Switching to a different library folder mid-session should reflect
// immediately, not show whatever the previous folder's scan found.
- refreshLibraryIndex(dir);
+ refreshLibraryIndex(dir, DEFAULT_LIBRARY_DIR_NAME);
return { success: true, libraryDir: dir };
});
+// SubLibrary switching -- listTags enumerates real tag folders (library.json
+// present) directly under libraryDir; getActiveLibraryTag/setActiveLibraryTag
+// track which one the Library tab is currently scanning/writing into. Same
+// "no fallback default, deliberate choice" stance as libraryDir itself isn't
+// needed here since DEFAULT_LIBRARY_DIR_NAME is always a safe, always-valid
+// default -- unlike libraryDir, there's no "unset" state worth representing.
+ipcMain.handle('library:listTags', async () => {
+ const { libraryDir } = readSettings();
+ if (!libraryDir) return { tags: [] };
+ return { tags: listLibraryTags(libraryDir) };
+});
+
+ipcMain.handle('library:createTag', async (e, name) => {
+ const { libraryDir } = readSettings();
+ try {
+ const tag = createLibraryTag(libraryDir, name);
+ // "Auto-switch to it when done" -- the whole point of creating one.
+ const settings = readSettings();
+ settings.activeLibraryTag = tag.folderName;
+ writeSettings(settings);
+ await refreshLibraryIndex(libraryDir, tag.folderName);
+ return { success: true, tag };
+ } catch (err) {
+ return { success: false, message: err instanceof Error ? err.message : String(err) };
+ }
+});
+
+// Video tags -- an unrelated, per-video concept from the sublibrary
+// switching above (hence "videoTag(s)" naming throughout, never bare
+// "tag"). All three scope to whichever sublibrary is currently active.
+ipcMain.handle('library:listVideoTags', async () => {
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
+ if (!libraryDir) return { tags: {} };
+ return { tags: listVideoTags(libraryDir, activeLibraryTag) };
+});
+
+ipcMain.handle('library:setVideoTag', async (e, { tagName, videoId, applied }) => {
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
+ const { tags } = setVideoTag({ libraryDir, libraryTag: activeLibraryTag, tagName, videoId, applied });
+ return { success: true, tags };
+});
+
+ipcMain.handle('library:tagVideos', async (e, { videoIds, tagName }) => {
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
+ const { tags } = addTagToVideos({ libraryDir, libraryTag: activeLibraryTag, tagName, videoIds });
+ return { success: true, tags };
+});
+
+ipcMain.handle('settings:getActiveLibraryTag', async () => {
+ const { libraryDir, activeLibraryTag } = readSettings();
+ const resolvedTag = activeLibraryTag || DEFAULT_LIBRARY_DIR_NAME;
+ // Resolved server-side (not string-concatenated in the renderer) so
+ // "Open library folder" gets a real, OS-correct path -- libraryDir may
+ // be empty (no library configured yet), in which case there's nothing
+ // meaningful to resolve.
+ return {
+ activeLibraryTag: resolvedTag,
+ activeLibraryTagDir: libraryDir ? libraryTagDir(libraryDir, resolvedTag) : '',
+ };
+});
+
+ipcMain.handle('settings:setActiveLibraryTag', async (e, tag) => {
+ const settings = readSettings();
+ settings.activeLibraryTag = tag || DEFAULT_LIBRARY_DIR_NAME;
+ writeSettings(settings);
+ // Same "reflect immediately" reasoning as settings:setLibraryDir above.
+ await refreshLibraryIndex(settings.libraryDir, settings.activeLibraryTag);
+ return { success: true, activeLibraryTag: settings.activeLibraryTag };
+});
+
ipcMain.handle('settings:getLibraryViewMode', async () => {
const { libraryViewMode } = readSettings();
return { libraryViewMode: libraryViewMode === 'video' ? 'video' : 'channel' };
@@ -453,23 +544,23 @@ ipcMain.handle('settings:setCustomConvertFormats', async (e, formats) => {
});
ipcMain.handle('library:getIndex', async () => {
- const { libraryDir } = readSettings();
- return getLibraryIndex(libraryDir);
+ const { libraryDir, activeLibraryTag: libraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
+ return getLibraryIndex(libraryDir, libraryTag);
});
ipcMain.handle('library:refreshIndex', async () => {
- const { libraryDir } = readSettings();
- return refreshLibraryIndex(libraryDir);
+ const { libraryDir, activeLibraryTag: libraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
+ return refreshLibraryIndex(libraryDir, libraryTag);
});
// User-triggered from the Library tab's channel view -- unlike the
// fire-and-forget calls below, this one is awaited so the button can show a
// loading state and the caller gets back a fresh index once it's done.
ipcMain.handle('library:refreshChannelIcon', async (e, { channelFolderName, channelId }) => {
- const { libraryDir } = readSettings();
- const channelDir = path.join(defaultLibraryDir(libraryDir), channelFolderName);
+ const { libraryDir, activeLibraryTag: libraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
+ const channelDir = path.join(libraryTagDir(libraryDir, libraryTag), channelFolderName);
await ensureChannelIcon(channelDir, channelId, { force: true });
- return refreshLibraryIndex(libraryDir);
+ return refreshLibraryIndex(libraryDir, libraryTag);
});
// The channel-icon/video-thumbnail fetches below are fire-and-forget, so
@@ -482,28 +573,39 @@ function notifyLibraryBackgroundUpdate() {
BrowserWindow.getAllWindows()[0]?.webContents.send('library:backgroundUpdate');
}
-ipcMain.handle('library:addEntry', async (e, videoMetaData) => {
- const { libraryDir } = readSettings();
- const result = writeLibraryEntry({ libraryDir, videoMetaData });
- await refreshLibraryIndex(libraryDir);
+// targetTag lets the renderer's "add to library" picker (only shown once
+// more than one sublibrary exists) send a video somewhere other than
+// whatever's currently active, without switching the active tag itself --
+// bulk-add (useBulkAddQueue.tsx) reuses this same channel per item, sending
+// the one tag chosen for the whole batch. Omitted (single-tag libraries,
+// or "just use what's active"), it falls back to the active tag.
+ipcMain.handle('library:addEntry', async (e, videoMetaData, targetTag) => {
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
+ const libraryTag = targetTag || activeLibraryTag;
+ const result = writeLibraryEntry({ libraryDir, libraryTag, videoMetaData });
+ await refreshLibraryIndex(libraryDir, libraryTag);
Promise.all([
ensureChannelIcon(result.channelDir, videoMetaData.channelId),
ensureVideoThumbnail(result.videoDir, videoMetaData.thumbnail),
- ]).then(() => refreshLibraryIndex(libraryDir)).then(notifyLibraryBackgroundUpdate);
+ ]).then(() => refreshLibraryIndex(libraryDir, libraryTag)).then(notifyLibraryBackgroundUpdate);
// epoch included alongside videoDir -- bulk-add (useBulkAddQueue.tsx) needs
// it immediately to kick off a download for the entry it just created,
// without a second round-trip to look it back up.
return { success: true, videoDir: result.videoDir, epoch: String(result.metadata.addedEpoch) };
});
-ipcMain.handle('library:overrideEntry', async (e, { videoMetaData, existingVideoDir }) => {
- const { libraryDir } = readSettings();
- const result = overrideLibraryEntry({ libraryDir, videoMetaData, existingVideoDir });
- await refreshLibraryIndex(libraryDir);
+// targetTag: the replacement write must land back in the same sublibrary
+// the entry being overridden actually came from -- same "explicit tag,
+// defaulting to active" pattern as library:addEntry's targetTag.
+ipcMain.handle('library:overrideEntry', async (e, { videoMetaData, existingVideoDir }, targetTag) => {
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
+ const libraryTag = targetTag || activeLibraryTag;
+ const result = overrideLibraryEntry({ libraryDir, libraryTag, videoMetaData, existingVideoDir });
+ await refreshLibraryIndex(libraryDir, libraryTag);
Promise.all([
ensureChannelIcon(result.channelDir, videoMetaData.channelId),
ensureVideoThumbnail(result.videoDir, videoMetaData.thumbnail),
- ]).then(() => refreshLibraryIndex(libraryDir)).then(notifyLibraryBackgroundUpdate);
+ ]).then(() => refreshLibraryIndex(libraryDir, libraryTag)).then(notifyLibraryBackgroundUpdate);
return { success: true, videoDir: result.videoDir };
});
@@ -512,13 +614,13 @@ ipcMain.handle('library:overrideEntry', async (e, { videoMetaData, existingVideo
// both "Add as new version" (Downloader tab's duplicate dialog) and
// "Download new version" (Library tab's video detail view).
ipcMain.handle('library:addVersion', async (e, { videoDir, videoMetaData }) => {
- const { libraryDir } = readSettings();
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
const result = addLibraryVersion({ libraryDir, videoDir, videoMetaData });
- await refreshLibraryIndex(libraryDir);
+ await refreshLibraryIndex(libraryDir, activeLibraryTag);
Promise.all([
ensureChannelIcon(path.dirname(result.videoDir), videoMetaData.channelId),
ensureVideoThumbnail(result.videoDir, videoMetaData.thumbnail),
- ]).then(() => refreshLibraryIndex(libraryDir)).then(notifyLibraryBackgroundUpdate);
+ ]).then(() => refreshLibraryIndex(libraryDir, activeLibraryTag)).then(notifyLibraryBackgroundUpdate);
return { success: true, videoDir: result.videoDir, epoch: result.epoch, metadata: result.metadata };
});
@@ -527,16 +629,35 @@ ipcMain.handle('library:addVersion', async (e, { videoDir, videoMetaData }) => {
// other refresh/add path) before calling this; this just writes it into the
// existing epoch in place, see refreshLibraryEntryMetadata's own comment.
ipcMain.handle('library:refreshEntry', async (e, { videoDir, epoch, videoMetaData }) => {
- const { libraryDir } = readSettings();
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
const metadata = refreshLibraryEntryMetadata({ libraryDir, videoDir, epoch, videoMetaData });
- await refreshLibraryIndex(libraryDir);
+ await refreshLibraryIndex(libraryDir, activeLibraryTag);
Promise.all([
ensureChannelIcon(path.dirname(videoDir), videoMetaData.channelId),
ensureVideoThumbnail(videoDir, videoMetaData.thumbnail),
- ]).then(() => refreshLibraryIndex(libraryDir)).then(notifyLibraryBackgroundUpdate);
+ ]).then(() => refreshLibraryIndex(libraryDir, activeLibraryTag)).then(notifyLibraryBackgroundUpdate);
return { success: true, metadata };
});
+// On-demand only -- called when the video detail view (LibraryVideoDetail.tsx)
+// opens a given epoch, never as part of a bulk scan (see
+// checkAndRepairEpochFiles's own comment, library.mjs, for why). No
+// libraryTag involved: videoDir is already a full, known absolute path
+// (same as recordDownload/deleteEntry/etc.), tag-agnostic like those.
+ipcMain.handle('library:checkAndRepairEpochFiles', async (e, { videoDir, epoch }) => {
+ const { libraryDir } = readSettings();
+ try {
+ const result = checkAndRepairEpochFiles({ libraryDir, videoDir, epoch });
+ if (result.videoRepaired || result.audioRepaired) {
+ const { activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
+ refreshLibraryIndex(libraryDir, activeLibraryTag).then(notifyLibraryBackgroundUpdate);
+ }
+ return { success: true, ...result };
+ } catch (err) {
+ return { success: false, message: err instanceof Error ? err.message : String(err) };
+ }
+});
+
// Flat-playlist entries carry more than just id/title/url for free: each
// entry already has its own `thumbnails[]` array and, when YouTube resolves
// it during the flat listing, a `timestamp`. Falls back to the predictable
@@ -606,12 +727,15 @@ ipcMain.handle('library:fetchPlaylistEntries', async (e, playlistUrl) => {
assertValidHttpUrl(playlistUrl, 'playlist URL');
const playlist = await fetchPlaylistEntries(playlistUrl);
// Snapshot saved every time a playlist is fetched (see
- // library.mjs's writePlaylistSnapshot).
- const { libraryDir } = readSettings();
+ // library.mjs's writePlaylistSnapshot). Always saved into whichever
+ // sublibrary is currently active -- unlike adding a video, playlist
+ // saves don't get their own target-tag picker.
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
if (libraryDir) {
- const index = await getLibraryIndex(libraryDir);
+ const index = await getLibraryIndex(libraryDir, activeLibraryTag);
const result = writePlaylistSnapshot({
libraryDir,
+ libraryTag: activeLibraryTag,
playlistId: playlist.id,
title: playlist.title,
uploader: playlist.uploader,
@@ -640,12 +764,12 @@ ipcMain.handle('library:fetchPlaylistEntries', async (e, playlistUrl) => {
// title/date/thumbnail so a future re-fetch, even after the video goes dead
// on YouTube, doesn't lose it (see enrichPlaylistEntry's never-regress guard).
ipcMain.handle('library:enrichPlaylistEntry', async (e, { playlistId, videoId, title, uploadDate, thumbnailUrl }) => {
- const { libraryDir } = readSettings();
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
if (!libraryDir || !playlistId) {
return { success: false };
}
try {
- enrichPlaylistEntry({ libraryDir, playlistId, videoId, title, uploadDate, thumbnailUrl });
+ enrichPlaylistEntry({ libraryDir, libraryTag: activeLibraryTag, playlistId, videoId, title, uploadDate, thumbnailUrl });
return { success: true };
} catch (err) {
return { success: false, message: err instanceof Error ? err.message : String(err) };
@@ -655,15 +779,15 @@ ipcMain.handle('library:enrichPlaylistEntry', async (e, { playlistId, videoId, t
// Backs the Library tab's Playlists section -- list/detail read straight off
// whatever's already saved, no network call.
ipcMain.handle('library:listPlaylists', async () => {
- const { libraryDir } = readSettings();
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
if (!libraryDir) return { playlists: [] };
- return { playlists: listPlaylistSnapshots({ libraryDir }) };
+ return { playlists: listPlaylistSnapshots({ libraryDir, libraryTag: activeLibraryTag }) };
});
ipcMain.handle('library:getPlaylist', async (e, playlistId) => {
- const { libraryDir } = readSettings();
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
if (!libraryDir) return { playlist: null };
- return { playlist: await getPlaylistSnapshot({ libraryDir, playlistId }) };
+ return { playlist: await getPlaylistSnapshot({ libraryDir, libraryTag: activeLibraryTag, playlistId }) };
});
// The explicit "Refresh" action -- re-fetches the playlist from yt-dlp using
@@ -672,19 +796,20 @@ ipcMain.handle('library:getPlaylist', async (e, playlistId) => {
// library:fetchPlaylistEntries above (still a no-op past the first save) --
// refresh only ever happens through this explicit action.
ipcMain.handle('library:refreshPlaylist', async (e, playlistId) => {
- const { libraryDir } = readSettings();
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
if (!libraryDir) {
return { success: false, message: 'No library folder configured.' };
}
try {
- const index = await getLibraryIndex(libraryDir);
- const saved = await getPlaylistSnapshot({ libraryDir, playlistId, index });
+ const index = await getLibraryIndex(libraryDir, activeLibraryTag);
+ const saved = await getPlaylistSnapshot({ libraryDir, libraryTag: activeLibraryTag, playlistId, index });
if (!saved || !saved.originalUrl) {
return { success: false, message: 'This playlist has no saved snapshot to refresh.' };
}
const fresh = await fetchPlaylistEntries(saved.originalUrl);
const result = reconcilePlaylistSnapshot({
libraryDir,
+ libraryTag: activeLibraryTag,
playlistId,
freshEntries: fresh.entries.map((entry) => ({
videoId: entry.id,
@@ -697,7 +822,7 @@ ipcMain.handle('library:refreshPlaylist', async (e, playlistId) => {
freshUploader: fresh.uploader,
index,
});
- const playlistDir = path.join(defaultLibraryDir(libraryDir), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId));
+ const playlistDir = path.join(libraryTagDir(libraryDir, activeLibraryTag), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId));
ensurePlaylistThumbnail(playlistDir, result.entries[0]?.thumbnailUrl);
return result;
} catch (err) {
@@ -706,12 +831,12 @@ ipcMain.handle('library:refreshPlaylist', async (e, playlistId) => {
});
ipcMain.handle('library:undoPlaylistRefresh', async (e, playlistId) => {
- const { libraryDir } = readSettings();
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
if (!libraryDir) {
return { success: false, message: 'No library folder configured.' };
}
try {
- return undoPlaylistRefresh({ libraryDir, playlistId });
+ return undoPlaylistRefresh({ libraryDir, libraryTag: activeLibraryTag, playlistId });
} catch (err) {
return { success: false, message: err instanceof Error ? err.message : String(err) };
}
@@ -721,12 +846,12 @@ ipcMain.handle('library:undoPlaylistRefresh', async (e, playlistId) => {
// references, which is why there's no equivalent of deleteLibraryEntry's
// videoDeleted flag here for the UI to react to.
ipcMain.handle('library:deletePlaylist', async (e, playlistId) => {
- const { libraryDir } = readSettings();
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
if (!libraryDir) {
return { success: false, message: 'No library folder configured.' };
}
try {
- return deletePlaylistSnapshot({ libraryDir, playlistId });
+ return deletePlaylistSnapshot({ libraryDir, libraryTag: activeLibraryTag, playlistId });
} catch (err) {
return { success: false, message: err instanceof Error ? err.message : String(err) };
}
@@ -735,9 +860,13 @@ ipcMain.handle('library:deletePlaylist', async (e, playlistId) => {
// Checked by the renderer before calling addEntry, so a duplicate can be
// caught with a warning dialog instead of silently piling up a redundant
// epoch folder for a video that's already tracked.
-ipcMain.handle('library:findVideo', async (e, videoId) => {
- const { libraryDir } = readSettings();
- const index = await getLibraryIndex(libraryDir);
+// libraryTag: scoped to whichever sublibrary the caller actually cares
+// about (e.g. the target of an in-progress add), defaulting to active when
+// omitted -- a dedup check against the wrong sublibrary would either miss a
+// real duplicate or flag a false one.
+ipcMain.handle('library:findVideo', async (e, videoId, libraryTag) => {
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
+ const index = await getLibraryIndex(libraryDir, libraryTag || activeLibraryTag);
const match = findVideoInIndex(index, videoId);
if (!match) {
return { found: false };
@@ -747,22 +876,22 @@ ipcMain.handle('library:findVideo', async (e, videoId) => {
ipcMain.handle('library:recordDownload', async (e, { videoDir, epoch, filePath, resolution, format, kind }) => {
recordLibraryDownload({ videoDir, epoch, filePath, resolution, format, kind });
- const { libraryDir } = readSettings();
- await refreshLibraryIndex(libraryDir);
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
+ await refreshLibraryIndex(libraryDir, activeLibraryTag);
return { success: true };
});
ipcMain.handle('library:swapDownload', async (e, { videoDir, epoch, tempFilePath, oldFilePath, resolution, format, kind }) => {
- const { libraryDir } = readSettings();
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
const metadata = swapLibraryDownload({ libraryDir, videoDir, epoch, tempFilePath, oldFilePath, resolution, format, kind });
- await refreshLibraryIndex(libraryDir);
+ await refreshLibraryIndex(libraryDir, activeLibraryTag);
return metadata;
});
ipcMain.handle('library:deleteEntry', async (e, { videoDir, epoch }) => {
- const { libraryDir } = readSettings();
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
const { videoDeleted } = deleteLibraryEntry({ libraryDir, videoDir, epoch });
- await refreshLibraryIndex(libraryDir);
+ await refreshLibraryIndex(libraryDir, activeLibraryTag);
return { success: true, videoDeleted };
});
@@ -771,16 +900,44 @@ ipcMain.handle('library:deleteEntry', async (e, { videoDir, epoch }) => {
// end instead of once per item. Used by the Library tab's bulk-select
// "Delete selected" action.
ipcMain.handle('library:deleteEntries', async (e, { videoDirs }) => {
- const { libraryDir } = readSettings();
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
const results = videoDirs.map((videoDir) => {
try {
- deleteLibraryEntry({ libraryDir, videoDir });
- return { videoDir, success: true };
+ const { videoId } = deleteLibraryEntry({ libraryDir, videoDir });
+ return { videoDir, success: true, videoId };
} catch (err) {
return { videoDir, success: false, error: err instanceof Error ? err.message : String(err) };
}
});
- await refreshLibraryIndex(libraryDir);
+ // One batched read-modify-write of the sublibrary's tag map instead of
+ // touching it per video -- see removeVideosFromTags (library.mjs).
+ removeVideosFromTags(libraryDir, activeLibraryTag, results.filter((r) => r.success && r.videoId).map((r) => r.videoId));
+ await refreshLibraryIndex(libraryDir, activeLibraryTag);
+ return { success: results.every((r) => r.success), results };
+});
+
+// Batched "Move selected" -- moves N whole videos into a different
+// sublibrary tag (see moveLibraryEntry, library.mjs) and refreshes the
+// *active* tag's index once at the end, same shape as deleteEntries/
+// deleteLocalFiles above: the moved videos vanish from whatever's currently
+// being viewed (the source), and the target tag's own index will scan fresh
+// the next time someone actually switches to it (getLibraryIndex's cache is
+// keyed per-tag, so there's nothing stale to bust there).
+ipcMain.handle('library:moveEntries', async (e, { videoDirs, targetTag }) => {
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
+ const results = videoDirs.map((videoDir) => {
+ try {
+ const { videoId } = moveLibraryEntry({ libraryDir, videoDir, targetTag });
+ return { videoDir, success: true, videoId };
+ } catch (err) {
+ return { videoDir, success: false, error: err instanceof Error ? err.message : String(err) };
+ }
+ });
+ // One batched transfer of the moved videos' tag membership from the
+ // source sublibrary's manifest to the target's, instead of touching
+ // either file per video -- see transferVideoTags (library.mjs).
+ transferVideoTags(libraryDir, activeLibraryTag, targetTag, results.filter((r) => r.success && r.videoId).map((r) => r.videoId));
+ await refreshLibraryIndex(libraryDir, activeLibraryTag);
return { success: results.every((r) => r.success), results };
});
@@ -790,7 +947,7 @@ ipcMain.handle('library:deleteEntries', async (e, { videoDirs }) => {
// library:deleteEntries for the same reason: the renderer needs to know
// exactly which ones failed to report back accurately.
ipcMain.handle('library:deleteLocalFiles', async (e, { videoDirs }) => {
- const { libraryDir } = readSettings();
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
const results = videoDirs.map((videoDir) => {
try {
deleteLocalFiles({ libraryDir, videoDir });
@@ -799,14 +956,17 @@ ipcMain.handle('library:deleteLocalFiles', async (e, { videoDirs }) => {
return { videoDir, success: false, error: err instanceof Error ? err.message : String(err) };
}
});
- await refreshLibraryIndex(libraryDir);
+ await refreshLibraryIndex(libraryDir, activeLibraryTag);
return { success: results.every((r) => r.success), results };
});
// Kick off the initial scan in the background at startup -- deliberately not
// awaited, since this could be scanning an arbitrarily large library.
// getLibraryIndex reuses this same in-flight scan when the Library tab asks.
-getLibraryIndex(readSettings().libraryDir);
+{
+ const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings();
+ getLibraryIndex(libraryDir, activeLibraryTag);
+}
// A literal fs.existsSync(filePath) isn't enough: postprocessors (MP3
// extraction, format recode) append their target extension rather than
diff --git a/src/electron/preload.cjs b/src/electron/preload.cjs
index 7d7b35e..0d569aa 100644
--- a/src/electron/preload.cjs
+++ b/src/electron/preload.cjs
@@ -31,6 +31,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
deleteVideoInfoCacheEntry: (url) => ipcRenderer.invoke('videoInfoCache:deleteEntry', url),
getLibraryDir: () => ipcRenderer.invoke('settings:getLibraryDir'),
setLibraryDir: (dir) => ipcRenderer.invoke('settings:setLibraryDir', dir),
+ listLibraryTags: () => ipcRenderer.invoke('library:listTags'),
+ createLibraryTag: (name) => ipcRenderer.invoke('library:createTag', name),
+ listVideoTags: () => ipcRenderer.invoke('library:listVideoTags'),
+ setVideoTag: (tagName, videoId, applied) => ipcRenderer.invoke('library:setVideoTag', { tagName, videoId, applied }),
+ tagVideos: (videoIds, tagName) => ipcRenderer.invoke('library:tagVideos', { videoIds, tagName }),
+ getActiveLibraryTag: () => ipcRenderer.invoke('settings:getActiveLibraryTag'),
+ setActiveLibraryTag: (tag) => ipcRenderer.invoke('settings:setActiveLibraryTag', tag),
getLibraryViewMode: () => ipcRenderer.invoke('settings:getLibraryViewMode'),
setLibraryViewMode: (mode) => ipcRenderer.invoke('settings:setLibraryViewMode', mode),
getLibrarySort: () => ipcRenderer.invoke('settings:getLibrarySort'),
@@ -46,11 +53,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
getLibraryIndex: () => ipcRenderer.invoke('library:getIndex'),
refreshLibraryIndex: () => ipcRenderer.invoke('library:refreshIndex'),
refreshChannelIcon: (payload) => ipcRenderer.invoke('library:refreshChannelIcon', payload),
- addLibraryEntry: (videoMetaData) => ipcRenderer.invoke('library:addEntry', videoMetaData),
- overrideLibraryEntry: (videoMetaData, existingVideoDir) => ipcRenderer.invoke('library:overrideEntry', { videoMetaData, existingVideoDir }),
+ addLibraryEntry: (videoMetaData, targetTag) => ipcRenderer.invoke('library:addEntry', videoMetaData, targetTag),
+ overrideLibraryEntry: (videoMetaData, existingVideoDir, targetTag) => ipcRenderer.invoke('library:overrideEntry', { videoMetaData, existingVideoDir }, targetTag),
addLibraryVersion: (videoMetaData, videoDir) => ipcRenderer.invoke('library:addVersion', { videoMetaData, videoDir }),
refreshLibraryEntry: (videoDir, epoch, videoMetaData) => ipcRenderer.invoke('library:refreshEntry', { videoDir, epoch, videoMetaData }),
- findLibraryVideo: (videoId) => ipcRenderer.invoke('library:findVideo', videoId),
+ checkAndRepairEpochFiles: (videoDir, epoch) => ipcRenderer.invoke('library:checkAndRepairEpochFiles', { videoDir, epoch }),
+ findLibraryVideo: (videoId, libraryTag) => ipcRenderer.invoke('library:findVideo', videoId, libraryTag),
fetchPlaylistEntries: (playlistUrl) => ipcRenderer.invoke('library:fetchPlaylistEntries', playlistUrl),
enrichPlaylistEntry: (payload) => ipcRenderer.invoke('library:enrichPlaylistEntry', payload),
listPlaylists: () => ipcRenderer.invoke('library:listPlaylists'),
@@ -62,6 +70,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
swapLibraryDownload: (payload) => ipcRenderer.invoke('library:swapDownload', payload),
deleteLibraryEntry: (videoDir, epoch) => ipcRenderer.invoke('library:deleteEntry', { videoDir, epoch }),
deleteLibraryEntries: (videoDirs) => ipcRenderer.invoke('library:deleteEntries', { videoDirs }),
+ moveLibraryEntries: (videoDirs, targetTag) => ipcRenderer.invoke('library:moveEntries', { videoDirs, targetTag }),
deleteLocalFiles: (videoDirs) => ipcRenderer.invoke('library:deleteLocalFiles', { videoDirs }),
onLibraryBackgroundUpdate: (callback) => ipcRenderer.on('library:backgroundUpdate', () => callback()),
removeLibraryBackgroundUpdateListener: () => ipcRenderer.removeAllListeners('library:backgroundUpdate'),
diff --git a/src/electron/preload.test.mjs b/src/electron/preload.test.mjs
index e0f6088..7f978b8 100644
--- a/src/electron/preload.test.mjs
+++ b/src/electron/preload.test.mjs
@@ -67,6 +67,13 @@ const invokeTable = [
['deleteVideoInfoCacheEntry', ['url'], ['videoInfoCache:deleteEntry', 'url']],
['getLibraryDir', [], ['settings:getLibraryDir']],
['setLibraryDir', ['/l'], ['settings:setLibraryDir', '/l']],
+ ['listLibraryTags', [], ['library:listTags']],
+ ['createLibraryTag', ['Music'], ['library:createTag', 'Music']],
+ ['listVideoTags', [], ['library:listVideoTags']],
+ ['setVideoTag', ['TVshows', 'vid1', true], ['library:setVideoTag', { tagName: 'TVshows', videoId: 'vid1', applied: true }]],
+ ['tagVideos', [['vid1', 'vid2'], 'TVshows'], ['library:tagVideos', { videoIds: ['vid1', 'vid2'], tagName: 'TVshows' }]],
+ ['getActiveLibraryTag', [], ['settings:getActiveLibraryTag']],
+ ['setActiveLibraryTag', ['Music'], ['settings:setActiveLibraryTag', 'Music']],
['getLibraryViewMode', [], ['settings:getLibraryViewMode']],
['setLibraryViewMode', ['video'], ['settings:setLibraryViewMode', 'video']],
['getThemeMode', [], ['settings:getThemeMode']],
@@ -76,10 +83,10 @@ const invokeTable = [
['getLibraryIndex', [], ['library:getIndex']],
['refreshLibraryIndex', [], ['library:refreshIndex']],
['refreshChannelIcon', [{ c: 1 }], ['library:refreshChannelIcon', { c: 1 }]],
- ['addLibraryEntry', [{ id: 1 }], ['library:addEntry', { id: 1 }]],
- ['overrideLibraryEntry', [{ id: 1 }, '/v'], ['library:overrideEntry', { videoMetaData: { id: 1 }, existingVideoDir: '/v' }]],
+ ['addLibraryEntry', [{ id: 1 }, 'Music'], ['library:addEntry', { id: 1 }, 'Music']],
+ ['overrideLibraryEntry', [{ id: 1 }, '/v', 'Music'], ['library:overrideEntry', { videoMetaData: { id: 1 }, existingVideoDir: '/v' }, 'Music']],
['addLibraryVersion', [{ id: 1 }, '/v'], ['library:addVersion', { videoMetaData: { id: 1 }, videoDir: '/v' }]],
- ['findLibraryVideo', ['vid'], ['library:findVideo', 'vid']],
+ ['findLibraryVideo', ['vid', 'Music'], ['library:findVideo', 'vid', 'Music']],
['fetchPlaylistEntries', ['url'], ['library:fetchPlaylistEntries', 'url']],
['enrichPlaylistEntry', [{ e: 1 }], ['library:enrichPlaylistEntry', { e: 1 }]],
['recordLibraryDownload', [{ r: 1 }], ['library:recordDownload', { r: 1 }]],
diff --git a/src/types/electron-api.d.ts b/src/types/electron-api.d.ts
index b727210..283f2a1 100644
--- a/src/types/electron-api.d.ts
+++ b/src/types/electron-api.d.ts
@@ -19,6 +19,22 @@ type OpenFolderResult = {
type LibrarySortField = 'title' | 'uploadDate' | 'dateAdded' | 'channel' | 'downloaded' | 'quality';
type LibrarySortDirection = 'asc' | 'desc';
+// Mirrors listLibraryTags' return shape (library.mjs) -- folderName is what
+// every IPC call actually keys on; tagName is presentational (today always
+// equal to folderName, since sublibrary names aren't renamable separately
+// from their folder).
+type LibraryTag = {
+ tagName: string;
+ folderName: string;
+ createdEpoch: number | null;
+};
+
+// A per-sublibrary map of user-defined video tags to the videoIds carrying
+// them (see listVideoTags, library.mjs) -- unrelated to LibraryTag above
+// (that's sublibrary switching); named videoTag(s) everywhere to keep the
+// two concepts unambiguous.
+type VideoTagsMap = Record;
+
type LibraryIndex = {
channels: {
channelFolderName: string;
@@ -68,6 +84,13 @@ declare global {
deleteVideoInfoCacheEntry: (url: string) => Promise<{ success: boolean; existed: boolean }>
getLibraryDir: () => Promise<{ libraryDir: string }>
setLibraryDir: (dir: string) => Promise<{ success: boolean; libraryDir: string }>
+ listLibraryTags: () => Promise<{ tags: LibraryTag[] }>
+ createLibraryTag: (name: string) => Promise<{ success: boolean; tag?: LibraryTag; message?: string }>
+ listVideoTags: () => Promise<{ tags: VideoTagsMap }>
+ setVideoTag: (tagName: string, videoId: string, applied: boolean) => Promise<{ success: boolean; tags: VideoTagsMap }>
+ tagVideos: (videoIds: string[], tagName: string) => Promise<{ success: boolean; tags: VideoTagsMap }>
+ getActiveLibraryTag: () => Promise<{ activeLibraryTag: string; activeLibraryTagDir: string }>
+ setActiveLibraryTag: (tag: string) => Promise<{ success: boolean; activeLibraryTag: string }>
getLibraryViewMode: () => Promise<{ libraryViewMode: 'channel' | 'video' }>
setLibraryViewMode: (mode: 'channel' | 'video') => Promise<{ success: boolean; libraryViewMode: 'channel' | 'video' }>
getLibrarySort: () => Promise<{ sortField: LibrarySortField; sortDirection: LibrarySortDirection }>
@@ -83,11 +106,12 @@ declare global {
getLibraryIndex: () => Promise
refreshLibraryIndex: () => Promise
refreshChannelIcon: (payload: { channelFolderName: string; channelId: string | null }) => Promise
- addLibraryEntry: (videoMetaData: T) => Promise<{ success: boolean; videoDir: string; epoch: string }>
- overrideLibraryEntry: (videoMetaData: T, existingVideoDir: string) => Promise<{ success: boolean; videoDir: string }>
+ addLibraryEntry: (videoMetaData: T, targetTag?: string) => Promise<{ success: boolean; videoDir: string; epoch: string }>
+ overrideLibraryEntry: (videoMetaData: T, existingVideoDir: string, targetTag?: string) => Promise<{ success: boolean; videoDir: string }>
addLibraryVersion: (videoMetaData: T, videoDir: string) => Promise<{ success: boolean; videoDir: string; epoch: string; metadata: LibraryVideoMetadata }>
refreshLibraryEntry: (videoDir: string, epoch: string, videoMetaData: T) => Promise<{ success: boolean; metadata: LibraryVideoMetadata }>
- findLibraryVideo: (videoId: string) => Promise<{ found: boolean; channelDisplayName?: string; videoDir?: string }>
+ checkAndRepairEpochFiles: (videoDir: string, epoch: string) => Promise<{ success: boolean; metadata?: LibraryVideoMetadata; videoRepaired?: boolean; audioRepaired?: boolean; videoMissing?: boolean; audioMissing?: boolean; message?: string }>
+ findLibraryVideo: (videoId: string, libraryTag?: string) => Promise<{ found: boolean; channelDisplayName?: string; videoDir?: string }>
fetchPlaylistEntries: (playlistUrl: string) => Promise<{ success: boolean; entries?: { id: string; title: string | null; url: string; thumbnailUrl: string; uploadDate: string | null }[]; playlistId?: string; message?: string }>
enrichPlaylistEntry: (payload: { playlistId: string; videoId: string; title?: string | null; uploadDate?: string | null; thumbnailUrl?: string | null }) => Promise<{ success: boolean; message?: string }>
listPlaylists: () => Promise<{ playlists: PlaylistSummary[] }>
@@ -99,6 +123,7 @@ declare global {
swapLibraryDownload: (payload: { videoDir: string; epoch: string; tempFilePath: string; oldFilePath: string | null; resolution: string; format?: string; kind?: 'video' | 'audio' }) => Promise
deleteLibraryEntry: (videoDir: string, epoch?: string) => Promise<{ success: boolean; videoDeleted: boolean }>
deleteLibraryEntries: (videoDirs: string[]) => Promise<{ success: boolean; results: { videoDir: string; success: boolean; error?: string }[] }>
+ moveLibraryEntries: (videoDirs: string[], targetTag: string) => Promise<{ success: boolean; results: { videoDir: string; success: boolean; error?: string }[] }>
deleteLocalFiles: (videoDirs: string[]) => Promise<{ success: boolean; results: { videoDir: string; success: boolean; error?: string }[] }>
onLibraryBackgroundUpdate: (callback: () => void) => void
removeLibraryBackgroundUpdateListener: () => void
diff --git a/src/ui/MainPage.tsx b/src/ui/MainPage.tsx
index b9b6a42..2eea7de 100644
--- a/src/ui/MainPage.tsx
+++ b/src/ui/MainPage.tsx
@@ -29,7 +29,7 @@ import { useLibraryNotification } from './hooks/useLibraryNotifications';
import { useYtdlpUpdater } from './hooks/useYtdlpUpdater';
import buttonIcon from '../../assets/button_icon.png';
-const REPO_URL = 'https://github.com/lltrash94/SlothArchiver';
+const REPO_URL = 'https://github.com/SlothSoftworks/Sloth-Archiver';
const LIBRARY_TAB_INDEX = 1;
const OPTIONS_TAB_INDEX = 2;
diff --git a/src/ui/components/BulkAddDialog.test.tsx b/src/ui/components/BulkAddDialog.test.tsx
index c52fde1..7e55a2f 100644
--- a/src/ui/components/BulkAddDialog.test.tsx
+++ b/src/ui/components/BulkAddDialog.test.tsx
@@ -11,6 +11,8 @@ beforeEach(() => {
fetchPlaylistEntries: vi.fn(),
getVideoInfoPython: vi.fn(() => new Promise(() => {})),
getMaxSimultaneousDownloads: vi.fn().mockResolvedValue({ maxSimultaneousDownloads: 1 }),
+ listLibraryTags: vi.fn().mockResolvedValue({ tags: [] }),
+ getActiveLibraryTag: vi.fn().mockResolvedValue({ activeLibraryTag: 'DefaultLibrary', activeLibraryTagDir: '' }),
};
window.electronAPIPythonDownload = {
startDownloadPython: vi.fn(),
diff --git a/src/ui/components/BulkAddDialog.tsx b/src/ui/components/BulkAddDialog.tsx
index 4838e0c..6021f74 100644
--- a/src/ui/components/BulkAddDialog.tsx
+++ b/src/ui/components/BulkAddDialog.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import {
Alert,
Button,
@@ -6,7 +6,9 @@ import {
DialogActions,
DialogContent,
DialogTitle,
+ FormControl,
FormControlLabel,
+ InputLabel,
MenuItem,
Select,
Stack,
@@ -22,6 +24,14 @@ import { useBulkAddQueue, type BulkAddEntry } from '../hooks/useBulkAddQueue.tsx
// fetched. Per-video matching happens in useBulkAddQueue's pickClosestResolution.
export const QUALITY_TIERS = ['2160', '1440', '1080', '720', '480', '360', '240', '144', 'MP3'];
+// Mirrors listLibraryTags' return shape (library.mjs) -- see LibraryScreen.tsx's
+// own copy of this type for why it isn't shared/imported across screens.
+type LibraryTag = {
+ tagName: string;
+ folderName: string;
+ createdEpoch: number | null;
+};
+
function isPlaylistUrl(url: string): boolean {
try {
return new URL(url).searchParams.has('list');
@@ -37,6 +47,20 @@ export default function BulkAddDialog({ open, onClose }: { open: boolean; onClos
const [targetResolution, setTargetResolution] = useState('720');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
+ // Only ever shown/relevant once more than one sublibrary exists -- same
+ // fetch-once-on-mount pattern as DownloaderScreen's own copy of this.
+ const [libraryTags, setLibraryTags] = useState([]);
+ const [targetLibraryTag, setTargetLibraryTag] = useState('');
+ useEffect(() => {
+ (async () => {
+ const [{ tags }, { activeLibraryTag }] = await Promise.all([
+ window.electronAPI.listLibraryTags(),
+ window.electronAPI.getActiveLibraryTag(),
+ ]);
+ setLibraryTags(tags);
+ setTargetLibraryTag(activeLibraryTag);
+ })();
+ }, []);
const handleClose = () => {
if (submitting) return;
@@ -76,7 +100,7 @@ export default function BulkAddDialog({ open, onClose }: { open: boolean; onClos
if (entries.length === 0) {
throw new Error('No videos found.');
}
- start(entries, { download, targetResolution });
+ start(entries, { download, targetResolution, targetLibraryTag: targetLibraryTag || undefined });
setInput('');
onClose();
} catch (err) {
@@ -104,6 +128,21 @@ export default function BulkAddDialog({ open, onClose }: { open: boolean; onClos
onChange={(e) => setInput(e.target.value)}
disabled={submitting}
/>
+ {libraryTags.length > 1 &&
+
+ Add to
+ setTargetLibraryTag(e.target.value)}
+ disabled={submitting}
+ >
+ {libraryTags.map((tag) => (
+ {tag.tagName}
+ ))}
+
+ }
setDownload(e.target.checked)} disabled={submitting} />}
label="Also download each video (not just add to the library)"
diff --git a/src/ui/components/BulkAddSidePanel.test.tsx b/src/ui/components/BulkAddSidePanel.test.tsx
index 522ee3f..10e6bcc 100644
--- a/src/ui/components/BulkAddSidePanel.test.tsx
+++ b/src/ui/components/BulkAddSidePanel.test.tsx
@@ -23,6 +23,8 @@ beforeEach(() => {
recordLibraryDownload: vi.fn().mockResolvedValue({ success: true }),
fetchPlaylistEntries: vi.fn(),
getMaxSimultaneousDownloads: vi.fn().mockResolvedValue({ maxSimultaneousDownloads: 1 }),
+ listLibraryTags: vi.fn().mockResolvedValue({ tags: [] }),
+ getActiveLibraryTag: vi.fn().mockResolvedValue({ activeLibraryTag: 'DefaultLibrary', activeLibraryTagDir: '' }),
};
window.electronAPIPythonDownload = {
startDownloadPython: vi.fn(),
diff --git a/src/ui/components/CreateSubLibraryDialog.tsx b/src/ui/components/CreateSubLibraryDialog.tsx
new file mode 100644
index 0000000..146bceb
--- /dev/null
+++ b/src/ui/components/CreateSubLibraryDialog.tsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import {
+ Button,
+ CircularProgress,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ TextField,
+ Typography,
+ DialogTitle,
+} from '@mui/material';
+
+// Pure UI, same split as BulkDownloadQualityDialog: this owns the text
+// field's own draft value, the caller owns the actual createLibraryTag IPC
+// call plus its loading/error state, since only the caller knows what to do
+// once creation succeeds (switch to it) or fails (surface the message here).
+export default function CreateSubLibraryDialog({ open, onClose, creating, error, onConfirm }: {
+ open: boolean;
+ onClose: () => void;
+ creating: boolean;
+ error: string | null;
+ onConfirm: (name: string) => void;
+}) {
+ const [name, setName] = useState('');
+
+ const handleClose = () => {
+ if (creating) return;
+ setName('');
+ onClose();
+ };
+
+ const handleConfirm = () => {
+ if (!name.trim()) return;
+ onConfirm(name.trim());
+ };
+
+ return (
+
+ Add new sublibrary
+
+
+ Creates a separate section of your library, kept apart from your other sublibraries until you switch back.
+
+ setName(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter') handleConfirm(); }}
+ disabled={creating}
+ />
+ {error && {error} }
+
+
+ Cancel
+
+ {creating ? : 'Create'}
+
+
+
+ );
+}
diff --git a/src/ui/components/LibraryBottomBar.tsx b/src/ui/components/LibraryBottomBar.tsx
index e2d3c93..16d5b42 100644
--- a/src/ui/components/LibraryBottomBar.tsx
+++ b/src/ui/components/LibraryBottomBar.tsx
@@ -4,6 +4,8 @@ import PhotoSizeSelectLargeIcon from '@mui/icons-material/PhotoSizeSelectLarge';
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';
export const THUMBNAIL_SIZE_MIN = 160;
export const THUMBNAIL_SIZE_MAX = 360;
@@ -17,6 +19,8 @@ export default function LibraryBottomBar({
thumbnailSize, onThumbnailSizeChange, onThumbnailSizeCommit,
selectedCount, canBulkDownload, onDownloadSelected,
canDeleteLocalFiles, onDeleteLocalFiles, onDeleteFromLibrary,
+ canMove, onMoveSelected,
+ canTag, onTagSelected,
}: {
thumbnailSize?: number;
onThumbnailSizeChange?: (size: number) => void;
@@ -27,6 +31,16 @@ export default function LibraryBottomBar({
canDeleteLocalFiles: boolean;
onDeleteLocalFiles: () => void;
onDeleteFromLibrary: () => void;
+ // Optional -- omitted by the Playlists section's own bulk bar, same as
+ // thumbnailSize above. Only ever true when more than one sublibrary
+ // exists (there's nowhere else to move a video to otherwise).
+ canMove?: boolean;
+ onMoveSelected?: () => void;
+ // Optional, same reason as canMove above -- Playlists has no tags. Unlike
+ // canMove there's no sublibrary-count gate: tagging only ever touches the
+ // one active sublibrary, so it's available whenever anything is selected.
+ canTag?: boolean;
+ onTagSelected?: () => void;
}) {
return (
// A flex sibling of the scrollable content region, not an in-flow
@@ -48,6 +62,14 @@ export default function LibraryBottomBar({
} onClick={onDownloadSelected}>
Download selected
}
+ {canTag && onTagSelected &&
+ } onClick={onTagSelected}>
+ Tag selected
+ }
+ {canMove && onMoveSelected &&
+ } onClick={onMoveSelected}>
+ Move selected
+ }
{canDeleteLocalFiles &&
} onClick={onDeleteLocalFiles}>
Delete local files
diff --git a/src/ui/components/MoveToSubLibraryDialog.tsx b/src/ui/components/MoveToSubLibraryDialog.tsx
new file mode 100644
index 0000000..f318cf8
--- /dev/null
+++ b/src/ui/components/MoveToSubLibraryDialog.tsx
@@ -0,0 +1,89 @@
+import { useState } from 'react';
+import {
+ Button,
+ CircularProgress,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ FormControl,
+ InputLabel,
+ MenuItem,
+ Select,
+ Typography,
+} from '@mui/material';
+
+// Mirrors listLibraryTags' return shape (library.mjs) -- see LibraryScreen.tsx's
+// own copy of this type for why it isn't shared/imported across screens.
+type LibraryTag = {
+ tagName: string;
+ folderName: string;
+ createdEpoch: number | null;
+};
+
+// options is deliberately the caller's responsibility to filter (excluding
+// whichever sublibrary is currently active -- there's nowhere to move a
+// video *to* the one it's already in), same as this dialog owning nothing
+// beyond its own draft selection, matching CreateSubLibraryDialog's split.
+export default function MoveToSubLibraryDialog({ open, onClose, count, options, moving, error, onConfirm }: {
+ open: boolean;
+ onClose: () => void;
+ count: number;
+ options: LibraryTag[];
+ moving: boolean;
+ error: string | null;
+ onConfirm: (targetTag: string) => void;
+}) {
+ const [targetTag, setTargetTag] = useState('');
+
+ // The dialog opens with nothing explicitly picked -- defaulting to
+ // options[0] the first time it renders with a real list avoids an extra
+ // click for the overwhelmingly common case (exactly one other sublibrary
+ // to move into). Confirming must use this, not the raw targetTag state,
+ // or accepting that default without ever touching the Select would submit
+ // an empty target.
+ const selectedValue = targetTag || options[0]?.folderName || '';
+
+ const handleClose = () => {
+ if (moving) return;
+ setTargetTag('');
+ onClose();
+ };
+
+ const handleConfirm = () => {
+ if (!selectedValue) return;
+ onConfirm(selectedValue);
+ };
+
+ return (
+
+
+
+ Move {count} selected video{count === 1 ? '' : 's'} (every saved version, clips, and its channel's icon if
+ needed) into a different sublibrary.
+
+
+ Move to
+ setTargetTag(e.target.value)}
+ disabled={moving}
+ >
+ {options.map((tag) => (
+ {tag.tagName}
+ ))}
+
+
+ {error && {error} }
+
+
+ Cancel
+
+ {moving ? : 'Move'}
+
+
+
+ );
+}
diff --git a/src/ui/components/TagFilterPopover.tsx b/src/ui/components/TagFilterPopover.tsx
new file mode 100644
index 0000000..fcf1ce7
--- /dev/null
+++ b/src/ui/components/TagFilterPopover.tsx
@@ -0,0 +1,57 @@
+import {
+ Box,
+ Button,
+ Checkbox,
+ FormControlLabel,
+ FormGroup,
+ Popover,
+ Typography,
+} from '@mui/material';
+
+// Pure selection state -- unlike VideoTagsPopover (which applies each
+// toggle immediately via setVideoTag, for one specific video), this has no
+// video of its own and no IPC call: the caller owns selectedTags and
+// narrows whatever list it's filtering. A video must carry every selected
+// tag to match (AND, not ANY), per the decided design.
+export default function TagFilterPopover({ open, anchorEl, onClose, allTags, selectedTags, onToggle, onClear }: {
+ open: boolean;
+ anchorEl: HTMLElement | null;
+ onClose: () => void;
+ allTags: string[];
+ selectedTags: Set;
+ onToggle: (tagName: string, checked: boolean) => void;
+ onClear: () => void;
+}) {
+ return (
+
+
+ {allTags.length > 0 ? (
+
+ {allTags.map((tag) => (
+ onToggle(tag, e.target.checked)}
+ />
+ }
+ />
+ ))}
+
+ ) : (
+ No tags yet in this sublibrary.
+ )}
+ {selectedTags.size > 0 &&
+ Clear filter }
+
+
+ );
+}
diff --git a/src/ui/components/TagSelectedDialog.tsx b/src/ui/components/TagSelectedDialog.tsx
new file mode 100644
index 0000000..b27c4e3
--- /dev/null
+++ b/src/ui/components/TagSelectedDialog.tsx
@@ -0,0 +1,67 @@
+import { useState } from 'react';
+import {
+ Autocomplete,
+ Button,
+ CircularProgress,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ TextField,
+ Typography,
+} from '@mui/material';
+
+// Add-only, mirroring MoveToSubLibraryDialog's controlled shape -- removal
+// only ever happens per-video, via VideoTagsPopover on the video detail
+// screen. freeSolo Autocomplete (same pattern as the convert-formats field
+// in OptionsScreen.tsx) lets picking an existing tag and typing a brand-new
+// one share one field, "one at a time" per the decided design.
+export default function TagSelectedDialog({ open, onClose, count, options, tagging, error, onConfirm }: {
+ open: boolean;
+ onClose: () => void;
+ count: number;
+ options: string[];
+ tagging: boolean;
+ error: string | null;
+ onConfirm: (tagName: string) => void;
+}) {
+ const [tagName, setTagName] = useState('');
+
+ const handleClose = () => {
+ if (tagging) return;
+ setTagName('');
+ onClose();
+ };
+
+ const handleConfirm = () => {
+ if (!tagName.trim()) return;
+ onConfirm(tagName.trim());
+ };
+
+ return (
+
+
+
+ Tag {count} selected video{count === 1 ? '' : 's'} with an existing tag, or create a new one.
+
+ setTagName(newValue)}
+ onKeyDown={(e) => { if (e.key === 'Enter') handleConfirm(); }}
+ disabled={tagging}
+ renderInput={(params) => }
+ />
+ {error && {error} }
+
+
+ Cancel
+
+ {tagging ? : 'Tag'}
+
+
+
+ );
+}
diff --git a/src/ui/components/VideoTagsPopover.tsx b/src/ui/components/VideoTagsPopover.tsx
new file mode 100644
index 0000000..6332619
--- /dev/null
+++ b/src/ui/components/VideoTagsPopover.tsx
@@ -0,0 +1,79 @@
+import { useState } from 'react';
+import {
+ Box,
+ Checkbox,
+ Divider,
+ FormControlLabel,
+ FormGroup,
+ Popover,
+ Stack,
+ TextField,
+ Typography,
+} from '@mui/material';
+
+// The only place a video's tags are ever removed -- a checkbox per tag
+// known in the active sublibrary (checked = applied to this video),
+// toggling calls onToggle immediately (no separate save step, same
+// immediate-effect convention as everything else in the bulk/detail bars).
+// The text field at the bottom creates a brand-new tag and applies it to
+// this video in one action -- no separate "create tag" flow exists.
+export default function VideoTagsPopover({ open, anchorEl, onClose, allTags, appliedTags, onToggle, onCreate }: {
+ open: boolean;
+ anchorEl: HTMLElement | null;
+ onClose: () => void;
+ allTags: string[];
+ appliedTags: string[];
+ onToggle: (tagName: string, applied: boolean) => void;
+ onCreate: (tagName: string) => void;
+}) {
+ const [newTagName, setNewTagName] = useState('');
+
+ const handleCreate = () => {
+ const trimmed = newTagName.trim();
+ if (!trimmed) return;
+ onCreate(trimmed);
+ setNewTagName('');
+ };
+
+ return (
+
+
+ {allTags.length > 0 &&
+
+ {allTags.map((tag) => (
+ onToggle(tag, e.target.checked)}
+ />
+ }
+ />
+ ))}
+ }
+ {allTags.length === 0 &&
+ No tags yet in this sublibrary. }
+
+
+ setNewTagName(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
+ />
+
+
+
+ );
+}
diff --git a/src/ui/hooks/useBulkAddQueue.tsx b/src/ui/hooks/useBulkAddQueue.tsx
index 1be0396..d643409 100644
--- a/src/ui/hooks/useBulkAddQueue.tsx
+++ b/src/ui/hooks/useBulkAddQueue.tsx
@@ -55,7 +55,11 @@ export type BulkAddItem = {
playlistId?: string;
};
-type StartOptions = { download: boolean; targetResolution: string };
+// targetLibraryTag is optional -- omitted (single-sublibrary case, or
+// "just use whatever's active"), addLibraryEntry falls back to the active
+// tag server-side. One tag for the whole batch, no per-item override, same
+// as targetResolution above.
+type StartOptions = { download: boolean; targetResolution: string; targetLibraryTag?: string };
// Derived from the item's own stashed fields (not a separate flag, which
// could drift) -- 'download' means the library entry already exists and
@@ -295,7 +299,7 @@ function useBulkAddQueueState() {
return;
}
- const added = await window.electronAPI.addLibraryEntry(videoInfo);
+ const added = await window.electronAPI.addLibraryEntry(videoInfo, optionsRef.current.targetLibraryTag);
if (!optionsRef.current.download) {
updateItem(item.id, { status: 'done' });
diff --git a/src/ui/screens/DownloaderScreen.test.tsx b/src/ui/screens/DownloaderScreen.test.tsx
index ad47ee0..52f549d 100644
--- a/src/ui/screens/DownloaderScreen.test.tsx
+++ b/src/ui/screens/DownloaderScreen.test.tsx
@@ -30,6 +30,8 @@ beforeEach(() => {
overrideLibraryEntry: vi.fn(),
addLibraryVersion: vi.fn(),
deleteVideoInfoCacheEntry: vi.fn().mockResolvedValue({ success: true, existed: true }),
+ listLibraryTags: vi.fn().mockResolvedValue({ tags: [] }),
+ getActiveLibraryTag: vi.fn().mockResolvedValue({ activeLibraryTag: 'DefaultLibrary', activeLibraryTagDir: '' }),
};
window.electronAPIPythonDownload = {
startDownloadPython: vi.fn(),
@@ -128,7 +130,7 @@ describe('DownloaderScreen', () => {
await user.click(within(screen.getByLabelText('Add to library')).getByRole('button'));
await waitFor(() => expect(screen.getByText('Added to library')).toBeInTheDocument());
- expect(window.electronAPI.addLibraryEntry).toHaveBeenCalledWith(videoResponse);
+ expect(window.electronAPI.addLibraryEntry).toHaveBeenCalledWith(videoResponse, 'DefaultLibrary');
expect(screen.queryByRole('link', { name: 'My Great Video' })).not.toBeInTheDocument();
expect(screen.getByLabelText('URL')).toHaveValue('');
});
@@ -143,7 +145,7 @@ describe('DownloaderScreen', () => {
expect(screen.getByText(/under "Some Channel"/)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Override' }));
- expect(window.electronAPI.overrideLibraryEntry).toHaveBeenCalledWith(videoResponse, '/d/v1');
+ expect(window.electronAPI.overrideLibraryEntry).toHaveBeenCalledWith(videoResponse, '/d/v1', 'DefaultLibrary');
});
it('"Add as new version" calls addLibraryVersion instead of overriding', async () => {
@@ -159,6 +161,37 @@ describe('DownloaderScreen', () => {
expect(window.electronAPI.overrideLibraryEntry).not.toHaveBeenCalled();
});
+ it('scopes the duplicate check and the add itself to the picked target sublibrary, and links "View" to it', async () => {
+ (window.electronAPI.listLibraryTags as ReturnType).mockResolvedValue({
+ tags: [
+ { tagName: 'DefaultLibrary', folderName: 'DefaultLibrary', createdEpoch: 1 },
+ { tagName: 'Music', folderName: 'Music', createdEpoch: 2 },
+ ],
+ });
+ const user = userEvent.setup();
+ await loadVideo(user);
+ await screen.findByRole('combobox', { name: 'Add to' });
+
+ await user.click(screen.getByRole('combobox', { name: 'Add to' }));
+ await user.click(await screen.findByRole('option', { name: 'Music' }));
+
+ (window.electronAPI.findLibraryVideo as ReturnType).mockResolvedValue({ found: false });
+ (window.electronAPI.addLibraryEntry as ReturnType).mockResolvedValue({ success: true, videoDir: '/d', epoch: '1' });
+
+ await user.click(within(screen.getByLabelText('Add to library')).getByRole('button'));
+
+ await waitFor(() => expect(screen.getByText('Added to library')).toBeInTheDocument());
+ // The duplicate check (before the add) and the add itself must both be
+ // scoped to the sublibrary actually picked, not whatever's active --
+ // otherwise a video only present in a different sublibrary would wrongly
+ // look like a duplicate, or land in the wrong place.
+ expect(window.electronAPI.findLibraryVideo).toHaveBeenCalledWith(videoResponse.id, 'Music');
+ expect(window.electronAPI.addLibraryEntry).toHaveBeenCalledWith(videoResponse, 'Music');
+ // The "View" link must carry the same tag, or LibraryScreen's deep link
+ // (scoped to whatever's currently active there) would never find it.
+ expect(screen.getByRole('link', { name: 'View' })).toHaveAttribute('href', expect.stringContaining('tag=Music'));
+ });
+
it('shows an error dialog when adding to the library fails', async () => {
const user = userEvent.setup();
await loadVideo(user);
diff --git a/src/ui/screens/DownloaderScreen.tsx b/src/ui/screens/DownloaderScreen.tsx
index 9471b5c..dc4ea9d 100644
--- a/src/ui/screens/DownloaderScreen.tsx
+++ b/src/ui/screens/DownloaderScreen.tsx
@@ -11,8 +11,12 @@ import {
DialogContent,
DialogContentText,
DialogTitle,
+ FormControl,
InputAdornment,
+ InputLabel,
Link,
+ MenuItem,
+ Select,
Stack,
IconButton,
Snackbar,
@@ -33,6 +37,13 @@ import { getInitialDownloaderVideoInfo } from '../../../testing/mockData/electro
import VideoDetailCardSkeleton from './VideoDetailCardSkeleton.tsx';
import { useLibraryNotification } from '../hooks/useLibraryNotifications';
+// Mirrors listLibraryTags' return shape (library.mjs) -- see LibraryScreen.tsx's
+// own copy of this type for why it isn't shared/imported across screens.
+type LibraryTag = {
+ tagName: string;
+ folderName: string;
+ createdEpoch: number | null;
+};
export default function DownloaderScreen() {
@@ -47,10 +58,30 @@ export default function DownloaderScreen() {
const [libraryAddStatus, setLibraryAddStatus] = useState<'idle' | 'saving' | 'error'>('idle');
const [libraryErrorMessage, setLibraryErrorMessage] = useState(null);
const [librarySuccessSnackbarOpen, setLibrarySuccessSnackbarOpen] = useState(false);
+ // Only ever shown/relevant once more than one sublibrary exists -- fetched
+ // once on mount, same as everything else here that doesn't change mid-session.
+ const [libraryTags, setLibraryTags] = useState([]);
+ const [targetLibraryTag, setTargetLibraryTag] = useState('');
+ useEffect(() => {
+ (async () => {
+ const [{ tags }, { activeLibraryTag }] = await Promise.all([
+ window.electronAPI.listLibraryTags(),
+ window.electronAPI.getActiveLibraryTag(),
+ ]);
+ setLibraryTags(tags);
+ setTargetLibraryTag(activeLibraryTag);
+ })();
+ }, []);
// Captured alongside the snackbar open, not read from videoInfo later,
// since all three add paths below clear videoInfo right after a
// successful add -- this is what the toast's "View" link navigates to.
const [lastAddedVideoId, setLastAddedVideoId] = useState(null);
+ // The sublibrary the video actually landed in -- same value as
+ // targetLibraryTag at add-time, captured separately since targetLibraryTag
+ // itself isn't reset after a successful add. The "View" link needs this so
+ // LibraryScreen knows which sublibrary to switch to/search, rather than
+ // assuming whatever's currently active.
+ const [lastAddedLibraryTag, setLastAddedLibraryTag] = useState(null);
const [duplicateMatch, setDuplicateMatch] = useState<{ channelDisplayName: string | null; videoDir: string } | null>(null);
// Drives both which card renders below (VideoDetailCard vs. the simplified
// OtherPlatformDownloadCard) and whether "add to library" can activate --
@@ -86,9 +117,10 @@ export default function DownloaderScreen() {
if (!videoInfo) return;
setLibraryAddStatus('saving');
try {
- await window.electronAPI.addLibraryEntry(videoInfo);
+ await window.electronAPI.addLibraryEntry(videoInfo, targetLibraryTag || undefined);
incrementLibraryNotifications();
setLastAddedVideoId(videoInfo.id);
+ setLastAddedLibraryTag(targetLibraryTag || null);
setLibrarySuccessSnackbarOpen(true);
setVideoUrl('');
setVideoInfo(null);
@@ -107,7 +139,11 @@ export default function DownloaderScreen() {
if (!videoInfo) return;
setLibraryAddStatus('saving');
try {
- const existing = await window.electronAPI.findLibraryVideo(videoInfo.id);
+ // Scoped to the target sublibrary (the one about to be written to),
+ // not whatever's currently active -- otherwise a video that's only in
+ // a different sublibrary would wrongly look like a duplicate here, and
+ // a real duplicate sitting in some third sublibrary would be missed.
+ const existing = await window.electronAPI.findLibraryVideo(videoInfo.id, targetLibraryTag || undefined);
if (existing.found && existing.videoDir) {
// Pause here rather than writing a redundant epoch folder for a video
// that's already tracked -- let the user decide via the dialog below.
@@ -132,9 +168,13 @@ export default function DownloaderScreen() {
setDuplicateMatch(null);
setLibraryAddStatus('saving');
try {
- await window.electronAPI.overrideLibraryEntry(videoInfo, existingVideoDir);
+ // The replacement write must land back in the same sublibrary the
+ // existing entry (just found via findLibraryVideo above) actually
+ // came from -- targetLibraryTag, not whatever's currently active.
+ await window.electronAPI.overrideLibraryEntry(videoInfo, existingVideoDir, targetLibraryTag || undefined);
incrementLibraryNotifications();
setLastAddedVideoId(videoInfo.id);
+ setLastAddedLibraryTag(targetLibraryTag || null);
setLibrarySuccessSnackbarOpen(true);
setVideoUrl('');
setVideoInfo(null);
@@ -160,6 +200,10 @@ export default function DownloaderScreen() {
await window.electronAPI.addLibraryVersion(videoInfo, existingVideoDir);
incrementLibraryNotifications();
setLastAddedVideoId(videoInfo.id);
+ // Adds a new epoch under the existing (already-resolved) videoDir --
+ // stays in whatever sublibrary that video already lives in, same as
+ // targetLibraryTag (it's the one findLibraryVideo just searched).
+ setLastAddedLibraryTag(targetLibraryTag || null);
setLibrarySuccessSnackbarOpen(true);
setVideoUrl('');
setVideoInfo(null);
@@ -224,6 +268,20 @@ export default function DownloaderScreen() {
},
}}
/>
+ {libraryTags.length > 1 &&
+
+ Add to
+ setTargetLibraryTag(e.target.value)}
+ >
+ {libraryTags.map((tag) => (
+ {tag.tagName}
+ ))}
+
+ }
setLibrarySuccessSnackbarOpen(false)}
color="inherit"
underline="always"
diff --git a/src/ui/screens/LibraryScreen.test.tsx b/src/ui/screens/LibraryScreen.test.tsx
index 87a921a..fabd51d 100644
--- a/src/ui/screens/LibraryScreen.test.tsx
+++ b/src/ui/screens/LibraryScreen.test.tsx
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import type { ReactElement } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render as rtlRender, screen, waitFor } from '@testing-library/react';
+import { render as rtlRender, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router';
import LibraryScreen from './LibraryScreen';
@@ -17,6 +17,12 @@ function render(ui: ReactElement) {
return rtlRender({ui} );
}
+// For the deep-link (?tag=) tests below -- same wrapper as render() but
+// starting on a specific route instead of the default "/".
+function renderAt(path: string, ui: ReactElement) {
+ return rtlRender({ui} );
+}
+
// LibraryVideoDetail is the biggest, most complex file in the app (its own
// dedicated test file covers it) -- mocked out here so LibraryScreen's tests
// stay scoped to its own navigation/state logic, and to confirm the props it
@@ -84,10 +90,19 @@ beforeEach(() => {
refreshLibraryIndex: vi.fn().mockResolvedValue({ channels: makeChannels() }),
refreshChannelIcon: vi.fn(),
openDirectory: vi.fn(),
+ listLibraryTags: vi.fn().mockResolvedValue({ tags: [{ tagName: 'DefaultLibrary', folderName: 'DefaultLibrary', createdEpoch: 1 }] }),
+ createLibraryTag: vi.fn().mockResolvedValue({ success: true, tag: { tagName: 'Music', folderName: 'Music', createdEpoch: 2 } }),
+ getActiveLibraryTag: vi.fn().mockResolvedValue({ activeLibraryTag: 'DefaultLibrary', activeLibraryTagDir: '/lib/DefaultLibrary' }),
+ setActiveLibraryTag: vi.fn().mockResolvedValue({ success: true, activeLibraryTag: 'DefaultLibrary' }),
+ findLibraryVideo: vi.fn().mockResolvedValue({ found: false }),
onLibraryBackgroundUpdate: vi.fn(),
removeLibraryBackgroundUpdateListener: vi.fn(),
deleteLibraryEntries: vi.fn().mockResolvedValue({ success: true, results: [] }),
+ moveLibraryEntries: vi.fn().mockResolvedValue({ success: true, results: [] }),
deleteLocalFiles: vi.fn().mockResolvedValue({ success: true, results: [] }),
+ listVideoTags: vi.fn().mockResolvedValue({ tags: {} }),
+ setVideoTag: vi.fn().mockResolvedValue({ success: true, tags: {} }),
+ tagVideos: vi.fn().mockResolvedValue({ success: true, tags: {} }),
// useBulkAddQueue's start() (fired by "Download selected") unconditionally
// calls this -- stubbed so bulk-select's download tests don't hit an
// unmocked IPC call, even though they don't assert on its result.
@@ -134,6 +149,21 @@ describe('LibraryScreen', () => {
expect(screen.getByText('3 clips')).toBeInTheDocument();
});
+ it('shows a tag chip on a video card only for tags actually applied to that video', async () => {
+ (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' });
+ (window.electronAPI.listVideoTags as ReturnType).mockResolvedValue({ tags: { TVshows: ['vidA'], games: ['vidB'] } });
+ render( );
+ await screen.findByText('Alpha Video');
+
+ expect(screen.getByText('TVshows')).toBeInTheDocument();
+ expect(screen.getByText('games')).toBeInTheDocument();
+ // Alpha Video only carries TVshows -- games (Beta Video's own tag)
+ // shouldn't also render on Alpha's card.
+ const alphaCard = screen.getByText('Alpha Video').closest('.MuiCard-root');
+ expect(alphaCard).not.toBeNull();
+ expect(alphaCard && within(alphaCard as HTMLElement).queryByText('games')).toBeNull();
+ });
+
it('drills into a channel, shows its videos, and back returns to the channel list', async () => {
const user = userEvent.setup();
render( );
@@ -211,13 +241,13 @@ describe('LibraryScreen', () => {
await waitFor(() => expect(window.electronAPI.refreshLibraryIndex).toHaveBeenCalled());
});
- it('opening the library folder calls openDirectory with the configured path', async () => {
+ it('opening the library folder calls openDirectory with the active sublibrary\'s resolved path', async () => {
const user = userEvent.setup();
render( );
await screen.findByText('Channel A');
await user.click(screen.getByRole('button', { name: 'Open library folder' }));
- expect(window.electronAPI.openDirectory).toHaveBeenCalledWith('/lib');
+ expect(window.electronAPI.openDirectory).toHaveBeenCalledWith('/lib/DefaultLibrary');
});
it('refreshing a channel icon calls refreshChannelIcon with the channel folder/id', async () => {
@@ -454,6 +484,115 @@ describe('LibraryScreen', () => {
expect(screen.queryByText('1 item selected')).not.toBeInTheDocument();
});
+ it('hides "Move selected" when only one sublibrary exists', async () => {
+ const user = userEvent.setup();
+ (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' });
+ render( );
+ await screen.findByText('Alpha Video');
+
+ await user.click(screen.getByRole('checkbox', { name: 'Select Alpha Video' }));
+
+ expect(screen.queryByRole('button', { name: /Move selected/ })).not.toBeInTheDocument();
+ });
+
+ it('moves the selection to the chosen sublibrary and clears it on confirm', async () => {
+ const user = userEvent.setup();
+ (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' });
+ (window.electronAPI.listLibraryTags as ReturnType).mockResolvedValue({
+ tags: [
+ { tagName: 'DefaultLibrary', folderName: 'DefaultLibrary', createdEpoch: 1 },
+ { tagName: 'Music', folderName: 'Music', createdEpoch: 2 },
+ ],
+ });
+ render( );
+ await screen.findByText('Alpha Video');
+
+ await user.click(screen.getByRole('checkbox', { name: 'Select Alpha Video' }));
+ await user.click(screen.getByRole('button', { name: /Move selected/ }));
+
+ expect(await screen.findByText(/Move 1 selected video/)).toBeInTheDocument();
+ // Only "Music" is offered -- DefaultLibrary is the currently-active
+ // tag, filtered out since there's nowhere to move a video *to* the
+ // sublibrary it's already in.
+ await user.click(screen.getByRole('button', { name: 'Move' }));
+
+ await waitFor(() => expect(window.electronAPI.moveLibraryEntries).toHaveBeenCalledWith(['/lib/Channel A/vidA'], 'Music'));
+ expect(screen.queryByText(/Move 1 selected video/)).not.toBeInTheDocument();
+ expect(screen.queryByText('1 item selected')).not.toBeInTheDocument();
+ });
+
+ it('a partial move failure keeps only the failed items selected', async () => {
+ const user = userEvent.setup();
+ (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' });
+ (window.electronAPI.listLibraryTags as ReturnType).mockResolvedValue({
+ tags: [
+ { tagName: 'DefaultLibrary', folderName: 'DefaultLibrary', createdEpoch: 1 },
+ { tagName: 'Music', folderName: 'Music', createdEpoch: 2 },
+ ],
+ });
+ (window.electronAPI.moveLibraryEntries as ReturnType).mockResolvedValue({
+ success: false,
+ results: [
+ { videoDir: '/lib/Channel A/vidA', success: true },
+ { videoDir: '/lib/Channel B/vidB', success: false, error: 'boom' },
+ ],
+ });
+ render( );
+ await screen.findByText('Alpha Video');
+
+ await user.click(screen.getByRole('checkbox', { name: 'Select Alpha Video' }));
+ await user.click(screen.getByRole('checkbox', { name: 'Select Beta Video' }));
+ await user.click(screen.getByRole('button', { name: /Move selected/ }));
+ await user.click(await screen.findByRole('button', { name: 'Move' }));
+
+ expect(await screen.findByText(/couldn't be moved/)).toBeInTheDocument();
+ expect(screen.getByText('1 item selected')).toBeInTheDocument();
+ });
+
+ it('"Tag selected" is offered whenever anything is selected, unlike Move\'s multi-sublibrary gate', async () => {
+ const user = userEvent.setup();
+ (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' });
+ render( );
+ await screen.findByText('Alpha Video');
+
+ await user.click(screen.getByRole('checkbox', { name: 'Select Alpha Video' }));
+
+ expect(screen.getByRole('button', { name: /Tag selected/ })).toBeInTheDocument();
+ });
+
+ it('tags the selection with a picked existing tag and clears the selection on confirm', async () => {
+ const user = userEvent.setup();
+ (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' });
+ (window.electronAPI.listVideoTags as ReturnType).mockResolvedValue({ tags: { TVshows: ['vidC'] } });
+ render( );
+ await screen.findByText('Alpha Video');
+
+ await user.click(screen.getByRole('checkbox', { name: 'Select Alpha Video' }));
+ await user.click(screen.getByRole('button', { name: /Tag selected/ }));
+
+ expect(await screen.findByText(/Tag 1 selected video/)).toBeInTheDocument();
+ await user.type(screen.getByRole('combobox'), 'TVshows');
+ await user.click(screen.getByRole('button', { name: 'Tag' }));
+
+ await waitFor(() => expect(window.electronAPI.tagVideos).toHaveBeenCalledWith(['vidA'], 'TVshows'));
+ expect(screen.queryByText(/Tag 1 selected video/)).not.toBeInTheDocument();
+ expect(screen.queryByText('1 item selected')).not.toBeInTheDocument();
+ });
+
+ it('tags the selection with a brand-new, freely typed tag', async () => {
+ const user = userEvent.setup();
+ (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' });
+ render( );
+ await screen.findByText('Alpha Video');
+
+ await user.click(screen.getByRole('checkbox', { name: 'Select Alpha Video' }));
+ await user.click(screen.getByRole('button', { name: /Tag selected/ }));
+ await user.type(screen.getByRole('combobox'), 'brandNewTag');
+ await user.click(await screen.findByRole('button', { name: 'Tag' }));
+
+ await waitFor(() => expect(window.electronAPI.tagVideos).toHaveBeenCalledWith(['vidA'], 'brandNewTag'));
+ });
+
it('resets the selection when navigating back to the channel list', async () => {
const user = userEvent.setup();
render( );
@@ -467,4 +606,108 @@ describe('LibraryScreen', () => {
expect(screen.queryByText(/item.*selected/)).not.toBeInTheDocument();
});
});
+
+ describe('tag filter', () => {
+ it('shows the filter popover with a checkbox per known tag', async () => {
+ const user = userEvent.setup();
+ (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' });
+ (window.electronAPI.listVideoTags as ReturnType).mockResolvedValue({ tags: { TVshows: ['vidA'], games: ['vidB'] } });
+ render( );
+ await screen.findByText('Alpha Video');
+
+ await user.click(screen.getByRole('button', { name: 'Filter by tag' }));
+
+ expect(screen.getByRole('checkbox', { name: 'TVshows' })).toBeInTheDocument();
+ expect(screen.getByRole('checkbox', { name: 'games' })).toBeInTheDocument();
+ });
+
+ it('filters to only videos carrying every selected tag (AND, not ANY)', async () => {
+ const user = userEvent.setup();
+ (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' });
+ (window.electronAPI.listVideoTags as ReturnType).mockResolvedValue({ tags: { TVshows: ['vidA'], games: ['vidB'] } });
+ render( );
+ await screen.findByText('Alpha Video');
+
+ await user.click(screen.getByRole('button', { name: 'Filter by tag' }));
+ await user.click(screen.getByRole('checkbox', { name: 'TVshows' }));
+
+ // Only Alpha (TVshows) matches -- Beta (games only) is filtered out.
+ expect(screen.getByText('Alpha Video')).toBeInTheDocument();
+ expect(screen.queryByText('Beta Video')).not.toBeInTheDocument();
+
+ // Selecting a second tag neither video carries both of -- AND
+ // semantics means the result narrows to nothing, not widens.
+ await user.click(screen.getByRole('checkbox', { name: 'games' }));
+ expect(screen.queryByText('Alpha Video')).not.toBeInTheDocument();
+ expect(screen.queryByText('Beta Video')).not.toBeInTheDocument();
+ expect(screen.getByText('No videos match the selected tag filter.')).toBeInTheDocument();
+ });
+
+ it('the tag filter composes with search, narrowing within the already-filtered set', async () => {
+ const user = userEvent.setup();
+ (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' });
+ (window.electronAPI.listVideoTags as ReturnType).mockResolvedValue({ tags: { TVshows: ['vidA', 'vidB'] } });
+ render( );
+ await screen.findByText('Alpha Video');
+
+ await user.click(screen.getByRole('button', { name: 'Filter by tag' }));
+ await user.click(screen.getByRole('checkbox', { name: 'TVshows' }));
+ expect(screen.getByText('Alpha Video')).toBeInTheDocument();
+ expect(screen.getByText('Beta Video')).toBeInTheDocument();
+
+ await user.keyboard('{Escape}');
+ await user.type(screen.getByPlaceholderText('Search videos...'), 'Alpha');
+
+ await waitFor(() => expect(screen.queryByText('Beta Video')).not.toBeInTheDocument());
+ expect(screen.getByText('Alpha Video')).toBeInTheDocument();
+ });
+
+ it('"Clear filter" resets the selection and shows every video again', async () => {
+ const user = userEvent.setup();
+ (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' });
+ (window.electronAPI.listVideoTags as ReturnType).mockResolvedValue({ tags: { TVshows: ['vidA'] } });
+ render( );
+ await screen.findByText('Alpha Video');
+
+ await user.click(screen.getByRole('button', { name: 'Filter by tag' }));
+ await user.click(screen.getByRole('checkbox', { name: 'TVshows' }));
+ expect(screen.queryByText('Beta Video')).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'Clear filter' }));
+ expect(await screen.findByText('Beta Video')).toBeInTheDocument();
+ });
+ });
+
+ describe('deep link with ?tag=', () => {
+ it('switches to the linked sublibrary before resolving the video, when it differs from the active one', async () => {
+ (window.electronAPI.listLibraryTags as ReturnType).mockResolvedValue({
+ tags: [
+ { tagName: 'DefaultLibrary', folderName: 'DefaultLibrary', createdEpoch: 1 },
+ { tagName: 'Music', folderName: 'Music', createdEpoch: 2 },
+ ],
+ });
+ (window.electronAPI.findLibraryVideo as ReturnType).mockResolvedValue({
+ found: true, videoDir: '/lib/Channel A/vidA',
+ });
+
+ renderAt('/library/video/vidA?tag=Music', );
+
+ await waitFor(() => expect(window.electronAPI.setActiveLibraryTag).toHaveBeenCalledWith('Music'));
+ // The lookup itself is scoped to the linked tag too -- not whatever
+ // was active when the link was clicked.
+ expect(window.electronAPI.findLibraryVideo).toHaveBeenCalledWith('vidA', 'Music');
+ expect(await screen.findByText('Detail: vidA')).toBeInTheDocument();
+ });
+
+ it('does not switch when the linked tag is already the active one', async () => {
+ (window.electronAPI.findLibraryVideo as ReturnType).mockResolvedValue({
+ found: true, videoDir: '/lib/Channel A/vidA',
+ });
+
+ renderAt('/library/video/vidA?tag=DefaultLibrary', );
+
+ expect(await screen.findByText('Detail: vidA')).toBeInTheDocument();
+ expect(window.electronAPI.setActiveLibraryTag).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/src/ui/screens/LibraryScreen.tsx b/src/ui/screens/LibraryScreen.tsx
index 1bd6381..2227b15 100644
--- a/src/ui/screens/LibraryScreen.tsx
+++ b/src/ui/screens/LibraryScreen.tsx
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
-import { useMatch, useNavigate } from 'react-router';
+import { useMatch, useNavigate, useSearchParams } from 'react-router';
import {
Alert,
Avatar,
+ Badge,
Box,
Card,
CardActionArea,
@@ -23,15 +24,18 @@ import {
Tooltip,
Typography,
} from '@mui/material';
+import { pink } from '@mui/material/colors';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import RefreshIcon from '@mui/icons-material/Refresh';
import FaceRetouchingNaturalIcon from '@mui/icons-material/FaceRetouchingNatural';
import FolderIcon from '@mui/icons-material/Folder';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
+import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder';
import VideoLibraryIcon from '@mui/icons-material/VideoLibrary';
import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
+import FilterListIcon from '@mui/icons-material/FilterList';
import { convertYYYYMMDDStringToDate, buildAppVideoUrl, getBestDownloadedQuality, responsiveGridTemplateColumns, thumbnailGridTemplateColumns } from '../../utils/utils.ts';
import LibraryVideoDetail from './LibraryVideoDetail';
import PlaylistsSection, { type PlaylistBulkBar } from '../components/PlaylistsSection';
@@ -39,6 +43,10 @@ import LibrarySearchBar from '../components/LibrarySearchBar';
import LibraryBottomBar from '../components/LibraryBottomBar';
import BulkDownloadQualityDialog from '../components/BulkDownloadQualityDialog';
import BulkDeleteConfirmDialog from '../components/BulkDeleteConfirmDialog';
+import CreateSubLibraryDialog from '../components/CreateSubLibraryDialog';
+import MoveToSubLibraryDialog from '../components/MoveToSubLibraryDialog';
+import TagSelectedDialog from '../components/TagSelectedDialog';
+import TagFilterPopover from '../components/TagFilterPopover';
import { useLibrarySearch } from '../hooks/useLibrarySearch.tsx';
import { useBulkAddQueue, type BulkAddEntry } from '../hooks/useBulkAddQueue.tsx';
import type { LibraryVideoMetadata } from '../../types';
@@ -68,6 +76,15 @@ type LibraryChannel = {
videos: LibraryVideo[];
};
+// Mirrors listLibraryTags' return shape (library.mjs) -- folderName is what
+// every IPC call actually keys on; tagName is presentational (today always
+// equal to folderName).
+type LibraryTag = {
+ tagName: string;
+ folderName: string;
+ createdEpoch: number | null;
+};
+
// Only the flat by-video list gets a sort control -- the channel view's own
// ordering is alphabetical-by-channel and isn't in scope here.
type SortField = 'title' | 'uploadDate' | 'dateAdded' | 'channel' | 'downloaded' | 'quality';
@@ -141,30 +158,93 @@ export default function LibraryScreen() {
const [bulkDeleteLocalFilesDialogOpen, setBulkDeleteLocalFilesDialogOpen] = useState(false);
const [bulkDeletingLocalFiles, setBulkDeletingLocalFiles] = useState(false);
const [bulkDeleteLocalFilesError, setBulkDeleteLocalFilesError] = useState(null);
+ const [moveDialogOpen, setMoveDialogOpen] = useState(false);
+ const [moving, setMoving] = useState(false);
+ const [moveError, setMoveError] = useState(null);
const [playlistBulkBar, setPlaylistBulkBar] = useState(null);
+ const [libraryTags, setLibraryTags] = useState([]);
+ const [activeLibraryTag, setActiveLibraryTagState] = useState('');
+ const [activeLibraryTagDir, setActiveLibraryTagDir] = useState('');
+ const [createTagDialogOpen, setCreateTagDialogOpen] = useState(false);
+ const [creatingTag, setCreatingTag] = useState(false);
+ const [createTagError, setCreateTagError] = useState(null);
+ // The active sublibrary's video-tag map (unrelated to libraryTags above,
+ // which is sublibrary switching) -- {} until load() finishes.
+ const [videoTags, setVideoTags] = useState>({});
+ const [tagDialogOpen, setTagDialogOpen] = useState(false);
+ const [tagging, setTagging] = useState(false);
+ const [tagError, setTagError] = useState(null);
const deepLinkMatch = useMatch('/library/video/:videoId');
+ const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { start } = useBulkAddQueue();
const load = async () => {
setLoading(true);
- const [{ libraryDir }, index, { libraryViewMode }, { thumbnailSize }] = await Promise.all([
+ const [{ libraryDir }, index, { libraryViewMode }, { thumbnailSize }, { tags }, { activeLibraryTag, activeLibraryTagDir }, { tags: videoTags }] = await Promise.all([
window.electronAPI.getLibraryDir(),
window.electronAPI.getLibraryIndex(),
window.electronAPI.getLibraryViewMode(),
window.electronAPI.getThumbnailSize(),
+ window.electronAPI.listLibraryTags(),
+ window.electronAPI.getActiveLibraryTag(),
+ window.electronAPI.listVideoTags(),
]);
setLibraryDir(libraryDir);
setChannels(index.channels);
setViewMode(libraryViewMode);
setThumbnailSize(thumbnailSize);
+ setLibraryTags(tags);
+ setActiveLibraryTagState(activeLibraryTag);
+ setActiveLibraryTagDir(activeLibraryTagDir);
+ setVideoTags(videoTags);
setLoading(false);
};
+ const refreshVideoTags = async () => {
+ const { tags } = await window.electronAPI.listVideoTags();
+ setVideoTags(tags);
+ };
+
useEffect(() => {
load();
}, []);
+ // Switching sublibraries resets everything the previous one's scan
+ // produced -- channels, any channel/video drill-down, and the current
+ // selection -- since none of it belongs to the newly-active sublibrary.
+ // Re-runs the same full load() rather than just refreshing the index, so
+ // the tag list/active tag/library dir all stay in sync too.
+ const switchLibraryTag = async (tag: string) => {
+ setSelectedChannel(null);
+ setSelectedVideo(null);
+ clearSelection();
+ await window.electronAPI.setActiveLibraryTag(tag);
+ await load();
+ };
+
+ const handleCreateLibraryTag = async (name: string) => {
+ setCreatingTag(true);
+ setCreateTagError(null);
+ try {
+ const result = await window.electronAPI.createLibraryTag(name);
+ if (!result.success) {
+ setCreateTagError(result.message || 'Could not create this sublibrary.');
+ return;
+ }
+ // createLibraryTag already switched the active tag server-side --
+ // just close the dialog and reload to pick it up, same as
+ // switchLibraryTag's own reset/reload.
+ setCreateTagDialogOpen(false);
+ setSelectedChannel(null);
+ setSelectedVideo(null);
+ clearSelection();
+ await load();
+ } finally {
+ setCreatingTag(false);
+ }
+ };
+
// Fire-and-forget, same as every other settings write in this codebase --
// the local state update below is what the UI reacts to; the write just
// needs to land before the next app launch reads it back.
@@ -225,6 +305,15 @@ export default function LibraryScreen() {
// gating style as Download selected), rather than silently skipping the
// ones with nothing to delete.
const canDeleteLocalFiles = selectedVideos.length > 0 && selectedVideos.every((v) => getBestDownloadedQuality(v.epochs) !== null);
+ // Only meaningful once another sublibrary actually exists -- there's
+ // nowhere else to move a video to otherwise. Every video qualifies
+ // regardless of download state (unlike canBulkDownload/canDeleteLocalFiles
+ // above), so this doesn't need to inspect selectedVideos at all.
+ const canMove = libraryTags.length > 1;
+ const moveTargetOptions = libraryTags.filter((tag) => tag.folderName !== activeLibraryTag);
+ // Unlike canMove, tagging only ever touches the one active sublibrary --
+ // no other-sublibrary-exists gate needed, just something selected.
+ const canTag = selectedVideoDirs.size > 0;
const handleConfirmBulkDownload = (targetResolution: string) => {
const isMp3 = targetResolution.toLowerCase() === 'mp3';
@@ -262,9 +351,12 @@ export default function LibraryScreen() {
}
// The index already refreshed server-side inside the IPC handler --
// this just pulls the updated channels list, same pattern as
- // refreshChannelsSilently.
+ // refreshChannelsSilently. deleteEntries also pruned any deleted
+ // videos out of the tag map server-side, in one batched write --
+ // refetch to pick that up too.
const index = await window.electronAPI.refreshLibraryIndex();
handleChannelsUpdated(index.channels);
+ await refreshVideoTags();
} finally {
setBulkDeleting(false);
}
@@ -290,6 +382,54 @@ export default function LibraryScreen() {
}
};
+ const handleConfirmMove = async (targetTag: string) => {
+ setMoving(true);
+ setMoveError(null);
+ try {
+ const { success, results } = await window.electronAPI.moveLibraryEntries([...selectedVideoDirs], targetTag);
+ if (!success) {
+ const failed = results.filter((r) => !r.success);
+ // Each result already carries the specific reason it failed (e.g. a
+ // video already existing at the target) -- surface it per-video
+ // rather than a generic count, so the user knows which ones and why.
+ const failedDetails = failed
+ .map((r) => `${videoByDir.get(r.videoDir)?.metadata.title ?? r.videoDir}${r.error ? `: ${r.error}` : ''}`)
+ .join('\n');
+ setMoveError(`${failed.length} of ${results.length} video(s) couldn't be moved:\n${failedDetails}`);
+ // Only the failed ones stay selected, so the user can immediately
+ // retry just those via the same bottom-bar button.
+ setSelectedVideoDirs(new Set(failed.map((r) => r.videoDir)));
+ } else {
+ setMoveDialogOpen(false);
+ clearSelection();
+ }
+ // The index already refreshed server-side inside the IPC handler --
+ // this just pulls the updated channels list, same pattern as
+ // refreshChannelsSilently. moveEntries also transferred the moved
+ // videos' tags to the target sublibrary's manifest server-side, in one
+ // batched write each -- refetch this sublibrary's map to pick up
+ // whatever got removed from it.
+ const index = await window.electronAPI.refreshLibraryIndex();
+ handleChannelsUpdated(index.channels);
+ await refreshVideoTags();
+ } finally {
+ setMoving(false);
+ }
+ };
+
+ const handleConfirmTag = async (tagName: string) => {
+ setTagging(true);
+ setTagError(null);
+ try {
+ await window.electronAPI.tagVideos(selectedVideos.map((v) => v.metadata.videoId), tagName);
+ await refreshVideoTags();
+ setTagDialogOpen(false);
+ clearSelection();
+ } finally {
+ setTagging(false);
+ }
+ };
+
const handleRefresh = async () => {
setLoading(true);
const index = await window.electronAPI.refreshLibraryIndex();
@@ -343,11 +483,29 @@ export default function LibraryScreen() {
// route), so this reads the param off the current location instead.
// Always navigates back to /library afterward (replace: true) so this is
// a one-shot jump, not a redirect that re-triggers on going back.
+ //
+ // An optional ?tag= query param names which sublibrary the video actually
+ // lives in (set by DownloaderScreen's "View" link when it added to a
+ // non-active sublibrary) -- without switching to it first, the lookup/
+ // refresh below (both scoped to whatever's currently active) would never
+ // find it. Omitted, this falls back to searching whatever's active, same
+ // as before this param existed.
const videoIdToOpen = deepLinkMatch?.params.videoId;
+ const libraryTagToOpen = searchParams.get('tag');
useEffect(() => {
if (!videoIdToOpen) return;
(async () => {
- const result = await window.electronAPI.findLibraryVideo(videoIdToOpen);
+ // Checked against the real current tag via IPC, not the local
+ // activeLibraryTag state -- this effect can fire before load()'s own
+ // fetch has resolved (both run on mount), so that state may still be
+ // its unset initial value here, wrongly triggering a switch.
+ if (libraryTagToOpen) {
+ const { activeLibraryTag: currentActiveTag } = await window.electronAPI.getActiveLibraryTag();
+ if (libraryTagToOpen !== currentActiveTag) {
+ await switchLibraryTag(libraryTagToOpen);
+ }
+ }
+ const result = await window.electronAPI.findLibraryVideo(videoIdToOpen, libraryTagToOpen || undefined);
if (!result.found || !result.videoDir) {
setDeepLinkError('This video is no longer in your library.');
navigate('/library', { replace: true });
@@ -416,6 +574,8 @@ export default function LibraryScreen() {
onLibraryChanged={refreshChannelsSilently}
onDeleted={handleVideoDeleted}
onVersionsChanged={handleVersionsChanged}
+ videoTags={videoTags}
+ onVideoTagsChanged={refreshVideoTags}
/>
) : selectedChannel ? (
{ setSelectedChannel(null); clearSelection(); }}
onSelectVideo={setSelectedVideo}
onChannelsUpdated={handleChannelsUpdated}
+ videoTags={videoTags}
/>
) : viewMode === 'video' ? (
) : (
{ setSelectedChannel(channel); clearSelection(); }}
@@ -464,22 +626,44 @@ export default function LibraryScreen() {
{/* Only shown at the root level -- hidden while drilled into a
channel's video grid or a video's own detail. */}
{!loading && libraryDir && !selectedVideo && !selectedChannel &&
- { if (value) setLibrarySection(value); clearSelection(); }}
- sx={{ mb: 2 }}
- >
-
-
- Videos
-
-
-
- Playlists
-
- }
+
+ { if (value) setLibrarySection(value); clearSelection(); }}
+ >
+
+
+ Videos
+
+
+
+ Playlists
+
+
+
+ {libraryTags.length > 1 &&
+
+ Sublibrary
+ switchLibraryTag(e.target.value)}
+ >
+ {libraryTags.map((tag) => (
+ {tag.tagName}
+ ))}
+
+ }
+
+ setCreateTagDialogOpen(true)} size="small" aria-label="Add new sublibrary">
+
+
+
+
+ }
{content}
{!loading && libraryDir && librarySection === 'videos' && !selectedVideo && (selectedChannel || viewMode === 'video') &&
@@ -493,6 +677,10 @@ export default function LibraryScreen() {
canDeleteLocalFiles={canDeleteLocalFiles}
onDeleteLocalFiles={() => setBulkDeleteLocalFilesDialogOpen(true)}
onDeleteFromLibrary={() => setBulkDeleteDialogOpen(true)}
+ canMove={canMove}
+ onMoveSelected={() => setMoveDialogOpen(true)}
+ canTag={canTag}
+ onTagSelected={() => setTagDialogOpen(true)}
/>}
{!loading && libraryDir && librarySection === 'playlists' && playlistBulkBar &&
}
+ { setCreateTagDialogOpen(false); setCreateTagError(null); }}
+ creating={creatingTag}
+ error={createTagError}
+ onConfirm={handleCreateLibraryTag}
+ />
setBulkDownloadDialogOpen(false)}
@@ -527,6 +722,24 @@ export default function LibraryScreen() {
onCancel={() => { setBulkDeleteLocalFilesDialogOpen(false); setBulkDeleteLocalFilesError(null); }}
onConfirm={handleConfirmBulkDeleteLocalFiles}
/>
+ { setMoveDialogOpen(false); setMoveError(null); }}
+ count={selectedVideoDirs.size}
+ options={moveTargetOptions}
+ moving={moving}
+ error={moveError}
+ onConfirm={handleConfirmMove}
+ />
+ { setTagDialogOpen(false); setTagError(null); }}
+ count={selectedVideoDirs.size}
+ options={Object.keys(videoTags)}
+ tagging={tagging}
+ error={tagError}
+ onConfirm={handleConfirmTag}
+ />
void;
channelLabel?: string;
selected: boolean;
selectionActive: boolean;
onToggleSelect: (videoDir: string) => void;
+ videoTags: Record;
}) {
const bestQuality = getBestDownloadedQuality(video.epochs);
+ const appliedTags = Object.keys(videoTags).filter((name) => videoTags[name].includes(video.metadata.videoId));
return (
{channelLabel &&
{channelLabel} }
+ {appliedTags.length > 0 &&
+
+ {appliedTags.map((tag) => (
+
+ ))}
+ }
{convertYYYYMMDDStringToDate(video.metadata.uploadDate || '') || video.metadata.uploadDate}
@@ -656,9 +877,9 @@ function VideoCard({ video, onSelect, channelLabel, selected, selectionActive, o
);
}
-function FlatVideoList({ channels, libraryDir, viewMode, thumbnailSize, selectedVideoDirs, onToggleSelect, onViewModeChange, onSelectVideo, onRefresh }: {
+function FlatVideoList({ channels, openFolderDir, viewMode, thumbnailSize, selectedVideoDirs, onToggleSelect, onViewModeChange, onSelectVideo, onRefresh, videoTags }: {
channels: LibraryChannel[];
- libraryDir: string;
+ openFolderDir: string;
viewMode: LibraryViewMode;
thumbnailSize: number;
selectedVideoDirs: Set;
@@ -666,6 +887,7 @@ function FlatVideoList({ channels, libraryDir, viewMode, thumbnailSize, selected
onViewModeChange: (mode: LibraryViewMode) => void;
onSelectVideo: (video: LibraryVideo) => void;
onRefresh: () => void;
+ videoTags: Record;
}) {
const selectionActive = selectedVideoDirs.size > 0;
const [sortField, setSortFieldState] = useState('title');
@@ -698,8 +920,29 @@ function FlatVideoList({ channels, libraryDir, viewMode, thumbnailSize, selected
entries.sort((a, b) => compareFlatVideos(a, b, sortField) * directionMultiplier);
return entries;
}, [channels, sortField, sortDirection]);
+
+ // Ephemeral, like search below -- resets on navigation/reload rather than
+ // persisting to settings the way sortField/sortDirection do, since this is
+ // a "narrow what I'm looking at right now" tool, not a standing
+ // preference. AND semantics (every selected tag, not just one): a video
+ // must carry all of them to match.
+ const [filterAnchorEl, setFilterAnchorEl] = useState(null);
+ const [selectedFilterTags, setSelectedFilterTags] = useState>(new Set());
+ const toggleFilterTag = (tag: string, checked: boolean) => {
+ setSelectedFilterTags((prev) => {
+ const next = new Set(prev);
+ if (checked) next.add(tag);
+ else next.delete(tag);
+ return next;
+ });
+ };
+ const tagFilteredVideos = useMemo(() => {
+ if (selectedFilterTags.size === 0) return flatVideos;
+ return flatVideos.filter(({ video }) => [...selectedFilterTags].every((tag) => videoTags[tag]?.includes(video.metadata.videoId)));
+ }, [flatVideos, selectedFilterTags, videoTags]);
+
const { query, setQuery, isSearching, filtered, clear } = useLibrarySearch(
- flatVideos,
+ tagFilteredVideos,
({ video }) => video.metadata.title || video.videoFolderName,
);
@@ -711,6 +954,18 @@ function FlatVideoList({ channels, libraryDir, viewMode, thumbnailSize, selected
+
+ setFilterAnchorEl(e.currentTarget)}
+ aria-label="Filter by tag"
+ color={selectedFilterTags.size > 0 ? 'primary' : 'default'}
+ >
+
+
+
+
+
{/* Grouped into one bordered container so the field picker and
direction toggle read as a single "sort" instrument -- Select
uses variant="standard" so this outer Paper is the only
@@ -741,7 +996,7 @@ function FlatVideoList({ channels, libraryDir, viewMode, thumbnailSize, selected
- window.electronAPI.openDirectory(libraryDir)} size="small" aria-label="Open library folder">
+ window.electronAPI.openDirectory(openFolderDir)} size="small" aria-label="Open library folder">
@@ -756,9 +1011,20 @@ function FlatVideoList({ channels, libraryDir, viewMode, thumbnailSize, selected
Nothing in the library yet -- use the library-add button next to the URL field on the Downloader tab.
- ) : isSearching && filtered.length === 0 && (
- No videos match "{query}".
+ ) : filtered.length === 0 && (
+
+ {isSearching ? `No videos match "${query}".` : 'No videos match the selected tag filter.'}
+
)}
+ setFilterAnchorEl(null)}
+ allTags={Object.keys(videoTags)}
+ selectedTags={selectedFilterTags}
+ onToggle={toggleFilterTag}
+ onClear={() => setSelectedFilterTags(new Set())}
+ />
{filtered.map(({ video, channelName }) => (
))}
@@ -776,9 +1043,9 @@ function FlatVideoList({ channels, libraryDir, viewMode, thumbnailSize, selected
);
}
-function ChannelList({ channels, libraryDir, viewMode, onViewModeChange, onSelectChannel, onRefresh }: {
+function ChannelList({ channels, openFolderDir, viewMode, onViewModeChange, onSelectChannel, onRefresh }: {
channels: LibraryChannel[];
- libraryDir: string;
+ openFolderDir: string;
viewMode: LibraryViewMode;
onViewModeChange: (mode: LibraryViewMode) => void;
onSelectChannel: (channel: LibraryChannel) => void;
@@ -794,7 +1061,7 @@ function ChannelList({ channels, libraryDir, viewMode, onViewModeChange, onSelec
- window.electronAPI.openDirectory(libraryDir)} size="small" aria-label="Open library folder">
+ window.electronAPI.openDirectory(openFolderDir)} size="small" aria-label="Open library folder">
@@ -837,7 +1104,7 @@ function ChannelList({ channels, libraryDir, viewMode, onViewModeChange, onSelec
);
}
-function VideoGrid({ channel, thumbnailSize, selectedVideoDirs, onToggleSelect, onBack, onSelectVideo, onChannelsUpdated }: {
+function VideoGrid({ channel, thumbnailSize, selectedVideoDirs, onToggleSelect, onBack, onSelectVideo, onChannelsUpdated, videoTags }: {
channel: LibraryChannel;
thumbnailSize: number;
selectedVideoDirs: Set;
@@ -845,6 +1112,7 @@ function VideoGrid({ channel, thumbnailSize, selectedVideoDirs, onToggleSelect,
onBack: () => void;
onSelectVideo: (video: LibraryVideo) => void;
onChannelsUpdated: (channels: LibraryChannel[]) => void;
+ videoTags: Record;
}) {
const selectionActive = selectedVideoDirs.size > 0;
const [refreshingIcon, setRefreshingIcon] = useState(false);
@@ -900,6 +1168,7 @@ function VideoGrid({ channel, thumbnailSize, selectedVideoDirs, onToggleSelect,
selected={selectedVideoDirs.has(video.videoDir)}
selectionActive={selectionActive}
onToggleSelect={onToggleSelect}
+ videoTags={videoTags}
/>
))}
diff --git a/src/ui/screens/LibraryVideoDetail.test.tsx b/src/ui/screens/LibraryVideoDetail.test.tsx
index 2e5b608..1f0ea1d 100644
--- a/src/ui/screens/LibraryVideoDetail.test.tsx
+++ b/src/ui/screens/LibraryVideoDetail.test.tsx
@@ -109,6 +109,11 @@ beforeEach(() => {
createClip: vi.fn().mockResolvedValue({ success: true, clip: { id: 'clip1', fileName: 'My Clip.mp4', title: 'My Clip', createdAt: 0, durationSeconds: 5 } }),
deleteClip: vi.fn().mockResolvedValue({ success: true }),
convertClip: vi.fn().mockResolvedValue({ success: true, clip: { id: 'clip1', fileName: 'My Clip.mkv', title: 'My Clip', createdAt: 0, durationSeconds: 5 } }),
+ // Defaults to "everything checked out fine" so the file-check effect
+ // (fired on mount/epoch-change whenever a downloaded path is set) is a
+ // no-op for every test that isn't specifically exercising it.
+ checkAndRepairEpochFiles: vi.fn().mockResolvedValue({ success: true, videoRepaired: false, audioRepaired: false, videoMissing: false, audioMissing: false }),
+ setVideoTag: vi.fn().mockResolvedValue({ success: true, tags: {} }),
};
window.electronAPIPythonDownload = {
startDownloadPython: vi.fn(),
@@ -118,11 +123,12 @@ beforeEach(() => {
} as unknown as typeof window.electronAPIPythonDownload;
});
-function renderDetail(video: ReturnType) {
+function renderDetail(video: ReturnType, videoTags: Record = {}) {
const onBack = vi.fn();
const onLibraryChanged = vi.fn().mockResolvedValue(undefined);
const onDeleted = vi.fn();
const onVersionsChanged = vi.fn().mockResolvedValue(undefined);
+ const onVideoTagsChanged = vi.fn().mockResolvedValue(undefined);
const utils = render(
) {
onLibraryChanged={onLibraryChanged}
onDeleted={onDeleted}
onVersionsChanged={onVersionsChanged}
+ videoTags={videoTags}
+ onVideoTagsChanged={onVideoTagsChanged}
/>,
);
- return { ...utils, onBack, onLibraryChanged, onDeleted, onVersionsChanged };
+ return { ...utils, onBack, onLibraryChanged, onDeleted, onVersionsChanged, onVideoTagsChanged };
}
describe('LibraryVideoDetail', () => {
@@ -481,4 +489,141 @@ describe('LibraryVideoDetail', () => {
await waitFor(() => expect(onVersionsChanged).toHaveBeenCalled());
expect(onDeleted).not.toHaveBeenCalled();
});
+
+ describe('on-demand file check (checkAndRepairEpochFiles)', () => {
+ it('checks the current epoch\'s files on mount when something is downloaded', async () => {
+ const video = makeVideo({ downloadedFilePath: '/v/video.mp4', downloadedAudioFilePath: '/v/audio.mp3' });
+ renderDetail(video);
+
+ await waitFor(() => expect(window.electronAPI.checkAndRepairEpochFiles).toHaveBeenCalledWith('/lib/Channel A/vidA', '100'));
+ });
+
+ it('never calls the check when nothing has been downloaded', async () => {
+ const video = makeVideo();
+ renderDetail(video);
+ await screen.findByText('Alpha Video');
+
+ expect(window.electronAPI.checkAndRepairEpochFiles).not.toHaveBeenCalled();
+ });
+
+ it('pops up a warning when the video file is missing and cannot be repaired', async () => {
+ (window.electronAPI.checkAndRepairEpochFiles as ReturnType).mockResolvedValue({
+ success: true, videoRepaired: false, audioRepaired: false, videoMissing: true, audioMissing: false,
+ });
+ const video = makeVideo({ downloadedFilePath: '/v/video.mp4' });
+ renderDetail(video);
+
+ expect(await screen.findByText('Video file not found')).toBeInTheDocument();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Dismiss' }));
+ await waitFor(() => expect(screen.queryByText('Video file not found')).not.toBeInTheDocument());
+ });
+
+ it('pops up a combined warning when both video and audio are missing', async () => {
+ (window.electronAPI.checkAndRepairEpochFiles as ReturnType).mockResolvedValue({
+ success: true, videoRepaired: false, audioRepaired: false, videoMissing: true, audioMissing: true,
+ });
+ const video = makeVideo({ downloadedFilePath: '/v/video.mp4', downloadedAudioFilePath: '/v/audio.mp3' });
+ renderDetail(video);
+
+ expect(await screen.findByText('Video and audio files not found')).toBeInTheDocument();
+ });
+
+ it('silently applies a repaired path and notifies onLibraryChanged, without any warning popup', async () => {
+ (window.electronAPI.checkAndRepairEpochFiles as ReturnType).mockResolvedValue({
+ success: true, videoRepaired: true, audioRepaired: false, videoMissing: false, audioMissing: false,
+ metadata: baseMetadata({ downloadedFilePath: '/lib/Channel A/vidA/100/video.mp4' }),
+ });
+ const video = makeVideo({ downloadedFilePath: '/v/old/video.mp4' });
+ const { onLibraryChanged } = renderDetail(video);
+
+ await waitFor(() => expect(onLibraryChanged).toHaveBeenCalled());
+ expect(screen.queryByText(/not found/)).not.toBeInTheDocument();
+ });
+
+ // The bug this covers: a file that was missing, then restored to the
+ // exact same stored path (so checkAndRepairEpochFiles' own repair never
+ // fires -- nothing about the path itself needed to change) never
+ // re-triggers a check on its own once the dialog is showing, since
+ // nothing about metadata/selectedEpoch/video.videoDir changes value
+ // either. Retry is the explicit way back from that; LibraryVideoPlayer
+ // itself is mocked out in this file (see the top-of-file comment), so
+ // what's covered here is the re-check firing and the dialog clearing --
+ // the player's own cacheBustKey-driven reload was verified live in the
+ // real app.
+ it('Retry re-runs the check and clears the warning once the file is confirmed present again', async () => {
+ const mockCheck = window.electronAPI.checkAndRepairEpochFiles as ReturnType;
+ mockCheck.mockResolvedValueOnce({
+ success: true, videoRepaired: false, audioRepaired: false, videoMissing: true, audioMissing: false,
+ });
+ const video = makeVideo({ downloadedFilePath: '/v/video.mp4' });
+ renderDetail(video);
+ await screen.findByText('Video file not found');
+
+ // Same stored path both times -- the file just became valid again,
+ // nothing for the repair itself to change.
+ mockCheck.mockResolvedValueOnce({
+ success: true, videoRepaired: false, audioRepaired: false, videoMissing: false, audioMissing: false,
+ });
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Retry' }));
+
+ expect(mockCheck).toHaveBeenCalledTimes(2);
+ await waitFor(() => expect(screen.queryByText('Video file not found')).not.toBeInTheDocument());
+ });
+ });
+
+ describe('video tags', () => {
+ it('renders a pink chip for every tag currently applied to this video', () => {
+ const video = makeVideo({ downloadedFilePath: '/v/video.mp4', downloadedResolution: '720' });
+ renderDetail(video, { TVshows: ['vid1'], games: ['someone-else'] });
+ expect(screen.getByText('TVshows')).toBeInTheDocument();
+ expect(screen.queryByText('games')).not.toBeInTheDocument();
+ });
+
+ it('the edit-tags popover lists every known tag as a checkbox, checked only for applied ones', async () => {
+ const video = makeVideo({ downloadedFilePath: '/v/video.mp4', downloadedResolution: '720' });
+ renderDetail(video, { TVshows: ['vid1'], games: ['someone-else'] });
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Edit tags' }));
+
+ const tvShowsCheckbox = screen.getByRole('checkbox', { name: 'TVshows' });
+ const gamesCheckbox = screen.getByRole('checkbox', { name: 'games' });
+ expect(tvShowsCheckbox).toBeChecked();
+ expect(gamesCheckbox).not.toBeChecked();
+ });
+
+ it('checking an unapplied tag calls setVideoTag with applied:true', async () => {
+ const video = makeVideo({ downloadedFilePath: '/v/video.mp4', downloadedResolution: '720' });
+ renderDetail(video, { games: ['someone-else'] });
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Edit tags' }));
+ await user.click(screen.getByRole('checkbox', { name: 'games' }));
+
+ expect(window.electronAPI.setVideoTag).toHaveBeenCalledWith('games', 'vid1', true);
+ });
+
+ it('unchecking an applied tag calls setVideoTag with applied:false', async () => {
+ const video = makeVideo({ downloadedFilePath: '/v/video.mp4', downloadedResolution: '720' });
+ renderDetail(video, { TVshows: ['vid1'] });
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Edit tags' }));
+ await user.click(screen.getByRole('checkbox', { name: 'TVshows' }));
+
+ expect(window.electronAPI.setVideoTag).toHaveBeenCalledWith('TVshows', 'vid1', false);
+ });
+
+ it('creating a new tag from the text field calls setVideoTag with applied:true and clears the field', async () => {
+ const video = makeVideo({ downloadedFilePath: '/v/video.mp4', downloadedResolution: '720' });
+ renderDetail(video);
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Edit tags' }));
+
+ const input = screen.getByPlaceholderText('New tag');
+ await user.type(input, 'brandNew{Enter}');
+
+ expect(window.electronAPI.setVideoTag).toHaveBeenCalledWith('brandNew', 'vid1', true);
+ expect(input).toHaveValue('');
+ });
+ });
});
diff --git a/src/ui/screens/LibraryVideoDetail.tsx b/src/ui/screens/LibraryVideoDetail.tsx
index 6e08de7..b176d86 100644
--- a/src/ui/screens/LibraryVideoDetail.tsx
+++ b/src/ui/screens/LibraryVideoDetail.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import {
Alert,
Box,
@@ -52,12 +52,17 @@ type LibraryVideo = {
// that change added -- "Refresh from YouTube" is what fixes it.
const CURRENT_VIDEO_SCHEMA_VERSION = 3;
-export default function LibraryVideoDetail({ video, onBack, onLibraryChanged, onDeleted, onVersionsChanged }: {
+export default function LibraryVideoDetail({ video, onBack, onLibraryChanged, onDeleted, onVersionsChanged, videoTags, onVideoTagsChanged }: {
video: LibraryVideo;
onBack: () => void;
onLibraryChanged: () => Promise | void;
onDeleted: () => void;
onVersionsChanged: () => Promise | void;
+ // The active sublibrary's whole tag map -- this video's own applied tags
+ // are derived from it below, rather than fetched separately, same shape
+ // LibraryScreen.tsx already loads for the bulk "Tag selected" dialog.
+ videoTags: Record;
+ onVideoTagsChanged: () => Promise | void;
}) {
const [selectedEpoch, setSelectedEpoch] = useState(video.latestEpoch);
const [metadata, setMetadata] = useState(video.metadata);
@@ -71,6 +76,18 @@ export default function LibraryVideoDetail({ video, onBack, onLibraryChanged, on
const [createVersionError, setCreateVersionError] = useState(null);
const [refreshingMetadata, setRefreshingMetadata] = useState(false);
const [refreshMetadataError, setRefreshMetadataError] = useState(null);
+ // Set by the checkAndRepairEpochFiles effect below when either downloaded
+ // file is missing and couldn't be repaired -- null means either nothing's
+ // downloaded, or everything checked out fine.
+ const [fileWarning, setFileWarning] = useState<{ video: boolean; audio: boolean } | null>(null);
+ // Mirrors fileWarning for runFileCheck below to read synchronously,
+ // without needing fileWarning itself as a dependency (which would mean
+ // re-defining/re-running the check effect every time it flips) and
+ // without a stale value from whatever render the Retry button's click
+ // handler closure was created in.
+ const fileWarningRef = useRef(fileWarning);
+ fileWarningRef.current = fileWarning;
+ const [checkingFiles, setCheckingFiles] = useState(false);
// 'initial' vs 'swap' decides which backend call the isDone effect below
// makes -- both flows reuse the same useDownloadVideo() instance below
// (startDownload resets isDone/isError/progress on every call, so reusing
@@ -82,10 +99,21 @@ export default function LibraryVideoDetail({ video, onBack, onLibraryChanged, on
// says which one owns the in-flight download; the UI disables the *other*
// target's controls while one is active.
const [downloadTarget, setDownloadTarget] = useState<'video' | 'audio'>('video');
- // Bumped after a successful quality swap and threaded into the player's
- // src URL -- a swap can land on the same file path+extension, and without
- // this the / element has no signal the bytes changed.
- const [cacheBustKey, setCacheBustKey] = useState(0);
+ // Bumped after a successful quality swap (or a file-check repair, below)
+ // and threaded into the player's src URL -- both can land on the same file
+ // path+extension, and without this the / element has no
+ // signal the bytes changed. Starts at Date.now() rather than a literal 0:
+ // reopening the *same* video (a fresh mount of this whole component, e.g.
+ // navigating back to the grid and back in) would otherwise always start
+ // back at the identical 0 again, producing the exact same src URL as the
+ // last time this video was viewed -- and some layer of Chromium's media
+ // pipeline for range-request video resources has proven not to reliably
+ // treat a same-URL reload as genuinely fresh even with the app-video://
+ // handler's own responses marked Cache-Control: no-store (main.mjs). A
+ // real per-mount value sidesteps needing to fully pin down which cache
+ // that is -- no caching layer keyed on the URL can ever have a stale entry
+ // for one it's never seen before.
+ const [cacheBustKey, setCacheBustKey] = useState(() => Date.now());
// FFMPEG utilities -- kept minimal: a single target-format choice for
// convert, plain start/end text fields for the clip trim, no scrubber.
@@ -167,6 +195,78 @@ export default function LibraryVideoDetail({ video, onBack, onLibraryChanged, on
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [video.epochs]);
+ // downloadedFilePath/downloadedAudioFilePath (metadata.json) are absolute
+ // paths captured once at download time and never recomputed -- if the
+ // library folder ever gets reorganized outside a deliberate move-aware
+ // flow (a user moving folders by hand, e.g. into DefaultLibrary/, or, once
+ // built, a "move between sublibraries" feature that doesn't yet rewrite
+ // these fields itself), the stored path silently goes stale and every
+ // consumer below (playback, open file location, every ffmpeg action) fails
+ // on it. Scoped to just the one epoch actually being viewed, not a
+ // library-wide scan -- checks and best-effort repairs it, and only
+ // surfaces fileWarning for whatever's still missing after that attempt.
+ //
+ // A plain function, not inlined in the effect below, because it also needs
+ // to be callable directly from the warning dialog's Retry button: once the
+ // stored path was already correct all along (nothing for the repair above
+ // to actually change) and the user restores the file to that same exact
+ // location, nothing about metadata/selectedEpoch/video.videoDir ever
+ // changes value -- so the effect's own dependencies never fire again, and
+ // without an explicit way to re-run this, the app would have no path back
+ // to noticing the file is there again short of navigating away and back
+ // (which happens to work only because that fully unmounts/remounts the
+ // player instead of anything actually re-checking).
+ const runFileCheck = async () => {
+ if (!selectedEpoch) return;
+ setCheckingFiles(true);
+ const result = await window.electronAPI.checkAndRepairEpochFiles(video.videoDir, selectedEpoch);
+ setCheckingFiles(false);
+ if (!result.success) return;
+ if (result.videoRepaired || result.audioRepaired) {
+ setMetadata((prev) => ({
+ ...prev,
+ ...(result.videoRepaired ? { downloadedFilePath: result.metadata!.downloadedFilePath } : {}),
+ ...(result.audioRepaired ? { downloadedAudioFilePath: result.metadata!.downloadedAudioFilePath } : {}),
+ }));
+ onLibraryChanged();
+ }
+ const stillMissing = result.videoMissing || result.audioMissing;
+ // fileWarning was showing and now isn't -- the player needs an explicit
+ // nudge here, not just the repaired metadata above: LibraryVideoPlayer's
+ // own recovery effect only retries when its filePath prop *string*
+ // actually changes, so the case where the exact same stored path simply
+ // became valid again (rather than the repair above finding a
+ // differently-named file and changing the stored path) would otherwise
+ // leave the player permanently stuck showing its earlier failure -- same
+ // "same path, new bytes" gap cacheBustKey already exists to solve for a
+ // quality swap landing on the same path+extension.
+ if (fileWarningRef.current && !stillMissing) {
+ setCacheBustKey((prev) => prev + 1);
+ }
+ setFileWarning(stillMissing ? { video: !!result.videoMissing, audio: !!result.audioMissing } : null);
+ };
+
+ // Depends on the metadata fields themselves (not just video.videoDir/
+ // selectedEpoch): those two identifiers can still be pointing at the
+ // *previous* video/epoch in the same render pass the two effects above
+ // update them in (state updates from an effect only land on the next
+ // render) -- keying on the fields this check actually reads guarantees it
+ // only ever runs once they're truly current, and naturally re-runs itself
+ // once more after its own repair (converging immediately, since the second
+ // pass finds the now-fixed path already on disk).
+ useEffect(() => {
+ if (!selectedEpoch || (!metadata.downloadedFilePath && !metadata.downloadedAudioFilePath)) {
+ setFileWarning(null);
+ return;
+ }
+ let cancelled = false;
+ (async () => {
+ if (!cancelled) await runFileCheck();
+ })();
+ return () => { cancelled = true; };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [video.videoDir, selectedEpoch, metadata.downloadedFilePath, metadata.downloadedAudioFilePath]);
+
const handleSelectEpoch = (epoch: string) => {
const found = video.epochs.find((e) => e.epoch === epoch);
if (!found) return;
@@ -722,6 +822,8 @@ export default function LibraryVideoDetail({ video, onBack, onLibraryChanged, on
)}
+ setFileWarning(null)}>
+
+ {fileWarning?.video && fileWarning?.audio ? 'Video and audio files not found'
+ : fileWarning?.audio ? 'Audio file not found' : 'Video file not found'}
+
+
+
+ The downloaded {fileWarning?.video && fileWarning?.audio ? 'video and audio files' : fileWarning?.audio ? 'audio file' : 'video file'} for
+ this version couldn't be found where this library entry expects {fileWarning?.video && fileWarning?.audio ? 'them' : 'it'} to be --
+ it may have been moved or deleted outside the app. Try re-downloading this version, or restore the file
+ to its original location yourself, then click Retry.
+
+
+
+ setFileWarning(null)} disabled={checkingFiles}>Dismiss
+
+ {checkingFiles ? : 'Retry'}
+
+
+
+
!deleting && setDeleteDialogOpen(false)}>
Delete this video?
diff --git a/src/ui/screens/VideoQualityDownload.tsx b/src/ui/screens/VideoQualityDownload.tsx
index d652141..2460b9b 100644
--- a/src/ui/screens/VideoQualityDownload.tsx
+++ b/src/ui/screens/VideoQualityDownload.tsx
@@ -1,3 +1,4 @@
+import { useState } from 'react';
import {
Box,
Button,
@@ -20,8 +21,11 @@ import CloudDownloadIcon from '@mui/icons-material/CloudDownload';
import DownloadDoneIcon from '@mui/icons-material/DownloadDone';
import AudiotrackIcon from '@mui/icons-material/Audiotrack';
import CancelIcon from '@mui/icons-material/Cancel';
+import LocalOfferOutlinedIcon from '@mui/icons-material/LocalOfferOutlined';
+import { pink } from '@mui/material/colors';
import { buildAppVideoUrl, formatEpochLabel } from '../../utils/utils.ts';
import LinearProgressWithLabel from '../components/LinearProgressWithLabel';
+import VideoTagsPopover from '../components/VideoTagsPopover';
import type { LibraryVideoMetadata, Resolution } from '../../types';
type LibraryVideo = {
@@ -153,6 +157,8 @@ export default function VideoQualityDownload({
onExtractAudioToLibrary,
isRetrying,
onCancelDownload,
+ videoTags,
+ onVideoTagsChanged,
}: {
video: LibraryVideo;
metadata: LibraryVideoMetadata;
@@ -192,7 +198,21 @@ export default function VideoQualityDownload({
// currently showing, since only one can be active at a time.
isRetrying: boolean;
onCancelDownload: () => void;
+ // The active sublibrary's whole tag map -- this video's own applied tags
+ // are derived from it below, same shape LibraryScreen.tsx already loads
+ // for the bulk "Tag selected" dialog and the library grid's own chips.
+ videoTags: Record;
+ onVideoTagsChanged: () => Promise | void;
}) {
+ const [tagsAnchorEl, setTagsAnchorEl] = useState(null);
+ const allTagNames = Object.keys(videoTags);
+ const appliedTagNames = allTagNames.filter((name) => videoTags[name].includes(metadata.videoId));
+
+ const handleToggleTag = async (tagName: string, applied: boolean) => {
+ await window.electronAPI.setVideoTag(tagName, metadata.videoId, applied);
+ await onVideoTagsChanged();
+ };
+
return (
<>
{(video.epochs.length > 1 || metadata.downloadedFilePath) &&
@@ -220,11 +240,22 @@ export default function VideoQualityDownload({
}
{metadata.downloadedFilePath &&
- }
+
+
+
+ {appliedTagNames.map((tag) => (
+
+ ))}
+
+ setTagsAnchorEl(e.currentTarget)} aria-label="Edit tags">
+
+
+
+
+ }
}
@@ -379,6 +410,15 @@ export default function VideoQualityDownload({
)}
>}
+ setTagsAnchorEl(null)}
+ allTags={allTagNames}
+ appliedTags={appliedTagNames}
+ onToggle={handleToggleTag}
+ onCreate={(tagName) => handleToggleTag(tagName, true)}
+ />
>
);
}
diff --git a/testing/mockData/electronAPIMocks.ts b/testing/mockData/electronAPIMocks.ts
index 3cec5df..7a865f6 100644
--- a/testing/mockData/electronAPIMocks.ts
+++ b/testing/mockData/electronAPIMocks.ts
@@ -57,6 +57,10 @@ export const electronAPIMock = {
deleteVideoInfoCacheEntry: async () => ({ success: true, existed: false }),
getLibraryDir: async () => ({ libraryDir: '' }),
setLibraryDir: async () => ({ success: true, libraryDir: '' }),
+ listLibraryTags: async () => ({ tags: [] }),
+ createLibraryTag: async () => ({ success: true, tag: { tagName: 'DefaultLibrary', folderName: 'DefaultLibrary', createdEpoch: 0 } }),
+ getActiveLibraryTag: async () => ({ activeLibraryTag: 'DefaultLibrary', activeLibraryTagDir: '' }),
+ setActiveLibraryTag: async () => ({ success: true, activeLibraryTag: 'DefaultLibrary' }),
getLibraryViewMode: async () => ({ libraryViewMode: 'channel' as const }),
setLibraryViewMode: async () => ({ success: true, libraryViewMode: 'channel' as const }),
getLibrarySort: async () => ({ sortField: 'title' as const, sortDirection: 'asc' as const }),