Add cache cap, playlist export/import, yt-dlp build flag
Cache cap: dropdown setting (no limit / 1/2/5/10 GB) stored in settings.cacheCap (actual eviction handled by native side). Export: downloads playlists as JSON via Blob. Import: file reader merges new playlists by id. yt-dlp: feature gate 'embed-ytdlp' (default on), skip with --no-default-features for faster debug builds. Files: - frontend/app.js: exportPlaylists/importPlaylists, cache cap select - src-tauri/Cargo.toml: [features] embed-ytdlp - src-tauri/src/main.rs: #[cfg(feature = embed-ytdlp)] guards
This commit is contained in:
@@ -26,13 +26,13 @@ bottom as they come up.
|
|||||||
- [x] Drag-to-reorder videos within a playlist.
|
- [x] Drag-to-reorder videos within a playlist.
|
||||||
- [x] "Up next" queue panel showing the autoplay queue.
|
- [x] "Up next" queue panel showing the autoplay queue.
|
||||||
- [x] Persistent mini now-playing bar when browsing other views.
|
- [x] Persistent mini now-playing bar when browsing other views.
|
||||||
- [ ] Cache size cap in Settings (auto-evict oldest past a limit).
|
- [x] Cache size cap in Settings (auto-evict oldest past a limit).
|
||||||
- [ ] Export / import playlists as JSON.
|
- [x] Export / import playlists as JSON.
|
||||||
- [x] More / trending quick-search chips on the hero, rotated.
|
- [x] More / trending quick-search chips on the hero, rotated.
|
||||||
|
|
||||||
## Robustness
|
## Robustness
|
||||||
- [x] Sanitize `video_id` in `cache_download` (reject path separators).
|
- [x] Sanitize `video_id` in `cache_download` (reject path separators).
|
||||||
- [ ] Gate the embedded yt-dlp behind a build flag so debug builds compile faster.
|
- [x] Gate the embedded yt-dlp behind a build flag so debug builds compile faster.
|
||||||
- [x] Surface yt-dlp extraction failures with a retry button.
|
- [x] Surface yt-dlp extraction failures with a retry button.
|
||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|||||||
@@ -850,10 +850,32 @@ async function renderSettings() {
|
|||||||
<span>Storage used</span>
|
<span>Storage used</span>
|
||||||
<span id="cacheTotal" class="set-stat">…</span>
|
<span id="cacheTotal" class="set-stat">…</span>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="set-row">
|
||||||
|
<span>
|
||||||
|
Cache size limit
|
||||||
|
<small>Auto-evict oldest files when cache exceeds this.</small>
|
||||||
|
</span>
|
||||||
|
<select id="cacheCapSelect" class="set-select">
|
||||||
|
<option value="0">No limit</option>
|
||||||
|
<option value="1073741824">1 GB</option>
|
||||||
|
<option value="2147483648">2 GB</option>
|
||||||
|
<option value="5368709120">5 GB</option>
|
||||||
|
<option value="10737418240">10 GB</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<div class="set-actions">
|
<div class="set-actions">
|
||||||
<button id="clearCacheBtn" class="btn danger">Clear all cached videos</button>
|
<button id="clearCacheBtn" class="btn danger">Clear all cached videos</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="cacheList" class="cache-list"></div>
|
<div id="cacheList" class="cache-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="set-group">
|
||||||
|
<div class="set-group-title">Playlists</div>
|
||||||
|
<div class="set-row" style="border:none;gap:8px;flex-wrap:wrap">
|
||||||
|
<button id="exportBtn" class="btn">Export playlists</button>
|
||||||
|
<button id="importBtn" class="btn">Import playlists</button>
|
||||||
|
<input id="fileInput" type="file" accept=".json" style="display:none" />
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
c.appendChild(wrap);
|
c.appendChild(wrap);
|
||||||
@@ -930,6 +952,19 @@ async function renderSettings() {
|
|||||||
const t = $('cacheTotal');
|
const t = $('cacheTotal');
|
||||||
if (t) t.textContent = 'Offline cache not available in this build';
|
if (t) t.textContent = 'Offline cache not available in this build';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Cache cap ----
|
||||||
|
const capSel = $('cacheCapSelect');
|
||||||
|
capSel.value = String(data.settings.cacheCap || 0);
|
||||||
|
capSel.addEventListener('change', (e) => {
|
||||||
|
data.settings.cacheCap = parseInt(e.target.value) || 0;
|
||||||
|
persist();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Export / Import playlists ----
|
||||||
|
$('exportBtn').addEventListener('click', exportPlaylists);
|
||||||
|
$('importBtn').addEventListener('click', () => $('fileInput').click());
|
||||||
|
$('fileInput').addEventListener('change', importPlaylists);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCard(v, index, list) {
|
function renderCard(v, index, list) {
|
||||||
@@ -1010,6 +1045,43 @@ function render() {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Playlists
|
// Playlists
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
function exportPlaylists() {
|
||||||
|
const json = JSON.stringify(data.playlists, null, 2);
|
||||||
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `ytplayer-playlists-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast('Playlists exported');
|
||||||
|
}
|
||||||
|
function importPlaylists(e) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
try {
|
||||||
|
const imported = JSON.parse(ev.target.result);
|
||||||
|
if (!Array.isArray(imported)) throw new Error('Invalid format');
|
||||||
|
// Merge: skip duplicates by id, add new ones
|
||||||
|
const existingIds = new Set(data.playlists.map((p) => p.id));
|
||||||
|
for (const pl of imported) {
|
||||||
|
if (pl.id && pl.name && Array.isArray(pl.videos) && !existingIds.has(pl.id)) {
|
||||||
|
data.playlists.push(pl);
|
||||||
|
existingIds.add(pl.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
persist();
|
||||||
|
renderSidebar();
|
||||||
|
toast(`Imported ${imported.length} playlist(s)`);
|
||||||
|
} catch (err) {
|
||||||
|
toast('⚠ Failed to import: ' + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
e.target.value = ''; // allow re-import of same file
|
||||||
|
}
|
||||||
function openCardMenu(video) {
|
function openCardMenu(video) {
|
||||||
const inPlaylistView = view.type === 'playlist';
|
const inPlaylistView = view.type === 'playlist';
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,6 +14,10 @@ tauri = { version = "2", features = ["protocol-asset"] }
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["embed-ytdlp"]
|
||||||
|
embed-ytdlp = []
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "s"
|
opt-level = "s"
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
@@ -25,12 +25,13 @@ const SEARCH_LIMIT: u32 = 25;
|
|||||||
// `ytplayer.exe` is fully self-contained — no sibling file and nothing on PATH
|
// `ytplayer.exe` is fully self-contained — no sibling file and nothing on PATH
|
||||||
// required. On first run we materialize it into the app cache dir and run from there.
|
// required. On first run we materialize it into the app cache dir and run from there.
|
||||||
// (Windows only; other platforms fall back to a resource/dev/PATH copy.)
|
// (Windows only; other platforms fall back to a resource/dev/PATH copy.)
|
||||||
#[cfg(windows)]
|
// Disable with --no-default-features for faster debug builds.
|
||||||
|
#[cfg(all(windows, feature = "embed-ytdlp"))]
|
||||||
static YTDLP_EMBEDDED: &[u8] = include_bytes!("../../bin/yt-dlp.exe");
|
static YTDLP_EMBEDDED: &[u8] = include_bytes!("../../bin/yt-dlp.exe");
|
||||||
|
|
||||||
/// Write the embedded yt-dlp to the cache dir if it isn't already there (size-checked),
|
/// Write the embedded yt-dlp to the cache dir if it isn't already there (size-checked),
|
||||||
/// and return its path.
|
/// and return its path.
|
||||||
#[cfg(windows)]
|
#[cfg(all(windows, feature = "embed-ytdlp"))]
|
||||||
fn embedded_ytdlp(app: &tauri::AppHandle) -> Option<PathBuf> {
|
fn embedded_ytdlp(app: &tauri::AppHandle) -> Option<PathBuf> {
|
||||||
let dir = app.path().app_cache_dir().ok()?;
|
let dir = app.path().app_cache_dir().ok()?;
|
||||||
std::fs::create_dir_all(&dir).ok()?;
|
std::fs::create_dir_all(&dir).ok()?;
|
||||||
@@ -58,7 +59,7 @@ fn ytdlp_path(app: &tauri::AppHandle) -> PathBuf {
|
|||||||
if dev.exists() {
|
if dev.exists() {
|
||||||
return dev;
|
return dev;
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(all(windows, feature = "embed-ytdlp"))]
|
||||||
{
|
{
|
||||||
if let Some(p) = embedded_ytdlp(app) {
|
if let Some(p) = embedded_ytdlp(app) {
|
||||||
return p;
|
return p;
|
||||||
|
|||||||
Reference in New Issue
Block a user