Compare commits

..

4 Commits

Author SHA1 Message Date
Simon
0009574b77 Keep favorites reachable with the bar hidden
The favorites bar carries the "Browse all" button and the sort control, so
switching the bar off in settings hid the way in to both. The command
palette now offers "Browse favorites" (or "Back to videos") and each sort
order, and opening the grid re-renders the bar so its header -- the way back
out -- is mounted even when settings say hidden.

Tests (scratchpad): a new palette test that starts with the bar switched
off, opens the grid through the palette, re-sorts through it, and returns to
the listing. It caught the second half of this: opening from the palette did
not re-render the bar, leaving no visible way out.

Also fixes the favorites playback test, which had been wedging headless
Chrome all session. It was reloading by navigating to the URL already
loaded; the first evaluate after that is answered by the outgoing execution
context and every one after it hangs forever. It now does its second visit
in a fresh tab -- localStorage is shared per origin, so it models "next day"
the same way -- and asserts what it had only been printing. It also runs
hermetically now (no favorites left over from another test, CDN icons and
fonts blocked) and no longer runs its whole body on import, which is what
made it hijack a debugging session earlier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-06 11:34:45 +00:00
Simon
d4ed9dce5d Browse favorites like a listing, sorted, and page them as you scroll
Favorites now carry `favoriteDate`. Ones saved before this had no way of
knowing when they were saved, so they are all stamped with the moment the
client first reads them -- they sort together as one batch, at the point
favorites learned to keep dates. An import brings the date the other client
recorded instead, so a restored library keeps its history.

The bar used to build a card per favorite, which an import of several
hundred made an expensive way to open the app. It now renders a screenful
and appends more as the strip is scrolled.

"Browse all" turns the whole grid into favorites: the same cards, the same
virtualized masonry, the same infinite scroll and reels mode as a channel
listing -- App.videos.loadVideos simply pages out of localStorage instead of
the server while that view is open. Sort applies to the bar and the grid
together: recently added (default), oldest, title, longest, shortest, and a
shuffle for rediscovering a long list.

Tests (scratchpad): dates backfilled onto undated favorites, the bar paging
as it scrolls rather than building every card, the grid paging to the full
list with zero server calls, each sort order reordering it, and the way back
to the channel listing. Also measured: 515 imported favorites load with the
page responsive and 24 bar cards built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-06 10:46:47 +00:00
Simon
c54d0889c1 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
2026-09-06 08:32:44 +00:00
Simon
b48d7aa161 Reuse upstream connections, and stop sniffing what we already know
Two costs sat in front of every video: a TLS handshake per upstream request,
and a round trip spent asking the proxy what kind of file it was about to
play.

The session cache was a thread-local, which never once hit -- the server
gives each connection a fresh thread, so every request found empty storage
and built a session, and with it a new connection to the CDN. Instrumented,
that was one session per request; a video is dozens of range requests and an
HLS stream one per segment. Sessions now live in a shared pool, checked out
for a request and returned when its response closes (for a streamed body,
after the last byte), so the connection stays warm. Measured against a
nearby CDN: 32-45ms per request becomes 9-11ms.

The player then HEADed the proxy before playback to sniff a content type --
and that HEAD ran a full upstream GET server-side, so two connections were
opened before the first byte of video was asked for. It now sniffs only when
neither the URL's extension nor yt-dlp's `protocol` says what the source is,
which is nearly never; `protocol` is newly carried through /api/resolve for
exactly this. A HEAD that does still happen asks upstream for one byte and
restates the 206 as a 200 describing the whole resource.

`format_note` joins the resolved fields too: the quality menu has been
reading it since 52d7802, but the backend was dropping it, so no note could
ever have been shown.

Tests (scratchpad, headless): sessions reused across requests, never shared
by two at once, returned after a client aborts mid-stream; HEAD probes one
byte while ranged and plain GETs are byte-identical; no HEAD for an mp4 or a
protocol-bearing URL, and one for a URL with neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-05 23:27:24 +00:00
14 changed files with 744 additions and 63 deletions

View File

@@ -10,6 +10,7 @@ import yt_dlp
from yt_dlp.networking.impersonate import ImpersonateTarget
from curl_cffi import requests as impersonate_requests
import threading
import queue
import io
import time
import hashlib
@@ -20,18 +21,75 @@ from urllib.parse import urljoin
# that isn't a real browser, so impersonation must be on by default.
IMPERSONATE_TARGET = os.getenv('STREAM_IMPERSONATE', 'chrome').strip() or 'chrome'
# curl_cffi sessions wrap a single libcurl handle and are not safe to share
# across threads; keep one per worker thread so the Flask `threaded=True`
# server can proxy concurrent segments without corrupting state.
_thread_local = threading.local()
# curl_cffi sessions wrap a single libcurl handle: they can't be shared by two
# requests at once, but reusing one *across* requests is what keeps the upstream
# connection alive, and with it the TLS handshake we already paid for. A video
# arrives as dozens of range requests (and an HLS stream as one request per
# segment), so a handshake per request is the difference between a stall and a
# seek.
#
# This used to be a thread-local, which never actually hit: the development
# server gives every connection a brand-new thread, so each request found empty
# thread-local storage and built a session from scratch. Sessions live in a
# shared pool instead -- checked out for the duration of one request, returned
# when its response is closed (which, for a streamed body, is when the last byte
# has been sent). LIFO so the hottest connection is the one handed out next.
try:
_SESSION_POOL_SIZE = max(1, int(os.getenv('STREAM_SESSION_POOL', '') or 8))
except ValueError:
_SESSION_POOL_SIZE = 8
_session_pool = queue.LifoQueue(maxsize=_SESSION_POOL_SIZE)
def get_impersonate_session():
sess = getattr(_thread_local, 'session', None)
if sess is None:
sess = impersonate_requests.Session(impersonate=IMPERSONATE_TARGET)
_thread_local.session = sess
return sess
def _borrow_session():
"""A session nobody else is using: from the pool, or a fresh one."""
try:
return _session_pool.get_nowait()
except queue.Empty:
return impersonate_requests.Session(impersonate=IMPERSONATE_TARGET)
def _return_session(sess):
"""Hand a session back. Beyond the pool's size the extras are closed, so a
burst of concurrency doesn't leave idle connections open forever."""
try:
_session_pool.put_nowait(sess)
except queue.Full:
try:
sess.close()
except Exception:
pass
def _discard_session(sess):
"""Drop a session that raised, rather than pooling a possibly-poisoned handle."""
try:
sess.close()
except Exception:
pass
def _release_when_closed(resp, sess):
"""Return `sess` to the pool once `resp` is closed.
Every caller either closes the response outright or streams it through a
generator that closes in a `finally`, so this is where a request's exclusive
hold on a session ends. Idempotent: a double close must not put the same
session in the pool twice."""
original_close = resp.close
released = False
def close():
nonlocal released
try:
original_close()
finally:
if not released:
released = True
_return_session(sess)
resp.close = close
return resp
def _is_tls_verify_error(err):
@@ -54,17 +112,27 @@ def impersonate_get(url, **kwargs):
stream we retry once with verification off, and say so in the log. Set
STREAM_TLS_VERIFY_ONLY=1 to keep the hard failure instead."""
host = urllib.parse.urlparse(url).netloc
sess = _borrow_session()
if host in _tls_unverified_hosts:
return get_impersonate_session().get(url, verify=False, **kwargs)
try:
return _release_when_closed(sess.get(url, verify=False, **kwargs), sess)
except Exception:
_discard_session(sess)
raise
try:
return get_impersonate_session().get(url, **kwargs)
return _release_when_closed(sess.get(url, **kwargs), sess)
except Exception as err:
strict = os.getenv('STREAM_TLS_VERIFY_ONLY', '').strip().lower() in ('1', 'true', 'yes', 'on')
if strict or not _is_tls_verify_error(err):
_discard_session(sess)
raise
app.logger.warning("[stream] TLS verification failed for %s (%s); retrying unverified", url, err)
_tls_unverified_hosts.add(host)
return get_impersonate_session().get(url, verify=False, **kwargs)
try:
return _release_when_closed(sess.get(url, verify=False, **kwargs), sess)
except Exception:
_discard_session(sess)
raise
# Stream params that have dedicated meaning and must never be treated as headers.
# `referer` is mapped to a real Referer header by collect_passthrough_headers, but
@@ -80,6 +148,10 @@ STREAM_IMPERSONATION_MANAGED_HEADERS = {
'user-agent', 'accept', 'accept-encoding', 'accept-language',
'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform',
}
# `Content-Range: bytes 0-0/12345` -> the total size of the resource. A '*'
# total (an origin that won't say) deliberately doesn't match, so the length is
# then simply left out rather than guessed at.
_CONTENT_RANGE_TOTAL_RE = re.compile(r'^\s*bytes\s+\d+-\d+/(\d+)\s*$', re.I)
# RFC 7230 token charset for header field-names.
HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
# Reject control characters (CR/LF/NUL etc.) that could be used for header injection.
@@ -217,8 +289,13 @@ _resolve_cache_lock = threading.Lock()
# Per-format fields the frontend needs to rank formats and build stream/probe
# URLs (see resolveStreamSources/rankFormats in videos.js). Everything else in a
# yt-dlp format dict is dropped to keep the payload small.
# `protocol` is what yt-dlp calls the delivery method ('https', 'm3u8_native',
# 'http_dash_segments', ...). Passing it on saves the player a HEAD round trip
# against the proxy -- and with it a whole upstream connection -- for URLs whose
# extension doesn't say what they are, which is most signed CDN links.
_RESOLVE_FORMAT_FIELDS = ('url', 'http_headers', 'height', 'width', 'tbr',
'fps', 'vcodec', 'acodec', 'ext', 'video_ext', 'quality')
'fps', 'vcodec', 'acodec', 'ext', 'video_ext', 'quality',
'protocol', 'format_note')
# Some channels surface pages that yt-dlp can't extract because the video is
# embedded in a third-party JS player iframe (e.g. the xtremestream family used
@@ -233,8 +310,11 @@ def resolve_unsupported_embed(page_url):
"""Best-effort resolver for iframe-embedded JS players yt-dlp can't handle.
Returns an info-like dict ({'url','formats','http_headers','is_live'}) whose
single format is the embed's HLS playlist, or None if nothing was found."""
# Both fetches are small and fully buffered, so this holds one pooled session
# for the whole scrape rather than going through impersonate_get (whose
# release is tied to closing a streamed response).
sess = _borrow_session()
try:
sess = get_impersonate_session()
page = sess.get(page_url, headers={'Referer': page_url}, timeout=15)
embed_url = None
for src in _EMBED_IFRAME_RE.findall(page.text):
@@ -262,7 +342,12 @@ def resolve_unsupported_embed(page_url):
'formats': [{'url': stream_url, 'ext': 'm3u8', 'protocol': 'm3u8', 'http_headers': headers}],
}
except Exception:
_discard_session(sess)
sess = None
return None
finally:
if sess is not None:
_return_session(sess)
@app.route('/api/resolve', methods=['POST', 'GET'])
def resolve_video():
@@ -634,6 +719,15 @@ def stream_video():
if 'Range' in request.headers:
safe_request_headers['Range'] = request.headers['Range']
# A HEAD wants headers, not video -- but we don't send a HEAD upstream
# here (hotlink-protected origins routinely answer one method and not
# the other, and the GET is the one we know works). Ask for a single
# byte instead: same headers, none of the transfer. The response is
# restated as a description of the whole resource further down.
head_probe = request.method == 'HEAD' and 'Range' not in safe_request_headers
if head_probe:
safe_request_headers['Range'] = 'bytes=0-0'
resp = impersonate_get(target_url, headers=safe_request_headers, stream=True, timeout=30, allow_redirects=True)
# Some channel proxies (e.g. the "animeidhentai" hottub proxy) use
# inverted hotlink protection: they 403 any request that carries a
@@ -694,8 +788,32 @@ def stream_video():
)
if request.method == 'HEAD':
status = resp.status_code
# Read from the headers we already copied: curl_cffi doesn't keep a
# response's headers readable once it has been closed.
content_range = next((value for name, value in forwarded_headers
if name.lower() == 'content-range'), '')
resp.close()
return Response("", status=resp.status_code, headers=forwarded_headers)
if head_probe and status == 206:
# We asked for one byte; the caller asked about the resource.
# Restate the 206 as a 200 describing the whole thing, taking
# the real length out of `Content-Range: bytes 0-0/<total>`.
# (An origin that ignored the range answered 200 already, and
# its headers need no fixing.)
match = _CONTENT_RANGE_TOTAL_RE.match(content_range or '')
total = match.group(1) if match else None
forwarded_headers = [(name, value) for name, value in forwarded_headers
if name.lower() not in ('content-range', 'content-length')]
head_response = Response("", status=200, headers=forwarded_headers)
if total:
# A HEAD carries the entity headers its GET would, with no
# body -- so the length is the resource's, not the zero
# bytes we're sending. Werkzeug derives Content-Length from
# the body unless told not to.
head_response.automatically_set_content_length = False
head_response.headers['Content-Length'] = total
return head_response
return Response("", status=status, headers=forwarded_headers)
def generate():
try:

View File

@@ -398,6 +398,15 @@ body.theme-light .sidebar {
margin-bottom: 8px;
}
/* Explanatory line under a control -- and where the import reports back. Not a
`label`, so it keeps sentence case and normal letter spacing. */
.setting-note {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
}
.setting-label-row label {
margin-bottom: 0;
}
@@ -766,6 +775,35 @@ body.theme-light .setting-item select option {
font-family: var(--font-display);
}
.favorites-actions {
display: flex;
align-items: center;
gap: 8px;
}
.favorites-sort {
height: 28px;
padding: 0 8px;
border-radius: var(--radius-sm, 6px);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 12px;
}
.favorites-actions .btn-secondary {
height: 28px;
padding: 0 12px;
font-size: 12px;
}
/* Browsing favorites as a grid: the bar above it would be the same list twice,
so it collapses to its header (which carries the way back out). */
body.favorites-view-open .favorites-list,
body.favorites-view-open .favorites-empty {
display: none;
}
.favorites-list {
display: flex;
gap: 12px;

View File

@@ -34,6 +34,10 @@
<section id="favorites-bar" class="favorites-bar" aria-label="Favorites">
<div class="favorites-header">
<h3>Favorites</h3>
<div class="favorites-actions">
<select id="favorites-sort" class="favorites-sort" aria-label="Sort favorites"></select>
<button id="favorites-browse-btn" class="btn-secondary" type="button" aria-pressed="false">Browse all</button>
</div>
</div>
<div id="favorites-list" class="favorites-list"></div>
<div id="favorites-empty" class="favorites-empty">No favorites yet. Tap the heart on a video to save it here.</div>
@@ -133,6 +137,17 @@
</div>
<div id="sources-list" class="sources-list"></div>
</div>
<div class="sidebar-section">
<h4 class="sidebar-subtitle">Hot Tub Backup</h4>
<div class="setting-item">
<label for="import-favorites-btn">Import Favorites</label>
<input id="import-favorites-file" type="file" accept=".sqlite3,.sqlite,.db" hidden>
<button id="import-favorites-btn" class="btn-secondary" type="button">Choose backup file…</button>
<p id="import-favorites-status" class="setting-note" role="status" aria-live="polite">
Reads the favorites out of an exported Hot Tub database. The file stays on this device.
</p>
</div>
</div>
</div>
</aside>
@@ -191,6 +206,9 @@
<script src="static/js/customPlayer.js"></script>
<script src="static/js/player.js"></script>
<script src="static/js/favorites.js"></script>
<script src="static/js/favoritesView.js"></script>
<script src="static/js/sqlite.js"></script>
<script src="static/js/hottubBackup.js"></script>
<script src="static/js/videos.js"></script>
<script src="static/js/feed.js"></script>
<script src="static/js/ui.js"></script>

View File

@@ -151,6 +151,26 @@ App.enhance = App.enhance || {};
if (App.virtualGrid && App.virtualGrid.relayout) App.virtualGrid.relayout();
}});
out.push({ label: 'Toggle Reels view', hint: 'Playback', run: () => { if (App.feed) App.feed.toggle(); } });
// The favorites bar carries these controls, but it can be switched
// off in settings -- in which case the palette is the way in.
if (App.favoritesView && App.favorites) {
const browsing = App.favoritesView.isActive();
out.push({
label: browsing ? 'Back to videos' : 'Browse favorites',
hint: browsing ? 'Leave the favorites grid' : 'All favorites as a grid',
run: () => App.favoritesView.toggle()
});
const currentSort = App.favorites.getSort();
App.favorites.SORTS.forEach((sort) => {
if (sort.id === currentSort) return;
out.push({
label: `Sort favorites: ${sort.label}`,
hint: 'Favorites',
run: () => App.favoritesView.applySort(sort.id)
});
});
}
out.push({ label: 'Reload channel', hint: 'Refresh the current feed', run: () => { if (App.videos) App.videos.resetAndReload(); } });
out.push({ label: 'Open Menu', hint: 'Source · channel · filters', run: () => { if (App.ui) App.ui.toggleDrawer('menu'); } });
out.push({ label: 'Open Settings', hint: 'Preferences', run: () => { if (App.ui) App.ui.toggleDrawer('settings'); } });

View File

@@ -10,23 +10,82 @@ App.favorites = App.favorites || {};
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;
// Two things are repaired on the way in, and written back once if
// anything changed, so the fix happens exactly one time:
//
// `meta`, from older versions, is a blob of resolved formats whose
// URLs are signed and long expired -- dropped so no code path can
// reach for one; everything re-resolves from `url` at play time.
//
// `favoriteDate` didn't exist before sorting needed it. There's no
// way to recover when an old favorite was actually saved, so it
// gets now: they sort together, as one batch, at the point the
// client learned to keep dates.
let repaired = false;
const now = new Date().toISOString();
const items = parsed.map((item) => {
if (!item || typeof item !== 'object') return item;
if (!item.meta && item.favoriteDate) return item;
const clean = Object.assign({}, item);
delete clean.meta;
if (!clean.favoriteDate) clean.favoriteDate = now;
repaired = true;
return clean;
});
if (repaired) App.favorites.setAll(items);
return items;
} catch (err) {
return [];
}
};
// Sort orders offered for the favorites bar and the favorites grid.
// `random` reshuffles on every read by design -- it's for rediscovering a
// long list, so landing somewhere different each time is the point.
App.favorites.SORTS = [
{ id: 'recent', label: 'Recently added' },
{ id: 'oldest', label: 'Oldest first' },
{ id: 'title', label: 'Title A-Z' },
{ id: 'longest', label: 'Longest' },
{ id: 'shortest', label: 'Shortest' },
{ id: 'random', label: 'Shuffle' }
];
App.favorites.DEFAULT_SORT = 'recent';
App.favorites.getSort = function() {
const stored = localStorage.getItem(App.constants.FAVORITES_SORT_KEY);
return App.favorites.SORTS.some((sort) => sort.id === stored) ? stored : App.favorites.DEFAULT_SORT;
};
App.favorites.setSort = function(sort) {
localStorage.setItem(App.constants.FAVORITES_SORT_KEY, sort);
};
const dateValue = function(item) {
const parsed = Date.parse((item && item.favoriteDate) || '');
return isNaN(parsed) ? 0 : parsed;
};
App.favorites.sorted = function(sort) {
const items = App.favorites.getAll();
const mode = sort || App.favorites.getSort();
if (mode === 'random') {
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
return items;
}
const comparators = {
recent: (a, b) => dateValue(b) - dateValue(a),
oldest: (a, b) => dateValue(a) - dateValue(b),
title: (a, b) => String(a.title || '').localeCompare(String(b.title || ''), undefined, { sensitivity: 'base' }),
longest: (a, b) => (Number(b.duration) || 0) - (Number(a.duration) || 0),
shortest: (a, b) => (Number(a.duration) || 0) - (Number(b.duration) || 0)
};
return items.sort(comparators[mode] || comparators.recent);
};
App.favorites.setAll = function(items) {
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items));
};
@@ -53,7 +112,10 @@ App.favorites = App.favorites || {};
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))
isLive: !!(video.isLive || (meta && meta.isLive)),
// When it was saved. An import carries the date the other client
// recorded; anything saved here is saved now.
favoriteDate: video.favoriteDate || new Date().toISOString()
// 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
@@ -63,10 +125,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 +227,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 +240,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);
@@ -118,18 +264,53 @@ App.favorites = App.favorites || {};
}
};
// The bar is a horizontal strip, and a long favorites list is hundreds of
// cards. Only a screenful or so is built up front; the rest arrives as the
// strip is scrolled, which keeps opening the app cheap no matter how many
// favorites are saved (an import can add hundreds at once).
const BAR_PAGE_SIZE = 24;
// How close to the right end the strip has to get before the next page is
// appended -- roughly a screen's worth of cards ahead of the reader.
const BAR_PAGE_AHEAD_PX = 800;
const barPage = { items: [], rendered: 0 };
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';
const favorites = App.favorites.sorted();
// While the favorites grid is open the bar is kept mounted even if it's
// switched off in settings: its header is what leads back out.
const browsing = !!(App.favoritesView && App.favoritesView.isActive());
bar.style.display = (App.favorites.isVisible() || browsing) ? 'block' : 'none';
list.innerHTML = "";
favorites.forEach((item) => {
barPage.items = favorites;
barPage.rendered = 0;
// While the favorites grid is open the strip is hidden -- the grid is
// the same list, larger -- so don't build cards nobody can see. The
// header stays, because it carries the way back out.
if (!browsing) appendBarPage(list);
// Assignment rather than addEventListener: renderBar runs on every
// favorite change, and this must not stack up handlers.
list.onscroll = () => {
if (barPage.rendered >= barPage.items.length) return;
const remaining = list.scrollWidth - (list.scrollLeft + list.clientWidth);
if (remaining <= BAR_PAGE_AHEAD_PX) appendBarPage(list);
};
if (empty) {
empty.style.display = favorites.length > 0 ? 'none' : 'block';
}
};
function appendBarPage(list) {
const slice = barPage.items.slice(barPage.rendered, barPage.rendered + BAR_PAGE_SIZE);
barPage.rendered += slice.length;
slice.forEach((item) => {
const card = document.createElement('div');
card.className = 'favorite-card';
card.dataset.favKey = item.key;
@@ -140,7 +321,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>
@@ -215,9 +396,5 @@ App.favorites = App.favorites || {};
}
list.appendChild(card);
});
if (empty) {
empty.style.display = favorites.length > 0 ? 'none' : 'block';
}
};
}
})();

View File

@@ -0,0 +1,110 @@
window.App = window.App || {};
App.favoritesView = App.favoritesView || {};
// Browsing favorites as a full grid, the same way the channel listing is
// browsed: same cards, same virtualized masonry, same infinite scroll, same
// reels mode. The only difference is where the pages come from -- localStorage
// instead of the server -- so App.videos.loadVideos routes here while this view
// is active and every page hands its slice to App.videos.renderVideos.
(function() {
const state = App.state;
// A page of a local list can be bigger than a page from the server: there's
// no request behind it, only the cost of building cards, which the
// virtualizer already keeps to what's on screen.
const PAGE_SIZE = 24;
const view = {
active: false,
queue: [], // the sorted favorites still to be handed to the grid
offset: 0
};
// A favorite as the grid expects a video: `id` has to be unique per card
// (the virtualizer and renderedVideoIds key on it), and an imported
// favorite has no server id -- its key, which is the URL, stands in.
const toVideo = function(entry) {
return Object.assign({}, entry, { id: entry.key, tags: [] });
};
App.favoritesView.isActive = function() {
return view.active;
};
App.favoritesView.loadNext = function() {
if (!view.active) return false;
const slice = view.queue.slice(view.offset, view.offset + PAGE_SIZE);
view.offset += slice.length;
state.hasNextPage = view.offset < view.queue.length;
if (!slice.length) {
App.videos.updateLoadMoreState();
return false;
}
App.videos.renderVideos({ items: slice.map(toVideo) });
App.videos.updateLoadMoreState();
return true;
};
// Starts (or restarts, after a sort change) the favorites grid.
App.favoritesView.open = function(options) {
const sort = (options && options.sort) || App.favorites.getSort();
const favorites = App.favorites.sorted(sort);
if (!favorites.length) {
App.ui.showError('No favorites yet. Tap the heart on a video to save one.');
return false;
}
App.videos.resetGrid();
view.active = true;
view.queue = favorites;
view.offset = 0;
state.hasNextPage = true;
document.body.classList.add('favorites-view-open');
// Re-render the bar so it re-decides whether to be mounted: hidden in
// settings or not, its header has to be on screen now, since that's
// where the way back out lives (the palette can open this view too).
App.favorites.renderBar();
App.favoritesView.syncControls();
App.favoritesView.loadNext();
window.scrollTo({ top: 0, behavior: 'auto' });
return true;
};
App.favoritesView.close = function(options) {
if (!view.active) return;
view.active = false;
view.queue = [];
view.offset = 0;
document.body.classList.remove('favorites-view-open');
// Back to whatever the settings say, and with its cards built again.
App.favorites.renderBar();
App.favoritesView.syncControls();
// Back to the channel listing, unless the caller is about to load
// something itself (a search, a channel switch).
if (!(options && options.silent)) App.videos.resetAndReload();
};
App.favoritesView.toggle = function() {
if (view.active) App.favoritesView.close();
else App.favoritesView.open();
};
// Re-pages the grid under a new order, and re-renders the bar so both show
// favorites the same way round.
App.favoritesView.applySort = function(sort) {
App.favorites.setSort(sort);
App.favorites.renderBar();
if (view.active) App.favoritesView.open({ sort });
};
App.favoritesView.syncControls = function() {
const button = document.getElementById('favorites-browse-btn');
if (button) {
button.textContent = view.active ? 'Back to videos' : 'Browse all';
button.setAttribute('aria-pressed', view.active ? 'true' : 'false');
}
const select = document.getElementById('favorites-sort');
if (select && select.value !== App.favorites.getSort()) {
select.value = App.favorites.getSort();
}
};
})();

View File

@@ -365,7 +365,7 @@ App.feed = App.feed || {};
// What's actually on screen, so the quality menu can tick it.
slide._activeUrl = resolved.url;
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
const isHls = resolved.isLive ? true : /\.m3u8($|\?)/i.test(resolved.url);
const isHls = App.videos.classifySource(resolved).isHls;
video.muted = state.feedMuted;
video.preload = 'auto';
@@ -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);

View File

@@ -0,0 +1,79 @@
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'];
// The app writes a local-time stamp with no zone ("2025-04-25T20:26:02.144").
// Read it as local time (which is what it was) and keep it as an instant, so
// imported favorites sort against ones saved here. Unparseable or missing
// dates fall back to now rather than to 1970, which would bury them.
const toIsoDate = function(value) {
const parsed = Date.parse(value || '');
return isNaN(parsed) ? new Date().toISOString() : new Date(parsed).toISOString();
};
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,
favoriteDate: toIsoDate(row.favoriteDate)
}));
};
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);
});
};
})();

View File

@@ -734,9 +734,9 @@ App.player = App.player || {};
};
let streamUrl = entry.direct ? resolved.url : App.videos.buildStreamUrlFromSource(resolved);
let isHls = /\.m3u8($|\?)/i.test(resolved.url);
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(resolved.url);
if (resolved.isLive) { isHls = true; isDirectMedia = false; }
const kind = App.videos.classifySource(resolved);
let isHls = kind.isHls;
let isDirectMedia = kind.isDirectMedia;
video.onerror = null;
if (state.hlsPlayer) {
@@ -749,7 +749,11 @@ App.player = App.player || {};
video.removeAttribute('src');
video.load();
if (!isHls && !entry.direct) {
// Last resort only: a HEAD through the proxy is a whole upstream
// connection (handshake included) before the first byte of video is
// ever requested, so it runs only when neither the URL nor the
// extractor's protocol says what this source is.
if (!isHls && !isDirectMedia && !entry.direct) {
try {
const headResp = await fetch(streamUrl, { method: 'HEAD' });
if (token !== cp.attemptToken) return;

BIN
frontend/js/sqlite.js Normal file

Binary file not shown.

View File

@@ -22,6 +22,7 @@ App.state = {
App.constants = {
FAVORITES_KEY: 'favorites',
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
FAVORITES_SORT_KEY: 'favoritesSort',
PREFERRED_QUALITY_KEY: 'preferredQuality',
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior'
};

View File

@@ -616,7 +616,75 @@ 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;
});
});
};
// Favorites bar header: the sort order (which applies to the bar and the
// favorites grid alike) and the toggle into that grid.
App.ui.bindFavoritesControls = function() {
const select = document.getElementById('favorites-sort');
const button = document.getElementById('favorites-browse-btn');
if (select && !select.options.length) {
App.favorites.SORTS.forEach((sort) => {
const option = document.createElement('option');
option.value = sort.id;
option.textContent = sort.label;
select.appendChild(option);
});
select.value = App.favorites.getSort();
select.addEventListener('change', () => App.favoritesView.applySort(select.value));
}
if (button) {
button.addEventListener('click', () => App.favoritesView.toggle());
}
App.favoritesView.syncControls();
};
App.ui.bindGlobalHandlers = function() {
App.ui.bindBackupImport();
App.ui.bindFavoritesControls();
window.toggleDrawer = App.ui.toggleDrawer;
window.closeDrawers = App.ui.closeDrawers;
window.handleSearch = App.videos.handleSearch;

View File

@@ -403,6 +403,12 @@ App.videos = App.videos || {};
// button being pressed.
App.videos.loadVideos = async function(opts) {
const force = !!(opts && opts.force);
// The favorites grid pages out of localStorage, not the server, but
// rides the same sentinel and load-more button to get there.
if (App.favoritesView && App.favoritesView.isActive()) {
App.favoritesView.loadNext();
return;
}
const session = App.storage.getSession();
if (!session || !session.channel) return;
if (loadRunning || state.isLoading) return;
@@ -460,7 +466,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>
@@ -630,6 +636,17 @@ App.videos = App.videos || {};
clearBtn.disabled = !hasValue;
}
}
// A search is a new result set, so it leaves the favorites grid.
if (App.favoritesView && App.favoritesView.isActive()) {
App.favoritesView.close({ silent: true });
}
App.videos.resetGrid();
App.videos.loadVideos();
};
// Empties the grid back to "nothing loaded yet", without deciding what
// fills it next -- the caller does that.
App.videos.resetGrid = function() {
// The held/in-flight page belongs to the old result set.
App.videos.resetPrefetch();
state.currentPage = 1;
@@ -642,7 +659,6 @@ App.videos = App.videos || {};
App.feed.reset();
}
App.videos.updateLoadMoreState();
App.videos.loadVideos();
};
App.videos.resetAndReload = function() {
@@ -651,18 +667,11 @@ App.videos = App.videos || {};
state.currentLoadController = null;
state.isLoading = false;
}
// The held/in-flight page belongs to the old result set.
App.videos.resetPrefetch();
state.currentPage = 1;
state.hasNextPage = true;
state.renderedVideoIds.clear();
state.loadedVideos = [];
state.groupCursors = null;
App.virtualGrid.reset();
if (App.feed && typeof App.feed.reset === 'function') {
App.feed.reset();
// Switching source/channel/filters means leaving the favorites grid.
if (App.favoritesView && App.favoritesView.isActive()) {
App.favoritesView.close({ silent: true });
}
App.videos.updateLoadMoreState();
App.videos.resetGrid();
App.videos.loadVideos();
};
@@ -1274,7 +1283,15 @@ App.videos = App.videos || {};
const referer = explicitReferer || deriveReferer(fmt.url);
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
const headers = mergeHeaders(meta.http_headers, fmt.http_headers);
return { url: fmt.url, referer, userAgent, headers, isLive, refererRequired: !!explicitReferer };
// `protocol` is the extractor's own word for how this format is
// delivered ('https', 'm3u8_native', ...). Carrying it through lets
// the player skip its content-type sniff -- a full round trip
// through the proxy -- for URLs whose extension gives nothing away.
return {
url: fmt.url, referer, userAgent, headers, isLive,
refererRequired: !!explicitReferer,
protocol: fmt.protocol || ''
};
});
if (!sources.length) {
@@ -1312,7 +1329,37 @@ App.videos = App.videos || {};
const referer = explicitReferer || deriveReferer(fmt.url);
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
const headers = mergeHeaders(meta.http_headers, fmt.http_headers);
return { url: fmt.url, referer, userAgent, headers, isLive, refererRequired: !!explicitReferer };
return {
url: fmt.url, referer, userAgent, headers, isLive,
refererRequired: !!explicitReferer,
protocol: fmt.protocol || ''
};
};
// How does this source play -- an HLS manifest, or a media file the <video>
// element can take directly? Answered from the strongest evidence at hand:
// a live stream is always a manifest here, then the extractor's `protocol`,
// then the URL's extension. When none of them says (a signed CDN link with
// no extension and no protocol), the caller is left to sniff the content
// type over the network, which is why `protocol` is worth carrying around.
App.videos.classifySource = function(resolved) {
const url = (resolved && resolved.url) || '';
let isHls = /\.m3u8($|\?)/i.test(url);
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(url);
const protocol = String((resolved && resolved.protocol) || '').toLowerCase();
if (protocol.indexOf('m3u8') >= 0) {
isHls = true;
isDirectMedia = false;
} else if (protocol === 'https' || protocol === 'http') {
// A plain HTTP(S) download: one file, played as-is. Anything else
// yt-dlp names (http_dash_segments, ism, ...) stays unknown here.
isDirectMedia = true;
}
if (resolved && resolved.isLive) {
isHls = true;
isDirectMedia = false;
}
return { isHls, isDirectMedia };
};
// Background "direct playability" probe. The backend proxy exists to work

1
media_srv2.log Normal file
View File

@@ -0,0 +1 @@
/bin/bash: line 1: cd: too many arguments