Use user's selected language in api (#1492)

This commit is contained in:
2026-05-05 22:43:44 +02:00
committed by GitHub
7 changed files with 142 additions and 5 deletions
+9
View File
@@ -7,6 +7,7 @@ import {
useMutation as useRQMutation,
} from "@tanstack/react-query";
import { useCallback, useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import type { z } from "zod/v4";
import { type KyooError, type Page, Paged } from "~/models";
@@ -28,6 +29,7 @@ export const queryFn = async <Parser extends z.ZodTypeAny>(context: {
formData?: FormData;
plainText?: boolean;
authToken: string | null;
lang?: string;
parser: Parser | null;
signal?: AbortSignal;
}): Promise<z.infer<Parser>> => {
@@ -47,6 +49,7 @@ export const queryFn = async <Parser extends z.ZodTypeAny>(context: {
? { Authorization: `Bearer ${context.authToken}` }
: {}),
...(context.body ? { "Content-Type": "application/json" } : {}),
"Accept-Language": context.lang ?? "en",
},
signal: context.signal,
});
@@ -170,6 +173,7 @@ export const keyToUrl = (key: ReturnType<typeof toQueryKey>) => {
};
export const useFetch = <Data,>(query: QueryIdentifier<Data>) => {
const { i18n } = useTranslation();
let { apiUrl, authToken, selectedAccount } = useContext(AccountContext);
if (query.options?.apiUrl) apiUrl = query.options.apiUrl;
const key = toQueryKey({ apiUrl, path: query.path, params: query.params });
@@ -182,6 +186,7 @@ export const useFetch = <Data,>(query: QueryIdentifier<Data>) => {
parser: query.parser,
signal: ctx.signal,
authToken: authToken ?? null,
lang: i18n.resolvedLanguage,
...query.options,
}) as Promise<Data>,
placeholderData: query.placeholderData as any,
@@ -230,6 +235,7 @@ export const useRefresh = (queries: QueryIdentifier<unknown>[]) => {
};
export const useInfiniteFetch = <Data,>(query: QueryIdentifier<Data>) => {
const { i18n } = useTranslation();
let { apiUrl, authToken } = useContext(AccountContext);
if (query.options?.apiUrl) apiUrl = query.options.apiUrl;
const key = toQueryKey({ apiUrl, path: query.path, params: query.params });
@@ -242,6 +248,7 @@ export const useInfiniteFetch = <Data,>(query: QueryIdentifier<Data>) => {
parser: query.parser ? Paged(query.parser) : null,
signal: ctx.signal,
authToken: authToken ?? null,
lang: i18n.resolvedLanguage,
...query.options,
}) as Promise<Page<Data>>,
getNextPageParam: (page: Page<Data>) => page?.next || undefined,
@@ -331,6 +338,7 @@ export const useMutation = <Ret = unknown, T = void, QueryRet = void>({
optimisticKey?: QueryIdentifier<unknown>;
invalidate: string[] | null;
}) => {
const { i18n } = useTranslation();
const { apiUrl, authToken } = useContext(AccountContext);
const queryClient = useQueryClient();
const mutation = useRQMutation({
@@ -346,6 +354,7 @@ export const useMutation = <Ret = unknown, T = void, QueryRet = void>({
body,
formData,
authToken,
lang: i18n.resolvedLanguage,
parser: parser ?? null,
});
},
+11 -3
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEvent, type VideoPlayer } from "react-native-video";
import type { Chapter } from "~/models";
import { Button } from "~/primitives";
import { Button, P } from "~/primitives";
import { useAccount } from "~/providers/account-context";
import { useFetch } from "~/query";
import { Info } from "~/ui/info";
@@ -76,12 +76,20 @@ export const SkipChapterButton = ({
return (
<Button
text={t(`player.chapters.skip`, { type: chapter.type })}
onPress={skipChapter}
className={cn(
"absolute right-safe bottom-2/10 m-8",
"z-20 bg-slate-900/70 px-4 py-2",
)}
/>
>
<P
className={cn(
"text-center text-slate-300 dark:text-slate-300",
"group-focus-within:text-slate-200 group-hover:text-slate-200",
)}
>
{t(`player.chapters.skip`, { type: chapter.type })}
</P>
</Button>
);
};
+6 -1
View File
@@ -2,6 +2,7 @@ import Theme from "@material-symbols/svg-400/outlined/dark_mode.svg";
import Language from "@material-symbols/svg-400/outlined/language.svg";
import Android from "@material-symbols/svg-400/rounded/android.svg";
import Public from "@material-symbols/svg-400/rounded/public.svg";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Uniwind, useUniwind } from "uniwind";
import { Link, Select } from "~/primitives";
@@ -11,6 +12,7 @@ import { Preference, SettingsContainer } from "./base";
export const GeneralSettings = () => {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const theme = useUniwind();
const getLanguageName = useLanguageName();
@@ -41,7 +43,10 @@ export const GeneralSettings = () => {
<Select
label={t("settings.general.language.label")}
value={i18n.resolvedLanguage!}
onValueChange={(value) => i18n.changeLanguage(value)}
onValueChange={(value) => {
i18n.changeLanguage(value);
queryClient.invalidateQueries();
}}
values={supportedLanguages}
getLabel={(key) => getLanguageName(key) ?? key}
/>
+7
View File
@@ -344,6 +344,13 @@ func (ts *Stream) run(ctx context.Context, start int32) error {
cmd.Process.Signal(os.Interrupt)
slog.InfoContext(ctx, "killing ffmpeg because segment already ready", "segment", segment, "encoderId", encoder_id)
should_stop = true
} else if copy_audio && end < length && segment == end-1 {
// Extra overlap segment for copied audio.
// Delete the file immediately and stop without closing
// the channel, so a real producer can close it later.
extraPath := fmt.Sprintf(ts.handle.getOutPath(encoder_id), segment)
_ = os.Remove(extraPath)
should_stop = true
} else {
ts.segments[segment].encoder = encoder_id
close(ts.segments[segment].channel)
+1 -1
View File
@@ -43,7 +43,7 @@ For `docker-compose.dev.yml` with API key auth via Traefik:
```bash
TRANSCODER_BASE_URL="http://localhost:8901/video" \
TRANSCODER_MEDIA_PATH="/video/Akudama Drive S01E01.mkv" \
TRANSCODER_MEDIA_PATH="/video/The legend of korra S04E05.mkv" \
TRANSCODER_API_KEY="admin" \
uv run --with pytest pytest transcoder/tests/hls_validation -q
```
@@ -37,9 +37,12 @@ def test_config() -> TestConfig:
)
api_key = os.getenv("TRANSCODER_API_KEY", "").strip()
auth_token = os.getenv("TRANSCODER_AUTH_TOKEN", "").strip()
headers: dict[str, str] = {}
if api_key:
headers["X-Api-Key"] = api_key
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"
return TestConfig(
base_url=os.getenv(
@@ -0,0 +1,105 @@
from __future__ import annotations
from .hls_utils import fetch_text, parse_media_playlist, probe_segment_timeline
def test_copied_audio_has_no_gaps_at_lazy_window_boundaries(
master_context: dict, test_config, byte_cache
) -> None:
"""For -c:a copy, each lazy window (every ~100 segments) can drop a
boundary-crossing packet. Verify there are no audible gaps."""
max_allowed_gap_seconds = 0.06
failures: list[str] = []
for audio in master_context["audios"]:
# Only test the "original" (copied) audio rendition.
if "original" not in audio.url:
continue
text = fetch_text(
audio.url,
timeout_seconds=test_config.timeout_seconds,
headers=test_config.headers,
)
playlist = parse_media_playlist(text, audio.url, master_context["client_id"])
if len(playlist.segment_urls) < 102:
# Not enough segments to cross a boundary
continue
# Probe segments around each 100-segment boundary.
boundaries = [i for i in range(99, len(playlist.segment_urls), 100)]
for boundary in boundaries:
before = probe_segment_timeline(
segment_url=playlist.segment_urls[boundary],
map_url=playlist.map_url,
timeout_seconds=test_config.timeout_seconds,
byte_cache=byte_cache,
headers=test_config.headers,
)
after = probe_segment_timeline(
segment_url=playlist.segment_urls[boundary + 1],
map_url=playlist.map_url,
timeout_seconds=test_config.timeout_seconds,
byte_cache=byte_cache,
headers=test_config.headers,
)
gap = after.start - before.end
if gap > max_allowed_gap_seconds:
failures.append(
f"{audio.attrs.get('GROUP-ID', audio.url)}"
f" boundary={boundary}->{boundary + 1}"
f" gap={gap:.6f}s"
)
assert not failures, "copied audio gap at lazy window boundary: " + ", ".join(
failures
)
def test_copied_audio_has_no_large_gaps_anywhere(
master_context: dict, test_config, byte_cache
) -> None:
"""Check every consecutive segment pair in the copied audio for gaps."""
max_allowed_gap_seconds = 0.06
failures: list[str] = []
for audio in master_context["audios"]:
if "original" not in audio.url:
continue
text = fetch_text(
audio.url,
timeout_seconds=test_config.timeout_seconds,
headers=test_config.headers,
)
playlist = parse_media_playlist(text, audio.url, master_context["client_id"])
if len(playlist.segment_urls) < 2:
continue
# Check up to 50 consecutive pairs (enough to cross a window boundary).
sample_count = min(len(playlist.segment_urls) - 1, 50)
for idx in range(sample_count):
before = probe_segment_timeline(
segment_url=playlist.segment_urls[idx],
map_url=playlist.map_url,
timeout_seconds=test_config.timeout_seconds,
byte_cache=byte_cache,
headers=test_config.headers,
)
after = probe_segment_timeline(
segment_url=playlist.segment_urls[idx + 1],
map_url=playlist.map_url,
timeout_seconds=test_config.timeout_seconds,
byte_cache=byte_cache,
headers=test_config.headers,
)
gap = after.start - before.end
if gap > max_allowed_gap_seconds:
failures.append(
f"{audio.attrs.get('GROUP-ID', audio.url)}"
f" seg={idx}->{idx + 1} gap={gap:.6f}s"
)
# Fail fast on the first large gap
break
assert not failures, "copied audio gap detected: " + ", ".join(failures)