14 changed files with 312 additions and 97 deletions
@ -1,92 +1,139 @@
@@ -1,92 +1,139 @@
|
||||
import { gql, request } from "graphql-request"; |
||||
import { unzip } from "unzipit"; |
||||
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch"; |
||||
import { testSubData } from "@/backend/helpers/testsub"; |
||||
import { languageMap } from "@/setup/iso6391"; |
||||
import { PlayerMeta } from "@/stores/player/slices/source"; |
||||
import { normalizeTitle } from "@/utils/normalizeTitle"; |
||||
|
||||
interface SuggestResult { |
||||
name: string; |
||||
year: string; |
||||
id: number; |
||||
kind: "tv" | "movie"; |
||||
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 |
||||
} |
||||
} |
||||
} |
||||
`;
|
||||
|
||||
export interface Subtitle { |
||||
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; |
||||
}; |
||||
} |
||||
|
||||
const metaTypeToOpenSubs = { |
||||
tv: "show", |
||||
movie: "movie", |
||||
} as const; |
||||
|
||||
export async function getOpenSubsId(meta: PlayerMeta): Promise<string | null> { |
||||
const req = await proxiedFetch<SuggestResult[]>( |
||||
`https://www.opensubtitles.org/libs/suggest.php`, |
||||
{ |
||||
method: "GET", |
||||
headers: { |
||||
"Alt-Used": "www.opensubtitles.org", |
||||
"X-Referer": "https://www.opensubtitles.org/en/search/subs", |
||||
}, |
||||
query: { |
||||
format: "json", |
||||
MovieName: meta.title, |
||||
}, |
||||
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; |
||||
}; |
||||
} |
||||
); |
||||
const foundMatch = req.find((v) => { |
||||
const type = metaTypeToOpenSubs[v.kind]; |
||||
if (type !== meta.type) return false; |
||||
if (+v.year !== meta.releaseYear) return false; |
||||
return normalizeTitle(v.name) === normalizeTitle(meta.title); |
||||
}); |
||||
if (!foundMatch) return null; |
||||
return foundMatch.id.toString(); |
||||
|
||||
interface SubtitleSearchData { |
||||
subtitleSearch: { |
||||
data: RawSubtitleSearchItem[]; |
||||
}; |
||||
} |
||||
|
||||
export async function getHighestRatedSubs(id: string): Promise<Subtitle[]> { |
||||
// TODO support episodes
|
||||
const document = await proxiedFetch<string>( |
||||
`https://www.opensubtitles.org/en/search/sublanguageid-all/idmovie-${encodeURIComponent( |
||||
id |
||||
)}/sort-6/asc-0` |
||||
); |
||||
const dom = new DOMParser().parseFromString(document, "text/html"); |
||||
const table = dom.querySelector("#search_results > tbody"); |
||||
if (!table) throw new Error("No result table found"); |
||||
const results = [...table.querySelectorAll("tr[id^='name']")].map((v) => { |
||||
const subId = v.id.substring(4); // remove "name" from "name<ID>"
|
||||
const languageFlag = v.children[1].querySelector("div[class*='flag']"); |
||||
if (!languageFlag) return null; |
||||
const languageFlagClasses = languageFlag.classList.toString().split(" "); |
||||
const languageCode = languageFlagClasses.filter( |
||||
(cssClass) => cssClass === "flag" |
||||
)[0]; |
||||
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, |
||||
}, |
||||
}); |
||||
|
||||
return { |
||||
id: subId, |
||||
language: languageCode, |
||||
}; |
||||
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); |
||||
}); |
||||
|
||||
const languages: string[] = []; |
||||
const output: Subtitle[] = []; |
||||
results.forEach((v) => { |
||||
if (!v) return; |
||||
if (languages.includes(v.language)) return; // no duplicate languages
|
||||
output.push(v); |
||||
languages.push(v.language); |
||||
return Object.values(sortedByLanguage).map((langs) => { |
||||
const sortedByRating = langs |
||||
.filter((v): v is SubtitleSearchItem => !!v.attributes.legacy_subtitle_id) // must have legacy id
|
||||
.sort((a, b) => b.attributes.ratings - a.attributes.ratings); |
||||
return sortedByRating[0]; |
||||
}); |
||||
} |
||||
|
||||
return output; |
||||
export function languageIdToName(langId: string): string | null { |
||||
return languageMap[langId]?.nativeName ?? null; |
||||
} |
||||
|
||||
export async function downloadSrt(_subId: string): Promise<string> { |
||||
// TODO download, unzip and return srt data
|
||||
return testSubData.srtData; |
||||
// export async function downloadSrt(subId: string): Promise<string> {
|
||||
// const downloadScript = await proxiedFetch<string>(
|
||||
// `https://www.opensubtitles.com/nocache/download/${subId}/subreq.js`,
|
||||
// {
|
||||
// query: {
|
||||
// file_name: "sub",
|
||||
// locale: "en", // locale is ignored
|
||||
// np: "true",
|
||||
// sub_frmt: "srt",
|
||||
// ext_installed: "false",
|
||||
// },
|
||||
// }
|
||||
// );
|
||||
|
||||
// // extract url from script
|
||||
// // example: https://www.opensubtitles.com/download/<LONG_HASH_OF_UPPERCASE_HEX>/subfile/sub.srt
|
||||
// const downloadUrlRegex =
|
||||
// /https:\/\/www.opensubtitles.com\/download\/[A-Fa-f0-9]+\/subfile\/sub\.srt/g;
|
||||
// const matchedUrl = downloadScript.match(downloadUrlRegex);
|
||||
// if (!matchedUrl) throw new Error("No download found");
|
||||
// const downloadUrl = matchedUrl[0];
|
||||
|
||||
// // download
|
||||
// const srtRequest = await fetch(downloadUrl);
|
||||
// const srtData = await srtRequest.text();
|
||||
// return srtData;
|
||||
// }
|
||||
|
||||
export async function downloadSrt(legacySubId: string): Promise<string> { |
||||
// TODO there is cloudflare protection so this may not always work. what to do about that?
|
||||
// language code is hardcoded here, it does nothing
|
||||
const zipFile = await proxiedFetch<ArrayBuffer>( |
||||
`https://dl.opensubtitles.org/en/subtitleserve/sub/${legacySubId}`, |
||||
{ |
||||
responseType: "arrayBuffer", |
||||
} |
||||
); |
||||
|
||||
/** |
||||
* None of this works, CF protected endpoints :( |
||||
*/ |
||||
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; |
||||
} |
||||
|
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
import { Menu } from "@/components/player/internals/ContextMenu"; |
||||
import { useOverlayRouter } from "@/hooks/useOverlayRouter"; |
||||
|
||||
export function DownloadView({ id }: { id: string }) { |
||||
const router = useOverlayRouter(id); |
||||
|
||||
return ( |
||||
<> |
||||
<Menu.BackLink onClick={() => router.navigate("/")}> |
||||
Download |
||||
</Menu.BackLink> |
||||
<Menu.Section> |
||||
<div className="space-y-4 mt-3"> |
||||
<Menu.Paragraph> |
||||
Downloads are taken directly from the provider. movie-web does not |
||||
have control over how the downloads are provided. |
||||
</Menu.Paragraph> |
||||
<Menu.Paragraph> |
||||
To download on iOS, click <Menu.Highlight>Share</Menu.Highlight>, |
||||
then <Menu.Highlight>Save to File</Menu.Highlight> and then as after |
||||
you click the button. |
||||
</Menu.Paragraph> |
||||
<Menu.Paragraph> |
||||
To download on Android or PC, click or tap and hold on the video, |
||||
then select save as. |
||||
</Menu.Paragraph> |
||||
</div> |
||||
</Menu.Section> |
||||
</> |
||||
); |
||||
} |
Loading…
Reference in new issue