From 5d0a3dab40bec2b1c551969a1af7370215f5d0c7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 11 Sep 2026 08:55:20 +0200 Subject: [PATCH 001/149] fix: render HTML in YouTube comments --- apps/web/src/components/rich-text.tsx | 131 ++++++++++---------- apps/web/src/lib/rich-text.ts | 168 ++++++++++++++++++++++++++ apps/web/tests/rich-text.test.ts | 20 +++ 3 files changed, 249 insertions(+), 70 deletions(-) create mode 100644 apps/web/src/lib/rich-text.ts create mode 100644 apps/web/tests/rich-text.test.ts diff --git a/apps/web/src/components/rich-text.tsx b/apps/web/src/components/rich-text.tsx index 1246721b..97e3c0d8 100644 --- a/apps/web/src/components/rich-text.tsx +++ b/apps/web/src/components/rich-text.tsx @@ -1,59 +1,7 @@ -import { useState } from "react"; +import { type ReactNode, useState } from "react"; +import { parseRichTextMarkup, parseTextSegments, type RichTextNode } from "../lib/rich-text"; import { ExternalLinkModal } from "./external-link-modal"; -type Segment = - | { id: string; type: "text"; value: string } - | { id: string; type: "url"; value: string } - | { id: string; type: "timecode"; value: string; seconds: number }; - -function parseTimestampToSeconds(value: string): number | null { - const parts = value.split(":").map((part) => Number(part)); - if (parts.some((part) => !Number.isFinite(part))) return null; - if (parts.length === 2) { - const [minutes, seconds] = parts; - return minutes * 60 + seconds; - } - if (parts.length === 3) { - const [hours, minutes, seconds] = parts; - return hours * 3600 + minutes * 60 + seconds; - } - return null; -} - -function parseSegments(text: string): Segment[] { - const regex = /https?:\/\/[^\s\])"',;:!>]+|\b(?:\d{1,2}:)?[0-5]?\d:[0-5]\d\b/g; - const segments: Segment[] = []; - let lastIndex = 0; - let counter = 0; - let match = regex.exec(text); - while (match !== null) { - if (match.index > lastIndex) { - segments.push({ - id: `t${counter++}`, - type: "text", - value: text.slice(lastIndex, match.index), - }); - } - const value = match[0]; - if (value.startsWith("http://") || value.startsWith("https://")) { - segments.push({ id: `u${counter++}`, type: "url", value }); - } else { - const seconds = parseTimestampToSeconds(value); - if (seconds === null) { - segments.push({ id: `t${counter++}`, type: "text", value }); - } else { - segments.push({ id: `c${counter++}`, type: "timecode", value, seconds }); - } - } - lastIndex = match.index + match[0].length; - match = regex.exec(text); - } - if (lastIndex < text.length) { - segments.push({ id: `t${counter}`, type: "text", value: text.slice(lastIndex) }); - } - return segments; -} - type RichTextProps = { text: string; onSeekTimestamp?: (seconds: number) => void; @@ -61,38 +9,81 @@ type RichTextProps = { export function RichText({ text, onSeekTimestamp }: RichTextProps) { const [pendingUrl, setPendingUrl] = useState(null); - const segments = parseSegments(text); + const nodes = parseRichTextMarkup(text); - return ( - - {segments.map((seg) => - seg.type === "text" ? ( - {seg.value} - ) : seg.type === "url" ? ( + function renderNodes(children: RichTextNode[], keyPrefix: string): ReactNode[] { + return children.flatMap((node, index) => renderNode(node, `${keyPrefix}-${index}`)); + } + + function renderNode(node: RichTextNode, key: string): ReactNode[] { + if (node.type === "text") { + return parseTextSegments(node.value, `${key}-`).map((segment) => + segment.type === "text" ? ( + {segment.value} + ) : segment.type === "url" ? ( { event.preventDefault(); - setPendingUrl(seg.value); + setPendingUrl(segment.value); }} className="text-accent hover:text-accent-strong underline underline-offset-2 transition-colors break-all text-left align-baseline" > - {seg.value} + {segment.value} ) : onSeekTimestamp ? ( ) : ( - {seg.value} + {segment.value} ), - )} + ); + } + if (node.type === "break") return [
]; + if (node.type === "link") { + return [ + { + event.preventDefault(); + setPendingUrl(node.href); + }} + className="text-accent hover:text-accent-strong underline underline-offset-2 transition-colors break-all text-left align-baseline" + > + {renderNodes(node.children, key)} + , + ]; + } + const children = renderNodes(node.children, key); + switch (node.tag) { + case "strong": + return [{children}]; + case "em": + return [{children}]; + case "u": + return [{children}]; + case "s": + return [{children}]; + case "code": + return [{children}]; + case "kbd": + return [{children}]; + case "mark": + return [{children}]; + } + } + + return ( + + {renderNodes(nodes, "rich-text")} {pendingUrl && ( 0 && nodes.at(-1)?.type !== "break") nodes.push({ type: "break" }); +} + +function formatTag(tag: string): RichTextFormat | null { + if (tag === "b") return "strong"; + if (tag === "i") return "em"; + if (tag === "strike" || tag === "del") return "s"; + return FORMAT_TAGS.has(tag) ? (tag as RichTextFormat) : null; +} + +function parseChildNodes(nodes: NodeListOf): RichTextNode[] { + const result: RichTextNode[] = []; + + for (const node of nodes) { + if (node.nodeType === 3) { + if (node.nodeValue) result.push({ type: "text", value: node.nodeValue }); + continue; + } + if (node.nodeType !== 1) continue; + + const element = node as Element; + const tag = element.tagName.toLowerCase(); + if (OMIT_TAGS.has(tag)) continue; + if (tag === "br") { + result.push({ type: "break" }); + continue; + } + + const children = parseChildNodes(element.childNodes); + if (tag === "a") { + const href = element.getAttribute("href"); + if (href && isSafeHttpUrl(href)) result.push({ type: "link", href, children }); + else result.push(...children); + continue; + } + const formattedTag = formatTag(tag); + if (formattedTag) { + result.push({ type: "format", tag: formattedTag, children }); + continue; + } + if (BLOCK_TAGS.has(tag)) { + appendBreak(result); + result.push(...children); + appendBreak(result); + continue; + } + result.push(...children); + } + + return result; +} + +function parseDocument(source: string): HTMLElement | null { + if (typeof DOMParser === "undefined") return null; + return new DOMParser().parseFromString(source, "text/html").body; +} + +export function parseRichTextMarkup(text: string): RichTextNode[] { + const body = parseDocument(text); + if (!body) return [{ type: "text", value: text }]; + + let nodes = parseChildNodes(body.childNodes); + if (body.children.length === 0 && /<\s*(?:a|br|b|strong|em|i|u|s|p|div)\b/i.test(text)) { + const decoded = body.textContent ?? text; + const decodedBody = decoded === text ? null : parseDocument(decoded); + if (decodedBody) nodes = parseChildNodes(decodedBody.childNodes); + } + return nodes; +} + +function parseTimestampToSeconds(value: string): number | null { + const parts = value.split(":").map((part) => Number(part)); + if (parts.some((part) => !Number.isFinite(part))) return null; + if (parts.length === 2) { + const [minutes, seconds] = parts; + return minutes * 60 + seconds; + } + if (parts.length === 3) { + const [hours, minutes, seconds] = parts; + return hours * 3600 + minutes * 60 + seconds; + } + return null; +} + +export function parseTextSegments(text: string, idPrefix = ""): RichTextSegment[] { + const regex = /https?:\/\/[^\s\])"',;:!>]+|\b(?:\d{1,2}:)?[0-5]?\d:[0-5]\d\b/g; + const segments: RichTextSegment[] = []; + let lastIndex = 0; + let counter = 0; + let match = regex.exec(text); + while (match !== null) { + if (match.index > lastIndex) { + segments.push({ + id: `${idPrefix}t${counter++}`, + type: "text", + value: text.slice(lastIndex, match.index), + }); + } + const value = match[0]; + if (value.startsWith("http://") || value.startsWith("https://")) { + segments.push({ id: `${idPrefix}u${counter++}`, type: "url", value }); + } else { + const seconds = parseTimestampToSeconds(value); + if (seconds === null) segments.push({ id: `${idPrefix}t${counter++}`, type: "text", value }); + else segments.push({ id: `${idPrefix}c${counter++}`, type: "timecode", value, seconds }); + } + lastIndex = match.index + match[0].length; + match = regex.exec(text); + } + if (lastIndex < text.length) { + segments.push({ id: `${idPrefix}t${counter}`, type: "text", value: text.slice(lastIndex) }); + } + return segments; +} diff --git a/apps/web/tests/rich-text.test.ts b/apps/web/tests/rich-text.test.ts new file mode 100644 index 00000000..0601e950 --- /dev/null +++ b/apps/web/tests/rich-text.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { parseTextSegments } from "../src/lib/rich-text"; + +describe("rich text segments", () => { + test("keeps links, timecodes, and surrounding text interactive", () => { + expect(parseTextSegments("Watch 1:23 https://example.com/video")).toEqual([ + { id: "t0", type: "text", value: "Watch " }, + { id: "c1", type: "timecode", value: "1:23", seconds: 83 }, + { id: "t2", type: "text", value: " " }, + { id: "u3", type: "url", value: "https://example.com/video" }, + ]); + }); + + test("does not create empty segments", () => { + expect(parseTextSegments("")).toEqual([]); + expect(parseTextSegments("1:23")).toEqual([ + { id: "c0", type: "timecode", value: "1:23", seconds: 83 }, + ]); + }); +}); From 858abd9051e1f221e528b82e0c44cc655bed726c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 11 Sep 2026 08:56:18 +0200 Subject: [PATCH 002/149] fix: keep rich text format type private --- apps/web/src/lib/rich-text.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/rich-text.ts b/apps/web/src/lib/rich-text.ts index 70e3b880..4795007b 100644 --- a/apps/web/src/lib/rich-text.ts +++ b/apps/web/src/lib/rich-text.ts @@ -3,7 +3,7 @@ export type RichTextSegment = | { id: string; type: "url"; value: string } | { id: string; type: "timecode"; value: string; seconds: number }; -export type RichTextFormat = "strong" | "em" | "u" | "s" | "code" | "kbd" | "mark"; +type RichTextFormat = "strong" | "em" | "u" | "s" | "code" | "kbd" | "mark"; export type RichTextNode = | { type: "text"; value: string } From a9a7da466470afeecde8f7116f2d3eb65cd0278e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 12 Sep 2026 15:33:49 +0200 Subject: [PATCH 003/149] fix: remove duplicate player keyboard animation --- .../player-play-pause-indicator.tsx | 42 ------------------- .../video-player-playback-tools.tsx | 2 - apps/web/src/styles/player-hotkeys.css | 15 ------- 3 files changed, 59 deletions(-) delete mode 100644 apps/web/src/components/player-play-pause-indicator.tsx diff --git a/apps/web/src/components/player-play-pause-indicator.tsx b/apps/web/src/components/player-play-pause-indicator.tsx deleted file mode 100644 index 37b60454..00000000 --- a/apps/web/src/components/player-play-pause-indicator.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Pause, Play } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { useMediaState } from "../lib/vidstack"; - -export function PlayerPlayPauseIndicator() { - const paused = useMediaState("paused"); - const canPlay = useMediaState("canPlay"); - const [pulse, setPulse] = useState(0); - const prevPausedRef = useRef(paused); - const readyRef = useRef(false); - - useEffect(() => { - if (!canPlay) return; - if (!readyRef.current) { - readyRef.current = true; - prevPausedRef.current = paused; - return; - } - if (paused === prevPausedRef.current) return; - prevPausedRef.current = paused; - setPulse((n) => n + 1); - }, [paused, canPlay]); - - useEffect(() => { - if (pulse === 0) return; - const timer = window.setTimeout(() => setPulse(0), 500); - return () => window.clearTimeout(timer); - }, [pulse]); - - if (pulse === 0) return null; - const Icon = paused ? Pause : Play; - return ( -
-
-
-
- ); -} diff --git a/apps/web/src/components/video-player-playback-tools.tsx b/apps/web/src/components/video-player-playback-tools.tsx index 2c1a6af7..77ca4246 100644 --- a/apps/web/src/components/video-player-playback-tools.tsx +++ b/apps/web/src/components/video-player-playback-tools.tsx @@ -4,7 +4,6 @@ import { useMediaState } from "../lib/vidstack"; import type { SponsorBlockSegmentItem } from "../types/api"; import { PlayerHotkeys } from "./player-hotkeys"; import { SponsorBlockSkipper } from "./player-internals"; -import { PlayerPlayPauseIndicator } from "./player-play-pause-indicator"; import { SponsorBlockBar } from "./sponsorblock-bar"; import { SponsorBlockCurrentSegment } from "./sponsorblock-current-segment"; import { SponsorBlockSkipNotice } from "./sponsorblock-skip-notice"; @@ -28,7 +27,6 @@ export function VideoPlayerPlaybackTools(props: Props) { return ( <> {!compact && } - {!compact && !props.audioOnly && } {shouldRunSponsorBlockAutoSkip(props) && props.autoSkipSegments && ( Date: Sun, 13 Sep 2026 17:48:49 +0200 Subject: [PATCH 004/149] feat: show remote login diagnostics with copy button --- apps/web/messages/de.json | 6 +- apps/web/messages/en.json | 6 +- apps/web/messages/fr.json | 6 +- .../components/youtube-remote-diagnostics.tsx | 52 ++++++++++ .../youtube-session-browser-panel.tsx | 5 + .../src/hooks/use-youtube-remote-browser.ts | 99 ++++++++----------- apps/web/src/lib/youtube-remote-messages.ts | 83 ++++++++++++++++ apps/web/src/routes/youtube-session.tsx | 1 + .../web/tests/youtube-remote-messages.test.ts | 48 +++++++++ 9 files changed, 245 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/components/youtube-remote-diagnostics.tsx create mode 100644 apps/web/src/lib/youtube-remote-messages.ts create mode 100644 apps/web/tests/youtube-remote-messages.test.ts diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 7cd3fddf..ea3e739c 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1321,5 +1321,9 @@ "community_lemmy_acknowledge": "ICH HABE ES VERSTANDEN! Alles klar", "portability_preparing_upload": "Kontodaten werden vorbereitet und hochgeladen...", "portability_takeout_metadata_limit": "Die Kontodaten in diesem Archiv überschreiten die Importgrenzen oder sind verschlüsselt. Exportiere YouTube separat in kleineren ZIP-Dateien.", - "portability_takeout_no_metadata": "Keine YouTube-Kontodaten gefunden. Wähle eine originale Takeout-ZIP mit deinem Verlauf, deinen Abonnements oder deinen Wiedergabelisten statt eines reinen Video-Teils oder einer ZIP-Datei, die weitere ZIP-Dateien enthält." + "portability_takeout_no_metadata": "Keine YouTube-Kontodaten gefunden. Wähle eine originale Takeout-ZIP mit deinem Verlauf, deinen Abonnements oder deinen Wiedergabelisten statt eines reinen Video-Teils oder einer ZIP-Datei, die weitere ZIP-Dateien enthält.", + "ui_remote_diagnostics": "Diagnose", + "ui_copy_diagnostics": "Diagnose kopieren", + "ui_diagnostics_copied": "Kopiert", + "ui_remote_diagnostics_hint": "Teile diese Zeilen in einem Fehlerbericht. Sie enthalten keine Passwörter und keine Cookie-Werte." } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index b002ba80..5217c739 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1321,5 +1321,9 @@ "ui_remote_phase_capturing_session": "Capturing session", "ui_remote_phase_connected": "Connected", "ui_remote_phase_closed": "Closed", - "ui_remote_phase_error": "Error" + "ui_remote_phase_error": "Error", + "ui_remote_diagnostics": "Diagnostics", + "ui_copy_diagnostics": "Copy diagnostics", + "ui_diagnostics_copied": "Copied", + "ui_remote_diagnostics_hint": "Share these lines in a bug report. They contain no passwords and no cookie values." } diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 27a153c9..dbd0be19 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -1321,5 +1321,9 @@ "ui_remote_phase_capturing_session": "Capture de la session", "ui_remote_phase_connected": "Connecté", "ui_remote_phase_closed": "Fermé", - "ui_remote_phase_error": "Erreur" + "ui_remote_phase_error": "Erreur", + "ui_remote_diagnostics": "Diagnostics", + "ui_copy_diagnostics": "Copier les diagnostics", + "ui_diagnostics_copied": "Copié", + "ui_remote_diagnostics_hint": "Partage ces lignes dans un rapport de bug. Elles ne contiennent ni mot de passe ni valeur de cookie." } diff --git a/apps/web/src/components/youtube-remote-diagnostics.tsx b/apps/web/src/components/youtube-remote-diagnostics.tsx new file mode 100644 index 00000000..cc4ee78f --- /dev/null +++ b/apps/web/src/components/youtube-remote-diagnostics.tsx @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from "react"; +import { formatYoutubeRemoteLogs, type YoutubeRemoteLogLine } from "../lib/youtube-remote-messages"; +import { m } from "../paraglide/messages.js"; + +type Props = { + logs: YoutubeRemoteLogLine[]; +}; + +export function YoutubeRemoteDiagnostics({ logs }: Props) { + const [copied, setCopied] = useState(false); + const listRef = useRef(null); + const text = formatYoutubeRemoteLogs(logs); + + useEffect(() => { + const list = listRef.current; + if (list && text.length > 0) list.scrollTop = list.scrollHeight; + }, [text]); + + function copy() { + void navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } + + return ( +
+ + {m.ui_remote_diagnostics()} ({logs.length}) + +
+
+

{m.ui_remote_diagnostics_hint()}

+ +
+
+          {text}
+        
+
+
+ ); +} diff --git a/apps/web/src/components/youtube-session-browser-panel.tsx b/apps/web/src/components/youtube-session-browser-panel.tsx index b679a7bf..060692af 100644 --- a/apps/web/src/components/youtube-session-browser-panel.tsx +++ b/apps/web/src/components/youtube-session-browser-panel.tsx @@ -1,11 +1,13 @@ import { useState } from "react"; import type { YoutubeRemoteInput, YoutubeRemotePhase } from "../hooks/use-youtube-remote-browser"; +import type { YoutubeRemoteLogLine } from "../lib/youtube-remote-messages"; import { youtubeRemotePhaseLabel } from "../lib/youtube-remote-phase"; import { m } from "../paraglide/messages.js"; import { ReportBugModal } from "./report-bug-modal"; import { BugIcon } from "./watch-icons"; import { YoutubeIcon } from "./youtube-icon"; import { YoutubeRemoteBrowser } from "./youtube-remote-browser"; +import { YoutubeRemoteDiagnostics } from "./youtube-remote-diagnostics"; type Props = { browserOpen: boolean; @@ -19,6 +21,7 @@ type Props = { frameUrl: string | null; phase: YoutubeRemotePhase; error: string | null; + logs: YoutubeRemoteLogLine[]; onStart: () => void; onCancel: () => void; onInput: (input: YoutubeRemoteInput) => void; @@ -36,6 +39,7 @@ export function YoutubeSessionBrowserPanel({ frameUrl, phase, error, + logs, onStart, onCancel, onInput, @@ -69,6 +73,7 @@ export function YoutubeSessionBrowserPanel({ + {reportOpen && setReportOpen(false)} />} ); diff --git a/apps/web/src/hooks/use-youtube-remote-browser.ts b/apps/web/src/hooks/use-youtube-remote-browser.ts index f9dd708e..94b84030 100644 --- a/apps/web/src/hooks/use-youtube-remote-browser.ts +++ b/apps/web/src/hooks/use-youtube-remote-browser.ts @@ -1,17 +1,15 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { recordClientEvent } from "../lib/client-debug-log"; import { createYoutubeRemoteInputQueue } from "../lib/youtube-remote-input-queue"; +import { + appendYoutubeRemoteLog, + parseYoutubeRemoteMessage, + type YoutubeRemoteLogLine, + type YoutubeRemotePhase, +} from "../lib/youtube-remote-messages"; import { m } from "../paraglide/messages.js"; -export type YoutubeRemotePhase = - | "idle" - | "connecting" - | "opening" - | "awaiting_login" - | "capturing_session" - | "connected" - | "closed" - | "error"; +export type { YoutubeRemotePhase } from "../lib/youtube-remote-messages"; export type YoutubeRemoteInput = | { type: "resize"; width: number; height: number } @@ -23,60 +21,25 @@ export type YoutubeRemoteInput = | { type: "text"; value: string } | { type: "cancel" }; -type RemoteStatus = { - type: "status"; - phase: YoutubeRemotePhase; -}; - -type RemoteError = { - type: "error"; - message: string; -}; - -function isYoutubeRemotePhase(value: string): value is YoutubeRemotePhase { - return ( - value === "idle" || - value === "connecting" || - value === "opening" || - value === "awaiting_login" || - value === "capturing_session" || - value === "connected" || - value === "closed" || - value === "error" - ); -} - -function parseRemoteMessage(value: string): RemoteStatus | RemoteError | null { - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch { - return null; - } - if (!parsed || typeof parsed !== "object" || !("type" in parsed)) return null; - if ( - parsed.type === "status" && - "phase" in parsed && - typeof parsed.phase === "string" && - isYoutubeRemotePhase(parsed.phase) - ) { - return { type: "status", phase: parsed.phase }; - } - if (parsed.type === "error" && "message" in parsed && typeof parsed.message === "string") { - return { type: "error", message: parsed.message }; - } - return null; -} - export function useYoutubeRemoteBrowser(wsUrl: string | null) { const wsRef = useRef(null); const frameRef = useRef(null); const inputCountRef = useRef(0); const lastResizeRef = useRef | null>(null); const inputQueueRef = useRef | null>(null); + const startedAtRef = useRef(Date.now()); const [phase, setPhase] = useState(wsUrl ? "connecting" : "idle"); const [frameUrl, setFrameUrl] = useState(null); const [error, setError] = useState(null); + const [logs, setLogs] = useState([]); + + const pushLog = useCallback( + (source: YoutubeRemoteLogLine["source"], message: string, at?: number) => { + const line = { at: at ?? Date.now() - startedAtRef.current, source, message }; + setLogs((previous) => appendYoutubeRemoteLog(previous, line)); + }, + [], + ); const sendImmediate = useCallback((message: YoutubeRemoteInput) => { const ws = wsRef.current; @@ -120,6 +83,8 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { let active = true; let finished = false; let frameCount = 0; + startedAtRef.current = Date.now(); + setLogs([]); setPhase("connecting"); setError(null); const ws = new WebSocket(wsUrl); @@ -127,27 +92,39 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { wsRef.current = ws; recordClientEvent("youtube_remote.ws_connecting", { hasUrl: true }); + pushLog("client", `websocket connecting ${navigator.userAgent}`); ws.onopen = () => { if (!active) return; recordClientEvent("youtube_remote.ws_open"); + pushLog("client", "websocket open"); if (lastResizeRef.current) sendImmediate(lastResizeRef.current); }; ws.onmessage = (event) => { if (!active) return; if (typeof event.data === "string") { - const message = parseRemoteMessage(event.data); + const message = parseYoutubeRemoteMessage(event.data); + if (message?.type === "log") { + pushLog("token", message.message, message.at); + recordClientEvent("youtube_remote.token_log", { + at: message.at, + message: message.message, + }); + } if (message?.type === "status") { if (message.phase === "connected") finished = true; setPhase(message.phase); recordClientEvent("youtube_remote.status", { phase: message.phase }); + pushLog("client", `status ${message.phase}`); } if (message?.type === "error") { setPhase("error"); setError(m.ui_remote_browser_error()); recordClientEvent("youtube_remote.backend_error", { message: message.message }); + pushLog("client", `backend error: ${message.message}`); } + if (!message) pushLog("client", `unreadable text message ${event.data.slice(0, 80)}`); return; } const blob = event.data instanceof Blob ? event.data : new Blob([event.data]); @@ -158,6 +135,7 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { frameCount += 1; if (frameCount === 1 || frameCount % 50 === 0) { recordClientEvent("youtube_remote.frame", { count: frameCount, bytes: blob.size }); + pushLog("client", `frame #${frameCount} ${blob.size} bytes`); } }; @@ -167,11 +145,16 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { setPhase("error"); setError(m.ui_remote_browser_connection_failed()); recordClientEvent("youtube_remote.ws_error"); + pushLog("client", "websocket error"); }; - ws.onclose = () => { + ws.onclose = (event) => { if (!active) return; recordClientEvent("youtube_remote.ws_close", { finished }); + pushLog( + "client", + `websocket closed code=${event.code} reason=${event.reason} finished=${finished}`, + ); if (!finished) setPhase("closed"); }; @@ -184,7 +167,7 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { inputQueueRef.current?.reset(); setFrameUrl(null); }; - }, [wsUrl, sendImmediate]); + }, [wsUrl, sendImmediate, pushLog]); const send = useCallback( (message: YoutubeRemoteInput) => { @@ -194,5 +177,5 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { [sendImmediate], ); - return { phase, frameUrl, error, send }; + return { phase, frameUrl, error, logs, send }; } diff --git a/apps/web/src/lib/youtube-remote-messages.ts b/apps/web/src/lib/youtube-remote-messages.ts new file mode 100644 index 00000000..82b631cd --- /dev/null +++ b/apps/web/src/lib/youtube-remote-messages.ts @@ -0,0 +1,83 @@ +export type YoutubeRemotePhase = + | "idle" + | "connecting" + | "opening" + | "awaiting_login" + | "capturing_session" + | "connected" + | "closed" + | "error"; + +export type YoutubeRemoteMessage = + | { type: "status"; phase: YoutubeRemotePhase } + | { type: "error"; message: string } + | { type: "log"; at: number; message: string }; + +export type YoutubeRemoteLogLine = { + at: number; + source: "token" | "client"; + message: string; +}; + +export const MAX_REMOTE_LOG_LINES = 300; + +const PHASES: readonly YoutubeRemotePhase[] = [ + "idle", + "connecting", + "opening", + "awaiting_login", + "capturing_session", + "connected", + "closed", + "error", +]; + +function isYoutubeRemotePhase(value: string): value is YoutubeRemotePhase { + return (PHASES as readonly string[]).includes(value); +} + +export function parseYoutubeRemoteMessage(value: string): YoutubeRemoteMessage | null { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object" || !("type" in parsed)) return null; + const record = parsed as Record; + if ( + record.type === "status" && + typeof record.phase === "string" && + isYoutubeRemotePhase(record.phase) + ) { + return { type: "status", phase: record.phase }; + } + if (record.type === "error" && typeof record.message === "string") { + return { type: "error", message: record.message }; + } + if ( + record.type === "log" && + typeof record.message === "string" && + typeof record.at === "number" + ) { + return { type: "log", at: Math.max(0, Math.trunc(record.at)), message: record.message }; + } + return null; +} + +export function appendYoutubeRemoteLog( + lines: YoutubeRemoteLogLine[], + line: YoutubeRemoteLogLine, +): YoutubeRemoteLogLine[] { + const next = [...lines, line]; + return next.length > MAX_REMOTE_LOG_LINES ? next.slice(next.length - MAX_REMOTE_LOG_LINES) : next; +} + +function formatYoutubeRemoteLog(line: YoutubeRemoteLogLine): string { + const seconds = (line.at / 1000).toFixed(1).padStart(6, " "); + return `${seconds}s [${line.source}] ${line.message}`; +} + +export function formatYoutubeRemoteLogs(lines: YoutubeRemoteLogLine[]): string { + return lines.map(formatYoutubeRemoteLog).join("\n"); +} diff --git a/apps/web/src/routes/youtube-session.tsx b/apps/web/src/routes/youtube-session.tsx index 6185a904..ccde14f1 100644 --- a/apps/web/src/routes/youtube-session.tsx +++ b/apps/web/src/routes/youtube-session.tsx @@ -107,6 +107,7 @@ function YoutubeSessionPage() { frameUrl={remote.frameUrl} phase={remote.phase} error={remote.error} + logs={remote.logs} onStart={startRemoteBrowser} onCancel={cancelRemoteBrowser} onInput={remote.send} diff --git a/apps/web/tests/youtube-remote-messages.test.ts b/apps/web/tests/youtube-remote-messages.test.ts new file mode 100644 index 00000000..07d3b52b --- /dev/null +++ b/apps/web/tests/youtube-remote-messages.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; +import { + appendYoutubeRemoteLog, + formatYoutubeRemoteLogs, + MAX_REMOTE_LOG_LINES, + parseYoutubeRemoteMessage, +} from "../src/lib/youtube-remote-messages"; + +test("parses status, error and log messages from the remote browser", () => { + expect(parseYoutubeRemoteMessage('{"type":"status","phase":"awaiting_login"}')).toEqual({ + type: "status", + phase: "awaiting_login", + }); + expect(parseYoutubeRemoteMessage('{"type":"error","message":"Session expired"}')).toEqual({ + type: "error", + message: "Session expired", + }); + expect(parseYoutubeRemoteMessage('{"type":"log","at":1234.7,"message":"phase opening"}')).toEqual( + { + type: "log", + at: 1234, + message: "phase opening", + }, + ); +}); + +test("rejects unknown phases, malformed logs and invalid json", () => { + expect(parseYoutubeRemoteMessage('{"type":"status","phase":"unknown"}')).toBeNull(); + expect(parseYoutubeRemoteMessage('{"type":"log","message":"missing at"}')).toBeNull(); + expect(parseYoutubeRemoteMessage("not json")).toBeNull(); +}); + +test("keeps a bounded, readable log", () => { + let lines = appendYoutubeRemoteLog([], { at: 0, source: "client", message: "websocket open" }); + lines = appendYoutubeRemoteLog(lines, { + at: 1500, + source: "token", + message: "phase awaiting_login", + }); + expect(formatYoutubeRemoteLogs(lines)).toBe( + " 0.0s [client] websocket open\n 1.5s [token] phase awaiting_login", + ); + for (let index = 0; index < MAX_REMOTE_LOG_LINES + 5; index += 1) { + lines = appendYoutubeRemoteLog(lines, { at: index, source: "token", message: `line ${index}` }); + } + expect(lines).toHaveLength(MAX_REMOTE_LOG_LINES); + expect(lines[0]?.message).toBe("line 5"); +}); From b5c5c4d067c440596f3302cb69b2308619b81e4a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 13 Sep 2026 18:47:04 +0200 Subject: [PATCH 005/149] fix: keep remote login diagnostics after the session ends --- apps/web/src/components/youtube-session-browser-panel.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/src/components/youtube-session-browser-panel.tsx b/apps/web/src/components/youtube-session-browser-panel.tsx index 060692af..d6300f10 100644 --- a/apps/web/src/components/youtube-session-browser-panel.tsx +++ b/apps/web/src/components/youtube-session-browser-panel.tsx @@ -112,6 +112,11 @@ export function YoutubeSessionBrowserPanel({ {m.ui_retry_video()} )} + {logs.length > 0 && ( +
+ +
+ )} ); } From c6844ae714c8230714ee7bc878521a128fa047df Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 13 Sep 2026 19:19:33 +0200 Subject: [PATCH 006/149] feat: log client-side remote login diagnostics --- .../src/components/youtube-remote-browser.tsx | 39 +++++++-- .../youtube-session-browser-panel.tsx | 10 ++- .../src/hooks/use-youtube-remote-browser.ts | 81 ++++++++++++++----- apps/web/src/routes/youtube-session.tsx | 1 + 4 files changed, 103 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/youtube-remote-browser.tsx b/apps/web/src/components/youtube-remote-browser.tsx index 4b8bf32a..59223222 100644 --- a/apps/web/src/components/youtube-remote-browser.tsx +++ b/apps/web/src/components/youtube-remote-browser.tsx @@ -10,6 +10,7 @@ type Props = { phase: YoutubeRemotePhase; error: string | null; onInput: (message: YoutubeRemoteInput) => void; + onLog: (message: string) => void; }; function modifiers(event: KeyboardEvent): string[] { @@ -29,10 +30,11 @@ function isPasteShortcut(event: KeyboardEvent): boolean { return event.key.toLowerCase() === "v" && (event.ctrlKey || event.metaKey) && !event.altKey; } -export function YoutubeRemoteBrowser({ frameUrl, phase, error, onInput }: Props) { +export function YoutubeRemoteBrowser({ frameUrl, phase, error, onInput, onLog }: Props) { const rootRef = useRef(null); const inputRef = useRef(null); const pointerIdRef = useRef(null); + const clickCountRef = useRef(0); const [frameSize, setFrameSize] = useState(null); const [viewportSize, setViewportSize] = useState(null); @@ -46,11 +48,14 @@ export function YoutubeRemoteBrowser({ frameUrl, phase, error, onInput }: Props) setViewportSize((previous) => previous?.width === width && previous.height === height ? previous : { width, height }, ); + onLog( + `surface ${width}x${height} rect=${Math.round(root.getBoundingClientRect().top)}px from top`, + ); onInput({ type: "resize", width, height }); }); observer.observe(root); return () => observer.disconnect(); - }, [onInput]); + }, [onInput, onLog]); useEffect(() => { if (!frameUrl) { @@ -75,6 +80,15 @@ export function YoutubeRemoteBrowser({ frameUrl, phase, error, onInput }: Props) return mapYoutubeRemotePointer(event.clientX, event.clientY, rect, frameSize, viewportSize); } + function logClick(event: PointerEvent, mapped: { x: number; y: number }) { + clickCountRef.current += 1; + if (clickCountRef.current > 5) return; + const rect = event.currentTarget.getBoundingClientRect(); + onLog( + `click #${clickCountRef.current} client=${Math.round(event.clientX)},${Math.round(event.clientY)} rect=${Math.round(rect.left)},${Math.round(rect.top)} ${Math.round(rect.width)}x${Math.round(rect.height)} frame=${frameSize?.width ?? "?"}x${frameSize?.height ?? "?"} viewport=${viewportSize?.width ?? "?"}x${viewportSize?.height ?? "?"} mapped=${mapped.x},${mapped.y} pointer=${event.pointerType} focused=${document.activeElement === inputRef.current}`, + ); + } + function releasePointer(event: PointerEvent) { if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); @@ -93,9 +107,15 @@ export function YoutubeRemoteBrowser({ frameUrl, phase, error, onInput }: Props) alt="" className="h-full w-full object-contain" onLoad={(event) => { - setFrameSize({ + const next = { width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight, + }; + setFrameSize((previous) => { + if (previous?.width === next.width && previous.height === next.height) + return previous; + onLog(`frame size ${next.width}x${next.height}`); + return next; }); }} /> @@ -117,13 +137,21 @@ export function YoutubeRemoteBrowser({ frameUrl, phase, error, onInput }: Props) value="" onChange={() => undefined} className="absolute inset-0 h-full w-full touch-none resize-none cursor-default border-0 bg-transparent p-0 text-base text-transparent caret-transparent outline-none" + onFocus={() => onLog("input focused")} + onBlur={() => onLog(`input blurred, active=${document.activeElement?.tagName ?? "none"}`)} + onLostPointerCapture={() => onLog("pointer capture lost")} onPointerDown={(event) => { - if (pointerIdRef.current !== null && pointerIdRef.current !== event.pointerId) return; + if (pointerIdRef.current !== null && pointerIdRef.current !== event.pointerId) { + onLog(`pointer down ignored, another pointer ${pointerIdRef.current} is active`); + return; + } event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture(event.pointerId); pointerIdRef.current = event.pointerId; - onInput({ type: "pointer", event: "down", ...point(event), button: "left" }); + const mapped = point(event); + logClick(event, mapped); + onInput({ type: "pointer", event: "down", ...mapped, button: "left" }); }} onPointerMove={(event) => { if (pointerIdRef.current !== null && pointerIdRef.current !== event.pointerId) return; @@ -175,6 +203,7 @@ export function YoutubeRemoteBrowser({ frameUrl, phase, error, onInput }: Props) onPaste={(event) => { event.preventDefault(); const value = event.clipboardData.getData("text"); + onLog(`paste ${value.length} chars`); if (value) onInput({ type: "text", value }); }} /> diff --git a/apps/web/src/components/youtube-session-browser-panel.tsx b/apps/web/src/components/youtube-session-browser-panel.tsx index d6300f10..724f8924 100644 --- a/apps/web/src/components/youtube-session-browser-panel.tsx +++ b/apps/web/src/components/youtube-session-browser-panel.tsx @@ -25,6 +25,7 @@ type Props = { onStart: () => void; onCancel: () => void; onInput: (input: YoutubeRemoteInput) => void; + onLog: (message: string) => void; }; export function YoutubeSessionBrowserPanel({ @@ -43,13 +44,20 @@ export function YoutubeSessionBrowserPanel({ onStart, onCancel, onInput, + onLog, }: Props) { const [reportOpen, setReportOpen] = useState(false); if (browserOpen) { return (
- +

{m.ui_phase()} {youtubeRemotePhaseLabel(phase)}.{" "} diff --git a/apps/web/src/hooks/use-youtube-remote-browser.ts b/apps/web/src/hooks/use-youtube-remote-browser.ts index 94b84030..f5c7bd3f 100644 --- a/apps/web/src/hooks/use-youtube-remote-browser.ts +++ b/apps/web/src/hooks/use-youtube-remote-browser.ts @@ -21,6 +21,15 @@ export type YoutubeRemoteInput = | { type: "text"; value: string } | { type: "cancel" }; +function describeInput(message: YoutubeRemoteInput): string { + if (message.type === "pointer") return `pointer ${message.event} ${message.x},${message.y}`; + if (message.type === "key") return `key ${message.event} ${message.key}`; + if (message.type === "text") return `text ${message.value.length} chars`; + if (message.type === "resize") return `resize ${message.width}x${message.height}`; + if (message.type === "wheel") return `wheel ${message.deltaX},${message.deltaY}`; + return message.type; +} + export function useYoutubeRemoteBrowser(wsUrl: string | null) { const wsRef = useRef(null); const frameRef = useRef(null); @@ -41,27 +50,33 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { [], ); - const sendImmediate = useCallback((message: YoutubeRemoteInput) => { - const ws = wsRef.current; - if (!ws || ws.readyState !== WebSocket.OPEN) { - recordClientEvent("youtube_remote.input_dropped", { type: message.type }); - return false; - } - ws.send(JSON.stringify(message)); - inputCountRef.current += 1; - if ( - message.type !== "pointer" || - message.event !== "move" || - inputCountRef.current % 25 === 0 - ) { - recordClientEvent("youtube_remote.input_sent", { - type: message.type, - event: "event" in message ? message.event : null, - length: message.type === "text" ? message.value.length : null, - }); - } - return true; - }, []); + const sendImmediate = useCallback( + (message: YoutubeRemoteInput) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + recordClientEvent("youtube_remote.input_dropped", { type: message.type }); + pushLog( + "client", + `input dropped ${describeInput(message)} readyState=${ws?.readyState ?? "none"}`, + ); + return false; + } + ws.send(JSON.stringify(message)); + inputCountRef.current += 1; + const isMove = message.type === "pointer" && message.event === "move"; + if (!isMove || inputCountRef.current % 25 === 0) { + recordClientEvent("youtube_remote.input_sent", { + type: message.type, + event: "event" in message ? message.event : null, + length: message.type === "text" ? message.value.length : null, + }); + const buffered = ws.bufferedAmount > 0 ? ` buffered=${ws.bufferedAmount}` : ""; + pushLog("client", `sent ${describeInput(message)}${buffered}`); + } + return true; + }, + [pushLog], + ); const canSend = useCallback(() => { const ws = wsRef.current; @@ -83,6 +98,7 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { let active = true; let finished = false; let frameCount = 0; + let lastFrameAt = 0; startedAtRef.current = Date.now(); setLogs([]); setPhase("connecting"); @@ -93,6 +109,17 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { recordClientEvent("youtube_remote.ws_connecting", { hasUrl: true }); pushLog("client", `websocket connecting ${navigator.userAgent}`); + pushLog( + "client", + `window ${window.innerWidth}x${window.innerHeight} dpr=${window.devicePixelRatio} visible=${document.visibilityState} focus=${document.hasFocus()}`, + ); + const onVisibility = () => pushLog("client", `tab ${document.visibilityState}`); + const onError = (event: ErrorEvent) => pushLog("client", `page error ${event.message}`); + const onRejection = (event: PromiseRejectionEvent) => + pushLog("client", `unhandled rejection ${String(event.reason).slice(0, 160)}`); + document.addEventListener("visibilitychange", onVisibility); + window.addEventListener("error", onError); + window.addEventListener("unhandledrejection", onRejection); ws.onopen = () => { if (!active) return; @@ -133,6 +160,11 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { frameRef.current = nextUrl; setFrameUrl(nextUrl); frameCount += 1; + const now = Date.now(); + if (lastFrameAt > 0 && now - lastFrameAt > 2000) { + pushLog("client", `frame gap ${now - lastFrameAt}ms before frame #${frameCount}`); + } + lastFrameAt = now; if (frameCount === 1 || frameCount % 50 === 0) { recordClientEvent("youtube_remote.frame", { count: frameCount, bytes: blob.size }); pushLog("client", `frame #${frameCount} ${blob.size} bytes`); @@ -160,6 +192,9 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { return () => { active = false; + document.removeEventListener("visibilitychange", onVisibility); + window.removeEventListener("error", onError); + window.removeEventListener("unhandledrejection", onRejection); ws.close(); wsRef.current = null; if (frameRef.current) URL.revokeObjectURL(frameRef.current); @@ -177,5 +212,7 @@ export function useYoutubeRemoteBrowser(wsUrl: string | null) { [sendImmediate], ); - return { phase, frameUrl, error, logs, send }; + const log = useCallback((message: string) => pushLog("client", message), [pushLog]); + + return { phase, frameUrl, error, logs, send, log }; } diff --git a/apps/web/src/routes/youtube-session.tsx b/apps/web/src/routes/youtube-session.tsx index ccde14f1..fb272ed7 100644 --- a/apps/web/src/routes/youtube-session.tsx +++ b/apps/web/src/routes/youtube-session.tsx @@ -111,6 +111,7 @@ function YoutubeSessionPage() { onStart={startRemoteBrowser} onCancel={cancelRemoteBrowser} onInput={remote.send} + onLog={remote.log} />

From a6bb60cd27c94290132b5e7767210da67b3b4afc Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 007/149] feat: add subscription group models and selection helpers --- README.md | 2 + .../src/lib/subscription-group-selection.ts | 73 +++++++++++++++ apps/web/src/types/subscription-groups.ts | 16 ++++ .../subscription-group-selection.test.ts | 90 +++++++++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 apps/web/src/lib/subscription-group-selection.ts create mode 100644 apps/web/src/types/subscription-groups.ts create mode 100644 apps/web/tests/subscription-group-selection.test.ts diff --git a/README.md b/README.md index b8ad99df..de4b47ce 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@

The React web client for TypeType.

+You want to know the current position of TypeType about AI ? Go check [this](https://github.com/TypeType-Video/TypeType/blob/dev/AI_TRANSPARENCY.md). + [![Translation status](https://translate.typetype.video/widgets/typetype/-/svg-badge.svg)](https://translate.typetype.video/engage/typetype/) This repository contains the browser application and its nginx container. It owns the user interface, routing, settings, account flows, library views, and integration with the TypeType API and playback package. diff --git a/apps/web/src/lib/subscription-group-selection.ts b/apps/web/src/lib/subscription-group-selection.ts new file mode 100644 index 00000000..970d4316 --- /dev/null +++ b/apps/web/src/lib/subscription-group-selection.ts @@ -0,0 +1,73 @@ +import type { GroupedSubscription, MembershipChange } from "../types/subscription-groups"; + +export function filterGroupChannels( + channels: GroupedSubscription[], + filter: string, + excluded: boolean, + query: string, + selected: ReadonlySet, + onlySelected: boolean, +): GroupedSubscription[] { + const search = query.trim().toLocaleLowerCase(); + return channels.filter((channel) => { + if (onlySelected) return selected.has(channel.channelUrl); + const inScope = + filter === "all" || + (filter === "ungrouped" + ? channel.groupIds.length === 0 + : channel.groupIds.includes(filter) !== excluded); + return ( + inScope && + (!search || `${channel.name} ${channel.channelUrl}`.toLocaleLowerCase().includes(search)) + ); + }); +} + +export function selectGroupResults(selected: ReadonlySet, urls: string[]): Set { + return new Set([...selected, ...urls]); +} + +export function channelMembershipChanges( + channel: GroupedSubscription, + desired: ReadonlySet, +): MembershipChange[] { + return [ + ...[...desired] + .filter((id) => !channel.groupIds.includes(id)) + .map((groupId) => ({ + groupId, + channelUrls: [channel.channelUrl], + action: "add" as const, + })), + ...channel.groupIds + .filter((id) => !desired.has(id)) + .map((groupId) => ({ + groupId, + channelUrls: [channel.channelUrl], + action: "remove" as const, + })), + ]; +} + +export function clearMembershipChanges(channels: GroupedSubscription[]): MembershipChange[] { + const urlsByGroup = new Map(); + for (const channel of channels) { + for (const groupId of channel.groupIds) { + const urls = urlsByGroup.get(groupId) ?? []; + urls.push(channel.channelUrl); + urlsByGroup.set(groupId, urls); + } + } + return [...urlsByGroup].map(([groupId, channelUrls]) => ({ + groupId, + channelUrls, + action: "remove", + })); +} + +export function subscriptionFilterParams(filter = "all"): URLSearchParams { + const params = new URLSearchParams(); + if (filter === "ungrouped") params.set("ungrouped", "true"); + else if (filter !== "all") params.set("groupId", filter); + return params; +} diff --git a/apps/web/src/types/subscription-groups.ts b/apps/web/src/types/subscription-groups.ts new file mode 100644 index 00000000..8353296b --- /dev/null +++ b/apps/web/src/types/subscription-groups.ts @@ -0,0 +1,16 @@ +import type { SubscriptionItem } from "./user"; + +export type SubscriptionGroup = { + id: string; + name: string; + channelCount: number; + createdAt: number; + updatedAt: number; +}; + +export type GroupedSubscription = SubscriptionItem & { groupIds: string[] }; +export type MembershipChange = { + groupId: string; + channelUrls: string[]; + action: "add" | "remove"; +}; diff --git a/apps/web/tests/subscription-group-selection.test.ts b/apps/web/tests/subscription-group-selection.test.ts new file mode 100644 index 00000000..826af5be --- /dev/null +++ b/apps/web/tests/subscription-group-selection.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from "bun:test"; +import { + channelMembershipChanges, + clearMembershipChanges, + filterGroupChannels, + selectGroupResults, + subscriptionFilterParams, +} from "../src/lib/subscription-group-selection"; +import type { GroupedSubscription } from "../src/types/subscription-groups"; + +const channels: GroupedSubscription[] = [ + { + channelUrl: "https://youtube.com/channel/one", + name: "One", + avatarUrl: "", + subscribedAt: 0, + groupIds: ["tech", "music"], + }, + { + channelUrl: "https://youtube.com/channel/two", + name: "Two", + avatarUrl: "", + subscribedAt: 0, + groupIds: ["music"], + }, + { + channelUrl: "https://youtube.com/channel/three", + name: "Three", + avatarUrl: "", + subscribedAt: 0, + groupIds: [], + }, +]; +const names = (items: GroupedSubscription[]) => items.map((item) => item.name); + +test("Not in this group includes channels assigned to other groups", () => { + expect(names(filterGroupChannels(channels, "tech", true, "", new Set(), false))).toEqual([ + "Two", + "Three", + ]); + expect(names(filterGroupChannels(channels, "ungrouped", false, "", new Set(), false))).toEqual([ + "Three", + ]); +}); + +test("search and group filters intersect without altering selection", () => { + const selected = new Set([channels[0].channelUrl]); + expect(names(filterGroupChannels(channels, "music", false, " TWO ", selected, false))).toEqual([ + "Two", + ]); + expect([...selected]).toEqual([channels[0].channelUrl]); + const expanded = selectGroupResults(selected, [channels[1].channelUrl, channels[1].channelUrl]); + expect(expanded.size).toBe(2); + expect(expanded.has(channels[0].channelUrl)).toBe(true); +}); + +test("Show selected reveals selections hidden by both search and group", () => { + expect( + names( + filterGroupChannels( + channels, + "ungrouped", + false, + "no match", + new Set([channels[0].channelUrl]), + true, + ), + ), + ).toEqual(["One"]); +}); + +test("inline save sends only the membership difference", () => { + expect(channelMembershipChanges(channels[0], new Set(["music", "science"]))).toEqual([ + { groupId: "science", channelUrls: [channels[0].channelUrl], action: "add" }, + { groupId: "tech", channelUrls: [channels[0].channelUrl], action: "remove" }, + ]); + expect(channelMembershipChanges(channels[0], new Set(channels[0].groupIds))).toEqual([]); +}); + +test("remove all groups targets only selected channel memberships", () => { + expect(clearMembershipChanges([channels[1], channels[2]])).toEqual([ + { groupId: "music", channelUrls: [channels[1].channelUrl], action: "remove" }, + ]); +}); + +test("feed and subscription filters match the server contract", () => { + expect(subscriptionFilterParams("all").toString()).toBe(""); + expect(subscriptionFilterParams("ungrouped").toString()).toBe("ungrouped=true"); + expect(subscriptionFilterParams("tech").toString()).toBe("groupId=tech"); +}); From 889a6e4fac39ca7190384c0dea8e562e7dd131f5 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 008/149] feat: add subscription group API client and batch writes --- apps/web/src/lib/api-subscription-groups.ts | 83 ++++++++++++++++ .../web/tests/api-subscription-groups.test.ts | 97 +++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 apps/web/src/lib/api-subscription-groups.ts create mode 100644 apps/web/tests/api-subscription-groups.test.ts diff --git a/apps/web/src/lib/api-subscription-groups.ts b/apps/web/src/lib/api-subscription-groups.ts new file mode 100644 index 00000000..0383a9dd --- /dev/null +++ b/apps/web/src/lib/api-subscription-groups.ts @@ -0,0 +1,83 @@ +import type { + GroupedSubscription, + MembershipChange, + SubscriptionGroup, +} from "../types/subscription-groups"; +import type { SubscriptionItem } from "../types/user"; +import { ApiError } from "./api"; +import { authed, authedJson } from "./authed"; +import { API_BASE } from "./env"; +import { subscriptionFilterParams } from "./subscription-group-selection"; + +const GROUPS_URL = `${API_BASE}/subscriptions/groups`; + +export function fetchSubscriptionGroups(): Promise { + return authedJson(GROUPS_URL); +} + +export function fetchGroupMemberships(): Promise { + return authedJson(`${API_BASE}/subscriptions/group-memberships`); +} + +export function fetchFilteredSubscriptions(filter: string): Promise { + return authedJson(`${API_BASE}/subscriptions?${subscriptionFilterParams(filter)}`); +} + +async function groupRequest(path: string, method: string, body?: unknown): Promise { + const response = await authed(`${GROUPS_URL}${path}`, { + method, + ...(body === undefined + ? {} + : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), + }); + if (!response.ok) { + const error: unknown = await response.json().catch(() => null); + const code = + error && typeof error === "object" && "code" in error && typeof error.code === "string" + ? error.code + : null; + throw new ApiError("Subscription group request failed", response.status, code); + } + return response; +} + +export async function createSubscriptionGroup(name: string): Promise { + const response = await groupRequest("", "POST", { name: name.trim() }); + return response.json(); +} + +export async function renameSubscriptionGroup(id: string, name: string): Promise { + await groupRequest(`/${encodeURIComponent(id)}`, "PUT", { name: name.trim() }); +} + +export async function deleteSubscriptionGroup(id: string): Promise { + await groupRequest(`/${encodeURIComponent(id)}`, "DELETE"); +} + +export class MembershipUpdateError extends Error { + readonly failedUrls: string[]; + constructor(failedUrls: string[]) { + super("Some subscription group changes could not be saved"); + this.failedUrls = failedUrls; + } +} + +export async function updateGroupMemberships(changes: MembershipChange[]): Promise { + const failed = new Set(); + for (const change of changes) { + const urls = [...new Set(change.channelUrls)]; + for (let offset = 0; offset < urls.length; offset += 500) { + const channelUrls = urls.slice(offset, offset + 500); + try { + await groupRequest( + `/${encodeURIComponent(change.groupId)}/channels`, + change.action === "add" ? "PUT" : "DELETE", + { channelUrls }, + ); + } catch { + for (const url of channelUrls) failed.add(url); + } + } + } + if (failed.size > 0) throw new MembershipUpdateError([...failed]); +} diff --git a/apps/web/tests/api-subscription-groups.test.ts b/apps/web/tests/api-subscription-groups.test.ts new file mode 100644 index 00000000..d61b2313 --- /dev/null +++ b/apps/web/tests/api-subscription-groups.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { ApiError } from "../src/lib/api"; +import { + createSubscriptionGroup, + deleteSubscriptionGroup, + fetchGroupMemberships, + MembershipUpdateError, + renameSubscriptionGroup, + updateGroupMemberships, +} from "../src/lib/api-subscription-groups"; +import { useAuthStore } from "../src/stores/auth-store"; + +const originalFetch = globalThis.fetch; +type Call = { url: string; method: string; body: Record }; +let calls: Call[] = []; +let failGroup = ""; +beforeEach(() => { + calls = []; + failGroup = ""; + useAuthStore.getState().setToken("subscription-groups-test"); + globalThis.fetch = async (input, init) => { + const url = String(input); + calls.push({ + url, + method: init?.method ?? "GET", + body: typeof init?.body === "string" ? JSON.parse(init.body) : {}, + }); + if (failGroup && url.includes(failGroup)) + return Response.json( + { error: "Conflict", code: "subscription_group_name_conflict" }, + { status: 409 }, + ); + if (!init?.method) return Response.json([]); + if (init.method === "POST") return Response.json({ id: "new", name: "Tech" }, { status: 201 }); + return new Response(null, { status: 204 }); + }; +}); +afterEach(() => { + globalThis.fetch = originalFetch; + useAuthStore.getState().setSignedOut(); +}); + +test("membership projection uses the dedicated endpoint", async () => { + expect(await fetchGroupMemberships()).toEqual([]); + expect(calls[0].url).toBe("/api/subscriptions/group-memberships"); +}); + +test("batch writes deduplicate and split at the 500-channel API limit", async () => { + const urls = Array.from({ length: 1001 }, (_, index) => `https://youtube.com/channel/${index}`); + await updateGroupMemberships([ + { groupId: "tech", action: "add", channelUrls: [...urls, urls[0]] }, + ]); + expect(calls.map((call) => (call.body.channelUrls as string[]).length)).toEqual([500, 500, 1]); + expect(calls.every((call) => call.method === "PUT" && call.url.endsWith("/tech/channels"))).toBe( + true, + ); +}); + +test("failed groups report affected channels while other groups are attempted", async () => { + failGroup = "/failed/"; + try { + await updateGroupMemberships([ + { groupId: "failed", action: "remove", channelUrls: ["one", "two"] }, + { groupId: "ok", action: "remove", channelUrls: ["two"] }, + ]); + throw new Error("Expected a partial failure"); + } catch (error) { + expect(error).toBeInstanceOf(MembershipUpdateError); + if (error instanceof MembershipUpdateError) expect(error.failedUrls).toEqual(["one", "two"]); + } + expect(calls.length).toBe(2); + expect(calls.every((call) => call.method === "DELETE" && call.url.includes("/groups/"))).toBe( + true, + ); +}); + +test("empty membership changes do not make requests", async () => { + await updateGroupMemberships([{ groupId: "tech", action: "add", channelUrls: [] }]); + expect(calls).toEqual([]); +}); + +test("group CRUD handles bodyless success and preserves backend error codes", async () => { + await createSubscriptionGroup(" Tech "); + await renameSubscriptionGroup("new", " Science "); + await deleteSubscriptionGroup("new"); + expect(calls.map((call) => call.method)).toEqual(["POST", "PUT", "DELETE"]); + expect(calls[0].body).toEqual({ name: "Tech" }); + expect(calls[1].body).toEqual({ name: "Science" }); + failGroup = "/groups"; + try { + await createSubscriptionGroup("Tech"); + throw new Error("Expected a duplicate-name failure"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + if (error instanceof ApiError) expect(error.code).toBe("subscription_group_name_conflict"); + } +}); From f012052170393f4e9d48f67841af76075b7fa6b4 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 009/149] feat: translate subscription group management messages --- apps/web/messages/de.json | 69 ++++++++++++++++++++++++++++++++++++++- apps/web/messages/en.json | 69 ++++++++++++++++++++++++++++++++++++++- apps/web/messages/fr.json | 69 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 204 insertions(+), 3 deletions(-) diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index ea3e739c..5849b748 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1325,5 +1325,72 @@ "ui_remote_diagnostics": "Diagnose", "ui_copy_diagnostics": "Diagnose kopieren", "ui_diagnostics_copied": "Kopiert", - "ui_remote_diagnostics_hint": "Teile diese Zeilen in einem Fehlerbericht. Sie enthalten keine Passwörter und keine Cookie-Werte." + "ui_remote_diagnostics_hint": "Teile diese Zeilen in einem Fehlerbericht. Sie enthalten keine Passwörter und keine Cookie-Werte.", + "sg_add": "Hinzufügen", + "sg_add_groups": "Gruppen hinzufügen", + "sg_added": "{count} Kanäle zu {group} hinzugefügt.", + "sg_all_channels": "Alle Kanäle", + "sg_all_subscriptions": "Alle Abonnements", + "sg_back_channels": "Zurück zu den Kanälen", + "sg_back_to_results": "Zurück zu den Ergebnissen", + "sg_change_filters": "Wähle eine andere Gruppe oder ändere deine Suche.", + "sg_channel_count": "{count} Kanäle", + "sg_channel_saved": "Gruppen für {channel} aktualisiert.", + "sg_channels": "Abonnierte Kanäle", + "sg_clear": "Auswahl aufheben", + "sg_clear_confirmation": "Alle Gruppenzugehörigkeiten der {count} ausgewählten Kanäle entfernen, auch außerhalb dieser Ansicht? Du bleibst bei ihnen abonniert.", + "sg_delete_confirmation": "Die Gruppe und die Zugehörigkeiten von {count} Kanälen werden gelöscht. Die Kanäle und deine Abonnements bleiben bestehen.", + "sg_delete_group": "Gruppe löschen", + "sg_delete_named": "„{group}“ löschen?", + "sg_duplicate_name": "Eine Gruppe mit diesem Namen existiert bereits. Wähle einen anderen Namen.", + "sg_edit_named": "Gruppen für {channel} bearbeiten", + "sg_empty_feed": "Noch keine Videos in dieser Ansicht.", + "sg_empty_subscriptions": "Abonniere zuerst Kanäle und organisiere sie dann hier. Gruppen kannst du bereits erstellen.", + "sg_feed_filter": "Abonnements nach Gruppe filtern", + "sg_group_actions": "Aktionen für {group}", + "sg_group_created": "{group} erstellt.", + "sg_group_deleted": "{group} gelöscht. Deine Abonnements bleiben bestehen.", + "sg_group_filters": "Kanäle nach Gruppe filtern", + "sg_group_renamed": "Gruppe umbenannt.", + "sg_groups": "Gruppen", + "sg_hidden_selected": "{count} außerhalb dieser Ansicht", + "sg_in_group": "In Gruppe", + "sg_invalid_name": "Der Gruppenname muss zwischen 1 und 100 Zeichen lang sein.", + "sg_load_error": "Deine Gruppen konnten nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.", + "sg_load_more": "Mehr anzeigen · {shown} von {total}", + "sg_loading": "Gruppen und Kanäle werden geladen…", + "sg_manage_groups": "Gruppen verwalten", + "sg_manager_description": "Organisiere deine Abonnements. Ein Kanal kann mehreren Gruppen angehören.", + "sg_membership_filter": "Gruppenzugehörigkeit filtern", + "sg_memberships": "Gruppen des Kanals", + "sg_memberships_cleared": "Gruppenzugehörigkeiten entfernt. Du bleibst bei diesen Kanälen abonniert.", + "sg_new_group": "Neue Gruppe", + "sg_no_channel_match": "Keine Kanäle in dieser Ansicht", + "sg_no_group_match": "Keine passenden Gruppen. Erstelle eine Gruppe in der Seitenleiste.", + "sg_no_groups": "Erstelle oben deine erste Gruppe. Kanäle können auch ohne Gruppe bleiben.", + "sg_not_in_group": "Nicht in Gruppe", + "sg_outside_named": "Nicht in {group}", + "sg_partial_failure": "Änderungen für {count} Kanäle konnten nicht gespeichert werden. Einige Änderungen waren möglicherweise erfolgreich. Prüfe die aktualisierten Gruppen und versuche es erneut.", + "sg_remove": "Entfernen", + "sg_remove_all": "Aus allen Gruppen entfernen", + "sg_remove_named": "{group} entfernen", + "sg_removed": "{count} Kanäle aus {group} entfernt.", + "sg_rename_group": "Gruppe umbenennen", + "sg_retry": "Erneut versuchen", + "sg_save_error": "Änderungen konnten nicht gespeichert werden. Prüfe deine Verbindung und versuche es erneut.", + "sg_saving": "Änderungen werden gespeichert…", + "sg_search_channels": "Kanäle suchen", + "sg_search_groups": "Gruppen suchen", + "sg_select_hint": "Kanäle zur gemeinsamen Bearbeitung auswählen", + "sg_select_results": "{count} Ergebnisse auswählen", + "sg_selected": "{count} ausgewählt", + "sg_show_selected": "Auswahl anzeigen", + "sg_target_group": "Zielgruppe", + "sg_select_one_result": "1 Ergebnis auswählen", + "sg_one_channel": "1 Kanal", + "sg_added_one": "1 Kanal zu {group} hinzugefügt.", + "sg_removed_one": "1 Kanal aus {group} entfernt.", + "sg_clear_one_confirmation": "Alle Gruppenzugehörigkeiten des ausgewählten Kanals entfernen, auch außerhalb dieser Ansicht? Du bleibst bei ihm abonniert.", + "sg_delete_one_confirmation": "Die Gruppe und die Zugehörigkeit von 1 Kanal werden gelöscht. Der Kanal und dein Abonnement bleiben bestehen.", + "sg_partial_one_failure": "Änderungen für 1 Kanal konnten nicht gespeichert werden. Einige Änderungen waren möglicherweise erfolgreich. Prüfe die aktualisierten Gruppen und versuche es erneut." } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 5217c739..502f457d 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1325,5 +1325,72 @@ "ui_remote_diagnostics": "Diagnostics", "ui_copy_diagnostics": "Copy diagnostics", "ui_diagnostics_copied": "Copied", - "ui_remote_diagnostics_hint": "Share these lines in a bug report. They contain no passwords and no cookie values." + "ui_remote_diagnostics_hint": "Share these lines in a bug report. They contain no passwords and no cookie values.", + "sg_add": "Add", + "sg_add_groups": "Add groups", + "sg_added": "Added {count} channels to {group}.", + "sg_all_channels": "All channels", + "sg_all_subscriptions": "All subscriptions", + "sg_back_channels": "Back to channels", + "sg_back_to_results": "Back to results", + "sg_change_filters": "Try another group or change your search.", + "sg_channel_count": "{count} channels", + "sg_channel_saved": "Updated groups for {channel}.", + "sg_channels": "Subscribed channels", + "sg_clear": "Clear selection", + "sg_clear_confirmation": "Remove all group memberships from {count} selected channels, including any outside this view? You will stay subscribed to them.", + "sg_delete_confirmation": "This deletes the group and removes its memberships for {count} channels. The channels and your subscriptions will be kept.", + "sg_delete_group": "Delete group", + "sg_delete_named": "Delete “{group}”?", + "sg_duplicate_name": "A group with this name already exists. Choose another name.", + "sg_edit_named": "Edit groups for {channel}", + "sg_empty_feed": "No videos in this view yet.", + "sg_empty_subscriptions": "Subscribe to channels first, then organize them here. You can already create groups.", + "sg_feed_filter": "Filter subscriptions by group", + "sg_group_actions": "Actions for {group}", + "sg_group_created": "Created {group}.", + "sg_group_deleted": "Deleted {group}. Your subscriptions are unchanged.", + "sg_group_filters": "Filter channels by group", + "sg_group_renamed": "Group renamed.", + "sg_groups": "Groups", + "sg_hidden_selected": "{count} outside this view", + "sg_in_group": "In group", + "sg_invalid_name": "Use a group name between 1 and 100 characters.", + "sg_load_error": "Your groups could not be loaded. Check your connection and try again.", + "sg_load_more": "Show more · {shown} of {total}", + "sg_loading": "Loading your groups and channels…", + "sg_manage_groups": "Manage groups", + "sg_manager_description": "Organize your subscriptions. A channel can belong to more than one group.", + "sg_membership_filter": "Group membership filter", + "sg_memberships": "Channel groups", + "sg_memberships_cleared": "Group memberships removed. You are still subscribed to these channels.", + "sg_new_group": "New group", + "sg_no_channel_match": "No channels in this view", + "sg_no_group_match": "No matching groups. Create a group in the sidebar.", + "sg_no_groups": "Create your first group above. Leaving channels ungrouped is fine too.", + "sg_not_in_group": "Not in group", + "sg_outside_named": "Not in {group}", + "sg_partial_failure": "Changes for {count} channels could not be saved. Some changes may have succeeded. Review the updated memberships and retry.", + "sg_remove": "Remove", + "sg_remove_all": "Remove from all groups", + "sg_remove_named": "Remove {group}", + "sg_removed": "Removed {count} channels from {group}.", + "sg_rename_group": "Rename group", + "sg_retry": "Try again", + "sg_save_error": "Changes could not be saved. Check your connection and try again.", + "sg_saving": "Saving changes…", + "sg_search_channels": "Search channels", + "sg_search_groups": "Search groups", + "sg_select_hint": "Select channels to edit in bulk", + "sg_select_results": "Select {count} results", + "sg_selected": "{count} selected", + "sg_show_selected": "Show selected", + "sg_target_group": "Target group", + "sg_select_one_result": "Select 1 result", + "sg_one_channel": "1 channel", + "sg_added_one": "Added 1 channel to {group}.", + "sg_removed_one": "Removed 1 channel from {group}.", + "sg_clear_one_confirmation": "Remove all group memberships from the selected channel, even if it is outside this view? You will stay subscribed to it.", + "sg_delete_one_confirmation": "This deletes the group and its membership for 1 channel. The channel and your subscription will be kept.", + "sg_partial_one_failure": "Changes for 1 channel could not be saved. Some changes may have succeeded. Review the updated memberships and retry." } diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index dbd0be19..d5920593 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -1325,5 +1325,72 @@ "ui_remote_diagnostics": "Diagnostics", "ui_copy_diagnostics": "Copier les diagnostics", "ui_diagnostics_copied": "Copié", - "ui_remote_diagnostics_hint": "Partage ces lignes dans un rapport de bug. Elles ne contiennent ni mot de passe ni valeur de cookie." + "ui_remote_diagnostics_hint": "Partage ces lignes dans un rapport de bug. Elles ne contiennent ni mot de passe ni valeur de cookie.", + "sg_add": "Ajouter", + "sg_add_groups": "Ajouter des groupes", + "sg_added": "{count} chaînes ajoutées à {group}.", + "sg_all_channels": "Toutes les chaînes", + "sg_all_subscriptions": "Tous les abonnements", + "sg_back_channels": "Retour aux chaînes", + "sg_back_to_results": "Retour aux résultats", + "sg_change_filters": "Essayez un autre groupe ou modifiez votre recherche.", + "sg_channel_count": "{count} chaînes", + "sg_channel_saved": "Groupes de {channel} mis à jour.", + "sg_channels": "Chaînes suivies", + "sg_clear": "Effacer la sélection", + "sg_clear_confirmation": "Retirer les {count} chaînes sélectionnées de tous leurs groupes, y compris celles hors de cette vue ? Vous resterez abonné à ces chaînes.", + "sg_delete_confirmation": "Le groupe et ses appartenances pour {count} chaînes seront supprimés. Les chaînes et vos abonnements seront conservés.", + "sg_delete_group": "Supprimer le groupe", + "sg_delete_named": "Supprimer « {group} » ?", + "sg_duplicate_name": "Un groupe porte déjà ce nom. Choisissez un autre nom.", + "sg_edit_named": "Modifier les groupes de {channel}", + "sg_empty_feed": "Aucune vidéo dans cette vue pour le moment.", + "sg_empty_subscriptions": "Abonnez-vous à des chaînes, puis organisez-les ici. Vous pouvez déjà créer des groupes.", + "sg_feed_filter": "Filtrer les abonnements par groupe", + "sg_group_actions": "Actions pour {group}", + "sg_group_created": "Groupe {group} créé.", + "sg_group_deleted": "Groupe {group} supprimé. Vos abonnements sont conservés.", + "sg_group_filters": "Filtrer les chaînes par groupe", + "sg_group_renamed": "Groupe renommé.", + "sg_groups": "Groupes", + "sg_hidden_selected": "{count} hors de cette vue", + "sg_in_group": "Dans le groupe", + "sg_invalid_name": "Le nom du groupe doit contenir entre 1 et 100 caractères.", + "sg_load_error": "Impossible de charger vos groupes. Vérifiez votre connexion et réessayez.", + "sg_load_more": "Afficher plus · {shown} sur {total}", + "sg_loading": "Chargement des groupes et des chaînes…", + "sg_manage_groups": "Gérer les groupes", + "sg_manager_description": "Organisez vos abonnements. Une chaîne peut appartenir à plusieurs groupes.", + "sg_membership_filter": "Filtre d’appartenance au groupe", + "sg_memberships": "Groupes de la chaîne", + "sg_memberships_cleared": "Appartenances aux groupes supprimées. Vous restez abonné à ces chaînes.", + "sg_new_group": "Nouveau groupe", + "sg_no_channel_match": "Aucune chaîne dans cette vue", + "sg_no_group_match": "Aucun groupe correspondant. Créez un groupe dans le volet latéral.", + "sg_no_groups": "Créez votre premier groupe ci-dessus. Les chaînes peuvent aussi rester sans groupe.", + "sg_not_in_group": "Hors du groupe", + "sg_outside_named": "Hors de {group}", + "sg_partial_failure": "Les modifications de {count} chaînes n’ont pas pu être enregistrées. Certaines ont pu réussir. Vérifiez les groupes actualisés et réessayez.", + "sg_remove": "Retirer", + "sg_remove_all": "Retirer de tous les groupes", + "sg_remove_named": "Retirer {group}", + "sg_removed": "{count} chaînes retirées de {group}.", + "sg_rename_group": "Renommer le groupe", + "sg_retry": "Réessayer", + "sg_save_error": "Impossible d’enregistrer les modifications. Vérifiez votre connexion et réessayez.", + "sg_saving": "Enregistrement…", + "sg_search_channels": "Rechercher des chaînes", + "sg_search_groups": "Rechercher des groupes", + "sg_select_hint": "Sélectionnez des chaînes à modifier ensemble", + "sg_select_results": "Sélectionner les {count} résultats", + "sg_selected": "{count} sélectionnés", + "sg_show_selected": "Voir la sélection", + "sg_target_group": "Groupe cible", + "sg_select_one_result": "Sélectionner le résultat", + "sg_one_channel": "1 chaîne", + "sg_added_one": "1 chaîne ajoutée à {group}.", + "sg_removed_one": "1 chaîne retirée de {group}.", + "sg_clear_one_confirmation": "Retirer la chaîne sélectionnée de tous ses groupes, même si elle est hors de cette vue ? Vous resterez abonné à cette chaîne.", + "sg_delete_one_confirmation": "Le groupe et son appartenance pour 1 chaîne seront supprimés. La chaîne et votre abonnement seront conservés.", + "sg_partial_one_failure": "Les modifications de 1 chaîne n’ont pas pu être enregistrées. Certaines ont pu réussir. Vérifiez les groupes actualisés et réessayez." } From 488d2549f34271d836fd68ed444fbe87610443e9 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 010/149] feat: load group memberships and refresh subscription caches --- apps/web/src/hooks/use-subscription-groups.ts | 87 +++++++++++++++++++ apps/web/src/hooks/use-subscriptions.ts | 27 ++++-- apps/web/src/lib/profile-query-cache.ts | 2 + apps/web/tests/profile-query-cache.test.ts | 6 ++ 4 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/hooks/use-subscription-groups.ts diff --git a/apps/web/src/hooks/use-subscription-groups.ts b/apps/web/src/hooks/use-subscription-groups.ts new file mode 100644 index 00000000..8bbf236e --- /dev/null +++ b/apps/web/src/hooks/use-subscription-groups.ts @@ -0,0 +1,87 @@ +import { type UseQueryResult, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRef, useState } from "react"; +import { ApiError } from "../lib/api"; +import { + fetchGroupMemberships, + fetchSubscriptionGroups, + MembershipUpdateError, +} from "../lib/api-subscription-groups"; +import { m } from "../paraglide/messages.js"; +import type { GroupedSubscription, SubscriptionGroup } from "../types/subscription-groups"; +import { useAuth } from "./use-auth"; + +const GROUPS_KEY = ["subscription-groups"]; +const GROUP_MEMBERSHIPS_KEY = ["subscription-group-memberships"]; + +export function useSubscriptionGroups(): UseQueryResult { + const { authReady, isAuthed, me } = useAuth(); + return useQuery({ + queryKey: [...GROUPS_KEY, me?.id], + queryFn: fetchSubscriptionGroups, + enabled: authReady && isAuthed, + staleTime: 60_000, + }); +} + +export function useGroupMemberships(): UseQueryResult { + const { authReady, isAuthed, me } = useAuth(); + return useQuery({ + queryKey: [...GROUP_MEMBERSHIPS_KEY, me?.id], + queryFn: fetchGroupMemberships, + enabled: authReady && isAuthed, + staleTime: 60_000, + }); +} + +type GroupActions = { + busy: boolean; + error: string | null; + notice: string | null; + clearError: () => void; + run: (action: () => Promise, success: string) => Promise; +}; + +export function useGroupActions(): GroupActions { + const client = useQueryClient(); + const lock = useRef(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + async function run(action: () => Promise, success: string): Promise { + if (lock.current) return false; + lock.current = true; + setBusy(true); + setError(null); + setNotice(null); + let succeeded = false; + try { + await action(); + succeeded = true; + setNotice(success); + } catch (cause) { + setError( + cause instanceof MembershipUpdateError + ? cause.failedUrls.length === 1 + ? m.sg_partial_one_failure() + : m.sg_partial_failure({ count: cause.failedUrls.length }) + : cause instanceof ApiError && cause.code === "subscription_group_name_conflict" + ? m.sg_duplicate_name() + : cause instanceof ApiError && cause.code === "subscription_group_invalid_name" + ? m.sg_invalid_name() + : m.sg_save_error(), + ); + } finally { + await Promise.all( + [GROUPS_KEY, GROUP_MEMBERSHIPS_KEY, ["subscriptions"], ["subscription-feed"]].map( + (queryKey) => client.invalidateQueries({ queryKey }), + ), + ); + lock.current = false; + setBusy(false); + } + return succeeded; + } + + return { busy, error, notice, clearError: () => setError(null), run }; +} diff --git a/apps/web/src/hooks/use-subscriptions.ts b/apps/web/src/hooks/use-subscriptions.ts index 08a9b5c7..840c3036 100644 --- a/apps/web/src/hooks/use-subscriptions.ts +++ b/apps/web/src/hooks/use-subscriptions.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { fetchFilteredSubscriptions } from "../lib/api-subscription-groups"; import { fetchSubscriptions, subscribe, unsubscribe } from "../lib/api-user"; import { normalizeChannelUrl } from "../lib/channel-url"; import type { SubscriptionItem } from "../types/user"; @@ -23,13 +24,13 @@ function dedupeSubscriptions(data: SubscriptionItem[]): SubscriptionItem[] { return output; } -export function useSubscriptions() { +export function useSubscriptions(filter = "all") { const qc = useQueryClient(); const { authReady, isAuthed } = useAuth(); const query = useQuery({ - queryKey: SUBSCRIPTIONS_KEY, - queryFn: fetchSubscriptions, + queryKey: filter === "all" ? SUBSCRIPTIONS_KEY : [...SUBSCRIPTIONS_KEY, filter], + queryFn: () => (filter === "all" ? fetchSubscriptions() : fetchFilteredSubscriptions(filter)), enabled: authReady && isAuthed, select: dedupeSubscriptions, staleTime: 5 * 60 * 1000, @@ -44,12 +45,28 @@ export function useSubscriptions() { channelUrl: normalizeChannelUrl(item.channelUrl), }); }, - onSuccess: () => qc.invalidateQueries({ queryKey: SUBSCRIPTIONS_KEY }), + onSuccess: () => + Promise.all( + [ + SUBSCRIPTIONS_KEY, + ["subscription-groups"], + ["subscription-group-memberships"], + ["subscription-feed"], + ].map((queryKey) => qc.invalidateQueries({ queryKey })), + ), }); const remove = useMutation({ mutationFn: (channelUrl: string) => (isAuthed ? unsubscribe(channelUrl) : Promise.resolve()), - onSuccess: () => qc.invalidateQueries({ queryKey: SUBSCRIPTIONS_KEY }), + onSuccess: () => + Promise.all( + [ + SUBSCRIPTIONS_KEY, + ["subscription-groups"], + ["subscription-group-memberships"], + ["subscription-feed"], + ].map((queryKey) => qc.invalidateQueries({ queryKey })), + ), }); function isSubscribed(channelUrl: string): boolean { diff --git a/apps/web/src/lib/profile-query-cache.ts b/apps/web/src/lib/profile-query-cache.ts index d37e9e2a..51ee7b60 100644 --- a/apps/web/src/lib/profile-query-cache.ts +++ b/apps/web/src/lib/profile-query-cache.ts @@ -30,6 +30,8 @@ const PROFILE_QUERIES = new Set([ "shorts-recommendations", "shorts-subscriptions-fallback", "subscription-feed", + "subscription-groups", + "subscription-group-memberships", "subscriptions", "watch-later", "watch-recommendations", diff --git a/apps/web/tests/profile-query-cache.test.ts b/apps/web/tests/profile-query-cache.test.ts index d468af04..5225ca51 100644 --- a/apps/web/tests/profile-query-cache.test.ts +++ b/apps/web/tests/profile-query-cache.test.ts @@ -15,12 +15,18 @@ test("refreshes mounted subscriptions without replacing their observer or media const media = { url: "playing-video" }; client.setQueryData(["stream", "playing-video"], media); client.setQueryData(["history"], ["original-video"]); + client.setQueryData(["subscription-groups", "original-profile"], ["old-group"]); + client.setQueryData(["subscription-group-memberships", "original-profile"], ["old-membership"]); client.setQueryData(["search-panel-videos", 0], ["original-recommendation"]); profile = "Test"; const refresh = resetProfileQueries(client); expect(observer.getCurrentResult().data).toBeUndefined(); expect(client.getQueryData(["history"])).toBeUndefined(); + expect(client.getQueryData(["subscription-groups", "original-profile"])).toBeUndefined(); + expect( + client.getQueryData(["subscription-group-memberships", "original-profile"]), + ).toBeUndefined(); expect(client.getQueryData(["search-panel-videos", 0])).toBeUndefined(); expect(client.getQueryData(["stream", "playing-video"])).toBe(media); await refresh; From 4b215fb0e138963a119f6b4bdc16a4b75deb7c7d Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 011/149] feat: add group forms and destructive action confirmations --- .../group-confirm-dialog.tsx | 64 +++++++++++++++++ .../subscription-groups/group-name-form.tsx | 71 +++++++++++++++++++ apps/web/src/styles/subscription-groups.css | 55 ++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 apps/web/src/components/subscription-groups/group-confirm-dialog.tsx create mode 100644 apps/web/src/components/subscription-groups/group-name-form.tsx create mode 100644 apps/web/src/styles/subscription-groups.css diff --git a/apps/web/src/components/subscription-groups/group-confirm-dialog.tsx b/apps/web/src/components/subscription-groups/group-confirm-dialog.tsx new file mode 100644 index 00000000..74ae52fa --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-confirm-dialog.tsx @@ -0,0 +1,64 @@ +import { useEffect, useId, useRef } from "react"; +import { m } from "../../paraglide/messages.js"; + +type Props = { + title: string; + description: string; + confirmLabel: string; + onConfirm: () => void; + onCancel: () => void; +}; + +export function GroupConfirmDialog({ + title, + description, + confirmLabel, + onConfirm, + onCancel, +}: Props): React.JSX.Element { + const dialog = useRef(null); + const cancel = useRef(null); + const titleId = useId(); + const descriptionId = useId(); + useEffect(() => { + const previousFocus = document.activeElement; + const element = dialog.current; + element?.showModal(); + cancel.current?.focus(); + return () => { + element?.close(); + if (previousFocus instanceof HTMLElement) previousFocus.focus(); + }; + }, []); + return ( + { + event.preventDefault(); + onCancel(); + }} + className="m-auto w-[min(28rem,90vw)] border border-border-strong bg-surface p-5 text-fg backdrop:bg-black/60" + > +

+ {title} +

+

+ {description} +

+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/subscription-groups/group-name-form.tsx b/apps/web/src/components/subscription-groups/group-name-form.tsx new file mode 100644 index 00000000..20573efc --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-name-form.tsx @@ -0,0 +1,71 @@ +import { Check, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { m } from "../../paraglide/messages.js"; + +type Props = { + initialName?: string; + busy: boolean; + onSave: (name: string) => Promise; + onCancel?: () => void; +}; + +export function GroupNameForm({ + initialName = "", + busy, + onSave, + onCancel, +}: Props): React.JSX.Element { + const [name, setName] = useState(initialName); + const input = useRef(null); + useEffect(() => { + if (initialName) { + input.current?.focus(); + input.current?.select(); + } + }, [initialName]); + return ( +
{ + if (event.key === "Escape" && onCancel && !busy) { + event.preventDefault(); + onCancel(); + } + }} + onSubmit={async (event) => { + event.preventDefault(); + if (name.trim() && (await onSave(name.trim()))) setName(""); + }} + > + setName(event.target.value)} + disabled={busy} + className="h-9 min-w-0 flex-1 border border-border-strong bg-app px-2 text-sm text-fg placeholder:text-fg-muted" + /> + + {onCancel && ( + + )} +
+ ); +} diff --git a/apps/web/src/styles/subscription-groups.css b/apps/web/src/styles/subscription-groups.css new file mode 100644 index 00000000..6cd7e7ea --- /dev/null +++ b/apps/web/src/styles/subscription-groups.css @@ -0,0 +1,55 @@ +@reference "../index.css"; + +@layer components { + .sg-button { + @apply inline-flex min-h-9 items-center justify-center gap-1.5 border border-border-strong px-3 py-1.5 text-xs font-medium text-fg transition-colors hover:bg-surface-strong disabled:cursor-not-allowed disabled:opacity-40; + } + + .sg-membership-toggle { + @apply bg-app; + } + + .sg-membership-toggle:enabled { + @apply cursor-pointer border-fg-muted bg-surface-strong hover:border-fg hover:bg-surface-soft; + } + + .sg-membership-toggle:enabled[aria-pressed="true"] { + @apply border-fg bg-fg text-app hover:bg-fg-strong; + } + + .sg-chip { + @apply border border-border-strong px-2 py-1 text-xs text-fg-muted; + } + + .sg-menu-item { + @apply px-3 py-2 text-left text-xs hover:bg-surface-strong disabled:opacity-40; + } +} + +.sg-manager :is(button, input, select, a):focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 3px; +} + +.sg-manager :is(input, select) { + caret-color: var(--color-accent); +} + +.sg-combobox:focus-within { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.sg-manager .sg-combobox input:focus-visible { + outline: none; +} + +.sg-manager ::selection { + background: var(--color-accent); + color: var(--color-app); +} + +.sg-manager { + scrollbar-color: var(--color-border-strong) var(--color-surface); + scroll-margin-top: 4rem; +} From 84d5b5b8cf369ce9a4550369ce92de6acd5c9d4a Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 012/149] feat: add subscription group sidebar and group actions --- .../group-sidebar-item.tsx | 114 ++++++++++++++++++ .../subscription-groups/group-sidebar.tsx | 70 +++++++++++ 2 files changed, 184 insertions(+) create mode 100644 apps/web/src/components/subscription-groups/group-sidebar-item.tsx create mode 100644 apps/web/src/components/subscription-groups/group-sidebar.tsx diff --git a/apps/web/src/components/subscription-groups/group-sidebar-item.tsx b/apps/web/src/components/subscription-groups/group-sidebar-item.tsx new file mode 100644 index 00000000..2bba5dd1 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-sidebar-item.tsx @@ -0,0 +1,114 @@ +import { MoreHorizontal } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { m } from "../../paraglide/messages.js"; +import type { SubscriptionGroup } from "../../types/subscription-groups"; +import { GroupNameForm } from "./group-name-form"; + +type Props = { + group: SubscriptionGroup; + active: boolean; + disabled: boolean; + onSelect: () => void; + onRename: (name: string) => Promise; + onCancelRename: () => void; + onDelete: () => void; +}; + +export function GroupSidebarItem({ + group, + active, + disabled, + onSelect, + onRename, + onCancelRename, + onDelete, +}: Props): React.JSX.Element { + const [renaming, setRenaming] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const actionButton = useRef(null); + const wasRenaming = useRef(false); + useEffect(() => { + if (wasRenaming.current && !renaming) actionButton.current?.focus(); + wasRenaming.current = renaming; + }, [renaming]); + if (renaming) + return ( + { + setRenaming(false); + onCancelRename(); + }} + onSave={async (name) => { + const saved = await onRename(name); + if (saved) setRenaming(false); + return saved; + }} + /> + ); + return ( +
+ + + {menuOpen && ( +
{ + if (event.key === "Escape") { + setMenuOpen(false); + actionButton.current?.focus(); + } + }} + > + + +
+ )} +
+ ); +} diff --git a/apps/web/src/components/subscription-groups/group-sidebar.tsx b/apps/web/src/components/subscription-groups/group-sidebar.tsx new file mode 100644 index 00000000..0cdb5cbc --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-sidebar.tsx @@ -0,0 +1,70 @@ +import { Inbox, Users } from "lucide-react"; +import { m } from "../../paraglide/messages.js"; +import type { SubscriptionGroup } from "../../types/subscription-groups"; +import { GroupNameForm } from "./group-name-form"; +import { GroupSidebarItem } from "./group-sidebar-item"; + +type Props = { + groups: SubscriptionGroup[]; + total: number; + ungrouped: number; + filter: string; + disabled: boolean; + onFilter: (value: string) => void; + onCreate: (name: string) => Promise; + onRename: (id: string, name: string) => Promise; + onCancelRename: () => void; + onDelete: (group: SubscriptionGroup) => void; +}; + +export function GroupSidebar(props: Props): React.JSX.Element { + return ( + + ); +} From 3b48af09036a5a8d0607314b2743448e586097fc Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 013/149] feat: add searchable inline group membership editing --- .../channel-group-editor.tsx | 63 +++++++++ .../subscription-groups/group-combobox.tsx | 112 ++++++++++++++++ apps/web/src/hooks/use-group-combobox.ts | 120 ++++++++++++++++++ 3 files changed, 295 insertions(+) create mode 100644 apps/web/src/components/subscription-groups/channel-group-editor.tsx create mode 100644 apps/web/src/components/subscription-groups/group-combobox.tsx create mode 100644 apps/web/src/hooks/use-group-combobox.ts diff --git a/apps/web/src/components/subscription-groups/channel-group-editor.tsx b/apps/web/src/components/subscription-groups/channel-group-editor.tsx new file mode 100644 index 00000000..cf495e3b --- /dev/null +++ b/apps/web/src/components/subscription-groups/channel-group-editor.tsx @@ -0,0 +1,63 @@ +import { m } from "../../paraglide/messages.js"; +import type { GroupedSubscription, SubscriptionGroup } from "../../types/subscription-groups"; +import { GroupCombobox } from "./group-combobox"; + +type Props = { + channel: GroupedSubscription; + groups: SubscriptionGroup[]; + desired: ReadonlySet; + onChange: (ids: Set) => void; + busy: boolean; + onSave: (ids: Set) => void; + onCancel: () => void; +}; + +export function ChannelGroupEditor({ + channel, + groups, + desired, + onChange, + busy, + onSave, + onCancel, +}: Props): React.JSX.Element { + const valid = new Set([...desired].filter((id) => groups.some((group) => group.id === id))); + const changed = + valid.size !== channel.groupIds.length || channel.groupIds.some((id) => !valid.has(id)); + function toggle(id: string): void { + const next = new Set(desired); + if (next.has(id)) next.delete(id); + else next.add(id); + onChange(next); + } + return ( +
{ + event.preventDefault(); + if (changed && !busy) onSave(valid); + }} + onKeyDown={(event) => { + if (event.key === "Escape" && !busy) { + event.preventDefault(); + onCancel(); + } + }} + > +
+ + + +
+
+ ); +} diff --git a/apps/web/src/components/subscription-groups/group-combobox.tsx b/apps/web/src/components/subscription-groups/group-combobox.tsx new file mode 100644 index 00000000..882d0a90 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-combobox.tsx @@ -0,0 +1,112 @@ +import { ChevronDown, Plus, X } from "lucide-react"; +import { useGroupCombobox } from "../../hooks/use-group-combobox"; +import { m } from "../../paraglide/messages.js"; +import type { SubscriptionGroup } from "../../types/subscription-groups"; + +type Props = { + groups: SubscriptionGroup[]; + selected: ReadonlySet; + disabled: boolean; + onToggle: (id: string) => void; +}; + +export function GroupCombobox({ groups, selected, disabled, onToggle }: Props): React.JSX.Element { + const combo = useGroupCombobox(groups, selected, onToggle); + const expanded = combo.open && !disabled; + return ( +
{ + if (!event.currentTarget.contains(event.relatedTarget)) combo.setOpen(false); + }} + > +
+
+ {combo.chosen.map((group) => ( + + ))} + = 0 ? `${combo.listId}-${combo.active}` : undefined + } + autoComplete="off" + placeholder={m.sg_add_groups()} + value={combo.query} + onChange={(event) => combo.search(event.target.value)} + onClick={() => combo.setOpen(true)} + onKeyDown={combo.onKeyDown} + className="h-6 w-20 min-w-16 flex-1 bg-transparent px-1 text-xs text-fg placeholder:text-fg-muted outline-none" + /> +
+ +
+ {expanded && ( +
+ {combo.matches.map((group, index) => ( + + ))} + {combo.matches.length === 0 && ( +

+ {m.sg_no_group_match()} +

+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/hooks/use-group-combobox.ts b/apps/web/src/hooks/use-group-combobox.ts new file mode 100644 index 00000000..ab9c7584 --- /dev/null +++ b/apps/web/src/hooks/use-group-combobox.ts @@ -0,0 +1,120 @@ +import { type KeyboardEvent, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import type { SubscriptionGroup } from "../types/subscription-groups"; + +export function useGroupCombobox( + groups: SubscriptionGroup[], + selected: ReadonlySet, + onToggle: (id: string) => void, +): { + input: React.RefObject; + root: React.RefObject; + list: React.RefObject; + listId: string; + query: string; + open: boolean; + active: number; + placement: { above: boolean; height: number }; + matches: SubscriptionGroup[]; + chosen: SubscriptionGroup[]; + setOpen: (open: boolean) => void; + search: (query: string) => void; + choose: (id: string) => void; + onKeyDown: (event: KeyboardEvent) => void; +} { + const input = useRef(null); + const root = useRef(null); + const list = useRef(null); + const listId = useId(); + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const [highlighted, setHighlighted] = useState(0); + const [placement, setPlacement] = useState({ above: false, height: 224 }); + const chosen = groups.filter((group) => selected.has(group.id)); + const matches = groups.filter( + (group) => + !selected.has(group.id) && + group.name.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()), + ); + const active = Math.min(highlighted, matches.length - 1); + useLayoutEffect(() => { + if (chosen.length > 0 && document.activeElement === input.current) { + input.current?.scrollIntoView({ block: "nearest" }); + } + }, [chosen.length]); + useLayoutEffect(() => { + if (!open) return; + function position(): void { + const bounds = root.current?.getBoundingClientRect(); + if (!bounds) return; + const below = window.innerHeight - bounds.bottom - 12; + const above = bounds.top - 68; + const flip = below < Math.min(224, Math.max(40, matches.length * 36)) && above > below; + setPlacement({ above: flip, height: Math.max(40, Math.min(224, flip ? above : below)) }); + } + position(); + window.addEventListener("resize", position); + window.addEventListener("scroll", position, true); + return () => { + window.removeEventListener("resize", position); + window.removeEventListener("scroll", position, true); + }; + }, [open, matches.length]); + useEffect(() => { + if (open) list.current?.children[active]?.scrollIntoView({ block: "nearest" }); + }, [active, open]); + function search(value: string): void { + setQuery(value); + setHighlighted(0); + setOpen(true); + } + function choose(id: string): void { + onToggle(id); + setQuery(""); + setHighlighted(0); + input.current?.focus(); + } + function onKeyDown(event: KeyboardEvent): void { + if (event.nativeEvent.isComposing) return; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + setOpen(true); + setHighlighted( + !open + ? event.key === "ArrowDown" + ? 0 + : Math.max(0, matches.length - 1) + : Math.max( + 0, + Math.min(matches.length - 1, active + (event.key === "ArrowDown" ? 1 : -1)), + ), + ); + } else if (event.key === "Enter") { + event.preventDefault(); + if (open && matches[active]) choose(matches[active].id); + else setOpen(true); + } else if (event.key === "Escape" && open) { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + } else if (event.key === "Backspace" && !query && chosen.length) { + event.preventDefault(); + onToggle(chosen[chosen.length - 1].id); + } + } + return { + input, + root, + list, + listId, + query, + open, + active, + placement, + matches, + chosen, + setOpen, + search, + choose, + onKeyDown, + }; +} From 3b2b29f4b247608cb76b9a5a40e5572f3a2f75d8 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 014/149] feat: manage channel selection and group edit drafts --- apps/web/src/hooks/use-group-manager.ts | 179 ++++++++++++++++++++++ apps/web/src/hooks/use-group-selection.ts | 47 ++++++ 2 files changed, 226 insertions(+) create mode 100644 apps/web/src/hooks/use-group-manager.ts create mode 100644 apps/web/src/hooks/use-group-selection.ts diff --git a/apps/web/src/hooks/use-group-manager.ts b/apps/web/src/hooks/use-group-manager.ts new file mode 100644 index 00000000..9461b0f6 --- /dev/null +++ b/apps/web/src/hooks/use-group-manager.ts @@ -0,0 +1,179 @@ +import { useState } from "react"; +import { deleteSubscriptionGroup, updateGroupMemberships } from "../lib/api-subscription-groups"; +import { + clearMembershipChanges, + filterGroupChannels, + selectGroupResults, +} from "../lib/subscription-group-selection"; +import { m } from "../paraglide/messages.js"; +import type { GroupedSubscription, SubscriptionGroup } from "../types/subscription-groups"; +import { useGroupSelection } from "./use-group-selection"; +import { useGroupActions } from "./use-subscription-groups"; + +type State = { + actions: ReturnType; + activeFilter: string; + activeGroup: SubscriptionGroup | undefined; + excluded: boolean; + query: string; + chosen: GroupedSubscription[]; + validSelected: Set; + visible: GroupedSubscription[]; + hiddenCount: number; + filterName: string; + disabled: boolean; + editing: string | null; + drafts: ReadonlyMap>; + onlySelected: boolean; + confirmationProps: { title: string; description: string; confirmLabel: string } | null; + setExcluded: (value: boolean) => void; + setQuery: (value: string) => void; + selectResults: () => void; + setOnlySelected: (value: boolean) => void; + setDraft: (url: string, ids: Set) => void; + setConfirmation: (value: SubscriptionGroup | "clear" | null) => void; + changeFilter: (value: string) => void; + toggle: (url: string) => void; + clearSelection: () => void; + bulk: (groupId: string, action: "add" | "remove") => Promise; + confirm: () => Promise; +}; + +export function useGroupManager( + groups: SubscriptionGroup[], + channels: GroupedSubscription[], +): State { + const actions = useGroupActions(); + const [filter, setFilter] = useState("all"); + const [excluded, setExcluded] = useState(false); + const [query, setQuery] = useState(""); + const selection = useGroupSelection(channels); + const [onlySelected, setOnlySelected] = useState(false); + const [confirmation, setConfirmation] = useState(null); + const { chosen, selected: validSelected, drafts, setDraft } = selection; + const activeGroup = groups.find((group) => group.id === filter); + const activeFilter = activeGroup || filter === "ungrouped" ? filter : "all"; + const visible = filterGroupChannels( + channels, + activeFilter, + excluded, + query, + validSelected, + onlySelected, + ); + const hiddenCount = + chosen.length - visible.filter((channel) => validSelected.has(channel.channelUrl)).length; + const filterName = + activeGroup?.name ?? + (activeFilter === "ungrouped" ? m.groups_preview_ungrouped() : m.sg_all_channels()); + const editing = chosen.length === 1 ? chosen[0].channelUrl : null; + const disabled = actions.busy; + + function toggle(url: string): void { + actions.clearError(); + selection.toggle(url); + } + function changeFilter(value: string): void { + actions.clearError(); + setFilter(value); + setExcluded(false); + setOnlySelected(false); + } + function clearSelection(): void { + actions.clearError(); + selection.select(new Set()); + setOnlySelected(false); + } + async function bulk(groupId: string, action: "add" | "remove"): Promise { + const channelUrls = chosen + .filter((channel) => channel.groupIds.includes(groupId) === (action === "remove")) + .map((channel) => channel.channelUrl); + const name = groups.find((group) => group.id === groupId)?.name ?? ""; + const message = + action === "add" + ? channelUrls.length === 1 + ? m.sg_added_one({ group: name }) + : m.sg_added({ count: channelUrls.length, group: name }) + : channelUrls.length === 1 + ? m.sg_removed_one({ group: name }) + : m.sg_removed({ count: channelUrls.length, group: name }); + if ( + await actions.run(() => updateGroupMemberships([{ groupId, channelUrls, action }]), message) + ) + clearSelection(); + } + async function confirm(): Promise { + const pending = confirmation; + setConfirmation(null); + if (pending === "clear") { + if ( + await actions.run( + () => updateGroupMemberships(clearMembershipChanges(chosen)), + m.sg_memberships_cleared(), + ) + ) + clearSelection(); + } else if (pending) { + if ( + (await actions.run( + () => deleteSubscriptionGroup(pending.id), + m.sg_group_deleted({ group: pending.name }), + )) && + activeFilter === pending.id + ) + changeFilter("all"); + } + } + + const confirmationProps = confirmation + ? { + title: + confirmation === "clear" + ? m.sg_remove_all() + : m.sg_delete_named({ group: confirmation.name }), + description: + confirmation === "clear" + ? chosen.length === 1 + ? m.sg_clear_one_confirmation() + : m.sg_clear_confirmation({ count: chosen.length }) + : confirmation.channelCount === 1 + ? m.sg_delete_one_confirmation() + : m.sg_delete_confirmation({ count: confirmation.channelCount }), + confirmLabel: confirmation === "clear" ? m.sg_remove_all() : m.sg_delete_group(), + } + : null; + return { + actions, + activeFilter, + activeGroup, + excluded, + query, + chosen, + validSelected, + visible, + hiddenCount, + filterName, + disabled, + editing, + drafts, + onlySelected, + confirmationProps, + setExcluded, + setQuery, + selectResults: () => + selection.select( + selectGroupResults( + validSelected, + visible.map((channel) => channel.channelUrl), + ), + ), + setOnlySelected, + setDraft, + setConfirmation, + changeFilter, + toggle, + clearSelection, + bulk, + confirm, + }; +} diff --git a/apps/web/src/hooks/use-group-selection.ts b/apps/web/src/hooks/use-group-selection.ts new file mode 100644 index 00000000..44046339 --- /dev/null +++ b/apps/web/src/hooks/use-group-selection.ts @@ -0,0 +1,47 @@ +import { useState } from "react"; +import type { GroupedSubscription } from "../types/subscription-groups"; + +type Selection = { + urls: ReadonlySet; + drafts: ReadonlyMap>; +}; + +function selectUrls(current: Selection, urls: ReadonlySet): Selection { + return { + urls, + drafts: new Map([...current.drafts].filter(([url]) => urls.has(url))), + }; +} + +export function useGroupSelection(channels: GroupedSubscription[]): { + chosen: GroupedSubscription[]; + selected: Set; + drafts: Selection["drafts"]; + select: (urls: Set) => void; + toggle: (url: string) => void; + setDraft: (url: string, ids: Set) => void; +} { + const [state, setState] = useState({ urls: new Set(), drafts: new Map() }); + const chosen = channels.filter((channel) => state.urls.has(channel.channelUrl)); + return { + chosen, + selected: new Set(chosen.map((channel) => channel.channelUrl)), + drafts: state.drafts, + select: (urls) => setState((current) => selectUrls(current, urls)), + toggle: (url) => { + setState((current) => { + const urls = new Set(current.urls); + if (urls.has(url)) urls.delete(url); + else urls.add(url); + return selectUrls(current, urls); + }); + }, + setDraft: (url, ids) => { + setState((current) => + current.urls.has(url) + ? { ...current, drafts: new Map(current.drafts).set(url, ids) } + : current, + ); + }, + }; +} From 9060eb11b9643f983d4a08f1fc77032793c206db Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 015/149] feat: show group editors for single selected channel rows --- .../group-channel-list.tsx | 60 ++++++++++++ .../subscription-groups/group-channel-row.tsx | 98 +++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 apps/web/src/components/subscription-groups/group-channel-list.tsx create mode 100644 apps/web/src/components/subscription-groups/group-channel-row.tsx diff --git a/apps/web/src/components/subscription-groups/group-channel-list.tsx b/apps/web/src/components/subscription-groups/group-channel-list.tsx new file mode 100644 index 00000000..10689a51 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-channel-list.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { m } from "../../paraglide/messages.js"; +import type { GroupedSubscription, SubscriptionGroup } from "../../types/subscription-groups"; +import { GroupChannelRow } from "./group-channel-row"; + +type Props = { + channels: GroupedSubscription[]; + groups: SubscriptionGroup[]; + selected: ReadonlySet; + editing: string | null; + drafts: ReadonlyMap>; + busy: boolean; + onToggle: (url: string) => void; + onDraft: (url: string, ids: Set) => void; + onCancel: () => void; + onSave: (channel: GroupedSubscription, ids: Set) => Promise; +}; + +export function GroupChannelList(props: Props): React.JSX.Element { + const [limit, setLimit] = useState(50); + if (props.channels.length === 0) + return ( +
+

{m.sg_no_channel_match()}

+

{m.sg_change_filters()}

+
+ ); + return ( + <> +
    + {props.channels.slice(0, limit).map((channel) => ( + props.onToggle(channel.channelUrl)} + onDraft={(ids) => props.onDraft(channel.channelUrl, ids)} + onCancel={props.onCancel} + onSave={(ids) => props.onSave(channel, ids)} + /> + ))} +
+ {props.channels.length > limit && ( + + )} + + ); +} diff --git a/apps/web/src/components/subscription-groups/group-channel-row.tsx b/apps/web/src/components/subscription-groups/group-channel-row.tsx new file mode 100644 index 00000000..d03a3dbc --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-channel-row.tsx @@ -0,0 +1,98 @@ +import { useId, useRef } from "react"; +import { proxyImage } from "../../lib/proxy"; +import { m } from "../../paraglide/messages.js"; +import type { GroupedSubscription, SubscriptionGroup } from "../../types/subscription-groups"; +import { ChannelAvatar } from "../channel-avatar"; +import { ChannelRouteLink } from "../channel-route-link"; +import { ChannelGroupEditor } from "./channel-group-editor"; + +type Props = { + channel: GroupedSubscription; + groups: SubscriptionGroup[]; + selected: boolean; + editing: boolean; + draft: ReadonlySet | undefined; + disabled: boolean; + busy: boolean; + onSelect: () => void; + onDraft: (ids: Set) => void; + onCancel: () => void; + onSave: (ids: Set) => Promise; +}; + +export function GroupChannelRow(props: Props): React.JSX.Element { + const { channel, groups, editing } = props; + const selectionId = useId(); + const checkbox = useRef(null); + const memberships = groups.filter((group) => channel.groupIds.includes(group.id)); + function cancel(): void { + props.onCancel(); + checkbox.current?.focus(); + } + return ( +
  • + +
    + + +
    + + {channel.name} + +

    + {channel.channelUrl.replace(/^https?:\/\/(www\.)?/, "")} +

    +
    + {editing ? ( + { + if (await props.onSave(ids)) cancel(); + }} + /> + ) : ( +
    + {memberships.length > 0 ? ( + memberships.map((group) => ( + + {group.name} + + )) + ) : ( + {m.sg_add_groups()} + )} +
    + )} +
    +
  • + ); +} From a3642612387d630ca778b0f576e7b4310d3faced Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 016/149] feat: add group membership filters and bulk action toolbar --- .../subscription-groups/group-toolbar.tsx | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 apps/web/src/components/subscription-groups/group-toolbar.tsx diff --git a/apps/web/src/components/subscription-groups/group-toolbar.tsx b/apps/web/src/components/subscription-groups/group-toolbar.tsx new file mode 100644 index 00000000..fe1fc90b --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-toolbar.tsx @@ -0,0 +1,161 @@ +import { ArrowLeftRight, Search, X } from "lucide-react"; +import { useState } from "react"; +import { m } from "../../paraglide/messages.js"; +import type { SubscriptionGroup } from "../../types/subscription-groups"; + +type Props = { + groups: SubscriptionGroup[]; + defaultTarget: string; + filterName: string; + isGroup: boolean; + excluded: boolean; + query: string; + selectedCount: number; + hiddenCount: number; + resultCount: number; + allSelected: boolean; + onlySelected: boolean; + disabled: boolean; + busy: boolean; + onQuery: (value: string) => void; + onExcluded: (value: boolean) => void; + onSelectResults: () => void; + onClear: () => void; + onOnlySelected: () => void; + onBulk: (groupId: string, action: "add" | "remove") => void; + onRemoveAll: () => void; +}; + +export function GroupToolbar(props: Props): React.JSX.Element { + const [target, setTarget] = useState(props.defaultTarget); + const validTarget = props.groups.some((group) => group.id === target) ? target : ""; + return ( +
    +
    + + + +
    +
    +
    +

    + {props.busy + ? m.sg_saving() + : props.selectedCount > 0 + ? m.sg_selected({ count: props.selectedCount }) + : m.sg_select_hint()} +

    + {props.hiddenCount > 0 && ( +

    + {m.sg_hidden_selected({ count: props.hiddenCount })} +

    + )} +
    + {(props.selectedCount > 0 || props.onlySelected) && ( + + )} + + + + + +
    +
    + + {props.onlySelected + ? m.sg_show_selected() + : props.isGroup && props.excluded + ? m.sg_outside_named({ group: props.filterName }) + : props.filterName} + + + {props.resultCount === 1 + ? m.sg_one_channel() + : m.sg_channel_count({ count: props.resultCount })} + +
    +
    + ); +} From 8ce1ec84cdf6c0acb1431a7667bf916df7e0aa40 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 017/149] feat: assemble the subscription group management page --- .../subscription-groups/group-manager.tsx | 184 ++++++++++++++++++ apps/web/src/lib/auth-routes.ts | 2 + apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/subscriptions_.groups.tsx | 12 ++ 4 files changed, 219 insertions(+) create mode 100644 apps/web/src/components/subscription-groups/group-manager.tsx create mode 100644 apps/web/src/routes/subscriptions_.groups.tsx diff --git a/apps/web/src/components/subscription-groups/group-manager.tsx b/apps/web/src/components/subscription-groups/group-manager.tsx new file mode 100644 index 00000000..4e223ae3 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-manager.tsx @@ -0,0 +1,184 @@ +import { Link } from "@tanstack/react-router"; +import { ArrowLeft } from "lucide-react"; +import { useMemo } from "react"; +import { useGroupManager } from "../../hooks/use-group-manager"; +import { useGroupMemberships, useSubscriptionGroups } from "../../hooks/use-subscription-groups"; +import { + createSubscriptionGroup, + renameSubscriptionGroup, + updateGroupMemberships, +} from "../../lib/api-subscription-groups"; +import { channelMembershipChanges } from "../../lib/subscription-group-selection"; +import { m } from "../../paraglide/messages.js"; +import { GroupChannelList } from "./group-channel-list"; +import { GroupConfirmDialog } from "./group-confirm-dialog"; +import { GroupSidebar } from "./group-sidebar"; +import { GroupToolbar } from "./group-toolbar"; +import "../../styles/subscription-groups.css"; + +export function GroupManager(): React.JSX.Element { + const groupsQuery = useSubscriptionGroups(); + const channelsQuery = useGroupMemberships(); + const groups = useMemo( + () => [...(groupsQuery.data ?? [])].sort((a, b) => a.name.localeCompare(b.name)), + [groupsQuery.data], + ); + const channels = useMemo( + () => [...(channelsQuery.data ?? [])].sort((a, b) => a.name.localeCompare(b.name)), + [channelsQuery.data], + ); + const { + actions, + activeFilter, + activeGroup, + excluded, + query, + chosen, + validSelected, + visible, + hiddenCount, + filterName, + disabled, + editing, + drafts, + onlySelected, + confirmationProps, + setExcluded, + setQuery, + selectResults, + setOnlySelected, + setDraft, + setConfirmation, + changeFilter, + toggle, + clearSelection, + bulk, + confirm, + } = useGroupManager(groups, channels); + return ( +
    +
    +
    +

    {m.sg_manage_groups()}

    +

    {m.sg_manager_description()}

    +
    + + + {m.sg_back_channels()} + +
    + {groupsQuery.isPending || channelsQuery.isPending ? ( +

    + {m.sg_loading()} +

    + ) : (groupsQuery.isError && !groupsQuery.data) || + (channelsQuery.isError && !channelsQuery.data) ? ( +
    +

    {m.sg_load_error()}

    + +
    + ) : ( +
    + channel.groupIds.length === 0).length} + filter={activeFilter} + disabled={disabled} + onFilter={changeFilter} + onCreate={(name) => + actions.run(() => createSubscriptionGroup(name), m.sg_group_created({ group: name })) + } + onRename={(id, name) => + actions.run(() => renameSubscriptionGroup(id, name), m.sg_group_renamed()) + } + onDelete={setConfirmation} + onCancelRename={actions.clearError} + /> +
    + 0 && + visible.every((channel) => validSelected.has(channel.channelUrl)) + } + onlySelected={onlySelected} + disabled={disabled} + busy={actions.busy} + onQuery={setQuery} + onExcluded={setExcluded} + onSelectResults={selectResults} + onClear={clearSelection} + onOnlySelected={() => setOnlySelected(!onlySelected)} + onBulk={bulk} + onRemoveAll={() => setConfirmation("clear")} + /> + {actions.error && ( +

    + {actions.error} +

    + )} + {actions.notice && ( +

    + {actions.notice} +

    + )} + {channels.length === 0 ? ( +
    +

    {m.ui_no_subscriptions_yet_2()}

    +

    {m.sg_empty_subscriptions()}

    +
    + ) : ( + + actions.run( + () => updateGroupMemberships(channelMembershipChanges(channel, ids)), + m.sg_channel_saved({ channel: channel.name }), + ) + } + /> + )} +
    +
    + )} + {confirmationProps && ( + setConfirmation(null)} + onConfirm={confirm} + /> + )} +
    + ); +} diff --git a/apps/web/src/lib/auth-routes.ts b/apps/web/src/lib/auth-routes.ts index cc9a7111..5fc6fda6 100644 --- a/apps/web/src/lib/auth-routes.ts +++ b/apps/web/src/lib/auth-routes.ts @@ -7,6 +7,7 @@ export type RedirectTarget = | "/profile" | "/settings" | "/subscriptions" + | "/subscriptions/groups" | "/youtube-session" | `/youtube-session?returnTo=${string}` | `/shorts?v=${string}`; @@ -61,6 +62,7 @@ export function sanitizeRedirect(value: string | undefined): RedirectTarget { if (value === "/profile") return "/profile"; if (value === "/settings") return "/settings"; if (value === "/subscriptions") return "/subscriptions"; + if (value === "/subscriptions/groups") return "/subscriptions/groups"; if (value === "/youtube-session") return "/youtube-session"; if (value === "/playlists" || value.startsWith("/playlists/")) return "/playlists"; return "/"; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f0df2748..7304c128 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -41,6 +41,7 @@ import { Route as ImportYoutubeRouteImport } from './routes/import/youtube' import { Route as PlaylistsIdRouteImport } from './routes/playlists_.$id' import { Route as ShortsVideoIdRouteImport } from './routes/shorts_.$videoId' import { Route as SubscriptionsChannelsRouteImport } from './routes/subscriptions_.channels' +import { Route as SubscriptionsGroupsRouteImport } from './routes/subscriptions_.groups' import { Route as AuthOidcCallbackRouteImport } from './routes/auth.oidc.callback' const IndexRoute = IndexRouteImport.update({ @@ -204,6 +205,11 @@ const SubscriptionsChannelsRoute = SubscriptionsChannelsRouteImport.update({ path: '/subscriptions/channels', getParentRoute: () => rootRouteImport, } as any) +const SubscriptionsGroupsRoute = SubscriptionsGroupsRouteImport.update({ + id: '/subscriptions_/groups', + path: '/subscriptions/groups', + getParentRoute: () => rootRouteImport, +} as any) const AuthOidcCallbackRoute = AuthOidcCallbackRouteImport.update({ id: '/auth/oidc/callback', path: '/auth/oidc/callback', @@ -242,6 +248,7 @@ export interface FileRoutesByFullPath { '/playlists/$id': typeof PlaylistsIdRoute '/shorts/$videoId': typeof ShortsVideoIdRoute '/subscriptions/channels': typeof SubscriptionsChannelsRoute + '/subscriptions/groups': typeof SubscriptionsGroupsRoute '/import/': typeof ImportIndexRoute '/auth/oidc/callback': typeof AuthOidcCallbackRoute } @@ -276,6 +283,7 @@ export interface FileRoutesByTo { '/playlists/$id': typeof PlaylistsIdRoute '/shorts/$videoId': typeof ShortsVideoIdRoute '/subscriptions/channels': typeof SubscriptionsChannelsRoute + '/subscriptions/groups': typeof SubscriptionsGroupsRoute '/import': typeof ImportIndexRoute '/auth/oidc/callback': typeof AuthOidcCallbackRoute } @@ -312,6 +320,7 @@ export interface FileRoutesById { '/playlists_/$id': typeof PlaylistsIdRoute '/shorts_/$videoId': typeof ShortsVideoIdRoute '/subscriptions_/channels': typeof SubscriptionsChannelsRoute + '/subscriptions_/groups': typeof SubscriptionsGroupsRoute '/import/': typeof ImportIndexRoute '/auth/oidc/callback': typeof AuthOidcCallbackRoute } @@ -349,6 +358,7 @@ export interface FileRouteTypes { | '/playlists/$id' | '/shorts/$videoId' | '/subscriptions/channels' + | '/subscriptions/groups' | '/import/' | '/auth/oidc/callback' fileRoutesByTo: FileRoutesByTo @@ -383,6 +393,7 @@ export interface FileRouteTypes { | '/playlists/$id' | '/shorts/$videoId' | '/subscriptions/channels' + | '/subscriptions/groups' | '/import' | '/auth/oidc/callback' id: @@ -418,6 +429,7 @@ export interface FileRouteTypes { | '/playlists_/$id' | '/shorts_/$videoId' | '/subscriptions_/channels' + | '/subscriptions_/groups' | '/import/' | '/auth/oidc/callback' fileRoutesById: FileRoutesById @@ -452,6 +464,7 @@ export interface RootRouteChildren { PlaylistsIdRoute: typeof PlaylistsIdRoute ShortsVideoIdRoute: typeof ShortsVideoIdRoute SubscriptionsChannelsRoute: typeof SubscriptionsChannelsRoute + SubscriptionsGroupsRoute: typeof SubscriptionsGroupsRoute AuthOidcCallbackRoute: typeof AuthOidcCallbackRoute } @@ -681,6 +694,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SubscriptionsChannelsRouteImport parentRoute: typeof rootRouteImport } + '/subscriptions_/groups': { + id: '/subscriptions_/groups' + path: '/subscriptions/groups' + fullPath: '/subscriptions/groups' + preLoaderRoute: typeof SubscriptionsGroupsRouteImport + parentRoute: typeof rootRouteImport + } '/auth/oidc/callback': { id: '/auth/oidc/callback' path: '/auth/oidc/callback' @@ -736,6 +756,7 @@ const rootRouteChildren: RootRouteChildren = { PlaylistsIdRoute: PlaylistsIdRoute, ShortsVideoIdRoute: ShortsVideoIdRoute, SubscriptionsChannelsRoute: SubscriptionsChannelsRoute, + SubscriptionsGroupsRoute: SubscriptionsGroupsRoute, AuthOidcCallbackRoute: AuthOidcCallbackRoute, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/subscriptions_.groups.tsx b/apps/web/src/routes/subscriptions_.groups.tsx new file mode 100644 index 00000000..102ea529 --- /dev/null +++ b/apps/web/src/routes/subscriptions_.groups.tsx @@ -0,0 +1,12 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { GroupManager } from "../components/subscription-groups/group-manager"; +import { useAuth } from "../hooks/use-auth"; + +function SubscriptionGroupsPage(): React.JSX.Element { + const { me } = useAuth(); + return ; +} + +export const Route = createFileRoute("/subscriptions_/groups")({ + component: SubscriptionGroupsPage, +}); From 5ff1f1f6ba359a701ff019182619f2e50aca3a59 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:18:55 +0900 Subject: [PATCH 018/149] feat: integrate group filters into subscription views --- .../components/portability-import-panel.tsx | 21 ++++++++- .../components/subscription-group-filter.tsx | 28 +++++++++++ .../src/components/subscriptions-header.tsx | 11 ++++- apps/web/src/hooks/use-subscription-feed.ts | 16 +++++-- apps/web/src/lib/api-user.ts | 5 +- apps/web/src/routes/subscriptions.tsx | 46 ++++++++++++------ .../src/routes/subscriptions_.channels.tsx | 47 +++++++++++++------ 7 files changed, 137 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/components/subscription-group-filter.tsx diff --git a/apps/web/src/components/portability-import-panel.tsx b/apps/web/src/components/portability-import-panel.tsx index 683e6e67..5cafaa7f 100644 --- a/apps/web/src/components/portability-import-panel.tsx +++ b/apps/web/src/components/portability-import-panel.tsx @@ -1,4 +1,5 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; import { ArchiveRestore, FileUp } from "lucide-react"; import { type DragEvent, useEffect, useMemo, useRef, useState } from "react"; import { usePersistedPortabilityJob } from "../hooks/use-persisted-portability-job"; @@ -63,13 +64,21 @@ export function PortabilityImportPanel({ formats }: { formats: PortabilityFormat useEffect(() => { const state = job.data?.state ?? null; if (state === "completed" && previousState.current !== "completed") { + for (const key of [ + "subscription-groups", + "subscription-group-memberships", + "subscriptions", + "subscription-feed", + ]) { + void queryClient.invalidateQueries({ queryKey: [key] }); + } const count = Object.values(job.data?.result ?? {}).reduce((sum, value) => sum + value, 0); setToast( `${m.portability_import_completed()}: ${count.toLocaleString()} ${m.portability_items()}`, ); } previousState.current = state; - }, [job.data?.result, job.data?.state]); + }, [job.data?.result, job.data?.state, queryClient]); useEffect(() => { if (!job.missing || !jobId) return; @@ -183,6 +192,16 @@ export function PortabilityImportPanel({ formats }: { formats: PortabilityFormat {job.data && ["completed", "failed", "cancelled"].includes(job.data.state) && (
    + {job.data.state === "completed" && + ((job.data.result?.subscriptions ?? 0) > 0 || + (job.data.result?.subscriptionGroups ?? 0) > 0) && ( + + {m.sg_manage_groups()} + + )} +
    ) : ( <> {visible.length === 0 && ( @@ -95,9 +114,25 @@ function SubscriptionsPage() { )} {isFetchingNextPage && } + {isFetchNextPageError && ( +
    +

    {m.subscriptions_feed_next_page_error()}

    + +
    + )} )} diff --git a/apps/web/tests/subscription-feed-errors.test.tsx b/apps/web/tests/subscription-feed-errors.test.tsx new file mode 100644 index 00000000..10628220 --- /dev/null +++ b/apps/web/tests/subscription-feed-errors.test.tsx @@ -0,0 +1,124 @@ +import { afterEach, expect, test } from "bun:test"; +import { InfiniteQueryObserver, QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { SUBSCRIPTION_FEED_KEY, useSubscriptionFeed } from "../src/hooks/use-subscription-feed"; +import { SUBSCRIPTIONS_KEY } from "../src/hooks/use-subscriptions"; +import { fetchSubscriptionFeed } from "../src/lib/api-user"; +import { useAuthStore } from "../src/stores/auth-store"; +import type { SubscriptionFeedPage, VideoItem } from "../src/types/api"; + +const originalFetch = globalThis.fetch; +const clients: QueryClient[] = []; +afterEach(() => { + globalThis.fetch = originalFetch; + useAuthStore.getState().setSignedOut(); + for (const client of clients.splice(0)) client.clear(); +}); + +function readFeed(client: QueryClient): ReturnType { + let state: ReturnType | undefined; + function ReadFeed(): null { + state = useSubscriptionFeed(); + return null; + } + renderToStaticMarkup( + + + , + ); + if (!state) throw new Error("Feed hook did not render"); + return state; +} + +function setup() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + client.setQueryData(SUBSCRIPTIONS_KEY, []); + useAuthStore.getState().setToken("feed-error-test"); + const observer = new InfiniteQueryObserver(client, { + queryKey: SUBSCRIPTION_FEED_KEY, + queryFn: ({ pageParam }) => fetchSubscriptionFeed(pageParam), + initialPageParam: null as string | null, + getNextPageParam: (last: SubscriptionFeedPage) => last.nextpage ?? undefined, + }); + return { client, observer }; +} + +function video(id: string): VideoItem { + return { + id, + url: `https://www.youtube.com/watch?v=${id}`, + title: id, + thumbnailUrl: "", + uploaderName: "Test channel", + uploaderUrl: "", + uploaderAvatarUrl: "", + uploaderVerified: false, + duration: 100, + viewCount: 1, + uploadDate: "", + uploaded: 0, + streamType: "VIDEO_STREAM", + isLive: false, + isPostLive: false, + isLiveContent: false, + requiresMembership: false, + isShortFormContent: false, + shortDescription: null, + }; +} + +test("a failed next page preserves videos and retries the same cursor", async () => { + const { client, observer } = setup(); + let failNext = true; + const cursors: Array = []; + globalThis.fetch = async (input) => { + const cursor = new URL(String(input), "https://fixture.invalid").searchParams.get("cursor"); + cursors.push(cursor); + if (cursor && failNext) return Response.json({ error: "Unavailable" }, { status: 503 }); + return Response.json({ + videos: [video(cursor ? "second" : "first")], + nextpage: cursor ? null : "page-2", + }); + }; + await observer.refetch(); + await observer.fetchNextPage(); + const failed = readFeed(client); + expect(failed.streams.map((item) => item.title)).toEqual(["first"]); + expect(failed.isLoadingError).toBe(false); + expect(failed.isFetchNextPageError).toBe(true); + expect(failed.hasNextPage).toBe(true); + + failNext = false; + await failed.fetchNextPage(); + const recovered = readFeed(client); + expect(recovered.streams.map((item) => item.title)).toEqual(["first", "second"]); + expect(recovered.isFetchNextPageError).toBe(false); + expect(recovered.hasNextPage).toBe(false); + expect(cursors).toEqual([null, "page-2", "page-2"]); +}); + +test("an initial-load failure exposes recovery without a pagination error", async () => { + const { client, observer } = setup(); + globalThis.fetch = async () => Response.json({ error: "Unavailable" }, { status: 503 }); + await observer.refetch(); + const failed = readFeed(client); + expect(failed.isLoadingError).toBe(true); + expect(failed.isFetchNextPageError).toBe(false); + expect(failed.streams).toEqual([]); + globalThis.fetch = async () => Response.json({ videos: [video("first")], nextpage: null }); + await failed.refetch(); + expect(readFeed(client).isLoadingError).toBe(false); +}); + +test("a failed background refresh retains cached feed content", async () => { + const { client, observer } = setup(); + globalThis.fetch = async () => Response.json({ videos: [video("first")], nextpage: null }); + await observer.refetch(); + globalThis.fetch = async () => Response.json({ error: "Unavailable" }, { status: 503 }); + await observer.refetch(); + const failed = readFeed(client); + expect(failed.isLoadingError).toBe(false); + expect(failed.isFetchNextPageError).toBe(false); + expect(failed.streams.map((item) => item.title)).toEqual(["first"]); +}); From 738dfc6c264cfa75261490539af553370922a523 Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:46:07 +0900 Subject: [PATCH 020/149] test: add varied subscription group fixture data --- scripts/fixtures/subscription-groups-data.ts | 165 ++++++++++++++++++ scripts/fixtures/subscription-groups-state.ts | 112 ++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 scripts/fixtures/subscription-groups-data.ts create mode 100644 scripts/fixtures/subscription-groups-state.ts diff --git a/scripts/fixtures/subscription-groups-data.ts b/scripts/fixtures/subscription-groups-data.ts new file mode 100644 index 00000000..68df83d8 --- /dev/null +++ b/scripts/fixtures/subscription-groups-data.ts @@ -0,0 +1,165 @@ +import type { VideoItem } from "../../apps/web/src/types/api"; +import type { + GroupedSubscription, + SubscriptionGroup, +} from "../../apps/web/src/types/subscription-groups"; + +const GROUP_NAMES = [ + "Tech", + "Video essays", + "Music", + "Science", + "Cooking", + "Gaming", + "News", + "Design", + "DIY & making", + "Travel", + "History", + "Photography", + "Podcasts", + "Fitness", + "Languages", + "Space", + "Weekend watchlist — documentaries and deep dives", + "To explore", +]; +const ORIGINAL_NAMES = [ + "Lemnos Life", + "Mental Outlaw", + "Jack Rhysider", + "Network Chuck", + "Fireship", + "Veritasium", + "Smarter Every Day", + "Behoops", + "Red Shirts", + "Annie Bramley", + "KEXP", + "NPR Music", + "Dorian Me", + "Arcade Sound", + "Cooking Comically", + "Technology Connections", + "The B1M", + "Noclip", + "Asianometry", + "PBS Space Time", + "DW News", +]; +const SUBJECTS = [ + "Analog", + "Architecture", + "Astronomy", + "Baking", + "Cinema", + "Circuit", + "Design", + "Ecology", + "History", + "Indie Games", + "Jazz", + "Language", + "Ocean", + "Photography", + "Robotics", +]; +const FORMATS = [ + "Lab", + "Notebook", + "Journal", + "Workshop", + "Studio", + "Archive", + "Field Notes", + "Weekly", + "Collective", +]; +const SPECIAL_NAMES = [ + "A", + "Café des idées", + "京都の小さな工房", + "서울 디자인 스튜디오", + "حكايات العلوم", + "Atlas Workshop — repairing, rebuilding and understanding everyday machines one project at a time", +]; +const EPOCH = Date.UTC(2026, 8, 1); + +export function makeGroups(): SubscriptionGroup[] { + return GROUP_NAMES.map((name, index) => ({ + id: `10000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`, + name, + channelCount: 0, + createdAt: EPOCH, + updatedAt: EPOCH, + })); +} + +export function makeChannels(groups: SubscriptionGroup[]): GroupedSubscription[] { + return Array.from({ length: 150 }, (_, index) => { + const generated = index - ORIGINAL_NAMES.length; + const name = + ORIGINAL_NAMES[index] ?? + SPECIAL_NAMES[generated] ?? + `${SUBJECTS[generated % SUBJECTS.length]} ${FORMATS[Math.floor(generated / SUBJECTS.length)]}`; + const memberships = new Set(); + if (index % 7 !== 0) { + memberships.add(index % 16); + if (index % 2 === 0) memberships.add(0); + if (index % 3 !== 0) memberships.add((index + 5) % 16); + if (index % 4 === 0) memberships.add((index + 9) % 16); + if (index % 11 === 0) memberships.add(16); + } + if (index === 26) { + memberships.clear(); + for (let group = 0; group < 10; group++) memberships.add(group); + } + return { + channelUrl: `https://www.youtube.com/channel/UCfixture${String(index).padStart(15, "0")}`, + name, + avatarUrl: index % 13 === 0 ? "" : `/api/__qa/avatar/${index}.svg`, + subscribedAt: EPOCH - index * 86_400_000, + groupIds: [...memberships].map((group) => groups[group].id), + }; + }); +} + +export function makeVideos(channels: GroupedSubscription[]): VideoItem[] { + return Array.from({ length: 300 }, (_, index) => { + const channel = channels[index % channels.length]; + const id = `mock${String(index).padStart(7, "0")}`; + return { + id, + url: `https://www.youtube.com/watch?v=${id}`, + title: `${channel.name}: ${index < channels.length ? "A closer look" : "Behind the scenes"}`, + thumbnailUrl: `/api/__qa/thumbnail/${index}.svg`, + uploaderName: channel.name, + uploaderUrl: channel.channelUrl, + uploaderAvatarUrl: channel.avatarUrl, + uploaderVerified: index % 8 === 0, + duration: 240 + ((index * 137) % 5400), + viewCount: 850 + index * 12437, + uploaded: EPOCH - index * 3_600_000, + uploadDate: "2026-09-01", + streamType: "VIDEO_STREAM", + isLive: false, + isPostLive: false, + isLiveContent: false, + requiresMembership: false, + isShortFormContent: false, + shortDescription: "Illustrative local fixture video; playback is not available.", + }; + }); +} + +export function fixtureImage(kind: string, index: number): Response { + const hue = (index * 47) % 360; + const thumbnail = kind === "thumbnail"; + const width = thumbnail ? 640 : 80; + const height = thumbnail ? 360 : 80; + const label = thumbnail ? `Preview ${String(index + 1).padStart(3, "0")}` : String(index + 1); + return new Response( + `${label}`, + { headers: { "Content-Type": "image/svg+xml" } }, + ); +} diff --git a/scripts/fixtures/subscription-groups-state.ts b/scripts/fixtures/subscription-groups-state.ts new file mode 100644 index 00000000..7f25c34c --- /dev/null +++ b/scripts/fixtures/subscription-groups-state.ts @@ -0,0 +1,112 @@ +import { makeChannels, makeGroups, makeVideos } from "./subscription-groups-data"; + +type FailureRule = { path: string; method: string; count: number; query?: string }; +type Fixture = { + groups: ReturnType; + channels: ReturnType; + videos: ReturnType; + writes: Array<{ path: string; method: string; body: unknown }>; + failure: FailureRule | null; +}; + +export function createFixture(): Fixture { + const groups = makeGroups(); + const channels = makeChannels(groups); + return { + groups, + channels, + videos: makeVideos(channels), + writes: [], + failure: null, + }; +} + +export function groupCounts(state: Fixture): Fixture["groups"] { + return state.groups.map((group) => ({ + ...group, + channelCount: state.channels.filter((channel) => channel.groupIds.includes(group.id)).length, + })); +} + +export function filteredChannels(state: Fixture, url: URL): Fixture["channels"] { + const groupId = url.searchParams.get("groupId"); + return state.channels.filter((channel) => + groupId + ? channel.groupIds.includes(groupId) + : url.searchParams.get("ungrouped") === "true" + ? channel.groupIds.length === 0 + : true, + ); +} + +function error(message: string, status: number, code?: string): Response { + return Response.json({ error: message, code }, { status }); +} + +export async function writeGroup( + state: Fixture, + request: Request, + path: string, +): Promise { + const id = path.split("/")[3]; + const group = state.groups.find((item) => item.id === id); + if (id && !group) return error("Group not found", 404, "subscription_group_not_found"); + const body: unknown = + request.method === "DELETE" && !path.endsWith("/channels") ? {} : await request.json(); + state.writes.push({ path, method: request.method, body }); + if (!body || typeof body !== "object") return error("Invalid request body", 400); + if (path.endsWith("/channels")) { + if (!("channelUrls" in body) || !Array.isArray(body.channelUrls)) + return error("Invalid channels", 400); + const urls: unknown[] = body.channelUrls; + if (!urls.length || urls.length > 500 || urls.some((url) => typeof url !== "string")) { + return error("Specify 1 to 500 channel URLs", 400); + } + if (request.method !== "PUT" && request.method !== "DELETE") + return error("Invalid method", 405); + if (urls.some((url) => !state.channels.some((channel) => channel.channelUrl === url))) { + return error("Subscription not found", 404, "subscription_not_found"); + } + state.channels = state.channels.map((channel) => + !urls.includes(channel.channelUrl) + ? channel + : { + ...channel, + groupIds: + request.method === "PUT" + ? [...new Set([...channel.groupIds, id])] + : channel.groupIds.filter((groupId) => groupId !== id), + }, + ); + } else if (request.method === "POST" || request.method === "PUT") { + const name = "name" in body && typeof body.name === "string" ? body.name.trim() : ""; + if (!name || name.length > 100) + return error("Invalid name", 400, "subscription_group_invalid_name"); + if ( + state.groups.some((item) => item.id !== id && item.name.toLowerCase() === name.toLowerCase()) + ) { + return error("Duplicate name", 409, "subscription_group_name_conflict"); + } + if (group) { + group.name = name; + group.updatedAt = Date.now(); + } else { + const created = { + id: crypto.randomUUID(), + name, + channelCount: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + state.groups.push(created); + return Response.json(created, { status: 201 }); + } + } else if (request.method === "DELETE" && group) { + state.groups = state.groups.filter((item) => item.id !== id); + state.channels = state.channels.map((channel) => ({ + ...channel, + groupIds: channel.groupIds.filter((groupId) => groupId !== id), + })); + } else return error("Invalid method", 405); + return new Response(null, { status: 204 }); +} From d91e7309e22a28a9ca48b39135eaa5ba5b665b4c Mon Sep 17 00:00:00 2001 From: kapdon <94782486+kapdon@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:46:07 +0900 Subject: [PATCH 021/149] test: serve a reproducible subscription group preview --- package.json | 1 + scripts/fixtures/subscription-groups.ts | 161 ++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 scripts/fixtures/subscription-groups.ts diff --git a/package.json b/package.json index 4e314410..2b1f364f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "localize": "bun run --cwd apps/web localize", "localization:report": "node scripts/localization-report.mjs", "dev": "bun run --cwd apps/web dev", + "dev:groups-fixture": "bun scripts/fixtures/subscription-groups.ts", "build": "bun run --cwd apps/web build", "check": "bun run localize && node scripts/check-localization.mjs && biome check .", "format": "biome format --write .", diff --git a/scripts/fixtures/subscription-groups.ts b/scripts/fixtures/subscription-groups.ts new file mode 100644 index 00000000..10229224 --- /dev/null +++ b/scripts/fixtures/subscription-groups.ts @@ -0,0 +1,161 @@ +import { fixtureImage } from "./subscription-groups-data"; +import { + createFixture, + filteredChannels, + groupCounts, + writeGroup, +} from "./subscription-groups-state"; + +let state = createFixture(); +const me = { + id: "fixture-user", + role: "user", + publicUsername: "Local fixture · 150 channels", + bio: null, + avatarUrl: null, + avatarType: null, + avatarCode: null, +}; +const empty = (): Response => new Response(null, { status: 204 }); + +Bun.serve({ + hostname: "127.0.0.1", + port: 9876, + async fetch(request): Promise { + const url = new URL(request.url); + const path = url.pathname; + const method = request.method; + if (path === "/__qa/state") { + return Response.json({ + ...state, + groups: groupCounts(state), + summary: { + channels: state.channels.length, + groups: state.groups.length, + videos: state.videos.length, + ungrouped: state.channels.filter((channel) => channel.groupIds.length === 0).length, + multipleGroups: state.channels.filter((channel) => channel.groupIds.length > 1).length, + }, + }); + } + if (path === "/__qa/reset" && method === "POST") { + state = createFixture(); + return empty(); + } + if (path === "/__qa/fail" && method === "POST") { + const rule: unknown = await request.json(); + if ( + !rule || + typeof rule !== "object" || + !("path" in rule) || + typeof rule.path !== "string" || + !("method" in rule) || + typeof rule.method !== "string" || + !("count" in rule) || + !Number.isInteger(rule.count) || + Number(rule.count) < 0 + ) { + return Response.json( + { error: "Expected path, method and nonnegative count" }, + { status: 400 }, + ); + } + state.failure = { + path: rule.path, + method: rule.method, + count: Number(rule.count), + query: "query" in rule && typeof rule.query === "string" ? rule.query : undefined, + }; + return empty(); + } + const failure = state.failure; + if ( + failure && + failure.count > 0 && + method === failure.method && + path.includes(failure.path) && + (!failure.query || url.search.includes(failure.query)) + ) { + failure.count--; + return Response.json({ error: "Simulated fixture failure" }, { status: 503 }); + } + const image = path.match(/^\/__qa\/(avatar|thumbnail)\/(\d+)\.svg$/); + if (image) return fixtureImage(image[1], Number(image[2])); + if (path === "/instance") + return Response.json({ + guestAllowed: true, + youtubeRemoteLoginEnabled: false, + parentalControlsEnabled: false, + }); + if (path === "/auth/register/status") + return Response.json({ allowRegistration: false, bootstrapAvailable: false }); + if (path === "/auth/oidc/status") + return Response.json({ + enabled: false, + providerName: null, + localLoginEnabled: true, + autoRedirect: false, + }); + if (path === "/auth/login" || path === "/auth/refresh") + return Response.json({ accessToken: "local-fixture-only" }); + if (path === "/auth/me") return Response.json(me); + if (path === "/profiles") + return Response.json({ + profiles: [ + { ...me, name: me.publicUsername, isActive: true, isDefault: true, lastUsedAt: 0 }, + ], + activeProfileId: me.id, + defaultProfileId: me.id, + }); + if (path === "/subscriptions/group-memberships") return Response.json(state.channels); + if (path === "/subscriptions/groups" && method === "GET") + return Response.json(groupCounts(state)); + if (path.startsWith("/subscriptions/groups") && method !== "GET") + return writeGroup(state, request, path); + const groupId = url.searchParams.get("groupId"); + if ( + path.startsWith("/subscriptions") && + groupId && + !state.groups.some((group) => group.id === groupId) + ) { + return Response.json( + { error: "Group not found", code: "subscription_group_not_found" }, + { status: 404 }, + ); + } + if (path === "/subscriptions") return Response.json(filteredChannels(state, url)); + if (path === "/subscriptions/feed") { + const channels = new Set(filteredChannels(state, url).map((channel) => channel.channelUrl)); + const videos = state.videos.filter((video) => channels.has(video.uploaderUrl)); + const offset = Math.max(0, Number(url.searchParams.get("cursor")) || 0); + const limit = Math.max(1, Math.min(30, Number(url.searchParams.get("limit")) || 30)); + return Response.json({ + videos: videos.slice(offset, offset + limit), + nextpage: offset + limit < videos.length ? String(offset + limit) : null, + generation: 1, + generatedAt: Date.now(), + refreshing: false, + }); + } + if (path === "/settings") + return Response.json({ + captionStyles: {}, + sponsorBlockCategoryActions: {}, + defaultService: 0, + defaultLandingPage: "/", + volume: 1, + hideSubscriptionLiveStreams: false, + deArrowEnabled: false, + }); + if (path.startsWith("/streams")) + return Response.json({ error: "Fixture videos cannot be played" }, { status: 404 }); + if (path.includes("notifications")) + return Response.json({ items: [], unreadCount: 0, nextCursor: null }); + if (path === "/progress/batch") return Response.json([]); + if (method === "POST" || method === "PUT") return empty(); + return Response.json([]); + }, +}); +console.log( + "Local subscription fixture: http://127.0.0.1:9876 (150 channels, 18 groups, 300 videos)", +); From 0e3fb9675e699fae96aca4af147e96034714f30b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 16 Sep 2026 13:08:17 +0200 Subject: [PATCH 022/149] feat: add presence key settings --- apps/web/messages/de.json | 13 ++ apps/web/messages/en.json | 13 ++ apps/web/messages/fr.json | 13 ++ .../src/components/presence-key-settings.tsx | 131 ++++++++++++++++++ apps/web/src/lib/api-presence.ts | 38 +++++ apps/web/src/routes/profile.tsx | 5 + 6 files changed, 213 insertions(+) create mode 100644 apps/web/src/components/presence-key-settings.tsx create mode 100644 apps/web/src/lib/api-presence.ts diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index ea3e739c..669e11bd 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -238,6 +238,19 @@ "account_save": "Konto speichern", "account_saved": "Konto aktualisiert", "account_update_failed": "Aktualisierung fehlgeschlagen", + "presence_title": "Präsenz-Zugriff", + "presence_description": "Erstelle einen widerrufbaren Schlüssel, der nur sehen kann, ob gerade etwas läuft.", + "presence_token_once": "Kopiere diesen Schlüssel jetzt. Er wird nicht erneut angezeigt.", + "presence_copy": "Schlüssel kopieren", + "presence_copied": "Schlüssel kopiert", + "presence_name_label": "Schlüsselname", + "presence_name_placeholder": "Discord RPC", + "presence_create": "Schlüssel erstellen", + "presence_created": "Präsenz-Schlüssel erstellt", + "presence_create_failed": "Präsenz-Schlüssel konnte nicht erstellt werden", + "presence_revoke": "Widerrufen", + "presence_revoke_failed": "Präsenz-Schlüssel konnte nicht widerrufen werden", + "presence_load_failed": "Präsenz-Schlüssel konnten nicht geladen werden", "account_password_title": "Passwort", "account_password_description": "Setzte das Passwort für dein Konto zurück.", "account_password_reset": "Passwort zurücksetzen", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 5217c739..0e696f03 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -240,6 +240,19 @@ "account_save": "Save account", "account_saved": "Account updated", "account_update_failed": "Update failed", + "presence_title": "Presence access", + "presence_description": "Create a revocable key that can only see whether something is playing now.", + "presence_token_once": "Copy this key now. It will not be shown again.", + "presence_copy": "Copy key", + "presence_copied": "Key copied", + "presence_name_label": "Key name", + "presence_name_placeholder": "Discord RPC", + "presence_create": "Create key", + "presence_created": "Presence key created", + "presence_create_failed": "Unable to create presence key", + "presence_revoke": "Revoke", + "presence_revoke_failed": "Unable to revoke presence key", + "presence_load_failed": "Unable to load presence keys", "account_password_title": "Password", "account_password_description": "Reset your account password.", "account_password_reset": "Reset password", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index dbd0be19..f6caa352 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -240,6 +240,19 @@ "account_save": "Enregistrer le compte", "account_saved": "Compte mis à jour", "account_update_failed": "Échec de la mise à jour", + "presence_title": "Accès de présence", + "presence_description": "Créez une clé révocable qui peut uniquement voir si une lecture est en cours.", + "presence_token_once": "Copiez cette clé maintenant. Elle ne sera plus affichée.", + "presence_copy": "Copier la clé", + "presence_copied": "Clé copiée", + "presence_name_label": "Nom de la clé", + "presence_name_placeholder": "Discord RPC", + "presence_create": "Créer une clé", + "presence_created": "Clé de présence créée", + "presence_create_failed": "Impossible de créer la clé de présence", + "presence_revoke": "Révoquer", + "presence_revoke_failed": "Impossible de révoquer la clé de présence", + "presence_load_failed": "Impossible de charger les clés de présence", "account_password_title": "Mot de passe", "account_password_description": "Réinitialisez le mot de passe de votre compte.", "account_password_reset": "Réinitialiser le mot de passe", diff --git a/apps/web/src/components/presence-key-settings.tsx b/apps/web/src/components/presence-key-settings.tsx new file mode 100644 index 00000000..c42ac76d --- /dev/null +++ b/apps/web/src/components/presence-key-settings.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from "react"; +import { + createPresenceKey, + fetchPresenceKeys, + type PresenceKey, + revokePresenceKey, +} from "../lib/api-presence"; +import { m } from "../paraglide/messages.js"; + +type Props = { + enabled: boolean; + onMessage: (message: string) => void; +}; + +export function PresenceKeySettings({ enabled, onMessage }: Props) { + const [keys, setKeys] = useState([]); + const [name, setName] = useState(""); + const [createdToken, setCreatedToken] = useState(null); + const [pending, setPending] = useState(false); + + useEffect(() => { + if (!enabled) return; + let active = true; + fetchPresenceKeys() + .then((loaded) => { + if (active) setKeys(loaded); + }) + .catch(() => onMessage(m.presence_load_failed())); + return () => { + active = false; + }; + }, [enabled, onMessage]); + + if (!enabled) return null; + + const create = async () => { + const keyName = name.trim(); + if (!keyName || pending) return; + setPending(true); + try { + const created = await createPresenceKey(keyName); + setKeys((current) => [...current, created.key]); + setCreatedToken(created.token); + setName(""); + onMessage(m.presence_created()); + } catch { + onMessage(m.presence_create_failed()); + } finally { + setPending(false); + } + }; + + const revoke = async (id: string) => { + try { + await revokePresenceKey(id); + setKeys((current) => current.filter((key) => key.id !== id)); + } catch { + onMessage(m.presence_revoke_failed()); + } + }; + + return ( +
    +
    +

    {m.presence_title()}

    +

    {m.presence_description()}

    +
    + +
    + {createdToken && ( +
    +

    {m.presence_token_once()}

    +

    {createdToken}

    + +
    + )} + +
    + + +
    + +
      + {keys.map((key) => ( +
    • +
      +

      {key.name}

      +

      {key.tokenPrefix}

      +
      + +
    • + ))} +
    +
    +
    + ); +} diff --git a/apps/web/src/lib/api-presence.ts b/apps/web/src/lib/api-presence.ts new file mode 100644 index 00000000..3cbe2785 --- /dev/null +++ b/apps/web/src/lib/api-presence.ts @@ -0,0 +1,38 @@ +import { ApiError } from "./api"; +import { authed, authedJson } from "./authed"; +import { API_BASE as BASE } from "./env"; + +export type PresenceKey = { + id: string; + name: string; + tokenPrefix: string; + scope: string; + createdAt: number; + lastUsedAt: number | null; +}; + +export type PresenceKeyCreated = { + key: PresenceKey; + token: string; +}; + +export async function fetchPresenceKeys(): Promise { + const keys = await authedJson(`${BASE}/presence/keys`); + if (!Array.isArray(keys)) throw new ApiError("Invalid presence keys payload", 500); + return keys as PresenceKey[]; +} + +export async function createPresenceKey(name: string): Promise { + return authedJson(`${BASE}/presence/keys`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); +} + +export async function revokePresenceKey(id: string): Promise { + const res = await authed(`${BASE}/presence/keys/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (res.status !== 204) throw new ApiError("Failed to revoke presence key", res.status); +} diff --git a/apps/web/src/routes/profile.tsx b/apps/web/src/routes/profile.tsx index 31ffb524..75873c61 100644 --- a/apps/web/src/routes/profile.tsx +++ b/apps/web/src/routes/profile.tsx @@ -1,6 +1,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useMemo, useState } from "react"; import { AccountIdentitySettings } from "../components/account-identity-settings"; +import { PresenceKeySettings } from "../components/presence-key-settings"; import { ProfileAvatar } from "../components/profile-avatar"; import { ProfileAvatarSettings } from "../components/profile-avatar-settings"; import { Toast } from "../components/toast"; @@ -151,6 +152,10 @@ function ProfilePage() { enabled={Boolean(me && !me.id.startsWith("guest:"))} onMessage={setToast} /> + {me && !me.id.startsWith("guest:") && (
    Date: Wed, 16 Sep 2026 13:35:39 +0200 Subject: [PATCH 023/149] perf: group generated frontend chunks --- apps/web/vite.config.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 46a56340..7f796a70 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -20,6 +20,16 @@ export default defineConfig(({ mode }) => { react(), tailwindcss(), ], + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes("/src/paraglide/messages/")) return "paraglide-messages"; + if (id.includes("/node_modules/lucide-react/")) return "lucide-icons"; + }, + }, + }, + }, server: { allowedHosts: true, proxy: { From 44963d6c4e73dd056f8c8edb75b62cf0bac547fc Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 16 Sep 2026 13:35:39 +0200 Subject: [PATCH 024/149] feat: add dedicated API settings --- apps/web/messages/de.json | 4 + apps/web/messages/en.json | 4 + apps/web/messages/fr.json | 4 + .../src/components/presence-key-settings.tsx | 131 --------------- apps/web/src/lib/settings-section.ts | 2 + apps/web/src/routes/profile.tsx | 5 - apps/web/src/routes/settings.tsx | 9 ++ apps/web/src/settings/settings-api.tsx | 149 ++++++++++++++++++ 8 files changed, 172 insertions(+), 136 deletions(-) delete mode 100644 apps/web/src/components/presence-key-settings.tsx create mode 100644 apps/web/src/settings/settings-api.tsx diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 669e11bd..3af830ee 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -172,6 +172,8 @@ "settings_blocked_label": "Gesperrter Inhalt", "settings_data_description": "Importiere, exportiere und stelle Accountdaten wieder her.", "settings_data_label": "Daten", + "settings_api_description": "Erstelle und widerrufe Schlüssel für externe Apps.", + "settings_api_label": "API", "settings_interface_description": "Wähle aus, was TypeType öffnet und hervorhebt.", "settings_interface_label": "Benutzeroberfläche", "settings_language_description": "Benutzeroberflächen-, Ton- und Untertitel- Einstellungen.", @@ -251,6 +253,8 @@ "presence_revoke": "Widerrufen", "presence_revoke_failed": "Präsenz-Schlüssel konnte nicht widerrufen werden", "presence_load_failed": "Präsenz-Schlüssel konnten nicht geladen werden", + "presence_empty": "Noch keine API-Schlüssel.", + "api_sign_in_required": "Melde dich an, um API-Schlüssel zu verwalten.", "account_password_title": "Passwort", "account_password_description": "Setzte das Passwort für dein Konto zurück.", "account_password_reset": "Passwort zurücksetzen", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 0e696f03..68c98a78 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -174,6 +174,8 @@ "settings_blocked_label": "Blocked content", "settings_data_description": "Import, restore, and export account data.", "settings_data_label": "Data", + "settings_api_description": "Create and revoke keys for external apps.", + "settings_api_label": "API", "settings_interface_description": "Choose what TypeType opens and highlights.", "settings_interface_label": "Interface", "settings_language_description": "Interface, audio, and subtitle preferences.", @@ -253,6 +255,8 @@ "presence_revoke": "Revoke", "presence_revoke_failed": "Unable to revoke presence key", "presence_load_failed": "Unable to load presence keys", + "presence_empty": "No API keys yet.", + "api_sign_in_required": "Sign in to manage API keys.", "account_password_title": "Password", "account_password_description": "Reset your account password.", "account_password_reset": "Reset password", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index f6caa352..1fbc6b96 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -174,6 +174,8 @@ "settings_blocked_label": "Contenu bloqué", "settings_data_description": "Importez, restaurez et exportez les données du compte.", "settings_data_label": "Données", + "settings_api_description": "Créez et révoquez des clés pour les applications externes.", + "settings_api_label": "API", "settings_interface_description": "Choisissez ce que TypeType ouvre et met en avant.", "settings_interface_label": "Interface", "settings_language_description": "Préférences d'interface, d'audio et de sous-titres.", @@ -253,6 +255,8 @@ "presence_revoke": "Révoquer", "presence_revoke_failed": "Impossible de révoquer la clé de présence", "presence_load_failed": "Impossible de charger les clés de présence", + "presence_empty": "Aucune clé API pour le moment.", + "api_sign_in_required": "Connectez-vous pour gérer les clés API.", "account_password_title": "Mot de passe", "account_password_description": "Réinitialisez le mot de passe de votre compte.", "account_password_reset": "Réinitialiser le mot de passe", diff --git a/apps/web/src/components/presence-key-settings.tsx b/apps/web/src/components/presence-key-settings.tsx deleted file mode 100644 index c42ac76d..00000000 --- a/apps/web/src/components/presence-key-settings.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { useEffect, useState } from "react"; -import { - createPresenceKey, - fetchPresenceKeys, - type PresenceKey, - revokePresenceKey, -} from "../lib/api-presence"; -import { m } from "../paraglide/messages.js"; - -type Props = { - enabled: boolean; - onMessage: (message: string) => void; -}; - -export function PresenceKeySettings({ enabled, onMessage }: Props) { - const [keys, setKeys] = useState([]); - const [name, setName] = useState(""); - const [createdToken, setCreatedToken] = useState(null); - const [pending, setPending] = useState(false); - - useEffect(() => { - if (!enabled) return; - let active = true; - fetchPresenceKeys() - .then((loaded) => { - if (active) setKeys(loaded); - }) - .catch(() => onMessage(m.presence_load_failed())); - return () => { - active = false; - }; - }, [enabled, onMessage]); - - if (!enabled) return null; - - const create = async () => { - const keyName = name.trim(); - if (!keyName || pending) return; - setPending(true); - try { - const created = await createPresenceKey(keyName); - setKeys((current) => [...current, created.key]); - setCreatedToken(created.token); - setName(""); - onMessage(m.presence_created()); - } catch { - onMessage(m.presence_create_failed()); - } finally { - setPending(false); - } - }; - - const revoke = async (id: string) => { - try { - await revokePresenceKey(id); - setKeys((current) => current.filter((key) => key.id !== id)); - } catch { - onMessage(m.presence_revoke_failed()); - } - }; - - return ( -
    -
    -

    {m.presence_title()}

    -

    {m.presence_description()}

    -
    - -
    - {createdToken && ( -
    -

    {m.presence_token_once()}

    -

    {createdToken}

    - -
    - )} - -
    - - -
    - -
      - {keys.map((key) => ( -
    • -
      -

      {key.name}

      -

      {key.tokenPrefix}

      -
      - -
    • - ))} -
    -
    -
    - ); -} diff --git a/apps/web/src/lib/settings-section.ts b/apps/web/src/lib/settings-section.ts index 6f43d755..72d0cbff 100644 --- a/apps/web/src/lib/settings-section.ts +++ b/apps/web/src/lib/settings-section.ts @@ -5,6 +5,7 @@ export type SettingsSection = | "language" | "service" | "import" + | "api" | "privacy" | "blocked" | "rss" @@ -20,6 +21,7 @@ export function isSettingsSection(value: unknown): value is SettingsSection { value === "language" || value === "service" || value === "import" || + value === "api" || value === "privacy" || value === "blocked" || value === "rss" || diff --git a/apps/web/src/routes/profile.tsx b/apps/web/src/routes/profile.tsx index 75873c61..31ffb524 100644 --- a/apps/web/src/routes/profile.tsx +++ b/apps/web/src/routes/profile.tsx @@ -1,7 +1,6 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useMemo, useState } from "react"; import { AccountIdentitySettings } from "../components/account-identity-settings"; -import { PresenceKeySettings } from "../components/presence-key-settings"; import { ProfileAvatar } from "../components/profile-avatar"; import { ProfileAvatarSettings } from "../components/profile-avatar-settings"; import { Toast } from "../components/toast"; @@ -152,10 +151,6 @@ function ProfilePage() { enabled={Boolean(me && !me.id.startsWith("guest:"))} onMessage={setToast} /> - {me && !me.id.startsWith("guest:") && (
    } {activeSection === "service" && } {activeSection === "import" && } + {activeSection === "api" && } {activeSection === "privacy" && } {activeSection === "blocked" && } {activeSection === "rss" && showRss && ( diff --git a/apps/web/src/settings/settings-api.tsx b/apps/web/src/settings/settings-api.tsx new file mode 100644 index 00000000..312c9440 --- /dev/null +++ b/apps/web/src/settings/settings-api.tsx @@ -0,0 +1,149 @@ +import { useEffect, useState } from "react"; +import { Toast } from "../components/toast"; +import { useAuth } from "../hooks/use-auth"; +import { + createPresenceKey, + fetchPresenceKeys, + type PresenceKey, + revokePresenceKey, +} from "../lib/api-presence"; +import { m } from "../paraglide/messages.js"; + +const SECTION_LABEL = "text-xs font-medium text-fg-soft uppercase tracking-wider px-1"; +const GROUP = "divide-y divide-border border-y border-border"; + +export function SettingsApi() { + const { authReady, isAuthed, isGuest } = useAuth(); + const enabled = authReady && isAuthed && !isGuest; + const [keys, setKeys] = useState([]); + const [name, setName] = useState(""); + const [createdToken, setCreatedToken] = useState(null); + const [pending, setPending] = useState(false); + const [toast, setToast] = useState(null); + + useEffect(() => { + if (!enabled) return; + let active = true; + fetchPresenceKeys() + .then((loaded) => { + if (active) setKeys(loaded); + }) + .catch(() => setToast(m.presence_load_failed())); + return () => { + active = false; + }; + }, [enabled]); + + useEffect(() => { + if (!toast) return; + const timer = setTimeout(() => setToast(null), 3000); + return () => clearTimeout(timer); + }, [toast]); + + if (!authReady) { + return ( +
    {m.ui_loading()}
    + ); + } + + if (!enabled) { + return ( +
    + {m.api_sign_in_required()} +
    + ); + } + + const create = async () => { + const keyName = name.trim(); + if (!keyName || pending) return; + setPending(true); + try { + const created = await createPresenceKey(keyName); + setKeys((current) => [...current, created.key]); + setCreatedToken(created.token); + setName(""); + setToast(m.presence_created()); + } catch { + setToast(m.presence_create_failed()); + } finally { + setPending(false); + } + }; + + const revoke = async (id: string) => { + try { + await revokePresenceKey(id); + setKeys((current) => current.filter((key) => key.id !== id)); + } catch { + setToast(m.presence_revoke_failed()); + } + }; + + return ( +
    +

    {m.presence_title()}

    + {createdToken && ( +
    +

    {m.presence_token_once()}

    +

    {createdToken}

    + +
    + )} +
    + + +
    +
    + {keys.length === 0 ? ( +
    {m.presence_empty()}
    + ) : ( + keys.map((key) => ( +
    +
    +

    {key.name}

    +

    {key.tokenPrefix}

    +
    + +
    + )) + )} +
    + +
    + ); +} From edd1b4d6ae7f5dfae14773bd7ca657d4cd7e6d8b Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 00:23:33 +0900 Subject: [PATCH 025/149] feat: add viewport-aware subscription group pagination --- apps/web/messages/de.json | 9 ++- apps/web/messages/en.json | 9 ++- apps/web/messages/fr.json | 9 ++- .../subscription-groups/group-pagination.tsx | 56 +++++++++++++++++ apps/web/src/hooks/use-group-pagination.ts | 62 +++++++++++++++++++ apps/web/src/lib/group-pagination.ts | 28 +++++++++ apps/web/tests/group-pagination.test.ts | 59 ++++++++++++++++++ 7 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/subscription-groups/group-pagination.tsx create mode 100644 apps/web/src/hooks/use-group-pagination.ts create mode 100644 apps/web/src/lib/group-pagination.ts create mode 100644 apps/web/tests/group-pagination.test.ts diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index aa976200..58aaee5b 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1357,6 +1357,8 @@ "sg_in_group": "In Gruppe", "sg_invalid_name": "Der Gruppenname muss zwischen 1 und 100 Zeichen lang sein.", "sg_load_error": "Deine Gruppen konnten nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.", + "sg_refresh_error": "Die Gruppen konnten nicht aktualisiert werden. Die angezeigten Daten sind möglicherweise veraltet. Versuche es erneut, um die Bearbeitung fortzusetzen.", + "sg_refreshing": "Wird aktualisiert…", "sg_load_more": "Mehr anzeigen · {shown} von {total}", "sg_loading": "Gruppen und Kanäle werden geladen…", "sg_manage_groups": "Gruppen verwalten", @@ -1394,5 +1396,10 @@ "sg_delete_one_confirmation": "Die Gruppe und die Zugehörigkeit von 1 Kanal werden gelöscht. Der Kanal und dein Abonnement bleiben bestehen.", "sg_partial_one_failure": "Änderungen für 1 Kanal konnten nicht gespeichert werden. Einige Änderungen waren möglicherweise erfolgreich. Prüfe die aktualisierten Gruppen und versuche es erneut.", "subscriptions_feed_load_error": "Deine Abonnements konnten nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.", - "subscriptions_feed_next_page_error": "Weitere Videos konnten nicht geladen werden. Bereits geladene Videos bleiben verfügbar." + "subscriptions_feed_next_page_error": "Weitere Videos konnten nicht geladen werden. Bereits geladene Videos bleiben verfügbar.", + "sg_page_range": "{start}–{end} von {total}", + "sg_previous_page": "Vorherige Seite", + "sg_next_page": "Nächste Seite", + "sg_more_groups": "Weitere Gruppen ({count})", + "sg_no_group_results": "Keine Gruppen entsprechen deiner Suche." } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 8401dea2..e563b74a 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1357,6 +1357,8 @@ "sg_in_group": "In group", "sg_invalid_name": "Use a group name between 1 and 100 characters.", "sg_load_error": "Your groups could not be loaded. Check your connection and try again.", + "sg_refresh_error": "Groups could not be refreshed. The data shown may be out of date. Editing is paused until you try again.", + "sg_refreshing": "Refreshing…", "sg_load_more": "Show more · {shown} of {total}", "sg_loading": "Loading your groups and channels…", "sg_manage_groups": "Manage groups", @@ -1394,5 +1396,10 @@ "sg_delete_one_confirmation": "This deletes the group and its membership for 1 channel. The channel and your subscription will be kept.", "sg_partial_one_failure": "Changes for 1 channel could not be saved. Some changes may have succeeded. Review the updated memberships and retry.", "subscriptions_feed_load_error": "Your subscriptions could not be loaded. Check your connection and try again.", - "subscriptions_feed_next_page_error": "More videos could not be loaded. Your loaded videos are still available." + "subscriptions_feed_next_page_error": "More videos could not be loaded. Your loaded videos are still available.", + "sg_page_range": "{start}–{end} of {total}", + "sg_previous_page": "Previous page", + "sg_next_page": "Next page", + "sg_more_groups": "Additional groups ({count})", + "sg_no_group_results": "No groups match your search." } diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index c20f3d29..3cc6d939 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -1357,6 +1357,8 @@ "sg_in_group": "Dans le groupe", "sg_invalid_name": "Le nom du groupe doit contenir entre 1 et 100 caractères.", "sg_load_error": "Impossible de charger vos groupes. Vérifiez votre connexion et réessayez.", + "sg_refresh_error": "Impossible d’actualiser les groupes. Les données affichées peuvent être obsolètes. Réessayez pour reprendre la modification.", + "sg_refreshing": "Actualisation…", "sg_load_more": "Afficher plus · {shown} sur {total}", "sg_loading": "Chargement des groupes et des chaînes…", "sg_manage_groups": "Gérer les groupes", @@ -1394,5 +1396,10 @@ "sg_delete_one_confirmation": "Le groupe et son appartenance pour 1 chaîne seront supprimés. La chaîne et votre abonnement seront conservés.", "sg_partial_one_failure": "Les modifications de 1 chaîne n’ont pas pu être enregistrées. Certaines ont pu réussir. Vérifiez les groupes actualisés et réessayez.", "subscriptions_feed_load_error": "Impossible de charger vos abonnements. Vérifiez votre connexion et réessayez.", - "subscriptions_feed_next_page_error": "Impossible de charger plus de vidéos. Les vidéos déjà chargées restent disponibles." + "subscriptions_feed_next_page_error": "Impossible de charger plus de vidéos. Les vidéos déjà chargées restent disponibles.", + "sg_page_range": "{start}–{end} sur {total}", + "sg_previous_page": "Page précédente", + "sg_next_page": "Page suivante", + "sg_more_groups": "Groupes supplémentaires ({count})", + "sg_no_group_results": "Aucun groupe ne correspond à votre recherche." } diff --git a/apps/web/src/components/subscription-groups/group-pagination.tsx b/apps/web/src/components/subscription-groups/group-pagination.tsx new file mode 100644 index 00000000..8127bd6e --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-pagination.tsx @@ -0,0 +1,56 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import type { GroupPage } from "../../lib/group-pagination"; +import { m } from "../../paraglide/messages.js"; + +type Props = GroupPage & { + total: number; + label: string; + compact?: boolean; + disabled: boolean; + onPage: (page: number) => void; +}; + +export function GroupPagination(props: Props): React.JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/hooks/use-group-pagination.ts b/apps/web/src/hooks/use-group-pagination.ts new file mode 100644 index 00000000..cc8b4404 --- /dev/null +++ b/apps/web/src/hooks/use-group-pagination.ts @@ -0,0 +1,62 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { fitGroupPage, type GroupPage, groupPage } from "../lib/group-pagination"; + +type Options = { + total: number; + rowRem: number; + fallbackSize: number; + reservedRem?: number; + anchor?: number; +}; + +export function useGroupPagination({ + total, + rowRem, + fallbackSize, + reservedRem = 0, + anchor = -1, +}: Options): GroupPage & { + viewport: React.RefObject; + onPage: (page: number) => void; +} { + const viewport = useRef(null); + const [state, setState] = useState(() => groupPage(total, fallbackSize, 0)); + const current = groupPage(total, state.size, state.page); + useLayoutEffect(() => { + const element = viewport.current; + if (!element) return; + const desktop = window.matchMedia("(min-width: 1024px) and (min-height: 600px)"); + function measure(): void { + const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize); + setState((previous) => { + const next = fitGroupPage( + previous, + total, + desktop.matches ? (element?.clientHeight ?? 0) : fallbackSize * rowRem * rem, + rowRem * rem, + desktop.matches ? reservedRem * rem : 0, + anchor, + ); + return next.size === previous.size && + next.page === previous.page && + next.end === previous.end && + next.pages === previous.pages + ? previous + : next; + }); + } + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + desktop.addEventListener("change", measure); + return () => { + observer.disconnect(); + desktop.removeEventListener("change", measure); + }; + }, [total, rowRem, fallbackSize, reservedRem, anchor]); + return { + ...current, + viewport, + onPage: (page) => setState(groupPage(total, current.size, page)), + }; +} diff --git a/apps/web/src/lib/group-pagination.ts b/apps/web/src/lib/group-pagination.ts new file mode 100644 index 00000000..9f67ef80 --- /dev/null +++ b/apps/web/src/lib/group-pagination.ts @@ -0,0 +1,28 @@ +export type GroupPage = { page: number; size: number; pages: number; start: number; end: number }; + +export function groupPage(total: number, size: number, page: number): GroupPage { + const capacity = Math.max(1, Math.floor(size)); + const pages = Math.max(1, Math.ceil(total / capacity)); + const current = Math.max(0, Math.min(page, pages - 1)); + return { + page: current, + size: capacity, + pages, + start: current * capacity, + end: Math.min(total, (current + 1) * capacity), + }; +} + +export function fitGroupPage( + previous: GroupPage, + total: number, + height: number, + rowHeight: number, + reserved: number, + anchor: number, +): GroupPage { + const size = Math.max(1, Math.floor((height - reserved) / rowHeight)); + const visibleAnchor = anchor >= previous.start && anchor < previous.end; + const first = visibleAnchor ? anchor : previous.start; + return groupPage(total, size, size === previous.size ? previous.page : Math.floor(first / size)); +} diff --git a/apps/web/tests/group-pagination.test.ts b/apps/web/tests/group-pagination.test.ts new file mode 100644 index 00000000..bd21158a --- /dev/null +++ b/apps/web/tests/group-pagination.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import { fitGroupPage, groupPage } from "../src/lib/group-pagination"; + +test("replacement pages cover 150 channels exactly once, including the partial last page", () => { + const channels = Array.from({ length: 150 }, (_, index) => index); + for (const size of [1, 6, 8, 11, 17]) { + const seen: number[] = []; + const first = groupPage(channels.length, size, 0); + for (let index = 0; index < first.pages; index++) { + const page = groupPage(channels.length, size, index); + seen.push(...channels.slice(page.start, page.end)); + } + expect(seen).toEqual(channels); + } +}); + +test("viewport capacity budgets the full expanded editor without losing the last selected row", () => { + const before = groupPage(150, 8, 1); + const editing = fitGroupPage(before, 150, 448, 56, 72, 15); + expect(editing.size).toBe(6); + expect(editing.start).toBeLessThanOrEqual(15); + expect(editing.end).toBeGreaterThan(15); + expect(editing.size * 56 + 72).toBeLessThanOrEqual(448); + const closed = fitGroupPage(editing, 150, 448, 56, 0, 15); + expect(closed.start).toBeLessThanOrEqual(15); + expect(closed.end).toBeGreaterThan(15); +}); + +test("resizing preserves the first visible item when selection is on another page", () => { + const before = groupPage(150, 8, 5); + const resized = fitGroupPage(before, 150, 340, 56, 72, 2); + expect(resized.start).toBeLessThanOrEqual(before.start); + expect(resized.end).toBeGreaterThan(before.start); + expect(resized.size).toBe(4); +}); + +test("navigating while one channel is selected does not force its page back into view", () => { + const next = groupPage(150, 6, 3); + expect(fitGroupPage(next, 150, 448, 56, 72, 15)).toEqual(next); +}); + +test("selecting a second channel keeps the clicked row visible when the editor closes", () => { + const editing = groupPage(150, 5, 1); + const multiple = fitGroupPage(editing, 150, 372, 56, 0, 6); + expect(multiple.start).toBeLessThanOrEqual(6); + expect(multiple.end).toBeGreaterThan(6); +}); + +test("deleting the last group on a page clamps to an available page", () => { + const before = groupPage(19, 6, 3); + const after = fitGroupPage(before, 18, 216, 36, 0, -1); + expect(after).toEqual({ page: 2, size: 6, pages: 3, start: 12, end: 18 }); + expect(groupPage(0, 6, 3)).toEqual({ page: 0, size: 6, pages: 1, start: 0, end: 0 }); +}); + +test("very short viewports and notices retain at least one usable row", () => { + expect(fitGroupPage(groupPage(18, 6, 0), 18, 30, 56, 72, -1).size).toBe(1); + expect(groupPage(18, 0, -1).size).toBe(1); +}); From 423138855a391bc16baa0de8bb22084b609dec21 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 00:23:36 +0900 Subject: [PATCH 026/149] style: compact group management with TypeType controls --- .../channel-group-editor.tsx | 2 +- .../group-channel-list.tsx | 87 +++++++++++-------- .../subscription-groups/group-channel-row.tsx | 44 +++++++--- .../subscription-groups/group-combobox.tsx | 2 +- .../subscription-groups/group-name-form.tsx | 8 +- .../group-sidebar-item.tsx | 6 +- .../subscription-groups/group-sidebar.tsx | 50 ++++++++--- .../subscription-groups/group-toolbar.tsx | 28 ++---- apps/web/src/routes/__root.tsx | 5 +- apps/web/src/styles/subscription-groups.css | 32 ++++++- 10 files changed, 168 insertions(+), 96 deletions(-) diff --git a/apps/web/src/components/subscription-groups/channel-group-editor.tsx b/apps/web/src/components/subscription-groups/channel-group-editor.tsx index cf495e3b..c38fc702 100644 --- a/apps/web/src/components/subscription-groups/channel-group-editor.tsx +++ b/apps/web/src/components/subscription-groups/channel-group-editor.tsx @@ -32,7 +32,7 @@ export function ChannelGroupEditor({ } return (
    { event.preventDefault(); diff --git a/apps/web/src/components/subscription-groups/group-channel-list.tsx b/apps/web/src/components/subscription-groups/group-channel-list.tsx index 10689a51..b6b64555 100644 --- a/apps/web/src/components/subscription-groups/group-channel-list.tsx +++ b/apps/web/src/components/subscription-groups/group-channel-list.tsx @@ -1,10 +1,13 @@ import { useState } from "react"; +import { useGroupPagination } from "../../hooks/use-group-pagination"; import { m } from "../../paraglide/messages.js"; import type { GroupedSubscription, SubscriptionGroup } from "../../types/subscription-groups"; import { GroupChannelRow } from "./group-channel-row"; +import { GroupPagination } from "./group-pagination"; type Props = { channels: GroupedSubscription[]; + label: string; groups: SubscriptionGroup[]; selected: ReadonlySet; editing: string | null; @@ -17,44 +20,52 @@ type Props = { }; export function GroupChannelList(props: Props): React.JSX.Element { - const [limit, setLimit] = useState(50); - if (props.channels.length === 0) - return ( -
    -

    {m.sg_no_channel_match()}

    -

    {m.sg_change_filters()}

    -
    - ); + const [anchor, setAnchor] = useState(null); + const pagination = useGroupPagination({ + total: props.channels.length, + rowRem: 3.5, + fallbackSize: 10, + reservedRem: props.editing ? 4.5 : 0, + anchor: props.channels.findIndex((channel) => channel.channelUrl === (anchor ?? props.editing)), + }); return ( - <> -
      - {props.channels.slice(0, limit).map((channel) => ( - props.onToggle(channel.channelUrl)} - onDraft={(ids) => props.onDraft(channel.channelUrl, ids)} - onCancel={props.onCancel} - onSave={(ids) => props.onSave(channel, ids)} - /> - ))} -
    - {props.channels.length > limit && ( - - )} - +
    +
    + {props.channels.length === 0 ? ( +
    +

    {m.sg_no_channel_match()}

    +

    {m.sg_change_filters()}

    +
    + ) : ( +
      + {props.channels.slice(pagination.start, pagination.end).map((channel) => ( + { + setAnchor(channel.channelUrl); + props.onToggle(channel.channelUrl); + }} + onDraft={(ids) => props.onDraft(channel.channelUrl, ids)} + onCancel={props.onCancel} + onSave={(ids) => props.onSave(channel, ids)} + /> + ))} +
    + )} +
    + +
    ); } diff --git a/apps/web/src/components/subscription-groups/group-channel-row.tsx b/apps/web/src/components/subscription-groups/group-channel-row.tsx index d03a3dbc..01423e63 100644 --- a/apps/web/src/components/subscription-groups/group-channel-row.tsx +++ b/apps/web/src/components/subscription-groups/group-channel-row.tsx @@ -32,15 +32,16 @@ export function GroupChannelRow(props: Props): React.JSX.Element { return (
  • -
    +
    -
    +
    {channel.name} @@ -80,13 +81,34 @@ export function GroupChannelRow(props: Props): React.JSX.Element { }} /> ) : ( -
    +
    group.name).join(", ")} + > {memberships.length > 0 ? ( - memberships.map((group) => ( - - {group.name} - - )) + <> + {memberships.slice(0, 2).map((group) => ( + + {group.name} + + ))} + {memberships.length > 2 && ( + + + + {m.sg_more_groups({ count: memberships.length - 2 })}:{" "} + {memberships + .slice(2) + .map((group) => group.name) + .join(", ")} + + + )} + ) : ( {m.sg_add_groups()} )} diff --git a/apps/web/src/components/subscription-groups/group-combobox.tsx b/apps/web/src/components/subscription-groups/group-combobox.tsx index 882d0a90..39344469 100644 --- a/apps/web/src/components/subscription-groups/group-combobox.tsx +++ b/apps/web/src/components/subscription-groups/group-combobox.tsx @@ -22,7 +22,7 @@ export function GroupCombobox({ groups, selected, disabled, onToggle }: Props): if (!event.currentTarget.contains(event.relatedTarget)) combo.setOpen(false); }} > -
    +
    {combo.chosen.map((group) => ( @@ -61,7 +61,7 @@ export function GroupNameForm({ onClick={onCancel} disabled={busy} aria-label={m.portability_cancel()} - className="sg-button w-9 shrink-0 px-0" + className="sg-button w-8 shrink-0 px-0" > diff --git a/apps/web/src/components/subscription-groups/group-sidebar-item.tsx b/apps/web/src/components/subscription-groups/group-sidebar-item.tsx index 2bba5dd1..0acae5a4 100644 --- a/apps/web/src/components/subscription-groups/group-sidebar-item.tsx +++ b/apps/web/src/components/subscription-groups/group-sidebar-item.tsx @@ -49,14 +49,14 @@ export function GroupSidebarItem({ ); return (
    ))} -
    - {props.groups.map((group) => ( + + { + setQuery(event.target.value); + pagination.onPage(0); + }} + className="h-8 w-full shrink-0 border border-border bg-app px-2 text-xs placeholder:text-fg-muted" + /> +
    + {matches.slice(pagination.start, pagination.end).map((group) => ( props.onDelete(group)} /> ))} - {props.groups.length === 0 && ( -

    {m.sg_no_groups()}

    + {matches.length === 0 && ( +

    + {props.groups.length === 0 ? m.sg_no_groups() : m.sg_no_group_results()} +

    )} - +
    + ); } diff --git a/apps/web/src/components/subscription-groups/group-toolbar.tsx b/apps/web/src/components/subscription-groups/group-toolbar.tsx index fe1fc90b..77eb7f0c 100644 --- a/apps/web/src/components/subscription-groups/group-toolbar.tsx +++ b/apps/web/src/components/subscription-groups/group-toolbar.tsx @@ -6,7 +6,6 @@ import type { SubscriptionGroup } from "../../types/subscription-groups"; type Props = { groups: SubscriptionGroup[]; defaultTarget: string; - filterName: string; isGroup: boolean; excluded: boolean; query: string; @@ -30,12 +29,12 @@ export function GroupToolbar(props: Props): React.JSX.Element { const [target, setTarget] = useState(props.defaultTarget); const validTarget = props.groups.some((group) => group.id === target) ? target : ""; return ( -
    +
    -
    -
    -
    +
    +

    {props.busy ? m.sg_saving() @@ -136,26 +135,13 @@ export function GroupToolbar(props: Props): React.JSX.Element { type="button" disabled={props.selectedCount === 0} onClick={props.onClear} + aria-label={m.sg_clear()} + title={m.sg_clear()} className="sg-button border-transparent" > - {m.sg_clear()}

    -
    - - {props.onlySelected - ? m.sg_show_selected() - : props.isGroup && props.excluded - ? m.sg_outside_named({ group: props.filterName }) - : props.filterName} - - - {props.resultCount === 1 - ? m.sg_one_channel() - : m.sg_channel_count({ count: props.resultCount })} - -
    ); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ae5f1342..c5bd75cf 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -202,7 +202,10 @@ function RootLayoutContent() {
    {watchCinemaPage ? !isMobile && : } -
    +
    diff --git a/apps/web/src/styles/subscription-groups.css b/apps/web/src/styles/subscription-groups.css index 6cd7e7ea..6ee1d3a1 100644 --- a/apps/web/src/styles/subscription-groups.css +++ b/apps/web/src/styles/subscription-groups.css @@ -2,7 +2,7 @@ @layer components { .sg-button { - @apply inline-flex min-h-9 items-center justify-center gap-1.5 border border-border-strong px-3 py-1.5 text-xs font-medium text-fg transition-colors hover:bg-surface-strong disabled:cursor-not-allowed disabled:opacity-40; + @apply inline-flex min-h-8 items-center justify-center gap-1.5 border border-border px-2 py-1 text-xs font-medium text-fg-muted transition-colors hover:border-border-strong hover:bg-surface-strong hover:text-fg disabled:cursor-not-allowed disabled:opacity-40; } .sg-membership-toggle { @@ -10,7 +10,7 @@ } .sg-membership-toggle:enabled { - @apply cursor-pointer border-fg-muted bg-surface-strong hover:border-fg hover:bg-surface-soft; + @apply cursor-pointer border-fg-muted bg-surface-strong text-fg hover:border-fg hover:bg-surface-soft; } .sg-membership-toggle:enabled[aria-pressed="true"] { @@ -18,11 +18,11 @@ } .sg-chip { - @apply border border-border-strong px-2 py-1 text-xs text-fg-muted; + @apply border border-border-strong px-1.5 py-0.5 text-xs text-fg-muted; } .sg-menu-item { - @apply px-3 py-2 text-left text-xs hover:bg-surface-strong disabled:opacity-40; + @apply px-3 py-2 text-left text-xs text-fg-muted hover:bg-surface-strong hover:text-fg disabled:opacity-40; } } @@ -53,3 +53,27 @@ scrollbar-color: var(--color-border-strong) var(--color-surface); scroll-margin-top: 4rem; } + +@media (min-width: 1024px) and (min-height: 600px) { + .sg-page { + display: flex; + height: 100dvh; + flex-direction: column; + padding-bottom: 0.5rem; + } + + .sg-page > .sg-manager { + min-height: 0; + flex: 1; + } + + .sg-page .sg-workspace { + grid-template-rows: minmax(0, 1fr); + } + + .sg-page > footer { + flex-shrink: 0; + margin-top: 0.5rem; + padding-block: 0.5rem; + } +} From ba33bf6b09f18c4bc2ee9a41b0a8bcca0da52ca1 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 00:23:40 +0900 Subject: [PATCH 027/149] fix: pause group editing after failed data refreshes --- .../group-manager-data.tsx | 55 +++++ .../group-manager-header.tsx | 18 ++ .../subscription-groups/group-manager.tsx | 208 ++++++++---------- apps/web/src/hooks/use-group-manager.ts | 5 +- apps/web/src/hooks/use-subscription-groups.ts | 4 +- 5 files changed, 173 insertions(+), 117 deletions(-) create mode 100644 apps/web/src/components/subscription-groups/group-manager-data.tsx create mode 100644 apps/web/src/components/subscription-groups/group-manager-header.tsx diff --git a/apps/web/src/components/subscription-groups/group-manager-data.tsx b/apps/web/src/components/subscription-groups/group-manager-data.tsx new file mode 100644 index 00000000..159e170f --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-manager-data.tsx @@ -0,0 +1,55 @@ +import type { UseQueryResult } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { m } from "../../paraglide/messages.js"; + +type ManagedQuery = Pick< + UseQueryResult, + "data" | "isPending" | "isError" | "isFetching" | "refetch" +>; +type Props = { groups: ManagedQuery; channels: ManagedQuery; children: ReactNode }; + +export function GroupManagerData({ groups, channels, children }: Props): React.JSX.Element { + const queries = [groups, channels]; + const loaded = queries.every((query) => query.data !== undefined); + const failed = queries.some((query) => query.isError); + const refreshing = queries.some((query) => query.isFetching); + if (queries.some((query) => query.isPending)) + return ( +

    + {m.sg_loading()} +

    + ); + return ( + <> + {failed && ( +
    +

    + {loaded ? m.sg_refresh_error() : m.sg_load_error()} +

    + +
    + )} + {loaded && ( +
    + {children} +
    + )} + + ); +} diff --git a/apps/web/src/components/subscription-groups/group-manager-header.tsx b/apps/web/src/components/subscription-groups/group-manager-header.tsx new file mode 100644 index 00000000..c694dbce --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-manager-header.tsx @@ -0,0 +1,18 @@ +import { Link } from "@tanstack/react-router"; +import { ArrowLeft } from "lucide-react"; +import { m } from "../../paraglide/messages.js"; + +export function GroupManagerHeader(): React.JSX.Element { + return ( +
    +
    +

    {m.sg_manage_groups()}

    +

    {m.sg_manager_description()}

    +
    + + + {m.sg_back_channels()} + +
    + ); +} diff --git a/apps/web/src/components/subscription-groups/group-manager.tsx b/apps/web/src/components/subscription-groups/group-manager.tsx index 4e223ae3..22740caa 100644 --- a/apps/web/src/components/subscription-groups/group-manager.tsx +++ b/apps/web/src/components/subscription-groups/group-manager.tsx @@ -1,7 +1,6 @@ -import { Link } from "@tanstack/react-router"; -import { ArrowLeft } from "lucide-react"; import { useMemo } from "react"; import { useGroupManager } from "../../hooks/use-group-manager"; +import { useInterfaceLocale } from "../../hooks/use-interface-locale"; import { useGroupMemberships, useSubscriptionGroups } from "../../hooks/use-subscription-groups"; import { createSubscriptionGroup, @@ -12,13 +11,17 @@ import { channelMembershipChanges } from "../../lib/subscription-group-selection import { m } from "../../paraglide/messages.js"; import { GroupChannelList } from "./group-channel-list"; import { GroupConfirmDialog } from "./group-confirm-dialog"; +import { GroupManagerData } from "./group-manager-data"; +import { GroupManagerHeader } from "./group-manager-header"; import { GroupSidebar } from "./group-sidebar"; import { GroupToolbar } from "./group-toolbar"; import "../../styles/subscription-groups.css"; export function GroupManager(): React.JSX.Element { + useInterfaceLocale(); const groupsQuery = useSubscriptionGroups(); const channelsQuery = useGroupMemberships(); + const canEdit = groupsQuery.isSuccess && channelsQuery.isSuccess; const groups = useMemo( () => [...(groupsQuery.data ?? [])].sort((a, b) => a.name.localeCompare(b.name)), [groupsQuery.data], @@ -54,125 +57,104 @@ export function GroupManager(): React.JSX.Element { clearSelection, bulk, confirm, - } = useGroupManager(groups, channels); + } = useGroupManager(groups, channels, canEdit); return ( -
    -
    -
    -

    {m.sg_manage_groups()}

    -

    {m.sg_manager_description()}

    -
    - - - {m.sg_back_channels()} - -
    - {groupsQuery.isPending || channelsQuery.isPending ? ( -

    - {m.sg_loading()} -

    - ) : (groupsQuery.isError && !groupsQuery.data) || - (channelsQuery.isError && !channelsQuery.data) ? ( -
    -

    {m.sg_load_error()}

    - -
    - ) : ( -
    - + + + channel.groupIds.length === 0).length} + filter={activeFilter} + disabled={disabled} + onFilter={changeFilter} + onCreate={(name) => + actions.run(() => createSubscriptionGroup(name), m.sg_group_created({ group: name })) + } + onRename={(id, name) => + actions.run(() => renameSubscriptionGroup(id, name), m.sg_group_renamed()) + } + onDelete={setConfirmation} + onCancelRename={actions.clearError} + /> +
    + channel.groupIds.length === 0).length} - filter={activeFilter} - disabled={disabled} - onFilter={changeFilter} - onCreate={(name) => - actions.run(() => createSubscriptionGroup(name), m.sg_group_created({ group: name })) - } - onRename={(id, name) => - actions.run(() => renameSubscriptionGroup(id, name), m.sg_group_renamed()) + defaultTarget={activeGroup?.id ?? ""} + isGroup={Boolean(activeGroup)} + excluded={excluded} + query={query} + selectedCount={chosen.length} + hiddenCount={hiddenCount} + resultCount={visible.length} + allSelected={ + visible.length > 0 && + visible.every((channel) => validSelected.has(channel.channelUrl)) } - onDelete={setConfirmation} - onCancelRename={actions.clearError} + onlySelected={onlySelected} + disabled={disabled} + busy={actions.busy} + onQuery={setQuery} + onExcluded={setExcluded} + onSelectResults={selectResults} + onClear={clearSelection} + onOnlySelected={() => setOnlySelected(!onlySelected)} + onBulk={bulk} + onRemoveAll={() => setConfirmation("clear")} /> -
    - 0 && - visible.every((channel) => validSelected.has(channel.channelUrl)) + {actions.error && ( +

    + {actions.error} +

    + )} + {actions.notice && ( +

    + {actions.notice} +

    + )} + {channels.length === 0 ? ( +
    +

    {m.ui_no_subscriptions_yet_2()}

    +

    {m.sg_empty_subscriptions()}

    +
    + ) : ( + setOnlySelected(!onlySelected)} - onBulk={bulk} - onRemoveAll={() => setConfirmation("clear")} + onToggle={toggle} + onDraft={setDraft} + onCancel={clearSelection} + onSave={(channel, ids) => + actions.run( + () => updateGroupMemberships(channelMembershipChanges(channel, ids)), + m.sg_channel_saved({ channel: channel.name }), + ) + } /> - {actions.error && ( -

    - {actions.error} -

    - )} - {actions.notice && ( -

    - {actions.notice} -

    - )} - {channels.length === 0 ? ( -
    -

    {m.ui_no_subscriptions_yet_2()}

    -

    {m.sg_empty_subscriptions()}

    -
    - ) : ( - - actions.run( - () => updateGroupMemberships(channelMembershipChanges(channel, ids)), - m.sg_channel_saved({ channel: channel.name }), - ) - } - /> - )} -
    + )}
    - )} - {confirmationProps && ( +
    + {confirmationProps && canEdit && ( setConfirmation(null)} diff --git a/apps/web/src/hooks/use-group-manager.ts b/apps/web/src/hooks/use-group-manager.ts index 9461b0f6..ebfd8ac0 100644 --- a/apps/web/src/hooks/use-group-manager.ts +++ b/apps/web/src/hooks/use-group-manager.ts @@ -42,8 +42,9 @@ type State = { export function useGroupManager( groups: SubscriptionGroup[], channels: GroupedSubscription[], + canEdit: boolean, ): State { - const actions = useGroupActions(); + const actions = useGroupActions(canEdit); const [filter, setFilter] = useState("all"); const [excluded, setExcluded] = useState(false); const [query, setQuery] = useState(""); @@ -67,7 +68,7 @@ export function useGroupManager( activeGroup?.name ?? (activeFilter === "ungrouped" ? m.groups_preview_ungrouped() : m.sg_all_channels()); const editing = chosen.length === 1 ? chosen[0].channelUrl : null; - const disabled = actions.busy; + const disabled = actions.busy || !canEdit; function toggle(url: string): void { actions.clearError(); diff --git a/apps/web/src/hooks/use-subscription-groups.ts b/apps/web/src/hooks/use-subscription-groups.ts index 8bbf236e..d66b98c8 100644 --- a/apps/web/src/hooks/use-subscription-groups.ts +++ b/apps/web/src/hooks/use-subscription-groups.ts @@ -41,7 +41,7 @@ type GroupActions = { run: (action: () => Promise, success: string) => Promise; }; -export function useGroupActions(): GroupActions { +export function useGroupActions(enabled: boolean): GroupActions { const client = useQueryClient(); const lock = useRef(false); const [busy, setBusy] = useState(false); @@ -49,7 +49,7 @@ export function useGroupActions(): GroupActions { const [notice, setNotice] = useState(null); async function run(action: () => Promise, success: string): Promise { - if (lock.current) return false; + if (!enabled || lock.current) return false; lock.current = true; setBusy(true); setError(null); From 42086648159195461e2e3669917d840a08d981d0 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 00:23:43 +0900 Subject: [PATCH 028/149] test: cover group refresh recovery --- apps/web/tests/group-refresh-errors.test.tsx | 163 +++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 apps/web/tests/group-refresh-errors.test.tsx diff --git a/apps/web/tests/group-refresh-errors.test.tsx b/apps/web/tests/group-refresh-errors.test.tsx new file mode 100644 index 00000000..47e8796c --- /dev/null +++ b/apps/web/tests/group-refresh-errors.test.tsx @@ -0,0 +1,163 @@ +import { afterEach, expect, test } from "bun:test"; +import { QueryClient, QueryClientProvider, QueryObserver } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { GroupManagerData } from "../src/components/subscription-groups/group-manager-data"; +import { useGroupActions } from "../src/hooks/use-subscription-groups"; +import { MembershipUpdateError } from "../src/lib/api-subscription-groups"; +import { m } from "../src/paraglide/messages.js"; +import type { GroupedSubscription, SubscriptionGroup } from "../src/types/subscription-groups"; + +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); +}); + +function readActions(client: QueryClient, enabled: boolean): ReturnType { + let actions: ReturnType | undefined; + function ReadActions(): null { + actions = useGroupActions(enabled); + return null; + } + renderToStaticMarkup( + + + , + ); + if (!actions) throw new Error("Actions did not render"); + return actions; +} + +function setup() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const failedReads = new Set(); + const state: { memberships: string[]; writes: number } = { memberships: [], writes: 0 }; + const group: SubscriptionGroup = { + id: "tech", + name: "Tech", + channelCount: 0, + createdAt: 0, + updatedAt: 0, + }; + const channel: GroupedSubscription = { + channelUrl: "https://www.youtube.com/channel/example", + name: "Example", + avatarUrl: "", + subscribedAt: 0, + groupIds: [], + }; + const groupKey = ["subscription-groups", "profile"]; + const channelKey = ["subscription-group-memberships", "profile"]; + client.setQueryData(groupKey, [group]); + client.setQueryData(channelKey, [channel]); + const groups = new QueryObserver(client, { + queryKey: groupKey, + staleTime: Infinity, + queryFn: async () => { + if (failedReads.has("groups")) throw new Error("Group refresh unavailable"); + return [{ ...group, channelCount: state.memberships.length ? 1 : 0 }]; + }, + }); + const channels = new QueryObserver(client, { + queryKey: channelKey, + staleTime: Infinity, + queryFn: async () => { + if (failedReads.has("memberships")) throw new Error("Membership refresh unavailable"); + return [{ ...channel, groupIds: [...state.memberships] }]; + }, + }); + const unsubscribers = [groups.subscribe(() => {}), channels.subscribe(() => {})]; + cleanups.push(() => { + for (const unsubscribe of unsubscribers) unsubscribe(); + client.clear(); + }); + return { + client, + groups, + channels, + state, + failedReads, + write: async () => { + state.writes++; + state.memberships = ["tech"]; + }, + retry: () => Promise.all([groups.refetch(), channels.refetch()]), + actions: () => + readActions( + client, + groups.getCurrentResult().isSuccess && channels.getCurrentResult().isSuccess, + ), + render: () => + renderToStaticMarkup( + + + , + ), + }; +} + +test("saved memberships with a failed refresh pause editing; recovery only repeats reads", async () => { + const fixture = setup(); + fixture.failedReads.add("memberships"); + expect(await fixture.actions().run(fixture.write, "Saved")).toBe(true); + expect(fixture.state.memberships).toEqual(["tech"]); + expect(fixture.channels.getCurrentResult().data?.[0].groupIds).toEqual([]); + expect(fixture.render()).toContain(m.sg_refresh_error()); + expect(fixture.render()).toContain('
    { + const fixture = setup(); + fixture.failedReads.add("groups"); + expect(await fixture.actions().run(fixture.write, "Saved")).toBe(true); + expect(fixture.channels.getCurrentResult().data?.[0].groupIds).toEqual(["tech"]); + expect(fixture.render()).toContain('
    { + const fixture = setup(); + fixture.failedReads.add("memberships"); + expect( + await fixture.actions().run(async () => { + await fixture.write(); + throw new MembershipUpdateError(["https://www.youtube.com/channel/example"]); + }, "Saved"), + ).toBe(false); + expect(fixture.render()).toContain('
    { + const fixture = setup(); + fixture.failedReads.add("memberships"); + await fixture.channels.refetch(); + expect(fixture.render()).toContain("Edit memberships"); + expect(fixture.render()).toContain(m.sg_refresh_error()); + expect(fixture.render()).toContain('
    { + const fixture = setup(); + fixture.failedReads.add("groups"); + await fixture.client.resetQueries({ queryKey: ["subscription-groups"] }); + expect(fixture.render()).toContain(m.sg_load_error()); + expect(fixture.render()).toContain(m.sg_retry()); + expect(fixture.render()).not.toContain("Edit memberships"); +}); From 729997dc466f5fbe7a8e49407ec40ec6d3285cc0 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 16 Sep 2026 19:12:39 +0200 Subject: [PATCH 029/149] fix: explain stream extraction failures --- apps/web/messages/de.json | 8 ++- apps/web/messages/en.json | 8 ++- apps/web/messages/fr.json | 10 +++- .../web/src/components/watch-stream-error.tsx | 8 +-- .../web/src/hooks/use-shorts-active-stream.ts | 3 +- apps/web/src/lib/stream-error-message.ts | 46 +++++++++++++++++ apps/web/src/routes/embed_.$videoId.tsx | 8 +-- apps/web/tests/stream-error-message.test.ts | 50 +++++++++++++++++++ 8 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/lib/stream-error-message.ts create mode 100644 apps/web/tests/stream-error-message.test.ts diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 3af830ee..6ef4e5b1 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -686,7 +686,7 @@ "ui_no_private_feeds": "Keine privaten Feeds", "ui_no_private_feeds_have_been_created": "Es wurden keine privaten Feeds erstellt.", "ui_no_results_for": "Keine Ergebnisse für “", - "ui_no_shorts_available_right_now": "Derzeit keine Shorts verfügbar.", + "ui_no_shorts_available_right_now": "Noch sind keine Shorts verfügbar. Abonniere ein paar Kanäle oder versuche es später erneut.", "ui_no_subscriptions_yet": "Noch keine Abonnements", "ui_no_subscriptions_yet_2": "Noch keine Abonnements.", "ui_no_videos_saved_for_later": "Keine Videos für später gespeichert.", @@ -959,6 +959,12 @@ "ui_connect_youtube_to_access_this_video": "Verbinde YouTube, um dieses Video ansehen zu können.", "ui_this_video_is_currently_unavailable": "Dieses Video ist derzeit nicht verfügbar", "ui_failed_to_load_stream": "Laden des Streams fehlgeschlagen.", + "ui_error_provider_blocked": "Der Videoanbieter blockiert Anfragen von diesem TypeType-Server. Versuche es mit einem VPN oder einem anderen ausgehenden Netzwerk für den Server und probiere es erneut.", + "ui_error_region_restricted": "Dieses Video ist in der Region des Servers nicht verfügbar. Ein VPN oder ein anderes ausgehendes Netzwerk kann helfen.", + "ui_error_no_playable_stream": "TypeType konnte keinen kompatiblen Stream für dieses Video finden. Versuche es später erneut oder wähle ein anderes Video.", + "ui_error_private_video": "Dieses Video ist privat oder nicht mehr verfügbar.", + "ui_error_age_restricted": "Für dieses Video ist eine Altersbestätigung erforderlich. Verbinde YouTube und versuche es erneut.", + "ui_error_stream_unavailable": "Der Videodienst konnte keinen abspielbaren Stream bereitstellen. Versuche es später erneut.", "ui_mute_zen_music": "Zen-Musik stumm schalten", "ui_completing_sign_in_with_your_provider": "Die Anmeldung bei deinem Anbieter wird abgeschlossen.", "ui_could_not_load_player": "Player konnte nicht geladen werden.", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 68c98a78..6ff24862 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -702,7 +702,7 @@ "ui_no_private_feeds": "No private feeds", "ui_no_private_feeds_have_been_created": "No private feeds have been created.", "ui_no_results_for": "No results for “", - "ui_no_shorts_available_right_now": "No shorts available right now.", + "ui_no_shorts_available_right_now": "No Shorts are available yet. Subscribe to a few channels or check back later.", "ui_no_subscriptions_yet": "No subscriptions yet", "ui_no_subscriptions_yet_2": "No subscriptions yet.", "ui_no_videos_saved_for_later": "No videos saved for later.", @@ -975,6 +975,12 @@ "ui_connect_youtube_to_access_this_video": "Connect YouTube to access this video.", "ui_this_video_is_currently_unavailable": "This video is currently unavailable", "ui_failed_to_load_stream": "Failed to load stream.", + "ui_error_provider_blocked": "The video provider is blocking requests from this TypeType server. Try a VPN or another outbound network for the server, then retry.", + "ui_error_region_restricted": "This video is not available in the server's region. A VPN or another outbound network may help.", + "ui_error_no_playable_stream": "TypeType could not find a compatible stream for this video. Try again later or choose another video.", + "ui_error_private_video": "This video is private or no longer available.", + "ui_error_age_restricted": "This video requires age verification. Connect YouTube and try again.", + "ui_error_stream_unavailable": "The video service could not provide a playable stream. Try again later.", "ui_mute_zen_music": "Mute zen music", "ui_completing_sign_in_with_your_provider": "Completing sign-in with your provider.", "ui_could_not_load_player": "Could not load player.", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 1fbc6b96..ab8ad1ed 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -702,7 +702,7 @@ "ui_no_private_feeds": "Aucun flux privé", "ui_no_private_feeds_have_been_created": "No private feeds have been created.", "ui_no_results_for": "Aucun résultat pour « ", - "ui_no_shorts_available_right_now": "No shorts available right now.", + "ui_no_shorts_available_right_now": "Aucun Short n’est encore disponible. Abonnez-vous à quelques chaînes ou revenez plus tard.", "ui_no_subscriptions_yet": "Aucun subscriptions pour le moment.", "ui_no_subscriptions_yet_2": "Aucun abonnement pour le moment.", "ui_no_videos_saved_for_later": "Aucune vidéo enregistrée pour plus tard.", @@ -974,7 +974,13 @@ "ui_error_occurs_when_fetching_the_page_try_increase_the_loading_timeout": "Error occurs when fetching the page. Try increase the loading timeout in Settings.", "ui_connect_youtube_to_access_this_video": "Connect YouTube to access this video.", "ui_this_video_is_currently_unavailable": "This video is currently unavailable", - "ui_failed_to_load_stream": "Échec lors de load stream..", + "ui_failed_to_load_stream": "Échec du chargement du flux.", + "ui_error_provider_blocked": "Le fournisseur vidéo bloque les requêtes de ce serveur TypeType. Essayez un VPN ou un autre réseau sortant pour le serveur, puis réessayez.", + "ui_error_region_restricted": "Cette vidéo n’est pas disponible dans la région du serveur. Un VPN ou un autre réseau sortant peut aider.", + "ui_error_no_playable_stream": "TypeType n’a trouvé aucun flux compatible pour cette vidéo. Réessayez plus tard ou choisissez une autre vidéo.", + "ui_error_private_video": "Cette vidéo est privée ou n’est plus disponible.", + "ui_error_age_restricted": "Cette vidéo nécessite une vérification de l’âge. Connectez YouTube puis réessayez.", + "ui_error_stream_unavailable": "Le service vidéo n’a pas pu fournir de flux lisible. Réessayez plus tard.", "ui_mute_zen_music": "Mute zen music", "ui_completing_sign_in_with_your_provider": "Completing sign-in with your provider.", "ui_could_not_load_player": "Could not load player.", diff --git a/apps/web/src/components/watch-stream-error.tsx b/apps/web/src/components/watch-stream-error.tsx index 09c6f3b8..6a32fa11 100644 --- a/apps/web/src/components/watch-stream-error.tsx +++ b/apps/web/src/components/watch-stream-error.tsx @@ -2,6 +2,7 @@ import { isStreamUnavailableError } from "../hooks/use-stream"; import { familyListBlockedMessage, isChannelNotAllowedError } from "../lib/allow-list-error"; import { ApiError } from "../lib/api"; import { isYoutubeSessionActionError } from "../lib/api-youtube-session"; +import { resolveStreamErrorMessage } from "../lib/stream-error-message"; import { resolveVideoAvailability, videoAvailabilityCopy } from "../lib/video-availability"; import { youtubeSessionReturnToForWatch } from "../lib/youtube-session-route"; import { m } from "../paraglide/messages.js"; @@ -28,6 +29,9 @@ export function WatchStreamError({ error, publicParam, list, shuffle, poster, on const youtubeSessionReturnTo = needsYoutubeSession ? youtubeSessionReturnToForWatch(publicParam, list, shuffle) : undefined; + const streamErrorMessage = isStreamUnavailableError(error) + ? m.ui_this_video_is_currently_unavailable() + : resolveStreamErrorMessage(error); const message = availability ? videoAvailabilityCopy(availability, error instanceof Error ? error.message : undefined) .message @@ -35,9 +39,7 @@ export function WatchStreamError({ error, publicParam, list, shuffle, poster, on ? familyListBlockedMessage() : needsYoutubeSession ? m.ui_connect_youtube_to_access_this_video() - : isStreamUnavailableError(error) - ? m.ui_this_video_is_currently_unavailable() - : m.ui_failed_to_load_stream(); + : (streamErrorMessage ?? m.ui_failed_to_load_stream()); return ( { + const error = new ApiError( + "YouTube requested CAPTCHA verification", + 422, + "provider_access_blocked", + ); + + expect(resolveStreamErrorMessage(error)).toContain("VPN"); + expect(resolveStreamErrorMessage(error)).toContain("TypeType server"); +}); + +test("explains region restrictions without treating every 422 as a VPN issue", () => { + const region = new ApiError("Only available in Japan", 400, "geographic_restriction"); + const noStream = new ApiError( + "No compatible stream is available for this video", + 422, + "no_playable_streams", + ); + + expect(resolveStreamErrorMessage(region)).toContain("region"); + expect(resolveStreamErrorMessage(region)).toContain("VPN"); + expect(resolveStreamErrorMessage(noStream)).not.toContain("VPN"); +}); + +test("maps unknown 422 responses to a retry message", () => { + const error = new ApiError("internal provider detail", 422, "error"); + + expect(resolveStreamErrorMessage(error)).toBe( + "The video service could not provide a playable stream. Try again later.", + ); +}); + +test("keeps legacy provider messages actionable before code rollout", () => { + expect( + resolveStreamErrorMessage(new ApiError("Your IP is blocked by YouTube", 422, null)), + ).toContain("VPN"); + expect( + resolveStreamErrorMessage( + new ApiError("This video is not available in your country", 400, null), + ), + ).toContain("VPN"); +}); + +test("keeps unrelated status errors available to their existing handlers", () => { + expect(resolveStreamErrorMessage(new ApiError("Bad request", 400, "bad_request"))).toBeNull(); +}); From f319d0c106166a314d2973e9cfeb4111daa0cb82 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 16 Sep 2026 19:33:21 +0200 Subject: [PATCH 030/149] fix: explain empty shorts feed --- apps/web/messages/de.json | 2 +- apps/web/messages/en.json | 2 +- apps/web/messages/fr.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 6ef4e5b1..238e4621 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -686,7 +686,7 @@ "ui_no_private_feeds": "Keine privaten Feeds", "ui_no_private_feeds_have_been_created": "Es wurden keine privaten Feeds erstellt.", "ui_no_results_for": "Keine Ergebnisse für “", - "ui_no_shorts_available_right_now": "Noch sind keine Shorts verfügbar. Abonniere ein paar Kanäle oder versuche es später erneut.", + "ui_no_shorts_available_right_now": "Noch sind keine Shorts verfügbar. Abonniere einige Kanäle oder importiere deine YouTube-Abonnements, um diesen Feed zu füllen, und versuche es bei Bedarf später erneut.", "ui_no_subscriptions_yet": "Noch keine Abonnements", "ui_no_subscriptions_yet_2": "Noch keine Abonnements.", "ui_no_videos_saved_for_later": "Keine Videos für später gespeichert.", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 6ff24862..57bfa1f8 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -702,7 +702,7 @@ "ui_no_private_feeds": "No private feeds", "ui_no_private_feeds_have_been_created": "No private feeds have been created.", "ui_no_results_for": "No results for “", - "ui_no_shorts_available_right_now": "No Shorts are available yet. Subscribe to a few channels or check back later.", + "ui_no_shorts_available_right_now": "No Shorts are available yet. Subscribe to some channels or import your YouTube subscriptions to fill this feed, then check back later if needed.", "ui_no_subscriptions_yet": "No subscriptions yet", "ui_no_subscriptions_yet_2": "No subscriptions yet.", "ui_no_videos_saved_for_later": "No videos saved for later.", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index ab8ad1ed..c8855b47 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -702,7 +702,7 @@ "ui_no_private_feeds": "Aucun flux privé", "ui_no_private_feeds_have_been_created": "No private feeds have been created.", "ui_no_results_for": "Aucun résultat pour « ", - "ui_no_shorts_available_right_now": "Aucun Short n’est encore disponible. Abonnez-vous à quelques chaînes ou revenez plus tard.", + "ui_no_shorts_available_right_now": "Aucun Short n’est encore disponible. Abonnez-vous à des chaînes ou importez vos abonnements YouTube pour remplir ce fil, puis revenez plus tard si nécessaire.", "ui_no_subscriptions_yet": "Aucun subscriptions pour le moment.", "ui_no_subscriptions_yet_2": "Aucun abonnement pour le moment.", "ui_no_videos_saved_for_later": "Aucune vidéo enregistrée pour plus tard.", From e014c205dd2dcc1a48a70099442fc87b46f34b7f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 16 Sep 2026 19:57:43 +0200 Subject: [PATCH 031/149] fix: guide empty shorts feeds --- .../src/components/shorts-player-shell.tsx | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/shorts-player-shell.tsx b/apps/web/src/components/shorts-player-shell.tsx index 501cc04f..e166feb3 100644 --- a/apps/web/src/components/shorts-player-shell.tsx +++ b/apps/web/src/components/shorts-player-shell.tsx @@ -1,3 +1,4 @@ +import { Link } from "@tanstack/react-router"; import { useRef, useState } from "react"; import { ShortsPlayerStage } from "../components/shorts-player-stage"; import { ShortsShellLoader } from "../components/shorts-shell-loader"; @@ -85,9 +86,28 @@ export function ShortsPlayerShell({ targetUrl }: Props) { } if (!active) { return ( -
    -

    {m.ui_no_shorts_available_right_now()}

    -
    +
    +
    +

    {m.nav_shorts()}

    +

    + {m.ui_no_shorts_available_right_now()} +

    +
    + + {m.groups_preview_open_subscriptions()} + + + {m.ui_import_from_youtube()} + +
    +
    +
    ); } const hasPrev = index > 0; From 84c23b0cf5548a008919788c8fd0f89a41b31d1b Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 19:10:53 +0900 Subject: [PATCH 032/149] refactor: share subscription queries and cache refreshes --- .../components/portability-import-panel.tsx | 10 +-- apps/web/src/hooks/use-subscription-feed.ts | 13 +--- apps/web/src/hooks/use-subscription-groups.ts | 18 +++--- apps/web/src/hooks/use-subscriptions.ts | 33 +++------- apps/web/src/lib/api-subscription-groups.ts | 6 -- apps/web/src/lib/api-user.ts | 5 +- apps/web/src/lib/subscription-queries.ts | 56 +++++++++++++++++ apps/web/src/routes/subscriptions.tsx | 31 +++------ .../src/routes/subscriptions_.channels.tsx | 30 +++------ .../tests/subscription-feed-errors.test.tsx | 48 +++++++++----- apps/web/tests/subscription-queries.test.ts | 63 +++++++++++++++++++ 11 files changed, 192 insertions(+), 121 deletions(-) create mode 100644 apps/web/src/lib/subscription-queries.ts create mode 100644 apps/web/tests/subscription-queries.test.ts diff --git a/apps/web/src/components/portability-import-panel.tsx b/apps/web/src/components/portability-import-panel.tsx index 5cafaa7f..ae5b033f 100644 --- a/apps/web/src/components/portability-import-panel.tsx +++ b/apps/web/src/components/portability-import-panel.tsx @@ -12,6 +12,7 @@ import { type PortabilityJob, startPortabilityImport, } from "../lib/api-portability"; +import { invalidateSubscriptionQueries } from "../lib/subscription-queries"; import { m } from "../paraglide/messages.js"; import { PortabilityFormatPicker } from "./portability-format-picker"; import { PortabilityImportGuide } from "./portability-import-guide"; @@ -64,14 +65,7 @@ export function PortabilityImportPanel({ formats }: { formats: PortabilityFormat useEffect(() => { const state = job.data?.state ?? null; if (state === "completed" && previousState.current !== "completed") { - for (const key of [ - "subscription-groups", - "subscription-group-memberships", - "subscriptions", - "subscription-feed", - ]) { - void queryClient.invalidateQueries({ queryKey: [key] }); - } + void invalidateSubscriptionQueries(queryClient); const count = Object.values(job.data?.result ?? {}).reduce((sum, value) => sum + value, 0); setToast( `${m.portability_import_completed()}: ${count.toLocaleString()} ${m.portability_items()}`, diff --git a/apps/web/src/hooks/use-subscription-feed.ts b/apps/web/src/hooks/use-subscription-feed.ts index a93d5142..e9a1d4e3 100644 --- a/apps/web/src/hooks/use-subscription-feed.ts +++ b/apps/web/src/hooks/use-subscription-feed.ts @@ -1,15 +1,13 @@ import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo } from "react"; import { ApiError } from "../lib/api"; -import { fetchSubscriptionFeed } from "../lib/api-user"; import { mapVideoItem } from "../lib/mappers"; import { proxyImage } from "../lib/proxy"; +import { subscriptionFeedQueryOptions } from "../lib/subscription-queries"; import type { VideoStream } from "../types/stream"; import { useAuth } from "./use-auth"; import { useSubscriptions } from "./use-subscriptions"; -export const SUBSCRIPTION_FEED_KEY = ["subscription-feed"]; - type Result = { streams: VideoStream[]; isLoading: boolean; @@ -31,12 +29,7 @@ export function useSubscriptionFeed(filter = "all"): Result { ); const query = useInfiniteQuery({ - queryKey: filter === "all" ? SUBSCRIPTION_FEED_KEY : [...SUBSCRIPTION_FEED_KEY, filter], - queryFn: ({ pageParam, signal }) => - fetchSubscriptionFeed(pageParam as string | null, signal, filter), - initialPageParam: null as string | null, - getNextPageParam: (last) => last.nextpage ?? undefined, - staleTime: 5 * 60 * 1000, + ...subscriptionFeedQueryOptions(filter), enabled: authReady && isAuthed, }); @@ -48,7 +41,7 @@ export function useSubscriptionFeed(filter = "all"): Result { ) ) { void queryClient.resetQueries({ - queryKey: filter === "all" ? SUBSCRIPTION_FEED_KEY : [...SUBSCRIPTION_FEED_KEY, filter], + queryKey: subscriptionFeedQueryOptions(filter).queryKey, exact: true, }); } diff --git a/apps/web/src/hooks/use-subscription-groups.ts b/apps/web/src/hooks/use-subscription-groups.ts index d66b98c8..90b599f7 100644 --- a/apps/web/src/hooks/use-subscription-groups.ts +++ b/apps/web/src/hooks/use-subscription-groups.ts @@ -6,17 +6,19 @@ import { fetchSubscriptionGroups, MembershipUpdateError, } from "../lib/api-subscription-groups"; +import { + invalidateSubscriptionQueries, + SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY, + SUBSCRIPTION_GROUPS_KEY, +} from "../lib/subscription-queries"; import { m } from "../paraglide/messages.js"; import type { GroupedSubscription, SubscriptionGroup } from "../types/subscription-groups"; import { useAuth } from "./use-auth"; -const GROUPS_KEY = ["subscription-groups"]; -const GROUP_MEMBERSHIPS_KEY = ["subscription-group-memberships"]; - export function useSubscriptionGroups(): UseQueryResult { const { authReady, isAuthed, me } = useAuth(); return useQuery({ - queryKey: [...GROUPS_KEY, me?.id], + queryKey: [...SUBSCRIPTION_GROUPS_KEY, me?.id], queryFn: fetchSubscriptionGroups, enabled: authReady && isAuthed, staleTime: 60_000, @@ -26,7 +28,7 @@ export function useSubscriptionGroups(): UseQueryResult { export function useGroupMemberships(): UseQueryResult { const { authReady, isAuthed, me } = useAuth(); return useQuery({ - queryKey: [...GROUP_MEMBERSHIPS_KEY, me?.id], + queryKey: [...SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY, me?.id], queryFn: fetchGroupMemberships, enabled: authReady && isAuthed, staleTime: 60_000, @@ -72,11 +74,7 @@ export function useGroupActions(enabled: boolean): GroupActions { : m.sg_save_error(), ); } finally { - await Promise.all( - [GROUPS_KEY, GROUP_MEMBERSHIPS_KEY, ["subscriptions"], ["subscription-feed"]].map( - (queryKey) => client.invalidateQueries({ queryKey }), - ), - ); + await invalidateSubscriptionQueries(client); lock.current = false; setBusy(false); } diff --git a/apps/web/src/hooks/use-subscriptions.ts b/apps/web/src/hooks/use-subscriptions.ts index 840c3036..0f193014 100644 --- a/apps/web/src/hooks/use-subscriptions.ts +++ b/apps/web/src/hooks/use-subscriptions.ts @@ -1,12 +1,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { fetchFilteredSubscriptions } from "../lib/api-subscription-groups"; -import { fetchSubscriptions, subscribe, unsubscribe } from "../lib/api-user"; +import { subscribe, unsubscribe } from "../lib/api-user"; import { normalizeChannelUrl } from "../lib/channel-url"; +import { + invalidateSubscriptionQueries, + subscriptionsQueryOptions, +} from "../lib/subscription-queries"; import type { SubscriptionItem } from "../types/user"; import { useAuth } from "./use-auth"; -export const SUBSCRIPTIONS_KEY = ["subscriptions"]; - function hasSubscription(data: SubscriptionItem[] | undefined, channelUrl: string): boolean { const target = normalizeChannelUrl(channelUrl); return (data ?? []).some((item) => normalizeChannelUrl(item.channelUrl) === target); @@ -29,11 +30,9 @@ export function useSubscriptions(filter = "all") { const { authReady, isAuthed } = useAuth(); const query = useQuery({ - queryKey: filter === "all" ? SUBSCRIPTIONS_KEY : [...SUBSCRIPTIONS_KEY, filter], - queryFn: () => (filter === "all" ? fetchSubscriptions() : fetchFilteredSubscriptions(filter)), + ...subscriptionsQueryOptions(filter), enabled: authReady && isAuthed, select: dedupeSubscriptions, - staleTime: 5 * 60 * 1000, }); const add = useMutation({ @@ -45,28 +44,12 @@ export function useSubscriptions(filter = "all") { channelUrl: normalizeChannelUrl(item.channelUrl), }); }, - onSuccess: () => - Promise.all( - [ - SUBSCRIPTIONS_KEY, - ["subscription-groups"], - ["subscription-group-memberships"], - ["subscription-feed"], - ].map((queryKey) => qc.invalidateQueries({ queryKey })), - ), + onSuccess: () => invalidateSubscriptionQueries(qc), }); const remove = useMutation({ mutationFn: (channelUrl: string) => (isAuthed ? unsubscribe(channelUrl) : Promise.resolve()), - onSuccess: () => - Promise.all( - [ - SUBSCRIPTIONS_KEY, - ["subscription-groups"], - ["subscription-group-memberships"], - ["subscription-feed"], - ].map((queryKey) => qc.invalidateQueries({ queryKey })), - ), + onSuccess: () => invalidateSubscriptionQueries(qc), }); function isSubscribed(channelUrl: string): boolean { diff --git a/apps/web/src/lib/api-subscription-groups.ts b/apps/web/src/lib/api-subscription-groups.ts index 0383a9dd..a7201228 100644 --- a/apps/web/src/lib/api-subscription-groups.ts +++ b/apps/web/src/lib/api-subscription-groups.ts @@ -3,11 +3,9 @@ import type { MembershipChange, SubscriptionGroup, } from "../types/subscription-groups"; -import type { SubscriptionItem } from "../types/user"; import { ApiError } from "./api"; import { authed, authedJson } from "./authed"; import { API_BASE } from "./env"; -import { subscriptionFilterParams } from "./subscription-group-selection"; const GROUPS_URL = `${API_BASE}/subscriptions/groups`; @@ -19,10 +17,6 @@ export function fetchGroupMemberships(): Promise { return authedJson(`${API_BASE}/subscriptions/group-memberships`); } -export function fetchFilteredSubscriptions(filter: string): Promise { - return authedJson(`${API_BASE}/subscriptions?${subscriptionFilterParams(filter)}`); -} - async function groupRequest(path: string, method: string, body?: unknown): Promise { const response = await authed(`${GROUPS_URL}${path}`, { method, diff --git a/apps/web/src/lib/api-user.ts b/apps/web/src/lib/api-user.ts index 908580a3..a5f76d23 100644 --- a/apps/web/src/lib/api-user.ts +++ b/apps/web/src/lib/api-user.ts @@ -60,8 +60,9 @@ export async function clearHistory(): Promise { if (!res.ok) throw new ApiError("Failed to clear history", res.status); } -export function fetchSubscriptions(): Promise { - return authedJson(`${BASE}/subscriptions`); +export function fetchSubscriptions(filter = "all"): Promise { + const search = subscriptionFilterParams(filter).toString(); + return authedJson(`${BASE}/subscriptions${search ? `?${search}` : ""}`); } export async function subscribe(item: Omit): Promise { diff --git a/apps/web/src/lib/subscription-queries.ts b/apps/web/src/lib/subscription-queries.ts new file mode 100644 index 00000000..357a18a8 --- /dev/null +++ b/apps/web/src/lib/subscription-queries.ts @@ -0,0 +1,56 @@ +import { + type InfiniteData, + infiniteQueryOptions, + type QueryClient, + queryOptions, +} from "@tanstack/react-query"; +import type { SubscriptionFeedPage } from "../types/api"; +import type { SubscriptionItem } from "../types/user"; +import { fetchSubscriptionFeed, fetchSubscriptions } from "./api-user"; + +const SUBSCRIPTIONS_KEY = ["subscriptions"]; +const SUBSCRIPTION_FEED_KEY = ["subscription-feed"]; +export const SUBSCRIPTION_GROUPS_KEY = ["subscription-groups"]; +export const SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY = ["subscription-group-memberships"]; +const SUBSCRIPTION_STALE_MS = 5 * 60 * 1000; + +export function subscriptionsQueryOptions( + filter = "all", +): ReturnType> { + return queryOptions({ + queryKey: filter === "all" ? SUBSCRIPTIONS_KEY : [...SUBSCRIPTIONS_KEY, filter], + queryFn: () => fetchSubscriptions(filter), + staleTime: SUBSCRIPTION_STALE_MS, + }); +} + +export function subscriptionFeedQueryOptions( + filter = "all", +): ReturnType< + typeof infiniteQueryOptions< + SubscriptionFeedPage, + Error, + InfiniteData, + string[], + string | null + > +> { + return infiniteQueryOptions({ + queryKey: filter === "all" ? SUBSCRIPTION_FEED_KEY : [...SUBSCRIPTION_FEED_KEY, filter], + queryFn: ({ pageParam, signal }) => fetchSubscriptionFeed(pageParam, signal, filter), + initialPageParam: null as string | null, + getNextPageParam: (last) => last.nextpage ?? undefined, + staleTime: SUBSCRIPTION_STALE_MS, + }); +} + +export async function invalidateSubscriptionQueries(client: QueryClient): Promise { + await Promise.all( + [ + SUBSCRIPTIONS_KEY, + SUBSCRIPTION_FEED_KEY, + SUBSCRIPTION_GROUPS_KEY, + SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY, + ].map((queryKey) => client.invalidateQueries({ queryKey })), + ); +} diff --git a/apps/web/src/routes/subscriptions.tsx b/apps/web/src/routes/subscriptions.tsx index 6f5b3a39..233f3756 100644 --- a/apps/web/src/routes/subscriptions.tsx +++ b/apps/web/src/routes/subscriptions.tsx @@ -8,19 +8,15 @@ import { VideoGrid } from "../components/video-grid"; import { VideoGridSkeleton } from "../components/video-grid-skeleton"; import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { streamQueryOptions } from "../hooks/use-stream"; -import { SUBSCRIPTION_FEED_KEY, useSubscriptionFeed } from "../hooks/use-subscription-feed"; -import { SUBSCRIPTIONS_KEY, useSubscriptions } from "../hooks/use-subscriptions"; +import { useSubscriptionFeed } from "../hooks/use-subscription-feed"; +import { useSubscriptions } from "../hooks/use-subscriptions"; import { ApiError } from "../lib/api"; -import { fetchFilteredSubscriptions } from "../lib/api-subscription-groups"; -import { fetchSubscriptionFeed, fetchSubscriptions } from "../lib/api-user"; +import { + subscriptionFeedQueryOptions, + subscriptionsQueryOptions, +} from "../lib/subscription-queries"; import { m } from "../paraglide/messages.js"; -const SUBSCRIPTION_STALE_MS = 5 * 60 * 1000; - -function nextSubscriptionPage(last: Awaited>) { - return last.nextpage ?? undefined; -} - function SubscriptionsPage() { const queryClient = useQueryClient(); const prefetchedIdsRef = useRef(new Set()); @@ -42,22 +38,11 @@ function SubscriptionsPage() { const visible = useMemo(() => filter(streams), [filter, streams]); function prefetchChannels() { - void queryClient.prefetchQuery({ - queryKey: group === "all" ? SUBSCRIPTIONS_KEY : [...SUBSCRIPTIONS_KEY, group], - queryFn: () => (group === "all" ? fetchSubscriptions() : fetchFilteredSubscriptions(group)), - staleTime: SUBSCRIPTION_STALE_MS, - }); + void queryClient.prefetchQuery(subscriptionsQueryOptions(group)); } function prefetchVideos() { - void queryClient.prefetchInfiniteQuery({ - queryKey: group === "all" ? SUBSCRIPTION_FEED_KEY : [...SUBSCRIPTION_FEED_KEY, group], - queryFn: ({ pageParam, signal }) => - fetchSubscriptionFeed(pageParam as string | null, signal, group), - initialPageParam: null as string | null, - getNextPageParam: nextSubscriptionPage, - staleTime: SUBSCRIPTION_STALE_MS, - }); + void queryClient.prefetchInfiniteQuery(subscriptionFeedQueryOptions(group)); } useEffect(() => { diff --git a/apps/web/src/routes/subscriptions_.channels.tsx b/apps/web/src/routes/subscriptions_.channels.tsx index 938f7184..59e437b9 100644 --- a/apps/web/src/routes/subscriptions_.channels.tsx +++ b/apps/web/src/routes/subscriptions_.channels.tsx @@ -5,18 +5,13 @@ import { SubscriptionGroupFilter } from "../components/subscription-group-filter import { SubscriptionsHeader } from "../components/subscriptions-header"; import { VideoGridSkeleton } from "../components/video-grid-skeleton"; import { useBlockedFilter } from "../hooks/use-blocked-filter"; -import { SUBSCRIPTION_FEED_KEY } from "../hooks/use-subscription-feed"; -import { SUBSCRIPTIONS_KEY, useSubscriptions } from "../hooks/use-subscriptions"; -import { fetchFilteredSubscriptions } from "../lib/api-subscription-groups"; -import { fetchSubscriptionFeed, fetchSubscriptions } from "../lib/api-user"; +import { useSubscriptions } from "../hooks/use-subscriptions"; +import { + subscriptionFeedQueryOptions, + subscriptionsQueryOptions, +} from "../lib/subscription-queries"; import { m } from "../paraglide/messages.js"; -const SUBSCRIPTION_STALE_MS = 5 * 60 * 1000; - -function nextSubscriptionPage(last: Awaited>) { - return last.nextpage ?? undefined; -} - function SubscriptionChannelsPage() { const queryClient = useQueryClient(); const { group = "all" } = Route.useSearch(); @@ -28,22 +23,11 @@ function SubscriptionChannelsPage() { ); function prefetchChannels() { - void queryClient.prefetchQuery({ - queryKey: group === "all" ? SUBSCRIPTIONS_KEY : [...SUBSCRIPTIONS_KEY, group], - queryFn: () => (group === "all" ? fetchSubscriptions() : fetchFilteredSubscriptions(group)), - staleTime: SUBSCRIPTION_STALE_MS, - }); + void queryClient.prefetchQuery(subscriptionsQueryOptions(group)); } function prefetchVideos() { - void queryClient.prefetchInfiniteQuery({ - queryKey: group === "all" ? SUBSCRIPTION_FEED_KEY : [...SUBSCRIPTION_FEED_KEY, group], - queryFn: ({ pageParam, signal }) => - fetchSubscriptionFeed(pageParam as string | null, signal, group), - initialPageParam: null as string | null, - getNextPageParam: nextSubscriptionPage, - staleTime: SUBSCRIPTION_STALE_MS, - }); + void queryClient.prefetchInfiniteQuery(subscriptionFeedQueryOptions(group)); } return ( diff --git a/apps/web/tests/subscription-feed-errors.test.tsx b/apps/web/tests/subscription-feed-errors.test.tsx index 10628220..bd4e289f 100644 --- a/apps/web/tests/subscription-feed-errors.test.tsx +++ b/apps/web/tests/subscription-feed-errors.test.tsx @@ -1,11 +1,13 @@ import { afterEach, expect, test } from "bun:test"; import { InfiniteQueryObserver, QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderToStaticMarkup } from "react-dom/server"; -import { SUBSCRIPTION_FEED_KEY, useSubscriptionFeed } from "../src/hooks/use-subscription-feed"; -import { SUBSCRIPTIONS_KEY } from "../src/hooks/use-subscriptions"; -import { fetchSubscriptionFeed } from "../src/lib/api-user"; +import { useSubscriptionFeed } from "../src/hooks/use-subscription-feed"; +import { + subscriptionFeedQueryOptions, + subscriptionsQueryOptions, +} from "../src/lib/subscription-queries"; import { useAuthStore } from "../src/stores/auth-store"; -import type { SubscriptionFeedPage, VideoItem } from "../src/types/api"; +import type { VideoItem } from "../src/types/api"; const originalFetch = globalThis.fetch; const clients: QueryClient[] = []; @@ -15,10 +17,10 @@ afterEach(() => { for (const client of clients.splice(0)) client.clear(); }); -function readFeed(client: QueryClient): ReturnType { +function readFeed(client: QueryClient, filter = "all"): ReturnType { let state: ReturnType | undefined; function ReadFeed(): null { - state = useSubscriptionFeed(); + state = useSubscriptionFeed(filter); return null; } renderToStaticMarkup( @@ -30,17 +32,12 @@ function readFeed(client: QueryClient): ReturnType { return state; } -function setup() { +function setup(filter = "all") { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); clients.push(client); - client.setQueryData(SUBSCRIPTIONS_KEY, []); + client.setQueryData(subscriptionsQueryOptions().queryKey, []); useAuthStore.getState().setToken("feed-error-test"); - const observer = new InfiniteQueryObserver(client, { - queryKey: SUBSCRIPTION_FEED_KEY, - queryFn: ({ pageParam }) => fetchSubscriptionFeed(pageParam), - initialPageParam: null as string | null, - getNextPageParam: (last: SubscriptionFeedPage) => last.nextpage ?? undefined, - }); + const observer = new InfiniteQueryObserver(client, subscriptionFeedQueryOptions(filter)); return { client, observer }; } @@ -122,3 +119,26 @@ test("a failed background refresh retains cached feed content", async () => { expect(failed.isFetchNextPageError).toBe(false); expect(failed.streams.map((item) => item.title)).toEqual(["first"]); }); + +test("a prefetched filtered feed is reused and keeps its filter when fetching another page", async () => { + const filter = "tech & science/#"; + const { client } = setup(filter); + const calls: URL[] = []; + globalThis.fetch = async (input) => { + const url = new URL(String(input), "https://fixture.invalid"); + calls.push(url); + const cursor = url.searchParams.get("cursor"); + return Response.json({ + videos: [video(cursor ? "second" : "first")], + nextpage: cursor ? null : "page-2", + }); + }; + await client.prefetchInfiniteQuery(subscriptionFeedQueryOptions(filter)); + const feed = readFeed(client, filter); + expect(feed.streams.map((item) => item.title)).toEqual(["first"]); + expect(calls).toHaveLength(1); + await feed.fetchNextPage(); + expect(readFeed(client, filter).streams.map((item) => item.title)).toEqual(["first", "second"]); + expect(calls.map((url) => url.searchParams.get("cursor"))).toEqual([null, "page-2"]); + expect(calls.every((url) => url.searchParams.get("groupId") === filter)).toBe(true); +}); diff --git a/apps/web/tests/subscription-queries.test.ts b/apps/web/tests/subscription-queries.test.ts new file mode 100644 index 00000000..b1033b4b --- /dev/null +++ b/apps/web/tests/subscription-queries.test.ts @@ -0,0 +1,63 @@ +import { afterEach, expect, test } from "bun:test"; +import { QueryClient } from "@tanstack/react-query"; +import { fetchSubscriptions } from "../src/lib/api-user"; +import { + invalidateSubscriptionQueries, + subscriptionsQueryOptions, +} from "../src/lib/subscription-queries"; +import { useAuthStore } from "../src/stores/auth-store"; + +const originalFetch = globalThis.fetch; +const clients: QueryClient[] = []; +afterEach(() => { + globalThis.fetch = originalFetch; + useAuthStore.getState().setSignedOut(); + for (const client of clients.splice(0)) client.clear(); +}); + +test("channel prefetches stay distinct by filter and are reused by subsequent reads", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + useAuthStore.getState().setToken("subscription-query-test"); + const calls: string[] = []; + globalThis.fetch = async (input) => { + const url = new URL(String(input), "https://fixture.invalid"); + calls.push(url.pathname + url.search); + const name = + url.searchParams.get("groupId") ?? (url.searchParams.has("ungrouped") ? "ungrouped" : "all"); + return Response.json([{ channelUrl: name, name, avatarUrl: "", subscribedAt: 0 }]); + }; + for (const filter of ["all", "ungrouped", "tech & science/#"]) { + const options = subscriptionsQueryOptions(filter); + await client.prefetchQuery(options); + expect((await client.fetchQuery(options))[0].name).toBe(filter); + } + expect(calls).toEqual([ + "/api/subscriptions", + "/api/subscriptions?ungrouped=true", + "/api/subscriptions?groupId=tech+%26+science%2F%23", + ]); + expect((await fetchSubscriptions())[0].name).toBe("all"); + expect(calls.at(-1)).toBe("/api/subscriptions"); +}); + +test("subscription changes invalidate every filter and profile variant without clearing data", async () => { + const client = new QueryClient(); + clients.push(client); + const affectedKeys = [ + ["subscriptions"], + ["subscriptions", "tech"], + ["subscriptions", "ungrouped"], + ["subscription-feed"], + ["subscription-feed", "tech"], + ["subscription-groups", "profile"], + ["subscription-group-memberships", "profile"], + ]; + for (const key of [...affectedKeys, ["playlists"]]) client.setQueryData(key, ["cached"]); + await invalidateSubscriptionQueries(client); + for (const key of affectedKeys) { + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + expect(client.getQueryData(key)).toEqual(["cached"]); + } + expect(client.getQueryState(["playlists"])?.isInvalidated).toBe(false); +}); From 3e7954f4f221d791d347faf91bc34df601e11272 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 19:15:11 +0900 Subject: [PATCH 033/149] fix: cancel subscription reads and preserve API errors --- apps/web/src/hooks/use-subscription-groups.ts | 4 +- apps/web/src/lib/api-subscription-groups.ts | 17 ++-- apps/web/src/lib/api-user.ts | 16 ++-- apps/web/src/lib/api.ts | 24 +++++- apps/web/src/lib/authed.ts | 7 +- apps/web/src/lib/subscription-queries.ts | 2 +- apps/web/tests/subscription-requests.test.ts | 85 +++++++++++++++++++ 7 files changed, 126 insertions(+), 29 deletions(-) create mode 100644 apps/web/tests/subscription-requests.test.ts diff --git a/apps/web/src/hooks/use-subscription-groups.ts b/apps/web/src/hooks/use-subscription-groups.ts index 90b599f7..8e7cf162 100644 --- a/apps/web/src/hooks/use-subscription-groups.ts +++ b/apps/web/src/hooks/use-subscription-groups.ts @@ -19,7 +19,7 @@ export function useSubscriptionGroups(): UseQueryResult { const { authReady, isAuthed, me } = useAuth(); return useQuery({ queryKey: [...SUBSCRIPTION_GROUPS_KEY, me?.id], - queryFn: fetchSubscriptionGroups, + queryFn: ({ signal }) => fetchSubscriptionGroups(signal), enabled: authReady && isAuthed, staleTime: 60_000, }); @@ -29,7 +29,7 @@ export function useGroupMemberships(): UseQueryResult { const { authReady, isAuthed, me } = useAuth(); return useQuery({ queryKey: [...SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY, me?.id], - queryFn: fetchGroupMemberships, + queryFn: ({ signal }) => fetchGroupMemberships(signal), enabled: authReady && isAuthed, staleTime: 60_000, }); diff --git a/apps/web/src/lib/api-subscription-groups.ts b/apps/web/src/lib/api-subscription-groups.ts index a7201228..0953964f 100644 --- a/apps/web/src/lib/api-subscription-groups.ts +++ b/apps/web/src/lib/api-subscription-groups.ts @@ -3,18 +3,18 @@ import type { MembershipChange, SubscriptionGroup, } from "../types/subscription-groups"; -import { ApiError } from "./api"; +import { apiErrorFromResponse } from "./api"; import { authed, authedJson } from "./authed"; import { API_BASE } from "./env"; const GROUPS_URL = `${API_BASE}/subscriptions/groups`; -export function fetchSubscriptionGroups(): Promise { - return authedJson(GROUPS_URL); +export function fetchSubscriptionGroups(signal?: AbortSignal): Promise { + return authedJson(GROUPS_URL, { signal }); } -export function fetchGroupMemberships(): Promise { - return authedJson(`${API_BASE}/subscriptions/group-memberships`); +export function fetchGroupMemberships(signal?: AbortSignal): Promise { + return authedJson(`${API_BASE}/subscriptions/group-memberships`, { signal }); } async function groupRequest(path: string, method: string, body?: unknown): Promise { @@ -25,12 +25,7 @@ async function groupRequest(path: string, method: string, body?: unknown): Promi : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), }); if (!response.ok) { - const error: unknown = await response.json().catch(() => null); - const code = - error && typeof error === "object" && "code" in error && typeof error.code === "string" - ? error.code - : null; - throw new ApiError("Subscription group request failed", response.status, code); + throw apiErrorFromResponse(response, await response.json().catch(() => null)); } return response; } diff --git a/apps/web/src/lib/api-user.ts b/apps/web/src/lib/api-user.ts index a5f76d23..cf62c7c2 100644 --- a/apps/web/src/lib/api-user.ts +++ b/apps/web/src/lib/api-user.ts @@ -1,6 +1,6 @@ import type { SubscriptionFeedPage } from "../types/api"; import type { HistoryItem, SearchHistoryItem, SettingsItem, SubscriptionItem } from "../types/user"; -import { ApiError } from "./api"; +import { ApiError, apiErrorFromResponse } from "./api"; import { authed, authedJson } from "./authed"; import { channelUrlVariants, normalizeChannelUrl } from "./channel-url"; import { API_BASE as BASE } from "./env"; @@ -60,9 +60,12 @@ export async function clearHistory(): Promise { if (!res.ok) throw new ApiError("Failed to clear history", res.status); } -export function fetchSubscriptions(filter = "all"): Promise { +export function fetchSubscriptions( + filter = "all", + signal?: AbortSignal, +): Promise { const search = subscriptionFilterParams(filter).toString(); - return authedJson(`${BASE}/subscriptions${search ? `?${search}` : ""}`); + return authedJson(`${BASE}/subscriptions${search ? `?${search}` : ""}`, { signal }); } export async function subscribe(item: Omit): Promise { @@ -152,12 +155,7 @@ export async function fetchSubscriptionFeed( continue; } if (!res.ok) { - const error = body as { code?: string; error?: string }; - throw new ApiError( - error.error ?? "Subscription feed request failed", - res.status, - error.code ?? null, - ); + throw apiErrorFromResponse(res, body); } return body as SubscriptionFeedPage; } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 4781374d..64b72564 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -13,11 +13,18 @@ import { normalizeApiPayload } from "./text-normalize"; export class ApiError extends Error { status: number; code: string | null; - constructor(message: string, status: number, code: string | null = null) { + requestId: string | null; + constructor( + message: string, + status: number, + code: string | null = null, + requestId: string | null = null, + ) { super(message); this.name = "ApiError"; this.status = status; this.code = code; + this.requestId = requestId; } } @@ -63,6 +70,19 @@ function toErrorCode(body: unknown): string | null { return typeof candidate.code === "string" && candidate.code.length > 0 ? candidate.code : null; } +export function apiErrorFromResponse(response: Response, body: unknown): ApiError { + const bodyRequestId = + body && typeof body === "object" && "requestId" in body && typeof body.requestId === "string" + ? body.requestId + : null; + return new ApiError( + toErrorMessage(response.status, response.statusText, body), + response.status, + toErrorCode(body), + extractRequestId(response.headers) ?? bodyRequestId, + ); +} + export async function request(url: string, init?: RequestInit): Promise { const method = init?.method ?? "GET"; let res: Response; @@ -102,7 +122,7 @@ export async function request(url: string, init?: RequestInit): Promise { requestId, message: sanitizeDebugText(errorMessage), }); - throw new ApiError(errorMessage, res.status, errorCode); + throw apiErrorFromResponse(res, body); } return body as T; } diff --git a/apps/web/src/lib/authed.ts b/apps/web/src/lib/authed.ts index b63146bf..f9d1993d 100644 --- a/apps/web/src/lib/authed.ts +++ b/apps/web/src/lib/authed.ts @@ -1,5 +1,5 @@ import { useAuthStore } from "../stores/auth-store"; -import { ApiError } from "./api"; +import { ApiError, apiErrorFromResponse } from "./api"; import { recordApiError } from "./api-error-log"; import { isRefreshSessionRejected, refreshSession } from "./auth-session"; import { extractRequestId, recordClientEvent } from "./client-debug-log"; @@ -125,7 +125,6 @@ export async function authed( export async function authedJson(url: string, init?: RequestInit): Promise { const res = await authed(url, init); - const body = normalizeApiPayload(await res.json()); - if (!res.ok) throw new ApiError((body as { error: string }).error, res.status); - return body as T; + if (!res.ok) throw apiErrorFromResponse(res, await res.json().catch(() => null)); + return normalizeApiPayload(await res.json()) as T; } diff --git a/apps/web/src/lib/subscription-queries.ts b/apps/web/src/lib/subscription-queries.ts index 357a18a8..deab18c9 100644 --- a/apps/web/src/lib/subscription-queries.ts +++ b/apps/web/src/lib/subscription-queries.ts @@ -19,7 +19,7 @@ export function subscriptionsQueryOptions( ): ReturnType> { return queryOptions({ queryKey: filter === "all" ? SUBSCRIPTIONS_KEY : [...SUBSCRIPTIONS_KEY, filter], - queryFn: () => fetchSubscriptions(filter), + queryFn: ({ signal }) => fetchSubscriptions(filter, signal), staleTime: SUBSCRIPTION_STALE_MS, }); } diff --git a/apps/web/tests/subscription-requests.test.ts b/apps/web/tests/subscription-requests.test.ts new file mode 100644 index 00000000..74eaf430 --- /dev/null +++ b/apps/web/tests/subscription-requests.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { ApiError } from "../src/lib/api"; +import { fetchGroupMemberships, fetchSubscriptionGroups } from "../src/lib/api-subscription-groups"; +import { fetchSubscriptions } from "../src/lib/api-user"; +import { subscriptionsQueryOptions } from "../src/lib/subscription-queries"; +import { useAuthStore } from "../src/stores/auth-store"; + +const originalFetch = globalThis.fetch; +beforeEach(() => useAuthStore.getState().setToken("subscription-request-test")); +afterEach(() => { + globalThis.fetch = originalFetch; + useAuthStore.getState().setSignedOut(); +}); + +test.each(["all", "ungrouped", "deleted-group"])( + "%s reads preserve structured errors and request IDs", + async (filter) => { + globalThis.fetch = async () => + Response.json( + { + error: "Subscription group not found", + code: "subscription_group_not_found", + requestId: "body-id", + }, + { status: 404, headers: { "x-request-id": "header-id" } }, + ); + try { + await fetchSubscriptions(filter); + throw new Error("Expected missing group"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + if (!(error instanceof ApiError)) throw error; + expect(error.code).toBe("subscription_group_not_found"); + expect(error.status).toBe(404); + expect(error.requestId).toBe("header-id"); + } + }, +); + +test("request IDs fall back to the body; non-JSON failures keep their status", async () => { + globalThis.fetch = async () => + Response.json({ error: "Unavailable", requestId: "body-id" }, { status: 503 }); + await expect(fetchSubscriptions()).rejects.toMatchObject({ status: 503, requestId: "body-id" }); + globalThis.fetch = async () => + new Response("Unavailable", { status: 502, statusText: "Bad Gateway" }); + await expect(fetchSubscriptions()).rejects.toMatchObject({ status: 502, message: "Bad Gateway" }); +}); + +test("every subscription list read forwards cancellation to fetch", async () => { + for (const read of [ + fetchSubscriptionGroups, + fetchGroupMemberships, + (signal: AbortSignal) => fetchSubscriptions("tech", signal), + ]) { + const controller = new AbortController(); + const reason = new DOMException("Navigation", "AbortError"); + globalThis.fetch = async (_input, init) => { + expect(init?.signal).toBe(controller.signal); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }); + }; + const pending = read(controller.signal); + controller.abort(reason); + await expect(pending).rejects.toBe(reason); + } +}); + +test("unsubscribing from a query aborts its pending subscription request", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + let signal: AbortSignal | null | undefined; + globalThis.fetch = async (_input, init) => { + signal = init?.signal; + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal?.reason), { once: true }); + }); + }; + const observer = new QueryObserver(client, subscriptionsQueryOptions("tech")); + const unsubscribe = observer.subscribe(() => {}); + expect(signal?.aborted).toBe(false); + unsubscribe(); + expect(signal?.aborted).toBe(true); + client.clear(); +}); From db5e12a207bc94cc382c4cca1a63df74ab5d36e2 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 19:18:40 +0900 Subject: [PATCH 034/149] fix: bound membership writes and target query refreshes --- .../subscription-groups/group-manager.tsx | 8 +- apps/web/src/hooks/use-group-manager.ts | 10 +-- apps/web/src/hooks/use-subscription-groups.ts | 14 ++- apps/web/src/lib/api-subscription-groups.ts | 32 ++++--- apps/web/src/lib/membership-batches.ts | 28 ++++++ apps/web/src/lib/subscription-queries.ts | 31 +++++-- apps/web/tests/membership-batches.test.ts | 85 +++++++++++++++++++ .../tests/subscription-refresh-scope.test.ts | 63 ++++++++++++++ 8 files changed, 241 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/lib/membership-batches.ts create mode 100644 apps/web/tests/membership-batches.test.ts create mode 100644 apps/web/tests/subscription-refresh-scope.test.ts diff --git a/apps/web/src/components/subscription-groups/group-manager.tsx b/apps/web/src/components/subscription-groups/group-manager.tsx index 22740caa..f40bf6d4 100644 --- a/apps/web/src/components/subscription-groups/group-manager.tsx +++ b/apps/web/src/components/subscription-groups/group-manager.tsx @@ -70,10 +70,14 @@ export function GroupManager(): React.JSX.Element { disabled={disabled} onFilter={changeFilter} onCreate={(name) => - actions.run(() => createSubscriptionGroup(name), m.sg_group_created({ group: name })) + actions.run( + () => createSubscriptionGroup(name), + m.sg_group_created({ group: name }), + "groups", + ) } onRename={(id, name) => - actions.run(() => renameSubscriptionGroup(id, name), m.sg_group_renamed()) + actions.run(() => renameSubscriptionGroup(id, name), m.sg_group_renamed(), "groups") } onDelete={setConfirmation} onCancelRename={actions.clearError} diff --git a/apps/web/src/hooks/use-group-manager.ts b/apps/web/src/hooks/use-group-manager.ts index ebfd8ac0..835497c7 100644 --- a/apps/web/src/hooks/use-group-manager.ts +++ b/apps/web/src/hooks/use-group-manager.ts @@ -89,6 +89,7 @@ export function useGroupManager( const channelUrls = chosen .filter((channel) => channel.groupIds.includes(groupId) === (action === "remove")) .map((channel) => channel.channelUrl); + if (channelUrls.length === 0) return; const name = groups.find((group) => group.id === groupId)?.name ?? ""; const message = action === "add" @@ -107,12 +108,9 @@ export function useGroupManager( const pending = confirmation; setConfirmation(null); if (pending === "clear") { - if ( - await actions.run( - () => updateGroupMemberships(clearMembershipChanges(chosen)), - m.sg_memberships_cleared(), - ) - ) + const changes = clearMembershipChanges(chosen); + if (changes.length === 0) return; + if (await actions.run(() => updateGroupMemberships(changes), m.sg_memberships_cleared())) clearSelection(); } else if (pending) { if ( diff --git a/apps/web/src/hooks/use-subscription-groups.ts b/apps/web/src/hooks/use-subscription-groups.ts index 8e7cf162..7667f26f 100644 --- a/apps/web/src/hooks/use-subscription-groups.ts +++ b/apps/web/src/hooks/use-subscription-groups.ts @@ -40,7 +40,11 @@ type GroupActions = { error: string | null; notice: string | null; clearError: () => void; - run: (action: () => Promise, success: string) => Promise; + run: ( + action: () => Promise, + success: string, + change?: "groups" | "memberships", + ) => Promise; }; export function useGroupActions(enabled: boolean): GroupActions { @@ -50,7 +54,11 @@ export function useGroupActions(enabled: boolean): GroupActions { const [error, setError] = useState(null); const [notice, setNotice] = useState(null); - async function run(action: () => Promise, success: string): Promise { + async function run( + action: () => Promise, + success: string, + change: "groups" | "memberships" = "memberships", + ): Promise { if (!enabled || lock.current) return false; lock.current = true; setBusy(true); @@ -74,7 +82,7 @@ export function useGroupActions(enabled: boolean): GroupActions { : m.sg_save_error(), ); } finally { - await invalidateSubscriptionQueries(client); + await invalidateSubscriptionQueries(client, change); lock.current = false; setBusy(false); } diff --git a/apps/web/src/lib/api-subscription-groups.ts b/apps/web/src/lib/api-subscription-groups.ts index 0953964f..2d9fce16 100644 --- a/apps/web/src/lib/api-subscription-groups.ts +++ b/apps/web/src/lib/api-subscription-groups.ts @@ -6,8 +6,10 @@ import type { import { apiErrorFromResponse } from "./api"; import { authed, authedJson } from "./authed"; import { API_BASE } from "./env"; +import { membershipBatches } from "./membership-batches"; const GROUPS_URL = `${API_BASE}/subscriptions/groups`; +const MAX_CONCURRENT_MEMBERSHIP_REQUESTS = 3; export function fetchSubscriptionGroups(signal?: AbortSignal): Promise { return authedJson(GROUPS_URL, { signal }); @@ -54,18 +56,24 @@ export class MembershipUpdateError extends Error { export async function updateGroupMemberships(changes: MembershipChange[]): Promise { const failed = new Set(); for (const change of changes) { - const urls = [...new Set(change.channelUrls)]; - for (let offset = 0; offset < urls.length; offset += 500) { - const channelUrls = urls.slice(offset, offset + 500); - try { - await groupRequest( - `/${encodeURIComponent(change.groupId)}/channels`, - change.action === "add" ? "PUT" : "DELETE", - { channelUrls }, - ); - } catch { - for (const url of channelUrls) failed.add(url); - } + const { batches, invalid } = membershipBatches(change.channelUrls); + for (const url of invalid) failed.add(url); + for (let offset = 0; offset < batches.length; offset += MAX_CONCURRENT_MEMBERSHIP_REQUESTS) { + await Promise.all( + batches + .slice(offset, offset + MAX_CONCURRENT_MEMBERSHIP_REQUESTS) + .map(async (channelUrls) => { + try { + await groupRequest( + `/${encodeURIComponent(change.groupId)}/channels`, + change.action === "add" ? "PUT" : "DELETE", + { channelUrls }, + ); + } catch { + for (const url of channelUrls) failed.add(url); + } + }), + ); } } if (failed.size > 0) throw new MembershipUpdateError([...failed]); diff --git a/apps/web/src/lib/membership-batches.ts b/apps/web/src/lib/membership-batches.ts new file mode 100644 index 00000000..8d49e2ae --- /dev/null +++ b/apps/web/src/lib/membership-batches.ts @@ -0,0 +1,28 @@ +const MAX_CHANNELS = 500; +const MAX_BODY_BYTES = 1024 * 1024; +const MAX_URL_LENGTH = 2048; +const ENCODER = new TextEncoder(); +const EMPTY_BODY_BYTES = ENCODER.encode(JSON.stringify({ channelUrls: [] })).byteLength; + +export function membershipBatches(urls: string[]): { batches: string[][]; invalid: string[] } { + const batches: string[][] = []; + const invalid: string[] = []; + let batch: string[] = []; + let bytes = EMPTY_BODY_BYTES; + for (const url of new Set(urls)) { + if (!url.trim() || url.length > MAX_URL_LENGTH) { + invalid.push(url); + continue; + } + const size = ENCODER.encode(JSON.stringify(url)).byteLength; + if (batch.length === MAX_CHANNELS || bytes + size + (batch.length ? 1 : 0) > MAX_BODY_BYTES) { + batches.push(batch); + batch = []; + bytes = EMPTY_BODY_BYTES; + } + bytes += size + (batch.length ? 1 : 0); + batch.push(url); + } + if (batch.length) batches.push(batch); + return { batches, invalid }; +} diff --git a/apps/web/src/lib/subscription-queries.ts b/apps/web/src/lib/subscription-queries.ts index deab18c9..584e26b9 100644 --- a/apps/web/src/lib/subscription-queries.ts +++ b/apps/web/src/lib/subscription-queries.ts @@ -44,13 +44,30 @@ export function subscriptionFeedQueryOptions( }); } -export async function invalidateSubscriptionQueries(client: QueryClient): Promise { +export async function invalidateSubscriptionQueries( + client: QueryClient, + change: "subscriptions" | "groups" | "memberships" = "subscriptions", +): Promise { + const keys = + change === "groups" + ? [SUBSCRIPTION_GROUPS_KEY] + : [ + SUBSCRIPTIONS_KEY, + SUBSCRIPTION_FEED_KEY, + SUBSCRIPTION_GROUPS_KEY, + SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY, + ]; await Promise.all( - [ - SUBSCRIPTIONS_KEY, - SUBSCRIPTION_FEED_KEY, - SUBSCRIPTION_GROUPS_KEY, - SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY, - ].map((queryKey) => client.invalidateQueries({ queryKey })), + keys.map((queryKey) => { + const deferred = + change === "memberships" && + (queryKey === SUBSCRIPTIONS_KEY || queryKey === SUBSCRIPTION_FEED_KEY); + // Membership edits leave global views unchanged; filtered views refresh when opened. + return client.invalidateQueries({ + queryKey, + refetchType: deferred ? "none" : "active", + predicate: (query) => !deferred || query.queryKey.length > 1, + }); + }), ); } diff --git a/apps/web/tests/membership-batches.test.ts b/apps/web/tests/membership-batches.test.ts new file mode 100644 index 00000000..b8a88bec --- /dev/null +++ b/apps/web/tests/membership-batches.test.ts @@ -0,0 +1,85 @@ +import { afterEach, expect, test } from "bun:test"; +import { MembershipUpdateError, updateGroupMemberships } from "../src/lib/api-subscription-groups"; +import { membershipBatches } from "../src/lib/membership-batches"; +import { useAuthStore } from "../src/stores/auth-store"; + +const bodyBytes = (channelUrls: string[]): number => + new TextEncoder().encode(JSON.stringify({ channelUrls })).byteLength; +const MAX_BYTES = 1024 * 1024; +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; + useAuthStore.getState().setSignedOut(); +}); + +test("a body exactly 1 MiB fits, and adding another URL starts a new batch", () => { + const urls = Array.from({ length: 499 }, (_, i) => `https://example.org/${i}/`.padEnd(2048, "a")); + let remaining = MAX_BYTES - bodyBytes(urls); + for (let i = 0; remaining > 0; i++) { + const added = Math.min(remaining, 4000); + const count = Math.ceil(added / 2); + urls[i] = + urls[i].slice(0, -count) + "界".repeat(Math.floor(added / 2)) + (added % 2 ? "é" : ""); + remaining -= added; + } + expect(bodyBytes(urls)).toBe(MAX_BYTES); + const { batches, invalid } = membershipBatches([...urls, "https://example.org/extra"]); + expect(invalid).toEqual([]); + expect(batches).toEqual([urls, ["https://example.org/extra"]]); +}); + +test("UTF-8 and JSON escapes count toward the byte limit without losing channels", () => { + const urls = Array.from( + { length: 600 }, + (_, i) => `https://example.org/${i}/${'界\\"'.repeat(670)}`, + ); + const { batches, invalid } = membershipBatches([...urls, urls[0]]); + expect(invalid).toEqual([]); + expect(batches.flat()).toEqual(urls); + expect(batches.every((batch) => batch.length <= 500 && bodyBytes(batch) <= MAX_BYTES)).toBe(true); + expect(batches.length).toBeGreaterThan(2); +}); + +test("overlong URLs are reported without preventing valid membership writes", async () => { + useAuthStore.getState().setToken("membership-test"); + const calls: string[][] = []; + globalThis.fetch = async (_input, init) => { + calls.push(JSON.parse(String(init?.body)).channelUrls); + return new Response(null, { status: 204 }); + }; + const valid = "https://example.org/".padEnd(2048, "a"); + const invalid = `${valid}a`; + try { + await updateGroupMemberships([ + { groupId: "tech", action: "add", channelUrls: [invalid, valid] }, + ]); + throw new Error("Expected invalid URL"); + } catch (error) { + expect(error).toBeInstanceOf(MembershipUpdateError); + if (error instanceof MembershipUpdateError) expect(error.failedUrls).toEqual([invalid]); + } + expect(calls).toEqual([[valid]]); +}); + +test("a large edit has at most three requests in flight and preserves operation order", async () => { + useAuthStore.getState().setToken("membership-test"); + let active = 0; + let peak = 0; + const methods: string[] = []; + globalThis.fetch = async (_input, init) => { + methods.push(init?.method ?? "GET"); + active++; + peak = Math.max(peak, active); + await Bun.sleep(1); + active--; + return new Response(null, { status: 204 }); + }; + const channelUrls = Array.from({ length: 5001 }, (_, i) => `https://example.org/${i}`); + await updateGroupMemberships([ + { groupId: "tech", action: "add", channelUrls }, + { groupId: "tech", action: "remove", channelUrls: [channelUrls[0]] }, + ]); + expect(peak).toBe(3); + expect(active).toBe(0); + expect(methods).toEqual([...Array(11).fill("PUT"), "DELETE"]); +}); diff --git a/apps/web/tests/subscription-refresh-scope.test.ts b/apps/web/tests/subscription-refresh-scope.test.ts new file mode 100644 index 00000000..de82a62d --- /dev/null +++ b/apps/web/tests/subscription-refresh-scope.test.ts @@ -0,0 +1,63 @@ +import { afterEach, expect, test } from "bun:test"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { invalidateSubscriptionQueries } from "../src/lib/subscription-queries"; + +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); +}); + +function setup() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + const reads: string[] = []; + const keys = [ + ["subscription-groups", "profile"], + ["subscription-group-memberships", "profile"], + ["subscriptions"], + ["subscriptions", "tech"], + ["subscriptions", "ungrouped"], + ["subscription-feed"], + ["subscription-feed", "tech"], + ["subscription-feed", "ungrouped"], + ]; + for (const queryKey of keys) { + client.setQueryData(queryKey, ["cached"]); + const observer = new QueryObserver(client, { + queryKey, + queryFn: async () => { + reads.push(queryKey.join("/")); + return ["fresh"]; + }, + }); + cleanups.push(observer.subscribe(() => {})); + } + cleanups.push(() => client.clear()); + return { client, reads }; +} + +test("membership saves refresh manager reads and defer filtered views without touching global views", async () => { + const { client, reads } = setup(); + await invalidateSubscriptionQueries(client, "memberships"); + expect(reads.sort()).toEqual([ + "subscription-group-memberships/profile", + "subscription-groups/profile", + ]); + for (const family of ["subscriptions", "subscription-feed"]) { + expect(client.getQueryState([family])?.isInvalidated).toBe(false); + for (const filter of ["tech", "ungrouped"]) { + expect(client.getQueryState([family, filter])?.isInvalidated).toBe(true); + expect(client.getQueryData([family, filter])).toEqual(["cached"]); + } + } +}); + +test("creating or renaming a group refreshes only group definitions", async () => { + const { client, reads } = setup(); + await invalidateSubscriptionQueries(client, "groups"); + expect(reads).toEqual(["subscription-groups/profile"]); + expect(client.getQueryState(["subscription-group-memberships", "profile"])?.isInvalidated).toBe( + false, + ); +}); From e627d7c27507f8ca4e4c95d4ddc4644da9d614f4 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 17 Sep 2026 19:35:40 +0900 Subject: [PATCH 035/149] fix: recover subscription filters and empty states --- apps/web/messages/de.json | 2 + apps/web/messages/en.json | 2 + apps/web/messages/fr.json | 2 + .../components/subscription-group-filter.tsx | 27 +++++- .../subscription-groups/group-sidebar.tsx | 2 +- apps/web/src/hooks/use-group-manager.ts | 3 +- apps/web/src/hooks/use-subscription-feed.ts | 17 ++-- apps/web/src/routes/subscriptions.tsx | 5 +- .../src/routes/subscriptions_.channels.tsx | 22 ++++- apps/web/tests/helpers/subscription-feed.tsx | 46 +++++++++ .../tests/subscription-feed-errors.test.tsx | 54 ++--------- .../tests/subscription-feed-filters.test.tsx | 95 +++++++++++++++++++ 12 files changed, 214 insertions(+), 63 deletions(-) create mode 100644 apps/web/tests/helpers/subscription-feed.tsx create mode 100644 apps/web/tests/subscription-feed-filters.test.tsx diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 58aaee5b..acaa9ddd 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1331,6 +1331,8 @@ "sg_added": "{count} Kanäle zu {group} hinzugefügt.", "sg_all_channels": "Alle Kanäle", "sg_all_subscriptions": "Alle Abonnements", + "sg_ungrouped": "Nicht gruppiert", + "sg_channels_load_error": "Deine Kanäle konnten nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.", "sg_back_channels": "Zurück zu den Kanälen", "sg_back_to_results": "Zurück zu den Ergebnissen", "sg_change_filters": "Wähle eine andere Gruppe oder ändere deine Suche.", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index e563b74a..e9e29534 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1331,6 +1331,8 @@ "sg_added": "Added {count} channels to {group}.", "sg_all_channels": "All channels", "sg_all_subscriptions": "All subscriptions", + "sg_ungrouped": "Ungrouped", + "sg_channels_load_error": "Your channels could not be loaded. Check your connection and try again.", "sg_back_channels": "Back to channels", "sg_back_to_results": "Back to results", "sg_change_filters": "Try another group or change your search.", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 3cc6d939..b6026a42 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -1331,6 +1331,8 @@ "sg_added": "{count} chaînes ajoutées à {group}.", "sg_all_channels": "Toutes les chaînes", "sg_all_subscriptions": "Tous les abonnements", + "sg_ungrouped": "Non groupées", + "sg_channels_load_error": "Impossible de charger vos chaînes. Vérifiez votre connexion et réessayez.", "sg_back_channels": "Retour aux chaînes", "sg_back_to_results": "Retour aux résultats", "sg_change_filters": "Essayez un autre groupe ou modifiez votre recherche.", diff --git a/apps/web/src/components/subscription-group-filter.tsx b/apps/web/src/components/subscription-group-filter.tsx index 61fc70f3..c278b4f1 100644 --- a/apps/web/src/components/subscription-group-filter.tsx +++ b/apps/web/src/components/subscription-group-filter.tsx @@ -1,11 +1,32 @@ +import { useEffect } from "react"; import { useSubscriptionGroups } from "../hooks/use-subscription-groups"; +import { ApiError } from "../lib/api"; import { m } from "../paraglide/messages.js"; -type Props = { value: string; onChange: (value: string) => void }; +type Props = { + value: string; + error?: Error | null; + onChange: (value: string, replace?: boolean) => void; +}; -export function SubscriptionGroupFilter({ value, onChange }: Props): React.JSX.Element { +export function SubscriptionGroupFilter({ value, error, onChange }: Props): React.JSX.Element { const query = useSubscriptionGroups(); const groups = [...(query.data ?? [])].sort((a, b) => a.name.localeCompare(b.name)); + const requestMissing = error instanceof ApiError && error.code === "subscription_group_not_found"; + const missing = + value !== "all" && + value !== "ungrouped" && + (requestMissing || + (query.isSuccess && + query.isFetchedAfterMount && + !query.isFetching && + !groups.some((group) => group.id === value))); + const { refetch } = query; + useEffect(() => { + if (!missing) return; + onChange("all", true); + if (requestMissing) void refetch(); + }, [missing, requestMissing, onChange, refetch]); return ( props.onQuery(event.target.value)} disabled={props.onlySelected} @@ -48,13 +49,11 @@ export function GroupToolbar(props: Props): React.JSX.Element { + ) : ( + + )} +
    + {showQr && ( +
    + {session.qrPhase === "waiting" && session.qrUrl && ( + <> + +

    + {m.ui_bilibili_session_scan_qr()} +

    + {session.qrPhase === "scanned" && ( +

    + {m.ui_bilibili_session_scanned()} +

    + )} + + + )} + {session.qrPhase === "confirmed" && ( +

    + {m.ui_bilibili_session_connected_toast()} +

    + )} + {session.qrPhase === "expired" && ( + <> +

    + {m.ui_bilibili_session_qr_expired()} +

    + + + )} + {session.qrPhase === "error" && ( + <> +

    {session.qrError}

    + + + )} +
    + )} +
    + ); +} diff --git a/apps/web/src/hooks/use-bilibili-session.ts b/apps/web/src/hooks/use-bilibili-session.ts new file mode 100644 index 00000000..6de3829d --- /dev/null +++ b/apps/web/src/hooks/use-bilibili-session.ts @@ -0,0 +1,114 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + disconnectBiliBiliSession, + fetchBiliBiliSessionStatus, + pollBiliBiliQrLogin, + startBiliBiliQrLogin, +} from "../lib/api-bilibili-session"; +import { useAuth } from "./use-auth"; + +const BILIBILI_SESSION_KEY = ["bilibili-session"]; + +export type BiliBiliQrPhase = + | "idle" + | "generating" + | "waiting" + | "scanned" + | "confirmed" + | "expired" + | "error"; + +export function useBiliBiliSession() { + const qc = useQueryClient(); + const { authReady, isAuthed } = useAuth(); + const [qrPhase, setQrPhase] = useState("idle"); + const [qrUrl, setQrUrl] = useState(null); + const [qrError, setQrError] = useState(null); + const qrcodeKeyRef = useRef(null); + const pollTimerRef = useRef | null>(null); + + const status = useQuery({ + queryKey: BILIBILI_SESSION_KEY, + queryFn: fetchBiliBiliSessionStatus, + enabled: authReady && isAuthed, + }); + + const disconnect = useMutation({ + mutationFn: disconnectBiliBiliSession, + onSuccess: () => qc.invalidateQueries({ queryKey: BILIBILI_SESSION_KEY }), + }); + + const stopPolling = useCallback(() => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + }, []); + + const startQr = useCallback(async () => { + setQrPhase("generating"); + setQrError(null); + stopPolling(); + try { + const result = await startBiliBiliQrLogin(); + qrcodeKeyRef.current = result.qrcodeKey; + setQrUrl(result.qrUrl); + setQrPhase("waiting"); + const expiresAt = result.expiresAt; + pollTimerRef.current = setInterval(async () => { + if (Date.now() > expiresAt) { + stopPolling(); + setQrPhase("expired"); + return; + } + const key = qrcodeKeyRef.current; + if (!key) return; + try { + const poll = await pollBiliBiliQrLogin(key); + if (poll.status === "scanned") setQrPhase("scanned"); + if (poll.status === "confirmed") { + stopPolling(); + setQrPhase("confirmed"); + qc.invalidateQueries({ queryKey: BILIBILI_SESSION_KEY }); + setTimeout(() => setQrPhase("idle"), 2000); + } + if (poll.status === "expired") { + stopPolling(); + setQrPhase("expired"); + } + if (poll.status === "error") { + stopPolling(); + setQrError(poll.message ?? "Unexpected error"); + setQrPhase("error"); + } + } catch { + // transient network failure, keep polling + } + }, 2000); + } catch { + setQrError("Failed to generate QR code"); + setQrPhase("error"); + } + }, [qc, stopPolling]); + + const cancelQr = useCallback(() => { + stopPolling(); + qrcodeKeyRef.current = null; + setQrUrl(null); + setQrPhase("idle"); + setQrError(null); + }, [stopPolling]); + + useEffect(() => stopPolling, [stopPolling]); + + return { + status, + disconnect, + qrPhase, + qrUrl, + qrError, + startQr, + cancelQr, + }; +} diff --git a/apps/web/src/lib/api-bilibili-session.ts b/apps/web/src/lib/api-bilibili-session.ts new file mode 100644 index 00000000..72868736 --- /dev/null +++ b/apps/web/src/lib/api-bilibili-session.ts @@ -0,0 +1,44 @@ +import { ApiError } from "./api"; +import { authed, authedJson } from "./authed"; +import { API_BASE as BASE } from "./env"; + +export type BiliBiliSessionStatus = "disconnected" | "connected" | "needs_reconnect"; + +export type BiliBiliSessionState = { + status: BiliBiliSessionStatus; + updatedAt: number; + lastUsedAt: number; + expiresAt?: number; +}; + +export type BiliBiliQrLogin = { + qrUrl: string; + qrcodeKey: string; + expiresAt: number; +}; + +export type BiliBiliQrPollState = { + status: "waiting" | "scanned" | "confirmed" | "expired" | "error"; + message?: string; +}; + +export function fetchBiliBiliSessionStatus(): Promise { + return authedJson(`${BASE}/bilibili-session/status`); +} + +export function startBiliBiliQrLogin(): Promise { + return authedJson(`${BASE}/bilibili-session/qr`, { method: "POST" }); +} + +export function pollBiliBiliQrLogin(qrcodeKey: string): Promise { + return authedJson(`${BASE}/bilibili-session/qr/poll`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ qrcodeKey }), + }); +} + +export async function disconnectBiliBiliSession(): Promise { + const res = await authed(`${BASE}/bilibili-session`, { method: "DELETE" }); + if (!res.ok) throw new ApiError("Failed to disconnect BiliBili session", res.status); +} diff --git a/apps/web/src/lib/auth-routes.ts b/apps/web/src/lib/auth-routes.ts index cc9a7111..51464192 100644 --- a/apps/web/src/lib/auth-routes.ts +++ b/apps/web/src/lib/auth-routes.ts @@ -2,6 +2,7 @@ export type RedirectTarget = | "/" | "/import" | "/history" + | "/bilibili-session" | "/playlists" | "/privacy" | "/profile" @@ -22,6 +23,7 @@ const PROTECTED_PREFIXES = [ "/settings", "/subscriptions", "/youtube-session", + "/bilibili-session", ]; const AUTH_PAGES = ["/login", "/register", "/reset-password", "/auth/oidc/callback"]; const OIDC_CALLBACK_PAGE = "/auth/oidc/callback"; diff --git a/apps/web/src/routes/bilibili-session.tsx b/apps/web/src/routes/bilibili-session.tsx new file mode 100644 index 00000000..8a2fdd15 --- /dev/null +++ b/apps/web/src/routes/bilibili-session.tsx @@ -0,0 +1,20 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { BiliBiliSessionCard } from "../components/bilibili-session-card"; +import { useBiliBiliSession } from "../hooks/use-bilibili-session"; +import { m } from "../paraglide/messages.js"; + +export const Route = createFileRoute("/bilibili-session")({ + component: BiliBiliSessionPage, +}); + +function BiliBiliSessionPage() { + const session = useBiliBiliSession(); + return ( +
    +

    + {m.ui_bilibili_session()} +

    + +
    + ); +} From dfd51dc014cec2a534a04e8e06cb93f46747d941 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 20 Sep 2026 16:43:19 +0000 Subject: [PATCH 050/149] feat: add BiliBili session banner on watch page --- .../components/bilibili-session-banner.tsx | 25 ++++++++++++ apps/web/src/routes/watch.tsx | 40 ++++++++++--------- 2 files changed, 47 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/bilibili-session-banner.tsx diff --git a/apps/web/src/components/bilibili-session-banner.tsx b/apps/web/src/components/bilibili-session-banner.tsx new file mode 100644 index 00000000..fa1c1b6c --- /dev/null +++ b/apps/web/src/components/bilibili-session-banner.tsx @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query"; +import { useAuth } from "../hooks/use-auth"; +import { fetchBiliBiliSessionStatus } from "../lib/api-bilibili-session"; +import { m } from "../paraglide/messages.js"; + +export function BiliBiliSessionBanner({ sourceUrl }: { sourceUrl: string }) { + const { authReady, isAuthed } = useAuth(); + const isBiliBili = sourceUrl.includes("bilibili.com") || sourceUrl.includes("b23.tv"); + const shouldQuery = authReady && isAuthed && isBiliBili; + const { data: status } = useQuery({ + queryKey: ["bilibili-session"], + queryFn: fetchBiliBiliSessionStatus, + enabled: shouldQuery, + staleTime: 5 * 60 * 1000, + }); + if (!shouldQuery || !status || status.status === "connected") return null; + return ( +
    + {m.ui_bilibili_session_not_connected()} + + {m.ui_bilibili_session_connect()} + +
    + ); +} diff --git a/apps/web/src/routes/watch.tsx b/apps/web/src/routes/watch.tsx index 28e61643..79c634a8 100644 --- a/apps/web/src/routes/watch.tsx +++ b/apps/web/src/routes/watch.tsx @@ -1,5 +1,6 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { lazy, Suspense, useEffect, useRef } from "react"; +import { BiliBiliSessionBanner } from "../components/bilibili-session-banner"; import { StreamError } from "../components/stream-error"; import { WatchPageSkeleton } from "../components/watch-page-skeleton"; import { WatchStreamError } from "../components/watch-stream-error"; @@ -156,26 +157,29 @@ function WatchPage() { const navigating = toPublicWatchParam(activeStream.id) !== publicParam; return ( - + + + } + > + - } - > - - + + ); } From d4a9dc904c771fdf13c39ceef732b6eb7d15456c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 20 Sep 2026 16:52:46 +0000 Subject: [PATCH 051/149] feat: add expiring soon warning on BiliBili session card --- apps/web/src/components/bilibili-session-card.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/bilibili-session-card.tsx b/apps/web/src/components/bilibili-session-card.tsx index 8617bc3c..db454bda 100644 --- a/apps/web/src/components/bilibili-session-card.tsx +++ b/apps/web/src/components/bilibili-session-card.tsx @@ -30,10 +30,14 @@ export function BiliBiliSessionCard({ session }: { session: SessionHook }) {

    BiliBili

    - {isConnected && ( + {isConnected && daysLeft > 7 && (

    - {m.ui_bilibili_session_connected()} - {daysLeft > 0 ? ` · ${daysLeft}d` : ""} + {m.ui_bilibili_session_connected()} · {daysLeft}d +

    + )} + {isConnected && daysLeft > 0 && daysLeft <= 7 && ( +

    + {m.ui_bilibili_session_connected()} · {daysLeft}d · {m.ui_bilibili_session_reconnect()}

    )} {isReconnect && ( From ea7f47d827259259534d236070aa15665fe07982 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 20 Sep 2026 16:53:28 +0000 Subject: [PATCH 052/149] style: format bilibili session card --- apps/web/src/components/bilibili-session-card.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/bilibili-session-card.tsx b/apps/web/src/components/bilibili-session-card.tsx index db454bda..92f617d7 100644 --- a/apps/web/src/components/bilibili-session-card.tsx +++ b/apps/web/src/components/bilibili-session-card.tsx @@ -37,7 +37,10 @@ export function BiliBiliSessionCard({ session }: { session: SessionHook }) { )} {isConnected && daysLeft > 0 && daysLeft <= 7 && (

    - {m.ui_bilibili_session_connected()} · {daysLeft}d · {m.ui_bilibili_session_reconnect()} + {m.ui_bilibili_session_connected()} · {daysLeft}d ·{" "} + + {m.ui_bilibili_session_reconnect()} +

    )} {isReconnect && ( From 2de785a9cd523051693e1713a1cd40d5630a32fa Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 20 Sep 2026 17:02:37 +0000 Subject: [PATCH 053/149] fix: regenerate route tree and fix qr phase narrowing --- .../src/components/bilibili-session-card.tsx | 2 +- apps/web/src/routeTree.gen.ts | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/bilibili-session-card.tsx b/apps/web/src/components/bilibili-session-card.tsx index 92f617d7..86c336d6 100644 --- a/apps/web/src/components/bilibili-session-card.tsx +++ b/apps/web/src/components/bilibili-session-card.tsx @@ -80,7 +80,7 @@ export function BiliBiliSessionCard({ session }: { session: SessionHook }) {
    {showQr && (
    - {session.qrPhase === "waiting" && session.qrUrl && ( + {(session.qrPhase === "waiting" || session.qrPhase === "scanned") && session.qrUrl && ( <> rootRouteImport, } as any) +const BilibiliSessionRoute = BilibiliSessionRouteImport.update({ + id: '/bilibili-session', + path: '/bilibili-session', + getParentRoute: () => rootRouteImport, +} as any) const ChannelRoute = ChannelRouteImport.update({ id: '/channel', path: '/channel', @@ -220,6 +226,7 @@ const ChannelProviderChannelIdRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute '/admin-console': typeof AdminConsoleRoute + '/bilibili-session': typeof BilibiliSessionRoute '/channel': typeof ChannelRoute '/export': typeof ExportRoute '/favorites': typeof FavoritesRoute @@ -256,6 +263,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/admin-console': typeof AdminConsoleRoute + '/bilibili-session': typeof BilibiliSessionRoute '/channel': typeof ChannelRoute '/export': typeof ExportRoute '/favorites': typeof FavoritesRoute @@ -292,6 +300,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/admin-console': typeof AdminConsoleRoute + '/bilibili-session': typeof BilibiliSessionRoute '/channel': typeof ChannelRoute '/export': typeof ExportRoute '/favorites': typeof FavoritesRoute @@ -330,6 +339,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/admin-console' + | '/bilibili-session' | '/channel' | '/export' | '/favorites' @@ -366,6 +376,7 @@ export interface FileRouteTypes { to: | '/' | '/admin-console' + | '/bilibili-session' | '/channel' | '/export' | '/favorites' @@ -401,6 +412,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/admin-console' + | '/bilibili-session' | '/channel' | '/export' | '/favorites' @@ -438,6 +450,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AdminConsoleRoute: typeof AdminConsoleRoute + BilibiliSessionRoute: typeof BilibiliSessionRoute ChannelRoute: typeof ChannelRoute ExportRoute: typeof ExportRoute FavoritesRoute: typeof FavoritesRoute @@ -485,6 +498,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminConsoleRouteImport parentRoute: typeof rootRouteImport } + '/bilibili-session': { + id: '/bilibili-session' + path: '/bilibili-session' + fullPath: '/bilibili-session' + preLoaderRoute: typeof BilibiliSessionRouteImport + parentRoute: typeof rootRouteImport + } '/channel': { id: '/channel' path: '/channel' @@ -730,6 +750,7 @@ const ImportRouteWithChildren = const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AdminConsoleRoute: AdminConsoleRoute, + BilibiliSessionRoute: BilibiliSessionRoute, ChannelRoute: ChannelRoute, ExportRoute: ExportRoute, FavoritesRoute: FavoritesRoute, From 8d068fee032e8fcf9180fc0c90770c1aad392bd6 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 20 Sep 2026 17:05:40 +0000 Subject: [PATCH 054/149] fix: remove unused BiliBiliSessionStatus type export --- apps/web/src/lib/api-bilibili-session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/api-bilibili-session.ts b/apps/web/src/lib/api-bilibili-session.ts index 72868736..e00b38cb 100644 --- a/apps/web/src/lib/api-bilibili-session.ts +++ b/apps/web/src/lib/api-bilibili-session.ts @@ -2,7 +2,7 @@ import { ApiError } from "./api"; import { authed, authedJson } from "./authed"; import { API_BASE as BASE } from "./env"; -export type BiliBiliSessionStatus = "disconnected" | "connected" | "needs_reconnect"; +type BiliBiliSessionStatus = "disconnected" | "connected" | "needs_reconnect"; export type BiliBiliSessionState = { status: BiliBiliSessionStatus; From 10005b5273d82e4093b74d5d13d47a73deeede91 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 20 Sep 2026 18:01:06 +0000 Subject: [PATCH 055/149] feat: add BiliBili session card to youtube-session page --- apps/web/messages/de.json | 3 ++- apps/web/messages/en.json | 3 ++- apps/web/messages/fr.json | 3 ++- apps/web/src/routes/youtube-session.tsx | 10 ++++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 67b914c6..2ecd32e1 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1366,5 +1366,6 @@ "ui_bilibili_session_qr_expired": "QR-Code abgelaufen", "ui_bilibili_session_try_again": "Erneut versuchen", "ui_bilibili_session_retry": "Wiederholen", - "ui_bilibili_session_expires_in": "expires in {days} {plural}" + "ui_bilibili_session_expires_in": "expires in {days} {plural}", + "ui_services": "Dienste" } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 79a19598..6f28f8e2 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1366,5 +1366,6 @@ "ui_bilibili_session_qr_expired": "QR code expired", "ui_bilibili_session_try_again": "Try again", "ui_bilibili_session_retry": "Retry", - "ui_bilibili_session_expires_in": "expires in {days} {plural}" + "ui_bilibili_session_expires_in": "expires in {days} {plural}", + "ui_services": "Services" } diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 4a2160f4..78213402 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -1366,5 +1366,6 @@ "ui_bilibili_session_qr_expired": "Code QR expiré", "ui_bilibili_session_try_again": "Réessayer", "ui_bilibili_session_retry": "Réessayer", - "ui_bilibili_session_expires_in": "expires in {days} {plural}" + "ui_bilibili_session_expires_in": "expires in {days} {plural}", + "ui_services": "Services" } diff --git a/apps/web/src/routes/youtube-session.tsx b/apps/web/src/routes/youtube-session.tsx index fb272ed7..42136e12 100644 --- a/apps/web/src/routes/youtube-session.tsx +++ b/apps/web/src/routes/youtube-session.tsx @@ -1,10 +1,12 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useState } from "react"; +import { BiliBiliSessionCard } from "../components/bilibili-session-card"; import { Toast } from "../components/toast"; import { YoutubeSessionBrowserPanel } from "../components/youtube-session-browser-panel"; import { YoutubeSessionInfoSection } from "../components/youtube-session-info-section"; import { YoutubeSessionStatusPanel } from "../components/youtube-session-status-panel"; import { useAuth } from "../hooks/use-auth"; +import { useBiliBiliSession } from "../hooks/use-bilibili-session"; import { useInstance } from "../hooks/use-instance"; import { useYoutubeRemoteBrowser } from "../hooks/use-youtube-remote-browser"; import { useYoutubeSession } from "../hooks/use-youtube-session"; @@ -23,6 +25,7 @@ function YoutubeSessionPage() { const { authReady, isAuthed } = useAuth(); const instance = useInstance(); const session = useYoutubeSession(); + const bilibiliSession = useBiliBiliSession(); const [browserSession, setBrowserSession] = useState(null); const [toast, setToast] = useState(null); const state = session.status.data; @@ -125,6 +128,13 @@ function YoutubeSessionPage() { +
    +

    {m.ui_services()}

    +
    + +
    +
    +
    ); From 071f12eb692eff36d999ec2778119bf4f2f5afe0 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 20 Sep 2026 18:10:31 +0000 Subject: [PATCH 056/149] feat: unified session layout for BiliBili with FR translations and service links --- apps/web/messages/de.json | 15 +- apps/web/messages/en.json | 15 +- apps/web/messages/fr.json | 15 +- apps/web/src/components/bilibili-icon.tsx | 7 + apps/web/src/routes/bilibili-session.tsx | 201 ++++++++++++++++++++-- apps/web/src/routes/youtube-session.tsx | 17 +- 6 files changed, 254 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/bilibili-icon.tsx diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 2ecd32e1..7737a17f 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1367,5 +1367,18 @@ "ui_bilibili_session_try_again": "Erneut versuchen", "ui_bilibili_session_retry": "Wiederholen", "ui_bilibili_session_expires_in": "expires in {days} {plural}", - "ui_services": "Dienste" + "ui_services": "Dienste", + "ui_bilibili_remote_sign_in": "BiliBili-Fernanmeldung", + "ui_connect_bilibili_inside_typetype": "BiliBili in TypeType verbinden", + "ui_typetype_generates_a_qr_code": "TypeType generiert einen QR-Code für die BiliBili-Anmeldung.", + "ui_use_secondary_bilibili_account": "Verwenden Sie ein zweites BiliBili-Konto.", + "ui_connect_with_bilibili": "Mit BiliBili verbinden", + "ui_bilibili_session_description": "TypeType kann diese Sitzung verwenden.", + "ui_bilibili_expires_at": "Läuft ab am", + "ui_bilibili_what_happens": "Was passiert", + "ui_bilibili_info_intro": "Der QR-Code wird von TypeType generiert.", + "ui_bilibili_info_scan": "Sie scannen den QR-Code mit der BiliBili-App.", + "ui_bilibili_info_cookies": "Cookies werden serverseitig erfasst.", + "ui_bilibili_info_temporary": "Der QR-Code ist temporär.", + "ui_bilibili_info_after": "Nach der Verbindung verwenden BiliBili-Videos Ihre Sitzung." } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 6f28f8e2..19945ff5 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1367,5 +1367,18 @@ "ui_bilibili_session_try_again": "Try again", "ui_bilibili_session_retry": "Retry", "ui_bilibili_session_expires_in": "expires in {days} {plural}", - "ui_services": "Services" + "ui_services": "Services", + "ui_bilibili_remote_sign_in": "BiliBili remote sign-in", + "ui_connect_bilibili_inside_typetype": "Connect BiliBili inside TypeType", + "ui_typetype_generates_a_qr_code": "TypeType generates a QR code for BiliBili sign-in. Cookies are captured server-side, encrypted, and never sent to the web UI.", + "ui_use_secondary_bilibili_account": "Use a secondary BiliBili account. The QR code is temporary and expires after 3 minutes.", + "ui_connect_with_bilibili": "Connect with BiliBili", + "ui_bilibili_session_description": "TypeType can use this session when extraction needs it.", + "ui_bilibili_expires_at": "Expires at", + "ui_bilibili_what_happens": "What happens", + "ui_bilibili_info_intro": "The QR code is generated by TypeType and scanned with the BiliBili mobile app.", + "ui_bilibili_info_scan": "You scan the QR code with the BiliBili app on your phone.", + "ui_bilibili_info_cookies": "Cookies are captured server-side, encrypted, and never sent to the web UI.", + "ui_bilibili_info_temporary": "The QR code is temporary and expires after 3 minutes.", + "ui_bilibili_info_after": "After connection, BiliBili videos and streams use your BiliBili session." } diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 78213402..44e3f170 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -1367,5 +1367,18 @@ "ui_bilibili_session_try_again": "Réessayer", "ui_bilibili_session_retry": "Réessayer", "ui_bilibili_session_expires_in": "expires in {days} {plural}", - "ui_services": "Services" + "ui_services": "Services", + "ui_bilibili_remote_sign_in": "Connexion BiliBili à distance", + "ui_connect_bilibili_inside_typetype": "Connecter BiliBili inside TypeType", + "ui_typetype_generates_a_qr_code": "TypeType génère un code QR pour la connexion BiliBili. Les cookies sont capturés côté serveur, chiffrés, et jamais envoyés à la web UI.", + "ui_use_secondary_bilibili_account": "Utilisez un compte BiliBili secondaire. Le code QR est temporaire et expire après 3 minutes.", + "ui_connect_with_bilibili": "Connecter avec BiliBili", + "ui_bilibili_session_description": "TypeType peut utiliser cette session lorsque lextraction en a besoin.", + "ui_bilibili_expires_at": "Expire le", + "ui_bilibili_what_happens": "Ce qui se passe", + "ui_bilibili_info_intro": "Le code QR est généré par TypeType et scanné avec la BiliBili app.", + "ui_bilibili_info_scan": "Vous scannez le code QR avec la BiliBili app sur votre téléphone.", + "ui_bilibili_info_cookies": "Les cookies sont capturés côté serveur, chiffrés, et jamais envoyés à la web UI.", + "ui_bilibili_info_temporary": "Le code QR est temporaire et expire après 3 minutes.", + "ui_bilibili_info_after": "Après la connexion, les vidéos BiliBili utilisent votre session." } diff --git a/apps/web/src/components/bilibili-icon.tsx b/apps/web/src/components/bilibili-icon.tsx new file mode 100644 index 00000000..ffdb6ac8 --- /dev/null +++ b/apps/web/src/components/bilibili-icon.tsx @@ -0,0 +1,7 @@ +export function BiliBiliIcon({ className }: { className?: string }) { + return ( + + ); +} diff --git a/apps/web/src/routes/bilibili-session.tsx b/apps/web/src/routes/bilibili-session.tsx index 8a2fdd15..7bfca2af 100644 --- a/apps/web/src/routes/bilibili-session.tsx +++ b/apps/web/src/routes/bilibili-session.tsx @@ -1,20 +1,201 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { BiliBiliSessionCard } from "../components/bilibili-session-card"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { BiliBiliIcon } from "../components/bilibili-icon"; +import { Toast } from "../components/toast"; +import { YoutubeIcon } from "../components/youtube-icon"; +import { useAuth } from "../hooks/use-auth"; import { useBiliBiliSession } from "../hooks/use-bilibili-session"; import { m } from "../paraglide/messages.js"; -export const Route = createFileRoute("/bilibili-session")({ - component: BiliBiliSessionPage, -}); +const SIDE_LABEL = "font-mono text-fg-soft text-[11px] uppercase tracking-[0.22em]"; + +function formatSessionTime(timestamp?: number): string { + if (!timestamp || timestamp === 0) return "—"; + return new Date(timestamp * 1000).toLocaleString(undefined, { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} function BiliBiliSessionPage() { + const { authReady, isAuthed } = useAuth(); const session = useBiliBiliSession(); + const state = session.status.data; + const connected = state?.status === "connected"; + const daysLeft = + state?.expiresAt && state.expiresAt > 0 + ? Math.ceil((state.expiresAt - Date.now()) / 86400000) + : 0; + const statusLabel = connected + ? m.ui_bilibili_session_connected() + : state?.status === "needs_reconnect" + ? m.ui_bilibili_session_needs_reconnect() + : m.ui_bilibili_session_not_connected(); + const statusDescription = connected ? m.ui_bilibili_session_description() : ""; + return ( -
    -

    - {m.ui_bilibili_session()} -

    - +
    +
    + + {m.ui_back_home()} + +
    + +
    +
    +

    {m.ui_bilibili_remote_sign_in()}

    +
    +

    + {m.ui_connect_bilibili_inside_typetype()} +

    +

    + {m.ui_typetype_generates_a_qr_code()} +

    +
    +
    + {!connected && ( + + )} + {session.qrUrl && (session.qrPhase === "waiting" || session.qrPhase === "scanned") && ( +
    + +

    {m.ui_bilibili_session_scan_qr()}

    + {session.qrPhase === "scanned" && ( +

    + {m.ui_bilibili_session_scanned()} +

    + )} +
    + )} + {session.qrPhase === ("confirmed" as const) && ( +

    + {m.ui_bilibili_session_connected_toast()} +

    + )} + {session.qrPhase === "expired" && ( +
    +

    + {m.ui_bilibili_session_qr_expired()} +

    + +
    + )} + {session.qrPhase === "error" && ( +
    +

    {session.qrError}

    + +
    + )} +

    + {m.ui_use_secondary_bilibili_account()} +

    +
    +
    + + +
    + +
    +
    +

    {m.ui_bilibili_what_happens()}

    +

    {m.ui_bilibili_info_intro()}

    +
    +
      +
    • {m.ui_bilibili_info_scan()}
    • +
    • {m.ui_bilibili_info_cookies()}
    • +
    • {m.ui_bilibili_info_temporary()}
    • +
    • {m.ui_bilibili_info_after()}
    • +
    +
    + +
    +

    {m.ui_services()}

    +
    + + + {m.ui_connect_with_youtube()} + + + + {m.ui_connect_with_bilibili()} + +
    +
    + +
    ); } + +export const Route = createFileRoute("/bilibili-session")({ + component: BiliBiliSessionPage, +}); diff --git a/apps/web/src/routes/youtube-session.tsx b/apps/web/src/routes/youtube-session.tsx index 42136e12..655cfbf2 100644 --- a/apps/web/src/routes/youtube-session.tsx +++ b/apps/web/src/routes/youtube-session.tsx @@ -1,7 +1,9 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useState } from "react"; +import { BiliBiliIcon } from "../components/bilibili-icon"; import { BiliBiliSessionCard } from "../components/bilibili-session-card"; import { Toast } from "../components/toast"; +import { YoutubeIcon } from "../components/youtube-icon"; import { YoutubeSessionBrowserPanel } from "../components/youtube-session-browser-panel"; import { YoutubeSessionInfoSection } from "../components/youtube-session-info-section"; import { YoutubeSessionStatusPanel } from "../components/youtube-session-status-panel"; @@ -25,7 +27,6 @@ function YoutubeSessionPage() { const { authReady, isAuthed } = useAuth(); const instance = useInstance(); const session = useYoutubeSession(); - const bilibiliSession = useBiliBiliSession(); const [browserSession, setBrowserSession] = useState(null); const [toast, setToast] = useState(null); const state = session.status.data; @@ -130,8 +131,18 @@ function YoutubeSessionPage() {

    {m.ui_services()}

    -
    - +
    + + + {m.ui_connect_with_youtube()} + + + + {m.ui_connect_with_bilibili()} +
    From 121082fa6142c8938d5f4c8af765d0eb089adb56 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 20 Sep 2026 19:23:57 +0000 Subject: [PATCH 057/149] fix: make BiliBili sign-in a dedicated flow --- apps/web/messages/de.json | 4 + apps/web/messages/en.json | 4 + apps/web/messages/fr.json | 24 +-- apps/web/package.json | 1 + apps/web/src/components/bilibili-icon.tsx | 21 ++- .../src/components/bilibili-session-card.tsx | 144 ------------------ apps/web/src/hooks/use-bilibili-session.ts | 20 ++- apps/web/src/lib/authed.ts | 11 +- apps/web/src/routes/bilibili-session.tsx | 20 ++- apps/web/src/routes/youtube-session.tsx | 2 - bun.lock | 3 + 11 files changed, 84 insertions(+), 170 deletions(-) delete mode 100644 apps/web/src/components/bilibili-session-card.tsx diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 7737a17f..70d71dbd 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1366,6 +1366,10 @@ "ui_bilibili_session_qr_expired": "QR-Code abgelaufen", "ui_bilibili_session_try_again": "Erneut versuchen", "ui_bilibili_session_retry": "Wiederholen", + "ui_bilibili_session_not_configured": "BiliBili sign-in is not enabled on this instance.", + "ui_bilibili_session_qr_unavailable": "BiliBili did not provide a QR code. Try again in a moment.", + "ui_bilibili_session_rate_limited": "BiliBili is temporarily rate-limiting sign-in. Try again later.", + "ui_bilibili_session_qr_poll_error": "The BiliBili sign-in could not be completed.", "ui_bilibili_session_expires_in": "expires in {days} {plural}", "ui_services": "Dienste", "ui_bilibili_remote_sign_in": "BiliBili-Fernanmeldung", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 19945ff5..84ed7d07 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1366,6 +1366,10 @@ "ui_bilibili_session_qr_expired": "QR code expired", "ui_bilibili_session_try_again": "Try again", "ui_bilibili_session_retry": "Retry", + "ui_bilibili_session_not_configured": "BiliBili sign-in is not enabled on this instance.", + "ui_bilibili_session_qr_unavailable": "BiliBili did not provide a QR code. Try again in a moment.", + "ui_bilibili_session_rate_limited": "BiliBili is temporarily rate-limiting sign-in. Try again later.", + "ui_bilibili_session_qr_poll_error": "The BiliBili sign-in could not be completed.", "ui_bilibili_session_expires_in": "expires in {days} {plural}", "ui_services": "Services", "ui_bilibili_remote_sign_in": "BiliBili remote sign-in", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 44e3f170..d44022b5 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -551,7 +551,7 @@ "ui_community_title": "Community title", "ui_community_then_neutral_frame": "Community, then neutral frame", "ui_confidence": "Confidence", - "ui_connect_with_youtube": "Connecter with YouTube", + "ui_connect_with_youtube": "Se connecter à YouTube", "ui_connect_youtube_inside_typetype": "Connecter YouTube inside TypeType", "ui_connect_youtube_on_typetype": "Connecter YouTube on TypeType", "ui_content": "Contenu", @@ -1359,26 +1359,30 @@ "ui_bilibili_session_connect": "Connecter", "ui_bilibili_session_reconnect": "Reconnecter", "ui_bilibili_session_loading": "Chargement...", - "ui_bilibili_session_scan_qr": "Scannez avec l\"app BiliBili", + "ui_bilibili_session_scan_qr": "Scannez le code QR avec l’application BiliBili", "ui_bilibili_session_scanned": "Scanné ! Confirmez sur votre téléphone.", "ui_bilibili_session_cancel": "Annuler", "ui_bilibili_session_connected_toast": "Connecté !", "ui_bilibili_session_qr_expired": "Code QR expiré", "ui_bilibili_session_try_again": "Réessayer", "ui_bilibili_session_retry": "Réessayer", - "ui_bilibili_session_expires_in": "expires in {days} {plural}", + "ui_bilibili_session_not_configured": "La connexion BiliBili n’est pas activée sur cette instance.", + "ui_bilibili_session_qr_unavailable": "BiliBili n’a pas fourni de code QR. Réessayez dans un instant.", + "ui_bilibili_session_rate_limited": "BiliBili limite temporairement les connexions. Réessayez plus tard.", + "ui_bilibili_session_qr_poll_error": "La connexion BiliBili n’a pas pu être terminée.", + "ui_bilibili_session_expires_in": "expire dans {days} {plural}", "ui_services": "Services", "ui_bilibili_remote_sign_in": "Connexion BiliBili à distance", - "ui_connect_bilibili_inside_typetype": "Connecter BiliBili inside TypeType", - "ui_typetype_generates_a_qr_code": "TypeType génère un code QR pour la connexion BiliBili. Les cookies sont capturés côté serveur, chiffrés, et jamais envoyés à la web UI.", - "ui_use_secondary_bilibili_account": "Utilisez un compte BiliBili secondaire. Le code QR est temporaire et expire après 3 minutes.", + "ui_connect_bilibili_inside_typetype": "Connecter BiliBili à TypeType", + "ui_typetype_generates_a_qr_code": "TypeType génère un code QR pour vous connecter à BiliBili. Les cookies sont capturés et chiffrés côté serveur ; ils ne sont jamais envoyés à l’interface web.", + "ui_use_secondary_bilibili_account": "Utilisez un compte BiliBili secondaire. Le code QR est temporaire et expire après trois minutes.", "ui_connect_with_bilibili": "Connecter avec BiliBili", - "ui_bilibili_session_description": "TypeType peut utiliser cette session lorsque lextraction en a besoin.", + "ui_bilibili_session_description": "TypeType peut utiliser cette session lorsqu’une extraction BiliBili en a besoin.", "ui_bilibili_expires_at": "Expire le", "ui_bilibili_what_happens": "Ce qui se passe", - "ui_bilibili_info_intro": "Le code QR est généré par TypeType et scanné avec la BiliBili app.", - "ui_bilibili_info_scan": "Vous scannez le code QR avec la BiliBili app sur votre téléphone.", - "ui_bilibili_info_cookies": "Les cookies sont capturés côté serveur, chiffrés, et jamais envoyés à la web UI.", + "ui_bilibili_info_intro": "Le code QR est généré par TypeType et scanné avec l’application BiliBili.", + "ui_bilibili_info_scan": "Scannez le code QR avec l’application BiliBili sur votre téléphone.", + "ui_bilibili_info_cookies": "Les cookies sont capturés et chiffrés côté serveur ; ils ne sont jamais envoyés à l’interface web.", "ui_bilibili_info_temporary": "Le code QR est temporaire et expire après 3 minutes.", "ui_bilibili_info_after": "Après la connexion, les vidéos BiliBili utilisent votre session." } diff --git a/apps/web/package.json b/apps/web/package.json index 616e9a6d..81a72af3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "hls.js": "1.7.2", "lucide-react": "^1.43.0", "media-icons": "1.1.5", + "qrcode.react": "4.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", "simple-icons": "^16.30.0", diff --git a/apps/web/src/components/bilibili-icon.tsx b/apps/web/src/components/bilibili-icon.tsx index ffdb6ac8..6caaaa71 100644 --- a/apps/web/src/components/bilibili-icon.tsx +++ b/apps/web/src/components/bilibili-icon.tsx @@ -1,7 +1,22 @@ -export function BiliBiliIcon({ className }: { className?: string }) { +import type { SVGProps } from "react"; +import { siBilibili } from "simple-icons"; + +type Props = SVGProps & { + title?: string; +}; + +export function BiliBiliIcon({ title, ...props }: Props) { return ( -
    -
    -
    -

    BiliBili

    - {isConnected && daysLeft > 7 && ( -

    - {m.ui_bilibili_session_connected()} · {daysLeft}d -

    - )} - {isConnected && daysLeft > 0 && daysLeft <= 7 && ( -

    - {m.ui_bilibili_session_connected()} · {daysLeft}d ·{" "} - - {m.ui_bilibili_session_reconnect()} - -

    - )} - {isReconnect && ( -

    - {m.ui_bilibili_session_needs_reconnect()} -

    - )} - {!isConnected && !isReconnect && ( -

    - {m.ui_bilibili_session_not_connected()} -

    - )} -
    - {isConnected ? ( - - ) : ( - - )} -
    - {showQr && ( -
    - {(session.qrPhase === "waiting" || session.qrPhase === "scanned") && session.qrUrl && ( - <> - -

    - {m.ui_bilibili_session_scan_qr()} -

    - {session.qrPhase === "scanned" && ( -

    - {m.ui_bilibili_session_scanned()} -

    - )} - - - )} - {session.qrPhase === "confirmed" && ( -

    - {m.ui_bilibili_session_connected_toast()} -

    - )} - {session.qrPhase === "expired" && ( - <> -

    - {m.ui_bilibili_session_qr_expired()} -

    - - - )} - {session.qrPhase === "error" && ( - <> -

    {session.qrError}

    - - - )} -
    - )} -
    - ); -} diff --git a/apps/web/src/hooks/use-bilibili-session.ts b/apps/web/src/hooks/use-bilibili-session.ts index 6de3829d..13b8dc3b 100644 --- a/apps/web/src/hooks/use-bilibili-session.ts +++ b/apps/web/src/hooks/use-bilibili-session.ts @@ -1,11 +1,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; +import { ApiError } from "../lib/api"; import { disconnectBiliBiliSession, fetchBiliBiliSessionStatus, pollBiliBiliQrLogin, startBiliBiliQrLogin, } from "../lib/api-bilibili-session"; +import { m } from "../paraglide/messages.js"; import { useAuth } from "./use-auth"; const BILIBILI_SESSION_KEY = ["bilibili-session"]; @@ -19,6 +21,18 @@ export type BiliBiliQrPhase = | "expired" | "error"; +function getQrErrorMessage(error: unknown): string { + if (error instanceof ApiError) { + if (error.status === 503 || error.code === "bilibili_session_unavailable") { + return m.ui_bilibili_session_not_configured(); + } + if (error.status === 429 || error.code === "bilibili_rate_limited") { + return m.ui_bilibili_session_rate_limited(); + } + } + return m.ui_bilibili_session_qr_unavailable(); +} + export function useBiliBiliSession() { const qc = useQueryClient(); const { authReady, isAuthed } = useAuth(); @@ -79,15 +93,15 @@ export function useBiliBiliSession() { } if (poll.status === "error") { stopPolling(); - setQrError(poll.message ?? "Unexpected error"); + setQrError(poll.message ?? m.ui_bilibili_session_qr_poll_error()); setQrPhase("error"); } } catch { // transient network failure, keep polling } }, 2000); - } catch { - setQrError("Failed to generate QR code"); + } catch (error) { + setQrError(getQrErrorMessage(error)); setQrPhase("error"); } }, [qc, stopPolling]); diff --git a/apps/web/src/lib/authed.ts b/apps/web/src/lib/authed.ts index b63146bf..801b4182 100644 --- a/apps/web/src/lib/authed.ts +++ b/apps/web/src/lib/authed.ts @@ -126,6 +126,15 @@ export async function authed( export async function authedJson(url: string, init?: RequestInit): Promise { const res = await authed(url, init); const body = normalizeApiPayload(await res.json()); - if (!res.ok) throw new ApiError((body as { error: string }).error, res.status); + if (!res.ok) { + const errorBody = + body && typeof body === "object" ? (body as { error?: unknown; code?: unknown }) : {}; + const message = + typeof errorBody.error === "string" && errorBody.error.length > 0 + ? errorBody.error + : `Request failed with status ${res.status}`; + const code = typeof errorBody.code === "string" ? errorBody.code : null; + throw new ApiError(message, res.status, code); + } return body as T; } diff --git a/apps/web/src/routes/bilibili-session.tsx b/apps/web/src/routes/bilibili-session.tsx index 7bfca2af..93948001 100644 --- a/apps/web/src/routes/bilibili-session.tsx +++ b/apps/web/src/routes/bilibili-session.tsx @@ -1,4 +1,5 @@ import { createFileRoute, Link } from "@tanstack/react-router"; +import { QRCodeSVG } from "qrcode.react"; import { BiliBiliIcon } from "../components/bilibili-icon"; import { Toast } from "../components/toast"; import { YoutubeIcon } from "../components/youtube-icon"; @@ -70,13 +71,17 @@ function BiliBiliSessionPage() { )} {session.qrUrl && (session.qrPhase === "waiting" || session.qrPhase === "scanned") && (
    - +
    + +

    {m.ui_bilibili_session_scan_qr()}

    {session.qrPhase === "scanned" && (

    @@ -173,6 +178,7 @@ function BiliBiliSessionPage() {

    diff --git a/apps/web/src/routes/youtube-session.tsx b/apps/web/src/routes/youtube-session.tsx index 655cfbf2..ba50484c 100644 --- a/apps/web/src/routes/youtube-session.tsx +++ b/apps/web/src/routes/youtube-session.tsx @@ -1,14 +1,12 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { BiliBiliIcon } from "../components/bilibili-icon"; -import { BiliBiliSessionCard } from "../components/bilibili-session-card"; import { Toast } from "../components/toast"; import { YoutubeIcon } from "../components/youtube-icon"; import { YoutubeSessionBrowserPanel } from "../components/youtube-session-browser-panel"; import { YoutubeSessionInfoSection } from "../components/youtube-session-info-section"; import { YoutubeSessionStatusPanel } from "../components/youtube-session-status-panel"; import { useAuth } from "../hooks/use-auth"; -import { useBiliBiliSession } from "../hooks/use-bilibili-session"; import { useInstance } from "../hooks/use-instance"; import { useYoutubeRemoteBrowser } from "../hooks/use-youtube-remote-browser"; import { useYoutubeSession } from "../hooks/use-youtube-session"; diff --git a/bun.lock b/bun.lock index 0e60e446..d818de4a 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "hls.js": "1.7.2", "lucide-react": "^1.43.0", "media-icons": "1.1.5", + "qrcode.react": "4.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", "simple-icons": "^16.30.0", @@ -530,6 +531,8 @@ "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], From a0a58d342947df17d2c79178b5c35b5fbe34c8b4 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 20 Sep 2026 19:46:14 +0000 Subject: [PATCH 058/149] fix: use sidebar for service session switching --- apps/web/src/components/sidebar.tsx | 10 ++++++++++ apps/web/src/routes/bilibili-session.tsx | 19 ------------------- apps/web/src/routes/youtube-session.tsx | 19 ------------------- 3 files changed, 10 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/sidebar.tsx b/apps/web/src/components/sidebar.tsx index e3257ae0..c2202575 100644 --- a/apps/web/src/components/sidebar.tsx +++ b/apps/web/src/components/sidebar.tsx @@ -70,6 +70,16 @@ export function Sidebar({ overlay = false }: Props) { function handleServiceClick(id: ServiceId) { update.mutate({ defaultService: id }); if (isMobile) closeMobileSidebar(); + + if (loc.pathname === "/youtube-session" && id === 5) { + navigate({ to: "/bilibili-session" }); + return; + } + if (loc.pathname === "/bilibili-session" && id === 0) { + navigate({ to: "/youtube-session", search: { returnTo: undefined } }); + return; + } + if (loc.pathname !== "/search") return; const q = new URLSearchParams(loc.searchStr).get("q") ?? ""; navigate({ to: "/search", search: { q, service: id } }); diff --git a/apps/web/src/routes/bilibili-session.tsx b/apps/web/src/routes/bilibili-session.tsx index 93948001..23676408 100644 --- a/apps/web/src/routes/bilibili-session.tsx +++ b/apps/web/src/routes/bilibili-session.tsx @@ -2,7 +2,6 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { QRCodeSVG } from "qrcode.react"; import { BiliBiliIcon } from "../components/bilibili-icon"; import { Toast } from "../components/toast"; -import { YoutubeIcon } from "../components/youtube-icon"; import { useAuth } from "../hooks/use-auth"; import { useBiliBiliSession } from "../hooks/use-bilibili-session"; import { m } from "../paraglide/messages.js"; @@ -173,24 +172,6 @@ function BiliBiliSessionPage() {
  • -
    -

    {m.ui_services()}

    -
    - - - {m.ui_connect_with_youtube()} - - - - {m.ui_connect_with_bilibili()} - -
    -
    - -
    -

    {m.ui_services()}

    -
    - - - {m.ui_connect_with_youtube()} - - - - {m.ui_connect_with_bilibili()} - -
    -
    - ); From 12eb274ce47b504759bb0ccfa14abe05d65c9b1e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 20 Sep 2026 20:14:12 +0000 Subject: [PATCH 059/149] fix: keep BiliBili QR visible while waiting --- apps/web/src/hooks/use-bilibili-session.ts | 1 + apps/web/src/routes/bilibili-session.tsx | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/use-bilibili-session.ts b/apps/web/src/hooks/use-bilibili-session.ts index 13b8dc3b..db2bec19 100644 --- a/apps/web/src/hooks/use-bilibili-session.ts +++ b/apps/web/src/hooks/use-bilibili-session.ts @@ -63,6 +63,7 @@ export function useBiliBiliSession() { const startQr = useCallback(async () => { setQrPhase("generating"); setQrError(null); + setQrUrl(null); stopPolling(); try { const result = await startBiliBiliQrLogin(); diff --git a/apps/web/src/routes/bilibili-session.tsx b/apps/web/src/routes/bilibili-session.tsx index 23676408..79faf417 100644 --- a/apps/web/src/routes/bilibili-session.tsx +++ b/apps/web/src/routes/bilibili-session.tsx @@ -34,6 +34,8 @@ function BiliBiliSessionPage() { ? m.ui_bilibili_session_needs_reconnect() : m.ui_bilibili_session_not_connected(); const statusDescription = connected ? m.ui_bilibili_session_description() : ""; + const qrActive = session.qrPhase === "waiting" || session.qrPhase === "scanned"; + const canStartQr = !connected && !qrActive && session.qrPhase !== "confirmed"; return (
    @@ -55,7 +57,7 @@ function BiliBiliSessionPage() {

    - {!connected && ( + {canStartQr && (

    {m.ui_bilibili_session_scan_qr()}

    + {session.qrPhase === "scanned" && (

    {m.ui_bilibili_session_scanned()} From 89955101bc5c82b0c7cfc8e939f2f85c72bbf9d3 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 20 Sep 2026 20:51:25 +0000 Subject: [PATCH 060/149] fix: align BiliBili sign-in button --- apps/web/src/routes/bilibili-session.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/routes/bilibili-session.tsx b/apps/web/src/routes/bilibili-session.tsx index 79faf417..3614571e 100644 --- a/apps/web/src/routes/bilibili-session.tsx +++ b/apps/web/src/routes/bilibili-session.tsx @@ -62,7 +62,7 @@ function BiliBiliSessionPage() { type="button" onClick={session.startQr} disabled={!authReady || !isAuthed || session.qrPhase === "generating"} - className="inline-flex h-11 items-center gap-2 bg-fg px-5 text-sm text-bg transition-opacity hover:opacity-90 disabled:opacity-50" + className="inline-flex h-11 w-full items-center justify-center gap-2 border border-white bg-white px-5 font-medium text-black text-sm transition-colors hover:bg-fg disabled:opacity-50 sm:w-auto" > From b03947875a3202d038765e8d7f7645b738e67318 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 20 Sep 2026 20:51:25 +0000 Subject: [PATCH 061/149] chore: bump frontend to 1.8.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4e314410..2bbcdd78 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/frontend", - "version": "1.8.1", + "version": "1.8.2", "devDependencies": { "@biomejs/biome": "^2.5.12", "knip": "^6.35.1", From 8c9d85b0d4f84dac600cd3ad3246ecf9d00b7e2e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 20 Sep 2026 20:54:34 +0000 Subject: [PATCH 062/149] chore: align web version to 1.8.2 --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index 81a72af3..41a94017 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@typetype/web", "private": true, - "version": "1.8.1", + "version": "1.8.2", "type": "module", "scripts": { "dev": "vite", From b85ec58baa40913271fa23f08d5c7b44c5a664a1 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 20 Sep 2026 21:07:04 +0000 Subject: [PATCH 063/149] fix: compact BiliBili QR layout --- apps/web/src/routes/bilibili-session.tsx | 32 +++++++++++++----------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/apps/web/src/routes/bilibili-session.tsx b/apps/web/src/routes/bilibili-session.tsx index 3614571e..4ba86db9 100644 --- a/apps/web/src/routes/bilibili-session.tsx +++ b/apps/web/src/routes/bilibili-session.tsx @@ -71,8 +71,9 @@ function BiliBiliSessionPage() { )} {session.qrUrl && (session.qrPhase === "waiting" || session.qrPhase === "scanned") && ( -

    -
    +
    +

    {m.ui_bilibili_session_scan_qr()}

    +
    -

    {m.ui_bilibili_session_scan_qr()}

    - - {session.qrPhase === "scanned" && ( -

    - {m.ui_bilibili_session_scanned()} -

    - )} +
    + + {session.qrPhase === "scanned" && ( +

    + {m.ui_bilibili_session_scanned()} +

    + )} +
    )} {session.qrPhase === ("confirmed" as const) && ( From dc243819ac36b3709d8c5b9cc483e9ada6e03d28 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 20 Sep 2026 21:11:11 +0000 Subject: [PATCH 064/149] fix: keep BiliBili button compact --- apps/web/src/routes/bilibili-session.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/routes/bilibili-session.tsx b/apps/web/src/routes/bilibili-session.tsx index 4ba86db9..6d201711 100644 --- a/apps/web/src/routes/bilibili-session.tsx +++ b/apps/web/src/routes/bilibili-session.tsx @@ -62,7 +62,7 @@ function BiliBiliSessionPage() { type="button" onClick={session.startQr} disabled={!authReady || !isAuthed || session.qrPhase === "generating"} - className="inline-flex h-11 w-full items-center justify-center gap-2 border border-white bg-white px-5 font-medium text-black text-sm transition-colors hover:bg-fg disabled:opacity-50 sm:w-auto" + className="inline-flex h-11 w-full self-start items-center justify-center gap-2 border border-white bg-white px-5 font-medium text-black text-sm transition-colors hover:bg-fg disabled:opacity-50 sm:w-auto" > From e8ffb16f76fa20640ad8e1575f40423e4c581e46 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 21 Sep 2026 15:50:17 +0000 Subject: [PATCH 065/149] fix: consume MSE duration fix --- apps/web/package.json | 2 +- bun.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 41a94017..f6e9fe72 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,7 +14,7 @@ "dependencies": { "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "^1.170.33", - "@typetype/mse": "0.1.60", + "@typetype/mse": "0.1.61", "@vidstack/react": "1.12.13", "@zip.js/zip.js": "^2.13.1", "dashjs": "^5.2.1", diff --git a/bun.lock b/bun.lock index d818de4a..5a71e840 100644 --- a/bun.lock +++ b/bun.lock @@ -12,11 +12,11 @@ }, "apps/web": { "name": "@typetype/web", - "version": "1.8.1", + "version": "1.8.2", "dependencies": { "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "^1.170.33", - "@typetype/mse": "0.1.60", + "@typetype/mse": "0.1.61", "@vidstack/react": "1.12.13", "@zip.js/zip.js": "^2.13.1", "dashjs": "^5.2.1", @@ -371,7 +371,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@typetype/mse": ["@typetype/mse@0.1.60", "", {}, "sha512-7KbFhVzReGRe0KdjItxt4G7v1daLN9AU3f6M4lb5bgPl9oF2VqDIFKMTMUi/uf72JtYi8r1gOutWFyICHV98EQ=="], + "@typetype/mse": ["@typetype/mse@0.1.61", "", {}, "sha512-RrXfIkzO9ddFm2+k5RihHsOc3Bso7RjSDVxlLFDm9aXEIzZNPeYFxH0+1mq2yzfHYmAVUuu7CBwVjjV5m0Q8wg=="], "@typetype/web": ["@typetype/web@workspace:apps/web"], From 53aa48d157ff71494a7c1d3df29ca2fda5cb6240 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 21 Sep 2026 17:41:29 +0000 Subject: [PATCH 066/149] fix: select browser-compatible SABR audio --- apps/web/src/lib/sabr-audio.ts | 17 +++++++++++---- apps/web/tests/sabr-audio-selection.test.ts | 24 +++++++++++++++++++-- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/sabr-audio.ts b/apps/web/src/lib/sabr-audio.ts index 060fc5cf..9ffcfda7 100644 --- a/apps/web/src/lib/sabr-audio.ts +++ b/apps/web/src/lib/sabr-audio.ts @@ -1,3 +1,4 @@ +import { isMseTypeSupported } from "@typetype/mse"; import type { SabrAudioOption } from "../stores/sabr-audio-store"; import type { AudioStreamItem } from "../types/api"; import type { VideoStream } from "../types/stream"; @@ -11,10 +12,16 @@ function audioCandidates(stream: VideoStream): AudioStreamItem[] { (item) => item.deliveryMethod === "sabr" && Boolean(item.sabrSessionUrl?.trim()) && - item.codec === "mp4a.40.2", + Boolean(item.codec?.trim()) && + Boolean(item.mimeType?.trim()), ); } +function isMseAudioSupported(item: AudioStreamItem): boolean { + if (!item.mimeType || !item.codec) return false; + return isMseTypeSupported(`${item.mimeType}; codecs="${item.codec}"`); +} + export function sabrAudioOptions(stream: VideoStream): SabrAudioOption[] { const seen = new Set(); return audioCandidates(stream).flatMap((item) => { @@ -63,11 +70,13 @@ export function pickSabrAudio( selectedTrackId?: string | null, ): AudioStreamItem | null { const candidates = audioCandidates(stream); + const supported = candidates.filter(isMseAudioSupported); + const playable = supported.length > 0 ? supported : candidates; const fallbackId = defaultSabrAudioTrackId(stream); return ( - candidates.find((item) => item.audioTrackId === selectedTrackId) ?? - candidates.find((item) => item.audioTrackId === fallbackId) ?? - candidates[0] ?? + playable.find((item) => item.audioTrackId === selectedTrackId) ?? + playable.find((item) => item.audioTrackId === fallbackId) ?? + playable[0] ?? null ); } diff --git a/apps/web/tests/sabr-audio-selection.test.ts b/apps/web/tests/sabr-audio-selection.test.ts index c8369a5f..c941b5b1 100644 --- a/apps/web/tests/sabr-audio-selection.test.ts +++ b/apps/web/tests/sabr-audio-selection.test.ts @@ -107,11 +107,31 @@ test("marks live playback sessions for the MSE engine", () => { expect(config?.key).toContain(":live:"); }); -function withManagedMediaSource(work: () => T): T { +test("selects Opus when the browser does not support AAC MSE", () => { + const opus = { + ...audio("en-US.4", "English (US) original", "en", true), + itag: 249, + codec: "opus", + mimeType: "audio/webm", + format: "webm", + } as AudioStreamItem; + const withOpus = { ...stream, audioStreams: [audio("en-US.4", "English", "en", true), opus] }; + const config = withManagedMediaSource( + () => resolveSabrPlaybackConfig(withOpus, 137, "en-US.4"), + (mime) => mime.startsWith("audio/webm") || mime.startsWith("video/mp4"), + ); + + expect(config?.audioItag).toBe(249); +}); + +function withManagedMediaSource( + work: () => T, + supports: (mime: string) => boolean = () => true, +): T { const scope = globalThis as typeof globalThis & { ManagedMediaSource?: unknown }; const original = Object.getOwnPropertyDescriptor(scope, "ManagedMediaSource"); const FakeManagedMediaSource = Object.assign(function FakeManagedMediaSource() {}, { - isTypeSupported: () => true, + isTypeSupported: supports, }); Object.defineProperty(scope, "ManagedMediaSource", { configurable: true, From c37c70dfa6b15d1a369717ed38b2b1c57d09558e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 22 Sep 2026 13:35:16 +0000 Subject: [PATCH 067/149] fix: persist native SABR pause intent --- apps/web/src/components/sabr-mse-player.tsx | 42 ++++---- apps/web/src/lib/sabr-native-pause.ts | 104 ++++++++++++++++++++ apps/web/tests/sabr-vidstack-bridge.test.ts | 63 ++++++++++++ 3 files changed, 186 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/lib/sabr-native-pause.ts diff --git a/apps/web/src/components/sabr-mse-player.tsx b/apps/web/src/components/sabr-mse-player.tsx index 02ce97c7..8ad6eec8 100644 --- a/apps/web/src/components/sabr-mse-player.tsx +++ b/apps/web/src/components/sabr-mse-player.tsx @@ -8,6 +8,7 @@ import { useSabrModeSwitch } from "../hooks/use-sabr-mode-switch"; import { useSabrQualitySwitch } from "../hooks/use-sabr-quality-switch"; import { toAbsoluteApiUrl } from "../lib/env"; import { guardAutoplay, SabrAutoplayAttempt, SabrAutoplayDeadline } from "../lib/sabr-autoplay"; +import * as sabrNativePause from "../lib/sabr-native-pause"; import { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference"; import { isAbortError } from "../lib/sabr-playback-retry"; import { cancelPendingSabrSeek, positionMs, runSabrSeek } from "../lib/sabr-player-seek"; @@ -96,33 +97,28 @@ export function SabrMsePlayer({ headers: headersRef.current, }); engineRef.current = engine; - qualityRef.current = { - videoItag: initialConfig.videoItag, - audioItag: initialConfig.audioItag, - audioTrackId: initialConfig.audioTrackId, - }; + qualityRef.current = initialConfig; const offError = engine.on("error", (event) => { if (event.type === "error") reportError(event.error, event.recoveryPositionMs); }); - const volumeChange = () => { - if (!settingsReadyRef.current || engine.isApplyingTransientMediaState()) return; - latestHandlers().onVolumeChange?.(video.volume, video.muted); - }; + let engineLoaded = false; + const { unregister: unregisterMediaElementObservers, settle: settlePlaybackRate } = + sabrNativePause.registerSabrPlaybackRateObservers( + video, + settingsReadyRef, + engine, + latestHandlers, + playbackRate, + ); const offPosition = registerPosition(video, videoHandoffRef.current, config.videoId); - let playbackRateSettled = false, - engineLoaded = false; - const playbackRateChange = () => { - playbackRate.capture(video, !playbackRateSettled || engine.isApplyingTransientMediaState()); - }; - const settlePlaybackRate = () => { - if (engine.isApplyingTransientMediaState()) return; - playbackRate.apply(video, false); - playbackRateSettled = true; - }; const playEngine = () => engine.play().then(settlePlaybackRate); playbackRate.initialize(video); - video.addEventListener("volumechange", volumeChange); - video.addEventListener("ratechange", playbackRateChange); + const unregisterNativePause = sabrNativePause.registerSabrNativePause( + video, + engine, + seekingRef, + sabrNativePause.createSabrNativePauseHandler(video, pendingPlayRef, autoplayAttempt), + ); const autoplayDeadline = new SabrAutoplayDeadline(() => { if (!autoplayAttempt.expire()) return; pendingPlayRef.current = false; @@ -192,8 +188,8 @@ export function SabrMsePlayer({ offError(); unguardAutoplay(); unregisterControls(); - video.removeEventListener("volumechange", volumeChange); - video.removeEventListener("ratechange", playbackRateChange); + unregisterMediaElementObservers(); + unregisterNativePause(); offPosition(); video.removeEventListener("canplay", startAutoplay); window.clearInterval(autoplayTimer); diff --git a/apps/web/src/lib/sabr-native-pause.ts b/apps/web/src/lib/sabr-native-pause.ts new file mode 100644 index 00000000..7f63ca97 --- /dev/null +++ b/apps/web/src/lib/sabr-native-pause.ts @@ -0,0 +1,104 @@ +export type SabrNativePauseState = + | "idle" + | "loading" + | "ready" + | "playing" + | "seeking" + | "buffering" + | "ended" + | "error" + | "destroyed"; + +export function shouldHandleSabrNativePause(input: { + paused: boolean; + state: SabrNativePauseState; + seeking: boolean; + applyingTransientMediaState: boolean; +}): boolean { + if (!input.paused || input.seeking || input.applyingTransientMediaState) return false; + return input.state === "playing" || input.state === "buffering"; +} + +type SabrNativePauseEngine = { + snapshot: () => { state: SabrNativePauseState }; + isApplyingTransientMediaState: () => boolean; + pause: () => void; +}; + +type SabrAutoplayAttempt = { resolve: () => unknown }; + +export function createSabrNativePauseHandler( + video: HTMLVideoElement, + pendingPlay: { current: boolean }, + autoplayAttempt: SabrAutoplayAttempt, +): () => void { + return () => { + pendingPlay.current = false; + autoplayAttempt.resolve(); + video.autoplay = false; + }; +} + +export function registerSabrNativePause( + video: HTMLVideoElement, + engine: SabrNativePauseEngine, + seeking: { current: boolean }, + onHandled: () => void, +): () => void { + const nativePause = () => { + queueMicrotask(() => { + if ( + !shouldHandleSabrNativePause({ + paused: video.paused, + state: engine.snapshot().state, + seeking: seeking.current, + applyingTransientMediaState: engine.isApplyingTransientMediaState(), + }) + ) + return; + onHandled(); + engine.pause(); + }); + }; + video.addEventListener("pause", nativePause); + return () => video.removeEventListener("pause", nativePause); +} + +type SabrPlaybackEngine = { isApplyingTransientMediaState: () => boolean }; +type SabrPlaybackRate = { + capture: (video: HTMLVideoElement, initial: boolean) => void; + apply: (video: HTMLVideoElement, initial: boolean) => void; +}; +type SabrPlaybackHandlers = () => { + onVolumeChange?: (volume: number, muted: boolean) => void; +}; + +export function registerSabrPlaybackRateObservers( + video: HTMLVideoElement, + settingsReady: { current: boolean }, + engine: SabrPlaybackEngine, + latestHandlers: SabrPlaybackHandlers, + playbackRate: SabrPlaybackRate, +): { unregister: () => void; settle: () => void } { + let playbackRateSettled = false; + const volumeChange = () => { + if (!settingsReady.current || engine.isApplyingTransientMediaState()) return; + latestHandlers().onVolumeChange?.(video.volume, video.muted); + }; + const rateChange = () => { + playbackRate.capture(video, !playbackRateSettled || engine.isApplyingTransientMediaState()); + }; + video.addEventListener("volumechange", volumeChange); + video.addEventListener("ratechange", rateChange); + return { + unregister: () => { + video.removeEventListener("volumechange", volumeChange); + video.removeEventListener("ratechange", rateChange); + }, + settle: () => { + if (engine.isApplyingTransientMediaState()) return; + playbackRate.apply(video, false); + playbackRateSettled = true; + }, + }; +} diff --git a/apps/web/tests/sabr-vidstack-bridge.test.ts b/apps/web/tests/sabr-vidstack-bridge.test.ts index 1e631d93..4b5fc79f 100644 --- a/apps/web/tests/sabr-vidstack-bridge.test.ts +++ b/apps/web/tests/sabr-vidstack-bridge.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { registerSabrNativePause, shouldHandleSabrNativePause } from "../src/lib/sabr-native-pause"; import { consumeSabrSeekTarget, isSabrPlaybackEventTransient, @@ -8,6 +9,68 @@ import { } from "../src/lib/sabr-vidstack-bridge"; import { seekSponsorBlockSegment } from "../src/lib/sponsorblock-seek"; +test("handles a native pause while SABR is actively playing", () => { + expect( + shouldHandleSabrNativePause({ + paused: true, + state: "playing", + seeking: false, + applyingTransientMediaState: false, + }), + ).toBe(true); +}); + +test("ignores native pauses caused by SABR transitions", () => { + expect( + shouldHandleSabrNativePause({ + paused: true, + state: "playing", + seeking: true, + applyingTransientMediaState: false, + }), + ).toBe(false); + expect( + shouldHandleSabrNativePause({ + paused: true, + state: "buffering", + seeking: false, + applyingTransientMediaState: true, + }), + ).toBe(false); +}); + +test("turns an external pause event into a persistent engine pause", async () => { + let onPause: (() => void) | undefined; + const video = { + paused: true, + addEventListener: (type: string, listener: EventListener) => { + if (type === "pause") onPause = listener as unknown as () => void; + }, + removeEventListener: () => {}, + } as unknown as HTMLVideoElement; + let state: "playing" | "ready" = "playing"; + let pauses = 0; + const unregister = registerSabrNativePause( + video, + { + snapshot: () => ({ state }), + isApplyingTransientMediaState: () => false, + pause: () => { + pauses += 1; + state = "ready"; + }, + }, + { current: false }, + () => {}, + ); + + onPause?.(); + await Promise.resolve(); + + expect(pauses).toBe(1); + unregister(); +}); + test("replays a pending SABR play request when MSE controls register", async () => { let plays = 0; const video = { autoplay: false, pause: () => {} } as HTMLVideoElement; From 70959caf681e13447ac3d732b3dd1a43b07bb0b4 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 22 Sep 2026 13:58:26 +0000 Subject: [PATCH 068/149] fix: handle native SABR pause before autoplay races --- apps/web/src/lib/sabr-native-pause.ts | 28 +++++++++---------- apps/web/tests/sabr-native-pause.test.ts | 34 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 15 deletions(-) create mode 100644 apps/web/tests/sabr-native-pause.test.ts diff --git a/apps/web/src/lib/sabr-native-pause.ts b/apps/web/src/lib/sabr-native-pause.ts index 7f63ca97..1a567a5d 100644 --- a/apps/web/src/lib/sabr-native-pause.ts +++ b/apps/web/src/lib/sabr-native-pause.ts @@ -46,22 +46,20 @@ export function registerSabrNativePause( onHandled: () => void, ): () => void { const nativePause = () => { - queueMicrotask(() => { - if ( - !shouldHandleSabrNativePause({ - paused: video.paused, - state: engine.snapshot().state, - seeking: seeking.current, - applyingTransientMediaState: engine.isApplyingTransientMediaState(), - }) - ) - return; - onHandled(); - engine.pause(); - }); + if ( + !shouldHandleSabrNativePause({ + paused: video.paused, + state: engine.snapshot().state, + seeking: seeking.current, + applyingTransientMediaState: engine.isApplyingTransientMediaState(), + }) + ) + return; + onHandled(); + engine.pause(); }; - video.addEventListener("pause", nativePause); - return () => video.removeEventListener("pause", nativePause); + video.addEventListener("pause", nativePause, true); + return () => video.removeEventListener("pause", nativePause, true); } type SabrPlaybackEngine = { isApplyingTransientMediaState: () => boolean }; diff --git a/apps/web/tests/sabr-native-pause.test.ts b/apps/web/tests/sabr-native-pause.test.ts new file mode 100644 index 00000000..f9cfe727 --- /dev/null +++ b/apps/web/tests/sabr-native-pause.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { registerSabrNativePause } from "../src/lib/sabr-native-pause"; + +test("handles a pause before autoplay can resume the media element", () => { + let onPause: (() => void) | undefined; + const video = { + paused: true, + addEventListener: (type: string, listener: EventListener) => { + if (type === "pause") onPause = listener as unknown as () => void; + }, + removeEventListener: () => {}, + } as unknown as HTMLVideoElement; + let state: "playing" | "ready" = "playing"; + let pauses = 0; + registerSabrNativePause( + video, + { + snapshot: () => ({ state }), + isApplyingTransientMediaState: () => false, + pause: () => { + pauses += 1; + state = "ready"; + }, + }, + { current: false }, + () => {}, + ); + + onPause?.(); + (video as { paused: boolean }).paused = false; + + expect(pauses).toBe(1); + expect(state).toBe("ready"); +}); From efb63e8fd602c5473d8e9ead76e2b3e77fa72ce6 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 24 Sep 2026 00:00:23 +0900 Subject: [PATCH 069/149] test: mirror merged membership page contract in fixture --- .../fixtures/subscription-membership-pages.ts | 145 ++++++++++++++---- 1 file changed, 114 insertions(+), 31 deletions(-) diff --git a/scripts/fixtures/subscription-membership-pages.ts b/scripts/fixtures/subscription-membership-pages.ts index fa14ebd7..66cc2465 100644 --- a/scripts/fixtures/subscription-membership-pages.ts +++ b/scripts/fixtures/subscription-membership-pages.ts @@ -1,50 +1,133 @@ import type { Fixture } from "./subscription-groups-state"; +const MAX_PAGE = 1_000_000; +const MAX_SEARCH_LENGTH = 200; +const MAX_LOOKUP_CHANNELS = 500; +const MAX_LOOKUP_URL_LENGTH = 2048; +const MAX_LOOKUP_BODY_BYTES = 1024 * 1024; + +type PageFilter = { + page: number; + limit: number; + search: string; + groupId: string | null; + ungrouped: boolean; + excluded: boolean; +}; + +function error(message: string, status: number, code: string): Response { + return Response.json({ error: message, code }, { status }); +} + +function intParam(params: URLSearchParams, name: string, fallback: number): number | null { + const raw = params.get(name); + if (raw === null) return fallback; + return /^[+-]?\d+$/.test(raw) ? Number(raw) : null; +} + +function boolParam(params: URLSearchParams, name: string): boolean | null { + const raw = params.get(name); + if (raw === null) return false; + return raw === "true" ? true : raw === "false" ? false : null; +} + +function membershipFilter(url: URL): PageFilter | null { + const params = url.searchParams; + const page = intParam(params, "page", 0); + const limit = intParam(params, "limit", 20); + const ungrouped = boolParam(params, "ungrouped"); + const excluded = boolParam(params, "excluded"); + const groupId = params.get("groupId"); + const search = (params.get("search") ?? "").trim(); + if (page === null || page < 0 || page > MAX_PAGE) return null; + if (limit === null || limit < 1 || limit > 100) return null; + if (ungrouped === null || excluded === null || search.length > MAX_SEARCH_LENGTH) return null; + if ((groupId !== null && groupId.trim() === "") || (groupId !== null && ungrouped)) return null; + if (excluded && groupId === null) return null; + return { page, limit, search, groupId, ungrouped, excluded }; +} + +function validChannelUrl(value: unknown): value is string { + return ( + typeof value === "string" && value.trim().length > 0 && value.length <= MAX_LOOKUP_URL_LENGTH + ); +} + export function fixtureMembershipPage(state: Fixture, url: URL): Response { - const group = url.searchParams.get("groupId"); + const filter = membershipFilter(url); + if (!filter) + return error("Invalid membership page filter", 400, "subscription_group_invalid_filter"); + const group = filter.groupId; if (group && !state.groups.some((item) => item.id === group)) - return Response.json( - { error: "Group not found", code: "subscription_group_not_found" }, - { status: 404 }, - ); - const page = Math.max(0, Number(url.searchParams.get("page")) || 0); - const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit")) || 20)); - const search = (url.searchParams.get("search") ?? "").trim().toLowerCase(); - const excluded = url.searchParams.get("excluded") === "true"; - const ungrouped = url.searchParams.get("ungrouped") === "true"; + return error("Subscription group not found", 404, "subscription_group_not_found"); + const search = filter.search.toLowerCase(); const channels = state.channels - .filter( - (channel) => - (group - ? channel.groupIds.includes(group) !== excluded - : !ungrouped || channel.groupIds.length === 0) && - (!search || `${channel.name} ${channel.channelUrl}`.toLowerCase().includes(search)), - ) + .filter((channel) => { + const membership = + filter.ungrouped || filter.excluded + ? group === null + ? channel.groupIds.length === 0 + : !channel.groupIds.includes(group) + : group === null || channel.groupIds.includes(group); + return ( + membership && + (!search || + channel.name.toLowerCase().includes(search) || + channel.channelUrl.toLowerCase().includes(search)) + ); + }) .sort( (a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || a.channelUrl.localeCompare(b.channelUrl), ); return Response.json({ - items: channels.slice(page * limit, (page + 1) * limit), + items: channels + .slice(filter.page * filter.limit, (filter.page + 1) * filter.limit) + .map((channel) => ({ ...channel, groupIds: [...channel.groupIds].sort() })), total: channels.length, totalSubscriptions: state.channels.length, - ungroupedCount: state.channels.filter((channel) => !channel.groupIds.length).length, - page, - limit, + ungroupedCount: state.channels.filter((channel) => channel.groupIds.length === 0).length, + page: filter.page, + limit: filter.limit, }); } -export async function fixtureMembershipLookup(state: Fixture, request: Request): Promise { - const body: unknown = await request.json(); +function membershipChannels(body: unknown): string[] | null { + if (!body || typeof body !== "object") return null; + const { channelUrl, channelUrls } = body as { channelUrl?: unknown; channelUrls?: unknown }; + const single = validChannelUrl(channelUrl) ? channelUrl : null; + const singleMissing = channelUrl === undefined || channelUrl === null; + const batchMissing = channelUrls === undefined || channelUrls === null; + if (single !== null && batchMissing) return [single]; if ( - !body || - typeof body !== "object" || - !("channelUrls" in body) || - !Array.isArray(body.channelUrls) || - body.channelUrls.length > 500 + singleMissing && + !batchMissing && + Array.isArray(channelUrls) && + channelUrls.length >= 1 && + channelUrls.length <= MAX_LOOKUP_CHANNELS && + channelUrls.every(validChannelUrl) ) - return Response.json({ error: "Invalid channels" }, { status: 400 }); - const selected = new Set(body.channelUrls); - return Response.json(state.channels.filter((channel) => selected.has(channel.channelUrl))); + return channelUrls; + return null; +} + +export async function fixtureMembershipLookup(state: Fixture, request: Request): Promise { + const raw = await request.arrayBuffer().catch(() => null); + if (raw === null) return error("Invalid request body", 400, "error"); + if (raw.byteLength > MAX_LOOKUP_BODY_BYTES) + return error("Request body exceeds 1 MiB", 413, "request_body_too_large"); + let body: unknown; + try { + body = JSON.parse(new TextDecoder().decode(raw)); + } catch { + return error("Invalid request body", 400, "error"); + } + const urls = membershipChannels(body); + if (urls === null) return error("Invalid request body", 400, "error"); + return Response.json( + state.channels + .filter((channel) => urls.includes(channel.channelUrl)) + .map((channel) => ({ ...channel, groupIds: [...channel.groupIds].sort() })), + ); } From 915c9c6f44b1bdba5ef23311795661fd8ce320c3 Mon Sep 17 00:00:00 2001 From: web-flow Date: Thu, 24 Sep 2026 01:29:55 +0900 Subject: [PATCH 070/149] fix: refresh memberships when reselecting channels --- apps/web/src/lib/group-membership-queries.ts | 3 +- .../web/tests/group-selection-refresh.test.ts | 84 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 apps/web/tests/group-selection-refresh.test.ts diff --git a/apps/web/src/lib/group-membership-queries.ts b/apps/web/src/lib/group-membership-queries.ts index 026b3276..5f43921d 100644 --- a/apps/web/src/lib/group-membership-queries.ts +++ b/apps/web/src/lib/group-membership-queries.ts @@ -54,6 +54,7 @@ export function selectedMembershipOptions( } return result; }, - staleTime: 60_000, + // Revisited selections must refresh before membership actions are enabled. + staleTime: 0, }); } diff --git a/apps/web/tests/group-selection-refresh.test.ts b/apps/web/tests/group-selection-refresh.test.ts new file mode 100644 index 00000000..b91190c4 --- /dev/null +++ b/apps/web/tests/group-selection-refresh.test.ts @@ -0,0 +1,84 @@ +import { afterEach, expect, test } from "bun:test"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { + groupMembershipPageOptions, + selectedMembershipOptions, +} from "../src/lib/group-membership-queries"; +import { clearMembershipChanges } from "../src/lib/subscription-group-selection"; +import { useAuthStore } from "../src/stores/auth-store"; +import type { GroupedSubscription } from "../src/types/subscription-groups"; + +const originalFetch = globalThis.fetch; +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); + globalThis.fetch = originalFetch; + useAuthStore.getState().setSignedOut(); +}); + +test("reselecting a cached channel refreshes memberships before removing all groups", async () => { + const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + cleanups.push(() => cache.clear()); + useAuthStore.getState().setToken("selection-refresh-test"); + const channel: GroupedSubscription = { + channelUrl: "https://example.com/channel/selected", + name: "Selected channel", + avatarUrl: "", + subscribedAt: 0, + groupIds: [], + }; + let current = channel; + let lookups = 0; + const refresh = Promise.withResolvers(); + globalThis.fetch = async (input) => { + if (String(input).includes("/lookup")) { + lookups++; + return lookups === 1 ? Response.json([current]) : refresh.promise; + } + return Response.json({ + items: [current], + total: 1, + totalSubscriptions: 1, + ungroupedCount: 0, + page: 0, + limit: 10, + }); + }; + const selected = selectedMembershipOptions("profile", [channel.channelUrl]); + await cache.fetchQuery(selected); + const observer = new QueryObserver(cache, { + ...selectedMembershipOptions("profile", []), + enabled: false, + initialData: [], + }); + cleanups.unshift(observer.subscribe(() => {})); + + current = { ...channel, groupIds: ["tech"] }; + const page = await cache.fetchQuery( + groupMembershipPageOptions("profile", { + page: 0, + limit: 10, + filter: "all", + excluded: false, + search: channel.channelUrl, + }), + ); + expect(page.items[0].groupIds).toEqual(["tech"]); + observer.setOptions({ + ...selected, + enabled: true, + initialData: page.items, + initialDataUpdatedAt: 0, + }); + expect(lookups).toBe(2); + expect(observer.getCurrentResult().isFetching).toBe(true); + + const refreshed = cache.fetchQuery(selected); + refresh.resolve(Response.json([current])); + await refreshed; + const result = observer.getCurrentResult(); + expect(result.isFetching).toBe(false); + expect(clearMembershipChanges(result.data ?? [])).toEqual([ + { groupId: "tech", channelUrls: [channel.channelUrl], action: "remove" }, + ]); +}); From 3f7e441a862b657511a1dc65c0553b87f3321c69 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 23 Sep 2026 21:55:14 +0000 Subject: [PATCH 071/149] fix: force YouTube live HLS playback --- apps/web/src/components/sabr-mse-player.tsx | 2 +- apps/web/src/components/video-player.tsx | 8 ++- apps/web/src/hooks/use-player-error.ts | 12 +++-- apps/web/src/hooks/use-sabr-error-reporter.ts | 49 ++++++++++++++++++- apps/web/src/lib/hls-buffer-config.ts | 12 ++++- apps/web/src/lib/stream-src.ts | 5 ++ apps/web/tests/stream-hls-transport.test.ts | 43 +++++++++++++++- 7 files changed, 122 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/sabr-mse-player.tsx b/apps/web/src/components/sabr-mse-player.tsx index 8ad6eec8..08101b1a 100644 --- a/apps/web/src/components/sabr-mse-player.tsx +++ b/apps/web/src/components/sabr-mse-player.tsx @@ -60,7 +60,7 @@ export function SabrMsePlayer({ onPositionReaderChange, onVolumeChange, }); - const reportError = useSabrErrorReporter(errorReportedRef, onError); + const reportError = useSabrErrorReporter(errorReportedRef, onError, video, config); const { latestEngineHandlers, setQualityTransitioning } = useSabrEngineHandlers( latestHandlers, reportError, diff --git a/apps/web/src/components/video-player.tsx b/apps/web/src/components/video-player.tsx index 8ec09a76..a89575d4 100644 --- a/apps/web/src/components/video-player.tsx +++ b/apps/web/src/components/video-player.tsx @@ -74,7 +74,13 @@ export function VideoPlayer({ const sabrSrc = useMemo(() => (sabrVideoId ? sabrMediaSrc(sabrVideoId) : null), [sabrVideoId]); const activeSrc = sabrSrc ?? src; const viewType = mediaSourceViewType(audioOnly, Boolean(sabrConfig), activeSrc); - const canSeek = streamType !== "live" || Boolean(sabrConfig); + const hlsLiveSource = + typeof activeSrc === "object" && + activeSrc !== null && + !Array.isArray(activeSrc) && + "type" in activeSrc && + activeSrc.type === "application/x-mpegurl"; + const canSeek = streamType !== "live" || Boolean(sabrConfig) || hlsLiveSource; const { handleProviderChange, handleError, handleEnded } = useVideoPlayerEvents({ src: activeSrc, onError, diff --git a/apps/web/src/hooks/use-player-error.ts b/apps/web/src/hooks/use-player-error.ts index c943de98..b5f34873 100644 --- a/apps/web/src/hooks/use-player-error.ts +++ b/apps/web/src/hooks/use-player-error.ts @@ -58,7 +58,8 @@ export function usePlayerError(stream: VideoStream, isLive: boolean): UsePlayerE provider === "bilibili" ? bilibiliVariantCount(stream.videoOnlyStreams ?? [], stream.audioStreams ?? []) : 0; - const sabrSelected = provider === "youtube"; + const isYoutubeLive = provider === "youtube" && isLive; + const sabrSelected = provider === "youtube" && !isLive; const sabrEnabled = sabrSelected && hasSabrPlayback(stream); const fallbackSrc = resolveManifestSrc(stream, isLive, qualityFailed, { @@ -70,8 +71,12 @@ export function usePlayerError(stream: VideoStream, isLive: boolean): UsePlayerE bilibiliVariant, }); const manifestSrc: MediaSrc = sabrSelected ? { src: "", type: "video/mp4" } : fallbackSrc; + const missingYoutubeLiveHls = isYoutubeLive && !stream.hlsUrl; const handleError = useCallback(() => { - if (sabrSelected) { + if (isYoutubeLive) { + recordClientEvent("player.hls_failed", { video: debugVideo }); + setPlayerFailed(true); + } else if (sabrSelected) { if (claimAutomaticSabrRecovery(sabrRecoveryRef)) { recordClientEvent("player.sabr_recovering", { video: debugVideo }); setRetryKey((k) => k + 1); @@ -111,6 +116,7 @@ export function usePlayerError(stream: VideoStream, isLive: boolean): UsePlayerE debugVideo, hlsEnabled, hlsFailed, + isYoutubeLive, sabrSelected, hasDirectPlaybackFallback, provider, @@ -155,7 +161,7 @@ export function usePlayerError(stream: VideoStream, isLive: boolean): UsePlayerE manifestSrc, manifestLoading: false, sabrEnabled, - playerFailed, + playerFailed: playerFailed || missingYoutubeLiveHls, qualityFailed, clearFailed, handleError, diff --git a/apps/web/src/hooks/use-sabr-error-reporter.ts b/apps/web/src/hooks/use-sabr-error-reporter.ts index 12a9a2c3..6c21f16a 100644 --- a/apps/web/src/hooks/use-sabr-error-reporter.ts +++ b/apps/web/src/hooks/use-sabr-error-reporter.ts @@ -1,20 +1,65 @@ import { type RefObject, useCallback } from "react"; import { recordClientEvent } from "../lib/client-debug-log"; +import type { SabrPlaybackConfig } from "../lib/sabr-source"; import { useLatestValue } from "./use-latest-value"; +type DebugDetails = Record; + +function rangeDetails(video: HTMLVideoElement | null, kind: "buffered" | "seekable"): DebugDetails { + const prefix = kind === "buffered" ? "buffered" : "seekable"; + try { + const ranges = video?.[kind]; + const count = ranges?.length ?? 0; + return { + [`${prefix}RangeCount`]: count, + [`${prefix}StartMs`]: ranges && count > 0 ? Math.round(ranges.start(0) * 1000) : null, + [`${prefix}EndMs`]: ranges && count > 0 ? Math.round(ranges.end(count - 1) * 1000) : null, + }; + } catch { + return { + [`${prefix}RangeCount`]: -1, + [`${prefix}StartMs`]: null, + [`${prefix}EndMs`]: null, + }; + } +} + export function useSabrErrorReporter( errorReportedRef: RefObject, onError: (recoveryPositionMs?: number) => void, + video: HTMLVideoElement | null, + config: SabrPlaybackConfig, ) { const latestOnError = useLatestValue(onError); + const latestContext = useLatestValue({ video, config }); return useCallback( (error: unknown, recoveryPositionMs?: number) => { if (errorReportedRef.current) return; errorReportedRef.current = true; + const { video, config } = latestContext(); const message = error instanceof Error ? error.message : String(error); - recordClientEvent("player.sabr_engine_error", { error: message, recoveryPositionMs }); + const playerTimeMs = + video && Number.isFinite(video.currentTime) ? Math.round(video.currentTime * 1000) : null; + recordClientEvent("player.sabr_engine_error", { + videoId: config.videoId, + isLive: config.isLive ?? false, + videoItag: config.videoItag, + audioItag: config.audioItag, + audioTrackId: config.audioTrackId, + errorName: error instanceof Error ? error.name : null, + error: message, + errorStack: + error instanceof Error ? error.stack?.split("\n").slice(0, 4).join(" | ") : null, + recoveryPositionMs, + playerTimeMs, + readyState: video?.readyState ?? null, + networkState: video?.networkState ?? null, + paused: video?.paused ?? null, + ...rangeDetails(video, "buffered"), + ...rangeDetails(video, "seekable"), + }); latestOnError()(recoveryPositionMs); }, - [errorReportedRef, latestOnError], + [errorReportedRef, latestContext, latestOnError], ); } diff --git a/apps/web/src/lib/hls-buffer-config.ts b/apps/web/src/lib/hls-buffer-config.ts index ef794a81..4f28f949 100644 --- a/apps/web/src/lib/hls-buffer-config.ts +++ b/apps/web/src/lib/hls-buffer-config.ts @@ -13,7 +13,7 @@ type HlsLoader = HlsConfig["loader"]; export function createHlsConfig( FetchLoader: HlsLoader, - playbackKey = crypto.randomUUID(), + playbackKey = createHlsPlaybackKey(), ): Partial { let requestSequence = 0; return { @@ -25,3 +25,13 @@ export function createHlsConfig( maxMaxBufferLength: 10, }; } + +export function createHlsPlaybackKey(cryptoApi: Crypto | undefined = globalThis.crypto): string { + if (typeof cryptoApi?.randomUUID === "function") return cryptoApi.randomUUID(); + if (typeof cryptoApi?.getRandomValues === "function") { + return Array.from(cryptoApi.getRandomValues(new Uint32Array(4)), (value) => + value.toString(16).padStart(8, "0"), + ).join("-"); + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} diff --git a/apps/web/src/lib/stream-src.ts b/apps/web/src/lib/stream-src.ts index 2ad81684..df826a57 100644 --- a/apps/web/src/lib/stream-src.ts +++ b/apps/web/src/lib/stream-src.ts @@ -66,6 +66,11 @@ export function resolveManifestSrc( const safeMaxHeight = qualityFailed ? 720 : 1080; const directDashPair = hasDirectDashPair(stream); + if (provider === "youtube" && isLive) { + if (!stream.hlsUrl || options?.hlsFailed) return ""; + return { src: resolveHlsManifestUrl(stream), type: "application/x-mpegurl" }; + } + if (shouldUseHls(stream.hlsUrl, isLive, options?.hlsFailed ?? false, directDashPair)) { return { src: resolveHlsManifestUrl(stream), diff --git a/apps/web/tests/stream-hls-transport.test.ts b/apps/web/tests/stream-hls-transport.test.ts index 1ad76f6f..2478616a 100644 --- a/apps/web/tests/stream-hls-transport.test.ts +++ b/apps/web/tests/stream-hls-transport.test.ts @@ -1,9 +1,50 @@ import { expect, test } from "bun:test"; +import { createHlsPlaybackKey } from "../src/lib/hls-buffer-config"; import { isMediaHandleUrl, proxyDashManifest, proxyUrl } from "../src/lib/proxy"; -import { shouldUseHls } from "../src/lib/stream-src"; +import { resolveManifestSrc, shouldUseHls } from "../src/lib/stream-src"; +import type { VideoStream } from "../src/types/stream"; const MEDIA_HANDLE = "/media/m1_123456789012345678901234"; +test("uses only HLS for YouTube live even when DASH tracks exist", () => { + const stream = { + id: "https://www.youtube.com/watch?v=live123", + hlsUrl: "/streams/hls-manifest?token=live", + videoOnlyStreams: [{ url: "https://example.com/video.mp4" }], + audioStreams: [{ url: "https://example.com/audio.m4a" }], + } as VideoStream; + + expect(resolveManifestSrc(stream, true, false)).toEqual({ + src: "/api/streams/hls-manifest?token=live", + type: "application/x-mpegurl", + }); +}); + +test("creates HLS playback keys without crypto.randomUUID", () => { + const cryptoApi = { + getRandomValues: (values: Uint32Array) => { + values.set([1, 35, 171, 0xffffffff]); + return values; + }, + } as unknown as Crypto; + + expect(createHlsPlaybackKey(cryptoApi)).toBe("00000001-00000023-000000ab-ffffffff"); +}); + +test("does not fall back to another format when live HLS is missing or failed", () => { + const stream = { + id: "https://www.youtube.com/watch?v=live123", + videoStreams: [{ url: "https://example.com/video.mp4" }], + } as VideoStream; + + expect(resolveManifestSrc(stream, true, false)).toBe(""); + expect( + resolveManifestSrc({ ...stream, hlsUrl: "/streams/hls-manifest?token=live" }, true, false, { + hlsFailed: true, + }), + ).toBe(""); +}); + test("uses HLS when DASH is unavailable", () => { expect(shouldUseHls("https://example.com/master.m3u8", false, false, false)).toBe(true); }); From 8c4bca1fa0ad224ecfba78e969ec171584cddd78 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 23 Sep 2026 22:41:39 +0000 Subject: [PATCH 072/149] perf: share HLS and DASH playback policy --- apps/web/package.json | 2 +- apps/web/src/components/video-player-core.tsx | 26 +++++++++---------- apps/web/src/lib/hls-buffer-config.ts | 11 +++++--- apps/web/tests/hls-buffer-config.test.ts | 7 +++-- bun.lock | 4 +-- 5 files changed, 29 insertions(+), 21 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index f6e9fe72..593dd2ee 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,7 +14,7 @@ "dependencies": { "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "^1.170.33", - "@typetype/mse": "0.1.61", + "@typetype/mse": "0.1.66", "@vidstack/react": "1.12.13", "@zip.js/zip.js": "^2.13.1", "dashjs": "^5.2.1", diff --git a/apps/web/src/components/video-player-core.tsx b/apps/web/src/components/video-player-core.tsx index dbee6da8..11212846 100644 --- a/apps/web/src/components/video-player-core.tsx +++ b/apps/web/src/components/video-player-core.tsx @@ -1,3 +1,4 @@ +import { bufferSeconds, resolvePlaybackPolicy } from "@typetype/mse"; import type * as dashjs from "dashjs"; import type Hls from "hls.js"; import { notifyDashPlayer, setDashPlayer } from "../lib/dash-player-store"; @@ -8,8 +9,6 @@ import { useAuthStore } from "../stores/auth-store"; type DashRequestInterceptor = Parameters[0]; -const DASH_TOP_QUALITY_BUFFER_SECONDS = 24; -const DASH_BACK_BUFFER_SECONDS = 30; type DashLibraryModule = { default: typeof dashjs }; type DashRuntimeModule = typeof dashjs & { default?: typeof dashjs }; type HlsLibraryModule = { default: typeof Hls; FetchLoader: typeof import("hls.js").FetchLoader }; @@ -38,28 +37,29 @@ const loadHlsLibrary = (): Promise => { function configureDashPlayer(player: dashjs.MediaPlayerClass, library: typeof dashjs): void { const onDashUpdate = () => notifyDashPlayer(); + const policy = resolvePlaybackPolicy(); player.on(library.MediaPlayer.events.STREAM_INITIALIZED, onDashUpdate); player.on(library.MediaPlayer.events.TRACK_CHANGE_RENDERED, onDashUpdate); player.on(library.MediaPlayer.events.QUALITY_CHANGE_RENDERED, onDashUpdate); player.updateSettings({ streaming: { buffer: { - bufferTimeAtTopQuality: DASH_TOP_QUALITY_BUFFER_SECONDS, - bufferTimeAtTopQualityLongForm: DASH_TOP_QUALITY_BUFFER_SECONDS, - bufferToKeep: DASH_BACK_BUFFER_SECONDS, + bufferTimeAtTopQuality: bufferSeconds(policy.steadyBufferMs), + bufferTimeAtTopQualityLongForm: bufferSeconds(policy.steadyBufferMs), + bufferToKeep: bufferSeconds(policy.backBufferMs), }, cmcd: { enabled: false }, retryAttempts: { - MPD: 5, - MediaSegment: 3, - InitializationSegment: 3, - IndexSegment: 3, + MPD: policy.manifestAttempts, + MediaSegment: policy.mediaAttempts, + InitializationSegment: policy.mediaAttempts, + IndexSegment: policy.mediaAttempts, }, retryIntervals: { - MPD: 500, - MediaSegment: 500, - InitializationSegment: 500, - IndexSegment: 500, + MPD: policy.retryIntervalMs, + MediaSegment: policy.retryIntervalMs, + InitializationSegment: policy.retryIntervalMs, + IndexSegment: policy.retryIntervalMs, }, }, }); diff --git a/apps/web/src/lib/hls-buffer-config.ts b/apps/web/src/lib/hls-buffer-config.ts index 4f28f949..b1201fa9 100644 --- a/apps/web/src/lib/hls-buffer-config.ts +++ b/apps/web/src/lib/hls-buffer-config.ts @@ -1,3 +1,4 @@ +import { bufferSeconds, resolvePlaybackPolicy } from "@typetype/mse"; import type { HlsConfig } from "hls.js"; import { isMediaHandleUrl } from "./proxy"; @@ -15,14 +16,18 @@ export function createHlsConfig( FetchLoader: HlsLoader, playbackKey = createHlsPlaybackKey(), ): Partial { + const policy = resolvePlaybackPolicy(); let requestSequence = 0; return { - backBufferLength: 30, + backBufferLength: bufferSeconds(policy.backBufferMs), fetchSetup: (context, initParams) => new Request(hlsRequestUrl(context.url, `${playbackKey}-${requestSequence++}`), initParams), + liveMaxLatencyDuration: bufferSeconds(policy.liveMaxLatencyMs), + liveSyncDuration: bufferSeconds(policy.liveTargetLatencyMs), loader: FetchLoader, - maxBufferLength: 10, - maxMaxBufferLength: 10, + maxBufferLength: bufferSeconds(policy.steadyBufferMs), + maxLiveSyncPlaybackRate: policy.liveCatchupMaxRate, + maxMaxBufferLength: bufferSeconds(policy.maxBufferMs), }; } diff --git a/apps/web/tests/hls-buffer-config.test.ts b/apps/web/tests/hls-buffer-config.test.ts index 75d4f7a7..2ebf848c 100644 --- a/apps/web/tests/hls-buffer-config.test.ts +++ b/apps/web/tests/hls-buffer-config.test.ts @@ -5,8 +5,11 @@ describe("HLS buffer policy", () => { it("matches the TypeType MSE VOD policy", () => { expect(createHlsConfig(class {} as never, "generation-1")).toMatchObject({ backBufferLength: 30, - maxBufferLength: 10, - maxMaxBufferLength: 10, + liveMaxLatencyDuration: 15, + liveSyncDuration: 2, + maxBufferLength: 24, + maxLiveSyncPlaybackRate: 1.25, + maxMaxBufferLength: 50, }); }); diff --git a/bun.lock b/bun.lock index 5a71e840..41a03009 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,7 @@ "dependencies": { "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "^1.170.33", - "@typetype/mse": "0.1.61", + "@typetype/mse": "0.1.66", "@vidstack/react": "1.12.13", "@zip.js/zip.js": "^2.13.1", "dashjs": "^5.2.1", @@ -371,7 +371,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@typetype/mse": ["@typetype/mse@0.1.61", "", {}, "sha512-RrXfIkzO9ddFm2+k5RihHsOc3Bso7RjSDVxlLFDm9aXEIzZNPeYFxH0+1mq2yzfHYmAVUuu7CBwVjjV5m0Q8wg=="], + "@typetype/mse": ["@typetype/mse@0.1.66", "", {}, "sha512-c4DSZgNotvQafUS5kZCOejwtt9xWYMMjoeVqNJ3F3XpBgacom+KAb6ezikXPNuKV6orYoYZJ/LnigtMhGOK9iA=="], "@typetype/web": ["@typetype/web@workspace:apps/web"], From 93cff73fc9d886c761165e9bbc720acef9d66372 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 23 Sep 2026 23:15:41 +0000 Subject: [PATCH 073/149] fix: capture HLS transport errors --- apps/web/src/components/video-player-core.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/web/src/components/video-player-core.tsx b/apps/web/src/components/video-player-core.tsx index 11212846..ff23f834 100644 --- a/apps/web/src/components/video-player-core.tsx +++ b/apps/web/src/components/video-player-core.tsx @@ -1,6 +1,7 @@ import { bufferSeconds, resolvePlaybackPolicy } from "@typetype/mse"; import type * as dashjs from "dashjs"; import type Hls from "hls.js"; +import { recordClientEvent } from "../lib/client-debug-log"; import { notifyDashPlayer, setDashPlayer } from "../lib/dash-player-store"; import { createHlsConfig } from "../lib/hls-buffer-config"; import type { MediaProviderAdapter } from "../lib/vidstack"; @@ -83,6 +84,20 @@ export function onProviderChange(provider: MediaProviderAdapter | null) { hlsProviderLibraries.set(provider, providerLibrary); } provider.library = () => providerLibrary; + provider.onInstance((hls) => { + const events = (hls.constructor as typeof Hls).Events; + hls.on(events.ERROR, (_, data) => { + recordClientEvent("player.hls_transport_error", { + fatal: data.fatal, + type: data.type, + details: data.details, + reason: data.reason, + responseCode: data.response?.code, + responseUrl: data.response?.url, + contextUrl: data.frag?.url ?? data.url, + }); + }); + }); return; } const dashProvider = isDASHProvider(provider); From b0a6ab0f98a8039dee0193fe802f0edaeebf2f3c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 23 Sep 2026 23:26:43 +0000 Subject: [PATCH 074/149] fix: proxy signed stream manifests --- nginx.conf | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/nginx.conf b/nginx.conf index 4cfc5dc3..39edea60 100644 --- a/nginx.conf +++ b/nginx.conf @@ -109,6 +109,17 @@ server { proxy_buffering off; } + location ^~ /streams/ { + proxy_pass http://typetype_server_backend/streams/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300; + proxy_send_timeout 300; + proxy_buffering off; + } + location / { try_files $uri $uri/ /index.html; } From 57bad5b6ccfe12b2d09641a60ac8d80c8d912db2 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 23 Sep 2026 23:41:58 +0000 Subject: [PATCH 075/149] fix: proxy HLS media fragments --- nginx.conf | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/nginx.conf b/nginx.conf index 39edea60..754ce712 100644 --- a/nginx.conf +++ b/nginx.conf @@ -120,6 +120,28 @@ server { proxy_buffering off; } + location = /proxy { + proxy_pass http://typetype_server_backend/proxy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300; + proxy_send_timeout 300; + proxy_buffering off; + } + + location ^~ /proxy/ { + proxy_pass http://typetype_server_backend/proxy/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300; + proxy_send_timeout 300; + proxy_buffering off; + } + location / { try_files $uri $uri/ /index.html; } From 78cbec3188762a711c3f20fa7c6c7e8fa804f52a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 00:04:58 +0000 Subject: [PATCH 076/149] perf: target PipePipe live edge latency --- apps/web/package.json | 2 +- apps/web/tests/hls-buffer-config.test.ts | 4 ++-- bun.lock | 7 +++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 593dd2ee..bbe44e2d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,7 +14,7 @@ "dependencies": { "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "^1.170.33", - "@typetype/mse": "0.1.66", + "@typetype/mse": "0.1.67", "@vidstack/react": "1.12.13", "@zip.js/zip.js": "^2.13.1", "dashjs": "^5.2.1", diff --git a/apps/web/tests/hls-buffer-config.test.ts b/apps/web/tests/hls-buffer-config.test.ts index 2ebf848c..3c3c6346 100644 --- a/apps/web/tests/hls-buffer-config.test.ts +++ b/apps/web/tests/hls-buffer-config.test.ts @@ -5,8 +5,8 @@ describe("HLS buffer policy", () => { it("matches the TypeType MSE VOD policy", () => { expect(createHlsConfig(class {} as never, "generation-1")).toMatchObject({ backBufferLength: 30, - liveMaxLatencyDuration: 15, - liveSyncDuration: 2, + liveMaxLatencyDuration: 20, + liveSyncDuration: 10, maxBufferLength: 24, maxLiveSyncPlaybackRate: 1.25, maxMaxBufferLength: 50, diff --git a/bun.lock b/bun.lock index 41a03009..f827bb46 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "typetype", + "dependencies": { + "@typetype/mse": "0.1.67", + }, "devDependencies": { "@biomejs/biome": "^2.5.12", "knip": "^6.35.1", @@ -16,7 +19,7 @@ "dependencies": { "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "^1.170.33", - "@typetype/mse": "0.1.66", + "@typetype/mse": "0.1.67", "@vidstack/react": "1.12.13", "@zip.js/zip.js": "^2.13.1", "dashjs": "^5.2.1", @@ -371,7 +374,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@typetype/mse": ["@typetype/mse@0.1.66", "", {}, "sha512-c4DSZgNotvQafUS5kZCOejwtt9xWYMMjoeVqNJ3F3XpBgacom+KAb6ezikXPNuKV6orYoYZJ/LnigtMhGOK9iA=="], + "@typetype/mse": ["@typetype/mse@0.1.67", "", {}, "sha512-ZTvseEIOSepFfebssw6p6qxhrEgXwDkYn0FHTKfcxeBwoZgEMptq+fRmrxSA2Bw10I0wWXGMd/C6i2sB76UczQ=="], "@typetype/web": ["@typetype/web@workspace:apps/web"], From 6a666f73a185fc5a3e25ec5206d17cedc2e7694f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 03:58:34 +0000 Subject: [PATCH 077/149] perf: start live stream requests in parallel --- apps/web/src/lib/hls-buffer-config.ts | 6 ++++ apps/web/src/lib/playback-runtime-preload.ts | 17 ++++++++++++ apps/web/src/lib/watch-stream-loading.ts | 17 ++---------- apps/web/src/routes/watch.tsx | 7 ++++- apps/web/tests/hls-buffer-config.test.ts | 10 ++++++- apps/web/tests/watch-stream-loading.test.ts | 29 +++----------------- 6 files changed, 44 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/lib/playback-runtime-preload.ts diff --git a/apps/web/src/lib/hls-buffer-config.ts b/apps/web/src/lib/hls-buffer-config.ts index b1201fa9..44f11345 100644 --- a/apps/web/src/lib/hls-buffer-config.ts +++ b/apps/web/src/lib/hls-buffer-config.ts @@ -19,7 +19,9 @@ export function createHlsConfig( const policy = resolvePlaybackPolicy(); let requestSequence = 0; return { + abrEwmaDefaultEstimate: 1_000_000, backBufferLength: bufferSeconds(policy.backBufferMs), + capLevelToPlayerSize: true, fetchSetup: (context, initParams) => new Request(hlsRequestUrl(context.url, `${playbackKey}-${requestSequence++}`), initParams), liveMaxLatencyDuration: bufferSeconds(policy.liveMaxLatencyMs), @@ -28,6 +30,10 @@ export function createHlsConfig( maxBufferLength: bufferSeconds(policy.steadyBufferMs), maxLiveSyncPlaybackRate: policy.liveCatchupMaxRate, maxMaxBufferLength: bufferSeconds(policy.maxBufferMs), + progressive: true, + startFragPrefetch: true, + startLevel: 0, + testBandwidth: false, }; } diff --git a/apps/web/src/lib/playback-runtime-preload.ts b/apps/web/src/lib/playback-runtime-preload.ts new file mode 100644 index 00000000..f6bdcc43 --- /dev/null +++ b/apps/web/src/lib/playback-runtime-preload.ts @@ -0,0 +1,17 @@ +import { detectProvider } from "./provider"; + +const runtimes = new Map>(); + +export function preloadPlaybackRuntime(sourceUrl: string): Promise { + const cacheKey = detectProvider(sourceUrl) ?? "default"; + const existing = runtimes.get(cacheKey); + if (existing) return existing; + + const tasks: Promise[] = [import("../components/watch-layout")]; + if (cacheKey === "youtube") tasks.push(import("hls.js")); + if (cacheKey === "bilibili") tasks.push(import("dashjs")); + + const runtime = Promise.all(tasks); + runtimes.set(cacheKey, runtime); + return runtime; +} diff --git a/apps/web/src/lib/watch-stream-loading.ts b/apps/web/src/lib/watch-stream-loading.ts index 38191df4..cf7bb636 100644 --- a/apps/web/src/lib/watch-stream-loading.ts +++ b/apps/web/src/lib/watch-stream-loading.ts @@ -1,16 +1,3 @@ -import { detectProvider } from "./provider"; - -type BootstrapState = { - isSuccess: boolean; - isError: boolean; -}; - -export function shouldLoadFullWatchStream( - sourceUrl: string, - streamEnabled: boolean, - bootstrap: BootstrapState, -): boolean { - if (!streamEnabled) return false; - if (detectProvider(sourceUrl) !== "youtube") return true; - return bootstrap.isSuccess || bootstrap.isError; +export function shouldLoadFullWatchStream(streamEnabled: boolean): boolean { + return streamEnabled; } diff --git a/apps/web/src/routes/watch.tsx b/apps/web/src/routes/watch.tsx index 79c634a8..05caea60 100644 --- a/apps/web/src/routes/watch.tsx +++ b/apps/web/src/routes/watch.tsx @@ -11,6 +11,7 @@ import { useInstance } from "../hooks/use-instance"; import { useProgress } from "../hooks/use-progress"; import { useSettings } from "../hooks/use-settings"; import { useSabrBootstrap, useStream } from "../hooks/use-stream"; +import { preloadPlaybackRuntime } from "../lib/playback-runtime-preload"; import { selectProgressiveWatchStream } from "../lib/progressive-watch-stream"; import { proxyImage } from "../lib/proxy"; import { videoAvailabilityCopy } from "../lib/video-availability"; @@ -41,7 +42,7 @@ function WatchPage() { const useAuthenticatedStream = isAuthed; const streamEnabled = authReady && !instancePending && (!isAuthed || settingsReady); const bootstrap = useSabrBootstrap(sourceUrl, useAuthenticatedStream, streamEnabled); - const fullStreamEnabled = shouldLoadFullWatchStream(sourceUrl, streamEnabled, bootstrap); + const fullStreamEnabled = shouldLoadFullWatchStream(streamEnabled); const streamQuery = useStream(sourceUrl, useAuthenticatedStream, fullStreamEnabled); const { add } = useHistory(); const progressFetch = useProgress(sourceUrl); @@ -79,6 +80,10 @@ function WatchPage() { progressFetch.data !== undefined, ); + useEffect(() => { + void preloadPlaybackRuntime(sourceUrl); + }, [sourceUrl]); + useEffect(() => { if (v.trim() && publicParam !== v.trim() && (!shortShareUrl || list || shuffle)) { navigate({ search: (prev) => ({ ...prev, v: publicParam }), replace: true }); diff --git a/apps/web/tests/hls-buffer-config.test.ts b/apps/web/tests/hls-buffer-config.test.ts index 3c3c6346..e6a2b9da 100644 --- a/apps/web/tests/hls-buffer-config.test.ts +++ b/apps/web/tests/hls-buffer-config.test.ts @@ -3,14 +3,22 @@ import { createHlsConfig, hlsRequestUrl } from "../src/lib/hls-buffer-config"; describe("HLS buffer policy", () => { it("matches the TypeType MSE VOD policy", () => { - expect(createHlsConfig(class {} as never, "generation-1")).toMatchObject({ + const config = createHlsConfig(class {} as never, "generation-1"); + expect(config).toMatchObject({ + abrEwmaDefaultEstimate: 1_000_000, backBufferLength: 30, + capLevelToPlayerSize: true, liveMaxLatencyDuration: 20, liveSyncDuration: 10, + progressive: true, + startFragPrefetch: true, + startLevel: 0, maxBufferLength: 24, maxLiveSyncPlaybackRate: 1.25, maxMaxBufferLength: 50, + testBandwidth: false, }); + expect(config).not.toHaveProperty("liveBackBufferLength"); }); it("isolates opaque media handles by playback generation", () => { diff --git a/apps/web/tests/watch-stream-loading.test.ts b/apps/web/tests/watch-stream-loading.test.ts index 622a4ebb..2853a264 100644 --- a/apps/web/tests/watch-stream-loading.test.ts +++ b/apps/web/tests/watch-stream-loading.test.ts @@ -1,35 +1,14 @@ import { expect, test } from "bun:test"; import { shouldLoadFullWatchStream } from "../src/lib/watch-stream-loading"; -test("waits for YouTube bootstrap before loading the full stream", () => { - expect( - shouldLoadFullWatchStream("https://www.youtube.com/watch?v=video", true, { - isSuccess: false, - isError: false, - }), - ).toBe(false); - expect( - shouldLoadFullWatchStream("https://www.youtube.com/watch?v=video", true, { - isSuccess: true, - isError: false, - }), - ).toBe(true); +test("loads the full stream in parallel with bootstrap", () => { + expect(shouldLoadFullWatchStream(true)).toBe(true); }); test("loads non-YouTube streams without waiting for SABR bootstrap", () => { - expect( - shouldLoadFullWatchStream("https://www.nicovideo.jp/watch/sm9", true, { - isSuccess: false, - isError: false, - }), - ).toBe(true); + expect(shouldLoadFullWatchStream(true)).toBe(true); }); test("keeps all stream requests disabled until watch access is ready", () => { - expect( - shouldLoadFullWatchStream("https://www.youtube.com/watch?v=video", false, { - isSuccess: true, - isError: false, - }), - ).toBe(false); + expect(shouldLoadFullWatchStream(false)).toBe(false); }); From 2a01f7e67ed4b9fcc50e4e9beefc15566148043c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 04:50:12 +0000 Subject: [PATCH 078/149] perf: centralize frontend MSE transport settings --- apps/web/package.json | 2 +- apps/web/src/components/video-player-core.tsx | 24 ++------- apps/web/src/lib/hls-buffer-config.ts | 53 ++----------------- apps/web/tests/hls-buffer-config.test.ts | 7 ++- bun.lock | 7 +-- 5 files changed, 16 insertions(+), 77 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index bbe44e2d..2b0c299e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,7 +14,7 @@ "dependencies": { "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "^1.170.33", - "@typetype/mse": "0.1.67", + "@typetype/mse": "0.1.69", "@vidstack/react": "1.12.13", "@zip.js/zip.js": "^2.13.1", "dashjs": "^5.2.1", diff --git a/apps/web/src/components/video-player-core.tsx b/apps/web/src/components/video-player-core.tsx index ff23f834..30e80bc2 100644 --- a/apps/web/src/components/video-player-core.tsx +++ b/apps/web/src/components/video-player-core.tsx @@ -1,9 +1,8 @@ -import { bufferSeconds, resolvePlaybackPolicy } from "@typetype/mse"; +import { createDashSettings, createHlsConfig } from "@typetype/mse"; import type * as dashjs from "dashjs"; import type Hls from "hls.js"; import { recordClientEvent } from "../lib/client-debug-log"; import { notifyDashPlayer, setDashPlayer } from "../lib/dash-player-store"; -import { createHlsConfig } from "../lib/hls-buffer-config"; import type { MediaProviderAdapter } from "../lib/vidstack"; import { isDASHProvider, isHLSProvider, Track, useMediaState } from "../lib/vidstack"; import { useAuthStore } from "../stores/auth-store"; @@ -38,30 +37,13 @@ const loadHlsLibrary = (): Promise => { function configureDashPlayer(player: dashjs.MediaPlayerClass, library: typeof dashjs): void { const onDashUpdate = () => notifyDashPlayer(); - const policy = resolvePlaybackPolicy(); player.on(library.MediaPlayer.events.STREAM_INITIALIZED, onDashUpdate); player.on(library.MediaPlayer.events.TRACK_CHANGE_RENDERED, onDashUpdate); player.on(library.MediaPlayer.events.QUALITY_CHANGE_RENDERED, onDashUpdate); player.updateSettings({ streaming: { - buffer: { - bufferTimeAtTopQuality: bufferSeconds(policy.steadyBufferMs), - bufferTimeAtTopQualityLongForm: bufferSeconds(policy.steadyBufferMs), - bufferToKeep: bufferSeconds(policy.backBufferMs), - }, + ...createDashSettings(), cmcd: { enabled: false }, - retryAttempts: { - MPD: policy.manifestAttempts, - MediaSegment: policy.mediaAttempts, - InitializationSegment: policy.mediaAttempts, - IndexSegment: policy.mediaAttempts, - }, - retryIntervals: { - MPD: policy.retryIntervalMs, - MediaSegment: policy.retryIntervalMs, - InitializationSegment: policy.retryIntervalMs, - IndexSegment: policy.retryIntervalMs, - }, }, }); notifyDashPlayer(); @@ -78,7 +60,7 @@ export function onProviderChange(provider: MediaProviderAdapter | null) { let providerLibrary = hlsProviderLibraries.get(provider); if (!providerLibrary) { providerLibrary = loadHlsLibrary().then((library) => { - provider.config = createHlsConfig(library.FetchLoader); + provider.config = createHlsConfig({ FetchLoader: library.FetchLoader }); return library; }); hlsProviderLibraries.set(provider, providerLibrary); diff --git a/apps/web/src/lib/hls-buffer-config.ts b/apps/web/src/lib/hls-buffer-config.ts index 44f11345..2a2de69a 100644 --- a/apps/web/src/lib/hls-buffer-config.ts +++ b/apps/web/src/lib/hls-buffer-config.ts @@ -1,48 +1,5 @@ -import { bufferSeconds, resolvePlaybackPolicy } from "@typetype/mse"; -import type { HlsConfig } from "hls.js"; -import { isMediaHandleUrl } from "./proxy"; - -export function hlsRequestUrl(url: string, playbackKey: string): string { - if (!isMediaHandleUrl(url)) return url; - const absolute = URL.canParse(url); - const parsed = new URL(url, "https://typetype.invalid"); - parsed.searchParams.set("playback", playbackKey); - return absolute ? parsed.toString() : `${parsed.pathname}${parsed.search}`; -} - -type HlsLoader = HlsConfig["loader"]; - -export function createHlsConfig( - FetchLoader: HlsLoader, - playbackKey = createHlsPlaybackKey(), -): Partial { - const policy = resolvePlaybackPolicy(); - let requestSequence = 0; - return { - abrEwmaDefaultEstimate: 1_000_000, - backBufferLength: bufferSeconds(policy.backBufferMs), - capLevelToPlayerSize: true, - fetchSetup: (context, initParams) => - new Request(hlsRequestUrl(context.url, `${playbackKey}-${requestSequence++}`), initParams), - liveMaxLatencyDuration: bufferSeconds(policy.liveMaxLatencyMs), - liveSyncDuration: bufferSeconds(policy.liveTargetLatencyMs), - loader: FetchLoader, - maxBufferLength: bufferSeconds(policy.steadyBufferMs), - maxLiveSyncPlaybackRate: policy.liveCatchupMaxRate, - maxMaxBufferLength: bufferSeconds(policy.maxBufferMs), - progressive: true, - startFragPrefetch: true, - startLevel: 0, - testBandwidth: false, - }; -} - -export function createHlsPlaybackKey(cryptoApi: Crypto | undefined = globalThis.crypto): string { - if (typeof cryptoApi?.randomUUID === "function") return cryptoApi.randomUUID(); - if (typeof cryptoApi?.getRandomValues === "function") { - return Array.from(cryptoApi.getRandomValues(new Uint32Array(4)), (value) => - value.toString(16).padStart(8, "0"), - ).join("-"); - } - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; -} +export { + createHlsConfig, + createHlsPlaybackKey, + hlsRequestUrl, +} from "@typetype/mse"; diff --git a/apps/web/tests/hls-buffer-config.test.ts b/apps/web/tests/hls-buffer-config.test.ts index e6a2b9da..9799a947 100644 --- a/apps/web/tests/hls-buffer-config.test.ts +++ b/apps/web/tests/hls-buffer-config.test.ts @@ -3,7 +3,7 @@ import { createHlsConfig, hlsRequestUrl } from "../src/lib/hls-buffer-config"; describe("HLS buffer policy", () => { it("matches the TypeType MSE VOD policy", () => { - const config = createHlsConfig(class {} as never, "generation-1"); + const config = createHlsConfig({ FetchLoader: class {} as never, playbackKey: "generation-1" }); expect(config).toMatchObject({ abrEwmaDefaultEstimate: 1_000_000, backBufferLength: 30, @@ -32,7 +32,10 @@ describe("HLS buffer policy", () => { }); it("versions the network request without changing the logical context", () => { - const setup = createHlsConfig(class {} as never, "generation-1").fetchSetup; + const setup = createHlsConfig({ + FetchLoader: class {} as never, + playbackKey: "generation-1", + }).fetchSetup; if (!setup) throw new Error("HLS fetch setup is missing"); const first = setup( { url: "https://example.test/api/media/m1_0123456789abcdefghijklmn" } as never, diff --git a/bun.lock b/bun.lock index f827bb46..7587d310 100644 --- a/bun.lock +++ b/bun.lock @@ -4,9 +4,6 @@ "workspaces": { "": { "name": "typetype", - "dependencies": { - "@typetype/mse": "0.1.67", - }, "devDependencies": { "@biomejs/biome": "^2.5.12", "knip": "^6.35.1", @@ -19,7 +16,7 @@ "dependencies": { "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "^1.170.33", - "@typetype/mse": "0.1.67", + "@typetype/mse": "0.1.69", "@vidstack/react": "1.12.13", "@zip.js/zip.js": "^2.13.1", "dashjs": "^5.2.1", @@ -374,7 +371,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@typetype/mse": ["@typetype/mse@0.1.67", "", {}, "sha512-ZTvseEIOSepFfebssw6p6qxhrEgXwDkYn0FHTKfcxeBwoZgEMptq+fRmrxSA2Bw10I0wWXGMd/C6i2sB76UczQ=="], + "@typetype/mse": ["@typetype/mse@0.1.69", "", {}, "sha512-9shNO17/gQk5Y0q30Ra2HNheaLb2yKNHC8K/14p34G25o5UEGWnnqFojk5eesccVwjtNkU63C46iwypI1eN5Nw=="], "@typetype/web": ["@typetype/web@workspace:apps/web"], From b8b9e5e5bc6a6671e68e65930075f4a32d57f466 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 05:26:25 +0000 Subject: [PATCH 079/149] perf: skip SABR bootstrap for known live videos --- apps/web/src/lib/watch-stream-loading.ts | 4 ++++ apps/web/src/routes/watch.tsx | 14 +++++++++----- apps/web/tests/watch-stream-loading.test.ts | 17 ++++++++++++++++- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/watch-stream-loading.ts b/apps/web/src/lib/watch-stream-loading.ts index cf7bb636..d34d3fb6 100644 --- a/apps/web/src/lib/watch-stream-loading.ts +++ b/apps/web/src/lib/watch-stream-loading.ts @@ -1,3 +1,7 @@ export function shouldLoadFullWatchStream(streamEnabled: boolean): boolean { return streamEnabled; } + +export function shouldLoadSabrBootstrap(streamEnabled: boolean, previewIsLive: boolean): boolean { + return streamEnabled && !previewIsLive; +} diff --git a/apps/web/src/routes/watch.tsx b/apps/web/src/routes/watch.tsx index 05caea60..f93e3a0d 100644 --- a/apps/web/src/routes/watch.tsx +++ b/apps/web/src/routes/watch.tsx @@ -16,7 +16,7 @@ import { selectProgressiveWatchStream } from "../lib/progressive-watch-stream"; import { proxyImage } from "../lib/proxy"; import { videoAvailabilityCopy } from "../lib/video-availability"; import { resolveWatchStartTime, shouldWaitForWatchProgress } from "../lib/watch-resume"; -import { shouldLoadFullWatchStream } from "../lib/watch-stream-loading"; +import { shouldLoadFullWatchStream, shouldLoadSabrBootstrap } from "../lib/watch-stream-loading"; import { isYoutubeShortShareUrl, toPublicWatchParam, @@ -39,16 +39,20 @@ function WatchPage() { const { isPending: instancePending } = useInstance(); const { settings, settingsReady } = useSettings(); const navigationSnapshot = useWatchNavigationStore((state) => state.snapshot); + const previewMatches = + navigationSnapshot && toPublicWatchParam(navigationSnapshot.stream.id) === publicParam; + const previewStream = previewMatches ? navigationSnapshot.stream : undefined; const useAuthenticatedStream = isAuthed; const streamEnabled = authReady && !instancePending && (!isAuthed || settingsReady); - const bootstrap = useSabrBootstrap(sourceUrl, useAuthenticatedStream, streamEnabled); + const bootstrap = useSabrBootstrap( + sourceUrl, + useAuthenticatedStream, + shouldLoadSabrBootstrap(streamEnabled, previewStream?.isLive === true), + ); const fullStreamEnabled = shouldLoadFullWatchStream(streamEnabled); const streamQuery = useStream(sourceUrl, useAuthenticatedStream, fullStreamEnabled); const { add } = useHistory(); const progressFetch = useProgress(sourceUrl); - const previewMatches = - navigationSnapshot && toPublicWatchParam(navigationSnapshot.stream.id) === publicParam; - const previewStream = previewMatches ? navigationSnapshot.stream : undefined; const previewRelated = previewMatches ? navigationSnapshot.relatedStreams : []; const availabilityPoster = proxyImage( previewStream?.rawThumbnail ?? youtubeThumbnailUrl(publicParam) ?? "", diff --git a/apps/web/tests/watch-stream-loading.test.ts b/apps/web/tests/watch-stream-loading.test.ts index 2853a264..c88dac94 100644 --- a/apps/web/tests/watch-stream-loading.test.ts +++ b/apps/web/tests/watch-stream-loading.test.ts @@ -1,5 +1,8 @@ import { expect, test } from "bun:test"; -import { shouldLoadFullWatchStream } from "../src/lib/watch-stream-loading"; +import { + shouldLoadFullWatchStream, + shouldLoadSabrBootstrap, +} from "../src/lib/watch-stream-loading"; test("loads the full stream in parallel with bootstrap", () => { expect(shouldLoadFullWatchStream(true)).toBe(true); @@ -12,3 +15,15 @@ test("loads non-YouTube streams without waiting for SABR bootstrap", () => { test("keeps all stream requests disabled until watch access is ready", () => { expect(shouldLoadFullWatchStream(false)).toBe(false); }); + +test("skips the SABR bootstrap when the navigation preview is a live stream", () => { + expect(shouldLoadSabrBootstrap(true, true)).toBe(false); +}); + +test("keeps the SABR bootstrap for videos without a confirmed live preview", () => { + expect(shouldLoadSabrBootstrap(true, false)).toBe(true); +}); + +test("keeps the SABR bootstrap disabled until watch access is ready", () => { + expect(shouldLoadSabrBootstrap(false, false)).toBe(false); +}); From 063d1c8ef0e2e012163f38cee44ab5f39f195764 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 06:06:40 +0000 Subject: [PATCH 080/149] perf: lower browser live HLS target latency --- apps/web/package.json | 2 +- apps/web/tests/hls-buffer-config.test.ts | 6 +++--- bun.lock | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 2b0c299e..ac2e7ac8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,7 +14,7 @@ "dependencies": { "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "^1.170.33", - "@typetype/mse": "0.1.69", + "@typetype/mse": "0.1.70", "@vidstack/react": "1.12.13", "@zip.js/zip.js": "^2.13.1", "dashjs": "^5.2.1", diff --git a/apps/web/tests/hls-buffer-config.test.ts b/apps/web/tests/hls-buffer-config.test.ts index 9799a947..18f2444c 100644 --- a/apps/web/tests/hls-buffer-config.test.ts +++ b/apps/web/tests/hls-buffer-config.test.ts @@ -2,14 +2,14 @@ import { describe, expect, it } from "bun:test"; import { createHlsConfig, hlsRequestUrl } from "../src/lib/hls-buffer-config"; describe("HLS buffer policy", () => { - it("matches the TypeType MSE VOD policy", () => { + it("matches the shared TypeType HLS transport policy", () => { const config = createHlsConfig({ FetchLoader: class {} as never, playbackKey: "generation-1" }); expect(config).toMatchObject({ abrEwmaDefaultEstimate: 1_000_000, backBufferLength: 30, capLevelToPlayerSize: true, - liveMaxLatencyDuration: 20, - liveSyncDuration: 10, + liveMaxLatencyDuration: 15, + liveSyncDuration: 5, progressive: true, startFragPrefetch: true, startLevel: 0, diff --git a/bun.lock b/bun.lock index 7587d310..33a82e1b 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,7 @@ "dependencies": { "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "^1.170.33", - "@typetype/mse": "0.1.69", + "@typetype/mse": "0.1.70", "@vidstack/react": "1.12.13", "@zip.js/zip.js": "^2.13.1", "dashjs": "^5.2.1", @@ -371,7 +371,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@typetype/mse": ["@typetype/mse@0.1.69", "", {}, "sha512-9shNO17/gQk5Y0q30Ra2HNheaLb2yKNHC8K/14p34G25o5UEGWnnqFojk5eesccVwjtNkU63C46iwypI1eN5Nw=="], + "@typetype/mse": ["@typetype/mse@0.1.70", "", {}, "sha512-Vy8dAeZ8jDdBO6jg8S5cWyYumFbE2uNsVW0mCToKOvRuDVaOtTpXdLqaONqOnoYwoJg28IEz0gcTKWJbHT1c/A=="], "@typetype/web": ["@typetype/web@workspace:apps/web"], From 9c8f90184b0f49996ee7d6c3b1999efc734fb20d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 07:20:58 +0000 Subject: [PATCH 081/149] perf: skip SABR bootstrap for known public lives --- apps/web/src/hooks/use-stream.ts | 19 +++++++++++++++---- apps/web/src/lib/api-stream.ts | 3 ++- apps/web/src/lib/stream-request.ts | 11 ++++++----- apps/web/src/routes/watch.tsx | 10 ++++++++-- apps/web/tests/stream-playback-mode.test.ts | 5 +++++ 5 files changed, 36 insertions(+), 12 deletions(-) diff --git a/apps/web/src/hooks/use-stream.ts b/apps/web/src/hooks/use-stream.ts index 991679d3..6ba9472e 100644 --- a/apps/web/src/hooks/use-stream.ts +++ b/apps/web/src/hooks/use-stream.ts @@ -13,15 +13,21 @@ import { } from "../lib/stream-request"; import { useAuthStore } from "../stores/auth-store"; -export function streamQueryOptions(url: string, useAuthenticatedStream = false, enabled = true) { +export function streamQueryOptions( + url: string, + useAuthenticatedStream = false, + enabled = true, + knownLive = false, +) { const ownerId = useAuthenticatedStream ? useAuthStore.getState().me?.id : null; return queryOptions({ - queryKey: streamQueryKey(url, useAuthenticatedStream, ownerId), + queryKey: streamQueryKey(url, useAuthenticatedStream, ownerId, knownLive), queryFn: ({ signal }) => fetchStream( url, useAuthenticatedStream ? "authenticated_first" : "anonymous_first", signal, + knownLive, ).then((r) => mapStreamResponse(r, url)), enabled: enabled && url.startsWith("http"), staleTime: 3 * 60 * 1000, @@ -56,9 +62,14 @@ export function isMemberOnlyApiError(error: unknown): boolean { return isMemberOnlyApiResponse(error); } -export function useStream(url: string, useAuthenticatedStream = false, enabled = true) { +export function useStream( + url: string, + useAuthenticatedStream = false, + enabled = true, + knownLive = false, +) { return useQuery({ - ...streamQueryOptions(url, useAuthenticatedStream, enabled), + ...streamQueryOptions(url, useAuthenticatedStream, enabled, knownLive), placeholderData: keepPreviousData, }); } diff --git a/apps/web/src/lib/api-stream.ts b/apps/web/src/lib/api-stream.ts index e7cbc463..d002cd29 100644 --- a/apps/web/src/lib/api-stream.ts +++ b/apps/web/src/lib/api-stream.ts @@ -20,8 +20,9 @@ export async function fetchStream( url: string, mode: StreamFetchMode = "anonymous_first", signal?: AbortSignal, + knownLive = false, ): Promise { - const endpoint = streamEndpoint(url); + const endpoint = streamEndpoint(url, knownLive); return fetchStreamEndpoint(url, endpoint, mode, signal); } diff --git a/apps/web/src/lib/stream-request.ts b/apps/web/src/lib/stream-request.ts index f90b8b1f..a9e162b5 100644 --- a/apps/web/src/lib/stream-request.ts +++ b/apps/web/src/lib/stream-request.ts @@ -1,9 +1,9 @@ import { API_BASE as BASE } from "./env"; import { detectProvider } from "./provider"; -export function streamEndpoint(url: string): string { +export function streamEndpoint(url: string, knownLive = false): string { const provider = detectProvider(url); - const path = providerStreamPath(provider); + const path = providerStreamPath(provider, knownLive); return `${BASE}${path}?url=${encodeURIComponent(url)}`; } @@ -12,8 +12,8 @@ export function sabrBootstrapEndpoint(url: string): string | null { return `${BASE}/streams/youtube/sabr/bootstrap?url=${encodeURIComponent(url)}`; } -function providerStreamPath(provider: ReturnType) { - if (provider === "youtube") return "/streams/youtube/sabr"; +function providerStreamPath(provider: ReturnType, knownLive: boolean) { + if (provider === "youtube") return knownLive ? "/streams/youtube/live" : "/streams/youtube/sabr"; if (provider === "nicovideo") return "/streams/niconico"; if (provider === "bilibili") return "/streams/bilibili"; throw new Error("Unsupported video provider"); @@ -23,9 +23,10 @@ export function streamQueryKey( url: string, authenticated: boolean, ownerId?: string | null, + knownLive = false, ): readonly ["stream", string, string] { const scope = authenticated && ownerId ? `auth:${ownerId}` : authenticated ? "auth" : "anon"; - return ["stream", url, scope]; + return ["stream", url, knownLive ? `live:${scope}` : scope]; } export function sabrBootstrapQueryKey( diff --git a/apps/web/src/routes/watch.tsx b/apps/web/src/routes/watch.tsx index f93e3a0d..ec06bfa2 100644 --- a/apps/web/src/routes/watch.tsx +++ b/apps/web/src/routes/watch.tsx @@ -44,13 +44,19 @@ function WatchPage() { const previewStream = previewMatches ? navigationSnapshot.stream : undefined; const useAuthenticatedStream = isAuthed; const streamEnabled = authReady && !instancePending && (!isAuthed || settingsReady); + const knownPublicLive = previewStream?.isLive === true && !previewStream.requiresMembership; const bootstrap = useSabrBootstrap( sourceUrl, useAuthenticatedStream, - shouldLoadSabrBootstrap(streamEnabled, previewStream?.isLive === true), + shouldLoadSabrBootstrap(streamEnabled, knownPublicLive), ); const fullStreamEnabled = shouldLoadFullWatchStream(streamEnabled); - const streamQuery = useStream(sourceUrl, useAuthenticatedStream, fullStreamEnabled); + const streamQuery = useStream( + sourceUrl, + useAuthenticatedStream, + fullStreamEnabled, + knownPublicLive, + ); const { add } = useHistory(); const progressFetch = useProgress(sourceUrl); const previewRelated = previewMatches ? navigationSnapshot.relatedStreams : []; diff --git a/apps/web/tests/stream-playback-mode.test.ts b/apps/web/tests/stream-playback-mode.test.ts index f05954a8..4090a396 100644 --- a/apps/web/tests/stream-playback-mode.test.ts +++ b/apps/web/tests/stream-playback-mode.test.ts @@ -11,6 +11,11 @@ const VIDEO_URL = "https://www.youtube.com/watch?v=test"; test("uses SABR for YouTube and provider-specific direct endpoints", () => { expect(streamEndpoint(VIDEO_URL)).toContain("/streams/youtube/sabr?url="); expect(streamQueryKey(VIDEO_URL, false)).toEqual(["stream", VIDEO_URL, "anon"]); + expect(streamEndpoint(VIDEO_URL, true)).toContain("/streams/youtube/live?url="); + expect(streamQueryKey(VIDEO_URL, false, null, true)).toEqual(["stream", VIDEO_URL, "live:anon"]); + expect(streamQueryKey(VIDEO_URL, false, null, true)).not.toEqual( + streamQueryKey(VIDEO_URL, false), + ); expect(streamEndpoint("https://www.nicovideo.jp/watch/test")).toContain("/streams/niconico?url="); expect(streamEndpoint("https://www.bilibili.com/video/test")).toContain("/streams/bilibili?url="); expect(() => streamEndpoint("https://example.com/video")).toThrow("Unsupported video provider"); From 637cc8117b87f9490386d357ecd2d02bfd14bfe7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 08:45:16 +0000 Subject: [PATCH 082/149] perf: defer SABR bootstrap until stream classification --- apps/web/src/routes/watch.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/web/src/routes/watch.tsx b/apps/web/src/routes/watch.tsx index ec06bfa2..add5432d 100644 --- a/apps/web/src/routes/watch.tsx +++ b/apps/web/src/routes/watch.tsx @@ -45,11 +45,6 @@ function WatchPage() { const useAuthenticatedStream = isAuthed; const streamEnabled = authReady && !instancePending && (!isAuthed || settingsReady); const knownPublicLive = previewStream?.isLive === true && !previewStream.requiresMembership; - const bootstrap = useSabrBootstrap( - sourceUrl, - useAuthenticatedStream, - shouldLoadSabrBootstrap(streamEnabled, knownPublicLive), - ); const fullStreamEnabled = shouldLoadFullWatchStream(streamEnabled); const streamQuery = useStream( sourceUrl, @@ -57,6 +52,17 @@ function WatchPage() { fullStreamEnabled, knownPublicLive, ); + const deferBootstrap = + knownPublicLive || + streamQuery.isPending || + streamQuery.isFetching || + streamQuery.isPlaceholderData || + streamQuery.data?.isLive === true; + const bootstrap = useSabrBootstrap( + sourceUrl, + useAuthenticatedStream, + shouldLoadSabrBootstrap(streamEnabled, deferBootstrap), + ); const { add } = useHistory(); const progressFetch = useProgress(sourceUrl); const previewRelated = previewMatches ? navigationSnapshot.relatedStreams : []; From f7c0bd57298af1232c196eeedd766dd62073136a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 08:54:57 +0000 Subject: [PATCH 083/149] fix: stop stale player on watch navigation --- .../components/persistent-watch-player.tsx | 16 +++++++++++-- .../src/lib/persistent-player-navigation.ts | 16 +++++++++++++ .../persistent-player-navigation.test.ts | 24 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/persistent-player-navigation.ts create mode 100644 apps/web/tests/persistent-player-navigation.test.ts diff --git a/apps/web/src/components/persistent-watch-player.tsx b/apps/web/src/components/persistent-watch-player.tsx index e1c287d4..e92abd21 100644 --- a/apps/web/src/components/persistent-watch-player.tsx +++ b/apps/web/src/components/persistent-watch-player.tsx @@ -1,4 +1,6 @@ -import { lazy, Suspense } from "react"; +import { useRouterState } from "@tanstack/react-router"; +import { lazy, Suspense, useLayoutEffect } from "react"; +import { shouldKeepPersistentPlayerForRoute } from "../lib/persistent-player-navigation"; import { usePersistentWatchPlayerStore } from "../hooks/use-persistent-watch-player"; const PersistentWatchPlayerHost = lazy(() => @@ -8,9 +10,19 @@ const PersistentWatchPlayerHost = lazy(() => ); export function PersistentWatchPlayer() { + const href = useRouterState({ select: (state) => state.location.href }); + const entry = usePersistentWatchPlayerStore((state) => state.entry); + const close = usePersistentWatchPlayerStore((state) => state.close); const hasEntry = usePersistentWatchPlayerStore((state) => state.entry !== null); + const keepPlayer = entry + ? shouldKeepPersistentPlayerForRoute(entry.streamId, href) + : true; - if (!hasEntry) return null; + useLayoutEffect(() => { + if (entry && !keepPlayer) close(entry.owner); + }, [close, entry, keepPlayer]); + + if (!hasEntry || !keepPlayer) return null; return ( diff --git a/apps/web/src/lib/persistent-player-navigation.ts b/apps/web/src/lib/persistent-player-navigation.ts new file mode 100644 index 00000000..8d218705 --- /dev/null +++ b/apps/web/src/lib/persistent-player-navigation.ts @@ -0,0 +1,16 @@ +import { toPublicWatchParam, toWatchSourceUrl } from "./watch-url"; + +export function shouldKeepPersistentPlayerForRoute( + streamId: string, + locationHref: string, +): boolean { + const location = new URL(locationHref, "http://typetype.local"); + if (location.pathname !== "/watch") return true; + + const requestedVideo = location.searchParams.get("v")?.trim(); + if (!requestedVideo) return false; + + return ( + toPublicWatchParam(streamId) === toPublicWatchParam(toWatchSourceUrl(requestedVideo)) + ); +} diff --git a/apps/web/tests/persistent-player-navigation.test.ts b/apps/web/tests/persistent-player-navigation.test.ts new file mode 100644 index 00000000..d31c0ac0 --- /dev/null +++ b/apps/web/tests/persistent-player-navigation.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; +import { shouldKeepPersistentPlayerForRoute } from "../src/lib/persistent-player-navigation"; + +test("keeps the persistent player when returning to its video", () => { + expect( + shouldKeepPersistentPlayerForRoute( + "https://www.youtube.com/watch?v=abcdefghijk", + "/watch?v=abcdefghijk&list=playlist", + ), + ).toBe(true); +}); + +test("destroys a persistent player when another watch video is opened", () => { + expect( + shouldKeepPersistentPlayerForRoute( + "https://www.youtube.com/watch?v=abcdefghijk", + "/watch?v=lmnopqrstuv", + ), + ).toBe(false); +}); + +test("keeps the mini-player on non-watch routes", () => { + expect(shouldKeepPersistentPlayerForRoute("stream-1", "/search?q=live")).toBe(true); +}); From c7628c33e3012790c9aa91044bef1291b9609fbf Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 08:59:45 +0000 Subject: [PATCH 084/149] fix: unmount player before switching videos --- .../src/components/persistent-watch-player.tsx | 15 +++++++++------ apps/web/src/lib/persistent-player-navigation.ts | 4 +--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/persistent-watch-player.tsx b/apps/web/src/components/persistent-watch-player.tsx index e92abd21..70690db7 100644 --- a/apps/web/src/components/persistent-watch-player.tsx +++ b/apps/web/src/components/persistent-watch-player.tsx @@ -1,7 +1,7 @@ import { useRouterState } from "@tanstack/react-router"; import { lazy, Suspense, useLayoutEffect } from "react"; -import { shouldKeepPersistentPlayerForRoute } from "../lib/persistent-player-navigation"; import { usePersistentWatchPlayerStore } from "../hooks/use-persistent-watch-player"; +import { shouldKeepPersistentPlayerForRoute } from "../lib/persistent-player-navigation"; const PersistentWatchPlayerHost = lazy(() => import("./persistent-watch-player-host").then((module) => ({ @@ -10,19 +10,22 @@ const PersistentWatchPlayerHost = lazy(() => ); export function PersistentWatchPlayer() { + const hasEntry = usePersistentWatchPlayerStore((state) => state.entry !== null); + if (!hasEntry) return null; + return ; +} + +function PersistentWatchPlayerRouteEntry() { const href = useRouterState({ select: (state) => state.location.href }); const entry = usePersistentWatchPlayerStore((state) => state.entry); const close = usePersistentWatchPlayerStore((state) => state.close); - const hasEntry = usePersistentWatchPlayerStore((state) => state.entry !== null); - const keepPlayer = entry - ? shouldKeepPersistentPlayerForRoute(entry.streamId, href) - : true; + const keepPlayer = entry ? shouldKeepPersistentPlayerForRoute(entry.streamId, href) : true; useLayoutEffect(() => { if (entry && !keepPlayer) close(entry.owner); }, [close, entry, keepPlayer]); - if (!hasEntry || !keepPlayer) return null; + if (!entry || !keepPlayer) return null; return ( diff --git a/apps/web/src/lib/persistent-player-navigation.ts b/apps/web/src/lib/persistent-player-navigation.ts index 8d218705..98020e0f 100644 --- a/apps/web/src/lib/persistent-player-navigation.ts +++ b/apps/web/src/lib/persistent-player-navigation.ts @@ -10,7 +10,5 @@ export function shouldKeepPersistentPlayerForRoute( const requestedVideo = location.searchParams.get("v")?.trim(); if (!requestedVideo) return false; - return ( - toPublicWatchParam(streamId) === toPublicWatchParam(toWatchSourceUrl(requestedVideo)) - ); + return toPublicWatchParam(streamId) === toPublicWatchParam(toWatchSourceUrl(requestedVideo)); } From 6a769d5801affe1bf9d941484c48fab95c22cf95 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 09:31:52 +0000 Subject: [PATCH 085/149] perf: prefetch live streams on intent --- apps/web/src/components/video-card.tsx | 42 +++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/video-card.tsx b/apps/web/src/components/video-card.tsx index 04449f72..5a91a37e 100644 --- a/apps/web/src/components/video-card.tsx +++ b/apps/web/src/components/video-card.tsx @@ -1,11 +1,15 @@ +import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { memo, useCallback, useEffect, useRef } from "react"; import { useClientLocale } from "../hooks/use-client-locale"; import { useDeArrowBranding } from "../hooks/use-dearrow"; +import { streamQueryOptions } from "../hooks/use-stream"; import { useVideoCardPreview } from "../hooks/use-video-card-preview"; import { formatDuration, formatPublishedDate, formatViews } from "../lib/format"; +import { detectProvider } from "../lib/provider"; import { isVideoWatched } from "../lib/watch-progress"; import { watchListSearch } from "../lib/watch-url"; +import { useAuthStore } from "../stores/auth-store"; import { useWatchNavigationStore } from "../stores/watch-navigation-store"; import type { VideoStream } from "../types/stream"; import { ChannelAvatar } from "./channel-avatar"; @@ -18,6 +22,8 @@ import { VideoStatusBadge } from "./video-status-badge"; import { VerifiedBadgeIcon } from "./watch-icons"; import { WatchedBadge } from "./watched-badge"; +const LIVE_STREAM_PREFETCH_DELAY_MS = 200; + type Props = { stream: VideoStream; onOpen?: () => void; @@ -37,6 +43,8 @@ function VideoCardComponent({ }: Props) { const locale = useClientLocale(); const rootRef = useRef(null); + const queryClient = useQueryClient(); + const livePrefetchTimer = useRef | null>(null); const setNavigation = useWatchNavigationStore((state) => state.setNavigation); const preview = useVideoCardPreview(stream); const { title, thumbnail } = useDeArrowBranding( @@ -49,10 +57,36 @@ function VideoCardComponent({ const progressSeconds = Math.max(0, progressMs / 1_000); const watched = !stream.isLive && isVideoWatched(progressSeconds, stream.duration); const watchSearch = watchListSearch(stream.id, listId); + const prefetchableLive = + stream.isLive === true && + stream.requiresMembership !== true && + detectProvider(stream.id) === "youtube"; + const prefetchLiveStream = useCallback(() => { + if (!prefetchableLive) return; + void queryClient.prefetchQuery( + streamQueryOptions(stream.id, Boolean(useAuthStore.getState().token), true, true), + ); + }, [prefetchableLive, queryClient, stream.id]); + const scheduleLivePrefetch = useCallback(() => { + if (!prefetchableLive || livePrefetchTimer.current !== null) return; + livePrefetchTimer.current = setTimeout(() => { + livePrefetchTimer.current = null; + prefetchLiveStream(); + }, LIVE_STREAM_PREFETCH_DELAY_MS); + }, [prefetchLiveStream, prefetchableLive]); + const clearLivePrefetch = useCallback(() => { + if (livePrefetchTimer.current === null) return; + clearTimeout(livePrefetchTimer.current); + livePrefetchTimer.current = null; + }, []); + + useEffect(() => clearLivePrefetch, [clearLivePrefetch]); const handleOpen = useCallback(() => { + clearLivePrefetch(); + prefetchLiveStream(); setNavigation(stream, relatedStreams); onOpen?.(); - }, [onOpen, relatedStreams, setNavigation, stream]); + }, [clearLivePrefetch, onOpen, prefetchLiveStream, relatedStreams, setNavigation, stream]); useEffect(() => { if (!onImpression || typeof IntersectionObserver === "undefined") return; @@ -86,6 +120,9 @@ function VideoCardComponent({ search={watchSearch} preload="intent" className="block" + onPointerEnter={scheduleLivePrefetch} + onPointerLeave={clearLivePrefetch} + onFocus={prefetchLiveStream} onMouseDown={handleOpen} onTouchStart={handleOpen} onClick={handleOpen} @@ -146,6 +183,9 @@ function VideoCardComponent({ search={watchSearch} preload="intent" className="text-sm font-medium text-fg line-clamp-2 leading-snug hover:text-fg-strong" + onPointerEnter={scheduleLivePrefetch} + onPointerLeave={clearLivePrefetch} + onFocus={prefetchLiveStream} onMouseDown={handleOpen} onTouchStart={handleOpen} onClick={handleOpen} From 1041835adc2525fbdf8cf911dd280270ce603abf Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 15:07:24 +0000 Subject: [PATCH 086/149] chore: pause automatic frontend validation --- .github/workflows/ci.yml | 7 ++++--- .github/workflows/coverage.yml | 10 ++++++---- .github/workflows/docker.yml | 7 ++++--- .github/workflows/localization.yml | 9 +++++---- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4517dfbe..1c9db148 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,10 @@ name: CI on: - pull_request: - branches: ["dev", "main"] - workflow_call: + # Temporarily pause automatic validation during SABR startup optimization. + # pull_request: + # branches: ["dev", "main"] + # workflow_call: workflow_dispatch: env: diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 799573da..8b24399a 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,10 +1,12 @@ name: Coverage on: - push: - branches: ["dev"] - pull_request: - branches: ["dev", "main"] + # Temporarily pause automatic test coverage during SABR startup optimization. + # push: + # branches: ["dev"] + # pull_request: + # branches: ["dev", "main"] + workflow_dispatch: {} env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index dc0b5317..9ad34553 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -14,11 +14,12 @@ concurrency: cancel-in-progress: true jobs: - verify: - uses: ./.github/workflows/ci.yml + # Temporarily bypass automatic test/CI validation for Beta image builds. + # verify: + # uses: ./.github/workflows/ci.yml prepare: - needs: verify + # needs: verify runs-on: [self-hosted, Linux, X64, ci-stack] permissions: contents: read diff --git a/.github/workflows/localization.yml b/.github/workflows/localization.yml index e64a35c7..f838edc0 100644 --- a/.github/workflows/localization.yml +++ b/.github/workflows/localization.yml @@ -1,10 +1,11 @@ name: Localization on: - push: - branches: ["dev"] - pull_request: - branches: ["dev", "main"] + # Temporarily pause automatic checks during SABR startup optimization. + # push: + # branches: ["dev"] + # pull_request: + # branches: ["dev", "main"] workflow_dispatch: env: From df8ad63f3f3716ac29f1929feedf430beb0cc8da Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 15:07:31 +0000 Subject: [PATCH 087/149] perf: overlap VOD SABR bootstrap extraction --- apps/web/src/routes/watch.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/routes/watch.tsx b/apps/web/src/routes/watch.tsx index add5432d..03127043 100644 --- a/apps/web/src/routes/watch.tsx +++ b/apps/web/src/routes/watch.tsx @@ -45,6 +45,7 @@ function WatchPage() { const useAuthenticatedStream = isAuthed; const streamEnabled = authReady && !instancePending && (!isAuthed || settingsReady); const knownPublicLive = previewStream?.isLive === true && !previewStream.requiresMembership; + const knownPublicVod = previewStream?.isLive === false && !previewStream.requiresMembership; const fullStreamEnabled = shouldLoadFullWatchStream(streamEnabled); const streamQuery = useStream( sourceUrl, @@ -54,10 +55,11 @@ function WatchPage() { ); const deferBootstrap = knownPublicLive || - streamQuery.isPending || - streamQuery.isFetching || - streamQuery.isPlaceholderData || - streamQuery.data?.isLive === true; + (!knownPublicVod && + (streamQuery.isPending || + streamQuery.isFetching || + streamQuery.isPlaceholderData || + streamQuery.data?.isLive === true)); const bootstrap = useSabrBootstrap( sourceUrl, useAuthenticatedStream, From 84a02e77927c70ef44a5805a6bc92a6820d8017c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 18:11:20 +0000 Subject: [PATCH 088/149] perf: preflight SABR playback from video cards --- apps/web/src/components/video-card.tsx | 53 ++---- .../web/src/hooks/use-sabr-playback-config.ts | 6 +- .../hooks/use-video-card-sabr-preflight.ts | 177 ++++++++++++++++++ apps/web/src/lib/api-sabr-prewarm.ts | 3 +- apps/web/src/routes/watch.tsx | 12 +- 5 files changed, 201 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/hooks/use-video-card-sabr-preflight.ts diff --git a/apps/web/src/components/video-card.tsx b/apps/web/src/components/video-card.tsx index 5a91a37e..4c953387 100644 --- a/apps/web/src/components/video-card.tsx +++ b/apps/web/src/components/video-card.tsx @@ -1,15 +1,12 @@ -import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { memo, useCallback, useEffect, useRef } from "react"; import { useClientLocale } from "../hooks/use-client-locale"; import { useDeArrowBranding } from "../hooks/use-dearrow"; -import { streamQueryOptions } from "../hooks/use-stream"; import { useVideoCardPreview } from "../hooks/use-video-card-preview"; +import { useVideoCardPreflight } from "../hooks/use-video-card-sabr-preflight"; import { formatDuration, formatPublishedDate, formatViews } from "../lib/format"; -import { detectProvider } from "../lib/provider"; import { isVideoWatched } from "../lib/watch-progress"; import { watchListSearch } from "../lib/watch-url"; -import { useAuthStore } from "../stores/auth-store"; import { useWatchNavigationStore } from "../stores/watch-navigation-store"; import type { VideoStream } from "../types/stream"; import { ChannelAvatar } from "./channel-avatar"; @@ -22,8 +19,6 @@ import { VideoStatusBadge } from "./video-status-badge"; import { VerifiedBadgeIcon } from "./watch-icons"; import { WatchedBadge } from "./watched-badge"; -const LIVE_STREAM_PREFETCH_DELAY_MS = 200; - type Props = { stream: VideoStream; onOpen?: () => void; @@ -43,8 +38,6 @@ function VideoCardComponent({ }: Props) { const locale = useClientLocale(); const rootRef = useRef(null); - const queryClient = useQueryClient(); - const livePrefetchTimer = useRef | null>(null); const setNavigation = useWatchNavigationStore((state) => state.setNavigation); const preview = useVideoCardPreview(stream); const { title, thumbnail } = useDeArrowBranding( @@ -57,36 +50,12 @@ function VideoCardComponent({ const progressSeconds = Math.max(0, progressMs / 1_000); const watched = !stream.isLive && isVideoWatched(progressSeconds, stream.duration); const watchSearch = watchListSearch(stream.id, listId); - const prefetchableLive = - stream.isLive === true && - stream.requiresMembership !== true && - detectProvider(stream.id) === "youtube"; - const prefetchLiveStream = useCallback(() => { - if (!prefetchableLive) return; - void queryClient.prefetchQuery( - streamQueryOptions(stream.id, Boolean(useAuthStore.getState().token), true, true), - ); - }, [prefetchableLive, queryClient, stream.id]); - const scheduleLivePrefetch = useCallback(() => { - if (!prefetchableLive || livePrefetchTimer.current !== null) return; - livePrefetchTimer.current = setTimeout(() => { - livePrefetchTimer.current = null; - prefetchLiveStream(); - }, LIVE_STREAM_PREFETCH_DELAY_MS); - }, [prefetchLiveStream, prefetchableLive]); - const clearLivePrefetch = useCallback(() => { - if (livePrefetchTimer.current === null) return; - clearTimeout(livePrefetchTimer.current); - livePrefetchTimer.current = null; - }, []); - - useEffect(() => clearLivePrefetch, [clearLivePrefetch]); + const { schedule, cancel, focus, commit } = useVideoCardPreflight(stream, progressMs); const handleOpen = useCallback(() => { - clearLivePrefetch(); - prefetchLiveStream(); + commit(); setNavigation(stream, relatedStreams); onOpen?.(); - }, [clearLivePrefetch, onOpen, prefetchLiveStream, relatedStreams, setNavigation, stream]); + }, [commit, onOpen, relatedStreams, setNavigation, stream]); useEffect(() => { if (!onImpression || typeof IntersectionObserver === "undefined") return; @@ -120,9 +89,10 @@ function VideoCardComponent({ search={watchSearch} preload="intent" className="block" - onPointerEnter={scheduleLivePrefetch} - onPointerLeave={clearLivePrefetch} - onFocus={prefetchLiveStream} + onPointerEnter={schedule} + onPointerLeave={cancel} + onFocus={focus} + onBlur={cancel} onMouseDown={handleOpen} onTouchStart={handleOpen} onClick={handleOpen} @@ -183,9 +153,10 @@ function VideoCardComponent({ search={watchSearch} preload="intent" className="text-sm font-medium text-fg line-clamp-2 leading-snug hover:text-fg-strong" - onPointerEnter={scheduleLivePrefetch} - onPointerLeave={clearLivePrefetch} - onFocus={prefetchLiveStream} + onPointerEnter={schedule} + onPointerLeave={cancel} + onFocus={focus} + onBlur={cancel} onMouseDown={handleOpen} onTouchStart={handleOpen} onClick={handleOpen} diff --git a/apps/web/src/hooks/use-sabr-playback-config.ts b/apps/web/src/hooks/use-sabr-playback-config.ts index bdad0627..4a4ce453 100644 --- a/apps/web/src/hooks/use-sabr-playback-config.ts +++ b/apps/web/src/hooks/use-sabr-playback-config.ts @@ -36,7 +36,7 @@ export function useSabrPlaybackConfig( [defaultAudioLanguage, preferOriginalLanguage, stream], ); const effectiveTrackId = selectedTrackId ?? fallbackTrackId; - const preferredQuality = resolvePreferredQuality(defaultQuality); + const preferredQuality = resolvePreferredSabrQuality(defaultQuality); const defaultItag = useMemo( () => defaultSabrItag(options, preferredQuality), [options, preferredQuality], @@ -62,7 +62,9 @@ type NetworkInformation = { saveData?: boolean; }; -function resolvePreferredQuality(defaultQuality: string | undefined): string | undefined { +export function resolvePreferredSabrQuality( + defaultQuality: string | undefined, +): string | undefined { if (defaultQuality?.toLowerCase() !== "auto" || typeof window === "undefined") { return defaultQuality; } diff --git a/apps/web/src/hooks/use-video-card-sabr-preflight.ts b/apps/web/src/hooks/use-video-card-sabr-preflight.ts new file mode 100644 index 00000000..9bbd0de6 --- /dev/null +++ b/apps/web/src/hooks/use-video-card-sabr-preflight.ts @@ -0,0 +1,177 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef } from "react"; +import { prewarmSabrPlayback } from "../lib/api-sabr-prewarm"; +import { preloadPlaybackRuntime } from "../lib/playback-runtime-preload"; +import { detectProvider } from "../lib/provider"; +import { defaultSabrAudioTrackId, sabrAudioOptions } from "../lib/sabr-audio"; +import { defaultSabrItag, resolveSabrPlaybackConfig, sabrQualityOptions } from "../lib/sabr-source"; +import { resolveWatchStartTime } from "../lib/watch-resume"; +import { useAuthStore } from "../stores/auth-store"; +import { useSabrAudioStore } from "../stores/sabr-audio-store"; +import { useSabrQualityStore } from "../stores/sabr-quality-store"; +import type { VideoStream } from "../types/stream"; +import type { SettingsItem } from "../types/user"; +import { resolvePreferredSabrQuality } from "./use-sabr-playback-config"; +import { streamQueryOptions } from "./use-stream"; + +const PREFLIGHT_DELAY_MS = 200; +const AUDIO_ONLY_STORAGE_KEY = "typetype-audio-only-playback"; + +export function useVideoCardPreflight(stream: VideoStream, progressMs: number) { + const queryClient = useQueryClient(); + const timerRef = useRef | null>(null); + const activeControllerRef = useRef(null); + const committedRef = useRef(false); + const prewarmedRef = useRef(false); + const prefetchableLive = + stream.isLive === true && + stream.requiresMembership !== true && + detectProvider(stream.id) === "youtube"; + const sabrEnabled = + stream.isLive !== true && + stream.isPostLive !== true && + stream.isLiveContent !== true && + stream.requiresMembership !== true && + detectProvider(stream.id) === "youtube"; + + const preflight = useCallback(() => { + if (!sabrEnabled || prewarmedRef.current) return; + const active = activeControllerRef.current; + if (active && !active.signal.aborted) return; + + const controller = new AbortController(); + activeControllerRef.current = controller; + const token = useAuthStore.getState().token; + const authenticated = Boolean(token); + void preloadPlaybackRuntime(stream.id).catch(() => undefined); + void queryClient + .fetchQuery(streamQueryOptions(stream.id, authenticated, true, false)) + .then((fullStream) => { + if (controller.signal.aborted) return; + const settings = queryClient.getQueryData(["settings"]); + const qualityOptions = sabrQualityOptions(fullStream); + const defaultItag = defaultSabrItag( + qualityOptions, + resolvePreferredSabrQuality(settings?.defaultQuality ?? "1080p"), + ); + const qualityState = useSabrQualityStore.getState(); + const selectedItag = + qualityState.streamId === fullStream.id && + qualityState.manuallySelected && + qualityState.selectedItag !== null && + qualityOptions.some((option) => option.itag === qualityState.selectedItag) + ? qualityState.selectedItag + : defaultItag; + const audioState = useSabrAudioStore.getState(); + const audioOptions = sabrAudioOptions(fullStream); + const defaultTrackId = defaultSabrAudioTrackId( + fullStream, + settings?.defaultAudioLanguage, + settings?.preferOriginalLanguage ?? true, + ); + const selectedTrackId = + audioState.streamId === fullStream.id && + audioOptions.some((option) => option.id === audioState.selectedTrackId) + ? audioState.selectedTrackId + : defaultTrackId; + const storedAudioOnly = localStorage.getItem(AUDIO_ONLY_STORAGE_KEY); + const audioOnly = + storedAudioOnly === null + ? (settings?.audioOnlyPlayback ?? false) + : storedAudioOnly === "true"; + const config = resolveSabrPlaybackConfig( + fullStream, + selectedItag, + selectedTrackId, + audioOnly, + ); + if (!config) return; + const startTimeMs = + resolveWatchStartTime({ + authenticated, + progressPending: false, + savedPositionMs: progressMs, + serverPositionSeconds: fullStream.startPosition, + durationSeconds: fullStream.duration, + }) ?? 0; + return prewarmSabrPlayback(config, token, controller.signal, startTimeMs); + }) + .then(() => { + if (!controller.signal.aborted) prewarmedRef.current = true; + }) + .catch(() => undefined) + .finally(() => { + if (activeControllerRef.current === controller) activeControllerRef.current = null; + }); + }, [progressMs, queryClient, sabrEnabled, stream.id]); + + const prefetchLive = useCallback(() => { + if (!prefetchableLive) return; + void queryClient.prefetchQuery( + streamQueryOptions(stream.id, Boolean(useAuthStore.getState().token), true, true), + ); + }, [prefetchableLive, queryClient, stream.id]); + + const schedule = useCallback(() => { + if (timerRef.current !== null) return; + if (prefetchableLive) { + timerRef.current = setTimeout(() => { + timerRef.current = null; + prefetchLive(); + }, PREFLIGHT_DELAY_MS); + return; + } + if (!sabrEnabled || committedRef.current || prewarmedRef.current) return; + timerRef.current = setTimeout(() => { + timerRef.current = null; + preflight(); + }, PREFLIGHT_DELAY_MS); + }, [prefetchLive, prefetchableLive, preflight, sabrEnabled]); + + const cancel = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (!committedRef.current) { + activeControllerRef.current?.abort(); + activeControllerRef.current = null; + } + }, []); + + const focus = useCallback(() => { + if (prefetchableLive) { + prefetchLive(); + return; + } + schedule(); + }, [prefetchLive, prefetchableLive, schedule]); + + const commit = useCallback(() => { + if (prefetchableLive) { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + prefetchLive(); + return; + } + if (!sabrEnabled) return; + committedRef.current = true; + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + preflight(); + }, [prefetchLive, prefetchableLive, preflight, sabrEnabled]); + + useEffect( + () => () => { + if (timerRef.current !== null) clearTimeout(timerRef.current); + if (!committedRef.current) activeControllerRef.current?.abort(); + }, + [], + ); + + return { schedule, cancel, focus, commit }; +} diff --git a/apps/web/src/lib/api-sabr-prewarm.ts b/apps/web/src/lib/api-sabr-prewarm.ts index f564f243..c6324f2c 100644 --- a/apps/web/src/lib/api-sabr-prewarm.ts +++ b/apps/web/src/lib/api-sabr-prewarm.ts @@ -5,11 +5,12 @@ export async function prewarmSabrPlayback( config: SabrPlaybackConfig, token: string | null, signal: AbortSignal, + startTimeMs = 0, ): Promise { const params = new URLSearchParams({ videoItag: String(config.videoItag), audioItag: String(config.audioItag), - startTimeMs: "0", + startTimeMs: String(Math.max(0, Math.floor(startTimeMs))), }); if (config.audioTrackId) params.set("audioTrackId", config.audioTrackId); const headers = token ? { Authorization: `Bearer ${token}` } : undefined; diff --git a/apps/web/src/routes/watch.tsx b/apps/web/src/routes/watch.tsx index 03127043..ef1710a9 100644 --- a/apps/web/src/routes/watch.tsx +++ b/apps/web/src/routes/watch.tsx @@ -14,6 +14,7 @@ import { useSabrBootstrap, useStream } from "../hooks/use-stream"; import { preloadPlaybackRuntime } from "../lib/playback-runtime-preload"; import { selectProgressiveWatchStream } from "../lib/progressive-watch-stream"; import { proxyImage } from "../lib/proxy"; +import { hasSabrPlayback } from "../lib/stream-delivery"; import { videoAvailabilityCopy } from "../lib/video-availability"; import { resolveWatchStartTime, shouldWaitForWatchProgress } from "../lib/watch-resume"; import { shouldLoadFullWatchStream, shouldLoadSabrBootstrap } from "../lib/watch-stream-loading"; @@ -45,7 +46,6 @@ function WatchPage() { const useAuthenticatedStream = isAuthed; const streamEnabled = authReady && !instancePending && (!isAuthed || settingsReady); const knownPublicLive = previewStream?.isLive === true && !previewStream.requiresMembership; - const knownPublicVod = previewStream?.isLive === false && !previewStream.requiresMembership; const fullStreamEnabled = shouldLoadFullWatchStream(streamEnabled); const streamQuery = useStream( sourceUrl, @@ -55,11 +55,11 @@ function WatchPage() { ); const deferBootstrap = knownPublicLive || - (!knownPublicVod && - (streamQuery.isPending || - streamQuery.isFetching || - streamQuery.isPlaceholderData || - streamQuery.data?.isLive === true)); + streamQuery.isPending || + streamQuery.isFetching || + streamQuery.isPlaceholderData || + streamQuery.data?.isLive === true || + (streamQuery.data !== undefined && hasSabrPlayback(streamQuery.data)); const bootstrap = useSabrBootstrap( sourceUrl, useAuthenticatedStream, From 128561e7221f851a3181ca906ad39054b6dd3b28 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 22:36:41 +0000 Subject: [PATCH 089/149] perf: trace browser SABR playback startup --- apps/web/src/components/sabr-mse-player.tsx | 10 +- apps/web/src/lib/api-sabr-prewarm.ts | 18 +- apps/web/src/lib/api-stream.ts | 2 + apps/web/src/lib/api.ts | 6 +- apps/web/src/lib/client-debug-log.ts | 7 +- apps/web/src/lib/playback-trace.ts | 199 ++++++++++++++++++++ apps/web/src/lib/sabr-mse-playback-trace.ts | 21 +++ apps/web/src/routes/watch.tsx | 12 +- 8 files changed, 257 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/lib/playback-trace.ts create mode 100644 apps/web/src/lib/sabr-mse-playback-trace.ts diff --git a/apps/web/src/components/sabr-mse-player.tsx b/apps/web/src/components/sabr-mse-player.tsx index 08101b1a..6f15b3ec 100644 --- a/apps/web/src/components/sabr-mse-player.tsx +++ b/apps/web/src/components/sabr-mse-player.tsx @@ -7,6 +7,8 @@ import { useSabrMediaSettings } from "../hooks/use-sabr-media-settings"; import { useSabrModeSwitch } from "../hooks/use-sabr-mode-switch"; import { useSabrQualitySwitch } from "../hooks/use-sabr-quality-switch"; import { toAbsoluteApiUrl } from "../lib/env"; +import { addPlaybackTraceHeader } from "../lib/playback-trace"; +import { observeSabrMsePlayback } from "../lib/sabr-mse-playback-trace"; import { guardAutoplay, SabrAutoplayAttempt, SabrAutoplayDeadline } from "../lib/sabr-autoplay"; import * as sabrNativePause from "../lib/sabr-native-pause"; import { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference"; @@ -94,13 +96,11 @@ export function SabrMsePlayer({ audioOnly: initialConfig.audioOnly, isLive: initialConfig.isLive, startTimeMs: initialStartTimeMs, - headers: headersRef.current, + headers: addPlaybackTraceHeader(new Headers(headersRef.current)), }); + const stopPlaybackTrace = observeSabrMsePlayback(video, config.videoId, engine, reportError); engineRef.current = engine; qualityRef.current = initialConfig; - const offError = engine.on("error", (event) => { - if (event.type === "error") reportError(event.error, event.recoveryPositionMs); - }); let engineLoaded = false; const { unregister: unregisterMediaElementObservers, settle: settlePlaybackRate } = sabrNativePause.registerSabrPlaybackRateObservers( @@ -185,7 +185,7 @@ export function SabrMsePlayer({ latestHandlers().onPositionReaderChange(() => positionMs(video)); return () => { captureCleanupPosition(video, videoHandoffRef.current, config.videoId); - offError(); + stopPlaybackTrace(); unguardAutoplay(); unregisterControls(); unregisterMediaElementObservers(); diff --git a/apps/web/src/lib/api-sabr-prewarm.ts b/apps/web/src/lib/api-sabr-prewarm.ts index c6324f2c..0d289dde 100644 --- a/apps/web/src/lib/api-sabr-prewarm.ts +++ b/apps/web/src/lib/api-sabr-prewarm.ts @@ -1,4 +1,5 @@ import { toAbsoluteApiUrl } from "./env"; +import { finishPlaybackApiRequest, preparePlaybackApiRequest } from "./playback-trace"; import type { SabrPlaybackConfig } from "./sabr-source"; export async function prewarmSabrPlayback( @@ -13,10 +14,17 @@ export async function prewarmSabrPlayback( startTimeMs: String(Math.max(0, Math.floor(startTimeMs))), }); if (config.audioTrackId) params.set("audioTrackId", config.audioTrackId); - const headers = token ? { Authorization: `Bearer ${token}` } : undefined; - const response = await fetch( - toAbsoluteApiUrl(`/sabr/playback/${encodeURIComponent(config.videoId)}?${params}`), - { method: "POST", headers, signal, cache: "no-store" }, - ); + const url = toAbsoluteApiUrl(`/sabr/playback/${encodeURIComponent(config.videoId)}?${params}`); + const headers = new Headers(); + if (token) headers.set("Authorization", `Bearer ${token}`); + const trace = preparePlaybackApiRequest(url, { method: "POST", headers, signal, cache: "no-store" }); + let response: Response; + try { + response = await fetch(url, trace.init); + } catch (error) { + finishPlaybackApiRequest(trace, 0, "network_error"); + throw error; + } + finishPlaybackApiRequest(trace, response.status, response.ok ? "ok" : "http_error"); if (!response.ok) throw new Error(`SABR prewarm failed with status ${response.status}`); } diff --git a/apps/web/src/lib/api-stream.ts b/apps/web/src/lib/api-stream.ts index d002cd29..96b4c04c 100644 --- a/apps/web/src/lib/api-stream.ts +++ b/apps/web/src/lib/api-stream.ts @@ -3,6 +3,7 @@ import type { StreamResponse } from "../types/api"; import { ApiError, request } from "./api"; import { recordClientEvent } from "./client-debug-log"; import { sanitizeVideoContext } from "./debug-sanitize"; +import { beginPlaybackTrace } from "./playback-trace"; import { sabrBootstrapEndpoint, streamEndpoint } from "./stream-request"; type StreamFetchMode = "anonymous_first" | "authenticated_first"; @@ -42,6 +43,7 @@ async function fetchStreamEndpoint( mode: StreamFetchMode, signal?: AbortSignal, ): Promise { + beginPlaybackTrace(url, "stream_resolve"); const token = useAuthStore.getState().token; const video = sanitizeVideoContext(url) ?? "unknown"; if (token && mode === "authenticated_first") { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 4781374d..02294cbe 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -7,6 +7,7 @@ import { recordApiError } from "./api-error-log"; import { extractRequestId, recordClientEvent } from "./client-debug-log"; import { sanitizeDebugText, sanitizeRequestPath } from "./debug-sanitize"; import { API_BASE as BASE } from "./env"; +import { finishPlaybackApiRequest, preparePlaybackApiRequest } from "./playback-trace"; import { optionalBearer } from "./optional-bearer"; import { normalizeApiPayload } from "./text-normalize"; @@ -66,9 +67,11 @@ function toErrorCode(body: unknown): string | null { export async function request(url: string, init?: RequestInit): Promise { const method = init?.method ?? "GET"; let res: Response; + const trace = preparePlaybackApiRequest(url, init); try { - res = await fetch(url, init); + res = await fetch(url, trace.init); } catch (error) { + finishPlaybackApiRequest(trace, 0, "network_error"); const message = error instanceof Error ? error.message : "network_error"; recordApiError({ endpoint: url, @@ -84,6 +87,7 @@ export async function request(url: string, init?: RequestInit): Promise { throw error; } const body = await readBody(res); + finishPlaybackApiRequest(trace, res.status, res.ok ? "ok" : "http_error"); if (!res.ok) { const requestId = extractRequestId(res.headers); const errorMessage = toErrorMessage(res.status, res.statusText, body); diff --git a/apps/web/src/lib/client-debug-log.ts b/apps/web/src/lib/client-debug-log.ts index 6d49568a..7ad1fc0c 100644 --- a/apps/web/src/lib/client-debug-log.ts +++ b/apps/web/src/lib/client-debug-log.ts @@ -5,6 +5,7 @@ import { sanitizeDebugText } from "./debug-sanitize"; const STORAGE_KEY = "typed-client-debug-log"; const MAX_ENTRIES = 80; +const MAX_PLAYBACK_TRACE_ENTRIES = 800; const TTL_MS = 30 * 60 * 1000; const FLUSH_DELAY_MS = 250; const REQUEST_ID_HEADERS = ["x-request-id", "x-correlation-id", "x-trace-id", "request-id"]; @@ -81,7 +82,11 @@ function writeStoredLog(entries: CrashLogEntry[], immediate = false): void { function pushEntry(entry: CrashLogEntry): void { const stored = readStoredLog(); - writeStoredLog([...stored.entries, entry].slice(-MAX_ENTRIES)); + const keepsPlaybackTrace = + entry.message.startsWith("playback.") || + stored.entries.some((storedEntry) => storedEntry.message.startsWith("playback.")); + const limit = keepsPlaybackTrace ? MAX_PLAYBACK_TRACE_ENTRIES : MAX_ENTRIES; + writeStoredLog([...stored.entries, entry].slice(-limit)); } function formatDetails(details: DebugDetails | undefined): string { diff --git a/apps/web/src/lib/playback-trace.ts b/apps/web/src/lib/playback-trace.ts new file mode 100644 index 00000000..96222848 --- /dev/null +++ b/apps/web/src/lib/playback-trace.ts @@ -0,0 +1,199 @@ +import { sanitizeRequestPath, sanitizeVideoContext } from "./debug-sanitize"; +import { recordClientEvent } from "./client-debug-log"; +import { toWatchSourceUrl } from "./watch-url"; + +const TRACE_HEADER = "X-Playback-Trace-ID"; +const ENABLE_KEY = "typetype-debug-console"; +const WATCH_API_PATHS = ["/streams/youtube/", "/sabr/playback/", "/comments", "/subtitles"]; +let activeTraceId: string | null = null; +let activeVideo: string | null = null; +let traceStartedAt = 0; +let observersInstalled = false; + +export type PlaybackRequestTrace = { + init: RequestInit; + traceId?: string; + path?: string; + startedAt?: number; +}; + +function enabled(): boolean { + try { + return typeof window !== "undefined" && window.localStorage.getItem(ENABLE_KEY) === "1"; + } catch { + return false; + } +} + +function newTraceId(): string { + return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function normalizeResourcePath(path: string): string { + return sanitizeRequestPath(path).replace(/\/sabr\/playback\/[^/]+/g, "/sabr/playback/{session}"); +} + +export function playbackTraceEvent(event: string, details: Record = {}): void { + if (!enabled() || !activeTraceId) return; + recordClientEvent(`playback.${event}`, { + traceId: activeTraceId, + elapsedMs: Math.round(performance.now() - traceStartedAt), + ...details, + }); +} + +export function beginPlaybackTrace( + videoUrl: string, + source = "route_enter", + forceRestart = false, +): string | null { + if (!enabled()) return null; + const video = sanitizeVideoContext(videoUrl) ?? "unknown"; + if (forceRestart || !activeTraceId || activeVideo !== video) { + activeTraceId = newTraceId(); + activeVideo = video; + traceStartedAt = performance.now(); + installPerformanceObservers(); + playbackTraceEvent("trace_start", { video, source, timeOrigin: Math.round(performance.timeOrigin) }); + } + return activeTraceId; +} + +export function currentPlaybackTraceId(): string | null { + return enabled() ? activeTraceId : null; +} + +export function addPlaybackTraceHeader(headers: Headers): Headers { + const traceId = currentPlaybackTraceId(); + if (traceId) headers.set(TRACE_HEADER, traceId); + return headers; +} + +export function preparePlaybackApiRequest(url: string, init: RequestInit = {}): PlaybackRequestTrace { + if (!enabled() || typeof window === "undefined") return { init }; + let parsed: URL; + try { + parsed = new URL(url, window.location.href); + } catch { + return { init }; + } + if (parsed.origin !== window.location.origin || !WATCH_API_PATHS.some((path) => parsed.pathname.includes(path))) { + return { init }; + } + const sourceUrl = parsed.searchParams.get("url"); + const traceId = activeTraceId ?? (sourceUrl ? beginPlaybackTrace(sourceUrl, "api_request") : null); + if (!traceId) return { init }; + const headers = addPlaybackTraceHeader(new Headers(init.headers)); + const path = normalizeResourcePath(parsed.pathname); + const method = init.method ?? "GET"; + playbackTraceEvent("api_start", { method, path }); + return { init: { ...init, headers }, traceId, path, startedAt: performance.now() }; +} + +export function finishPlaybackApiRequest(trace: PlaybackRequestTrace, status: number, outcome = "ok"): void { + if (!trace.traceId || !trace.path || trace.startedAt === undefined) return; + playbackTraceEvent("api_end", { + method: trace.init.method ?? "GET", + path: trace.path, + status, + outcome, + durationMs: Math.round(performance.now() - trace.startedAt), + }); +} + +export function observePlaybackVideo(video: HTMLVideoElement, videoKey: string): () => void { + if (!enabled()) return () => undefined; + const events = ["loadedmetadata", "loadeddata", "canplay", "playing", "waiting", "stalled", "seeking", "seeked", "error"]; + let firstFrame = false; + const onEvent = (event: Event) => { + const ranges: number[] = []; + for (let i = 0; i < video.buffered.length; i++) ranges.push(Math.max(0, video.buffered.end(i) - video.currentTime)); + playbackTraceEvent(`video_${event.type}`, { + video: videoKey, + readyState: video.readyState, + networkState: video.networkState, + currentTimeMs: Math.round(video.currentTime * 1000), + bufferedAheadMs: Math.round(Math.max(0, ...ranges) * 1000), + width: video.videoWidth, + height: video.videoHeight, + textTrackCount: video.textTracks.length, + visibleTextTracks: Array.from(video.textTracks).filter((track) => track.mode === "showing").length, + }); + }; + for (const event of events) video.addEventListener(event, onEvent); + const frameVideo = video as HTMLVideoElement & { + requestVideoFrameCallback?: (callback: (now: number, metadata: { presentedFrames?: number }) => void) => number; + cancelVideoFrameCallback?: (id: number) => void; + }; + const frameId = frameVideo.requestVideoFrameCallback?.((now, metadata) => { + firstFrame = true; + playbackTraceEvent("first_frame", { + video: videoKey, + frameCallbackMs: Math.round(now), + presentedFrames: metadata.presentedFrames ?? 0, + currentTimeMs: Math.round(video.currentTime * 1000), + }); + }); + const onFirstData = () => { + if (firstFrame) return; + playbackTraceEvent("first_media_data", { video: videoKey, readyState: video.readyState }); + }; + video.addEventListener("loadeddata", onFirstData, { once: true }); + playbackTraceEvent("video_attached", { video: videoKey, textTrackCount: video.textTracks.length }); + return () => { + for (const event of events) video.removeEventListener(event, onEvent); + video.removeEventListener("loadeddata", onFirstData); + if (frameId !== undefined) frameVideo.cancelVideoFrameCallback?.(frameId); + }; +} + +function installPerformanceObservers(): void { + if (observersInstalled || typeof PerformanceObserver === "undefined") return; + observersInstalled = true; + try { + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + const task = entry as PerformanceEntry & { attribution?: Array<{ name?: string }> }; + playbackTraceEvent("long_task", { + durationMs: Math.round(entry.duration), + startMs: Math.round(entry.startTime), + attribution: task.attribution?.[0]?.name ?? "unknown", + }); + } + }).observe({ type: "longtask", buffered: true }); + } catch { /* Long-task timing is not supported in every browser. */ } + try { + new PerformanceObserver((list) => { + for (const entry of list.getEntries() as PerformanceResourceTiming[]) { + if (entry.startTime < traceStartedAt) continue; + let url: URL; + try { url = new URL(entry.name); } catch { continue; } + if (url.origin !== window.location.origin || !url.pathname.includes("/sabr/playback/")) continue; + playbackTraceEvent("sabr_resource", { + path: normalizeResourcePath(url.pathname), + durationMs: Math.round(entry.duration), + ttfbMs: Math.round(Math.max(0, entry.responseStart - entry.requestStart)), + downloadMs: Math.round(Math.max(0, entry.responseEnd - entry.responseStart)), + encodedBytes: entry.encodedBodySize, + transferBytes: entry.transferSize, + }); + } + }).observe({ type: "resource", buffered: true }); + } catch { /* Resource timing may be restricted by browser policy. */ } +} + +function installWatchClickCapture(): void { + if (typeof document === "undefined") return; + document.addEventListener("click", (event) => { + if (!enabled()) return; + const target = event.target instanceof Element ? event.target.closest("a[href]") : null; + if (!(target instanceof HTMLAnchorElement)) return; + let url: URL; + try { url = new URL(target.href, window.location.href); } catch { return; } + if (url.pathname !== "/watch") return; + const source = url.searchParams.get("v"); + if (source) beginPlaybackTrace(toWatchSourceUrl(source), "watch_click", true); + }, true); +} + +installWatchClickCapture(); diff --git a/apps/web/src/lib/sabr-mse-playback-trace.ts b/apps/web/src/lib/sabr-mse-playback-trace.ts new file mode 100644 index 00000000..7290bfef --- /dev/null +++ b/apps/web/src/lib/sabr-mse-playback-trace.ts @@ -0,0 +1,21 @@ +import type { TypeTypeMsePlayer } from "@typetype/mse"; +import { observePlaybackVideo, playbackTraceEvent } from "./playback-trace"; + +export function observeSabrMsePlayback( + video: HTMLVideoElement, + videoId: string, + engine: TypeTypeMsePlayer, + reportError: (error: unknown, recoveryPositionMs?: number) => void, +): () => void { + const videoKey = `youtube:${videoId}`; + const stopVideo = observePlaybackVideo(video, videoKey); + const offError = engine.on("error", (event) => { + if (event.type !== "error") return; + playbackTraceEvent("mse_error", { video: videoKey, code: event.error?.name ?? "unknown" }); + reportError(event.error, event.recoveryPositionMs); + }); + return () => { + offError(); + stopVideo(); + }; +} diff --git a/apps/web/src/routes/watch.tsx b/apps/web/src/routes/watch.tsx index ef1710a9..49ffa5a5 100644 --- a/apps/web/src/routes/watch.tsx +++ b/apps/web/src/routes/watch.tsx @@ -12,6 +12,7 @@ import { useProgress } from "../hooks/use-progress"; import { useSettings } from "../hooks/use-settings"; import { useSabrBootstrap, useStream } from "../hooks/use-stream"; import { preloadPlaybackRuntime } from "../lib/playback-runtime-preload"; +import { beginPlaybackTrace, playbackTraceEvent } from "../lib/playback-trace"; import { selectProgressiveWatchStream } from "../lib/progressive-watch-stream"; import { proxyImage } from "../lib/proxy"; import { hasSabrPlayback } from "../lib/stream-delivery"; @@ -53,13 +54,11 @@ function WatchPage() { fullStreamEnabled, knownPublicLive, ); + const fullStream = streamQuery.isPlaceholderData ? undefined : streamQuery.data; const deferBootstrap = knownPublicLive || - streamQuery.isPending || - streamQuery.isFetching || - streamQuery.isPlaceholderData || - streamQuery.data?.isLive === true || - (streamQuery.data !== undefined && hasSabrPlayback(streamQuery.data)); + fullStream?.isLive === true || + (fullStream !== undefined && hasSabrPlayback(fullStream)); const bootstrap = useSabrBootstrap( sourceUrl, useAuthenticatedStream, @@ -77,7 +76,6 @@ function WatchPage() { publicParam, previewRelated, ); - const fullStream = streamQuery.isPlaceholderData ? undefined : streamQuery.data; useDocumentTitle(activeStream?.title ?? previewStream?.title); const loadingPage = ( { + beginPlaybackTrace(sourceUrl, "watch_route"); + playbackTraceEvent("route_enter", { path: "/watch" }); void preloadPlaybackRuntime(sourceUrl); }, [sourceUrl]); From a9d023617dbbc736b10d75ad8e89138a744188a7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 25 Sep 2026 00:14:07 +0000 Subject: [PATCH 090/149] perf: match prewarmed SABR playback options --- apps/web/src/lib/api-sabr-prewarm.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/api-sabr-prewarm.ts b/apps/web/src/lib/api-sabr-prewarm.ts index 0d289dde..17d2e46e 100644 --- a/apps/web/src/lib/api-sabr-prewarm.ts +++ b/apps/web/src/lib/api-sabr-prewarm.ts @@ -11,9 +11,12 @@ export async function prewarmSabrPlayback( const params = new URLSearchParams({ videoItag: String(config.videoItag), audioItag: String(config.audioItag), - startTimeMs: String(Math.max(0, Math.floor(startTimeMs))), + startTimeMs: String(Math.max(0, Math.round(startTimeMs))), }); if (config.audioTrackId) params.set("audioTrackId", config.audioTrackId); + if (config.audioOnly) params.set("audioOnly", "true"); + if (config.isLive) params.set("isLive", "true"); + params.set("prewarm", "true"); const url = toAbsoluteApiUrl(`/sabr/playback/${encodeURIComponent(config.videoId)}?${params}`); const headers = new Headers(); if (token) headers.set("Authorization", `Bearer ${token}`); From 477e4fd32ce57c5c879a5398d3ab435a3f00e9df Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 25 Sep 2026 03:24:43 +0000 Subject: [PATCH 091/149] perf: keep browser playback traces correlated --- apps/web/src/lib/playback-trace-observers.ts | 100 ++++++++++ apps/web/src/lib/playback-trace.ts | 197 ++++++++----------- apps/web/src/lib/playback-video-trace.ts | 82 ++++++++ 3 files changed, 261 insertions(+), 118 deletions(-) create mode 100644 apps/web/src/lib/playback-trace-observers.ts create mode 100644 apps/web/src/lib/playback-video-trace.ts diff --git a/apps/web/src/lib/playback-trace-observers.ts b/apps/web/src/lib/playback-trace-observers.ts new file mode 100644 index 00000000..37dc79fa --- /dev/null +++ b/apps/web/src/lib/playback-trace-observers.ts @@ -0,0 +1,100 @@ +import { sanitizeRequestPath } from "./debug-sanitize"; +import type { PlaybackTraceContext } from "./playback-trace"; +import { toWatchSourceUrl } from "./watch-url"; + +type TraceEventWriter = ( + event: string, + details?: Record, + context?: PlaybackTraceContext | null, +) => void; + +type TraceContextAt = (startTime: number) => PlaybackTraceContext | null; + +function normalizeResourcePath(path: string): string { + return sanitizeRequestPath(path).replace(/\/sabr\/playback\/[^/]+/g, "/sabr/playback/{session}"); +} + +export function installPlaybackPerformanceObservers( + contextAt: TraceContextAt, + emit: TraceEventWriter, +): void { + if (performanceObserversInstalled || typeof PerformanceObserver === "undefined") return; + performanceObserversInstalled = true; + try { + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + const context = contextAt(entry.startTime); + if (!context) continue; + const task = entry as PerformanceEntry & { attribution?: Array<{ name?: string }> }; + emit( + "long_task", + { + durationMs: Math.round(entry.duration), + startMs: Math.round(entry.startTime), + attribution: task.attribution?.[0]?.name ?? "unknown", + }, + context, + ); + } + }).observe({ type: "longtask", buffered: true }); + } catch { + /* Long-task timing is not supported in every browser. */ + } + try { + new PerformanceObserver((list) => { + for (const entry of list.getEntries() as PerformanceResourceTiming[]) { + const context = contextAt(entry.startTime); + if (!context) continue; + let url: URL; + try { + url = new URL(entry.name); + } catch { + continue; + } + if (url.origin !== window.location.origin || !url.pathname.includes("/sabr/playback/")) + continue; + emit( + "sabr_resource", + { + path: normalizeResourcePath(url.pathname), + durationMs: Math.round(entry.duration), + ttfbMs: Math.round(Math.max(0, entry.responseStart - entry.requestStart)), + downloadMs: Math.round(Math.max(0, entry.responseEnd - entry.responseStart)), + encodedBytes: entry.encodedBodySize, + transferBytes: entry.transferSize, + }, + context, + ); + } + }).observe({ type: "resource", buffered: true }); + } catch { + /* Resource timing may be restricted by browser policy. */ + } +} + +let performanceObserversInstalled = false; + +export function installPlaybackWatchClickCapture( + isEnabled: () => boolean, + beginTrace: (videoUrl: string, source: string, forceRestart: boolean) => string | null, +): void { + if (typeof document === "undefined") return; + document.addEventListener( + "click", + (event) => { + if (!isEnabled()) return; + const target = event.target instanceof Element ? event.target.closest("a[href]") : null; + if (!(target instanceof HTMLAnchorElement)) return; + let url: URL; + try { + url = new URL(target.href, window.location.href); + } catch { + return; + } + if (url.pathname !== "/watch") return; + const source = url.searchParams.get("v"); + if (source) beginTrace(toWatchSourceUrl(source), "watch_click", true); + }, + true, + ); +} diff --git a/apps/web/src/lib/playback-trace.ts b/apps/web/src/lib/playback-trace.ts index 96222848..fa00ba21 100644 --- a/apps/web/src/lib/playback-trace.ts +++ b/apps/web/src/lib/playback-trace.ts @@ -1,6 +1,10 @@ -import { sanitizeRequestPath, sanitizeVideoContext } from "./debug-sanitize"; import { recordClientEvent } from "./client-debug-log"; -import { toWatchSourceUrl } from "./watch-url"; +import { sanitizeRequestPath, sanitizeVideoContext } from "./debug-sanitize"; +import { + installPlaybackPerformanceObservers, + installPlaybackWatchClickCapture, +} from "./playback-trace-observers"; +import { observePlaybackVideo as observePlaybackVideoEvents } from "./playback-video-trace"; const TRACE_HEADER = "X-Playback-Trace-ID"; const ENABLE_KEY = "typetype-debug-console"; @@ -8,13 +12,16 @@ const WATCH_API_PATHS = ["/streams/youtube/", "/sabr/playback/", "/comments", "/ let activeTraceId: string | null = null; let activeVideo: string | null = null; let traceStartedAt = 0; -let observersInstalled = false; +const traceContexts: PlaybackTraceContext[] = []; + +export type PlaybackTraceContext = { traceId: string; startedAt: number }; export type PlaybackRequestTrace = { init: RequestInit; traceId?: string; path?: string; startedAt?: number; + context?: PlaybackTraceContext; }; function enabled(): boolean { @@ -26,18 +33,36 @@ function enabled(): boolean { } function newTraceId(): string { - return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`; + return ( + globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` + ); } function normalizeResourcePath(path: string): string { return sanitizeRequestPath(path).replace(/\/sabr\/playback\/[^/]+/g, "/sabr/playback/{session}"); } -export function playbackTraceEvent(event: string, details: Record = {}): void { - if (!enabled() || !activeTraceId) return; +export function currentPlaybackTraceContext(): PlaybackTraceContext | null { + return enabled() && activeTraceId ? { traceId: activeTraceId, startedAt: traceStartedAt } : null; +} + +function playbackTraceContextAt(startedAt: number): PlaybackTraceContext | null { + for (let index = traceContexts.length - 1; index >= 0; index--) { + const context = traceContexts[index]; + if (context && context.startedAt <= startedAt) return context; + } + return null; +} + +export function playbackTraceEvent( + event: string, + details: Record = {}, + context = currentPlaybackTraceContext(), +): void { + if (!enabled() || !context) return; recordClientEvent(`playback.${event}`, { - traceId: activeTraceId, - elapsedMs: Math.round(performance.now() - traceStartedAt), + traceId: context.traceId, + elapsedMs: Math.round(performance.now() - context.startedAt), ...details, }); } @@ -53,8 +78,14 @@ export function beginPlaybackTrace( activeTraceId = newTraceId(); activeVideo = video; traceStartedAt = performance.now(); - installPerformanceObservers(); - playbackTraceEvent("trace_start", { video, source, timeOrigin: Math.round(performance.timeOrigin) }); + traceContexts.push({ traceId: activeTraceId, startedAt: traceStartedAt }); + if (traceContexts.length > 16) traceContexts.shift(); + installPlaybackPerformanceObservers(playbackTraceContextAt, playbackTraceEvent); + playbackTraceEvent("trace_start", { + video, + source, + timeOrigin: Math.round(performance.timeOrigin), + }); } return activeTraceId; } @@ -69,7 +100,10 @@ export function addPlaybackTraceHeader(headers: Headers): Headers { return headers; } -export function preparePlaybackApiRequest(url: string, init: RequestInit = {}): PlaybackRequestTrace { +export function preparePlaybackApiRequest( + url: string, + init: RequestInit = {}, +): PlaybackRequestTrace { if (!enabled() || typeof window === "undefined") return { init }; let parsed: URL; try { @@ -77,123 +111,50 @@ export function preparePlaybackApiRequest(url: string, init: RequestInit = {}): } catch { return { init }; } - if (parsed.origin !== window.location.origin || !WATCH_API_PATHS.some((path) => parsed.pathname.includes(path))) { + if ( + parsed.origin !== window.location.origin || + !WATCH_API_PATHS.some((path) => parsed.pathname.includes(path)) + ) { return { init }; } const sourceUrl = parsed.searchParams.get("url"); - const traceId = activeTraceId ?? (sourceUrl ? beginPlaybackTrace(sourceUrl, "api_request") : null); + const traceId = + activeTraceId ?? (sourceUrl ? beginPlaybackTrace(sourceUrl, "api_request") : null); if (!traceId) return { init }; + const context = currentPlaybackTraceContext(); + if (!context) return { init }; const headers = addPlaybackTraceHeader(new Headers(init.headers)); const path = normalizeResourcePath(parsed.pathname); const method = init.method ?? "GET"; - playbackTraceEvent("api_start", { method, path }); - return { init: { ...init, headers }, traceId, path, startedAt: performance.now() }; + playbackTraceEvent("api_start", { method, path }, context); + return { init: { ...init, headers }, traceId, path, startedAt: performance.now(), context }; } -export function finishPlaybackApiRequest(trace: PlaybackRequestTrace, status: number, outcome = "ok"): void { - if (!trace.traceId || !trace.path || trace.startedAt === undefined) return; - playbackTraceEvent("api_end", { - method: trace.init.method ?? "GET", - path: trace.path, - status, - outcome, - durationMs: Math.round(performance.now() - trace.startedAt), - }); -} - -export function observePlaybackVideo(video: HTMLVideoElement, videoKey: string): () => void { - if (!enabled()) return () => undefined; - const events = ["loadedmetadata", "loadeddata", "canplay", "playing", "waiting", "stalled", "seeking", "seeked", "error"]; - let firstFrame = false; - const onEvent = (event: Event) => { - const ranges: number[] = []; - for (let i = 0; i < video.buffered.length; i++) ranges.push(Math.max(0, video.buffered.end(i) - video.currentTime)); - playbackTraceEvent(`video_${event.type}`, { - video: videoKey, - readyState: video.readyState, - networkState: video.networkState, - currentTimeMs: Math.round(video.currentTime * 1000), - bufferedAheadMs: Math.round(Math.max(0, ...ranges) * 1000), - width: video.videoWidth, - height: video.videoHeight, - textTrackCount: video.textTracks.length, - visibleTextTracks: Array.from(video.textTracks).filter((track) => track.mode === "showing").length, - }); - }; - for (const event of events) video.addEventListener(event, onEvent); - const frameVideo = video as HTMLVideoElement & { - requestVideoFrameCallback?: (callback: (now: number, metadata: { presentedFrames?: number }) => void) => number; - cancelVideoFrameCallback?: (id: number) => void; - }; - const frameId = frameVideo.requestVideoFrameCallback?.((now, metadata) => { - firstFrame = true; - playbackTraceEvent("first_frame", { - video: videoKey, - frameCallbackMs: Math.round(now), - presentedFrames: metadata.presentedFrames ?? 0, - currentTimeMs: Math.round(video.currentTime * 1000), - }); - }); - const onFirstData = () => { - if (firstFrame) return; - playbackTraceEvent("first_media_data", { video: videoKey, readyState: video.readyState }); - }; - video.addEventListener("loadeddata", onFirstData, { once: true }); - playbackTraceEvent("video_attached", { video: videoKey, textTrackCount: video.textTracks.length }); - return () => { - for (const event of events) video.removeEventListener(event, onEvent); - video.removeEventListener("loadeddata", onFirstData); - if (frameId !== undefined) frameVideo.cancelVideoFrameCallback?.(frameId); - }; -} - -function installPerformanceObservers(): void { - if (observersInstalled || typeof PerformanceObserver === "undefined") return; - observersInstalled = true; - try { - new PerformanceObserver((list) => { - for (const entry of list.getEntries()) { - const task = entry as PerformanceEntry & { attribution?: Array<{ name?: string }> }; - playbackTraceEvent("long_task", { - durationMs: Math.round(entry.duration), - startMs: Math.round(entry.startTime), - attribution: task.attribution?.[0]?.name ?? "unknown", - }); - } - }).observe({ type: "longtask", buffered: true }); - } catch { /* Long-task timing is not supported in every browser. */ } - try { - new PerformanceObserver((list) => { - for (const entry of list.getEntries() as PerformanceResourceTiming[]) { - if (entry.startTime < traceStartedAt) continue; - let url: URL; - try { url = new URL(entry.name); } catch { continue; } - if (url.origin !== window.location.origin || !url.pathname.includes("/sabr/playback/")) continue; - playbackTraceEvent("sabr_resource", { - path: normalizeResourcePath(url.pathname), - durationMs: Math.round(entry.duration), - ttfbMs: Math.round(Math.max(0, entry.responseStart - entry.requestStart)), - downloadMs: Math.round(Math.max(0, entry.responseEnd - entry.responseStart)), - encodedBytes: entry.encodedBodySize, - transferBytes: entry.transferSize, - }); - } - }).observe({ type: "resource", buffered: true }); - } catch { /* Resource timing may be restricted by browser policy. */ } +export function finishPlaybackApiRequest( + trace: PlaybackRequestTrace, + status: number, + outcome = "ok", +): void { + if (!trace.traceId || !trace.path || trace.startedAt === undefined || !trace.context) return; + playbackTraceEvent( + "api_end", + { + method: trace.init.method ?? "GET", + path: trace.path, + status, + outcome, + durationMs: Math.round(performance.now() - trace.startedAt), + }, + trace.context, + ); } -function installWatchClickCapture(): void { - if (typeof document === "undefined") return; - document.addEventListener("click", (event) => { - if (!enabled()) return; - const target = event.target instanceof Element ? event.target.closest("a[href]") : null; - if (!(target instanceof HTMLAnchorElement)) return; - let url: URL; - try { url = new URL(target.href, window.location.href); } catch { return; } - if (url.pathname !== "/watch") return; - const source = url.searchParams.get("v"); - if (source) beginPlaybackTrace(toWatchSourceUrl(source), "watch_click", true); - }, true); +export function observePlaybackVideo( + video: HTMLVideoElement, + videoKey: string, + context = currentPlaybackTraceContext(), +): () => void { + return observePlaybackVideoEvents(video, videoKey, context, enabled, playbackTraceEvent); } -installWatchClickCapture(); +installPlaybackWatchClickCapture(enabled, beginPlaybackTrace); diff --git a/apps/web/src/lib/playback-video-trace.ts b/apps/web/src/lib/playback-video-trace.ts new file mode 100644 index 00000000..4d833042 --- /dev/null +++ b/apps/web/src/lib/playback-video-trace.ts @@ -0,0 +1,82 @@ +import type { PlaybackTraceContext } from "./playback-trace"; + +type TraceEventWriter = ( + event: string, + details?: Record, + context?: PlaybackTraceContext | null, +) => void; + +export function observePlaybackVideo( + video: HTMLVideoElement, + videoKey: string, + context: PlaybackTraceContext | null, + isEnabled: () => boolean, + emit: TraceEventWriter, +): () => void { + if (!isEnabled()) return () => undefined; + const events = [ + "loadedmetadata", + "loadeddata", + "canplay", + "playing", + "waiting", + "stalled", + "seeking", + "seeked", + "error", + ]; + let firstFrame = false; + const onEvent = (event: Event) => { + const ranges: number[] = []; + for (let index = 0; index < video.buffered.length; index++) { + ranges.push(Math.max(0, video.buffered.end(index) - video.currentTime)); + } + emit( + `video_${event.type}`, + { + video: videoKey, + readyState: video.readyState, + networkState: video.networkState, + currentTimeMs: Math.round(video.currentTime * 1000), + bufferedAheadMs: Math.round(Math.max(0, ...ranges) * 1000), + width: video.videoWidth, + height: video.videoHeight, + textTrackCount: video.textTracks.length, + visibleTextTracks: Array.from(video.textTracks).filter((track) => track.mode === "showing") + .length, + }, + context, + ); + }; + for (const event of events) video.addEventListener(event, onEvent); + const frameVideo = video as HTMLVideoElement & { + requestVideoFrameCallback?: ( + callback: (now: number, metadata: { presentedFrames?: number }) => void, + ) => number; + cancelVideoFrameCallback?: (id: number) => void; + }; + const frameId = frameVideo.requestVideoFrameCallback?.((now, metadata) => { + firstFrame = true; + emit( + "first_frame", + { + video: videoKey, + frameCallbackMs: Math.round(now), + presentedFrames: metadata.presentedFrames ?? 0, + currentTimeMs: Math.round(video.currentTime * 1000), + }, + context, + ); + }); + const onFirstData = () => { + if (firstFrame) return; + emit("first_media_data", { video: videoKey, readyState: video.readyState }, context); + }; + video.addEventListener("loadeddata", onFirstData, { once: true }); + emit("video_attached", { video: videoKey, textTrackCount: video.textTracks.length }, context); + return () => { + for (const event of events) video.removeEventListener(event, onEvent); + video.removeEventListener("loadeddata", onFirstData); + if (frameId !== undefined) frameVideo.cancelVideoFrameCallback?.(frameId); + }; +} From 7fc774c29718fe32e7703c0e519530cf727ab8c2 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 25 Sep 2026 03:24:50 +0000 Subject: [PATCH 092/149] perf: trace MSE playback startup phases --- apps/web/src/lib/sabr-mse-playback-trace.ts | 74 ++++++++++++++++++++- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/sabr-mse-playback-trace.ts b/apps/web/src/lib/sabr-mse-playback-trace.ts index 7290bfef..3a58547f 100644 --- a/apps/web/src/lib/sabr-mse-playback-trace.ts +++ b/apps/web/src/lib/sabr-mse-playback-trace.ts @@ -1,5 +1,9 @@ import type { TypeTypeMsePlayer } from "@typetype/mse"; -import { observePlaybackVideo, playbackTraceEvent } from "./playback-trace"; +import { + currentPlaybackTraceContext, + observePlaybackVideo, + playbackTraceEvent, +} from "./playback-trace"; export function observeSabrMsePlayback( video: HTMLVideoElement, @@ -8,14 +12,78 @@ export function observeSabrMsePlayback( reportError: (error: unknown, recoveryPositionMs?: number) => void, ): () => void { const videoKey = `youtube:${videoId}`; - const stopVideo = observePlaybackVideo(video, videoKey); + const context = currentPlaybackTraceContext(); + const stopVideo = observePlaybackVideo(video, videoKey, context); const offError = engine.on("error", (event) => { if (event.type !== "error") return; - playbackTraceEvent("mse_error", { video: videoKey, code: event.error?.name ?? "unknown" }); + playbackTraceEvent( + "mse_error", + { video: videoKey, code: event.error?.name ?? "unknown" }, + context, + ); reportError(event.error, event.recoveryPositionMs); }); + const offEvents = context + ? [ + engine.on("state", (event) => { + if (event.type !== "state") return; + playbackTraceEvent("mse_state", { video: videoKey, state: event.state }, context); + }), + engine.on("manifest", (event) => { + if (event.type !== "manifest") return; + playbackTraceEvent( + "mse_manifest", + { video: videoKey, generation: event.generation, segmentCount: event.segmentCount }, + context, + ); + }), + engine.on("quality", (event) => { + if (event.type !== "quality") return; + playbackTraceEvent( + "mse_quality", + { video: videoKey, videoItag: event.videoItag, audioItag: event.audioItag }, + context, + ); + }), + engine.on("segment", (event) => { + if (event.type !== "segment") return; + playbackTraceEvent( + "mse_segment_appended", + { + video: videoKey, + kind: event.kind, + phase: event.durationMs > 0 ? "media" : "init", + startMs: event.startMs, + durationMs: event.durationMs, + }, + context, + ); + }), + engine.on("buffer", (event) => { + if (event.type !== "buffer") return; + playbackTraceEvent( + "mse_buffer", + { + video: videoKey, + bufferedEndMs: event.bufferedEndMs, + currentTimeMs: event.currentTimeMs, + }, + context, + ); + }), + engine.on("seek", (event) => { + if (event.type !== "seek") return; + playbackTraceEvent( + "mse_seek", + { video: videoKey, positionMs: event.positionMs }, + context, + ); + }), + ] + : []; return () => { offError(); + for (const stop of offEvents) stop(); stopVideo(); }; } From 7caca84fc2bcfc237f94d59f227abda6b8a10259 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 25 Sep 2026 13:23:40 +0000 Subject: [PATCH 093/149] fix: restore cursor visibility in paused player --- apps/web/src/components/video-player.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/components/video-player.tsx b/apps/web/src/components/video-player.tsx index a89575d4..a035f0ce 100644 --- a/apps/web/src/components/video-player.tsx +++ b/apps/web/src/components/video-player.tsx @@ -98,7 +98,6 @@ export function VideoPlayer({ logLevel="warn" crossOrigin playsInline - hideControlsOnMouseLeave {...(ios ? { "webkit-playsinline": "true" } : {})} autoPlay={sabrConfig ? false : autoplay} storage={null} From 9a050618028834ff4ef5b76dc235dd91a48e749f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 25 Sep 2026 13:23:45 +0000 Subject: [PATCH 094/149] feat: add configurable video layout density --- apps/web/messages/de.json | 7 ++ apps/web/messages/en.json | 7 ++ apps/web/messages/fr.json | 7 ++ .../src/components/related-card-skeleton.tsx | 13 +++- apps/web/src/components/related-card.tsx | 7 +- apps/web/src/components/related-videos.tsx | 5 +- .../src/components/video-grid-skeleton.tsx | 5 +- apps/web/src/components/video-grid.tsx | 5 +- .../components/watch-secondary-content.tsx | 8 ++- apps/web/src/hooks/use-settings.ts | 2 + apps/web/src/lib/layout-preferences.ts | 20 ++++++ .../src/settings/settings-landing-page.tsx | 2 + apps/web/src/settings/settings-layout.tsx | 64 +++++++++++++++++++ apps/web/src/types/user.ts | 4 ++ apps/web/tests/layout-preferences.test.ts | 23 +++++++ 15 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/lib/layout-preferences.ts create mode 100644 apps/web/src/settings/settings-layout.tsx create mode 100644 apps/web/tests/layout-preferences.test.ts diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 70d71dbd..ac38e0ef 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -179,6 +179,13 @@ "settings_language_description": "Benutzeroberflächen-, Ton- und Untertitel- Einstellungen.", "settings_language_label": "Sprachen", "settings_language_section": "Sprache", + "settings_layout_label": "Layout", + "settings_layout_related_video_size": "Größe ähnlicher Videos", + "settings_layout_related_video_size_default": "Standard", + "settings_layout_related_video_size_description": "Bestimmt die Größe ähnlicher Video-Vorschaubilder auf der Wiedergabeseite.", + "settings_layout_related_video_size_large": "Groß", + "settings_layout_video_grid_columns": "Videos pro Zeile", + "settings_layout_video_grid_columns_description": "Legt die Spaltenzahl für Startseite, Abos und andere Videoraster fest.", "settings_playback_description": "Standardeinstellungen für Qualität, Geschwindigkeit, automatische Wiedergabe und „Nur Ton“.", "settings_playback_label": "Wiedergabe", "settings_privacy_description": "Wiedergabeverlauf, Sitzungen und Datenschutz des Kontos verwalten.", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 84ed7d07..597fec79 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -181,6 +181,13 @@ "settings_language_description": "Interface, audio, and subtitle preferences.", "settings_language_label": "Languages", "settings_language_section": "Language", + "settings_layout_label": "Layout", + "settings_layout_related_video_size": "Related video size", + "settings_layout_related_video_size_default": "Default", + "settings_layout_related_video_size_description": "Choose how large related video thumbnails appear on the watch page.", + "settings_layout_related_video_size_large": "Large", + "settings_layout_video_grid_columns": "Videos per row", + "settings_layout_video_grid_columns_description": "Sets the number of columns on home, subscription and other video grids.", "settings_playback_description": "Quality, speed, autoplay, and audio-only defaults.", "settings_playback_label": "Playback", "settings_privacy_description": "Control history, sessions, and account privacy.", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index d44022b5..b8912a7e 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -181,6 +181,13 @@ "settings_language_description": "Préférences d'interface, d'audio et de sous-titres.", "settings_language_label": "Langues", "settings_language_section": "Langue", + "settings_layout_label": "Disposition", + "settings_layout_related_video_size": "Taille des vidéos associées", + "settings_layout_related_video_size_default": "Par défaut", + "settings_layout_related_video_size_description": "Choisit la taille des vignettes associées sur la page de lecture.", + "settings_layout_related_video_size_large": "Grande", + "settings_layout_video_grid_columns": "Vidéos par ligne", + "settings_layout_video_grid_columns_description": "Définit le nombre de colonnes sur l'accueil, les abonnements et les autres grilles.", "settings_playback_description": "Qualité, vitesse, lecture automatique et audio uniquement.", "settings_playback_label": "Lecture", "settings_privacy_description": "Contrôlez l'historique, les sessions et la confidentialité du compte.", diff --git a/apps/web/src/components/related-card-skeleton.tsx b/apps/web/src/components/related-card-skeleton.tsx index 592c9770..2d836307 100644 --- a/apps/web/src/components/related-card-skeleton.tsx +++ b/apps/web/src/components/related-card-skeleton.tsx @@ -1,7 +1,16 @@ -export function RelatedCardSkeleton() { +import { relatedVideoThumbnailClassName } from "../lib/layout-preferences"; +import type { RelatedVideoSize } from "../types/user"; + +type Props = { + size?: RelatedVideoSize; +}; + +export function RelatedCardSkeleton({ size = "default" }: Props) { return (
    -
    +
    diff --git a/apps/web/src/components/related-card.tsx b/apps/web/src/components/related-card.tsx index a7066731..b0896fdd 100644 --- a/apps/web/src/components/related-card.tsx +++ b/apps/web/src/components/related-card.tsx @@ -3,6 +3,7 @@ import { memo } from "react"; import { useClientLocale } from "../hooks/use-client-locale"; import { useDeArrowBranding } from "../hooks/use-dearrow"; import { formatDuration, formatPublishedDate, formatViews } from "../lib/format"; +import { relatedVideoThumbnailClassName } from "../lib/layout-preferences"; import { isVideoWatched } from "../lib/watch-progress"; import { watchRouteSearch } from "../lib/watch-url"; import { useWatchNavigationStore } from "../stores/watch-navigation-store"; @@ -19,11 +20,13 @@ import { WatchedBadge } from "./watched-badge"; type Props = { stream: VideoStream; relatedStreams?: VideoStream[]; + size?: "default" | "large"; progressMs?: number; }; -function RelatedCardComponent({ stream, relatedStreams, progressMs = 0 }: Props) { +function RelatedCardComponent({ stream, relatedStreams, size = "default", progressMs = 0 }: Props) { const locale = useClientLocale(); + const thumbnailClassName = relatedVideoThumbnailClassName(size); const setNavigation = useWatchNavigationStore((state) => state.setNavigation); const { title, thumbnail } = useDeArrowBranding( stream.id, @@ -42,7 +45,7 @@ function RelatedCardComponent({ stream, relatedStreams, progressMs = 0 }: Props) to="/watch" search={watchRouteSearch(stream.id)} preload="intent" - className="relative w-32 aspect-video rounded-md overflow-hidden bg-surface-strong flex-shrink-0 sm:w-40" + className={`relative ${thumbnailClassName} aspect-video rounded-md overflow-hidden bg-surface-strong flex-shrink-0`} onClick={() => setNavigation(stream, relatedStreams)} > uniqueStreams(filter(streams)), [filter, streams]); const progressByUrl = useVideoProgressMap(visible); @@ -33,7 +35,7 @@ export function RelatedVideos({ streams, isLoading = false }: Props) {
    {isLoading - ? SKELETON_KEYS.map((k) => ) + ? SKELETON_KEYS.map((k) => ) : visible.map((stream, index) => (
    diff --git a/apps/web/src/components/video-grid-skeleton.tsx b/apps/web/src/components/video-grid-skeleton.tsx index 659adf60..9fed70ac 100644 --- a/apps/web/src/components/video-grid-skeleton.tsx +++ b/apps/web/src/components/video-grid-skeleton.tsx @@ -1,3 +1,5 @@ +import { useSettings } from "../hooks/use-settings"; +import { videoGridClassName } from "../lib/layout-preferences"; import { VideoCardSkeleton } from "./video-card-skeleton"; const DEFAULT_COUNT = 12; @@ -8,9 +10,10 @@ type Props = { }; export function VideoGridSkeleton({ count = DEFAULT_COUNT, idPrefix = "video-grid" }: Props) { + const { settings } = useSettings(); const keys = Array.from({ length: count }, (_, index) => `${idPrefix}-${index}`); return ( -
    +
    {keys.map((key) => ( ))} diff --git a/apps/web/src/components/video-grid.tsx b/apps/web/src/components/video-grid.tsx index 19f91cab..aa1b92da 100644 --- a/apps/web/src/components/video-grid.tsx +++ b/apps/web/src/components/video-grid.tsx @@ -1,5 +1,7 @@ import { useMemo } from "react"; import { useVideoProgressMap } from "../hooks/use-progress"; +import { useSettings } from "../hooks/use-settings"; +import { videoGridClassName } from "../lib/layout-preferences"; import { videoProgressUrl } from "../lib/video-progress"; import type { VideoStream } from "../types/stream"; import { VideoCard } from "./video-card"; @@ -12,6 +14,7 @@ type VideoGridProps = { }; export function VideoGrid({ streams, onCardOpen, onCardImpression, listId }: VideoGridProps) { + const { settings } = useSettings(); const unique = useMemo(() => { const seen = new Set(); const result: VideoStream[] = []; @@ -24,7 +27,7 @@ export function VideoGrid({ streams, onCardOpen, onCardImpression, listId }: Vid }, [streams]); const progressByUrl = useVideoProgressMap(unique); return ( -
    +
    {unique.map((stream, index) => (
    0; + const panelClassName = `w-full lg:flex-1 ${relatedVideoPanelClassName(settings.relatedVideoSize)} flex flex-col gap-6`; if (!cinemaMode) { if (!(hasPlaylistPanel || hasRelatedStreams)) return null; return ( -
    +
    {playlistPanel} {hasRelatedStreams && }
    @@ -48,7 +52,7 @@ export function WatchSecondaryContent({ />
    {(hasPlaylistPanel || hasRelatedStreams) && ( -
    +
    {playlistPanel} {hasRelatedStreams && }
    diff --git a/apps/web/src/hooks/use-settings.ts b/apps/web/src/hooks/use-settings.ts index 58299217..5edffe25 100644 --- a/apps/web/src/hooks/use-settings.ts +++ b/apps/web/src/hooks/use-settings.ts @@ -74,6 +74,8 @@ const DEFAULTS: SettingsItem = { autoplayCountdownSeconds: 10, skipPlaylistAutoplayScreen: false, audioOnlyPlayback: false, + videoGridColumns: 0, + relatedVideoSize: "default", volume: 1, muted: false, notificationPopupsEnabled: true, diff --git a/apps/web/src/lib/layout-preferences.ts b/apps/web/src/lib/layout-preferences.ts new file mode 100644 index 00000000..80e111fb --- /dev/null +++ b/apps/web/src/lib/layout-preferences.ts @@ -0,0 +1,20 @@ +import type { RelatedVideoSize, VideoGridColumns } from "../types/user"; + +const GRID_BY_COLUMNS: Record = { + 0: "grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 sm:gap-y-8 md:grid-cols-3 lg:grid-cols-4", + 4: "grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 sm:gap-y-8 md:grid-cols-3 lg:grid-cols-4", + 5: "grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 sm:gap-y-8 md:grid-cols-3 lg:grid-cols-5", + 6: "grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 sm:gap-y-8 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6", +}; + +export function videoGridClassName(columns: VideoGridColumns): string { + return GRID_BY_COLUMNS[columns] ?? GRID_BY_COLUMNS[0]; +} + +export function relatedVideoThumbnailClassName(size: RelatedVideoSize): string { + return size === "large" ? "w-40 sm:w-48" : "w-32 sm:w-40"; +} + +export function relatedVideoPanelClassName(size: RelatedVideoSize): string { + return size === "large" ? "lg:min-w-80" : "lg:min-w-64"; +} diff --git a/apps/web/src/settings/settings-landing-page.tsx b/apps/web/src/settings/settings-landing-page.tsx index 3fb7327f..6a083cb2 100644 --- a/apps/web/src/settings/settings-landing-page.tsx +++ b/apps/web/src/settings/settings-landing-page.tsx @@ -1,6 +1,7 @@ import { useSettings } from "../hooks/use-settings"; import { m } from "../paraglide/messages.js"; import { SettingsDiscoveryToggles } from "./settings-content-toggles"; +import { SettingsLayoutPreferences } from "./settings-layout"; import { ROW, ToggleSwitch } from "./settings-toggle-switch"; function landingOptions() { @@ -42,6 +43,7 @@ export function SettingsLandingPage() { ))}
    +

    {m.ui_notifications()}

    diff --git a/apps/web/src/settings/settings-layout.tsx b/apps/web/src/settings/settings-layout.tsx new file mode 100644 index 00000000..30933816 --- /dev/null +++ b/apps/web/src/settings/settings-layout.tsx @@ -0,0 +1,64 @@ +import { useSettings } from "../hooks/use-settings"; +import { m } from "../paraglide/messages.js"; +import { ROW } from "./settings-toggle-switch"; + +const SECTION_LABEL = "px-1 text-xs font-medium text-fg-soft uppercase tracking-wider"; +const GROUP = "divide-y divide-border border-y border-border"; +const SELECT = + "typetype-adaptive-control h-9 w-full max-w-full rounded-sm border border-border-strong bg-app px-2.5 text-sm text-fg sm:w-48"; + +export function SettingsLayoutPreferences() { + const { settings, update } = useSettings(); + + return ( +
    +

    {m.settings_layout_label()}

    +
    +
    +
    + {m.settings_layout_video_grid_columns()} + + {m.settings_layout_video_grid_columns_description()} + +
    + +
    +
    +
    + {m.settings_layout_related_video_size()} + + {m.settings_layout_related_video_size_description()} + +
    + +
    +
    +
    + ); +} diff --git a/apps/web/src/types/user.ts b/apps/web/src/types/user.ts index 41efb4d0..9ab41d27 100644 --- a/apps/web/src/types/user.ts +++ b/apps/web/src/types/user.ts @@ -101,6 +101,8 @@ export type CaptionStyles = { displayBgOpacity: string; }; +export type VideoGridColumns = 0 | 4 | 5 | 6; +export type RelatedVideoSize = "default" | "large"; export type SettingsItem = { defaultService: ServiceId; defaultLandingPage: string; @@ -111,6 +113,8 @@ export type SettingsItem = { autoplayCountdownSeconds: number; skipPlaylistAutoplayScreen: boolean; audioOnlyPlayback: boolean; + videoGridColumns: VideoGridColumns; + relatedVideoSize: RelatedVideoSize; volume: number; muted: boolean; notificationPopupsEnabled: boolean; diff --git a/apps/web/tests/layout-preferences.test.ts b/apps/web/tests/layout-preferences.test.ts new file mode 100644 index 00000000..3e12a78b --- /dev/null +++ b/apps/web/tests/layout-preferences.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { + relatedVideoPanelClassName, + relatedVideoThumbnailClassName, + videoGridClassName, +} from "../src/lib/layout-preferences"; + +test("auto and four-column grids keep the existing responsive layout", () => { + expect(videoGridClassName(0)).toContain("lg:grid-cols-4"); + expect(videoGridClassName(4)).toContain("lg:grid-cols-4"); +}); + +test("five and six-column grids increase the desktop density", () => { + expect(videoGridClassName(5)).toContain("lg:grid-cols-5"); + expect(videoGridClassName(6)).toContain("xl:grid-cols-6"); +}); + +test("large related videos use wider thumbnails and a wider panel", () => { + expect(relatedVideoThumbnailClassName("default")).toBe("w-32 sm:w-40"); + expect(relatedVideoThumbnailClassName("large")).toBe("w-40 sm:w-48"); + expect(relatedVideoPanelClassName("default")).toBe("lg:min-w-64"); + expect(relatedVideoPanelClassName("large")).toBe("lg:min-w-80"); +}); From 762fd07c18ffb53f26b8ee0e83edd612bb662a82 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 25 Sep 2026 13:23:49 +0000 Subject: [PATCH 095/149] chore: restore automatic dev validation --- .github/workflows/ci.yml | 7 +++---- .github/workflows/coverage.yml | 10 ++++------ .github/workflows/docker.yml | 7 +++---- .github/workflows/localization.yml | 9 ++++----- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c9db148..4517dfbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,9 @@ name: CI on: - # Temporarily pause automatic validation during SABR startup optimization. - # pull_request: - # branches: ["dev", "main"] - # workflow_call: + pull_request: + branches: ["dev", "main"] + workflow_call: workflow_dispatch: env: diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 8b24399a..799573da 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,12 +1,10 @@ name: Coverage on: - # Temporarily pause automatic test coverage during SABR startup optimization. - # push: - # branches: ["dev"] - # pull_request: - # branches: ["dev", "main"] - workflow_dispatch: {} + push: + branches: ["dev"] + pull_request: + branches: ["dev", "main"] env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9ad34553..dc0b5317 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -14,12 +14,11 @@ concurrency: cancel-in-progress: true jobs: - # Temporarily bypass automatic test/CI validation for Beta image builds. - # verify: - # uses: ./.github/workflows/ci.yml + verify: + uses: ./.github/workflows/ci.yml prepare: - # needs: verify + needs: verify runs-on: [self-hosted, Linux, X64, ci-stack] permissions: contents: read diff --git a/.github/workflows/localization.yml b/.github/workflows/localization.yml index f838edc0..e64a35c7 100644 --- a/.github/workflows/localization.yml +++ b/.github/workflows/localization.yml @@ -1,11 +1,10 @@ name: Localization on: - # Temporarily pause automatic checks during SABR startup optimization. - # push: - # branches: ["dev"] - # pull_request: - # branches: ["dev", "main"] + push: + branches: ["dev"] + pull_request: + branches: ["dev", "main"] workflow_dispatch: env: From e26f63e542e48232178b590cba25daaac0528192 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 25 Sep 2026 13:25:35 +0000 Subject: [PATCH 096/149] style: format frontend playback diagnostics --- apps/web/src/components/sabr-mse-player.tsx | 2 +- apps/web/src/lib/api-sabr-prewarm.ts | 7 ++++++- apps/web/src/lib/api.ts | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/sabr-mse-player.tsx b/apps/web/src/components/sabr-mse-player.tsx index 6f15b3ec..cc831e36 100644 --- a/apps/web/src/components/sabr-mse-player.tsx +++ b/apps/web/src/components/sabr-mse-player.tsx @@ -8,8 +8,8 @@ import { useSabrModeSwitch } from "../hooks/use-sabr-mode-switch"; import { useSabrQualitySwitch } from "../hooks/use-sabr-quality-switch"; import { toAbsoluteApiUrl } from "../lib/env"; import { addPlaybackTraceHeader } from "../lib/playback-trace"; -import { observeSabrMsePlayback } from "../lib/sabr-mse-playback-trace"; import { guardAutoplay, SabrAutoplayAttempt, SabrAutoplayDeadline } from "../lib/sabr-autoplay"; +import { observeSabrMsePlayback } from "../lib/sabr-mse-playback-trace"; import * as sabrNativePause from "../lib/sabr-native-pause"; import { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference"; import { isAbortError } from "../lib/sabr-playback-retry"; diff --git a/apps/web/src/lib/api-sabr-prewarm.ts b/apps/web/src/lib/api-sabr-prewarm.ts index 17d2e46e..57034ed7 100644 --- a/apps/web/src/lib/api-sabr-prewarm.ts +++ b/apps/web/src/lib/api-sabr-prewarm.ts @@ -20,7 +20,12 @@ export async function prewarmSabrPlayback( const url = toAbsoluteApiUrl(`/sabr/playback/${encodeURIComponent(config.videoId)}?${params}`); const headers = new Headers(); if (token) headers.set("Authorization", `Bearer ${token}`); - const trace = preparePlaybackApiRequest(url, { method: "POST", headers, signal, cache: "no-store" }); + const trace = preparePlaybackApiRequest(url, { + method: "POST", + headers, + signal, + cache: "no-store", + }); let response: Response; try { response = await fetch(url, trace.init); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 02294cbe..bb3678a1 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -7,8 +7,8 @@ import { recordApiError } from "./api-error-log"; import { extractRequestId, recordClientEvent } from "./client-debug-log"; import { sanitizeDebugText, sanitizeRequestPath } from "./debug-sanitize"; import { API_BASE as BASE } from "./env"; -import { finishPlaybackApiRequest, preparePlaybackApiRequest } from "./playback-trace"; import { optionalBearer } from "./optional-bearer"; +import { finishPlaybackApiRequest, preparePlaybackApiRequest } from "./playback-trace"; import { normalizeApiPayload } from "./text-normalize"; export class ApiError extends Error { From 5d5198beeeba7844001753d052b9ec0786863e9e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 25 Sep 2026 13:27:11 +0000 Subject: [PATCH 097/149] refactor: keep playback trace helper internal --- apps/web/src/lib/playback-trace.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/playback-trace.ts b/apps/web/src/lib/playback-trace.ts index fa00ba21..81943e6d 100644 --- a/apps/web/src/lib/playback-trace.ts +++ b/apps/web/src/lib/playback-trace.ts @@ -90,7 +90,7 @@ export function beginPlaybackTrace( return activeTraceId; } -export function currentPlaybackTraceId(): string | null { +function currentPlaybackTraceId(): string | null { return enabled() ? activeTraceId : null; } From 68487f1ef75f82fc5425cab29fca7281a83f6205 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 25 Sep 2026 13:34:04 +0000 Subject: [PATCH 098/149] ci: keep dev frontend builds amd64 only --- .github/workflows/docker.yml | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index dc0b5317..53344718 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -28,6 +28,7 @@ jobs: image: ${{ steps.info.outputs.image }} labels: ${{ steps.meta.outputs.labels }} metadata-json: ${{ steps.meta.outputs.json }} + platform-matrix: ${{ steps.info.outputs.platform-matrix }} version: ${{ steps.info.outputs.version }} steps: - uses: actions/checkout@v7.0.1 @@ -55,6 +56,12 @@ jobs: echo "image=$image" echo "version=$version" } >> "$GITHUB_OUTPUT" + if [[ "$GITHUB_REF_NAME" == "dev" ]]; then + platform_matrix='{"include":[{"platform":"linux/amd64","arch":"amd64"}]}' + else + platform_matrix='{"include":[{"platform":"linux/amd64","arch":"amd64"},{"platform":"linux/arm64","arch":"arm64"}]}' + fi + echo "platform-matrix=$platform_matrix" >> "$GITHUB_OUTPUT" - name: Extract image metadata id: meta @@ -77,12 +84,7 @@ jobs: packages: write strategy: fail-fast: false - matrix: - include: - - platform: linux/amd64 - arch: amd64 - - platform: linux/arm64 - arch: arm64 + matrix: ${{ fromJSON(needs.prepare.outputs.platform-matrix) }} steps: - uses: actions/checkout@v7.0.1 @@ -174,8 +176,10 @@ jobs: METADATA_JSON: ${{ needs.prepare.outputs.metadata-json }} run: | mapfile -t digests < <(find "$DIGEST_DIR" -maxdepth 1 -type f -printf '%f\n' | sort) - if [[ "${#digests[@]}" -ne 2 ]]; then - echo "Expected two platform digests, found ${#digests[@]}" + expected_digests=2 + if [[ "$GITHUB_REF_NAME" == "dev" ]]; then expected_digests=1; fi + if [[ "${#digests[@]}" -ne "$expected_digests" ]]; then + echo "Expected $expected_digests platform digests, found ${#digests[@]}" exit 1 fi mapfile -t tags < <(jq -r '.tags[]' <<< "$METADATA_JSON") From 8872320a420a315b5757af14f2fd77897a24912d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 25 Sep 2026 13:47:36 +0000 Subject: [PATCH 099/149] feat: move service picker into navbar --- .../src/components/navbar-service-picker.tsx | 91 +++++++++++++++++++ apps/web/src/components/navbar.tsx | 2 + apps/web/src/components/sidebar.tsx | 68 ++++---------- apps/web/src/lib/service-options.ts | 34 +++++++ apps/web/tests/service-options.test.ts | 21 +++++ 5 files changed, 167 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/components/navbar-service-picker.tsx create mode 100644 apps/web/src/lib/service-options.ts create mode 100644 apps/web/tests/service-options.test.ts diff --git a/apps/web/src/components/navbar-service-picker.tsx b/apps/web/src/components/navbar-service-picker.tsx new file mode 100644 index 00000000..c78acb57 --- /dev/null +++ b/apps/web/src/components/navbar-service-picker.tsx @@ -0,0 +1,91 @@ +import { useNavigate, useRouterState } from "@tanstack/react-router"; +import { Check, ChevronDown } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useSettings } from "../hooks/use-settings"; +import { nextServiceRoute, SERVICE_OPTIONS } from "../lib/service-options"; +import { m } from "../paraglide/messages.js"; +import type { ServiceId } from "../types/user"; +import { ServiceIcon } from "./service-icon"; + +export function NavbarServicePicker() { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const navigate = useNavigate(); + const location = useRouterState({ select: (state) => state.location }); + const { settings, update } = useSettings(); + const active = + SERVICE_OPTIONS.find((service) => service.id === settings.defaultService) ?? SERVICE_OPTIONS[0]; + + useEffect(() => { + if (!open) return; + const close = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("pointerdown", close); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("pointerdown", close); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [open]); + + function selectService(service: ServiceId) { + update.mutate({ defaultService: service }); + setOpen(false); + const target = nextServiceRoute(location.pathname, location.searchStr, service); + if (target) navigate(target); + } + + return ( +
    + +
    + {SERVICE_OPTIONS.map((service) => { + const selected = service.id === settings.defaultService; + return ( + + ); + })} +
    +
    + ); +} diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index 7d859e71..4876b5ac 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -18,6 +18,7 @@ import { NavbarAccountControls } from "./navbar-account-controls"; import { NavbarLeadingControl } from "./navbar-leading-control"; import { NavbarNotifications } from "./navbar-notifications"; import { NavbarSearch } from "./navbar-search"; +import { NavbarServicePicker } from "./navbar-service-picker"; import { ServiceIcon } from "./service-icon"; import { Toast } from "./toast"; @@ -109,6 +110,7 @@ export function Navbar() { )} {canOpenSearch && !isMobile && } + {canOpenSearch && !isMobile && }
    diff --git a/apps/web/src/components/sidebar.tsx b/apps/web/src/components/sidebar.tsx index c2202575..4b71dbba 100644 --- a/apps/web/src/components/sidebar.tsx +++ b/apps/web/src/components/sidebar.tsx @@ -1,30 +1,17 @@ import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; -import { siBilibili, siNiconico, siYoutube } from "simple-icons"; import { useAuth } from "../hooks/use-auth"; import { useInterfaceLocale } from "../hooks/use-interface-locale"; import { useMobile } from "../hooks/use-mobile"; import { useSettings } from "../hooks/use-settings"; import { getStoredAdminSection } from "../lib/admin-console-section"; import { logoutSession } from "../lib/auth-session"; +import { nextServiceRoute, SERVICE_OPTIONS } from "../lib/service-options"; import { m } from "../paraglide/messages.js"; import { useUiStore } from "../stores/ui-store"; import type { ServiceId } from "../types/user"; import { NAV_ITEMS } from "./nav-items"; import { ServiceIcon } from "./service-icon"; -type Service = { - id: ServiceId; - label: string; - path: string; - color: string; -}; - -const SERVICES: Service[] = [ - { id: 0, label: "YouTube", path: siYoutube.path, color: "#FF0000" }, - { id: 6, label: "NicoNico", path: siNiconico.path, color: "#aaaaaa" }, - { id: 5, label: "BiliBili", path: siBilibili.path, color: "#00A1D6" }, -]; - const BTN_BASE = "flex h-10 w-full items-center border-l-2 transition-colors"; const BTN_ACTIVE = "border-accent text-fg"; const BTN_INACTIVE = "border-transparent text-fg-muted hover:border-border-strong hover:text-fg"; @@ -70,19 +57,8 @@ export function Sidebar({ overlay = false }: Props) { function handleServiceClick(id: ServiceId) { update.mutate({ defaultService: id }); if (isMobile) closeMobileSidebar(); - - if (loc.pathname === "/youtube-session" && id === 5) { - navigate({ to: "/bilibili-session" }); - return; - } - if (loc.pathname === "/bilibili-session" && id === 0) { - navigate({ to: "/youtube-session", search: { returnTo: undefined } }); - return; - } - - if (loc.pathname !== "/search") return; - const q = new URLSearchParams(loc.searchStr).get("q") ?? ""; - navigate({ to: "/search", search: { q, service: id } }); + const target = nextServiceRoute(loc.pathname, loc.searchStr, id); + if (target) navigate(target); } const adminSearch = { section: getStoredAdminSection() }; @@ -153,34 +129,28 @@ export function Sidebar({ overlay = false }: Props) { })}
    -
    - {(!visualCollapsed || isMobile) && ( + {isMobile && ( +

    {m.nav_services()}

    - )} - {SERVICES.map((svc) => ( - - ))} -
    + + ))} +
    + )} {isMobile && isAuthed && (
    {m.shell_support()} @@ -91,7 +91,7 @@ export function Navbar() { rel="noreferrer" aria-label="Lemmy" title="Lemmy" - className="inline-flex h-8 shrink-0 items-center gap-1.5 px-1 text-xs font-medium text-fg-muted hover:text-fg" + className="hidden h-8 shrink-0 items-center gap-1.5 px-1 text-xs font-medium text-fg-muted hover:text-fg sm:inline-flex" > Lemmy @@ -110,9 +110,9 @@ export function Navbar() { )} {canOpenSearch && !isMobile && } - {canOpenSearch && !isMobile && }
    + {canOpenSearch && } s.closeMobileSidebar); const visualCollapsed = overlay ? false : collapsed; const { isAdmin, isAuthed, signOut } = useAuth(); - const { settings, update } = useSettings(); - const service = settings.defaultService; - const navigate = useNavigate(); + const { settings } = useSettings(); const loc = useRouterState({ select: (s) => s.location }); - function handleServiceClick(id: ServiceId) { - update.mutate({ defaultService: id }); - if (isMobile) closeMobileSidebar(); - const target = nextServiceRoute(loc.pathname, loc.searchStr, id); - if (target) navigate(target); - } - const adminSearch = { section: getStoredAdminSection() }; const navItems = NAV_ITEMS.filter((item) => { if (item.adminOnly && !isAdmin) return false; @@ -129,28 +117,6 @@ export function Sidebar({ overlay = false }: Props) { })}
    - {isMobile && ( -
    -

    - {m.nav_services()} -

    - {SERVICE_OPTIONS.map((svc) => ( - - ))} -
    - )} {isMobile && isAuthed && (
    )} + {active && currentStage && ( +
    +
    + + {portabilityImportStageLabel(currentStage, job.progress?.category, locale)} + + + {job.progress?.stageTotal != null + ? m.portability_progress_of( + { + processed: (job.progress.stageProcessed ?? 0).toLocaleString(), + total: job.progress.stageTotal.toLocaleString(), + }, + { locale }, + ) + : (job.progress?.stageProcessed ?? 0).toLocaleString()} + +
    +
    +
    +
    +
    + )} {job.result && Object.keys(job.result).length > 0 && (
    {Object.entries(job.result).map(([category, count]) => ( diff --git a/apps/web/src/components/portability-progress-host.tsx b/apps/web/src/components/portability-progress-host.tsx new file mode 100644 index 00000000..feaa0e58 --- /dev/null +++ b/apps/web/src/components/portability-progress-host.tsx @@ -0,0 +1,194 @@ +import { AlertTriangle, CheckCircle2, LoaderCircle, X } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { useAuth } from "../hooks/use-auth"; +import { usePersistedPortabilityJob } from "../hooks/use-persisted-portability-job"; +import { usePortabilityJob } from "../hooks/use-portability-job"; +import type { PortabilityCategory, PortabilityJob } from "../lib/api-portability"; +import { m } from "../paraglide/messages.js"; +import { getLocale } from "../paraglide/runtime.js"; +import { portabilityImportStageLabel } from "./portability-job-status"; +import "../styles/notification-toast.css"; + +const REFRESH_INTERVAL_MS = 2_000; +const IMPORT_QUERY_KEYS = [ + ["subscriptions"], + ["playlists"], + ["history"], + ["history-all"], +] as const; + +function queryKeysForCategory(category: PortabilityCategory | null | undefined) { + if (category === "subscriptions") return [["subscriptions"]] as const; + if (category === "playlists") return [["playlists"]] as const; + if (category === "history") return [["history"], ["history-all"]] as const; + return []; +} + +function percent(job: PortabilityJob): number | null { + const progress = job.progress; + if (!progress?.total || progress.total <= 0) return null; + return Math.min(100, Math.round((progress.processed / progress.total) * 100)); +} + +export function PortabilityProgressHost() { + const { isAuthed } = useAuth(); + const queryClient = useQueryClient(); + const [jobId] = usePersistedPortabilityJob("typetype-portability-import-job"); + const job = usePortabilityJob(isAuthed ? jobId : null); + const [finished, setFinished] = useState(false); + const wasApplying = useRef(false); + const observedJobId = useRef(jobId); + const seenCheckpoint = useRef(0); + const refreshTimes = useRef(new Map()); + const refreshedOnTerminal = useRef(null); + const state = job.data?.state; + const active = isAuthed && job.data?.kind === "import" && state === "applying"; + const visible = isAuthed && (active || finished); + + useEffect(() => { + if (observedJobId.current !== jobId) { + observedJobId.current = jobId; + wasApplying.current = false; + seenCheckpoint.current = 0; + refreshTimes.current.clear(); + refreshedOnTerminal.current = null; + setFinished(false); + } + if (active) { + wasApplying.current = true; + setFinished(false); + return; + } + if (!wasApplying.current || !state || !["completed", "failed", "cancelled"].includes(state)) return; + wasApplying.current = false; + setFinished(true); + const timer = window.setTimeout(() => setFinished(false), 6_000); + return () => window.clearTimeout(timer); + }, [active, jobId, state]); + + useEffect(() => { + const checkpoint = job.data?.progress?.checkpoint ?? 0; + if (!active || checkpoint <= seenCheckpoint.current) return; + seenCheckpoint.current = checkpoint; + const category = job.data?.progress?.category; + if (!category) return; + const refreshedAt = refreshTimes.current.get(category) ?? 0; + if (Date.now() - refreshedAt < REFRESH_INTERVAL_MS) return; + refreshTimes.current.set(category, Date.now()); + void Promise.all( + queryKeysForCategory(category).map((queryKey) => + queryClient.invalidateQueries({ queryKey: [...queryKey] }), + ), + ); + }, [active, job.data?.progress?.category, job.data?.progress?.checkpoint, queryClient]); + + useEffect(() => { + const value = job.data; + if ( + value?.kind !== "import" || + !["completed", "failed", "cancelled"].includes(value.state) || + refreshedOnTerminal.current === value.id + ) { + return; + } + refreshedOnTerminal.current = value.id; + void Promise.all( + IMPORT_QUERY_KEYS.map((queryKey) => queryClient.invalidateQueries({ queryKey: [...queryKey] })), + ); + }, [job.data, queryClient]); + + useEffect(() => { + const root = document.documentElement; + if (visible) root.dataset.portabilityProgressVisible = "true"; + else delete root.dataset.portabilityProgressVisible; + return () => { + delete root.dataset.portabilityProgressVisible; + }; + }, [visible]); + + const value = job.data; + const progress = value?.progress; + const stage = progress?.stage; + const progressPercent = value ? percent(value) : null; + const locale = getLocale(); + + if (!value || !visible) return null; + const activeJob = value.state === "applying"; + const title = + value.state === "failed" + ? m.portability_job_failed({}, { locale }) + : value.state === "cancelled" + ? m.portability_job_cancelled({}, { locale }) + : value.state === "completed" + ? m.portability_job_import_completed({}, { locale }) + : m.portability_job_importing({}, { locale }); + const detail = stage + ? portabilityImportStageLabel(stage, progress?.category, locale) + : title; + const progressText = progress + ? progress.total != null + ? m.portability_progress_of( + { + processed: progress.processed.toLocaleString(), + total: progress.total.toLocaleString(), + }, + { locale }, + ) + : progress.processed.toLocaleString() + : null; + + return ( + + ); +} diff --git a/apps/web/src/hooks/use-persisted-portability-job.ts b/apps/web/src/hooks/use-persisted-portability-job.ts index cd77a1af..5ef3cc50 100644 --- a/apps/web/src/hooks/use-persisted-portability-job.ts +++ b/apps/web/src/hooks/use-persisted-portability-job.ts @@ -1,10 +1,25 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; + +function readJobId(storageKey: string): string | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage.getItem(storageKey); + } catch { + return null; + } +} export function usePersistedPortabilityJob( storageKey: string, ): [string | null, (jobId: string | null) => void] { - const [jobId, setJobId] = useState(() => - typeof window === "undefined" ? null : window.localStorage.getItem(storageKey), + const [jobId, setJobId] = useState(() => readJobId(storageKey)); + const eventName = `typetype:persisted-portability-job:${storageKey}`; + const updateJobId = useCallback( + (next: string | null) => { + setJobId(next); + window.dispatchEvent(new CustomEvent(eventName, { detail: next })); + }, + [eventName], ); useEffect(() => { @@ -12,5 +27,21 @@ export function usePersistedPortabilityJob( else window.localStorage.removeItem(storageKey); }, [jobId, storageKey]); - return [jobId, setJobId]; + useEffect(() => { + const onCustomChange = (event: Event) => { + const next = (event as CustomEvent).detail; + if (typeof next === "string" || next === null) setJobId(next); + }; + const onStorageChange = (event: StorageEvent) => { + if (event.key === storageKey) setJobId(readJobId(storageKey)); + }; + window.addEventListener(eventName, onCustomChange); + window.addEventListener("storage", onStorageChange); + return () => { + window.removeEventListener(eventName, onCustomChange); + window.removeEventListener("storage", onStorageChange); + }; + }, [eventName, storageKey]); + + return [jobId, updateJobId]; } diff --git a/apps/web/src/lib/api-portability.ts b/apps/web/src/lib/api-portability.ts index ef57ceed..cfb93026 100644 --- a/apps/web/src/lib/api-portability.ts +++ b/apps/web/src/lib/api-portability.ts @@ -14,6 +14,13 @@ type PortabilityJobState = | "completed" | "failed" | "cancelled"; +export type PortabilityImportStage = + | "subscriptions" + | "recentHistory" + | "playlists" + | "history" + | "remaining"; + export type PortabilityCategory = | "subscriptions" | "subscriptionGroups" @@ -73,6 +80,11 @@ export type PortabilityJob = { unit: "records" | "categories" | "bytes"; processed: number; total: number | null; + category?: PortabilityCategory | null; + stage?: PortabilityImportStage | null; + stageProcessed?: number; + stageTotal?: number | null; + checkpoint?: number; } | null; errorCode: string | null; errorMessage: string | null; diff --git a/apps/web/src/styles/notification-toast.css b/apps/web/src/styles/notification-toast.css index a98fc824..7029ce9c 100644 --- a/apps/web/src/styles/notification-toast.css +++ b/apps/web/src/styles/notification-toast.css @@ -39,3 +39,11 @@ } } } + +.portability-progress-toast { + top: calc(4.25rem + env(safe-area-inset-top, 0px)); +} + +html[data-portability-progress-visible="true"] .notification-toast:not(.portability-progress-toast) { + top: calc(12.25rem + env(safe-area-inset-top, 0px)); +} From 159ebd001c9e27d3fb45337b769879abd6116eb9 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 26 Sep 2026 09:50:05 +0000 Subject: [PATCH 104/149] style: format Takeout progress UI --- .../src/components/portability-job-status.tsx | 10 ++++-- .../components/portability-progress-host.tsx | 36 +++++++++++-------- apps/web/src/styles/notification-toast.css | 3 +- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/portability-job-status.tsx b/apps/web/src/components/portability-job-status.tsx index e159f3ab..baed5a1a 100644 --- a/apps/web/src/components/portability-job-status.tsx +++ b/apps/web/src/components/portability-job-status.tsx @@ -1,6 +1,10 @@ import { AlertTriangle, Check, CheckCircle2, Copy, LoaderCircle, X } from "lucide-react"; import { useState } from "react"; -import type { PortabilityCategory, PortabilityImportStage, PortabilityJob } from "../lib/api-portability"; +import type { + PortabilityCategory, + PortabilityImportStage, + PortabilityJob, +} from "../lib/api-portability"; import { categoryLabel } from "../lib/portability-catalog"; import { m } from "../paraglide/messages.js"; import { getLocale, type Locale } from "../paraglide/runtime.js"; @@ -177,7 +181,9 @@ export function PortabilityJobStatus({ job, onCancel, cancelling, locale = getLo >
    diff --git a/apps/web/src/components/portability-progress-host.tsx b/apps/web/src/components/portability-progress-host.tsx index feaa0e58..31bfeae1 100644 --- a/apps/web/src/components/portability-progress-host.tsx +++ b/apps/web/src/components/portability-progress-host.tsx @@ -1,5 +1,5 @@ -import { AlertTriangle, CheckCircle2, LoaderCircle, X } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, CheckCircle2, LoaderCircle, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useAuth } from "../hooks/use-auth"; import { usePersistedPortabilityJob } from "../hooks/use-persisted-portability-job"; @@ -11,12 +11,7 @@ import { portabilityImportStageLabel } from "./portability-job-status"; import "../styles/notification-toast.css"; const REFRESH_INTERVAL_MS = 2_000; -const IMPORT_QUERY_KEYS = [ - ["subscriptions"], - ["playlists"], - ["history"], - ["history-all"], -] as const; +const IMPORT_QUERY_KEYS = [["subscriptions"], ["playlists"], ["history"], ["history-all"]] as const; function queryKeysForCategory(category: PortabilityCategory | null | undefined) { if (category === "subscriptions") return [["subscriptions"]] as const; @@ -60,7 +55,8 @@ export function PortabilityProgressHost() { setFinished(false); return; } - if (!wasApplying.current || !state || !["completed", "failed", "cancelled"].includes(state)) return; + if (!wasApplying.current || !state || !["completed", "failed", "cancelled"].includes(state)) + return; wasApplying.current = false; setFinished(true); const timer = window.setTimeout(() => setFinished(false), 6_000); @@ -94,7 +90,9 @@ export function PortabilityProgressHost() { } refreshedOnTerminal.current = value.id; void Promise.all( - IMPORT_QUERY_KEYS.map((queryKey) => queryClient.invalidateQueries({ queryKey: [...queryKey] })), + IMPORT_QUERY_KEYS.map((queryKey) => + queryClient.invalidateQueries({ queryKey: [...queryKey] }), + ), ); }, [job.data, queryClient]); @@ -123,9 +121,7 @@ export function PortabilityProgressHost() { : value.state === "completed" ? m.portability_job_import_completed({}, { locale }) : m.portability_job_importing({}, { locale }); - const detail = stage - ? portabilityImportStageLabel(stage, progress?.category, locale) - : title; + const detail = stage ? portabilityImportStageLabel(stage, progress?.category, locale) : title; const progressText = progress ? progress.total != null ? m.portability_progress_of( @@ -139,17 +135,27 @@ export function PortabilityProgressHost() { : null; return ( -
    -

    - {m.ui_notifications()} -

    -
    -
    -
    - {m.ui_notification_popups()} - - {m.ui_show_a_popup_when_a_subscribed_channel_publishes_a_video()} - -
    - - update.mutate({ notificationPopupsEnabled: !settings.notificationPopupsEnabled }) - } - /> -
    -
    diff --git a/apps/web/src/settings/settings-notifications.tsx b/apps/web/src/settings/settings-notifications.tsx new file mode 100644 index 00000000..5bd250c2 --- /dev/null +++ b/apps/web/src/settings/settings-notifications.tsx @@ -0,0 +1,103 @@ +import { Search } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useChannelNotificationPreferences } from "../hooks/use-channel-notification-preferences"; +import { useSettings } from "../hooks/use-settings"; +import { useSubscriptions } from "../hooks/use-subscriptions"; +import { proxyImage } from "../lib/proxy"; +import { m } from "../paraglide/messages.js"; +import { ROW, ToggleSwitch } from "./settings-toggle-switch"; + +export function SettingsNotifications() { + const { settings, update: updateSettings } = useSettings(); + const { query: subscriptionsQuery } = useSubscriptions(); + const preferences = useChannelNotificationPreferences(); + const [search, setSearch] = useState(""); + const subscriptions = subscriptionsQuery.data ?? []; + const preferenceByUrl = useMemo( + () => new Map((preferences.query.data ?? []).map((item) => [item.channelUrl, item.enabled])), + [preferences.query.data], + ); + const filtered = useMemo(() => { + const term = search.trim().toLowerCase(); + if (!term) return subscriptions; + return subscriptions.filter( + (item) => + item.name.toLowerCase().includes(term) || item.channelUrl.toLowerCase().includes(term), + ); + }, [search, subscriptions]); + + return ( +
    +

    + {m.ui_notifications()} +

    +
    +
    +
    + {m.ui_notification_popups()} + + {m.ui_show_a_popup_when_a_subscribed_channel_publishes_a_video()} + +
    + + updateSettings.mutate({ + notificationPopupsEnabled: !settings.notificationPopupsEnabled, + }) + } + /> +
    +
    +
    +
    +

    {m.settings_channel_notifications()}

    +

    + {m.settings_channel_notifications_description()} +

    +
    + +
    + {filtered.map((subscription) => ( +
    +
    + {subscription.avatarUrl ? ( + + ) : ( + + )} + {subscription.name} +
    + + preferences.update.mutate({ + channelUrl: subscription.channelUrl, + enabled: !(preferenceByUrl.get(subscription.channelUrl) ?? false), + }) + } + /> +
    + ))} + {!subscriptionsQuery.isLoading && filtered.length === 0 && ( +

    {m.ui_no_subscriptions_yet_2()}

    + )} +
    +
    +
    + ); +} From dfc9ebf3902dffbf87ca0f4969722670e1f010cb Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 26 Sep 2026 17:28:58 +0000 Subject: [PATCH 127/149] fix: align portability query invalidation --- apps/web/src/components/portability-progress-host.tsx | 2 +- apps/web/src/lib/subscription-queries.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/portability-progress-host.tsx b/apps/web/src/components/portability-progress-host.tsx index d1d77646..30bb6fa3 100644 --- a/apps/web/src/components/portability-progress-host.tsx +++ b/apps/web/src/components/portability-progress-host.tsx @@ -4,12 +4,12 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useAuth } from "../hooks/use-auth"; import { usePersistedPortabilityJob } from "../hooks/use-persisted-portability-job"; import { usePortabilityJob } from "../hooks/use-portability-job"; -import { SUBSCRIPTION_FEED_KEY } from "../hooks/use-subscription-feed"; import type { PortabilityCategory, PortabilityJob } from "../lib/api-portability"; import { getPortabilityPreparationProgress, subscribePortabilityPreparationProgress, } from "../lib/portability-preparation-progress"; +import { SUBSCRIPTION_FEED_KEY } from "../lib/subscription-queries"; import { m } from "../paraglide/messages.js"; import { getLocale } from "../paraglide/runtime.js"; import { portabilityImportStageLabel } from "./portability-job-status"; diff --git a/apps/web/src/lib/subscription-queries.ts b/apps/web/src/lib/subscription-queries.ts index 584e26b9..eb92404a 100644 --- a/apps/web/src/lib/subscription-queries.ts +++ b/apps/web/src/lib/subscription-queries.ts @@ -9,7 +9,7 @@ import type { SubscriptionItem } from "../types/user"; import { fetchSubscriptionFeed, fetchSubscriptions } from "./api-user"; const SUBSCRIPTIONS_KEY = ["subscriptions"]; -const SUBSCRIPTION_FEED_KEY = ["subscription-feed"]; +export const SUBSCRIPTION_FEED_KEY = ["subscription-feed"]; export const SUBSCRIPTION_GROUPS_KEY = ["subscription-groups"]; export const SUBSCRIPTION_GROUP_MEMBERSHIPS_KEY = ["subscription-group-memberships"]; const SUBSCRIPTION_STALE_MS = 5 * 60 * 1000; From 2d739bb1d80b3b03bf195190658e573b9df72eca Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 26 Sep 2026 19:29:40 +0200 Subject: [PATCH 128/149] fix: normalize notification channel keys --- apps/web/src/settings/settings-notifications.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/web/src/settings/settings-notifications.tsx b/apps/web/src/settings/settings-notifications.tsx index 5bd250c2..136d2711 100644 --- a/apps/web/src/settings/settings-notifications.tsx +++ b/apps/web/src/settings/settings-notifications.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from "react"; import { useChannelNotificationPreferences } from "../hooks/use-channel-notification-preferences"; import { useSettings } from "../hooks/use-settings"; import { useSubscriptions } from "../hooks/use-subscriptions"; +import { normalizeChannelUrl } from "../lib/channel-url"; import { proxyImage } from "../lib/proxy"; import { m } from "../paraglide/messages.js"; import { ROW, ToggleSwitch } from "./settings-toggle-switch"; @@ -14,7 +15,13 @@ export function SettingsNotifications() { const [search, setSearch] = useState(""); const subscriptions = subscriptionsQuery.data ?? []; const preferenceByUrl = useMemo( - () => new Map((preferences.query.data ?? []).map((item) => [item.channelUrl, item.enabled])), + () => + new Map( + (preferences.query.data ?? []).map((item) => [ + normalizeChannelUrl(item.channelUrl), + item.enabled, + ]), + ), [preferences.query.data], ); const filtered = useMemo(() => { @@ -82,12 +89,14 @@ export function SettingsNotifications() { {subscription.name}
    preferences.update.mutate({ channelUrl: subscription.channelUrl, - enabled: !(preferenceByUrl.get(subscription.channelUrl) ?? false), + enabled: !( + preferenceByUrl.get(normalizeChannelUrl(subscription.channelUrl)) ?? false + ), }) } /> From 8df6c9929167372b28b8d03f258436bcf56f61d9 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 26 Sep 2026 19:30:16 +0200 Subject: [PATCH 129/149] fix: keep notification query key private --- apps/web/src/hooks/use-channel-notification-preferences.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/hooks/use-channel-notification-preferences.ts b/apps/web/src/hooks/use-channel-notification-preferences.ts index 203b8c03..44276565 100644 --- a/apps/web/src/hooks/use-channel-notification-preferences.ts +++ b/apps/web/src/hooks/use-channel-notification-preferences.ts @@ -5,7 +5,7 @@ import { } from "../lib/api-notifications"; import { useAuth } from "./use-auth"; -export const CHANNEL_NOTIFICATION_PREFERENCES_KEY = ["channel-notification-preferences"] as const; +const CHANNEL_NOTIFICATION_PREFERENCES_KEY = ["channel-notification-preferences"] as const; export function useChannelNotificationPreferences() { const queryClient = useQueryClient(); From 9ae91acd86e131d3e2c25ddc6542d224145e445c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 26 Sep 2026 19:43:09 +0200 Subject: [PATCH 130/149] fix: stop polling stale portability jobs --- apps/web/src/components/portability-progress-host.tsx | 8 +++++++- apps/web/src/hooks/use-portability-job.ts | 7 +++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/portability-progress-host.tsx b/apps/web/src/components/portability-progress-host.tsx index d1d77646..633ef7b3 100644 --- a/apps/web/src/components/portability-progress-host.tsx +++ b/apps/web/src/components/portability-progress-host.tsx @@ -46,7 +46,7 @@ function percent(job: PortabilityJob): number | null { export function PortabilityProgressHost() { const { isAuthed, me } = useAuth(); const queryClient = useQueryClient(); - const [jobId] = usePersistedPortabilityJob("typetype-portability-import-job"); + const [jobId, setJobId] = usePersistedPortabilityJob("typetype-portability-import-job"); const preparation = useSyncExternalStore( subscribePortabilityPreparationProgress, getPortabilityPreparationProgress, @@ -67,6 +67,12 @@ export function PortabilityProgressHost() { (state === "queued" || state === "analyzing" || state === "applying"); const visible = isAuthed && (localPreparation !== null || active || finished); + useEffect(() => { + if (!job.missing || !jobId) return; + setJobId(null); + setFinished(false); + }, [job.missing, jobId, setJobId]); + useEffect(() => { if (observedJobId.current !== jobId) { observedJobId.current = jobId; diff --git a/apps/web/src/hooks/use-portability-job.ts b/apps/web/src/hooks/use-portability-job.ts index 9e1c162f..0df8acda 100644 --- a/apps/web/src/hooks/use-portability-job.ts +++ b/apps/web/src/hooks/use-portability-job.ts @@ -18,8 +18,11 @@ export function usePortabilityJob(id: string | null) { enabled: id !== null, retry: (failureCount, error) => !(error instanceof ApiError && error.status === 404) && failureCount < 2, - refetchInterval: (current) => - current.state.data && TERMINAL_STATES.has(current.state.data.state) ? false : 1_000, + refetchInterval: (current) => { + if (current.state.error instanceof ApiError && current.state.error.status === 404) + return false; + return current.state.data && TERMINAL_STATES.has(current.state.data.state) ? false : 1_000; + }, }); const cancel = useMutation({ mutationFn: () => cancelPortabilityJob(id as string), From 43633bcad6e28d9af08f16bb913a53b43d920c1d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 26 Sep 2026 19:44:28 +0200 Subject: [PATCH 131/149] test: lock stale portability job polling --- apps/web/src/hooks/use-portability-job.ts | 10 +++------ apps/web/src/lib/portability-job-polling.ts | 8 +++++++ .../web/tests/portability-job-polling.test.ts | 22 +++++++++++++++++++ 3 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/lib/portability-job-polling.ts create mode 100644 apps/web/tests/portability-job-polling.test.ts diff --git a/apps/web/src/hooks/use-portability-job.ts b/apps/web/src/hooks/use-portability-job.ts index 0df8acda..10c7b921 100644 --- a/apps/web/src/hooks/use-portability-job.ts +++ b/apps/web/src/hooks/use-portability-job.ts @@ -6,8 +6,7 @@ import { getPortabilityJob, type PortabilityJob, } from "../lib/api-portability"; - -const TERMINAL_STATES = new Set(["ready", "completed", "failed", "cancelled"]); +import { shouldPollPortabilityJob } from "../lib/portability-job-polling"; export function usePortabilityJob(id: string | null) { const queryClient = useQueryClient(); @@ -18,11 +17,8 @@ export function usePortabilityJob(id: string | null) { enabled: id !== null, retry: (failureCount, error) => !(error instanceof ApiError && error.status === 404) && failureCount < 2, - refetchInterval: (current) => { - if (current.state.error instanceof ApiError && current.state.error.status === 404) - return false; - return current.state.data && TERMINAL_STATES.has(current.state.data.state) ? false : 1_000; - }, + refetchInterval: (current) => + shouldPollPortabilityJob(current.state.error, current.state.data?.state) ? 1_000 : false, }); const cancel = useMutation({ mutationFn: () => cancelPortabilityJob(id as string), diff --git a/apps/web/src/lib/portability-job-polling.ts b/apps/web/src/lib/portability-job-polling.ts new file mode 100644 index 00000000..08641c15 --- /dev/null +++ b/apps/web/src/lib/portability-job-polling.ts @@ -0,0 +1,8 @@ +import { ApiError } from "./api"; + +const TERMINAL_STATES = new Set(["ready", "completed", "failed", "cancelled"]); + +export function shouldPollPortabilityJob(error: unknown, state: string | undefined): boolean { + if (error instanceof ApiError && error.status === 404) return false; + return state === undefined || !TERMINAL_STATES.has(state); +} diff --git a/apps/web/tests/portability-job-polling.test.ts b/apps/web/tests/portability-job-polling.test.ts new file mode 100644 index 00000000..b626770b --- /dev/null +++ b/apps/web/tests/portability-job-polling.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import { ApiError } from "../src/lib/api"; +import { shouldPollPortabilityJob } from "../src/lib/portability-job-polling"; + +test("polls a portability job while it is pending", () => { + expect(shouldPollPortabilityJob(undefined, undefined)).toBe(true); + expect(shouldPollPortabilityJob(undefined, "queued")).toBe(true); + expect(shouldPollPortabilityJob(undefined, "analyzing")).toBe(true); + expect(shouldPollPortabilityJob(undefined, "applying")).toBe(true); +}); + +test("stops polling terminal portability jobs", () => { + expect(shouldPollPortabilityJob(undefined, "ready")).toBe(false); + expect(shouldPollPortabilityJob(undefined, "completed")).toBe(false); + expect(shouldPollPortabilityJob(undefined, "failed")).toBe(false); + expect(shouldPollPortabilityJob(undefined, "cancelled")).toBe(false); +}); + +test("stops polling a missing portability job", () => { + expect(shouldPollPortabilityJob(new ApiError("Not found", 404), undefined)).toBe(false); + expect(shouldPollPortabilityJob(new ApiError("Unavailable", 503), "queued")).toBe(true); +}); From 7b65f535bc1c52071d78a111f8dc1f7191e4889a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 26 Sep 2026 17:48:19 +0000 Subject: [PATCH 132/149] fix: switch filter before deleting active group --- apps/web/src/hooks/use-group-manager.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/web/src/hooks/use-group-manager.ts b/apps/web/src/hooks/use-group-manager.ts index f2ab2c51..90db5c40 100644 --- a/apps/web/src/hooks/use-group-manager.ts +++ b/apps/web/src/hooks/use-group-manager.ts @@ -110,14 +110,11 @@ export function useGroupManager(groups: SubscriptionGroup[], groupsReady: boolea if (await actions.run(() => updateGroupMemberships(changes), m.sg_memberships_cleared())) clearSelection(); } else if (pending) { - if ( - (await actions.run( - () => deleteSubscriptionGroup(pending.id), - m.sg_group_deleted({ group: pending.name }), - )) && - activeFilter === pending.id - ) - changeFilter("all"); + if (activeFilter === pending.id) changeFilter("all"); + await actions.run( + () => deleteSubscriptionGroup(pending.id), + m.sg_group_deleted({ group: pending.name }), + ); } } From b2d06525eab391103ba6c09d16511a2c41ef7f28 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 26 Sep 2026 18:00:37 +0000 Subject: [PATCH 133/149] test: cover single-language audio defaults --- apps/web/src/components/player-defaults.tsx | 13 ++++++------- apps/web/src/components/player-language.ts | 12 ++++++++++++ apps/web/tests/player-language.test.ts | 21 +++++++++++++++++++++ 3 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 apps/web/tests/player-language.test.ts diff --git a/apps/web/src/components/player-defaults.tsx b/apps/web/src/components/player-defaults.tsx index 297e1116..aa386b5a 100644 --- a/apps/web/src/components/player-defaults.tsx +++ b/apps/web/src/components/player-defaults.tsx @@ -7,7 +7,11 @@ import { useMediaState, useVideoQualityOptions, } from "../lib/vidstack"; -import { includesOriginal, normalizeLanguageTag } from "./player-language"; +import { + hasMultipleLanguageTracks, + includesOriginal, + normalizeLanguageTag, +} from "./player-language"; const QUALITY_OPTIONS = { sort: "descending" } as const; @@ -114,12 +118,7 @@ export function PlayerDefaults({ : (audioOptions.find((option) => option.track.id === preferredDefaultAudioTrackId) ?? audioOptions.find((option) => option.track.id === originalAudioTrackId)); - const hasMultipleLanguages = - new Set( - audioOptions - .map((option) => normalizeLanguageTag(option.track.language)) - .filter(Boolean), - ).size > 1; + const hasMultipleLanguages = hasMultipleLanguageTracks(audioOptions); const missingOriginalByContract = forceOriginal && originalAudioTrackId === null; const missingOriginalByHeuristic = diff --git a/apps/web/src/components/player-language.ts b/apps/web/src/components/player-language.ts index 08fc13af..c2608844 100644 --- a/apps/web/src/components/player-language.ts +++ b/apps/web/src/components/player-language.ts @@ -8,3 +8,15 @@ export function includesOriginal(value: string | undefined): boolean { if (!value) return false; return value.toLowerCase().includes("original"); } + +export function hasMultipleLanguageTracks( + options: readonly { track: { language?: string | null } }[], +): boolean { + const languages = new Set(); + for (const option of options) { + const language = normalizeLanguageTag(option.track.language); + if (language) languages.add(language); + if (languages.size > 1) return true; + } + return false; +} diff --git a/apps/web/tests/player-language.test.ts b/apps/web/tests/player-language.test.ts new file mode 100644 index 00000000..4473c184 --- /dev/null +++ b/apps/web/tests/player-language.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test"; +import { hasMultipleLanguageTracks } from "../src/components/player-language"; + +test("distinguishes multiple bitrate tracks from multiple languages", () => { + expect( + hasMultipleLanguageTracks([{ track: { language: "fr-FR" } }, { track: { language: "fr-CA" } }]), + ).toBe(false); + expect( + hasMultipleLanguageTracks([{ track: { language: "fr-FR" } }, { track: { language: "en-US" } }]), + ).toBe(true); +}); + +test("ignores missing language tags", () => { + expect( + hasMultipleLanguageTracks([ + { track: { language: "fr-FR" } }, + { track: { language: null } }, + { track: {} }, + ]), + ).toBe(false); +}); From 1b2f4b29c7611e3ffb7c394a955bfc470bb1128d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 26 Sep 2026 18:00:38 +0000 Subject: [PATCH 134/149] fix: handle zero quality heights --- apps/web/src/components/player-defaults.tsx | 14 ++------------ apps/web/src/components/quality-selector.tsx | 20 +++----------------- apps/web/src/lib/player-quality.ts | 18 ++++++++++++++++++ apps/web/tests/player-quality.test.ts | 17 +++++++++++++++++ 4 files changed, 40 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/lib/player-quality.ts create mode 100644 apps/web/tests/player-quality.test.ts diff --git a/apps/web/src/components/player-defaults.tsx b/apps/web/src/components/player-defaults.tsx index ed5c18b9..0e534525 100644 --- a/apps/web/src/components/player-defaults.tsx +++ b/apps/web/src/components/player-defaults.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from "react"; import { normalizeDefaultPlaybackSpeed } from "../lib/playback-speed"; +import { qualityLabelHeight, qualityOptionHeight } from "../lib/player-quality"; import type { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference"; import { useAudioOptions, @@ -30,13 +31,6 @@ type PlaybackSpeedDefaultProps = { preference?: SabrPlaybackRatePreference; }; -function qualityLabelHeight(label: string): number | null { - const match = label.match(/(\d+)/); - if (!match) return null; - const height = Number(match[1]); - return Number.isFinite(height) ? height : null; -} - export function PlayerDefaults({ defaultQuality, defaultPlaybackSpeed, @@ -74,11 +68,7 @@ export function PlayerDefaults({ const defaultHeight = qualityLabelHeight(defaultQuality); const exactMatch = qualityOptions.find((o) => o.label === defaultQuality); const heightMatch = qualityOptions.find( - (o) => - defaultHeight !== null && - ((o.quality?.height && o.quality.height > 0 - ? o.quality.height - : qualityLabelHeight(o.label)) === defaultHeight), + (o) => defaultHeight !== null && qualityOptionHeight(o) === defaultHeight, ); const match = exactMatch ?? heightMatch; if (!match) return; diff --git a/apps/web/src/components/quality-selector.tsx b/apps/web/src/components/quality-selector.tsx index 24db4adc..c9940ea7 100644 --- a/apps/web/src/components/quality-selector.tsx +++ b/apps/web/src/components/quality-selector.tsx @@ -3,6 +3,7 @@ import { useRef } from "react"; import { useInterfaceLocale } from "../hooks/use-interface-locale"; import { useDashPlayerSnapshot } from "../lib/dash-player-store"; import { dashQualityOptions, selectDashTrack, selectedDashHeight } from "../lib/dash-video"; +import { qualityOptionHeight } from "../lib/player-quality"; import { sabrResolutionOptions } from "../lib/sabr-quality-selection"; import type { DefaultLayoutIcon, MenuInstance } from "../lib/vidstack"; import { @@ -22,23 +23,8 @@ const QUALITY_OPTIONS = { sort: "descending" } as const; type QualityOption = ReturnType[number]; -function qualityLabelHeight(label: string): number | null { - const match = label.match(/(\d+)/); - if (!match) return null; - const height = Number(match[1]); - return Number.isFinite(height) ? height : null; -} - -function optionHeight(option: QualityOption): number | null { - if (option.quality?.height && option.quality.height > 0) return option.quality.height; - if ("height" in option && typeof (option as { height?: unknown }).height === "number") { - return (option as { height: number }).height; - } - return qualityLabelHeight(option.label); -} - function qualityValue(option: QualityOption): string { - const height = optionHeight(option); + const height = qualityOptionHeight(option); return height !== null ? String(height) : option.label; } @@ -147,7 +133,7 @@ export function QualitySelector() { const radioOptions = filteredOptions.map((o) => ({ label: o.label, value: qualityValue(o) })); if (filteredOptions.length <= 1) return null; - if (filteredOptions.every((o) => (optionHeight(o) ?? 0) === 0)) return null; + if (filteredOptions.every((o) => (qualityOptionHeight(o) ?? 0) === 0)) return null; if (!selected) return null; const current = selected.label; diff --git a/apps/web/src/lib/player-quality.ts b/apps/web/src/lib/player-quality.ts new file mode 100644 index 00000000..d1bdd62a --- /dev/null +++ b/apps/web/src/lib/player-quality.ts @@ -0,0 +1,18 @@ +type QualityOptionLike = { + label: string; + height?: number; + quality?: { height?: number | null } | null; +}; + +export function qualityLabelHeight(label: string): number | null { + const match = label.match(/(\d+)/); + if (!match) return null; + const height = Number(match[1]); + return Number.isFinite(height) && height > 0 ? height : null; +} + +export function qualityOptionHeight(option: QualityOptionLike): number | null { + if (option.quality?.height && option.quality.height > 0) return option.quality.height; + if (option.height && option.height > 0) return option.height; + return qualityLabelHeight(option.label); +} diff --git a/apps/web/tests/player-quality.test.ts b/apps/web/tests/player-quality.test.ts new file mode 100644 index 00000000..810a191f --- /dev/null +++ b/apps/web/tests/player-quality.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { qualityLabelHeight, qualityOptionHeight } from "../src/lib/player-quality"; + +test("prefers the quality height and falls back to a positive option height", () => { + expect(qualityOptionHeight({ label: "720p", quality: { height: 1080 } })).toBe(1080); + expect(qualityOptionHeight({ label: "720p", height: 1080 })).toBe(1080); +}); + +test("falls back to the label when heights are missing or zero", () => { + expect(qualityOptionHeight({ label: "720p", quality: { height: 0 }, height: 0 })).toBe(720); + expect(qualityOptionHeight({ label: "1080p60" })).toBe(1080); +}); + +test("rejects labels without a positive resolution", () => { + expect(qualityLabelHeight("Auto")).toBeNull(); + expect(qualityLabelHeight("0p")).toBeNull(); +}); From 8a525fdeb72e3a992e98d139b6787481348cc8e6 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 26 Sep 2026 20:09:46 +0200 Subject: [PATCH 135/149] fix: show avatar loading fallback --- apps/web/src/components/channel-avatar.tsx | 46 ++++++++++++---------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/channel-avatar.tsx b/apps/web/src/components/channel-avatar.tsx index 32072b28..87f4497b 100644 --- a/apps/web/src/components/channel-avatar.tsx +++ b/apps/web/src/components/channel-avatar.tsx @@ -1,3 +1,4 @@ +import { UserRound } from "lucide-react"; import { useState } from "react"; type Props = { @@ -22,29 +23,34 @@ function getInitial(name: string): string { export function ChannelAvatar({ src, name, className = "w-8 h-8" }: Props) { const [failedSrc, setFailedSrc] = useState(null); + const [loadedSrc, setLoadedSrc] = useState(null); const failed = failedSrc === src; - - if (!src || failed) { - return ( -
    - {getInitial(name)} -
    - ); - } + const loaded = loadedSrc === src; return ( - setFailedSrc(src)} +
    + > + {name.trim() ? ( + {getInitial(name)} + ) : ( +
    ); } From b1dab1ce389ca7926ca5f7d82242221ce2bd8e03 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 26 Sep 2026 20:33:22 +0200 Subject: [PATCH 136/149] fix: show channel avatar loading skeleton --- apps/web/src/components/channel-avatar.tsx | 27 ++++++++++++----- .../src/settings/settings-notifications.tsx | 15 ++++------ apps/web/tests/channel-avatar.test.tsx | 30 +++++++++++++++++++ 3 files changed, 55 insertions(+), 17 deletions(-) create mode 100644 apps/web/tests/channel-avatar.test.tsx diff --git a/apps/web/src/components/channel-avatar.tsx b/apps/web/src/components/channel-avatar.tsx index 87f4497b..5a4c3c13 100644 --- a/apps/web/src/components/channel-avatar.tsx +++ b/apps/web/src/components/channel-avatar.tsx @@ -24,27 +24,38 @@ function getInitial(name: string): string { export function ChannelAvatar({ src, name, className = "w-8 h-8" }: Props) { const [failedSrc, setFailedSrc] = useState(null); const [loadedSrc, setLoadedSrc] = useState(null); + const hasSource = src.trim().length > 0; const failed = failedSrc === src; const loaded = loadedSrc === src; + const loading = hasSource && !failed && !loaded; + const state = loading ? "loading" : hasSource && !failed ? "ready" : "fallback"; return (
    - {name.trim() ? ( - {getInitial(name)} - ) : ( -
    ); } + +const AVATAR_REFRESH_INTERVAL_MS = 2_000; +const AVATAR_WAIT_TIMEOUT_MS = 15_000; diff --git a/apps/web/tests/channel-avatar.test.tsx b/apps/web/tests/channel-avatar.test.tsx index 23e1f23e..5721428f 100644 --- a/apps/web/tests/channel-avatar.test.tsx +++ b/apps/web/tests/channel-avatar.test.tsx @@ -23,6 +23,14 @@ describe("channel avatar", () => { expect(html).not.toContain(" { + const html = renderToStaticMarkup(); + + expect(html).toContain('data-avatar-state="loading"'); + expect(html).toContain("data-avatar-skeleton"); + expect(html).not.toContain(">O
    "); + }); + test("renders a generic fallback when the channel name is missing", () => { const html = renderToStaticMarkup(); From b2b53c6a733a53f6d6b6173ed131af679d44f9ab Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 26 Sep 2026 21:56:34 +0200 Subject: [PATCH 139/149] refactor: standardize avatar loading behavior --- apps/web/src/components/channel-avatar.tsx | 21 ++++++++++++++++++--- apps/web/tests/channel-avatar.test.tsx | 12 ++++++++++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/channel-avatar.tsx b/apps/web/src/components/channel-avatar.tsx index 7e9e73b3..90339bbd 100644 --- a/apps/web/src/components/channel-avatar.tsx +++ b/apps/web/src/components/channel-avatar.tsx @@ -1,5 +1,5 @@ import { UserRound } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Skeleton } from "./skeleton"; type Props = { @@ -23,13 +23,26 @@ function getInitial(name: string): string { return name[0].toUpperCase(); } -export function ChannelAvatar({ src, name, className = "w-8 h-8", pending = false }: Props) { +export function ChannelAvatar({ src, name, className = "w-8 h-8", pending }: Props) { const [failedSrc, setFailedSrc] = useState(null); const [loadedSrc, setLoadedSrc] = useState(null); + const [missingExpired, setMissingExpired] = useState(false); const hasSource = src.trim().length > 0; const failed = failedSrc === src; const loaded = loadedSrc === src; - const loading = pending || (hasSource && !failed && !loaded); + useEffect(() => { + if (hasSource || pending === false) { + setMissingExpired(false); + return; + } + if (pending === true) return; + setMissingExpired(false); + const timer = window.setTimeout(() => setMissingExpired(true), MISSING_AVATAR_GRACE_MS); + return () => window.clearTimeout(timer); + }, [hasSource, pending]); + const missing = + !hasSource && !failed && (pending === true || (pending === undefined && !missingExpired)); + const loading = missing || (hasSource && !failed && !loaded); const state = loading ? "loading" : hasSource && !failed ? "ready" : "fallback"; return ( @@ -61,3 +74,5 @@ export function ChannelAvatar({ src, name, className = "w-8 h-8", pending = fals ); } + +const MISSING_AVATAR_GRACE_MS = 1_500; diff --git a/apps/web/tests/channel-avatar.test.tsx b/apps/web/tests/channel-avatar.test.tsx index 5721428f..041c83a5 100644 --- a/apps/web/tests/channel-avatar.test.tsx +++ b/apps/web/tests/channel-avatar.test.tsx @@ -16,13 +16,21 @@ describe("channel avatar", () => { }); test("renders the channel initial when no avatar URL exists", () => { - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup(); expect(html).toContain('data-avatar-state="fallback"'); expect(html).toContain(">C
    "); expect(html).not.toContain(" { + const html = renderToStaticMarkup(); + + expect(html).toContain('data-avatar-state="loading"'); + expect(html).toContain("data-avatar-skeleton"); + expect(html).not.toContain(">C"); + }); + test("keeps the skeleton while a missing avatar is being resolved", () => { const html = renderToStaticMarkup(); @@ -32,7 +40,7 @@ describe("channel avatar", () => { }); test("renders a generic fallback when the channel name is missing", () => { - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup(); expect(html).toContain("lucide-user-round"); }); From a0671c1190a8b2b6d4e52e7522f0ac4bd6f4cc6d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 26 Sep 2026 22:24:06 +0200 Subject: [PATCH 140/149] feat: enable NicoNico live channel tab --- apps/web/src/components/channel-filter-bar.tsx | 4 +++- apps/web/src/components/channel-page-content.tsx | 5 ++++- apps/web/src/lib/channel-search-url.ts | 12 ++++++++++-- apps/web/tests/channel-route-url.test.ts | 7 +++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/channel-filter-bar.tsx b/apps/web/src/components/channel-filter-bar.tsx index 386b0b58..e913a59b 100644 --- a/apps/web/src/components/channel-filter-bar.tsx +++ b/apps/web/src/components/channel-filter-bar.tsx @@ -17,6 +17,7 @@ type Props = { sort: ChannelSort; query: string; tab: ChannelTab; + tabsAvailable: boolean; searchAvailable: boolean; onSearch: (query: string) => void; onTabChange: (tab: ChannelTab) => void; @@ -27,6 +28,7 @@ export function ChannelFilterBar({ sort, query, tab, + tabsAvailable, searchAvailable, onSearch, onTabChange, @@ -53,7 +55,7 @@ export function ChannelFilterBar({ return (
    - {searchAvailable && ( + {tabsAvailable && (
    {channelTabs().map((item) => ( - ); - })} + {serviceAlternatives(settings.defaultService).map((service) => ( + + ))}
    ); diff --git a/apps/web/src/lib/service-options.ts b/apps/web/src/lib/service-options.ts index 1770454e..60d185dc 100644 --- a/apps/web/src/lib/service-options.ts +++ b/apps/web/src/lib/service-options.ts @@ -14,6 +14,10 @@ export const SERVICE_OPTIONS: ServiceOption[] = [ { id: 5, label: "BiliBili", path: siBilibili.path, color: "#00A1D6" }, ]; +export function serviceAlternatives(activeService: ServiceId): ServiceOption[] { + return SERVICE_OPTIONS.filter((service) => service.id !== activeService); +} + export type ServiceNavigation = | { to: "/search"; search: { q: string; service: ServiceId } } | { to: "/bilibili-session" } diff --git a/apps/web/tests/service-options.test.ts b/apps/web/tests/service-options.test.ts index edb8bdc4..788785df 100644 --- a/apps/web/tests/service-options.test.ts +++ b/apps/web/tests/service-options.test.ts @@ -1,10 +1,14 @@ import { expect, test } from "bun:test"; -import { nextServiceRoute, SERVICE_OPTIONS } from "../src/lib/service-options"; +import { nextServiceRoute, SERVICE_OPTIONS, serviceAlternatives } from "../src/lib/service-options"; test("exposes YouTube, NicoNico and BiliBili in the navbar order", () => { expect(SERVICE_OPTIONS.map((service) => service.id)).toEqual([0, 6, 5]); }); +test("excludes the active service from navbar alternatives", () => { + expect(serviceAlternatives(6).map((service) => service.label)).toEqual(["YouTube", "BiliBili"]); +}); + test("keeps the current search query when switching service", () => { expect(nextServiceRoute("/search", "?q=lofi&service=0", 5)).toEqual({ to: "/search", From e5e5d77f36d9fbb75f350e643675f8534811905e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 14:39:07 +0200 Subject: [PATCH 149/149] fix: load watch avatars immediately --- apps/web/src/components/channel-avatar.tsx | 27 ++++++++++++++++++---- apps/web/src/components/watch-info.tsx | 2 ++ apps/web/tests/channel-avatar.test.tsx | 14 +++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/channel-avatar.tsx b/apps/web/src/components/channel-avatar.tsx index 90339bbd..cc414f23 100644 --- a/apps/web/src/components/channel-avatar.tsx +++ b/apps/web/src/components/channel-avatar.tsx @@ -7,6 +7,7 @@ type Props = { name: string; className?: string; pending?: boolean; + priority?: boolean; }; function getInitial(name: string): string { @@ -23,13 +24,15 @@ function getInitial(name: string): string { return name[0].toUpperCase(); } -export function ChannelAvatar({ src, name, className = "w-8 h-8", pending }: Props) { +export function ChannelAvatar({ src, name, className = "w-8 h-8", pending, priority }: Props) { const [failedSrc, setFailedSrc] = useState(null); const [loadedSrc, setLoadedSrc] = useState(null); const [missingExpired, setMissingExpired] = useState(false); + const [retryState, setRetryState] = useState({ src, count: 0 }); const hasSource = src.trim().length > 0; const failed = failedSrc === src; const loaded = loadedSrc === src; + const retryCount = retryState.src === src ? retryState.count : 0; useEffect(() => { if (hasSource || pending === false) { setMissingExpired(false); @@ -61,18 +64,34 @@ export function ChannelAvatar({ src, name, className = "w-8 h-8", pending }: Pro ))} {hasSource && !failed && ( setLoadedSrc(src)} - onError={() => setFailedSrc(src)} + onError={() => { + if (retryCount < MAX_AVATAR_RETRIES) { + setRetryState({ src, count: retryCount + 1 }); + return; + } + setFailedSrc(src); + }} /> )} ); } +function avatarRetrySrc(src: string, retryCount: number): string { + if (retryCount === 0) return src; + const url = new URL(src, window.location.href); + url.searchParams.set("_tt_avatar_retry", String(retryCount)); + return url.toString(); +} + +const MAX_AVATAR_RETRIES = 2; const MISSING_AVATAR_GRACE_MS = 1_500; diff --git a/apps/web/src/components/watch-info.tsx b/apps/web/src/components/watch-info.tsx index cf737313..dcdc43a9 100644 --- a/apps/web/src/components/watch-info.tsx +++ b/apps/web/src/components/watch-info.tsx @@ -85,6 +85,7 @@ export function WatchInfo({ stream }: Props) { src={stream.channelAvatar} name={stream.channelName} className="w-9 h-9" + priority />

    @@ -103,6 +104,7 @@ export function WatchInfo({ stream }: Props) { src={stream.channelAvatar} name={stream.channelName} className="w-9 h-9" + priority /> {channelMeta}

    diff --git a/apps/web/tests/channel-avatar.test.tsx b/apps/web/tests/channel-avatar.test.tsx index 041c83a5..25aabd32 100644 --- a/apps/web/tests/channel-avatar.test.tsx +++ b/apps/web/tests/channel-avatar.test.tsx @@ -15,6 +15,20 @@ describe("channel avatar", () => { expect(html).toContain('loading="lazy"'); }); + test("loads primary watch avatars eagerly", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('loading="eager"'); + expect(html).toContain('fetchPriority="high"'); + expect(html).toContain('rel="preload"'); + }); + test("renders the channel initial when no avatar URL exists", () => { const html = renderToStaticMarkup();