diff --git a/package.json b/package.json index 097149c6..2223761f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "movie-web", - "version": "3.0.10", + "version": "3.0.11", "private": true, "homepage": "https://movie.squeezebox.dev", "dependencies": { diff --git a/src/backend/index.ts b/src/backend/index.ts index 7a13a445..5cf50906 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -6,6 +6,7 @@ import "./providers/flixhq"; import "./providers/superstream"; import "./providers/netfilm"; import "./providers/m4ufree"; +import "./providers/hdwatched"; // embeds import "./embeds/streamm4u"; diff --git a/src/backend/providers/hdwatched.ts b/src/backend/providers/hdwatched.ts new file mode 100644 index 00000000..cacaec41 --- /dev/null +++ b/src/backend/providers/hdwatched.ts @@ -0,0 +1,196 @@ +import { proxiedFetch } from "../helpers/fetch"; +import { MWProviderContext } from "../helpers/provider"; +import { registerProvider } from "../helpers/register"; +import { MWStreamQuality, MWStreamType } from "../helpers/streams"; +import { MWMediaType } from "../metadata/types"; + +const hdwatchedBase = "https://www.hdwatched.xyz"; + +const qualityMap: Record = { + 360: MWStreamQuality.Q360P, + 540: MWStreamQuality.Q540P, + 480: MWStreamQuality.Q480P, + 720: MWStreamQuality.Q720P, + 1080: MWStreamQuality.Q1080P, +}; + +interface SearchRes { + title: string; + year?: number; + href: string; + id: string; +} + +function getStreamFromEmbed(stream: string) { + const embedPage = new DOMParser().parseFromString(stream, "text/html"); + const source = embedPage.querySelector("#vjsplayer > source"); + if (!source) { + throw new Error("Unable to fetch stream"); + } + + const streamSrc = source.getAttribute("src"); + const streamRes = source.getAttribute("res"); + + if (!streamSrc || !streamRes) throw new Error("Unable to find stream"); + + return { + streamUrl: streamSrc, + quality: + streamRes && typeof +streamRes === "number" + ? qualityMap[+streamRes] + : MWStreamQuality.QUNKNOWN, + }; +} + +async function fetchMovie(targetSource: SearchRes) { + const stream = await proxiedFetch(`/embed/${targetSource.id}`, { + baseURL: hdwatchedBase, + }); + + const embedPage = new DOMParser().parseFromString(stream, "text/html"); + const source = embedPage.querySelector("#vjsplayer > source"); + if (!source) { + throw new Error("Unable to fetch movie stream"); + } + + return getStreamFromEmbed(stream); +} + +async function fetchSeries( + targetSource: SearchRes, + { media, episode, progress }: MWProviderContext +) { + if (media.meta.type !== MWMediaType.SERIES) + throw new Error("Media type mismatch"); + + const seasonNumber = media.meta.seasonData.number; + const episodeNumber = media.meta.seasonData.episodes.find( + (e) => e.id === episode + )?.number; + + if (!seasonNumber || !episodeNumber) + throw new Error("Unable to get season or episode number"); + + const seriesPage = await proxiedFetch( + `${targetSource.href}?season=${media.meta.seasonData.number}`, + { + baseURL: hdwatchedBase, + } + ); + + const seasonPage = new DOMParser().parseFromString(seriesPage, "text/html"); + const pageElements = seasonPage.querySelectorAll("div.i-container"); + + const seriesList: SearchRes[] = []; + pageElements.forEach((pageElement) => { + const href = pageElement.querySelector("a")?.getAttribute("href") || ""; + const title = + pageElement?.querySelector("span.content-title")?.textContent || ""; + + seriesList.push({ + title, + href, + id: href.split("/")[2], // Format: /free/{id}/{series-slug}-season-{season-number}-episode-{episode-number} + }); + }); + + const targetEpisode = seriesList.find( + (episodeEl) => + episodeEl.title.trim().toLowerCase() === `episode ${episodeNumber}` + ); + + if (!targetEpisode) throw new Error("Unable to find episode"); + + progress(70); + + const stream = await proxiedFetch(`/embed/${targetEpisode.id}`, { + baseURL: hdwatchedBase, + }); + + const embedPage = new DOMParser().parseFromString(stream, "text/html"); + const source = embedPage.querySelector("#vjsplayer > source"); + if (!source) { + throw new Error("Unable to fetch movie stream"); + } + + return getStreamFromEmbed(stream); +} + +registerProvider({ + id: "hdwatched", + displayName: "HDwatched", + rank: 150, + type: [MWMediaType.MOVIE, MWMediaType.SERIES], + async scrape(options) { + const { media, progress } = options; + if (!this.type.includes(media.meta.type)) { + throw new Error("Unsupported type"); + } + + const search = await proxiedFetch(`/search/${media.imdbId}`, { + baseURL: hdwatchedBase, + }); + + const searchPage = new DOMParser().parseFromString(search, "text/html"); + const pageElements = searchPage.querySelectorAll("div.i-container"); + + const searchList: SearchRes[] = []; + pageElements.forEach((pageElement) => { + const href = pageElement.querySelector("a")?.getAttribute("href") || ""; + const title = + pageElement?.querySelector("span.content-title")?.textContent || ""; + const year = + parseInt( + pageElement + ?.querySelector("div.duration") + ?.textContent?.trim() + ?.split(" ") + ?.pop() || "", + 10 + ) || 0; + + searchList.push({ + title, + year, + href, + id: href.split("/")[2], // Format: /free/{id}/{movie-slug} or /series/{id}/{series-slug} + }); + }); + + progress(20); + + const targetSource = searchList.find( + (source) => source.year === (media.meta.year ? +media.meta.year : 0) // Compare year to make the search more robust + ); + + if (!targetSource) { + throw new Error("Could not find stream"); + } + + progress(40); + + if (media.meta.type === MWMediaType.SERIES) { + const series = await fetchSeries(targetSource, options); + return { + embeds: [], + stream: { + streamUrl: series.streamUrl, + quality: series.quality, + type: MWStreamType.MP4, + captions: [], + }, + }; + } + + const movie = await fetchMovie(targetSource); + return { + embeds: [], + stream: { + streamUrl: movie.streamUrl, + quality: movie.quality, + type: MWStreamType.MP4, + captions: [], + }, + }; + }, +});