- {playlists.map((playlist, index) => (
+ {visiblePlaylists.map((playlist, index) => (
(null);
@@ -45,7 +47,8 @@ function PlaylistDetailPage() {
);
}
- const videos = playlist.videos ?? [];
+ const allVideos = playlist.videos ?? [];
+ const videos = filter(allVideos);
const count = videos.length;
const sortedVideos = sortPlaylistVideos(videos, sortMode);
const reorderable = sortMode === "manual";
@@ -116,7 +119,11 @@ function PlaylistDetailPage() {
-
No videos in this playlist yet.
+
+ {allVideos.length > 0
+ ? "All videos in this playlist are blocked."
+ : "No videos in this playlist yet."}
+
Save videos from the watch page using the Save button.
diff --git a/apps/web/src/routes/search.tsx b/apps/web/src/routes/search.tsx
index 26face6..29f01eb 100644
--- a/apps/web/src/routes/search.tsx
+++ b/apps/web/src/routes/search.tsx
@@ -11,15 +11,24 @@ import { useSearchFilters } from "../hooks/use-search-filters";
import { useSettings } from "../hooks/use-settings";
function SearchPage() {
- const { q, service, contentFilter, sortFilter } = Route.useSearch();
+ const {
+ q,
+ service,
+ contentFilter,
+ filters: selectedFilters = [],
+ sortFilter,
+ } = Route.useSearch();
const navigate = useNavigate();
- const filters = useSearchFilters(service);
+ const filters = useSearchFilters(service, contentFilter);
+ const searchFilters = [...selectedFilters, ...(sortFilter ? [sortFilter] : [])].filter(
+ (value, index, values) => values.indexOf(value) === index,
+ );
const { settings } = useSettings();
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useSearch(
q,
service,
contentFilter,
- sortFilter,
+ searchFilters,
);
const { filter, isChannelBlocked, isPlaylistBlocked } = useBlockedFilter();
@@ -59,20 +68,23 @@ function SearchPage() {
}
function setContentFilter(value: string | undefined) {
- navigate({ to: "/search", search: { q, service, contentFilter: value, sortFilter } });
+ navigate({ to: "/search", search: { q, service, contentFilter: value } });
}
- function setSortFilter(value: string | undefined) {
- navigate({ to: "/search", search: { q, service, contentFilter, sortFilter: value } });
+ function setSearchFilters(values: string[]) {
+ navigate({
+ to: "/search",
+ search: { q, service, contentFilter, ...(values.length > 0 ? { filters: values } : {}) },
+ });
}
const filterBar = filters.data ? (
) : null;
@@ -131,11 +143,19 @@ function SearchPage() {
}
export const Route = createFileRoute("/search")({
- validateSearch: (search: Record
) => ({
- q: typeof search.q === "string" ? search.q : "",
- service: typeof search.service === "number" ? search.service : 0,
- ...(typeof search.contentFilter === "string" ? { contentFilter: search.contentFilter } : {}),
- ...(typeof search.sortFilter === "string" ? { sortFilter: search.sortFilter } : {}),
- }),
+ validateSearch: (search: Record) => {
+ const filters = Array.isArray(search.filters)
+ ? search.filters.filter((value): value is string => typeof value === "string")
+ : typeof search.filters === "string"
+ ? [search.filters]
+ : [];
+ return {
+ q: typeof search.q === "string" ? search.q : "",
+ service: typeof search.service === "number" ? search.service : 0,
+ ...(typeof search.contentFilter === "string" ? { contentFilter: search.contentFilter } : {}),
+ ...(filters.length > 0 ? { filters } : {}),
+ ...(typeof search.sortFilter === "string" ? { sortFilter: search.sortFilter } : {}),
+ };
+ },
component: SearchPage,
});
diff --git a/apps/web/src/routes/subscriptions_.channels.tsx b/apps/web/src/routes/subscriptions_.channels.tsx
index 7432092..f716eae 100644
--- a/apps/web/src/routes/subscriptions_.channels.tsx
+++ b/apps/web/src/routes/subscriptions_.channels.tsx
@@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { SubscriptionChannelList } from "../components/subscription-channel-list";
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 { fetchSubscriptionFeed, fetchSubscriptions } from "../lib/api-user";
@@ -16,7 +17,10 @@ function nextSubscriptionPage(last: Awaited !isChannelIdentityBlocked({ url: item.channelUrl, name: item.name }),
+ );
function prefetchChannels() {
void queryClient.prefetchQuery({
diff --git a/apps/web/src/routes/watch.tsx b/apps/web/src/routes/watch.tsx
index c7ddd6a..e91ae3f 100644
--- a/apps/web/src/routes/watch.tsx
+++ b/apps/web/src/routes/watch.tsx
@@ -27,11 +27,10 @@ function WatchPage() {
const sourceUrl = toWatchSourceUrl(v);
const publicParam = toPublicWatchParam(sourceUrl);
const { authReady, isAuthed } = useAuth();
- const { data: instance, isPending: instancePending } = useInstance();
+ const { isPending: instancePending } = useInstance();
const { settings, settingsReady } = useSettings();
const navigationSnapshot = useWatchNavigationStore((state) => state.snapshot);
- const useAuthenticatedStream =
- isAuthed && (settings.accessMode === "allow_list" || instance?.guestAllowed === false);
+ const useAuthenticatedStream = isAuthed;
const streamEnabled = authReady && !instancePending && (!isAuthed || settingsReady);
const streamQuery = useStream(sourceUrl, useAuthenticatedStream, streamEnabled);
const bootstrap = useSabrBootstrap(sourceUrl, useAuthenticatedStream, streamEnabled);
diff --git a/apps/web/src/styles/player-menu-overrides.css b/apps/web/src/styles/player-menu-overrides.css
index 0a95bf6..2260923 100644
--- a/apps/web/src/styles/player-menu-overrides.css
+++ b/apps/web/src/styles/player-menu-overrides.css
@@ -19,6 +19,13 @@
--media-cue-bg: rgb(0 0 0 / var(--media-user-text-bg-opacity, 0.7));
}
+.vds-captions [data-part="cue-display"] {
+ left: 50%;
+ right: auto;
+ transform: translateX(-50%);
+ text-align: center;
+}
+
@media (pointer: coarse) {
.vds-video-layout[data-lg] .vds-volume-slider {
margin-left: var(--gap, 10px);
diff --git a/apps/web/src/styles/shorts-overrides.css b/apps/web/src/styles/shorts-overrides.css
index 8189c18..f7026a8 100644
--- a/apps/web/src/styles/shorts-overrides.css
+++ b/apps/web/src/styles/shorts-overrides.css
@@ -87,10 +87,3 @@
.shorts-shell .typetype-shorts-layout {
border-radius: inherit;
}
-
-.shorts-shell .vds-captions [data-part="cue-display"] {
- left: 50%;
- right: auto;
- transform: translateX(-50%);
- text-align: center;
-}
diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts
index 3199853..4690ab6 100644
--- a/apps/web/src/types/api.ts
+++ b/apps/web/src/types/api.ts
@@ -103,14 +103,23 @@ export type SearchPageResponse = {
isCorrectedSearch: boolean;
};
-type SearchFilterOption = {
+export type SearchFilterOption = {
value: string;
label: string;
+ isDefault?: boolean;
+};
+
+export type SearchFilterGroup = {
+ key: string;
+ label: string;
+ multiSelect: boolean;
+ options: SearchFilterOption[];
};
export type SearchFiltersResponse = {
contentFilters: SearchFilterOption[];
sortFilters: SearchFilterOption[];
+ filterGroups?: SearchFilterGroup[];
};
export type HomeRecommendationsResponse = {
diff --git a/apps/web/src/types/playlist.ts b/apps/web/src/types/playlist.ts
index c284ab8..34772c7 100644
--- a/apps/web/src/types/playlist.ts
+++ b/apps/web/src/types/playlist.ts
@@ -8,6 +8,7 @@ export type WatchPlaylistItem = {
title: string;
thumbnail: string;
channelName?: string;
+ channelUrl?: string;
};
export type PublicPlaylistInfo = {
diff --git a/apps/web/tests/admin-user-avatar.test.ts b/apps/web/tests/admin-user-avatar.test.ts
new file mode 100644
index 0000000..c11d193
--- /dev/null
+++ b/apps/web/tests/admin-user-avatar.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, test } from "bun:test";
+import { getAdminUserAvatarUrl } from "../src/lib/admin-user-avatar";
+import { getOpenMojiUrl, pickOpenMojiCode } from "../src/lib/openmoji";
+import type { AuthUser } from "../src/types/auth";
+
+const user: AuthUser = {
+ id: "user-123",
+ email: "before@example.com",
+ name: "Example",
+ role: "user",
+ publicUsername: null,
+ bio: null,
+ avatarUrl: null,
+ avatarType: null,
+ avatarCode: null,
+ suspended: false,
+ verified: true,
+ accessMode: "unrestricted",
+ createdAt: 0,
+};
+
+describe("getAdminUserAvatarUrl", () => {
+ test("keeps the fallback avatar stable when the email changes", () => {
+ const before = getAdminUserAvatarUrl(user);
+ const after = getAdminUserAvatarUrl({ ...user, email: "after@example.com" });
+
+ expect(after).toBe(before);
+ expect(after).toBe(getOpenMojiUrl(pickOpenMojiCode(user.id)));
+ });
+
+ test("preserves configured emoji and custom avatars", () => {
+ expect(getAdminUserAvatarUrl({ ...user, avatarType: "emoji", avatarCode: "1F600" })).toBe(
+ getOpenMojiUrl("1F600"),
+ );
+ expect(
+ getAdminUserAvatarUrl({ ...user, avatarType: "custom", avatarUrl: "/avatar/custom/user" }),
+ ).toBe("/api/avatar/custom/user");
+ });
+});
diff --git a/apps/web/tests/api-version.test.ts b/apps/web/tests/api-version.test.ts
index e28f3be..c00a2bf 100644
--- a/apps/web/tests/api-version.test.ts
+++ b/apps/web/tests/api-version.test.ts
@@ -1,12 +1,14 @@
import { describe, expect, test } from "bun:test";
-Object.defineProperty(globalThis, "localStorage", {
- value: {
- getItem: () => null,
- setItem: () => undefined,
- removeItem: () => undefined,
- },
-});
+if (!("localStorage" in globalThis)) {
+ Object.defineProperty(globalThis, "localStorage", {
+ value: {
+ getItem: () => null,
+ setItem: () => undefined,
+ removeItem: () => undefined,
+ },
+ });
+}
const { parseComponentVersion } = await import("../src/lib/api-version");
diff --git a/apps/web/tests/auth-session-refresh.test.ts b/apps/web/tests/auth-session-refresh.test.ts
new file mode 100644
index 0000000..f4a1e27
--- /dev/null
+++ b/apps/web/tests/auth-session-refresh.test.ts
@@ -0,0 +1,143 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
+
+if (!("localStorage" in globalThis)) {
+ Object.defineProperty(globalThis, "localStorage", {
+ value: {
+ getItem: () => null,
+ setItem: () => undefined,
+ removeItem: () => undefined,
+ },
+ });
+}
+
+const { authed } = await import("../src/lib/authed");
+const { bootstrapSession } = await import("../src/lib/auth-session");
+const { useAuthStore } = await import("../src/stores/auth-store");
+
+const originalFetch = globalThis.fetch;
+const me = {
+ id: "user-1",
+ role: "USER" as const,
+ publicUsername: "tester",
+ bio: null,
+ avatarUrl: null,
+ avatarType: null,
+ avatarCode: null,
+};
+
+function jsonResponse(status: number, body: object): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+beforeEach(() => {
+ useAuthStore.getState().setSession("stale-token", me);
+});
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ useAuthStore.getState().setSignedOut();
+});
+
+describe("session refresh failures", () => {
+ test("keeps the cached session when bootstrap refresh is temporarily unavailable", async () => {
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/auth/me")) return jsonResponse(401, { error: "Unauthorized" });
+ if (url.endsWith("/auth/refresh")) {
+ return jsonResponse(503, { error: "Temporarily unavailable" });
+ }
+ throw new Error(`Unexpected request: ${url}`);
+ });
+
+ await bootstrapSession();
+
+ expect(useAuthStore.getState()).toMatchObject({
+ token: "stale-token",
+ me,
+ status: "authenticated",
+ });
+ });
+
+ test("preserves the session and propagates a temporary refresh error", async () => {
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === "/api/settings") return new Response(null, { status: 401 });
+ if (url.endsWith("/auth/refresh")) {
+ return jsonResponse(503, { error: "Temporarily unavailable" });
+ }
+ throw new Error(`Unexpected request: ${url}`);
+ });
+
+ await expect(authed("/api/settings")).rejects.toEqual(expect.objectContaining({ status: 503 }));
+ expect(useAuthStore.getState()).toMatchObject({
+ token: "stale-token",
+ me,
+ status: "authenticated",
+ });
+ });
+
+ test("preserves the session when the refresh request loses connectivity", async () => {
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === "/api/settings") return new Response(null, { status: 401 });
+ if (url.endsWith("/auth/refresh")) throw new Error("Connection lost");
+ throw new Error(`Unexpected request: ${url}`);
+ });
+
+ await expect(authed("/api/settings")).rejects.toEqual(
+ expect.objectContaining({ message: "Connection lost" }),
+ );
+ expect(useAuthStore.getState()).toMatchObject({
+ token: "stale-token",
+ me,
+ status: "authenticated",
+ });
+ });
+
+ test("keeps the refreshed session when the retried request loses connectivity", async () => {
+ let settingsRequests = 0;
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === "/api/settings") {
+ settingsRequests += 1;
+ if (settingsRequests === 1) return new Response(null, { status: 401 });
+ throw new Error("Connection lost after refresh");
+ }
+ if (url.endsWith("/auth/refresh")) {
+ return jsonResponse(200, { accessToken: "fresh-token" });
+ }
+ if (url.endsWith("/auth/me")) return jsonResponse(200, me);
+ throw new Error(`Unexpected request: ${url}`);
+ });
+
+ await expect(authed("/api/settings")).rejects.toEqual(
+ expect.objectContaining({ message: "Connection lost after refresh" }),
+ );
+ expect(useAuthStore.getState()).toMatchObject({
+ token: "fresh-token",
+ me,
+ status: "authenticated",
+ });
+ });
+
+ test("signs out only when the refresh session is rejected", async () => {
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === "/api/settings") return new Response(null, { status: 401 });
+ if (url.endsWith("/auth/refresh")) return jsonResponse(401, { error: "Unauthorized" });
+ throw new Error(`Unexpected request: ${url}`);
+ });
+
+ await expect(authed("/api/settings")).rejects.toEqual(
+ expect.objectContaining({ status: 401, message: "Session expired" }),
+ );
+ expect(useAuthStore.getState()).toMatchObject({
+ token: null,
+ me: null,
+ status: "signed_out",
+ });
+ });
+});
diff --git a/apps/web/tests/blocked-content.test.ts b/apps/web/tests/blocked-content.test.ts
new file mode 100644
index 0000000..2e04904
--- /dev/null
+++ b/apps/web/tests/blocked-content.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from "bun:test";
+import {
+ createBlockedContentMatcher,
+ normalizeBlockedContentUrl,
+} from "../src/lib/blocked-content";
+
+describe("blocked content matching", () => {
+ test("treats equivalent YouTube video URLs as the same video", () => {
+ const matcher = createBlockedContentMatcher(
+ [],
+ [{ url: "https://www.youtube.com/watch?v=AbC_123-xyZ" }],
+ [],
+ );
+
+ expect(matcher.isVideoExplicitlyBlocked({ id: "https://youtu.be/AbC_123-xyZ?t=12" })).toBe(
+ true,
+ );
+ expect(
+ matcher.isVideoExplicitlyBlocked({ url: "https://m.youtube.com/shorts/AbC_123-xyZ" }),
+ ).toBe(true);
+ });
+
+ test("normalizes YouTube channel hosts and ignores URL decorations", () => {
+ expect(normalizeBlockedContentUrl("http://www.youtube.com/@Example/?view=0#top")).toBe(
+ "youtube.com/@Example",
+ );
+ });
+
+ test("matches blocked channels by canonical URL or normalized name", () => {
+ const matcher = createBlockedContentMatcher(
+ [{ url: "https://www.youtube.com/@Example", name: "Test Channel" }],
+ [],
+ [],
+ );
+
+ expect(matcher.isChannelBlocked({ url: "https://m.youtube.com/@Example/" })).toBe(true);
+ expect(matcher.isChannelBlocked({ name: "test channel" })).toBe(true);
+ });
+
+ test("removes blocked videos, channels, and keywords from ordered candidates", () => {
+ const matcher = createBlockedContentMatcher(
+ [{ url: "https://youtube.com/@blocked", name: "Blocked" }],
+ [{ url: "https://youtube.com/watch?v=blocked-video" }],
+ ["spoiler"],
+ );
+ const candidates = [
+ { id: "https://youtube.com/watch?v=blocked-video", title: "One" },
+ {
+ id: "https://youtube.com/watch?v=blocked-channel",
+ title: "Two",
+ channelUrl: "https://www.youtube.com/@blocked",
+ },
+ { id: "https://youtube.com/watch?v=blocked-title", title: "A spoiler inside" },
+ { id: "https://youtube.com/watch?v=visible", title: "Visible" },
+ ];
+
+ expect(matcher.filterVideos(candidates).map((item) => item.title)).toEqual(["Visible"]);
+ });
+});
diff --git a/apps/web/tests/copy-text.test.ts b/apps/web/tests/copy-text.test.ts
new file mode 100644
index 0000000..8aea7be
--- /dev/null
+++ b/apps/web/tests/copy-text.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, mock, test } from "bun:test";
+import { type CopyTextEnvironment, copyText } from "../src/lib/copy-text";
+
+function fallbackEnvironment(copyResult = true) {
+ const remove = mock(() => {});
+ const textarea = {
+ value: "",
+ readOnly: false,
+ style: {} as CSSStyleDeclaration,
+ focus: mock(() => {}),
+ select: mock(() => {}),
+ remove,
+ } as unknown as HTMLTextAreaElement;
+ const appendChild = mock(() => textarea);
+ const execCommand = mock(() => copyResult);
+ const documentRef = {
+ body: { appendChild },
+ createElement: mock(() => textarea),
+ execCommand,
+ } as unknown as Document;
+
+ return { documentRef, textarea, appendChild, execCommand, remove };
+}
+
+describe("copyText", () => {
+ test("uses the Clipboard API when it is available", async () => {
+ const writeText = mock(async () => {});
+ const environment: CopyTextEnvironment = {
+ clipboard: { writeText },
+ document: null,
+ };
+
+ expect(await copyText("reset-token", environment)).toBe(true);
+ expect(writeText).toHaveBeenCalledWith("reset-token");
+ });
+
+ test("falls back when the Clipboard API is unavailable", async () => {
+ const fallback = fallbackEnvironment();
+
+ expect(
+ await copyText("reset-token", {
+ clipboard: null,
+ document: fallback.documentRef,
+ }),
+ ).toBe(true);
+ expect(fallback.textarea.value).toBe("reset-token");
+ expect(fallback.appendChild).toHaveBeenCalledTimes(1);
+ expect(fallback.execCommand).toHaveBeenCalledWith("copy");
+ expect(fallback.remove).toHaveBeenCalledTimes(1);
+ });
+
+ test("falls back after a Clipboard API rejection", async () => {
+ const fallback = fallbackEnvironment();
+ const writeText = mock(async () => {
+ throw new Error("clipboard denied");
+ });
+
+ expect(
+ await copyText("reset-token", {
+ clipboard: { writeText },
+ document: fallback.documentRef,
+ }),
+ ).toBe(true);
+ expect(fallback.execCommand).toHaveBeenCalledWith("copy");
+ });
+
+ test("reports failure when no copy mechanism is available", async () => {
+ expect(await copyText("reset-token", { clipboard: null, document: null })).toBe(false);
+ });
+});
diff --git a/apps/web/tests/sabr-vidstack-provider.test.ts b/apps/web/tests/sabr-vidstack-provider.test.ts
new file mode 100644
index 0000000..019b7b4
--- /dev/null
+++ b/apps/web/tests/sabr-vidstack-provider.test.ts
@@ -0,0 +1,22 @@
+import { expect, test } from "bun:test";
+import { registerSabrVidstackControls } from "../src/lib/sabr-vidstack-bridge";
+import { bindSabrVideoProvider } from "../src/lib/sabr-vidstack-provider";
+
+test("routes Vidstack provider seeks through SABR controls", () => {
+ const positions: number[] = [];
+ const video = { autoplay: false, pause: () => {} } as HTMLVideoElement;
+ const provider = { video } as Parameters[0];
+ const unregister = registerSabrVidstackControls(video, {
+ play: async () => {},
+ pause: () => {},
+ seek: (seconds) => positions.push(seconds),
+ });
+
+ try {
+ bindSabrVideoProvider(provider).setCurrentTime(93.5);
+
+ expect(positions).toEqual([93.5]);
+ } finally {
+ unregister();
+ }
+});
diff --git a/apps/web/tests/search-filter-selection.test.ts b/apps/web/tests/search-filter-selection.test.ts
new file mode 100644
index 0000000..4b433d1
--- /dev/null
+++ b/apps/web/tests/search-filter-selection.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, test } from "bun:test";
+import {
+ activeSearchFilterOptions,
+ sanitizeSearchFilters,
+ searchFilterGroups,
+ searchFilterLabel,
+ toggleSearchFilter,
+} from "../src/lib/search-filter-selection";
+import type { SearchFilterGroup } from "../src/types/api";
+
+const groups: SearchFilterGroup[] = [
+ {
+ key: "sort",
+ label: "sortby",
+ multiSelect: false,
+ options: [
+ { value: "relevance", label: "sort_relevance", isDefault: true },
+ { value: "views", label: "sort_view" },
+ { value: "rating", label: "sort_rating" },
+ ],
+ },
+ {
+ key: "features",
+ label: "features",
+ multiSelect: true,
+ options: [
+ { value: "hd", label: "HD" },
+ { value: "captions", label: "Subtitles" },
+ ],
+ },
+];
+
+function option(groupKey: string, value: string) {
+ const match = groups
+ .find((group) => group.key === groupKey)
+ ?.options.find((candidate) => candidate.value === value);
+ if (!match) throw new Error(`Missing ${groupKey} option ${value}`);
+ return match;
+}
+
+describe("search filter selection", () => {
+ test("keeps one exclusive value and multiple feature values", () => {
+ expect(sanitizeSearchFilters(groups, ["views", "rating", "hd", "captions"])).toEqual([
+ "views",
+ "hd",
+ "captions",
+ ]);
+ });
+
+ test("replaces exclusive filters without changing other groups", () => {
+ expect(toggleSearchFilter(groups, ["views", "hd"], "sort", option("sort", "rating"))).toEqual([
+ "rating",
+ "hd",
+ ]);
+ });
+
+ test("selecting a default removes the group from the URL", () => {
+ expect(
+ toggleSearchFilter(groups, ["views", "hd"], "sort", option("sort", "relevance")),
+ ).toEqual(["hd"]);
+ });
+
+ test("toggles multi-select filters independently", () => {
+ expect(
+ toggleSearchFilter(groups, ["views", "hd"], "features", option("features", "captions")),
+ ).toEqual(["views", "hd", "captions"]);
+ expect(
+ toggleSearchFilter(groups, ["views", "hd"], "features", option("features", "hd")),
+ ).toEqual(["views"]);
+ });
+
+ test("falls back to the legacy flat filter response", () => {
+ expect(
+ searchFilterGroups({ contentFilters: [], sortFilters: [{ value: "views", label: "Views" }] }),
+ ).toEqual([
+ {
+ key: "legacy-sort",
+ label: "Sort by",
+ multiSelect: false,
+ options: [{ value: "views", label: "Views", isDefault: true }],
+ },
+ ]);
+ });
+
+ test("returns active options and human labels", () => {
+ expect(
+ activeSearchFilterOptions(groups, ["views", "hd"]).map((option) => option.value),
+ ).toEqual(["views", "hd"]);
+ expect(searchFilterLabel("upload_date")).toBe("Upload date");
+ expect(searchFilterLabel("sort_view")).toBe("View count");
+ });
+});
diff --git a/apps/web/tests/subtitle-track-utils.test.ts b/apps/web/tests/subtitle-track-utils.test.ts
index 2b96098..333faba 100644
--- a/apps/web/tests/subtitle-track-utils.test.ts
+++ b/apps/web/tests/subtitle-track-utils.test.ts
@@ -6,14 +6,14 @@ Object.assign(globalThis, { window: { location: { origin: "https://typetype.test
test("uses caption variants instead of numeric duplicate language labels", () => {
const tracks = buildSafeSubtitleTracks([
{
- url: "https://www.youtube.com/api/timedtext?lang=en&name=CC1",
+ url: "https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en&name=CC1&expire=123&sig=secret",
mimeType: "text/vtt",
languageTag: "en",
displayLanguageName: "English",
isAutoGenerated: false,
},
{
- url: "https://www.youtube.com/api/timedtext?lang=en&name=DTVCC1",
+ url: "https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en&name=DTVCC1&expire=456&sig=secret",
mimeType: "text/vtt",
languageTag: "en",
displayLanguageName: "English",
@@ -22,6 +22,46 @@ test("uses caption variants instead of numeric duplicate language labels", () =>
]);
expect(tracks.map((track) => track.label)).toEqual(["English (CC1)", "English (DTVCC1)"]);
- expect(tracks.every((track) => new URL(track.src).searchParams.get("fmt") === "vtt")).toBe(true);
- expect(tracks.every((track) => new URL(track.src).hostname === "www.youtube.com")).toBe(true);
+ expect(tracks.every((track) => new URL(track.src).searchParams.get("format") === "vtt")).toBe(
+ true,
+ );
+ expect(tracks.every((track) => new URL(track.src).hostname === "typetype.test")).toBe(true);
+ expect(
+ tracks.every((track) => new URL(track.src).pathname === "/api/subtitles/youtube/abcdefghijk"),
+ ).toBe(true);
+ expect(tracks.every((track) => !track.src.includes("secret"))).toBe(true);
+});
+
+test("preserves generated and translated YouTube subtitle selection", () => {
+ const [track] = buildSafeSubtitleTracks([
+ {
+ url: "https://m.youtube.com/api/timedtext?v=abcdefghijk&lang=en&kind=asr&tlang=fr",
+ mimeType: "application/ttml+xml",
+ languageTag: "fr",
+ displayLanguageName: "French",
+ isAutoGenerated: false,
+ },
+ ]);
+
+ const src = new URL(track?.src ?? "");
+ expect(src.searchParams.get("language")).toBe("fr");
+ expect(src.searchParams.get("sourceLanguage")).toBe("en");
+ expect(src.searchParams.get("translation")).toBe("fr");
+ expect(src.searchParams.get("variant")).toBe("auto");
+});
+
+test("keeps non YouTube subtitle tracks on the generic proxy", () => {
+ const [track] = buildSafeSubtitleTracks([
+ {
+ url: "https://subtitles.example.test/captions.ttml",
+ mimeType: "application/ttml+xml",
+ languageTag: "en",
+ displayLanguageName: "English",
+ isAutoGenerated: false,
+ },
+ ]);
+
+ const src = new URL(track?.src ?? "");
+ expect(src.pathname).toBe("/api/proxy");
+ expect(src.searchParams.get("url")).toContain("fmt=vtt");
});
diff --git a/bun.lock b/bun.lock
index 43d5ba2..7e65590 100644
--- a/bun.lock
+++ b/bun.lock
@@ -12,7 +12,7 @@
},
"apps/web": {
"name": "@typetype/web",
- "version": "1.3.1",
+ "version": "1.4.0",
"dependencies": {
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.17",
diff --git a/package.json b/package.json
index 610b9bb..a7776f6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@typetype/frontend",
- "version": "1.3.1",
+ "version": "1.4.0",
"devDependencies": {
"@biomejs/biome": "^2.5.6",
"knip": "^6.29.0",