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
+
+ {
+ window.player?.get(currentSlug)?.destroy();
+ setFinished(false);
+ setLoaded(false);
+ setCurrentSlug(e.target.value as string);
+ }}
+ >
+ {programsLive.filter((item) => item !== undefined).length >
+ 0 && Channels }
+ {programsLive.map((program) => {
+ if (!program) return;
+
+ const alreadyOpen = window.player?.has(program.slug);
+
+ return (
+
+ {program.name} -{" "}
+ {
+ program.airings?.find((airing) => {
+ const startDate = new Date(airing.startDate);
+ const endDate = new Date(airing.endDate);
+ const now = new Date();
+
+ return startDate < now && endDate > now;
+ })?.title
+ }
+ {alreadyOpen ? " (already open)" : ""}
+
+ );
+ })}
+
+ {eventsLive.filter((item) => item !== undefined).length > 0 && (
+ Live events
+ )}
+ {eventsLive
+ .sort((a, b) => {
+ if (!a?.sportName || !b?.sportName) return 0;
+ if (a.sportName < b.sportName) {
+ return -1;
+ }
+ if (a.sportName > b.sportName) {
+ return 1;
+ }
+ return 0;
+ })
+ .map((event) => {
+ if (!event) return;
+
+ const alreadyOpen = window.player?.has(event.slug);
+
+ return (
+
+
+ {event.sportName} | {event.displayName} -{" "}
+ {event.subtitle}
+ {alreadyOpen ? " (already open)" : ""}
+
+
+ );
+ })}
+
+ {eventsLive.filter((item) => item !== undefined).length === 0 &&
+ programsLive.filter((item) => item !== undefined).length ===
+ 0 && (
+
+ Sorry! No programs are currently live...
+
+ )}
+
+
+ {
+ setFinished(false);
+ }}
+ >
+ My program is not done!
+
+
+ )}
+
{
)
).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"
}}
>
-
+
Open selected grid
{gridList.length > 0 ? ` (${gridList.length} streams)` : ""}
@@ -282,7 +284,11 @@ function App() {
mb: 2
}}
>
-
+
Open selected grid
{gridList.length > 0 ? ` (${gridList.length} streams)` : ""}
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;
}
}