mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-08-15 18:45:24 +00:00
Use user's selected language in api (#1492)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user