From 0e077b84c8f47d4c49ed721a811a7dc83c2b453f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 4 Oct 2026 17:53:19 +0200 Subject: [PATCH] fix(search): the live-search tooltip says what a search costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It always said a live search spends the shared API quota, while the default source (the website's own endpoint, scrape) spends none — the owner avoided a free search because of it. /api/me now names the running search_source, and the tooltip names the cost under it: none for scrape (the enrich step costs a unit or two), 100 units a page for the official API. --- backend/app/routes/me.py | 3 ++ backend/tests/test_me_search_source.py | 26 ++++++++++ .../components/Header.searchTip.dom.test.tsx | 47 +++++++++++++++++++ frontend/src/components/Header.tsx | 4 +- frontend/src/i18n/locales/en/header.json | 3 +- frontend/src/i18n/locales/hu/header.json | 3 +- frontend/src/lib/api/account.ts | 1 + frontend/src/lib/gates.snapshot.json | 1 + 8 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_me_search_source.py create mode 100644 frontend/src/components/Header.searchTip.dom.test.tsx diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 935c3331..52cd8f3f 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -148,6 +148,9 @@ def get_me( "google_enabled": google_enabled(db), # Whether the optional Plex module is enabled (UI shows the Plex feed source when so). "plex_enabled": sysconfig.get_bool(db, "plex_enabled"), + # Which live-search source runs, so the search button can say what a search costs: "scrape" + # spends no API quota, "api" spends 100 units a page. + "search_source": sysconfig.get_str(db, "search_source"), "can_read": has_read_scope(user), "can_write": has_write_scope(user), "pending_invites": pending_invites, diff --git a/backend/tests/test_me_search_source.py b/backend/tests/test_me_search_source.py new file mode 100644 index 00000000..7f5bb2f3 --- /dev/null +++ b/backend/tests/test_me_search_source.py @@ -0,0 +1,26 @@ +"""`/api/me` names the live-search source, so the search button's tooltip can say what a search costs +under the source that actually runs (the default website source spends no API quota).""" + +from app import sysconfig + +_PW = "correct horse battery" + + +def _me(client): + r = client.get("/api/me") + assert r.status_code == 200 and r.json()["authenticated"] is True + return r.json() + + +def test_me_names_the_running_search_source(client, db, make_user): + # /api/me resolves its user through optional_current_user, which the as_user override does not + # cover — so sign in for real (see test_me_email_flag). + user = make_user(password=_PW) + login = client.post("/auth/password-login", json={"email": user.email, "password": _PW}) + assert login.status_code == 200 + + assert _me(client)["search_source"] == "scrape" # the default + + sysconfig.set_value(db, "search_source", "api") + db.commit() + assert _me(client)["search_source"] == "api" diff --git a/frontend/src/components/Header.searchTip.dom.test.tsx b/frontend/src/components/Header.searchTip.dom.test.tsx new file mode 100644 index 00000000..8b120412 --- /dev/null +++ b/frontend/src/components/Header.searchTip.dom.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +// +// The live-search button's tooltip says what a search costs under the search source the instance +// actually runs. It used to say "uses shared API quota" always, while the default source (the +// website's own endpoint) spends none — so the owner avoided a free search for months. +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }), +})); +vi.mock("./navigationContext", () => ({ useNavigation: () => ({ page: "feed" }) })); +vi.mock("./feedFiltersContext", () => ({ + useFeedFilters: () => ({ q: "cats", sort: "newest" }), + useFeedFiltersActions: () => ({ setFilters: vi.fn() }), +})); +vi.mock("./playlistsContext", () => ({ + usePlaylists: () => ({ search: "" }), + usePlaylistsActions: () => ({ setSearch: vi.fn() }), +})); +vi.mock("./channelsContext", () => ({ + useChannels: () => ({ search: "" }), + useChannelsActions: () => ({ setSearch: vi.fn(), goToFullHistory: vi.fn() }), +})); +vi.mock("./SyncStatus", () => ({ default: () => null })); + +import Header from "./Header"; +import type { Me } from "../lib/api"; + +afterEach(cleanup); + +function tipFor(source: Me["search_source"]): string | null { + const me = { is_demo: false, plex_enabled: false, search_source: source } as Me; + render(
{}} />); + return screen.getByRole("button", { name: /header\.searchYoutube$/ }).getAttribute("title"); +} + +describe("the live-search tooltip", () => { + it("does not claim a quota cost under the free website source", () => { + expect(tipFor("scrape")).toBe("header.searchYoutubeTip"); + }); + + it("names the quota cost when the instance searches through the official API", () => { + expect(tipFor("api")).toBe("header.searchYoutubeTipApi"); + }); +}); diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 8729df4a..0fbe3651 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -60,7 +60,9 @@ const Header = forwardRef< }} onGo={() => onYtSearch(trimmed)} goLabel={t("header.searchYoutube")} - goTitle={t("header.searchYoutubeTip")} + goTitle={t( + me.search_source === "api" ? "header.searchYoutubeTipApi" : "header.searchYoutubeTip", + )} goIcon={} goDisabled={!trimmed || me.is_demo} /> diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index c03b081f..ac80bdd2 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -2,7 +2,8 @@ "searchPlaceholder": "Search your subscriptions…", "clearSearch": "Clear search", "searchYoutube": "Search YouTube", - "searchYoutubeTip": "Search live on YouTube for this term (uses shared API quota). You can also press Enter.", + "searchYoutubeTip": "Search live on YouTube for this term — the search itself spends no API quota; adding the new videos it finds costs a unit or two. You can also press Enter.", + "searchYoutubeTipApi": "Search live on YouTube for this term — uses the shared API quota (100 units a page). You can also press Enter.", "go": "Go", "goTip": "Search (or press Enter)", "channelManager": "Channels", diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index ed782b2e..f19bca06 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -2,7 +2,8 @@ "searchPlaceholder": "Keresés a feliratkozásaid között…", "clearSearch": "Keresés törlése", "searchYoutube": "Keresés a YouTube-on", - "searchYoutubeTip": "Élő keresés a YouTube-on erre a kifejezésre (a közös API-kvótát fogyasztja). Entert is nyomhatsz.", + "searchYoutubeTip": "Élő keresés a YouTube-on erre a kifejezésre — maga a keresés nem fogyaszt API-kvótát, a talált új videók behúzása is csak egy-két egységet. Entert is nyomhatsz.", + "searchYoutubeTipApi": "Élő keresés a YouTube-on erre a kifejezésre — a közös API-kvótát fogyasztja (oldalanként 100 egység). Entert is nyomhatsz.", "go": "Mehet", "goTip": "Keresés (vagy Enter)", "channelManager": "Csatornák", diff --git a/frontend/src/lib/api/account.ts b/frontend/src/lib/api/account.ts index 171f5f1c..47574547 100644 --- a/frontend/src/lib/api/account.ts +++ b/frontend/src/lib/api/account.ts @@ -20,6 +20,7 @@ export interface Me { has_password: boolean; google_enabled: boolean; plex_enabled: boolean; + search_source: "scrape" | "api"; can_read: boolean; can_write: boolean; pending_invites: number; diff --git a/frontend/src/lib/gates.snapshot.json b/frontend/src/lib/gates.snapshot.json index 00f878f1..e208ef36 100644 --- a/frontend/src/lib/gates.snapshot.json +++ b/frontend/src/lib/gates.snapshot.json @@ -1794,6 +1794,7 @@ "src/components/Feed.ytsort.dom.test.tsx", "src/components/FeedFiltersProvider.dom.test.ts", "src/components/FeedFiltersProvider.seed.dom.test.tsx", + "src/components/Header.searchTip.dom.test.tsx", "src/components/Home.dom.test.tsx", "src/components/Home.pin.dom.test.tsx", "src/components/IssueComposer.dom.test.tsx",