Files
jacuzzi/frontend/js/storage.js
Simon 2fcc17877d Let saved searches from different sources be read as one
A saved search is one source, one channel on it, that channel's filters
and the words. Two of them on different sources had no way to be read
together, and a channel group is not it: every channel in a group belongs
to the same server and shares one query, where the interesting pair is
"uncensored JAV over here" and "amateur 1080p over there".

So a saved search can now hold several of those instead of one. Opening it
feeds a single grid from all of them, taken alternately -- one from each in
turn, the way a group zips its channels -- rather than one source's page
and then the next. Each member keeps its own source, channel, filters and
words, because having its own words is most of why it was worth saving
separately.

Every entry is read as a list of members, a plain search being a merge of
one. That is what lets the daily check, mark-all-read and the bookmark go
on working without learning what a merge is: they ask each member and zip
the answers, so the badge counts what the grid would have shown.

The search box is turned off while a merge is open and says why -- there
is nothing for one box to say for several members. A tag or an uploader
tapped on a card steps out of the merge onto one real channel instead,
since otherwise it would reload the same videos and look broken.

A merge answers to no single server, so it is not stored as a channel
preference and is not looked up from a server's status: its members ride
on the session, and a reload rebuilds it from the saved search so a rename
is picked up rather than frozen in. One source failing drops that member
and leaves the rest readable. Merging a merge flattens it, so "the
searches in it" means the same however it was assembled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-11-01 07:59:12 +00:00

517 lines
23 KiB
JavaScript

window.App = window.App || {};
App.storage = App.storage || {};
App.session = App.session || {};
(function() {
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY, PREFERRED_QUALITY_KEY, FEED_END_BEHAVIOR_KEY } = App.constants;
// Basic localStorage helpers.
App.storage.getConfig = function() {
return JSON.parse(localStorage.getItem('config')) || { servers: [] };
};
App.storage.setConfig = function(nextConfig) {
localStorage.setItem('config', JSON.stringify(nextConfig));
};
App.storage.getSession = function() {
return JSON.parse(localStorage.getItem('session')) || null;
};
App.storage.setSession = function(nextSession) {
localStorage.setItem('session', JSON.stringify(nextSession));
};
App.storage.getPreferences = function() {
return JSON.parse(localStorage.getItem('preferences')) || {};
};
App.storage.setPreferences = function(nextPreferences) {
localStorage.setItem('preferences', JSON.stringify(nextPreferences));
};
App.storage.getPreferredQuality = function() {
return localStorage.getItem(PREFERRED_QUALITY_KEY) || '1080';
};
App.storage.setPreferredQuality = function(nextQuality) {
localStorage.setItem(PREFERRED_QUALITY_KEY, nextQuality);
};
// Reels/TikTok mode behavior when a video reaches its end: 'loop' replays
// the same clip; 'scroll' advances to the next video. Defaults to 'loop'.
App.storage.getFeedEndBehavior = function() {
return localStorage.getItem(FEED_END_BEHAVIOR_KEY) === 'scroll' ? 'scroll' : 'loop';
};
App.storage.setFeedEndBehavior = function(nextBehavior) {
localStorage.setItem(FEED_END_BEHAVIOR_KEY, nextBehavior === 'scroll' ? 'scroll' : 'loop');
};
// ---------------------------------------------------------------------
// Searches
//
// What was searched for before, newest first. The grid searches as you
// type, so the box never sees a "submit" to record: every pause in the
// typing is a search of its own and storing each one would fill the list
// with the way to a query rather than the query. So a new entry swallows
// the ones it grew out of -- typing "hot", "hote", "hotel" leaves "hotel"
// -- and going back to something shorter than what is already stored adds
// nothing, since that is a step back through the same typing.
// ---------------------------------------------------------------------
const SEARCH_HISTORY_MAX = 50;
App.storage.getSearches = function() {
try {
const stored = JSON.parse(localStorage.getItem(App.constants.SEARCH_HISTORY_KEY));
return Array.isArray(stored) ? stored.filter((q) => typeof q === 'string' && q.trim()) : [];
} catch (err) {
return [];
}
};
App.storage.rememberSearch = function(query) {
const next = (query || '').trim();
if (!next) return App.storage.getSearches();
const lower = next.toLowerCase();
const stored = App.storage.getSearches();
// Already on the way to something longer: nothing new to record.
if (stored.some((q) => q.toLowerCase() !== lower && q.toLowerCase().startsWith(lower))) {
return stored;
}
const kept = stored.filter((q) => !lower.startsWith(q.toLowerCase()));
kept.unshift(next);
const capped = kept.slice(0, SEARCH_HISTORY_MAX);
try {
localStorage.setItem(App.constants.SEARCH_HISTORY_KEY, JSON.stringify(capped));
} catch (err) { /* a full or blocked store is not worth failing a search over */ }
return capped;
};
App.storage.forgetSearches = function() {
localStorage.removeItem(App.constants.SEARCH_HISTORY_KEY);
};
// The past searches worth offering for what is being typed, best first.
// Best means where the typing lands in them -- at the front, at the start
// of a word, anywhere at all -- and then which was searched for most
// recently. What is already typed in full is not a suggestion.
App.storage.suggestSearches = function(query, limit) {
const want = Math.max(1, limit || 5);
const typed = (query || '').trim().toLowerCase();
const scored = [];
App.storage.getSearches().forEach((entry, age) => {
const candidate = entry.toLowerCase();
if (candidate === typed) return;
let rank = 0;
if (typed) {
const at = candidate.indexOf(typed);
if (at < 0) return;
rank = at === 0 ? 3 : (candidate[at - 1] === ' ' ? 2 : 1);
}
scored.push({ entry: entry, rank: rank, age: age });
});
scored.sort((a, b) => (b.rank - a.rank) || (a.age - b.age));
return scored.slice(0, want).map((s) => s.entry);
};
// Grid density: 'comfortable' (default) or 'compact' (more, smaller columns).
App.storage.getDensity = function() {
return localStorage.getItem('density') === 'compact' ? 'compact' : 'comfortable';
};
App.storage.setDensity = function(nextDensity) {
localStorage.setItem('density', nextDensity === 'compact' ? 'compact' : 'comfortable');
};
// User-tunable card width / text size multipliers (default 1.0). Clamped so a
// stale or hand-edited value can never break the layout.
const clampScale = function(value, min, max, fallback) {
const n = parseFloat(value);
if (!isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
};
App.storage.getCardScale = function() {
return clampScale(localStorage.getItem('cardScale'), 0.7, 1.5, 1);
};
App.storage.setCardScale = function(next) {
localStorage.setItem('cardScale', clampScale(next, 0.7, 1.5, 1));
};
App.storage.getFontScale = function() {
return clampScale(localStorage.getItem('fontScale'), 0.8, 1.4, 1);
};
App.storage.setFontScale = function(next) {
localStorage.setItem('fontScale', clampScale(next, 0.8, 1.4, 1));
};
App.storage.getServerEntries = function() {
const config = App.storage.getConfig();
if (!config.servers || !Array.isArray(config.servers)) return [];
return config.servers.map((serverObj) => {
const server = Object.keys(serverObj)[0];
return {
url: server,
data: serverObj[server] || null
};
});
};
// Synthetic filter id used to let a channel group expose its member
// channels as a toggleable multi-select, so the user can browse "All <group>"
// while disabling individual channels.
App.session.GROUP_CHANNELS_OPTION_ID = '__groupChannels';
App.session.MERGED_MEMBERS_OPTION_ID = '__mergedMembers';
// Options/session helpers that power channel selection and filters.
App.session.serializeOptions = function(options) {
const serialized = {};
Object.entries(options || {}).forEach(([key, value]) => {
if (Array.isArray(value)) {
serialized[key] = value.map((entry) => entry.id);
} else if (value && value.id) {
serialized[key] = value.id;
}
});
return serialized;
};
App.session.hydrateOptions = function(channel, savedOptions) {
const hydrated = {};
if (!channel || !Array.isArray(channel.options)) return hydrated;
const saved = savedOptions || {};
channel.options.forEach((optionGroup) => {
const allOptions = optionGroup.options || [];
const savedValue = saved[optionGroup.id];
if (optionGroup.multiSelect) {
const fallback = optionGroup.selectAllDefault ? allOptions.slice() : allOptions.slice(0, 1);
if (Array.isArray(savedValue)) {
const selected = allOptions.filter((opt) => savedValue.includes(opt.id));
hydrated[optionGroup.id] = selected.length > 0 ? selected : fallback;
} else {
hydrated[optionGroup.id] = fallback;
}
} else {
const selected = allOptions.find((opt) => opt.id === savedValue) || allOptions[0];
if (selected) hydrated[optionGroup.id] = selected;
}
});
return hydrated;
};
// Builds a pseudo-channel representing a whole channel group, used so the
// rest of the app (session, filters, video loading) can treat a selected
// group the same way it treats a single channel.
App.session.buildGroupChannel = function(group, channels) {
if (!group) return null;
const knownIds = new Set((channels || []).map((channel) => channel.id));
const channelIds = Array.isArray(group.channelIds) ?
group.channelIds.filter((id) => knownIds.has(id)) :
[];
const channelOptions = channelIds.map((id) => {
const channel = (channels || []).find((ch) => ch.id === id);
return { id: id, title: (channel && (channel.name || channel.id)) || id };
});
return {
id: `group:${group.id}`,
name: group.title || group.id,
isGroup: true,
groupId: group.id,
channelIds: channelIds,
// Expose member channels as a multi-select filter (all on by
// default) so the user can disable individual channels while
// browsing the whole group.
options: channelOptions.length > 0 ? [{
id: App.session.GROUP_CHANNELS_OPTION_ID,
title: 'Channels',
multiSelect: true,
selectAllDefault: true,
options: channelOptions
}] : []
};
};
// Builds a pseudo-channel representing a merged saved search: several
// saved searches, each with its own source, channel, filters and words,
// browsed as one listing. A channel group does something similar but
// cannot express this -- every channel in a group belongs to the same
// server and shares one query, where a merge is held together by nothing
// except the reader having put it together (see fetchMergedBatch).
//
// The members are carried on the channel itself rather than looked up from
// a server's status, because no single server knows about them. That is
// also what lets a merged session survive a reload.
App.session.buildMergedChannel = function(entry) {
if (!entry || !Array.isArray(entry.members) || entry.members.length === 0) return null;
const members = entry.members.map((member, i) => Object.assign({}, member, {
// Stable within the merge, so a member toggle keeps meaning the
// same member even if two of them name the same channel.
memberId: member.memberId || `m${i}`
}));
const memberOptions = members.map((member) => ({
id: member.memberId,
title: member.channelName || member.channelId
}));
return {
id: `merged:${entry.id}`,
name: entry.name || 'Merged search',
isMerged: true,
savedSearchId: entry.id,
members: members,
// Same shape as a group's "Channels" filter, so the existing
// filter UI shows it without knowing what a merge is.
options: memberOptions.length > 0 ? [{
id: App.session.MERGED_MEMBERS_OPTION_ID,
title: 'Searches',
multiSelect: true,
selectAllDefault: true,
options: memberOptions
}] : []
};
};
// Resolves a stored channel id (which may reference a single channel or a
// "group:<id>" pseudo-channel) against a server's status payload.
App.session.resolveChannelById = function(serverData, channelId) {
if (!serverData || !channelId) return null;
const channels = Array.isArray(serverData.channels) ? serverData.channels : [];
if (channelId.startsWith('group:')) {
const groupId = channelId.slice('group:'.length);
const groups = Array.isArray(serverData.channelGroups) ? serverData.channelGroups : [];
const group = groups.find((g) => g.id === groupId);
return App.session.buildGroupChannel(group, channels);
}
return channels.find((channel) => channel.id === channelId) || null;
};
App.session.savePreference = function(session) {
if (!session || !session.server || !session.channel) return;
const prefs = App.storage.getPreferences();
const serverPrefs = prefs[session.server] || {};
serverPrefs.channelId = session.channel.id;
serverPrefs.optionsByChannel = serverPrefs.optionsByChannel || {};
serverPrefs.optionsByChannel[session.channel.id] = App.session.serializeOptions(session.options);
prefs[session.server] = serverPrefs;
App.storage.setPreferences(prefs);
};
App.session.buildDefaultOptions = function(channel) {
const selected = {};
if (!channel || !Array.isArray(channel.options)) return selected;
channel.options.forEach((optionGroup) => {
if (!optionGroup.options || optionGroup.options.length === 0) return;
if (optionGroup.multiSelect) {
selected[optionGroup.id] = optionGroup.selectAllDefault ?
optionGroup.options.slice() :
[optionGroup.options[0]];
} else {
selected[optionGroup.id] = optionGroup.options[0];
}
});
return selected;
};
// Ensures defaults exist and establishes a session from cached status.
// Intentionally does NOT touch the network: the last known status of every
// server is persisted in localStorage, so the UI can render instantly from
// it. Fresh status is fetched separately (and non-blockingly) via
// refreshServerStatusInBackground().
App.storage.ensureDefaults = async function() {
if (!localStorage.getItem('config')) {
localStorage.setItem('config', JSON.stringify({
servers: [
{ "https://getfigleaf.com": {} },
{ "https://hottubapp.io": {} },
{ "https://hottub.spacemoehre.de": {} }
]
}));
}
if (!localStorage.getItem('theme')) {
localStorage.setItem('theme', 'dark');
}
if (!localStorage.getItem(PREFERRED_QUALITY_KEY)) {
localStorage.setItem(PREFERRED_QUALITY_KEY, '1080');
}
if (!localStorage.getItem(FAVORITES_KEY)) {
localStorage.setItem(FAVORITES_KEY, JSON.stringify([]));
}
if (!localStorage.getItem(FAVORITES_VISIBILITY_KEY)) {
localStorage.setItem(FAVORITES_VISIBILITY_KEY, 'true');
}
if (!localStorage.getItem(FEED_END_BEHAVIOR_KEY)) {
localStorage.setItem(FEED_END_BEHAVIOR_KEY, 'loop');
}
App.storage.ensureSessionFromCache();
};
// A stable fingerprint of which server/channel a session targets, used to
// decide whether a status refresh actually changed what's being shown (and
// thus whether videos need reloading).
function sessionSignature(session) {
if (!session) return '';
return `${session.server}::${session.channel ? session.channel.id : ''}`;
}
// Builds a session pointing at a valid channel/options using ONLY the status
// data already cached in `config` (no network). Returns the session object,
// or null if no server in the config currently exposes any channels.
App.session.buildSessionFromCache = function(config) {
if (!config || !Array.isArray(config.servers) || config.servers.length === 0) return null;
const serverKeys = config.servers.map((serverObj) => Object.keys(serverObj)[0]);
const existingSession = App.storage.getSession();
const selectedServerKey = existingSession && serverKeys.includes(existingSession.server)
? existingSession.server
: serverKeys[0];
const serverEntry = config.servers.find((serverObj) => Object.keys(serverObj)[0] === selectedServerKey);
const serverData = serverEntry ? serverEntry[selectedServerKey] : null;
if (!serverData || !Array.isArray(serverData.channels) || serverData.channels.length === 0) {
return null;
}
const prefs = App.storage.getPreferences();
const serverPrefs = prefs[selectedServerKey] || {};
const channel = App.session.resolveChannelById(serverData, serverPrefs.channelId) || serverData.channels[0];
const savedOptions = serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[channel.id] : null;
const options = savedOptions ? App.session.hydrateOptions(channel, savedOptions) : App.session.buildDefaultOptions(channel);
return {
server: selectedServerKey,
channel: channel,
options: options,
};
};
// Ensures the stored session points at a channel that still exists in the
// cached status, rebuilding it from cache if necessary. Never clears a valid
// selection. Returns true if a usable session exists afterwards.
App.storage.ensureSessionFromCache = function() {
const config = App.storage.getConfig();
const serverKeys = (config.servers || []).map((serverObj) => Object.keys(serverObj)[0]);
const existingSession = App.storage.getSession();
// A merged search answers to no single server, so the check below --
// "does this server still list this channel" -- cannot speak for it.
// It is valid while the saved search it came from is still there and
// at least one member's source is still set up. Rebuilt from that
// saved search rather than trusted as stored, so a rename or an edited
// member is picked up instead of being frozen into the session.
if (existingSession && existingSession.channel && existingSession.channel.isMerged) {
const stored = App.savedSearches && typeof App.savedSearches.get === 'function'
? App.savedSearches.get(existingSession.channel.savedSearchId)
: null;
const channel = stored ? App.session.buildMergedChannel(stored) : existingSession.channel;
const members = (channel && channel.members) || [];
if (members.some((member) => serverKeys.includes(member.server))) {
App.storage.setSession({
server: members[0].server,
channel: channel,
options: App.session.hydrateOptions(channel, App.session.serializeOptions(existingSession.options))
});
return true;
}
}
// Leave a still-valid session untouched so we don't disturb the user's
// current server/channel selection on refresh.
if (existingSession && existingSession.channel && serverKeys.includes(existingSession.server)) {
const serverEntry = config.servers.find((serverObj) => Object.keys(serverObj)[0] === existingSession.server);
const serverData = serverEntry ? serverEntry[existingSession.server] : null;
if (serverData && App.session.resolveChannelById(serverData, existingSession.channel.id)) {
return true;
}
}
const sessionData = App.session.buildSessionFromCache(config);
if (sessionData) {
App.storage.setSession(sessionData);
App.session.savePreference(sessionData);
return true;
}
return false;
};
// Fetches fresh server status and merges it into the cached config. Crucially,
// a failed status request preserves the server's LAST KNOWN status (channels,
// groups, etc.) instead of wiping it -- so a flaky/down status endpoint can no
// longer brick the app. Returns true if the active session's target changed
// (e.g. channels appeared for the first time), signalling a video reload.
App.storage.initializeServerStatus = async function() {
const config = JSON.parse(localStorage.getItem('config'));
if (!config || !config.servers) return false;
const fetchDirectStatus = async (server) => {
const directUrl = `${server.replace(/\/$/, '')}/api/status`;
const response = await fetch(directUrl);
if (!response.ok) throw new Error(`Direct status request failed: ${response.status}`);
return await response.json();
};
const fetchProxiedStatus = async (server) => {
const response = await fetch(`/api/status`, {
method: "POST",
body: JSON.stringify({
server: server
}),
headers: {
"Content-Type": "application/json"
},
});
if (!response.ok) throw new Error(`Proxied status request failed: ${response.status}`);
return await response.json();
};
const statusPromises = config.servers.map(async (serverObj) => {
const server = Object.keys(serverObj)[0];
const prior = serverObj[server];
try {
// Try a direct request first, then fall back to the server-side proxy.
try {
serverObj[server] = await fetchDirectStatus(server);
} catch (directErr) {
serverObj[server] = await fetchProxiedStatus(server);
}
} catch (err) {
// The request failed. Keep the last known good status so the user
// doesn't lose their channels when the status endpoint is down;
// just flag it offline. Only fall back to an empty stub when we've
// never successfully fetched this server.
if (prior && Array.isArray(prior.channels) && prior.channels.length > 0) {
serverObj[server] = Object.assign({}, prior, { online: false });
} else {
serverObj[server] = {
online: false,
channels: []
};
}
}
});
await Promise.all(statusPromises);
localStorage.setItem('config', JSON.stringify(config));
const before = sessionSignature(App.storage.getSession());
App.storage.ensureSessionFromCache();
const after = sessionSignature(App.storage.getSession());
return before !== after;
};
// Refreshes server status without blocking; updates the menu and reloads
// videos only if the refresh actually changed the active selection. Safe to
// fire-and-forget during startup so the UI renders from cache immediately.
App.storage.refreshServerStatusInBackground = function() {
return App.storage.initializeServerStatus()
.then((changed) => {
if (App.ui && typeof App.ui.renderMenu === 'function') {
App.ui.renderMenu();
}
if (changed && App.videos && typeof App.videos.resetAndReload === 'function') {
App.videos.resetAndReload();
}
})
.catch((err) => {
console.error('Background status refresh failed:', err);
});
};
})();