19 changed files with 359 additions and 325 deletions
@ -1,116 +1,33 @@
@@ -1,116 +1,33 @@
|
||||
import { gql, request } from "graphql-request"; |
||||
import { list } from "subsrt-ts"; |
||||
import { unzip } from "unzipit"; |
||||
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch"; |
||||
import { languageMap } from "@/setup/iso6391"; |
||||
import { PlayerMeta } from "@/stores/player/slices/source"; |
||||
|
||||
const GQL_API = "https://gqlos.plus-sub.com"; |
||||
|
||||
const subtitleSearchQuery = gql` |
||||
query SubtitleSearch($tmdb_id: String!, $ep: Int, $season: Int) { |
||||
subtitleSearch( |
||||
tmdb_id: $tmdb_id |
||||
language: "" |
||||
episode_number: $ep |
||||
season_number: $season |
||||
) { |
||||
data { |
||||
attributes { |
||||
language |
||||
subtitle_id |
||||
ai_translated |
||||
auto_translation |
||||
ratings |
||||
votes |
||||
legacy_subtitle_id |
||||
} |
||||
id |
||||
} |
||||
} |
||||
} |
||||
`;
|
||||
|
||||
interface RawSubtitleSearchItem { |
||||
id: string; |
||||
attributes: { |
||||
language: string; |
||||
ai_translated: boolean | null; |
||||
auto_translation: null | boolean; |
||||
ratings: number; |
||||
votes: number | null; |
||||
legacy_subtitle_id: string | null; |
||||
}; |
||||
} |
||||
|
||||
export interface SubtitleSearchItem { |
||||
id: string; |
||||
attributes: { |
||||
language: string; |
||||
ai_translated: boolean | null; |
||||
auto_translation: null | boolean; |
||||
ratings: number; |
||||
votes: number | null; |
||||
legacy_subtitle_id: string; |
||||
}; |
||||
} |
||||
|
||||
interface SubtitleSearchData { |
||||
subtitleSearch: { |
||||
data: RawSubtitleSearchItem[]; |
||||
}; |
||||
} |
||||
|
||||
export async function searchSubtitles( |
||||
meta: PlayerMeta |
||||
): Promise<SubtitleSearchItem[]> { |
||||
const data = await request<SubtitleSearchData>({ |
||||
document: subtitleSearchQuery, |
||||
url: GQL_API, |
||||
variables: { |
||||
tmdb_id: meta.tmdbId, |
||||
ep: meta.episode?.number, |
||||
season: meta.season?.number, |
||||
}, |
||||
}); |
||||
|
||||
const sortedByLanguage: Record<string, RawSubtitleSearchItem[]> = {}; |
||||
data.subtitleSearch.data.forEach((v) => { |
||||
if (!sortedByLanguage[v.attributes.language]) |
||||
sortedByLanguage[v.attributes.language] = []; |
||||
sortedByLanguage[v.attributes.language].push(v); |
||||
}); |
||||
|
||||
return Object.values(sortedByLanguage).map((langs) => { |
||||
const onlyLegacySubs = langs.filter( |
||||
(v): v is SubtitleSearchItem => !!v.attributes.legacy_subtitle_id |
||||
); |
||||
const sortedByRating = onlyLegacySubs.sort( |
||||
(a, b) => |
||||
b.attributes.ratings * (b.attributes.votes ?? 0) - |
||||
a.attributes.ratings * (a.attributes.votes ?? 0) |
||||
); |
||||
return sortedByRating[0]; |
||||
}); |
||||
} |
||||
|
||||
export async function downloadSrt(legacySubId: string): Promise<string> { |
||||
// TODO there is cloudflare protection so this may not always work. what to do about that?
|
||||
// TODO also there is ratelimit on the page itself
|
||||
// language code is hardcoded here, it does nothing
|
||||
const zipFile = await proxiedFetch<ArrayBuffer>( |
||||
`https://dl.opensubtitles.org/en/subtitleserve/sub/${legacySubId}`, |
||||
{ |
||||
responseType: "arrayBuffer", |
||||
} |
||||
); |
||||
|
||||
const { entries } = await unzip(zipFile); |
||||
const srtEntry = Object.values(entries).find((v) => v.name); |
||||
if (!srtEntry) throw new Error("No srt file found in zip"); |
||||
const srtData = srtEntry.text(); |
||||
return srtData; |
||||
} |
||||
import { convertSubtitlesToSrt } from "@/components/player/utils/captions"; |
||||
import { CaptionListItem } from "@/stores/player/slices/source"; |
||||
import { SimpleCache } from "@/utils/cache"; |
||||
|
||||
export const subtitleTypeList = list().map((type) => `.${type}`); |
||||
const downloadCache = new SimpleCache<string, string>(); |
||||
downloadCache.setCompare((a, b) => a === b); |
||||
const expirySeconds = 24 * 60 * 60; |
||||
|
||||
/** |
||||
* Always returns SRT |
||||
*/ |
||||
export async function downloadCaption( |
||||
caption: CaptionListItem |
||||
): Promise<string> { |
||||
const cached = downloadCache.get(caption.url); |
||||
if (cached) return cached; |
||||
|
||||
let data: string | undefined; |
||||
if (caption.needsProxy) { |
||||
data = await proxiedFetch<string>(caption.url, { responseType: "text" }); |
||||
} else { |
||||
data = await fetch(caption.url).then((v) => v.text()); |
||||
} |
||||
if (!data) throw new Error("failed to get caption data"); |
||||
|
||||
const output = convertSubtitlesToSrt(data); |
||||
downloadCache.set(caption.url, output, expirySeconds); |
||||
return output; |
||||
} |
||||
|
@ -0,0 +1,152 @@
@@ -0,0 +1,152 @@
|
||||
import classNames from "classnames"; |
||||
import { useCallback, useEffect, useState } from "react"; |
||||
import { useHistory } from "react-router-dom"; |
||||
|
||||
import { UserAvatar } from "@/components/Avatar"; |
||||
import { Icon, Icons } from "@/components/Icon"; |
||||
import { Transition } from "@/components/Transition"; |
||||
import { useAuth } from "@/hooks/auth/useAuth"; |
||||
import { conf } from "@/setup/config"; |
||||
import { useAuthStore } from "@/stores/auth"; |
||||
|
||||
function Divider() { |
||||
return <hr className="border-0 w-full h-px bg-dropdown-border" />; |
||||
} |
||||
|
||||
function GoToLink(props: { |
||||
children: React.ReactNode; |
||||
href?: string; |
||||
className?: string; |
||||
onClick?: () => void; |
||||
}) { |
||||
const history = useHistory(); |
||||
|
||||
const goTo = (href: string) => { |
||||
if (href.startsWith("http")) window.open(href, "_blank"); |
||||
else history.push(href); |
||||
}; |
||||
|
||||
return ( |
||||
<a |
||||
href={props.href} |
||||
onClick={(evt) => { |
||||
evt.preventDefault(); |
||||
if (props.href) goTo(props.href); |
||||
else props.onClick?.(); |
||||
}} |
||||
className={props.className} |
||||
> |
||||
{props.children} |
||||
</a> |
||||
); |
||||
} |
||||
|
||||
function DropdownLink(props: { |
||||
children: React.ReactNode; |
||||
href?: string; |
||||
icon?: Icons; |
||||
highlight?: boolean; |
||||
className?: string; |
||||
onClick?: () => void; |
||||
}) { |
||||
return ( |
||||
<GoToLink |
||||
onClick={props.onClick} |
||||
href={props.href} |
||||
className={classNames( |
||||
"cursor-pointer flex gap-3 items-center m-4 font-medium transition-colors duration-100", |
||||
props.highlight |
||||
? "text-dropdown-highlight hover:text-dropdown-highlightHover" |
||||
: "text-dropdown-text hover:text-white", |
||||
props.className |
||||
)} |
||||
> |
||||
{props.icon ? <Icon icon={props.icon} className="text-xl" /> : null} |
||||
{props.children} |
||||
</GoToLink> |
||||
); |
||||
} |
||||
|
||||
function CircleDropdownLink(props: { icon: Icons; href: string }) { |
||||
return ( |
||||
<GoToLink |
||||
href={props.href} |
||||
className="w-11 h-11 rounded-full bg-dropdown-contentBackground text-dropdown-text hover:text-white transition-colors duration-100 flex justify-center items-center" |
||||
> |
||||
<Icon className="text-2xl" icon={props.icon} /> |
||||
</GoToLink> |
||||
); |
||||
} |
||||
|
||||
export function LinksDropdown(props: { children: React.ReactNode }) { |
||||
const [open, setOpen] = useState(false); |
||||
const userId = useAuthStore((s) => s.account?.userId); |
||||
const { logout } = useAuth(); |
||||
|
||||
useEffect(() => { |
||||
function onWindowClick(evt: MouseEvent) { |
||||
if ((evt.target as HTMLElement).closest(".is-dropdown")) return; |
||||
setOpen(false); |
||||
} |
||||
|
||||
window.addEventListener("click", onWindowClick); |
||||
return () => window.removeEventListener("click", onWindowClick); |
||||
}, []); |
||||
|
||||
const toggleOpen = useCallback(() => { |
||||
setOpen((s) => !s); |
||||
}, []); |
||||
|
||||
return ( |
||||
<div className="relative is-dropdown"> |
||||
<div className="cursor-pointer" onClick={toggleOpen}> |
||||
{props.children} |
||||
</div> |
||||
<Transition animation="slide-down" show={open}> |
||||
<div className="rounded-lg absolute w-64 bg-dropdown-altBackground top-full mt-3 right-0"> |
||||
{userId ? ( |
||||
<DropdownLink className="text-white" href="/settings"> |
||||
<UserAvatar /> |
||||
{userId} |
||||
</DropdownLink> |
||||
) : ( |
||||
<DropdownLink href="/login" icon={Icons.RISING_STAR} highlight> |
||||
Sync to cloud |
||||
</DropdownLink> |
||||
)} |
||||
<Divider /> |
||||
<DropdownLink href="/settings" icon={Icons.SETTINGS}> |
||||
Settings |
||||
</DropdownLink> |
||||
<DropdownLink href="/faq" icon={Icons.EPISODES}> |
||||
About us |
||||
</DropdownLink> |
||||
<DropdownLink href="/faq" icon={Icons.FILM}> |
||||
HELP MEEE |
||||
</DropdownLink> |
||||
{userId ? ( |
||||
<DropdownLink |
||||
className="!text-type-danger opacity-75 hover:opacity-100" |
||||
icon={Icons.LOGOUT} |
||||
onClick={logout} |
||||
> |
||||
Log out |
||||
</DropdownLink> |
||||
) : null} |
||||
<Divider /> |
||||
<div className="my-4 flex justify-center items-center gap-4"> |
||||
<CircleDropdownLink |
||||
href={conf().DISCORD_LINK} |
||||
icon={Icons.DISCORD} |
||||
/> |
||||
<CircleDropdownLink href={conf().GITHUB_LINK} icon={Icons.GITHUB} /> |
||||
<CircleDropdownLink |
||||
href={conf().DONATION_LINK} |
||||
icon={Icons.COINS} |
||||
/> |
||||
</div> |
||||
</div> |
||||
</Transition> |
||||
</div> |
||||
); |
||||
} |
@ -1,95 +1,51 @@
@@ -1,95 +1,51 @@
|
||||
import { useCallback } from "react"; |
||||
|
||||
import { |
||||
SubtitleSearchItem, |
||||
downloadSrt, |
||||
searchSubtitles, |
||||
} from "@/backend/helpers/subs"; |
||||
import { downloadCaption } from "@/backend/helpers/subs"; |
||||
import { usePlayerStore } from "@/stores/player/store"; |
||||
import { useSubtitleStore } from "@/stores/subtitles"; |
||||
import { SimpleCache } from "@/utils/cache"; |
||||
|
||||
const cacheTimeSec = 24 * 60 * 60; // 24 hours
|
||||
|
||||
const downloadCache = new SimpleCache<string, string>(); |
||||
downloadCache.setCompare((a, b) => a === b); |
||||
|
||||
const searchCache = new SimpleCache< |
||||
{ tmdbId: string; ep?: string; season?: string }, |
||||
SubtitleSearchItem[] |
||||
>(); |
||||
searchCache.setCompare( |
||||
(a, b) => a.tmdbId === b.tmdbId && a.ep === b.ep && a.season === b.season |
||||
); |
||||
|
||||
export function useCaptions() { |
||||
const setLanguage = useSubtitleStore((s) => s.setLanguage); |
||||
const enabled = useSubtitleStore((s) => s.enabled); |
||||
const setCaption = usePlayerStore((s) => s.setCaption); |
||||
const lastSelectedLanguage = useSubtitleStore((s) => s.lastSelectedLanguage); |
||||
const meta = usePlayerStore((s) => s.meta); |
||||
const captionList = usePlayerStore((s) => s.captionList); |
||||
|
||||
const download = useCallback( |
||||
async (subtitleId: string, language: string) => { |
||||
let srtData = downloadCache.get(subtitleId); |
||||
if (!srtData) { |
||||
srtData = await downloadSrt(subtitleId); |
||||
downloadCache.set(subtitleId, srtData, cacheTimeSec); |
||||
} |
||||
const selectLanguage = useCallback( |
||||
async (language: string) => { |
||||
const caption = captionList.find((v) => v.language === language); |
||||
if (!caption) return; |
||||
const srtData = await downloadCaption(caption); |
||||
setCaption({ |
||||
language, |
||||
language: caption.language, |
||||
srtData, |
||||
url: "", // TODO remove url
|
||||
url: caption.url, |
||||
}); |
||||
setLanguage(language); |
||||
}, |
||||
[setCaption, setLanguage] |
||||
[setLanguage, captionList, setCaption] |
||||
); |
||||
|
||||
const search = useCallback(async () => { |
||||
if (!meta) throw new Error("No meta"); |
||||
const key = { |
||||
tmdbId: meta.tmdbId, |
||||
ep: meta.episode?.tmdbId, |
||||
season: meta.season?.tmdbId, |
||||
}; |
||||
const results = searchCache.get(key); |
||||
if (results) return [...results]; |
||||
|
||||
const freshResults = await searchSubtitles(meta); |
||||
searchCache.set(key, [...freshResults], cacheTimeSec); |
||||
return freshResults; |
||||
}, [meta]); |
||||
|
||||
const disable = useCallback(async () => { |
||||
setCaption(null); |
||||
setLanguage(null); |
||||
}, [setCaption, setLanguage]); |
||||
|
||||
const downloadLastUsed = useCallback(async () => { |
||||
const selectLastUsedLanguage = useCallback(async () => { |
||||
const language = lastSelectedLanguage ?? "en"; |
||||
const searchResult = await search(); |
||||
const languageResult = searchResult.find( |
||||
(v) => v.attributes.language === language |
||||
); |
||||
if (!languageResult) return false; |
||||
await download( |
||||
languageResult.attributes.legacy_subtitle_id, |
||||
languageResult.attributes.language |
||||
); |
||||
await selectLanguage(language); |
||||
return true; |
||||
}, [lastSelectedLanguage, search, download]); |
||||
}, [lastSelectedLanguage, selectLanguage]); |
||||
|
||||
const toggleLastUsed = useCallback(async () => { |
||||
if (enabled) disable(); |
||||
else await downloadLastUsed(); |
||||
}, [downloadLastUsed, disable, enabled]); |
||||
else await selectLastUsedLanguage(); |
||||
}, [selectLastUsedLanguage, disable, enabled]); |
||||
|
||||
return { |
||||
download, |
||||
search, |
||||
selectLanguage, |
||||
disable, |
||||
downloadLastUsed, |
||||
selectLastUsedLanguage, |
||||
toggleLastUsed, |
||||
}; |
||||
} |
||||
|
Loading…
Reference in new issue