mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-21 11:52:22 +00:00
move external subtitle scrape to client
This commit is contained in:
parent
fbba8bc90f
commit
8f5728ebf0
6 changed files with 308 additions and 0 deletions
|
|
@ -75,6 +75,7 @@
|
||||||
"semver": "^7.7.2",
|
"semver": "^7.7.2",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"subsrt-ts": "^2.1.2",
|
"subsrt-ts": "^2.1.2",
|
||||||
|
"wyzie-lib": "^2.2.5",
|
||||||
"zustand": "^4.5.7"
|
"zustand": "^4.5.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,9 @@ importers:
|
||||||
subsrt-ts:
|
subsrt-ts:
|
||||||
specifier: ^2.1.2
|
specifier: ^2.1.2
|
||||||
version: 2.1.2
|
version: 2.1.2
|
||||||
|
wyzie-lib:
|
||||||
|
specifier: ^2.2.5
|
||||||
|
version: 2.2.5
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^4.5.7
|
specifier: ^4.5.7
|
||||||
version: 4.5.7(@types/react@18.3.23)(immer@10.1.1)(react@18.3.1)
|
version: 4.5.7(@types/react@18.3.23)(immer@10.1.1)(react@18.3.1)
|
||||||
|
|
|
||||||
|
|
@ -687,6 +687,8 @@
|
||||||
"unknownLanguage": "Unknown",
|
"unknownLanguage": "Unknown",
|
||||||
"dropSubtitleFile": "Drop subtitle file here! >_<",
|
"dropSubtitleFile": "Drop subtitle file here! >_<",
|
||||||
"scrapeButton": "Scrape subtitles",
|
"scrapeButton": "Scrape subtitles",
|
||||||
|
"refresh": "Refresh External Subtitles",
|
||||||
|
"refreshing": "Refreshing...",
|
||||||
"empty": "There are no provided subtitles for this.",
|
"empty": "There are no provided subtitles for this.",
|
||||||
"notFound": "None of the available options match your query",
|
"notFound": "None of the available options match your query",
|
||||||
"useNativeSubtitles": "Use native video subtitles",
|
"useNativeSubtitles": "Use native video subtitles",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export function OpenSubtitlesCaptionView({
|
||||||
const { selectCaptionById } = useCaptions();
|
const { selectCaptionById } = useCaptions();
|
||||||
const captionList = usePlayerStore((s) => s.captionList);
|
const captionList = usePlayerStore((s) => s.captionList);
|
||||||
const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList);
|
const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList);
|
||||||
|
const addExternalSubtitles = usePlayerStore((s) => s.addExternalSubtitles);
|
||||||
|
|
||||||
const captions = useMemo(
|
const captions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -48,6 +49,10 @@ export function OpenSubtitlesCaptionView({
|
||||||
[selectCaptionById, setCurrentlyDownloading],
|
[selectCaptionById, setCurrentlyDownloading],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [refreshReq, startRefresh] = useAsyncFn(async () => {
|
||||||
|
return addExternalSubtitles();
|
||||||
|
}, [addExternalSubtitles]);
|
||||||
|
|
||||||
const content = subtitleList.length
|
const content = subtitleList.length
|
||||||
? subtitleList.map((v) => {
|
? subtitleList.map((v) => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -98,6 +103,14 @@ export function OpenSubtitlesCaptionView({
|
||||||
<div className="p-4 rounded-xl bg-video-context-light bg-opacity-10 font-medium text-center">
|
<div className="p-4 rounded-xl bg-video-context-light bg-opacity-10 font-medium text-center">
|
||||||
<div className="flex flex-col items-center justify-center gap-3">
|
<div className="flex flex-col items-center justify-center gap-3">
|
||||||
{t("player.menus.subtitles.empty")}
|
{t("player.menus.subtitles.empty")}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startRefresh()}
|
||||||
|
disabled={refreshReq.loading}
|
||||||
|
className="p-1 w-3/4 rounded tabbable duration-200 bg-opacity-10 bg-video-context-light hover:bg-opacity-20"
|
||||||
|
>
|
||||||
|
{t("player.menus.subtitles.scrapeButton")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* eslint-disable no-console */
|
||||||
import { ScrapeMedia } from "@p-stream/providers";
|
import { ScrapeMedia } from "@p-stream/providers";
|
||||||
|
|
||||||
import { MakeSlice } from "@/stores/player/slices/types";
|
import { MakeSlice } from "@/stores/player/slices/types";
|
||||||
|
|
@ -98,6 +99,7 @@ export interface SourceSlice {
|
||||||
enableAutomaticQuality(): void;
|
enableAutomaticQuality(): void;
|
||||||
redisplaySource(startAt: number): void;
|
redisplaySource(startAt: number): void;
|
||||||
setCaptionAsTrack(asTrack: boolean): void;
|
setCaptionAsTrack(asTrack: boolean): void;
|
||||||
|
addExternalSubtitles(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia {
|
export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia {
|
||||||
|
|
@ -184,6 +186,12 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
||||||
});
|
});
|
||||||
const store = get();
|
const store = get();
|
||||||
store.redisplaySource(startAt);
|
store.redisplaySource(startAt);
|
||||||
|
|
||||||
|
// Trigger external subtitle scraping after stream is loaded
|
||||||
|
// This runs asynchronously so it doesn't block the stream loading
|
||||||
|
setTimeout(() => {
|
||||||
|
store.addExternalSubtitles();
|
||||||
|
}, 100);
|
||||||
},
|
},
|
||||||
redisplaySource(startAt: number) {
|
redisplaySource(startAt: number) {
|
||||||
const store = get();
|
const store = get();
|
||||||
|
|
@ -235,4 +243,29 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
||||||
s.caption.asTrack = asTrack;
|
s.caption.asTrack = asTrack;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async addExternalSubtitles() {
|
||||||
|
const store = get();
|
||||||
|
if (!store.meta) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { scrapeExternalSubtitles } = await import(
|
||||||
|
"@/utils/externalSubtitles"
|
||||||
|
);
|
||||||
|
const externalCaptions = await scrapeExternalSubtitles(store.meta);
|
||||||
|
|
||||||
|
if (externalCaptions.length > 0) {
|
||||||
|
set((s) => {
|
||||||
|
// Add external captions to the existing list, avoiding duplicates
|
||||||
|
const existingIds = new Set(s.captionList.map((c) => c.id));
|
||||||
|
const newCaptions = externalCaptions.filter(
|
||||||
|
(c) => !existingIds.has(c.id),
|
||||||
|
);
|
||||||
|
s.captionList = [...s.captionList, ...newCaptions];
|
||||||
|
});
|
||||||
|
console.log(`Added ${externalCaptions.length} external captions`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to scrape external subtitles:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
256
src/utils/externalSubtitles.ts
Normal file
256
src/utils/externalSubtitles.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
import { type SubtitleData, searchSubtitles } from "wyzie-lib";
|
||||||
|
|
||||||
|
import { CaptionListItem, PlayerMeta } from "@/stores/player/slices/source";
|
||||||
|
|
||||||
|
// Helper function to convert language names to language codes
|
||||||
|
function labelToLanguageCode(languageName: string): string {
|
||||||
|
const languageMap: Record<string, string> = {
|
||||||
|
English: "en",
|
||||||
|
Spanish: "es",
|
||||||
|
French: "fr",
|
||||||
|
German: "de",
|
||||||
|
Italian: "it",
|
||||||
|
Portuguese: "pt",
|
||||||
|
Russian: "ru",
|
||||||
|
Japanese: "ja",
|
||||||
|
Korean: "ko",
|
||||||
|
Chinese: "zh",
|
||||||
|
Arabic: "ar",
|
||||||
|
Hindi: "hi",
|
||||||
|
Turkish: "tr",
|
||||||
|
Dutch: "nl",
|
||||||
|
Polish: "pl",
|
||||||
|
Swedish: "sv",
|
||||||
|
Norwegian: "no",
|
||||||
|
Danish: "da",
|
||||||
|
Finnish: "fi",
|
||||||
|
Greek: "el",
|
||||||
|
Hebrew: "he",
|
||||||
|
Thai: "th",
|
||||||
|
Vietnamese: "vi",
|
||||||
|
Indonesian: "id",
|
||||||
|
Malay: "ms",
|
||||||
|
Filipino: "tl",
|
||||||
|
Ukrainian: "uk",
|
||||||
|
Romanian: "ro",
|
||||||
|
Czech: "cs",
|
||||||
|
Hungarian: "hu",
|
||||||
|
Bulgarian: "bg",
|
||||||
|
Croatian: "hr",
|
||||||
|
Serbian: "sr",
|
||||||
|
Slovak: "sk",
|
||||||
|
Slovenian: "sl",
|
||||||
|
Estonian: "et",
|
||||||
|
Latvian: "lv",
|
||||||
|
Lithuanian: "lt",
|
||||||
|
Icelandic: "is",
|
||||||
|
Maltese: "mt",
|
||||||
|
Georgian: "ka",
|
||||||
|
Armenian: "hy",
|
||||||
|
Azerbaijani: "az",
|
||||||
|
Kazakh: "kk",
|
||||||
|
Kyrgyz: "ky",
|
||||||
|
Uzbek: "uz",
|
||||||
|
Tajik: "tg",
|
||||||
|
Turkmen: "tk",
|
||||||
|
Mongolian: "mn",
|
||||||
|
Persian: "fa",
|
||||||
|
Urdu: "ur",
|
||||||
|
Bengali: "bn",
|
||||||
|
Tamil: "ta",
|
||||||
|
Telugu: "te",
|
||||||
|
Marathi: "mr",
|
||||||
|
Gujarati: "gu",
|
||||||
|
Kannada: "kn",
|
||||||
|
Malayalam: "ml",
|
||||||
|
Punjabi: "pa",
|
||||||
|
Sinhala: "si",
|
||||||
|
Nepali: "ne",
|
||||||
|
Burmese: "my",
|
||||||
|
Khmer: "km",
|
||||||
|
Lao: "lo",
|
||||||
|
Tibetan: "bo",
|
||||||
|
Uyghur: "ug",
|
||||||
|
Kurdish: "ku",
|
||||||
|
Pashto: "ps",
|
||||||
|
Dari: "prs",
|
||||||
|
Sindhi: "sd",
|
||||||
|
Kashmiri: "ks",
|
||||||
|
Dogri: "doi",
|
||||||
|
Konkani: "kok",
|
||||||
|
Manipuri: "mni",
|
||||||
|
Bodo: "brx",
|
||||||
|
Sanskrit: "sa",
|
||||||
|
Santhali: "sat",
|
||||||
|
Maithili: "mai",
|
||||||
|
Bhojpuri: "bho",
|
||||||
|
Awadhi: "awa",
|
||||||
|
Chhattisgarhi: "hne",
|
||||||
|
Magahi: "mag",
|
||||||
|
Rajasthani: "raj",
|
||||||
|
Malvi: "mup",
|
||||||
|
Bundeli: "bns",
|
||||||
|
Bagheli: "bfy",
|
||||||
|
Pahari: "phr",
|
||||||
|
Kumaoni: "kfy",
|
||||||
|
Garhwali: "gbm",
|
||||||
|
Kangri: "xnr",
|
||||||
|
};
|
||||||
|
|
||||||
|
return languageMap[languageName] || languageName.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = (ms: number, source: string) =>
|
||||||
|
new Promise<null>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.error(`${source} captions request timed out after ${ms}ms`);
|
||||||
|
resolve(null);
|
||||||
|
}, ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function scrapeWyzieCaptions(
|
||||||
|
tmdbId: string | number,
|
||||||
|
imdbId: string,
|
||||||
|
season?: number,
|
||||||
|
episode?: number,
|
||||||
|
): Promise<CaptionListItem[]> {
|
||||||
|
try {
|
||||||
|
const searchParams: any = {
|
||||||
|
encoding: "utf-8",
|
||||||
|
source: "all",
|
||||||
|
imdb_id: imdbId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tmdbId && !imdbId) {
|
||||||
|
searchParams.tmdb_id =
|
||||||
|
typeof tmdbId === "string" ? parseInt(tmdbId, 10) : tmdbId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (season && episode) {
|
||||||
|
searchParams.season = season;
|
||||||
|
searchParams.episode = episode;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Searching Wyzie subtitles with params:", searchParams);
|
||||||
|
const wyzieSubtitles: SubtitleData[] = await searchSubtitles(searchParams);
|
||||||
|
|
||||||
|
const wyzieCaptions: CaptionListItem[] = wyzieSubtitles.map((subtitle) => ({
|
||||||
|
id: subtitle.id,
|
||||||
|
language: subtitle.language,
|
||||||
|
url: subtitle.url,
|
||||||
|
type:
|
||||||
|
subtitle.format === "srt" || subtitle.format === "vtt"
|
||||||
|
? subtitle.format
|
||||||
|
: "srt",
|
||||||
|
needsProxy: false,
|
||||||
|
opensubtitles: true,
|
||||||
|
// Additional metadata from Wyzie
|
||||||
|
display: subtitle.display,
|
||||||
|
media: subtitle.media,
|
||||||
|
isHearingImpaired: subtitle.isHearingImpaired,
|
||||||
|
source:
|
||||||
|
typeof subtitle.source === "number"
|
||||||
|
? subtitle.source.toString()
|
||||||
|
: subtitle.source,
|
||||||
|
encoding: subtitle.encoding,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return wyzieCaptions;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching Wyzie subtitles:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scrapeOpenSubtitlesCaptions(
|
||||||
|
imdbId: string,
|
||||||
|
season?: number,
|
||||||
|
episode?: number,
|
||||||
|
): Promise<CaptionListItem[]> {
|
||||||
|
try {
|
||||||
|
const url = `https://rest.opensubtitles.org/search/${
|
||||||
|
season && episode ? `episode-${episode}/` : ""
|
||||||
|
}imdbid-${imdbId.slice(2)}${season && episode ? `/season-${season}` : ""}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"X-User-Agent": "VLSub 0.10.2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`OpenSubtitles API returned ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const openSubtitlesCaptions: CaptionListItem[] = [];
|
||||||
|
|
||||||
|
for (const caption of data) {
|
||||||
|
const downloadUrl = caption.SubDownloadLink.replace(".gz", "").replace(
|
||||||
|
"download/",
|
||||||
|
"download/subencoding-utf8/",
|
||||||
|
);
|
||||||
|
const language = labelToLanguageCode(caption.LanguageName);
|
||||||
|
|
||||||
|
if (!downloadUrl || !language) continue;
|
||||||
|
|
||||||
|
openSubtitlesCaptions.push({
|
||||||
|
id: downloadUrl,
|
||||||
|
language,
|
||||||
|
url: downloadUrl,
|
||||||
|
type: caption.SubFormat || "srt",
|
||||||
|
needsProxy: false,
|
||||||
|
opensubtitles: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return openSubtitlesCaptions;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching OpenSubtitles:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scrapeExternalSubtitles(
|
||||||
|
meta: PlayerMeta,
|
||||||
|
): Promise<CaptionListItem[]> {
|
||||||
|
try {
|
||||||
|
// Extract IMDb ID from meta
|
||||||
|
const imdbId = meta.imdbId;
|
||||||
|
if (!imdbId) {
|
||||||
|
console.log("No IMDb ID available for external subtitle scraping");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const season = meta.season?.number;
|
||||||
|
const episode = meta.episode?.number;
|
||||||
|
const tmdbId = meta.tmdbId;
|
||||||
|
|
||||||
|
// Fetch both Wyzie and OpenSubtitles captions with timeouts
|
||||||
|
const [wyzieCaptions, openSubsCaptions] = await Promise.all([
|
||||||
|
Promise.race([
|
||||||
|
scrapeWyzieCaptions(tmdbId, imdbId, season, episode),
|
||||||
|
timeout(2000, "Wyzie"),
|
||||||
|
]),
|
||||||
|
Promise.race([
|
||||||
|
scrapeOpenSubtitlesCaptions(imdbId, season, episode),
|
||||||
|
timeout(5000, "OpenSubtitles"),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allCaptions: CaptionListItem[] = [];
|
||||||
|
|
||||||
|
if (wyzieCaptions) allCaptions.push(...wyzieCaptions);
|
||||||
|
if (openSubsCaptions) allCaptions.push(...openSubsCaptions);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Found ${allCaptions.length} external captions (Wyzie: ${wyzieCaptions?.length || 0}, OpenSubtitles: ${openSubsCaptions?.length || 0})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return allCaptions;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in scrapeExternalSubtitles:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue