diff --git a/book.md b/book.md deleted file mode 100644 index 962a4b3..0000000 --- a/book.md +++ /dev/null @@ -1,51 +0,0 @@ -# Reverse engineering the myCANAL API - -## Getting channels + live token - -`POST https://ltv.slc-app-aka.prod.bo.canal.canalplustech.pro/api/V4/zones/cpfra/devices/3/apps/1/jobs/InitLiveTV` -TokenPass in auth. - -Make sure to save the `RMUToken` - -Channels are in `Root.ServiceResponse.OutData.PDS.ChannelsGroups.ChannelsGroups[0].Channels` - -## Live stream - -To play live stream fetch `{channel object}.WSXUrl.replace("{RMUToken}", RMUToken)` - -This will return a few keys, CDN, and `dvr`, `nodvr`, `primary` with `src` in each. - -By default myCANAL fetches `primary` which most of the time is `dvr`, it avoids incompatibility channels - -## DRM - -License and WideVine certificate are available in the public config: `https://player.canalplus.com/one/configs/v2/11/mycanal/prod.json` - -The `offerZone` are the following: - -```json -{ - "cpfra": "mycanal", - "cppol": "mycanalpl", - "cpche": "mycanalch", - "cpafr": "mycanalcos", - "cpreu": "mycanalcos", - "cpmdg": "mycanalcos", - "cpant": "mycanalcos", - "cpmus": "mycanalcosmus", - "cpncl": "mycanalcos", - "cpoth": "mycanalcos", - "cppyf": "mycanalcos", - "cpeth": "mycanaleth", - "tiita": "mycanaltim", - "cpchd": "mycanalchde", - "default": "mycanal" -} -``` - -Device ID appears to always be `31`? - -The license is in `Root.ServiceResponse.OutData.LicenseInfo` as Base64 encoded - -In the body, ChallengeInfo is just the challenge in Base64. -`UserKeyId` can be find in the local storage in myCANAL (`oneplayer:user:usr`) and `oneplayer:user:dev` for `DeviceKeyId` diff --git a/bun.lockb b/bun.lockb index 26b4483..a70ec5b 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/electron/main/utils/config.ts b/electron/main/utils/config.ts index e8c3d3c..992c8c4 100644 --- a/electron/main/utils/config.ts +++ b/electron/main/utils/config.ts @@ -13,18 +13,15 @@ const store = new Store({ const set = (key: string, value: unknown) => { store.set(key, value); - console.log(key, value); }; const get = (key?: string) => { - console.log("get", key); if (!key || key === "") return store.store; return store.get(key); }; const remove = (key: string) => { store.delete(key); - console.log("delete", key); }; export default function () { diff --git a/index.html b/index.html index f593408..b5b9d42 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ MultiViewer for 9NOW diff --git a/package.json b/package.json index 1126518..9ca3d7d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "multiviewer-for-9now", "displayName": "MultiViewer for 9NOW", - "version": "1.1.0", + "version": "1.2.0", "main": "dist-electron/main/index.js", "description": "Watch many channels at the same time from 9NOW", "author": "Quentin Baguette", @@ -33,7 +33,8 @@ "react-router-dom": "^6.25.1", "serve-handler": "^6.1.5", "shaka-player": "^4.10.9", - "swr": "^2.2.5" + "swr": "^2.2.5", + "tesseract.js": "^5.1.0" }, "devDependencies": { "@eslint/js": "^9.8.0", diff --git a/src/components/Channel.tsx b/src/components/Channel.tsx index 601175f..047d2ab 100644 --- a/src/components/Channel.tsx +++ b/src/components/Channel.tsx @@ -5,7 +5,8 @@ import { CardContent, CardMedia, Stack, - Typography + Typography, + CardActionArea } from "@mui/material"; import moment from "moment"; import React, { FC } from "react"; @@ -64,27 +65,37 @@ const Channel: FC = ({ }} /> - - - - {channel.name} - - {currentAiring?.title || "No current airing"} - - - {moment(currentAiring?.startDate).format("h:mm a")} -{" "} - {moment(currentAiring?.endDate).format("h:mm a")} - - - + { + window.mv.player.create( + `/grid/${encodeURI(JSON.stringify([channel.slug]))}`, + location.port, + [channel.slug] + ); + }} + > + + + + {channel.name} + + {currentAiring?.title || "No current airing"} + + + {moment(currentAiring?.startDate).format("h:mm a")} -{" "} + {moment(currentAiring?.endDate).format("h:mm a")} + + + + ); diff --git a/src/components/LiveEventGroup.tsx b/src/components/LiveEventGroup.tsx index 6fbd2cb..8ddc7ae 100644 --- a/src/components/LiveEventGroup.tsx +++ b/src/components/LiveEventGroup.tsx @@ -25,7 +25,7 @@ interface Props { olympicsFilter: boolean; } -const nonOlympicsSlug = [ +export const nonOlympicsSlug = [ "100-footy", "60-minutes-australia", "9crime", @@ -222,50 +222,62 @@ const Stream: FC = ({ stream, gridList, setGridList }) => { }} /> - { + window.mv.player.create( + `/grid/${encodeURI(JSON.stringify([stream.slug]))}`, + location.port, + [stream.slug] + ); }} - image={stream.image.sizes.w320} - alt={stream.image.alt} - /> - - - {stream.name}{" "} - + + - - - {stream.description} - - - {moment(stream.startDate).format("h:mm a")} -{" "} - {moment(stream.endDate).format("h:mm a")} - - + + + {stream.name}{" "} + + + + {stream.description} + + + {moment(stream.startDate).format("h:mm a")} -{" "} + {moment(stream.endDate).format("h:mm a")} + + + + ); diff --git a/src/components/Player.tsx b/src/components/Player.tsx index ec49fd1..c7d72cf 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -1,25 +1,40 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Box, CircularProgress } from "@mui/material"; +import { + Box, + Button, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Select, + Typography +} from "@mui/material"; +import WarningIcon from "@mui/icons-material/Warning"; import axios from "axios"; -import React, { FC, useEffect, useRef, useState } from "react"; +import React, { FC, useCallback, useEffect, useRef, useState } from "react"; import "shaka-player/dist/controls.css"; import "../styles/Player.css"; import shaka from "shaka-player/dist/shaka-player.ui"; import { BrightcoveGetStream } from "shared/BrightcoveGetStream"; import { LXPStream } from "shared/LXPStream"; +import Tesseract from "tesseract.js"; +import { GetLiveExperience, SwitcherRail } from "shared/getLiveExperienceTypes"; +import { LiveExperienceGroup } from "shared/LXPGroupTypes"; +import { nonOlympicsSlug } from "./LiveEventGroup"; async function initPlayer( manifestUri: string, video: HTMLVideoElement, uiContainer: HTMLDivElement, - setLoaded: (loaded: boolean) => void + setLoaded: (loaded: boolean) => void, + slug: string ) { shaka.polyfill.installAll(); const player = new shaka.Player(); await player.attach(video); - window.player = player; + window.player?.set(slug, player); player.addEventListener("error", onErrorEvent); @@ -53,7 +68,7 @@ async function initPlayer( }); await player.load(manifestUri); - console.log("The video has now been loaded!"); + setLoaded(true); } catch (e) { onError(e); @@ -73,18 +88,48 @@ type Props = { }; const Player: FC = ({ slug }) => { + const canvasRef = useRef(null); const videoElement = useRef(null); const uiContainer = useRef(null); + const [LXP, setLXP] = useState(null); const [manifestUri, setManifestUri] = useState(null); const [loaded, setLoaded] = useState(false); + const [finished, setFinished] = useState(false); + const [currentSlug, setCurrentSlug] = useState(slug); + const [programsLive, setProgramsLive] = useState< + (SwitcherRail | undefined)[] + >([]); + const [eventsLive, setEventsLive] = useState< + ( + | { + sportName: string; + description: string; + displayName: string; + endDate: string; + id: number; + name: string; + slug: string; + promoStartDate: string; + programStartDate: any; + programEndDate: any; + startDate: string; + subtitle: string; + type: string; + image: object; + } + | undefined + )[] + >([]); - const fetchManifest = async () => { + const fetchManifest = useCallback(async () => { const token = (await window.mv.config.get()).token; const response = await fetch( - `https://api.9now.com.au/web/live-experience?device=web&slug=${slug}&streamParams=web%2Cchrome%2Cmacos®ion=act&offset=0&token=${token}` + `https://api.9now.com.au/web/live-experience?device=web&slug=${currentSlug}&streamParams=web%2Cchrome%2Cmacos®ion=act&offset=0&token=${token}` ); const LXP = (await response.json()) as LXPStream; + setLXP(LXP); + if (LXP.data.getLXP.stream.video.url) { return LXP.data.getLXP.stream.video.url; } else { @@ -105,7 +150,7 @@ const Player: FC = ({ slug }) => { return manifestUri; } - }; + }, [currentSlug]); useEffect(() => { if (!manifestUri || !videoElement.current || !uiContainer.current) return; @@ -114,14 +159,149 @@ const Player: FC = ({ slug }) => { manifestUri, videoElement.current, uiContainer.current, - setLoaded + setLoaded, + currentSlug ); - }, [manifestUri, slug]); + + return () => { + window.player?.get(currentSlug)?.destroy(); + }; + }, [manifestUri, currentSlug]); useEffect(() => { fetchManifest().then((result) => { setManifestUri(result); }); + }, [currentSlug]); + + // OCR + useEffect(() => { + const captureFrame = (video: HTMLVideoElement) => { + const canvas = canvasRef.current!; + const context = canvas.getContext("2d")!; + + // Set canvas dimensions to match video dimensions + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + context.drawImage(video, 0, 0, canvas.width, canvas.height); + return canvas.toDataURL("image/png"); + }; + + const performOCR = async (dataUrl: string) => { + const result = await Tesseract.recognize(dataUrl, "eng"); + const text = result.data.text; + // Check for the presence of specific text + if (text.includes("Want more action")) { + await fetchProgramsLive(); + setFinished(true); + } + }; + + const checkTextInFrame = () => { + if (videoElement.current) { + const frame = captureFrame(videoElement.current); + + performOCR(frame); + } + }; + + const fetchProgramsLive = async () => { + const config = await window.mv.config.get(); + + if (!config.token) return; + + const { data } = (await ( + await fetch( + `https://api.9now.com.au/web/live-experience?device=web&slug=gem&streamParams=web%2Cchrome%2Cmacos®ion=nsw&offset=0&token=${config.token}` + ) + ).json()) as GetLiveExperience; + + setProgramsLive( + await Promise.all( + data.getLXP.switcherRail.map(async (switcherRail) => { + if (switcherRail.type === "channel") { + const currentlyAiring = switcherRail.airings?.find((airing) => { + const startDate = new Date(airing.startDate); + const endDate = new Date(airing.endDate); + const now = new Date(); + + return startDate < now && endDate > now; + }); + + if ( + currentlyAiring?.title.includes("Paris") || + currentlyAiring?.title.includes("Olympic") + ) + return switcherRail; + + return; + } + }) + ) + ); + + const eventsLive: ( + | { + sportName: string; + description: string; + displayName: string; + endDate: string; + id: number; + name: string; + slug: string; + promoStartDate: string; + programStartDate: any; + programEndDate: any; + startDate: string; + subtitle: string; + type: string; + image: object; + } + | undefined + )[] = []; + + await Promise.all( + data.getLXP.switcherRail.map(async (switcherRail) => { + const res = await fetch( + `https://api.9now.com.au/web/metadata/live-experience?device=web&slug=${switcherRail.slug}&streamParams=web%2Cchrome%2Cmacos®ion=act&offset=0&token=${config.token}` + ); + + if (!res.ok) { + console.error(`Failed to fetch LXP data for ${slug}`); + throw new Error("Failed to fetch LXP data"); + } + + const group = (await res.json()) as LiveExperienceGroup; + + if (nonOlympicsSlug.includes(group.data.getLXP.promoRail.slug)) + return; + + const lives = group.data.getLXP.promoRail.items.map((stream) => { + const currentlyAiring = + new Date(stream.startDate) < new Date() && + new Date(stream.endDate) > new Date(); + + return currentlyAiring + ? { + ...stream, + sportName: group.data.getLXP.stream.display.tagline + } + : undefined; + }); + + eventsLive.push(...lives); + }) + ); + + setEventsLive(eventsLive); + }; + + const intervalId = setInterval(checkTextInFrame, 15000); + + return () => { + clearInterval(intervalId); + }; }, []); return ( @@ -143,10 +323,16 @@ const Player: FC = ({ slug }) => { zIndex: 100, display: "flex", justifyContent: "center", - alignItems: "center" + alignItems: "center", + flexDirection: "column" }} > + + {LXP + ? LXP.data.getLXP.stream.display.listings[0].title + : "Loading..."} + )}
= ({ slug }) => { display: loaded ? "block" : "none" }} > + {finished && ( + + + + It looks like this program has ended (or not started yet)! Select + below a new program: + + + + Currently Live + + + + + + )} +
{ ) ).json()) as GetLiveExperience; - console.log(data); - if (!data.data) { window.mv.config.set("token", ""); window.location.reload(); @@ -218,7 +216,11 @@ function App() { justifyContent: "center" }} > - @@ -282,7 +284,11 @@ function App() { mb: 2 }} > - diff --git a/src/pages/GridPlayer.tsx b/src/pages/GridPlayer.tsx index 9465228..68055a4 100644 --- a/src/pages/GridPlayer.tsx +++ b/src/pages/GridPlayer.tsx @@ -7,7 +7,7 @@ import { useParams } from "react-router-dom"; const GridPlayer = () => { const [open, setOpen] = useState(true); const [draggable, setDraggable] = useState(false); - const [slug, setSlug] = useState([]); + const [slug, setSlug] = useState>(new Map()); const [numRows, setNumRows] = useState(0); const [numCols, setNumCols] = useState(0); const { data } = useParams<{ data: string }>(); @@ -17,6 +17,10 @@ const GridPlayer = () => { setOpen(false); }; + const addSlugToList = (key: string, value: boolean) => { + setSlug((prevMap) => new Map(prevMap.set(key, value))); + }; + useEffect(() => { const fetchSlugs = async () => { if (!data) return; @@ -25,12 +29,14 @@ const GridPlayer = () => { const numRows = Math.floor(Math.sqrt(slugs.length)); const numCols = Math.ceil(slugs.length / numRows); - setSlug(slugs); + slugs.forEach((slug) => addSlugToList(slug, true)); + setNumRows(numRows); setNumCols(numCols); }; fetchSlugs(); + window.player = new Map(); }, [data]); return ( @@ -51,7 +57,7 @@ const GridPlayer = () => {
)} - {slug.map((slug) => ( + {Array.from(slug).map(([slug]) => ( | null | undefined; } }