refactor: improve error handling for custom player

This commit is contained in:
Kentai Radiquum 2025-03-22 21:04:31 +05:00
parent fc8fe97da7
commit f94b551cdf
Signed by: Radiquum
GPG key ID: 858E8EE696525EED
2 changed files with 328 additions and 187 deletions

View file

@ -27,6 +27,46 @@ export async function tryCatch<T, E = Error>(
} }
} }
export async function tryCatchPlayer<T, E = Error>(
promise: Promise<any>
): Promise<Result<any, any>> {
try {
const res: Awaited<Response> = await promise;
const data = await res.json();
if (!res.ok) {
if (data.message) {
return {
data: null,
error: {
message: data.message,
code: res.status,
},
};
} else if (data.detail) {
return {
data: null,
error: {
message: data.detail,
code: res.status,
},
};
} else {
return {
data: null,
error: {
message: res.statusText,
code: res.status,
},
};
}
}
return { data, error: null };
} catch (error) {
return { data: null, error: error as E };
}
}
export async function tryCatchAPI<T, E = Error>( export async function tryCatchAPI<T, E = Error>(
promise: Promise<any> promise: Promise<any>
): Promise<Result<any, any>> { ): Promise<Result<any, any>> {

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { Card } from "flowbite-react"; import { Button, Card } from "flowbite-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ENDPOINTS } from "#/api/config"; import { ENDPOINTS } from "#/api/config";
@ -14,6 +14,7 @@ import HlsVideo from "hls-video-element/react";
import VideoJS from "videojs-video-element/react"; import VideoJS from "videojs-video-element/react";
import MediaThemeSutro from "./MediaThemeSutro"; import MediaThemeSutro from "./MediaThemeSutro";
import { getAnonEpisodesWatched } from "./ReleasePlayer"; import { getAnonEpisodesWatched } from "./ReleasePlayer";
import { tryCatchPlayer, tryCatchAPI } from "#/api/utils";
export const ReleasePlayerCustom = (props: { export const ReleasePlayerCustom = (props: {
id: number; id: number;
@ -38,157 +39,207 @@ export const ReleasePlayerCustom = (props: {
useCustom: false, useCustom: false,
}); });
const [playbackRate, setPlaybackRate] = useState(1); const [playbackRate, setPlaybackRate] = useState(1);
const [playerError, setPlayerError] = useState(null);
const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const playerPreferenceStore = useUserPlayerPreferencesStore(); const playerPreferenceStore = useUserPlayerPreferencesStore();
const preferredVO = playerPreferenceStore.getPreferredVoiceover(props.id); const preferredVO = playerPreferenceStore.getPreferredVoiceover(props.id);
const preferredSource = playerPreferenceStore.getPreferredPlayer(props.id); const preferredSource = playerPreferenceStore.getPreferredPlayer(props.id);
const _fetchVoiceover = async (release_id: number) => { async function _fetchAPI(
let url = `${ENDPOINTS.release.episode}/${release_id}`; url: string,
if (props.token) { onErrorMsg: string,
url += `?token=${props.token}`; onErrorCodes?: Record<number, string>
} ) {
const response = await fetch(url); const { data, error } = await tryCatchAPI(fetch(url));
const data = await response.json(); if (error) {
return data; let errorDetail = "Мы правда не знаем что произошло...";
};
const _fetchSource = async (release_id: number, voiceover_id: number) => { if (error.name) {
const response = await fetch( if (error.name == "TypeError") {
`${ENDPOINTS.release.episode}/${release_id}/${voiceover_id}` errorDetail = "Не удалось подключиться к серверу";
); } else {
const data = await response.json(); errorDetail = `Неизвестная ошибка ${error.name}: ${error.message}`;
return data; }
}; }
if (error.code) {
if (Object.keys(onErrorCodes).includes(error.code.toString())) {
errorDetail = onErrorCodes[error.code.toString()];
} else {
errorDetail = `API вернуло ошибку: ${error.code}`;
}
}
const _fetchEpisode = async ( setPlayerError({
release_id: number, message: onErrorMsg,
voiceover_id: number, detail: errorDetail,
source_id: number });
) => { return null;
let url = `${ENDPOINTS.release.episode}/${release_id}/${voiceover_id}/${source_id}`;
if (props.token) {
url += `?token=${props.token}`;
} }
const response = await fetch(url);
const data = await response.json();
return data; return data;
}; }
async function _fetchPlayer(url: string) {
const { data, error } = (await tryCatchPlayer(fetch(url))) as any;
if (error) {
let errorDetail = "Мы правда не знаем что произошло...";
if (error.name) {
if (error.name == "TypeError") {
errorDetail = "Не удалось подключиться к серверу";
} else {
errorDetail = `Неизвестная ошибка ${error.name}: ${error.message}`;
}
} else if (error.message) {
errorDetail = error.message;
}
setPlayerError({
message: "Не удалось получить ссылку на видео",
detail: errorDetail,
});
return null;
}
return data;
}
const _fetchKodikManifest = async (url: string) => { const _fetchKodikManifest = async (url: string) => {
const response = await fetch( const data = await _fetchPlayer(
`https://anix-player.wah.su/?url=${url}&player=kodik` `https://anix-player.wah.su/?url=${url}&player=kodik`
); );
const data = await response.json(); if (data) {
let lowQualityLink = data.links["360"][0].src; let lowQualityLink = data.links["360"][0].src;
if (lowQualityLink.includes("https://")) { if (lowQualityLink.includes("https://")) {
lowQualityLink = lowQualityLink.replace("https://", "//"); lowQualityLink = lowQualityLink.replace("https://", "//");
}
let manifest = `https:${lowQualityLink.replace("360.mp4:hls:", "")}`;
let poster = `https:${lowQualityLink.replace("360.mp4:hls:manifest.m3u8", "thumb001.jpg")}`;
if (lowQualityLink.includes("animetvseries")) {
let blobTxt = "#EXTM3U\n";
if (data.links.hasOwnProperty("240")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=427x240,BANDWIDTH=200000\n";
!data.links["240"][0].src.startsWith("https:") ?
(blobTxt += `https:${data.links["240"][0].src}\n`)
: (blobTxt += `${data.links["240"][0].src}\n`);
}
if (data.links.hasOwnProperty("360")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=578x360,BANDWIDTH=400000\n";
!data.links["360"][0].src.startsWith("https:") ?
(blobTxt += `https:${data.links["360"][0].src}\n`)
: (blobTxt += `${data.links["360"][0].src}\n`);
}
if (data.links.hasOwnProperty("480")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=854x480,BANDWIDTH=596000\n";
!data.links["480"][0].src.startsWith("https:") ?
(blobTxt += `https:${data.links["480"][0].src}\n`)
: (blobTxt += `${data.links["480"][0].src}\n`);
}
if (data.links.hasOwnProperty("720")) {
blobTxt +=
"#EXT-X-STREAM-INF:RESOLUTION=1280x720,BANDWIDTH=1280000\n";
!data.links["720"][0].src.startsWith("https:") ?
(blobTxt += `https:${data.links["720"][0].src}\n`)
: (blobTxt += `${data.links["720"][0].src}\n`);
}
let file = new File([blobTxt], "manifest.m3u8", {
type: "application/x-mpegURL",
});
manifest = URL.createObjectURL(file);
}
return { manifest, poster };
} }
let manifest = `https:${lowQualityLink.replace("360.mp4:hls:", "")}`; return { manifest: null, poster: null };
let poster = `https:${lowQualityLink.replace("360.mp4:hls:manifest.m3u8", "thumb001.jpg")}`;
if (lowQualityLink.includes("animetvseries")) {
let blobTxt = "#EXTM3U\n";
if (data.links.hasOwnProperty("240")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=427x240,BANDWIDTH=200000\n";
!data.links["240"][0].src.startsWith("https:") ?
(blobTxt += `https:${data.links["240"][0].src}\n`)
: (blobTxt += `${data.links["240"][0].src}\n`);
}
if (data.links.hasOwnProperty("360")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=578x360,BANDWIDTH=400000\n";
!data.links["360"][0].src.startsWith("https:") ?
(blobTxt += `https:${data.links["360"][0].src}\n`)
: (blobTxt += `${data.links["360"][0].src}\n`);
}
if (data.links.hasOwnProperty("480")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=854x480,BANDWIDTH=596000\n";
!data.links["480"][0].src.startsWith("https:") ?
(blobTxt += `https:${data.links["480"][0].src}\n`)
: (blobTxt += `${data.links["480"][0].src}\n`);
}
if (data.links.hasOwnProperty("720")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=1280x720,BANDWIDTH=1280000\n";
!data.links["720"][0].src.startsWith("https:") ?
(blobTxt += `https:${data.links["720"][0].src}\n`)
: (blobTxt += `${data.links["720"][0].src}\n`);
}
let file = new File([blobTxt], "manifest.m3u8", {
type: "application/x-mpegURL",
});
manifest = URL.createObjectURL(file);
}
return { manifest, poster };
}; };
const _fetchAnilibriaManifest = async (url: string) => { const _fetchAnilibriaManifest = async (url: string) => {
const id = url.split("?id=")[1].split("&ep=")[0]; const id = url.split("?id=")[1].split("&ep=")[0];
const data = await _fetchPlayer(
`https://api.anilibria.tv/v3/title?id=${id}`
);
if (data) {
const host = `https://${data.player.host}`;
const ep = data.player.list[episode.selected.position];
const response = await fetch(`https://api.anilibria.tv/v3/title?id=${id}`); const blobTxt = `#EXTM3U\n${ep.hls.sd && `#EXT-X-STREAM-INF:RESOLUTION=854x480,BANDWIDTH=596000\n${host}${ep.hls.sd}\n`}${ep.hls.hd && `#EXT-X-STREAM-INF:RESOLUTION=1280x720,BANDWIDTH=1280000\n${host}${ep.hls.hd}\n`}${ep.hls.fhd && `#EXT-X-STREAM-INF:RESOLUTION=1920x1080,BANDWIDTH=2560000\n${host}${ep.hls.fhd}\n`}`;
const data = await response.json(); let file = new File([blobTxt], "manifest.m3u8", {
type: "application/x-mpegURL",
const host = `https://${data.player.host}`; });
const ep = data.player.list[episode.selected.position]; let manifest = URL.createObjectURL(file);
let poster = `https://anixart.libria.fun${ep.preview}`;
const blobTxt = `#EXTM3U\n${ep.hls.sd && `#EXT-X-STREAM-INF:RESOLUTION=854x480,BANDWIDTH=596000\n${host}${ep.hls.sd}\n`}${ep.hls.hd && `#EXT-X-STREAM-INF:RESOLUTION=1280x720,BANDWIDTH=1280000\n${host}${ep.hls.hd}\n`}${ep.hls.fhd && `#EXT-X-STREAM-INF:RESOLUTION=1920x1080,BANDWIDTH=2560000\n${host}${ep.hls.fhd}\n`}`; return { manifest, poster };
let file = new File([blobTxt], "manifest.m3u8", { }
type: "application/x-mpegURL", return { manifest: null, poster: null };
});
let manifest = URL.createObjectURL(file);
let poster = `https://anixart.libria.fun${ep.preview}`;
return { manifest, poster };
}; };
const _fetchSibnetManifest = async (url: string) => { const _fetchSibnetManifest = async (url: string) => {
const response = await fetch( const data = await _fetchPlayer(
`https://sibnet.anix-player.wah.su/?url=${url}` `https://sibnet.anix-player.wah.su/?url=${url}`
); );
const data = await response.json(); if (data) {
let manifest = data.video;
let manifest = data.video; let poster = data.poster;
let poster = data.poster; return { manifest, poster };
return { manifest, poster }; }
return { manifest: null, poster: null };
}; };
useEffect(() => { useEffect(() => {
const __getInfo = async () => { const __getInfo = async () => {
const vo = await _fetchVoiceover(props.id); let url = `${ENDPOINTS.release.episode}/${props.id}`;
const selectedVO = if (props.token) {
vo.types.find((voiceover: any) => voiceover.name === preferredVO) || url += `?token=${props.token}`;
vo.types[0]; }
setVoiceover({ const vo = await _fetchAPI(
selected: selectedVO, url,
available: vo.types, "Не удалось получить информацию о озвучках",
}); { 1: "Просмотр запрещён" }
);
if (vo) {
const selectedVO =
vo.types.find((voiceover: any) => voiceover.name === preferredVO) ||
vo.types[0];
setVoiceover({
selected: selectedVO,
available: vo.types,
});
}
}; };
__getInfo(); __getInfo();
}, []); }, []);
useEffect(() => { useEffect(() => {
const __getInfo = async () => { const __getInfo = async () => {
const src = await _fetchSource(props.id, voiceover.selected.id); let url = `${ENDPOINTS.release.episode}/${props.id}/${voiceover.selected.id}`;
const selectedSrc = const src = await _fetchAPI(
src.sources.find((source: any) => source.name === preferredSource) || url,
src.sources[0]; "Не удалось получить информацию о источниках"
if (selectedSrc.episodes_count == 0) { );
const remSources = src.sources.filter( if (src) {
(source: any) => source.id !== selectedSrc.id const selectedSrc =
); src.sources.find((source: any) => source.name === preferredSource) ||
src.sources[0];
if (selectedSrc.episodes_count == 0) {
const remSources = src.sources.filter(
(source: any) => source.id !== selectedSrc.id
);
setSource({
selected: remSources[0],
available: remSources,
});
return;
}
setSource({ setSource({
selected: remSources[0], selected: selectedSrc,
available: remSources, available: src.sources,
}); });
return;
} }
setSource({
selected: selectedSrc,
available: src.sources,
});
}; };
if (voiceover.selected) { if (voiceover.selected) {
__getInfo(); __getInfo();
@ -197,34 +248,38 @@ export const ReleasePlayerCustom = (props: {
useEffect(() => { useEffect(() => {
const __getInfo = async () => { const __getInfo = async () => {
const episodes = await _fetchEpisode( let url = `${ENDPOINTS.release.episode}/${props.id}/${voiceover.selected.id}/${source.selected.id}`;
props.id, if (props.token) {
voiceover.selected.id, url += `?token=${props.token}`;
source.selected.id }
const episodes = await _fetchAPI(
url,
"Не удалось получить информацию о эпизодах"
); );
if (episodes) {
let anonEpisodesWatched = getAnonEpisodesWatched(
props.id,
source.selected.id,
voiceover.selected.id
);
let lastEpisodeWatched = Math.max.apply(
0,
Object.keys(
anonEpisodesWatched[props.id][source.selected.id][
voiceover.selected.id
]
)
);
let selectedEpisode =
episodes.episodes.find(
(episode: any) => episode.position == lastEpisodeWatched
) || episodes.episodes[0];
let anonEpisodesWatched = getAnonEpisodesWatched( setEpisode({
props.id, selected: selectedEpisode,
source.selected.id, available: episodes.episodes,
voiceover.selected.id });
); }
let lastEpisodeWatched = Math.max.apply(
0,
Object.keys(
anonEpisodesWatched[props.id][source.selected.id][
voiceover.selected.id
]
)
);
let selectedEpisode =
episodes.episodes.find(
(episode: any) => episode.position == lastEpisodeWatched
) || episodes.episodes[0];
setEpisode({
selected: selectedEpisode,
available: episodes.episodes,
});
}; };
if (source.selected) { if (source.selected) {
__getInfo(); __getInfo();
@ -237,36 +292,45 @@ export const ReleasePlayerCustom = (props: {
const { manifest, poster } = await _fetchKodikManifest( const { manifest, poster } = await _fetchKodikManifest(
episode.selected.url episode.selected.url
); );
SetPlayerProps({ if (manifest) {
src: manifest, SetPlayerProps({
poster: poster, src: manifest,
useCustom: true, poster: poster,
type: "hls", useCustom: true,
}); type: "hls",
});
setIsLoading(false);
}
return; return;
} }
if (source.selected.name == "Libria") { if (source.selected.name == "Libria") {
const { manifest, poster } = await _fetchAnilibriaManifest( const { manifest, poster } = await _fetchAnilibriaManifest(
episode.selected.url episode.selected.url
); );
SetPlayerProps({ if (manifest) {
src: manifest, SetPlayerProps({
poster: poster, src: manifest,
useCustom: true, poster: poster,
type: "hls", useCustom: true,
}); type: "hls",
});
setIsLoading(false);
}
return; return;
} }
if (source.selected.name == "Sibnet") { if (source.selected.name == "Sibnet") {
const { manifest, poster } = await _fetchSibnetManifest( const { manifest, poster } = await _fetchSibnetManifest(
episode.selected.url episode.selected.url
); );
SetPlayerProps({ if (manifest) {
src: manifest, SetPlayerProps({
poster: poster, src: manifest,
useCustom: true, poster: poster,
type: "mp4", useCustom: true,
}); type: "mp4",
});
setIsLoading(false);
}
return; return;
} }
SetPlayerProps({ SetPlayerProps({
@ -275,6 +339,7 @@ export const ReleasePlayerCustom = (props: {
useCustom: false, useCustom: false,
type: null, type: null,
}); });
setIsLoading(false);
}; };
if (episode.selected) { if (episode.selected) {
__getInfo(); __getInfo();
@ -283,31 +348,47 @@ export const ReleasePlayerCustom = (props: {
return ( return (
<Card className="aspect-video min-h-min-h-[300px] sm:min-h-[466px] md:min-h-[540px] lg:min-h-[512px] xl:min-h-[608px] 2xl:min-h-[712px]"> <Card className="aspect-video min-h-min-h-[300px] sm:min-h-[466px] md:min-h-[540px] lg:min-h-[512px] xl:min-h-[608px] 2xl:min-h-[712px]">
{( <div className="flex flex-wrap gap-4">
!voiceover.selected || {voiceover.selected && (
!source.selected || <VoiceoverSelector
!episode.selected || availableVoiceover={voiceover.available}
!playerProps.src voiceover={voiceover.selected}
) ? setVoiceover={setVoiceover}
<div className="flex items-center justify-center w-full aspect-video"> release_id={props.id}
<Spinner /> />
</div> )}
: <div className="flex flex-col gap-4"> {source.selected && (
<div className="flex flex-wrap gap-4"> <SourceSelector
<VoiceoverSelector availableSource={source.available}
availableVoiceover={voiceover.available} source={source.selected}
voiceover={voiceover.selected} setSource={setSource}
setVoiceover={setVoiceover} release_id={props.id}
release_id={props.id} />
/> )}
<SourceSelector </div>
availableSource={source.available}
source={source.selected} <div className="flex items-center justify-center w-full h-full">
setSource={setSource} {isLoading ?
release_id={props.id} !playerError ?
/> <Spinner />
</div> : <div className="flex flex-col gap-2">
{playerProps.useCustom ? <p className="text-lg font-bold">Ошибка: {playerError.message}</p>
{!isErrorDetailsOpen ?
<Button
color="light"
size="xs"
onClick={() => setIsErrorDetailsOpen(true)}
>
Подробнее
</Button>
: <p className="text-gray-600 dark:text-gray-100">
{playerError.detail}
</p>
}
</div>
: playerProps.useCustom ?
!playerError ?
<MediaThemeSutro className="object-none w-full aspect-video"> <MediaThemeSutro className="object-none w-full aspect-video">
{playerProps.type == "hls" ? {playerProps.type == "hls" ?
<HlsVideo <HlsVideo
@ -334,7 +415,27 @@ export const ReleasePlayerCustom = (props: {
></VideoJS> ></VideoJS>
} }
</MediaThemeSutro> </MediaThemeSutro>
: <iframe src={playerProps.src} className="w-full aspect-video" />} : <div className="flex flex-col gap-2">
<p className="text-lg font-bold">Ошибка: {playerError.message}</p>
{!isErrorDetailsOpen ?
<Button
color="light"
size="xs"
onClick={() => setIsErrorDetailsOpen(true)}
>
Подробнее
</Button>
: <p className="text-gray-600 dark:text-gray-100">
{playerError.detail}
</p>
}
</div>
: <iframe src={playerProps.src} className="w-full aspect-video" />}
</div>
<div>
{episode.selected && source.selected && voiceover.selected && (
<EpisodeSelector <EpisodeSelector
availableEpisodes={episode.available} availableEpisodes={episode.available}
episode={episode.selected} episode={episode.selected}
@ -344,8 +445,8 @@ export const ReleasePlayerCustom = (props: {
voiceover={voiceover.selected} voiceover={voiceover.selected}
token={props.token} token={props.token}
/> />
</div> )}
} </div>
</Card> </Card>
); );
}; };