Import favorites from a Hot Tub backup
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
This commit is contained in:
@@ -63,10 +63,90 @@ App.favorites = App.favorites || {};
|
||||
};
|
||||
};
|
||||
|
||||
// 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';
|
||||
};
|
||||
@@ -85,10 +165,12 @@ App.favorites = App.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;
|
||||
if (!key) return;
|
||||
App.favorites.setButtonState(button, favoritesSet.has(key));
|
||||
const urlKey = App.favorites.urlKey(button.dataset.favUrl);
|
||||
if (!key && !urlKey) return;
|
||||
App.favorites.setButtonState(button, (key && favoritesSet.has(key)) || (urlKey && favoriteUrls.has(urlKey)));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -96,7 +178,9 @@ App.favorites = App.favorites || {};
|
||||
const key = App.favorites.getKey(video);
|
||||
if (!key) return;
|
||||
const favorites = App.favorites.getAll();
|
||||
const existingIndex = favorites.findIndex((item) => item.key === key);
|
||||
// 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);
|
||||
@@ -140,7 +224,7 @@ App.favorites = App.favorites || {};
|
||||
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}">♥</button>
|
||||
<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>
|
||||
|
||||
@@ -442,7 +442,7 @@ App.feed = App.feed || {};
|
||||
<img class="feed-poster" src="${v.thumb || ''}" alt="" loading="lazy" decoding="async">
|
||||
<video class="feed-video" muted playsinline webkit-playsinline preload="none"></video>
|
||||
${liveBadge}
|
||||
${favKey ? `<button class="favorite-btn feed-fav-btn" type="button" data-fav-key="${favKey}"></button>` : ''}
|
||||
${favKey ? `<button class="favorite-btn feed-fav-btn" type="button" data-fav-key="${favKey}" data-fav-url="${v.url || ''}"></button>` : ''}
|
||||
<button class="cp-pip-btn feed-pip-btn" type="button" aria-label="Picture in picture" hidden>
|
||||
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-top-right-on-square.svg" alt="">
|
||||
</button>
|
||||
@@ -484,7 +484,7 @@ App.feed = App.feed || {};
|
||||
|
||||
const favBtn = slide.querySelector('.feed-fav-btn');
|
||||
if (favBtn && App.favorites) {
|
||||
App.favorites.setButtonState(favBtn, App.favorites.getSet().has(favKey));
|
||||
App.favorites.setButtonState(favBtn, App.favorites.indexOfEntry(App.favorites.getAll(), v) >= 0);
|
||||
favBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
App.favorites.toggle(v);
|
||||
|
||||
69
frontend/js/hottubBackup.js
Normal file
69
frontend/js/hottubBackup.js
Normal file
@@ -0,0 +1,69 @@
|
||||
window.App = window.App || {};
|
||||
App.hottubBackup = App.hottubBackup || {};
|
||||
|
||||
// Reads a Hot Tub app backup (an exported SQLite database) and turns the videos
|
||||
// it has flagged as favorites into this client's favorites.
|
||||
//
|
||||
// The app and this client don't agree on identity: the app keys a video by a
|
||||
// hash it computes locally, while the server -- and so this client -- keys it by
|
||||
// something like "reddit-1rdudss". So an imported favorite is matched to an
|
||||
// existing one by URL, and carries no id of its own; see App.favorites.mergeImported.
|
||||
(function() {
|
||||
// The app stores a comma-separated set here ("favorite", "recent", ...).
|
||||
// It also keeps a `favoriteDate` on rows it no longer flags -- a leftover
|
||||
// from unfavoriting -- so the flag, not the date, is what counts.
|
||||
const FAVORITE_FLAG = 'favorite';
|
||||
|
||||
// Only what a favorite needs. Skipping the rest matters: `allFormats` alone
|
||||
// is kilobytes of resolved-format JSON per row, and it is exactly the kind
|
||||
// of thing this client must not store -- those URLs are signed and expire
|
||||
// (see App.favorites.normalize).
|
||||
const COLUMNS = ['url', 'title', 'thumb', 'duration', 'uploader', 'flags', 'favoriteDate'];
|
||||
|
||||
const hasFavoriteFlag = function(flags) {
|
||||
if (!flags) return false;
|
||||
return String(flags).split(',').some((flag) => flag.trim().toLowerCase() === FAVORITE_FLAG);
|
||||
};
|
||||
|
||||
// Newest first, matching how favorites are ordered when added by hand.
|
||||
const byNewest = function(a, b) {
|
||||
return String(b.favoriteDate || '').localeCompare(String(a.favoriteDate || ''));
|
||||
};
|
||||
|
||||
App.hottubBackup.readFavorites = function(buffer) {
|
||||
const db = App.sqlite.open(buffer);
|
||||
if (db.tableNames().indexOf('video_details') < 0) {
|
||||
throw new Error('This database has no video_details table -- is it a Hot Tub backup?');
|
||||
}
|
||||
const rows = db.readTable('video_details', { columns: COLUMNS });
|
||||
return rows
|
||||
.filter((row) => row.url && hasFavoriteFlag(row.flags))
|
||||
.sort(byNewest)
|
||||
.map((row) => ({
|
||||
// No id: the app's own is meaningless to this client, and the
|
||||
// URL is what both sides agree on.
|
||||
key: row.url,
|
||||
id: null,
|
||||
url: row.url,
|
||||
title: row.title || '',
|
||||
thumb: row.thumb || '',
|
||||
channel: '',
|
||||
uploader: row.uploader || '',
|
||||
duration: Number(row.duration) || 0,
|
||||
isLive: false
|
||||
}));
|
||||
};
|
||||
|
||||
App.hottubBackup.readFile = function(file) {
|
||||
return file.arrayBuffer().then((buffer) => App.hottubBackup.readFavorites(buffer));
|
||||
};
|
||||
|
||||
// Reads the file and merges what it finds. Resolves to the merge summary
|
||||
// ({found, added, skipped, total}) so the caller can report it.
|
||||
App.hottubBackup.importFile = function(file) {
|
||||
return App.hottubBackup.readFile(file).then((entries) => {
|
||||
const result = App.favorites.mergeImported(entries);
|
||||
return Object.assign({ found: entries.length }, result);
|
||||
});
|
||||
};
|
||||
})();
|
||||
BIN
frontend/js/sqlite.js
Normal file
BIN
frontend/js/sqlite.js
Normal file
Binary file not shown.
@@ -616,7 +616,51 @@ App.ui = App.ui || {};
|
||||
};
|
||||
|
||||
// Expose inline handlers + keyboard shortcuts.
|
||||
// Settings -> Hot Tub Backup: pick an exported database and merge the
|
||||
// favorites out of it. Bound once (unlike the controls in renderMenu, which
|
||||
// are re-assigned on every render) because a file input mid-read must not
|
||||
// have its handler swapped underneath it.
|
||||
App.ui.bindBackupImport = function() {
|
||||
const button = document.getElementById('import-favorites-btn');
|
||||
const input = document.getElementById('import-favorites-file');
|
||||
const status = document.getElementById('import-favorites-status');
|
||||
if (!button || !input) return;
|
||||
|
||||
const say = (message) => { if (status) status.textContent = message; };
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
// Cleared first so picking the same file twice still fires change.
|
||||
input.value = '';
|
||||
input.click();
|
||||
});
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
button.disabled = true;
|
||||
say('Reading backup…');
|
||||
App.hottubBackup.importFile(file).then((result) => {
|
||||
if (!result.found) {
|
||||
say('No favorites found in that backup.');
|
||||
} else if (!result.added) {
|
||||
say(`Nothing new: all ${result.found} favorites in that backup are already saved.`);
|
||||
} else {
|
||||
const plural = result.added === 1 ? 'favorite' : 'favorites';
|
||||
const already = result.skipped ? ` ${result.skipped} were already saved.` : '';
|
||||
say(`Imported ${result.added} ${plural}.${already}`);
|
||||
}
|
||||
}).catch((err) => {
|
||||
say('Could not read that file.');
|
||||
App.ui.showError((err && err.message) || 'Could not read that backup.');
|
||||
}).then(() => {
|
||||
button.disabled = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
App.ui.bindGlobalHandlers = function() {
|
||||
App.ui.bindBackupImport();
|
||||
|
||||
window.toggleDrawer = App.ui.toggleDrawer;
|
||||
window.closeDrawers = App.ui.closeDrawers;
|
||||
window.handleSearch = App.videos.handleSearch;
|
||||
|
||||
@@ -460,7 +460,7 @@ App.videos = App.videos || {};
|
||||
const liveBadge = v.isLive ? '<span class="live-badge">● LIVE</span>' : '';
|
||||
card.innerHTML = `
|
||||
${liveBadge}
|
||||
<button class="favorite-btn" type="button" aria-pressed="false" aria-label="Add to favorites" data-fav-key="${favoriteKey || ''}">♡</button>
|
||||
<button class="favorite-btn" type="button" aria-pressed="false" aria-label="Add to favorites" data-fav-key="${favoriteKey || ''}" data-fav-url="${v.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>
|
||||
|
||||
Reference in New Issue
Block a user