Settings gains a file picker that reads an exported Hot Tub database and merges its favorites into this client's. The file never leaves the device: sqlite.js is a small read-only reader -- header, schema, table b-trees, record decoding, and the overflow pages that real rows here spill onto -- which is all it takes to walk one table, and avoids putting a wasm SQLite behind a CDN fetch. The two sides don't agree on what identifies a video. The app keys one by a hash it computes locally (a 64-hex string); the server, and so this client, keys it as something like "reddit-1rdudss". So the merge matches on normalized URL: entries already saved here are left exactly as they are, keeping the server id that makes a listing card's heart light up, and only genuinely new videos are appended. That means an imported favorite has no server id, so hearts now also match by URL (`data-fav-url` on the card, feed slide and favorites bar). Without it an imported favorite would look unsaved on its own card, and clicking the heart would file a second copy of the same video. Only the columns a favorite needs are read. `allFormats` is deliberately left behind: it holds resolved, signed URLs, which is exactly what favorites must not store (they expire -- see App.favorites.normalize). Tests (scratchpad): the reader checked against Python's sqlite3 on a real 12MB backup -- table list, every table's row count, all 515 favorites with their fields and order, and the 25 longest records byte-for-byte, which is where a wrong overflow split shows up; and the Settings control driven end-to-end, covering the merge, an existing favorite keeping its id, a re-import adding nothing, and a listing card recognising an imported favorite and unfavoriting it cleanly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
308 lines
14 KiB
JavaScript
308 lines
14 KiB
JavaScript
window.App = window.App || {};
|
|
App.favorites = App.favorites || {};
|
|
|
|
(function() {
|
|
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants;
|
|
|
|
// Favorites storage helpers.
|
|
App.favorites.getAll = function() {
|
|
try {
|
|
const raw = localStorage.getItem(FAVORITES_KEY);
|
|
const parsed = raw ? JSON.parse(raw) : [];
|
|
if (!Array.isArray(parsed)) return [];
|
|
// Favorites saved by older versions carry a `meta` blob of resolved
|
|
// formats whose URLs are signed and long expired. Drop it on the way
|
|
// in so no code path can reach for one; everything re-resolves from
|
|
// `url` at play time, and normalize() no longer stores it.
|
|
return parsed.map((item) => {
|
|
if (item && typeof item === 'object' && item.meta) {
|
|
const clean = Object.assign({}, item);
|
|
delete clean.meta;
|
|
return clean;
|
|
}
|
|
return item;
|
|
});
|
|
} catch (err) {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
App.favorites.setAll = function(items) {
|
|
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items));
|
|
};
|
|
|
|
App.favorites.getKey = function(video) {
|
|
if (!video) return null;
|
|
const meta = video.meta || video;
|
|
return video.key || meta.key || video.id || meta.id || video.url || meta.url || null;
|
|
};
|
|
|
|
App.favorites.normalize = function(video) {
|
|
const key = App.favorites.getKey(video);
|
|
if (!key) return null;
|
|
const meta = video && video.meta ? video.meta : video;
|
|
return {
|
|
key,
|
|
id: video.id || null,
|
|
// The page/source URL (e.g. the YouTube watch URL), not a resolved
|
|
// CDN media URL -- those expire, so favorites must always re-resolve
|
|
// via the server at play time instead of caching a stream link.
|
|
url: video.url || (meta && meta.url) || '',
|
|
title: video.title || '',
|
|
thumb: video.thumb || '',
|
|
channel: video.channel || (meta && meta.channel) || '',
|
|
uploader: video.uploader || (meta && meta.uploader) || '',
|
|
duration: video.duration || (meta && meta.duration) || 0,
|
|
isLive: !!(video.isLive || (meta && meta.isLive))
|
|
// No `meta` field: persisting resolved formats would freeze their
|
|
// (expiring) CDN URLs into localStorage. Leaving it unset makes a
|
|
// favorite look like a fresh, unresolved listing item again, so
|
|
// playback/download/info all re-resolve through the backend from
|
|
// `url` -- see resolveStreamSources' no-formats fallback, which the
|
|
// backend resolves live via yt-dlp (main.py stream_video).
|
|
};
|
|
};
|
|
|
|
// Identity across sources. Favorites added here are keyed by the server's
|
|
// id; ones imported from a Hot Tub backup can only be keyed by URL (the app
|
|
// keys videos by a hash of its own). Comparing normalized URLs is what
|
|
// stops the same video being listed twice under two different keys.
|
|
App.favorites.urlKey = function(url) {
|
|
const raw = String(url || '').trim();
|
|
if (!raw) return '';
|
|
try {
|
|
const parsed = new URL(raw, window.location.href);
|
|
const host = parsed.host.replace(/^www\./i, '').toLowerCase();
|
|
const path = parsed.pathname.replace(/\/+$/, '');
|
|
return `${host}${path}${parsed.search}`;
|
|
} catch (err) {
|
|
return raw.toLowerCase();
|
|
}
|
|
};
|
|
|
|
// Adds favorites from an import, skipping any this client already has.
|
|
// Existing entries are left exactly as they are -- they carry the server id
|
|
// that makes a listing card's heart light up, which an imported entry has
|
|
// no way to know -- and new ones are appended after them.
|
|
App.favorites.mergeImported = function(entries) {
|
|
const incoming = Array.isArray(entries) ? entries : [];
|
|
const favorites = App.favorites.getAll();
|
|
const keys = new Set();
|
|
const urls = new Set();
|
|
favorites.forEach((item) => {
|
|
if (!item) return;
|
|
if (item.key) keys.add(item.key);
|
|
const urlKey = App.favorites.urlKey(item.url);
|
|
if (urlKey) urls.add(urlKey);
|
|
});
|
|
|
|
let added = 0;
|
|
let skipped = 0;
|
|
incoming.forEach((entry) => {
|
|
if (!entry || !entry.key) return;
|
|
const urlKey = App.favorites.urlKey(entry.url);
|
|
if (keys.has(entry.key) || (urlKey && urls.has(urlKey))) {
|
|
skipped++;
|
|
return;
|
|
}
|
|
keys.add(entry.key);
|
|
if (urlKey) urls.add(urlKey);
|
|
favorites.push(entry);
|
|
added++;
|
|
});
|
|
|
|
if (added) {
|
|
App.favorites.setAll(favorites);
|
|
App.favorites.renderBar();
|
|
App.favorites.syncButtons();
|
|
}
|
|
return { added, skipped, total: favorites.length };
|
|
};
|
|
|
|
App.favorites.getSet = function() {
|
|
return new Set(App.favorites.getAll().map((item) => item.key));
|
|
};
|
|
|
|
// Same set, addressed by URL. Imported favorites are keyed by URL rather
|
|
// than by a server id, so a listing card can only recognise one this way.
|
|
App.favorites.getUrlSet = function() {
|
|
const urls = new Set();
|
|
App.favorites.getAll().forEach((item) => {
|
|
const urlKey = item && App.favorites.urlKey(item.url);
|
|
if (urlKey) urls.add(urlKey);
|
|
});
|
|
return urls;
|
|
};
|
|
|
|
// Is this video already a favorite, whichever way it got saved? Checked by
|
|
// key first, then by URL, so a card and an imported entry for the same
|
|
// video are recognised as one thing.
|
|
App.favorites.indexOfEntry = function(favorites, video) {
|
|
const key = App.favorites.getKey(video);
|
|
const byKey = key ? favorites.findIndex((item) => item && item.key === key) : -1;
|
|
if (byKey >= 0) return byKey;
|
|
const meta = (video && video.meta) || video || {};
|
|
const urlKey = App.favorites.urlKey(video && (video.url || meta.url));
|
|
if (!urlKey) return -1;
|
|
return favorites.findIndex((item) => item && App.favorites.urlKey(item.url) === urlKey);
|
|
};
|
|
|
|
App.favorites.isVisible = function() {
|
|
return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false';
|
|
};
|
|
|
|
App.favorites.setVisible = function(isVisible) {
|
|
localStorage.setItem(FAVORITES_VISIBILITY_KEY, isVisible ? 'true' : 'false');
|
|
};
|
|
|
|
// UI helpers for rendering and syncing heart states.
|
|
App.favorites.setButtonState = function(button, isFavorite) {
|
|
button.classList.toggle('is-favorite', isFavorite);
|
|
button.textContent = isFavorite ? '♥' : '♡';
|
|
button.setAttribute('aria-pressed', isFavorite ? 'true' : 'false');
|
|
button.setAttribute('aria-label', isFavorite ? 'Remove from favorites' : 'Add to favorites');
|
|
};
|
|
|
|
App.favorites.syncButtons = function() {
|
|
const favoritesSet = App.favorites.getSet();
|
|
const favoriteUrls = App.favorites.getUrlSet();
|
|
document.querySelectorAll('.favorite-btn[data-fav-key]').forEach((button) => {
|
|
const key = button.dataset.favKey;
|
|
const urlKey = App.favorites.urlKey(button.dataset.favUrl);
|
|
if (!key && !urlKey) return;
|
|
App.favorites.setButtonState(button, (key && favoritesSet.has(key)) || (urlKey && favoriteUrls.has(urlKey)));
|
|
});
|
|
};
|
|
|
|
App.favorites.toggle = function(video) {
|
|
const key = App.favorites.getKey(video);
|
|
if (!key) return;
|
|
const favorites = App.favorites.getAll();
|
|
// By key or by URL: unfavoriting a card whose video came in from a
|
|
// backup must remove that entry, not add a second one beside it.
|
|
const existingIndex = App.favorites.indexOfEntry(favorites, video);
|
|
const becameFavorite = existingIndex < 0;
|
|
if (existingIndex >= 0) {
|
|
favorites.splice(existingIndex, 1);
|
|
} else {
|
|
const entry = App.favorites.normalize(video);
|
|
if (entry) favorites.unshift(entry);
|
|
}
|
|
App.favorites.setAll(favorites);
|
|
App.favorites.renderBar();
|
|
App.favorites.syncButtons();
|
|
// Celebrate an add with a brass pop + ring on every button for this key.
|
|
if (becameFavorite) {
|
|
document.querySelectorAll(`.favorite-btn[data-fav-key="${(window.CSS && CSS.escape) ? CSS.escape(key) : key}"]`).forEach((btn) => {
|
|
btn.classList.remove('just-favorited');
|
|
void btn.offsetWidth; // restart the animation
|
|
btn.classList.add('just-favorited');
|
|
btn.addEventListener('animationend', () => btn.classList.remove('just-favorited'), { once: true });
|
|
});
|
|
}
|
|
};
|
|
|
|
App.favorites.renderBar = function() {
|
|
const bar = document.getElementById('favorites-bar');
|
|
const list = document.getElementById('favorites-list');
|
|
const empty = document.getElementById('favorites-empty');
|
|
if (!bar || !list) return;
|
|
|
|
const favorites = App.favorites.getAll();
|
|
const visible = App.favorites.isVisible();
|
|
bar.style.display = visible ? 'block' : 'none';
|
|
|
|
list.innerHTML = "";
|
|
favorites.forEach((item) => {
|
|
const card = document.createElement('div');
|
|
card.className = 'favorite-card';
|
|
card.dataset.favKey = item.key;
|
|
const uploaderText = item.uploader || '';
|
|
const durationText = (!item.isLive && App.videos && typeof App.videos.formatDuration === 'function')
|
|
? App.videos.formatDuration(item.duration)
|
|
: '';
|
|
const liveBadge = item.isLive ? '<span class="live-badge">● LIVE</span>' : '';
|
|
card.innerHTML = `
|
|
${liveBadge}
|
|
<button class="favorite-btn is-favorite" type="button" aria-pressed="true" aria-label="Remove from favorites" data-fav-key="${item.key}" data-fav-url="${item.url || ''}">♥</button>
|
|
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
|
|
<div class="video-menu" role="menu">
|
|
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
|
|
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
|
</div>
|
|
<div class="video-thumb">
|
|
<img src="${item.thumb}" alt="${item.title}" loading="lazy" decoding="async">
|
|
<div class="video-loading" aria-hidden="true">
|
|
<div class="video-loading-spinner"></div>
|
|
</div>
|
|
${uploaderText ? `<button class="video-uploader uploader-link" type="button" data-uploader="${uploaderText}">${uploaderText}</button>` : ''}
|
|
${durationText ? `<span class="video-duration">${durationText}</span>` : ''}
|
|
</div>
|
|
<div class="favorite-info">
|
|
<h4>${item.title}</h4>
|
|
</div>
|
|
`;
|
|
const thumb = card.querySelector('img');
|
|
if (App.videos && typeof App.videos.attachNoReferrerRetry === 'function') {
|
|
App.videos.attachNoReferrerRetry(thumb);
|
|
}
|
|
card.onclick = () => {
|
|
if (card.classList.contains('is-loading')) return;
|
|
card.classList.add('is-loading');
|
|
// Ignore any stale `meta` a favorite saved before this fix may
|
|
// still carry in localStorage -- always re-resolve from `item`
|
|
// (id/url) so playback never reuses an expired stream URL.
|
|
App.player.open(item, { originEl: card });
|
|
};
|
|
const favoriteBtn = card.querySelector('.favorite-btn');
|
|
if (favoriteBtn) {
|
|
favoriteBtn.onclick = (event) => {
|
|
event.stopPropagation();
|
|
App.favorites.toggle(item);
|
|
};
|
|
}
|
|
const menuBtn = card.querySelector('.video-menu-btn');
|
|
const menu = card.querySelector('.video-menu');
|
|
const showInfoBtn = card.querySelector('.video-menu-item[data-action="info"]');
|
|
const downloadBtn = card.querySelector('.video-menu-item[data-action="download"]');
|
|
if (menuBtn && menu) {
|
|
menuBtn.onclick = (event) => {
|
|
event.stopPropagation();
|
|
App.videos.toggleMenu(menu, menuBtn);
|
|
};
|
|
}
|
|
if (showInfoBtn) {
|
|
showInfoBtn.onclick = (event) => {
|
|
event.stopPropagation();
|
|
App.videos.closeAllMenus();
|
|
// Favorites deliberately store no resolved metadata, so pull
|
|
// it fresh before showing the full info dump.
|
|
App.videos.ensureFormats(item).then(() => App.ui.showInfo(item));
|
|
};
|
|
}
|
|
if (downloadBtn) {
|
|
downloadBtn.onclick = (event) => {
|
|
event.stopPropagation();
|
|
App.videos.closeAllMenus();
|
|
// Same as playback: resolve a real media URL first rather
|
|
// than pointing the download at the page URL.
|
|
App.videos.ensureFormats(item).then(() => App.videos.downloadVideo(item));
|
|
};
|
|
}
|
|
const uploaderBtn = card.querySelector('.uploader-link');
|
|
if (uploaderBtn) {
|
|
uploaderBtn.onclick = (event) => {
|
|
event.stopPropagation();
|
|
const uploader = uploaderBtn.dataset.uploader || uploaderBtn.textContent || '';
|
|
App.videos.handleSearch(uploader);
|
|
};
|
|
}
|
|
list.appendChild(card);
|
|
});
|
|
|
|
if (empty) {
|
|
empty.style.display = favorites.length > 0 ? 'none' : 'block';
|
|
}
|
|
};
|
|
})();
|