14 changed files with 312 additions and 97 deletions
@ -1,92 +1,139 @@ |
|||||||
|
import { gql, request } from "graphql-request"; |
||||||
|
import { unzip } from "unzipit"; |
||||||
|
|
||||||
import { proxiedFetch } from "@/backend/helpers/fetch"; |
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 { PlayerMeta } from "@/stores/player/slices/source"; |
||||||
import { normalizeTitle } from "@/utils/normalizeTitle"; |
|
||||||
|
|
||||||
interface SuggestResult { |
const GQL_API = "https://gqlos.plus-sub.com"; |
||||||
name: string; |
|
||||||
year: string; |
const subtitleSearchQuery = gql` |
||||||
id: number; |
query SubtitleSearch($tmdb_id: String!, $ep: Int, $season: Int) { |
||||||
kind: "tv" | "movie"; |
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; |
id: string; |
||||||
|
attributes: { |
||||||
language: string; |
language: string; |
||||||
|
ai_translated: boolean | null; |
||||||
|
auto_translation: null | boolean; |
||||||
|
ratings: number; |
||||||
|
votes: number | null; |
||||||
|
legacy_subtitle_id: string | null; |
||||||
|
}; |
||||||
} |
} |
||||||
|
|
||||||
const metaTypeToOpenSubs = { |
export interface SubtitleSearchItem { |
||||||
tv: "show", |
id: string; |
||||||
movie: "movie", |
attributes: { |
||||||
} as const; |
language: string; |
||||||
|
ai_translated: boolean | null; |
||||||
export async function getOpenSubsId(meta: PlayerMeta): Promise<string | null> { |
auto_translation: null | boolean; |
||||||
const req = await proxiedFetch<SuggestResult[]>( |
ratings: number; |
||||||
`https://www.opensubtitles.org/libs/suggest.php`, |
votes: number | null; |
||||||
{ |
legacy_subtitle_id: string; |
||||||
method: "GET", |
}; |
||||||
headers: { |
|
||||||
"Alt-Used": "www.opensubtitles.org", |
|
||||||
"X-Referer": "https://www.opensubtitles.org/en/search/subs", |
|
||||||
}, |
|
||||||
query: { |
|
||||||
format: "json", |
|
||||||
MovieName: meta.title, |
|
||||||
}, |
|
||||||
} |
} |
||||||
); |
|
||||||
const foundMatch = req.find((v) => { |
interface SubtitleSearchData { |
||||||
const type = metaTypeToOpenSubs[v.kind]; |
subtitleSearch: { |
||||||
if (type !== meta.type) return false; |
data: RawSubtitleSearchItem[]; |
||||||
if (+v.year !== meta.releaseYear) return false; |
}; |
||||||
return normalizeTitle(v.name) === normalizeTitle(meta.title); |
|
||||||
}); |
|
||||||
if (!foundMatch) return null; |
|
||||||
return foundMatch.id.toString(); |
|
||||||
} |
} |
||||||
|
|
||||||
export async function getHighestRatedSubs(id: string): Promise<Subtitle[]> { |
export async function searchSubtitles( |
||||||
// TODO support episodes
|
meta: PlayerMeta |
||||||
const document = await proxiedFetch<string>( |
): Promise<SubtitleSearchItem[]> { |
||||||
`https://www.opensubtitles.org/en/search/sublanguageid-all/idmovie-${encodeURIComponent( |
const data = await request<SubtitleSearchData>({ |
||||||
id |
document: subtitleSearchQuery, |
||||||
)}/sort-6/asc-0` |
url: GQL_API, |
||||||
); |
variables: { |
||||||
const dom = new DOMParser().parseFromString(document, "text/html"); |
tmdb_id: meta.tmdbId, |
||||||
const table = dom.querySelector("#search_results > tbody"); |
ep: meta.episode?.number, |
||||||
if (!table) throw new Error("No result table found"); |
season: meta.season?.number, |
||||||
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]; |
|
||||||
|
|
||||||
return { |
const sortedByLanguage: Record<string, RawSubtitleSearchItem[]> = {}; |
||||||
id: subId, |
data.subtitleSearch.data.forEach((v) => { |
||||||
language: languageCode, |
if (!sortedByLanguage[v.attributes.language]) |
||||||
}; |
sortedByLanguage[v.attributes.language] = []; |
||||||
|
sortedByLanguage[v.attributes.language].push(v); |
||||||
}); |
}); |
||||||
|
|
||||||
const languages: string[] = []; |
return Object.values(sortedByLanguage).map((langs) => { |
||||||
const output: Subtitle[] = []; |
const sortedByRating = langs |
||||||
results.forEach((v) => { |
.filter((v): v is SubtitleSearchItem => !!v.attributes.legacy_subtitle_id) // must have legacy id
|
||||||
if (!v) return; |
.sort((a, b) => b.attributes.ratings - a.attributes.ratings); |
||||||
if (languages.includes(v.language)) return; // no duplicate languages
|
return sortedByRating[0]; |
||||||
output.push(v); |
|
||||||
languages.push(v.language); |
|
||||||
}); |
}); |
||||||
|
} |
||||||
|
|
||||||
return output; |
export function languageIdToName(langId: string): string | null { |
||||||
|
return languageMap[langId]?.nativeName ?? null; |
||||||
} |
} |
||||||
|
|
||||||
export async function downloadSrt(_subId: string): Promise<string> { |
// export async function downloadSrt(subId: string): Promise<string> {
|
||||||
// TODO download, unzip and return srt data
|
// const downloadScript = await proxiedFetch<string>(
|
||||||
return testSubData.srtData; |
// `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", |
||||||
} |
} |
||||||
|
); |
||||||
|
|
||||||
/** |
const { entries } = await unzip(zipFile); |
||||||
* None of this works, CF protected endpoints :( |
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 @@ |
|||||||
|
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