diff --git a/backend/main.py b/backend/main.py index f38cb54..50465fa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -210,13 +210,15 @@ def stream_video(): video_url = "" if request.method == 'POST': video_url = request.json.get('url') + live_hint = bool((request.json or {}).get('live')) else: video_url = request.args.get('url') - + live_hint = str(request.args.get('live', '')).strip().lower() in ('1', 'true', 'yes', 'on') + if not video_url: return jsonify({"error": "No URL provided"}), 400 - dbg(f"method={request.method} url={video_url}") + dbg(f"method={request.method} url={video_url} live={live_hint}") def is_hls(url): return '.m3u8' in urllib.parse.urlparse(url).path @@ -529,11 +531,96 @@ def stream_video(): return None return headers.get('Referer') or headers.get('referer') + def build_master_playlist(info, referer): + """Synthesize an HLS master playlist from yt-dlp's parsed formats. + + yt-dlp already downloads and parses the upstream master during + extraction, so we reconstruct an equivalent master that points each + variant playlist at our proxy. This avoids re-fetching the upstream + master (some sites issue a single-use session token on it, which the + extraction already consumed) and works generically for any HLS source + that exposes separate audio/video renditions. Returns the playlist + text, or None if there aren't enough HLS formats to build one. + """ + formats = info.get('formats') or [] + + def codec_present(value): + return value not in (None, '', 'none') + + def is_hls_format(fmt): + url = fmt.get('url') or '' + return bool(url) and ('m3u8' in str(fmt.get('protocol') or '') or is_hls(url)) + + audio_fmts, video_fmts = [], [] + for fmt in formats: + if not is_hls_format(fmt): + continue + if codec_present(fmt.get('vcodec')): + video_fmts.append(fmt) + elif codec_present(fmt.get('acodec')): + audio_fmts.append(fmt) + + if not video_fmts: + return None + + def proxied(url): + return (f"/api/stream?url={urllib.parse.quote(url, safe='')}" + f"&referer={urllib.parse.quote(referer, safe='')}") + + lines = ['#EXTM3U', '#EXT-X-VERSION:3'] + + audio_group = None + if audio_fmts: + audio_group = 'aud' + for index, fmt in enumerate(audio_fmts): + name = (fmt.get('format_note') or fmt.get('language') + or fmt.get('format_id') or f'audio{index}') + attrs = [ + 'TYPE=AUDIO', + f'GROUP-ID="{audio_group}"', + f'NAME="{name}"', + f'DEFAULT={"YES" if index == 0 else "NO"}', + 'AUTOSELECT=YES', + ] + if fmt.get('language'): + attrs.append(f'LANGUAGE="{fmt["language"]}"') + attrs.append(f'URI="{proxied(fmt["url"])}"') + lines.append('#EXT-X-MEDIA:' + ','.join(attrs)) + + for fmt in video_fmts: + bitrate = fmt.get('tbr') or fmt.get('vbr') + bandwidth = int(float(bitrate) * 1000) if bitrate else 1000000 + codecs = [] + if codec_present(fmt.get('vcodec')): + codecs.append(fmt['vcodec']) + if audio_group and codec_present(audio_fmts[0].get('acodec')): + codecs.append(audio_fmts[0]['acodec']) + elif codec_present(fmt.get('acodec')): + codecs.append(fmt['acodec']) + attrs = [f'BANDWIDTH={bandwidth}'] + if fmt.get('width') and fmt.get('height'): + attrs.append(f'RESOLUTION={int(fmt["width"])}x{int(fmt["height"])}') + if fmt.get('fps'): + attrs.append(f'FRAME-RATE={float(fmt["fps"]):.3f}') + if codecs: + attrs.append(f'CODECS="{",".join(codecs)}"') + if audio_group: + attrs.append(f'AUDIO="{audio_group}"') + lines.append('#EXT-X-STREAM-INF:' + ','.join(attrs)) + lines.append(proxied(fmt['url'])) + + return '\n'.join(lines) + '\n' + try: # Configure yt-dlp options ydl_opts = { # Prefer HLS when available to enable chunked streaming in the browser. - 'format': 'best[protocol*=m3u8]/best[ext=mp4]/best', + # Live cam streams expose only separate video-only and audio-only HLS + # tracks (no muxed format), so `best` alone raises "Requested format + # is not available". Fall back to the best video-only rendition; we + # then hand the browser the master manifest below so its HLS player + # can pull in the matching audio track. + 'format': 'best[protocol*=m3u8]/best[ext=mp4]/best/bestvideo[protocol*=m3u8]/bestvideo', 'format_sort': ['res', 'fps', 'vcodec:avc1', 'acodec:aac'], 'quiet': False, 'no_warnings': False, @@ -587,26 +674,55 @@ def stream_video(): elif isinstance(info.get('http_headers'), dict): upstream_headers = info['http_headers'] - if request.method == 'HEAD' and selected_format: - manifest_url = selected_format.get('manifest_url') - if manifest_url: - return Response("", status=301, headers=[('Location', f"/api/stream?url={manifest_url}")]) - referer_hint = None if upstream_headers: referer_hint = extract_referer(upstream_headers) if not referer_hint: parsed = urllib.parse.urlparse(video_url) referer_hint = f"{parsed.scheme}://{parsed.netloc}/" + + # When the chosen rendition carries no muxed audio (live cam + # streams) or the source is live, the browser needs a *master* + # playlist so its HLS player can combine the separate audio + video + # renditions. We synthesize that master from yt-dlp's already-parsed + # formats rather than re-fetching the upstream master. + def format_lacks_audio(fmt): + if not isinstance(fmt, dict): + return False + acodec = str(fmt.get('acodec') or '').lower() + vcodec = str(fmt.get('vcodec') or '').lower() + return vcodec not in ('', 'none') and acodec in ('', 'none') + + needs_master = bool(info.get('is_live') or live_hint or format_lacks_audio(selected_format)) + master_playlist = build_master_playlist(info, referer_hint) if needs_master else None + dbg(f"is_live={info.get('is_live')} needs_master={needs_master} synthesized={bool(master_playlist)}") + + if master_playlist: + if request.method == 'HEAD': + return Response("", status=200, content_type='application/vnd.apple.mpegurl') + return Response(master_playlist, status=200, content_type='application/vnd.apple.mpegurl') + + # Synthesis unavailable (e.g. a single muxed variant): fall back to + # the upstream master URL when one exists, else the variant itself. + master_fallback = None + if needs_master: + master_fallback = (selected_format or {}).get('manifest_url') or info.get('manifest_url') + + if request.method == 'HEAD' and selected_format: + head_manifest = master_fallback or selected_format.get('manifest_url') + if head_manifest: + location = f"/api/stream?url={urllib.parse.quote(head_manifest, safe='')}" + return Response("", status=301, headers=[('Location', location)]) + dbg(f"resolved stream_url={stream_url} referer_hint={referer_hint}") if protocol and 'm3u8' in protocol: dbg("protocol indicates hls") - return proxy_hls_playlist(stream_url, referer_hint, upstream_headers=upstream_headers) + return proxy_hls_playlist(master_fallback or stream_url, referer_hint, upstream_headers=upstream_headers) if is_hls(stream_url): dbg("stream_url is hls") - return proxy_hls_playlist(stream_url, referer_hint, upstream_headers=upstream_headers) + return proxy_hls_playlist(master_fallback or stream_url, referer_hint, upstream_headers=upstream_headers) if is_dash(stream_url): dbg("stream_url is dash") diff --git a/frontend/css/style.css b/frontend/css/style.css index 660191e..0030c6a 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -1095,6 +1095,37 @@ body.theme-light .video-menu-btn { background: var(--bg-tertiary); } +.live-badge { + position: absolute; + top: 10px; + left: 50px; + z-index: 2; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 999px; + background: #e0245e; + color: #fff; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + line-height: 1; + text-transform: uppercase; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35); + pointer-events: none; +} + +.feed-live-badge { + top: 16px; + left: 16px; +} + +/* Live streams can't be seeked, so hide the feed scrubber. */ +.feed-slide.is-live .feed-timeline { + display: none; +} + .favorite-btn { position: absolute; top: 10px; diff --git a/frontend/index.html b/frontend/index.html index e72a4b0..18e0867 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -14,7 +14,7 @@
- +
@@ -162,6 +162,5 @@ - diff --git a/frontend/js/favorites.js b/frontend/js/favorites.js index 108e040..4bf8604 100644 --- a/frontend/js/favorites.js +++ b/frontend/js/favorites.js @@ -38,6 +38,7 @@ 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)), meta: meta }; }; @@ -103,14 +104,16 @@ App.favorites = App.favorites || {}; card.className = 'favorite-card'; card.dataset.favKey = item.key; const uploaderText = item.uploader || ''; + const liveBadge = item.isLive ? '● LIVE' : ''; card.innerHTML = ` + ${liveBadge} - ${item.title} + ${item.title} diff --git a/frontend/js/feed.js b/frontend/js/feed.js index 47f651b..91f84c2 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -138,31 +138,55 @@ App.feed = App.feed || {}; if (!resolved.url) return; const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : ''; const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : ''; - const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}`; - const isHls = /\.m3u8($|\?)/i.test(resolved.url); - const canUseHls = !!(window.Hls && window.Hls.isSupported()); + const liveParam = resolved.isLive ? '&live=1' : ''; + const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`; + const isHls = resolved.isLive ? true : /\.m3u8($|\?)/i.test(resolved.url); video.muted = state.feedMuted; video.preload = 'auto'; - if (isHls && canUseHls) { - const hls = new window.Hls(); + const startPlay = () => { + if (!autoplay) return; + const playPromise = video.play(); + if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {}); + }; + + const attachHls = (HlsLib) => { + const hls = new HlsLib(); video._hlsPlayer = hls; hls.loadSource(streamUrl); hls.attachMedia(video); - hls.on(window.Hls.Events.ERROR, (event, data) => { + hls.on(HlsLib.Events.ERROR, (event, data) => { if (data && data.fatal && video._hlsPlayer === hls) { hls.destroy(); video._hlsPlayer = null; } }); - } else { + startPlay(); + }; + + const startNative = () => { video.src = streamUrl; + startPlay(); + }; + + if (!isHls) { + startNative(); + return; } - if (autoplay) { - const playPromise = video.play(); - if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {}); + if (window.Hls && window.Hls.isSupported()) { + attachHls(window.Hls); + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + startNative(); + } else { + // Lazy-load hls.js on demand for the first HLS slide. + App.ensureHls() + .then((HlsLib) => { + if (HlsLib && HlsLib.isSupported()) attachHls(HlsLib); + else startNative(); + }) + .catch(startNative); } }; @@ -177,15 +201,17 @@ App.feed = App.feed || {}; if (!scroller) return null; const slide = document.createElement('div'); - slide.className = 'feed-slide'; + slide.className = v.isLive ? 'feed-slide is-live' : 'feed-slide'; slide.dataset.videoId = v.id; slide.dataset.index = String(index); slide._videoData = v; slide._index = index; const uploaderText = v.uploader || ''; + const liveBadge = v.isLive ? '● LIVE' : ''; slide.innerHTML = ` - + + ${liveBadge}

${v.title || ''}

${uploaderText ? `

${uploaderText}

` : ''} diff --git a/frontend/js/player.js b/frontend/js/player.js index a2f2693..7135905 100644 --- a/frontend/js/player.js +++ b/frontend/js/player.js @@ -77,9 +77,16 @@ App.player = App.player || {}; } const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : ''; const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : ''; - const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}`; + const liveParam = resolved.isLive ? '&live=1' : ''; + const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`; let isHls = /\.m3u8($|\?)/i.test(resolved.url); let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(resolved.url); + // Live cam streams resolve (server-side) to HLS; treat them as HLS up + // front so we skip the content-type HEAD probe and go straight to it. + if (resolved.isLive) { + isHls = true; + isDirectMedia = false; + } // Cleanup existing player instance to prevent aborted bindings. if (state.hlsPlayer) { @@ -159,6 +166,16 @@ App.player = App.player || {}; } }; + // Confirmed direct media (mp4/webm/…) never needs hls.js; everything + // else might, so pull it in now that the type has been sniffed. + if (!window.Hls && (isHls || !isDirectMedia)) { + try { + await App.ensureHls(); + } catch (err) { + // Fall back to native playback below. + } + } + const canUseHls = !!(window.Hls && window.Hls.isSupported()); const prefersHls = isHls || (canUseHls && !isDirectMedia && !video.canPlayType('application/vnd.apple.mpegurl')); let hlsTried = false; diff --git a/frontend/js/state.js b/frontend/js/state.js index b2cd10b..44d3dd7 100644 --- a/frontend/js/state.js +++ b/frontend/js/state.js @@ -27,3 +27,23 @@ App.constants = { FAVORITES_VISIBILITY_KEY: 'favoritesVisible', PREFERRED_QUALITY_KEY: 'preferredQuality' }; + +// Lazily injects hls.js the first time a stream actually needs it. Sessions +// that only browse thumbnails, or that play native/MP4, never download it. +// Resolves with window.Hls (or null if loading failed). +App.ensureHls = function() { + if (window.Hls) return Promise.resolve(window.Hls); + if (App._hlsPromise) return App._hlsPromise; + App._hlsPromise = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5'; + script.async = true; + script.onload = () => resolve(window.Hls || null); + script.onerror = () => { + App._hlsPromise = null; + reject(new Error('Failed to load hls.js')); + }; + document.head.appendChild(script); + }); + return App._hlsPromise; +}; diff --git a/frontend/js/ui.js b/frontend/js/ui.js index 97f1312..26e53c3 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -556,6 +556,9 @@ App.ui = App.ui || {}; const searchInput = document.getElementById('search-input'); const clearSearchBtn = document.getElementById('search-clear-btn'); if (searchInput && clearSearchBtn) { + let searchDebounce = null; + const SEARCH_DEBOUNCE_MS = 300; + const updateClearVisibility = () => { const hasValue = searchInput.value.trim().length > 0; clearSearchBtn.classList.toggle('is-visible', hasValue); @@ -565,13 +568,24 @@ App.ui = App.ui || {}; clearSearchBtn.addEventListener('click', (event) => { event.preventDefault(); if (!searchInput.value) return; + if (searchDebounce) clearTimeout(searchDebounce); searchInput.value = ''; updateClearVisibility(); App.videos.handleSearch(''); searchInput.focus(); }); + // Update the clear button immediately for snappy feedback, but + // debounce the actual reload so typing doesn't wipe the grid and + // fire a backend request on every keystroke. searchInput.addEventListener('input', updateClearVisibility); + searchInput.addEventListener('input', () => { + if (searchDebounce) clearTimeout(searchDebounce); + searchDebounce = setTimeout(() => { + searchDebounce = null; + App.videos.handleSearch(searchInput.value); + }, SEARCH_DEBOUNCE_MS); + }); updateClearVisibility(); } diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 9e6b075..079f8da 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -110,7 +110,7 @@ App.videos = App.videos || {}; App.videos.buildImageProxyUrl = function(imageUrl) { if (!imageUrl) return ''; try { - return `/api/image?url=${encodeURIComponent(imageUrl)}&ts=${Date.now()}`; + return `/api/image?url=${encodeURIComponent(imageUrl)}`; } catch (err) { return ''; } @@ -290,14 +290,16 @@ App.videos = App.videos || {}; const tagsMarkup = tags.length ? `
${tags.map(tag => ``).join('')}
` : ''; + const liveBadge = v.isLive ? '● LIVE' : ''; card.innerHTML = ` + ${liveBadge} - ${v.title} + ${v.title} @@ -432,11 +434,16 @@ App.videos = App.videos || {}; App.videos.handleSearch = function(value) { if (typeof value === 'string') { const searchInput = document.getElementById('search-input'); - if (searchInput) { - if (searchInput.value !== value) { - searchInput.value = value; - } - searchInput.dispatchEvent(new Event('input', { bubbles: true })); + if (searchInput && searchInput.value !== value) { + searchInput.value = value; + } + // Keep the clear button in sync without re-dispatching an `input` + // event (which would re-trigger the debounced reload listener). + const clearBtn = document.getElementById('search-clear-btn'); + if (searchInput && clearBtn) { + const hasValue = searchInput.value.trim().length > 0; + clearBtn.classList.toggle('is-visible', hasValue); + clearBtn.disabled = !hasValue; } } state.currentPage = 1; @@ -600,6 +607,8 @@ App.videos = App.videos || {}; let sourceUrl = ''; let referer = ''; let userAgent = ''; + const isLive = !!(videoOrUrl && typeof videoOrUrl === 'object' && + (videoOrUrl.isLive || (videoOrUrl.meta && videoOrUrl.meta.isLive))); if (typeof videoOrUrl === 'string') { sourceUrl = videoOrUrl; } else if (videoOrUrl && typeof videoOrUrl === 'object') { @@ -634,7 +643,7 @@ App.videos = App.videos || {}; referer = ''; } } - return { url: sourceUrl, referer, userAgent }; + return { url: sourceUrl, referer, userAgent, isLive }; }; // Builds a proxied stream URL. Extra params other than `url` are forwarded @@ -644,7 +653,8 @@ App.videos = App.videos || {}; if (!resolved.url) return ''; const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : ''; const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : ''; - return `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}`; + const liveParam = resolved.isLive ? '&live=1' : ''; + return `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`; }; App.videos.downloadVideo = function(video) {