diff --git a/stats/src/components/anime/TmdbSelector.test.tsx b/stats/src/components/anime/TmdbSelector.test.tsx
new file mode 100644
index 00000000..0a530319
--- /dev/null
+++ b/stats/src/components/anime/TmdbSelector.test.tsx
@@ -0,0 +1,387 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { Window } from 'happy-dom';
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { apiClient } from '../../lib/api-client';
+import type { StatsTmdbSearchResult } from '../../types/stats';
+import { TmdbSelector } from './TmdbSelector';
+
+interface TestWindow extends Window {
+ IS_REACT_ACT_ENVIRONMENT?: boolean;
+}
+
+function installDom(): () => void {
+ const previousWindow = globalThis.window;
+ const previousDocument = globalThis.document;
+ const previousHTMLElement = globalThis.HTMLElement;
+ const previousISReactActEnvironment = (
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
+ ).IS_REACT_ACT_ENVIRONMENT;
+ const window = new Window() as TestWindow;
+
+ Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
+ Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
+ Object.defineProperty(globalThis, 'HTMLElement', {
+ value: window.HTMLElement,
+ configurable: true,
+ });
+ (
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
+ ).IS_REACT_ACT_ENVIRONMENT = true;
+
+ return () => {
+ Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
+ Object.defineProperty(globalThis, 'document', {
+ value: previousDocument,
+ configurable: true,
+ });
+ Object.defineProperty(globalThis, 'HTMLElement', {
+ value: previousHTMLElement,
+ configurable: true,
+ });
+ (
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
+ ).IS_REACT_ACT_ENVIRONMENT = previousISReactActEnvironment;
+ };
+}
+
+const HANZAWA: StatsTmdbSearchResult = {
+ tmdbId: 61222,
+ tmdbType: 'tv',
+ title: 'Hanzawa Naoki',
+ originalTitle: '半沢直樹',
+ originalLanguage: 'ja',
+ overview: null,
+ posterUrl: null,
+ year: 2013,
+ isAnimation: false,
+};
+
+test('TmdbSelector searches the normalized title and links the picked result by id', async () => {
+ const uninstallDom = installDom();
+ const original = {
+ searchTmdb: apiClient.searchTmdb,
+ reassignAnimeTmdb: apiClient.reassignAnimeTmdb,
+ };
+ const searchCalls: string[] = [];
+ const linkCalls: Array<[number, { tmdbId: number; tmdbType: string }]> = [];
+ let linked = 0;
+ apiClient.searchTmdb = (async (query: string) => {
+ searchCalls.push(query);
+ return [HANZAWA];
+ }) as typeof apiClient.searchTmdb;
+ apiClient.reassignAnimeTmdb = (async (animeId: number, info) => {
+ linkCalls.push([animeId, info]);
+ }) as typeof apiClient.reassignAnimeTmdb;
+
+ try {
+ const container = document.createElement('div');
+ document.body.append(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(
+
{}}
+ onLinked={() => {
+ linked += 1;
+ }}
+ />,
+ );
+ });
+
+ assert.deepEqual(searchCalls, ['Hanzawa Naoki']);
+ assert.match(container.textContent ?? '', /半沢直樹/);
+ assert.match(container.textContent ?? '', /TV · 2013/);
+
+ const pick = [...container.querySelectorAll('button')].find((button) =>
+ /Select/.test(button.textContent ?? ''),
+ );
+ assert.ok(pick);
+ await act(async () => {
+ pick.click();
+ });
+
+ assert.deepEqual(linkCalls, [[9, { tmdbId: 61222, tmdbType: 'tv' }]]);
+ assert.equal(linked, 1);
+
+ await act(async () => {
+ root.unmount();
+ });
+ } finally {
+ apiClient.searchTmdb = original.searchTmdb;
+ apiClient.reassignAnimeTmdb = original.reassignAnimeTmdb;
+ uninstallDom();
+ }
+});
+
+test('TmdbSelector explains a missing API key instead of showing "No results"', async () => {
+ const uninstallDom = installDom();
+ const originalSearch = apiClient.searchTmdb;
+ apiClient.searchTmdb = (async () => {
+ throw new Error('Stats API error: 503 {"error":"TMDB API key not configured."}');
+ }) as typeof apiClient.searchTmdb;
+
+ try {
+ const container = document.createElement('div');
+ document.body.append(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(
+ {}}
+ onLinked={() => {}}
+ />,
+ );
+ });
+
+ assert.match(container.textContent ?? '', /tmdb\.apiKey/);
+ assert.doesNotMatch(container.textContent ?? '', /No results/);
+
+ await act(async () => {
+ root.unmount();
+ });
+ } finally {
+ apiClient.searchTmdb = originalSearch;
+ uninstallDom();
+ }
+});
+
+test('TmdbSelector reports a failed link as a link problem, not a search failure', async () => {
+ const uninstallDom = installDom();
+ const original = {
+ searchTmdb: apiClient.searchTmdb,
+ reassignAnimeTmdb: apiClient.reassignAnimeTmdb,
+ };
+ apiClient.searchTmdb = (async () => [HANZAWA]) as typeof apiClient.searchTmdb;
+ apiClient.reassignAnimeTmdb = (async () => {
+ throw new Error('Stats API error: 404');
+ }) as typeof apiClient.reassignAnimeTmdb;
+
+ try {
+ const container = document.createElement('div');
+ document.body.append(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(
+ {}}
+ onLinked={() => {}}
+ />,
+ );
+ });
+
+ const pick = [...container.querySelectorAll('button')].find((button) =>
+ /Select/.test(button.textContent ?? ''),
+ );
+ assert.ok(pick);
+ await act(async () => {
+ pick.click();
+ });
+
+ assert.match(container.textContent ?? '', /TMDB has no details for this title/);
+ assert.doesNotMatch(container.textContent ?? '', /search failed/);
+ // The results stay on screen so the user can pick another one.
+ assert.match(container.textContent ?? '', /半沢直樹/);
+
+ await act(async () => {
+ root.unmount();
+ });
+ } finally {
+ apiClient.searchTmdb = original.searchTmdb;
+ apiClient.reassignAnimeTmdb = original.reassignAnimeTmdb;
+ uninstallDom();
+ }
+});
+
+test('TmdbSelector cannot be dismissed while a link is in flight', async () => {
+ const uninstallDom = installDom();
+ const original = {
+ searchTmdb: apiClient.searchTmdb,
+ reassignAnimeTmdb: apiClient.reassignAnimeTmdb,
+ };
+ let finishLink: () => void = () => {};
+ let closed = 0;
+ apiClient.searchTmdb = (async () => [HANZAWA]) as typeof apiClient.searchTmdb;
+ apiClient.reassignAnimeTmdb = (() =>
+ new Promise((resolve) => {
+ finishLink = resolve;
+ })) as typeof apiClient.reassignAnimeTmdb;
+
+ try {
+ const container = document.createElement('div');
+ document.body.append(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(
+ {
+ closed += 1;
+ }}
+ onLinked={() => {}}
+ />,
+ );
+ });
+
+ const pick = [...container.querySelectorAll('button')].find((button) =>
+ /Select/.test(button.textContent ?? ''),
+ );
+ assert.ok(pick);
+ await act(async () => {
+ pick.click();
+ });
+
+ const close = [...container.querySelectorAll('button')].find((button) =>
+ /✕/.test(button.textContent ?? ''),
+ ) as HTMLButtonElement | undefined;
+ assert.ok(close);
+ assert.equal(close.disabled, true);
+ await act(async () => {
+ (container.firstElementChild as HTMLElement).click();
+ });
+ assert.equal(closed, 0);
+
+ await act(async () => {
+ finishLink();
+ });
+
+ await act(async () => {
+ root.unmount();
+ });
+ } finally {
+ apiClient.searchTmdb = original.searchTmdb;
+ apiClient.reassignAnimeTmdb = original.reassignAnimeTmdb;
+ uninstallDom();
+ }
+});
+
+for (const staleFailure of [false, true]) {
+ test(`TmdbSelector ignores superseded ${staleFailure ? 'errors' : 'results'} and loading changes`, async () => {
+ const uninstallDom = installDom();
+ const originalSearch = apiClient.searchTmdb;
+ const requests: Array<{
+ resolve: (results: StatsTmdbSearchResult[]) => void;
+ reject: (error: Error) => void;
+ }> = [];
+ apiClient.searchTmdb = () =>
+ new Promise((resolve, reject) => {
+ requests.push({ resolve, reject });
+ });
+ const container = document.createElement('div');
+ document.body.append(container);
+ const root = createRoot(container);
+ try {
+ const render = (initialQuery: string) =>
+ root.render(
+ {}}
+ onLinked={() => {}}
+ />,
+ );
+ await act(async () => {
+ render('First title');
+ });
+ await act(async () => {
+ render('Second title');
+ });
+ assert.equal(requests.length, 2);
+ await act(async () => {
+ if (staleFailure) requests[0]!.reject(new Error('stale error'));
+ else requests[0]!.resolve([HANZAWA]);
+ });
+ assert.match(container.textContent ?? '', /Searching/);
+ assert.doesNotMatch(container.textContent ?? '', /Hanzawa|failed/);
+ await act(async () => {
+ requests[1]!.resolve([HANZAWA]);
+ });
+ assert.match(container.textContent ?? '', /Hanzawa/);
+ assert.doesNotMatch(container.textContent ?? '', /Searching/);
+ await act(async () => {
+ render('Third title');
+ });
+ await act(async () => {
+ render('');
+ });
+ await act(async () => {
+ requests[2]!.resolve([HANZAWA]);
+ });
+ assert.doesNotMatch(container.textContent ?? '', /Hanzawa|Searching/);
+ } finally {
+ await act(async () => {
+ root.unmount();
+ });
+ apiClient.searchTmdb = originalSearch;
+ uninstallDom();
+ }
+ });
+}
+
+test('TmdbSelector invalidates requests as soon as the user edits or clears the query', async () => {
+ const uninstallDom = installDom();
+ const originalSearch = apiClient.searchTmdb;
+ const requests: Array<(results: StatsTmdbSearchResult[]) => void> = [];
+ apiClient.searchTmdb = () =>
+ new Promise((resolve) => {
+ requests.push(resolve);
+ });
+ const container = document.createElement('div');
+ document.body.append(container);
+ const root = createRoot(container);
+ try {
+ await act(async () => {
+ root.render(
+ {}} onLinked={() => {}} />,
+ );
+ });
+ const input = container.querySelector('input');
+ assert.ok(input);
+ const setValue = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ 'value',
+ )?.set;
+ assert.ok(setValue);
+ const edit = async (value: string) => {
+ await act(async () => {
+ setValue.call(input, value);
+ input.dispatchEvent(new window.Event('input', { bubbles: true }));
+ input.dispatchEvent(new window.KeyboardEvent('keyup', { bubbles: true }));
+ });
+ };
+ await edit('Second');
+ await act(async () => {
+ requests[0]!([HANZAWA]);
+ });
+ assert.doesNotMatch(container.textContent ?? '', /Hanzawa|No results/);
+ assert.match(container.textContent ?? '', /Searching/);
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 450));
+ });
+ assert.equal(requests.length, 2);
+ await edit('');
+ assert.doesNotMatch(container.textContent ?? '', /Searching/);
+ await act(async () => {
+ requests[1]!([HANZAWA]);
+ });
+ assert.doesNotMatch(container.textContent ?? '', /Hanzawa|Searching/);
+ } finally {
+ await act(async () => {
+ root.unmount();
+ });
+ apiClient.searchTmdb = originalSearch;
+ uninstallDom();
+ }
+});
diff --git a/stats/src/components/anime/TmdbSelector.tsx b/stats/src/components/anime/TmdbSelector.tsx
new file mode 100644
index 00000000..f77ecfa3
--- /dev/null
+++ b/stats/src/components/anime/TmdbSelector.tsx
@@ -0,0 +1,199 @@
+import { useState, useEffect, useRef } from 'react';
+import { apiClient } from '../../lib/api-client';
+import { normalizeAnilistSearchQuery } from '../../lib/anilist-search-query';
+import type { StatsTmdbSearchResult } from '../../types/stats';
+
+interface TmdbSelectorProps {
+ animeId: number;
+ initialQuery: string;
+ onClose: () => void;
+ onLinked: () => void;
+}
+
+const MISSING_KEY_MESSAGE =
+ 'TMDB API key not configured. Set tmdb.apiKey or tmdb.apiKeyCommand in your config.';
+
+function statusOf(err: unknown): number | null {
+ const match = err instanceof Error ? /^Stats API error: (\d{3})\b/.exec(err.message) : null;
+ return match ? Number(match[1]) : null;
+}
+
+// The stats API answers a missing key with 503 and the message from config.
+function describeSearchError(err: unknown): string {
+ if (statusOf(err) === 503) return MISSING_KEY_MESSAGE;
+ return 'TMDB search failed. Check your connection and API key.';
+}
+
+// The link route answers 404 when TMDB has no details for the picked id or
+// when the library entry itself is gone.
+function describeLinkError(err: unknown): string {
+ switch (statusOf(err)) {
+ case 503:
+ return MISSING_KEY_MESSAGE;
+ case 404:
+ return 'TMDB has no details for this title. Pick another result or refresh the Library.';
+ default:
+ return 'Linking to TMDB failed. Check your connection and try again.';
+ }
+}
+
+export function TmdbSelector({ animeId, initialQuery, onClose, onLinked }: TmdbSelectorProps) {
+ const [query, setQuery] = useState(() => normalizeAnilistSearchQuery(initialQuery));
+ const [results, setResults] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [linking, setLinking] = useState(null);
+ const inputRef = useRef(null);
+ const debounceRef = useRef | null>(null);
+ const searchSequenceRef = useRef(0);
+
+ useEffect(() => {
+ inputRef.current?.focus();
+ const normalizedInitialQuery = normalizeAnilistSearchQuery(initialQuery);
+ setQuery(normalizedInitialQuery);
+ setResults([]);
+ setError(null);
+ setLoading(false);
+ setLinking(null);
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ if (normalizedInitialQuery) void doSearch(normalizedInitialQuery);
+ return () => {
+ searchSequenceRef.current += 1;
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ };
+ }, [initialQuery, animeId]);
+
+ const doSearch = async (q: string) => {
+ const sequence = ++searchSequenceRef.current;
+ const searchQuery = normalizeAnilistSearchQuery(q);
+ if (!searchQuery) {
+ setResults([]);
+ setError(null);
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ try {
+ const nextResults = await apiClient.searchTmdb(searchQuery);
+ if (sequence === searchSequenceRef.current) setResults(nextResults);
+ } catch (err) {
+ if (sequence !== searchSequenceRef.current) return;
+ setResults([]);
+ setError(describeSearchError(err));
+ } finally {
+ if (sequence === searchSequenceRef.current) setLoading(false);
+ }
+ };
+
+ const handleInput = (value: string) => {
+ searchSequenceRef.current += 1;
+ setQuery(value);
+ setResults([]);
+ setError(null);
+ const hasQuery = Boolean(normalizeAnilistSearchQuery(value));
+ setLoading(hasQuery);
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ if (!hasQuery) return;
+ debounceRef.current = setTimeout(() => void doSearch(value), 400);
+ };
+
+ const handleSelect = async (media: StatsTmdbSearchResult) => {
+ setLinking(media.tmdbId);
+ setError(null);
+ try {
+ await apiClient.reassignAnimeTmdb(animeId, {
+ tmdbId: media.tmdbId,
+ tmdbType: media.tmdbType,
+ });
+ onLinked();
+ } catch (err) {
+ setError(describeLinkError(err));
+ setLinking(null);
+ }
+ };
+
+ // Dismissing mid-link would leave the caller unaware of a relink that is
+ // still going to land, so the backdrop and close button wait for it.
+ const handleDismiss = () => {
+ if (linking === null) onClose();
+ };
+
+ return (
+
+
+
e.stopPropagation()}
+ >
+
+
+
Select TMDB Title
+
+
+
handleInput(e.target.value)}
+ placeholder="Search TMDB for a drama or movie..."
+ className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
+ />
+
+
+
+ {loading &&
Searching...
}
+ {error &&
{error}
}
+ {!loading && !error && results.length === 0 && query.trim() && (
+
No results
+ )}
+ {results.map((media) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/stats/src/components/vocabulary/WordDetailPanel.tsx b/stats/src/components/vocabulary/WordDetailPanel.tsx
index b44123b3..f1cd2ef4 100644
--- a/stats/src/components/vocabulary/WordDetailPanel.tsx
+++ b/stats/src/components/vocabulary/WordDetailPanel.tsx
@@ -1,7 +1,6 @@
import { useRef, useState, useEffect } from 'react';
import { useWordDetail } from '../../hooks/useWordDetail';
import { apiClient } from '../../lib/api-client';
-import { assetUrl } from '../../lib/asset-url';
import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters';
import {
buildStatsMineCardParams,
@@ -167,7 +166,7 @@ export function WordDetailPanel({
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
new Notification('Anki Card Created', {
body: `Mined: ${label}`,
- icon: assetUrl('favicon.png'),
+ icon: '/favicon.png',
});
} else if (typeof Notification !== 'undefined' && Notification.permission !== 'denied') {
Notification.requestPermission().then((p) => {
diff --git a/stats/src/lib/api-client.test.ts b/stats/src/lib/api-client.test.ts
index 0f6092ad..014e6bf9 100644
--- a/stats/src/lib/api-client.test.ts
+++ b/stats/src/lib/api-client.test.ts
@@ -1,36 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { apiClient, BASE_URL, resolveStatsBaseUrl } from './api-client';
-
-test('resolveStatsBaseUrl prefers apiBase query parameter for file-based overlay mode', () => {
- const baseUrl = resolveStatsBaseUrl({
- protocol: 'file:',
- origin: 'null',
- search: '?overlay=1&apiBase=http%3A%2F%2F127.0.0.1%3A6123',
- });
-
- assert.equal(baseUrl, 'http://127.0.0.1:6123');
-});
-
-test('resolveStatsBaseUrl falls back to configured window origin for browser mode', () => {
- const baseUrl = resolveStatsBaseUrl({
- protocol: 'http:',
- origin: 'http://127.0.0.1:6123',
- search: '',
- });
-
- assert.equal(baseUrl, 'http://127.0.0.1:6123');
-});
-
-test('resolveStatsBaseUrl keeps legacy localhost fallback for file mode without apiBase', () => {
- const baseUrl = resolveStatsBaseUrl({
- protocol: 'file:',
- origin: 'null',
- search: '?overlay=1',
- });
-
- assert.equal(baseUrl, 'http://127.0.0.1:6969');
-});
+import { apiClient, BASE_URL } from './api-client';
test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
const getAnimeCoverUrl = apiClient.getAnimeCoverUrl as (
@@ -38,10 +8,7 @@ test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
retryToken?: number,
) => string;
- assert.equal(
- getAnimeCoverUrl(42, 3),
- 'http://127.0.0.1:6969/api/stats/anime/42/cover?coverRetry=3',
- );
+ assert.equal(getAnimeCoverUrl(42, 3), `${BASE_URL}/api/stats/anime/42/cover?coverRetry=3`);
});
test('getAnimeMergeRecommendations loads pending duplicate pairs', async () => {
diff --git a/stats/src/lib/api-client.ts b/stats/src/lib/api-client.ts
index addf6ef6..3462d49c 100644
--- a/stats/src/lib/api-client.ts
+++ b/stats/src/lib/api-client.ts
@@ -15,6 +15,7 @@ import type {
StatsMergeAnimeResponse,
StatsMoveVideoRequest,
StatsMoveVideoResponse,
+ StatsTmdbAssignment,
StatsTrendGroupBy,
StatsTrendRange,
StatsVideoWatchedRequest,
@@ -23,24 +24,8 @@ import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
import { appendCoverRetryToken } from './cover-retry';
import { trackDelete } from './delete-progress';
-type StatsLocationLike = Pick;
-
-export function resolveStatsBaseUrl(location?: StatsLocationLike): string {
- const resolvedLocation =
- location ??
- (typeof window === 'undefined'
- ? { protocol: 'file:', origin: 'null', search: '' }
- : window.location);
-
- const queryApiBase = new URLSearchParams(resolvedLocation.search).get('apiBase')?.trim();
- if (queryApiBase) {
- return queryApiBase;
- }
-
- return resolvedLocation.protocol === 'file:' ? 'http://127.0.0.1:6969' : resolvedLocation.origin;
-}
-
-export const BASE_URL = resolveStatsBaseUrl();
+// Both browser and in-app dashboards use the server that served the page.
+export const BASE_URL = typeof window === 'undefined' ? '' : window.location.origin;
async function fetchResponse(path: string, init?: RequestInit): Promise {
const res = await fetch(`${BASE_URL}${path}`, init);
@@ -256,6 +241,15 @@ export const apiClient = {
body: JSON.stringify(info),
});
},
+ searchTmdb: (query: string) =>
+ fetchJson('tmdbSearch', `/api/stats/tmdb/search?q=${encodeURIComponent(query)}`),
+ reassignAnimeTmdb: async (animeId: number, info: StatsTmdbAssignment): Promise => {
+ await fetchResponse(`/api/stats/anime/${animeId}/tmdb`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(info satisfies StatsTmdbAssignment),
+ });
+ },
mineCard: async (params: StatsMineCardParams): Promise => {
const res = await fetch(`${BASE_URL}/api/stats/mine-card?mode=${params.mode}`, {
method: 'POST',
diff --git a/stats/src/lib/asset-url.test.ts b/stats/src/lib/asset-url.test.ts
deleted file mode 100644
index 399773ed..00000000
--- a/stats/src/lib/asset-url.test.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import assert from 'node:assert/strict';
-import test from 'node:test';
-import { assetUrl, resolveAssetUrl } from './asset-url';
-
-// vite.config.ts sets `base: './'`, so this is what the built bundle sees.
-const BUILT_BASE = './';
-
-test('built asset URLs are never root-absolute', () => {
- assert.equal(resolveAssetUrl('favicon.png', BUILT_BASE).startsWith('/'), false);
-});
-
-test('built asset URL resolves next to a file:// index.html', () => {
- const resolved = new URL(
- resolveAssetUrl('favicon.png', BUILT_BASE),
- 'file:///opt/SubMiner/stats/dist/index.html',
- );
- assert.equal(resolved.href, 'file:///opt/SubMiner/stats/dist/favicon.png');
-});
-
-test('built asset URL resolves against the server root when served over http', () => {
- const resolved = new URL(resolveAssetUrl('favicon.png', BUILT_BASE), 'http://127.0.0.1:8770/');
- assert.equal(resolved.href, 'http://127.0.0.1:8770/favicon.png');
-});
-
-test('dev server base stays root-absolute', () => {
- assert.equal(resolveAssetUrl('favicon.png', '/'), '/favicon.png');
-});
-
-test('a base without a trailing slash still joins cleanly', () => {
- assert.equal(resolveAssetUrl('favicon.png', '/stats'), '/stats/favicon.png');
-});
-
-test('a leading slash in the requested path is tolerated', () => {
- assert.equal(resolveAssetUrl('/favicon.png', BUILT_BASE), './favicon.png');
-});
-
-test('assetUrl falls back to a relative base outside a Vite bundle', () => {
- assert.equal(assetUrl('favicon.png').startsWith('/'), false);
-});
diff --git a/stats/src/lib/asset-url.ts b/stats/src/lib/asset-url.ts
deleted file mode 100644
index 9a8e8d2f..00000000
--- a/stats/src/lib/asset-url.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-/**
- * Resolve a bundled public asset against Vite's base URL.
- *
- * The in-player stats window is loaded with `loadFile`, so the document lives on
- * `file://`. A root-absolute path like `/favicon.png` resolves to the filesystem
- * root there and 404s, while the HTTP-served web app resolves it fine. Vite
- * rewrites asset refs in `index.html` but not string literals in JSX, so build
- * the URL from the configured base instead of hardcoding a leading slash.
- */
-export function resolveAssetUrl(path: string, base: string): string {
- const normalizedBase = base.endsWith('/') ? base : `${base}/`;
- return `${normalizedBase}${path.replace(/^\/+/, '')}`;
-}
-
-function currentBase(): string {
- // Vite injects BASE_URL at build time ('./' per vite.config.ts) and serves '/'
- // in dev. Outside a Vite bundle (tests) there is no env, so fall back to './'.
- const env = (import.meta as { env?: Record }).env;
- return env?.BASE_URL || './';
-}
-
-export function assetUrl(path: string): string {
- return resolveAssetUrl(path, currentBase());
-}
diff --git a/stats/src/lib/media-library-grouping.test.tsx b/stats/src/lib/media-library-grouping.test.tsx
index e57edf04..11448581 100644
--- a/stats/src/lib/media-library-grouping.test.tsx
+++ b/stats/src/lib/media-library-grouping.test.tsx
@@ -169,14 +169,14 @@ test('CoverImage renders explicit remote artwork when src is provided', () => {
test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls', () => {
const markup = renderToStaticMarkup( {}} />);
- assert.match(markup, /src="http:\/\/127\.0\.0\.1:6969\/api\/stats\/media\/1\/cover"/);
+ assert.match(markup, /src="\/api\/stats\/media\/1\/cover"/);
assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/);
});
test('resolveMediaCoverApiUrl appends retry tokens for late cover refreshes', () => {
assert.equal(
resolveMediaCoverApiUrl(youtubeEpisodeA.videoId, 2),
- 'http://127.0.0.1:6969/api/stats/media/1/cover?coverRetry=2',
+ '/api/stats/media/1/cover?coverRetry=2',
);
});
diff --git a/stats/src/lib/yomitan-lookup.test.tsx b/stats/src/lib/yomitan-lookup.test.tsx
index f364becc..dac82c36 100644
--- a/stats/src/lib/yomitan-lookup.test.tsx
+++ b/stats/src/lib/yomitan-lookup.test.tsx
@@ -118,6 +118,8 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
canonicalTitle: 'Anime',
mediaKind: 'anime',
anilistId: null,
+ tmdbId: null,
+ tmdbType: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
diff --git a/vendor/subminer-yomitan b/vendor/subminer-yomitan
index 99d6bf85..57516d3b 160000
--- a/vendor/subminer-yomitan
+++ b/vendor/subminer-yomitan
@@ -1 +1 @@
-Subproject commit 99d6bf853ccf94f10114df5834d5abc68bc8ab55
+Subproject commit 57516d3b7f3bffa604f575026cd39390067137ce