diff --git a/package.json b/package.json index 6845f887..f31e3826 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "semver": "^7.7.2", "slugify": "^1.6.6", "subsrt-ts": "^2.1.2", + "wyzie-lib": "^2.2.5", "zustand": "^4.5.7" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4411a8c9..93b4a097 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,9 @@ importers: subsrt-ts: specifier: ^2.1.2 version: 2.1.2 + wyzie-lib: + specifier: ^2.2.5 + version: 2.2.5 zustand: specifier: ^4.5.7 version: 4.5.7(@types/react@18.3.23)(immer@10.1.1)(react@18.3.1) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index f52d37f0..c905b464 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -687,6 +687,8 @@ "unknownLanguage": "Unknown", "dropSubtitleFile": "Drop subtitle file here! >_<", "scrapeButton": "Scrape subtitles", + "refresh": "Refresh External Subtitles", + "refreshing": "Refreshing...", "empty": "There are no provided subtitles for this.", "notFound": "None of the available options match your query", "useNativeSubtitles": "Use native video subtitles", diff --git a/src/components/player/atoms/settings/OpensubtitlesCaptionsView.tsx b/src/components/player/atoms/settings/OpensubtitlesCaptionsView.tsx index a7cfca12..15150654 100644 --- a/src/components/player/atoms/settings/OpensubtitlesCaptionsView.tsx +++ b/src/components/player/atoms/settings/OpensubtitlesCaptionsView.tsx @@ -27,6 +27,7 @@ export function OpenSubtitlesCaptionView({ const { selectCaptionById } = useCaptions(); const captionList = usePlayerStore((s) => s.captionList); const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList); + const addExternalSubtitles = usePlayerStore((s) => s.addExternalSubtitles); const captions = useMemo( () => @@ -48,6 +49,10 @@ export function OpenSubtitlesCaptionView({ [selectCaptionById, setCurrentlyDownloading], ); + const [refreshReq, startRefresh] = useAsyncFn(async () => { + return addExternalSubtitles(); + }, [addExternalSubtitles]); + const content = subtitleList.length ? subtitleList.map((v) => { return ( @@ -98,6 +103,14 @@ export function OpenSubtitlesCaptionView({
{t("player.menus.subtitles.empty")} +
) : ( diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 9c2335dd..8c04dc6b 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import { ScrapeMedia } from "@p-stream/providers"; import { MakeSlice } from "@/stores/player/slices/types"; @@ -98,6 +99,7 @@ export interface SourceSlice { enableAutomaticQuality(): void; redisplaySource(startAt: number): void; setCaptionAsTrack(asTrack: boolean): void; + addExternalSubtitles(): Promise; } export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia { @@ -184,6 +186,12 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ }); const store = get(); 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) { const store = get(); @@ -235,4 +243,29 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ 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); + } + }, }); diff --git a/src/utils/externalSubtitles.ts b/src/utils/externalSubtitles.ts new file mode 100644 index 00000000..d3b33819 --- /dev/null +++ b/src/utils/externalSubtitles.ts @@ -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 = { + 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((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 { + 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 { + 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 { + 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 []; + } +}