Compare commits
4 Commits
52d7802491
...
0009574b77
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0009574b77 | ||
|
|
d4ed9dce5d | ||
|
|
c54d0889c1 | ||
|
|
b48d7aa161 |
150
backend/main.py
150
backend/main.py
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'); } });
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
110
frontend/js/favoritesView.js
Normal file
110
frontend/js/favoritesView.js
Normal 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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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);
|
||||
|
||||
79
frontend/js/hottubBackup.js
Normal file
79
frontend/js/hottubBackup.js
Normal 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);
|
||||
});
|
||||
};
|
||||
})();
|
||||
@@ -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
BIN
frontend/js/sqlite.js
Normal file
Binary file not shown.
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
1
media_srv2.log
Normal file
@@ -0,0 +1 @@
|
||||
/bin/bash: line 1: cd: too many arguments
|
||||
Reference in New Issue
Block a user