From 1fa1a1ae22a4a7a72c41e041b5c0ab948e07404d Mon Sep 17 00:00:00 2001
From: Joe Ipson
Date: Thu, 5 Sep 2024 17:07:35 -0600
Subject: [PATCH] Added B1G+
---
README.md | 18 +-
index.ts | 6 +
services/b1g-handler.ts | 348 +++++++++++++++++++++++++++++++++++++
services/channels.ts | 2 +-
services/launch-channel.ts | 6 +-
services/networks.ts | 2 +
services/user-agent.ts | 2 +
7 files changed, 376 insertions(+), 8 deletions(-)
create mode 100644 services/b1g-handler.ts
diff --git a/README.md b/README.md
index 3161245..d77e804 100644
--- a/README.md
+++ b/README.md
@@ -2,17 +2,15 @@
-Current version: **2.1.13**
+Current version: **2.2.0**
# About
-This takes ESPN/ESPN+, FOX Sports, Paramount+, MSG+, and MLB.tv programming and transforms it into a "live TV" experience with virtual linear channels. It will discover what is on, and generate a schedule of channels that will give you M3U and XMLTV files that you can import into something like [Jellyfin](https://jellyfin.org) or [Channels](https://getchannels.com).
+This takes ESPN/ESPN+, FOX Sports, Paramount+, MSG+, B1G+, or MLB.tv programming and transforms it into a "live TV" experience with virtual linear channels. It will discover what is on, and generate a schedule of channels that will give you M3U and XMLTV files that you can import into something like [Jellyfin](https://jellyfin.org) or [Channels](https://getchannels.com).
## Notes
-* This was not made for pirating streams. This is made for using your own credentials and have a different presentation than the ESPN, FOX Sports, and MLB.tv apps currently provide.
+* This was not made for pirating streams. This is made for using your own credentials and have a different presentation than the streaming apps currently provide.
* The Mouse might not like it and it could be taken down at any minute. Enjoy it while it lasts. ¯\\_(ツ)_/¯
-If you're using the `USE_LINEAR` option, have your client pull the XMLTV file every 4 hours so it can stay current if channels change.
-
# Using
The server exposes 2 main endpoints:
@@ -28,7 +26,7 @@ The recommended way of running is to pull the image from [Docker Hub](https://hu
| Environment Variable | Description | Required? | Default |
|---|---|---|---|
| START_CHANNEL | What the first channel number should be. | No | 1 |
-| NUM_OF_CHANNELS | How many channels to create? This is dependent on the networks you are using. A good number to start with is >= 150 if you are using ESPN+. | No | 150 |
+| NUM_OF_CHANNELS | How many channels to create? This is dependent on the networks you are using. A good number to start with is >= 200 if you are using ESPN+. | No | 200 |
| PROXY_SEGMENTS | Proxy keyed `*.ts` files. | No | False |
| PUID | Current user ID. Use if you have permission issues. Needs to be combined with PGID. | No | - |
| PGID | Current group ID. Use if you have permission issues. Needs to be combined with PUID. | No | - |
@@ -68,6 +66,14 @@ Use if you would like to login with Paramount+
|---|---|---|---|
| PARAMOUNTPLUS | Set if you would like CBS Sports events | False | False |
+#### B1G+
+Use if you would like to login with your B1G+ account
+| Environment Variable | Description | Default |
+|---|---|---|
+| B1GPLUS | Set if you would like to use B1G+ | False |
+| B1GPLUS_USER | B1G+ Username | False |
+| B1GPLUS_PASS | B1G+ Password | False |
+
#### MLB.tv
Use if you would like to login with your MLB.tv account
| Environment Variable | Description | Default |
diff --git a/index.ts b/index.ts
index 903ca1e..5a456ee 100644
--- a/index.ts
+++ b/index.ts
@@ -9,6 +9,7 @@ import {scheduleEntries} from './services/build-schedule';
import {espnHandler} from './services/espn-handler';
import {foxHandler} from './services/fox-handler';
import {mlbHandler} from './services/mlb-handler';
+import {b1gHandler} from './services/b1g-handler';
import {paramountHandler} from './services/paramount-handler';
import {msgHandler} from './services/msg-handler';
import {cleanEntries, removeChannelStatus} from './services/shared-helpers';
@@ -32,6 +33,7 @@ const schedule = async () => {
await espnHandler.getSchedule();
await foxHandler.getSchedule();
await mlbHandler.getSchedule();
+ await b1gHandler.getSchedule();
await paramountHandler.getSchedule();
await msgHandler.getSchedule();
@@ -209,6 +211,9 @@ process.on('SIGINT', shutDown);
await mlbHandler.initialize();
await mlbHandler.refreshTokens();
+ await b1gHandler.initialize();
+ await b1gHandler.refreshTokens();
+
await paramountHandler.initialize();
await paramountHandler.refreshTokens();
@@ -231,6 +236,7 @@ setInterval(async () => {
await espnHandler.refreshTokens();
await foxHandler.refreshTokens();
await mlbHandler.refreshTokens();
+ await b1gHandler.refreshTokens();
await paramountHandler.refreshTokens();
await msgHandler.refreshTokens();
}, 1000 * 60 * 30);
diff --git a/services/b1g-handler.ts b/services/b1g-handler.ts
new file mode 100644
index 0000000..bb1f654
--- /dev/null
+++ b/services/b1g-handler.ts
@@ -0,0 +1,348 @@
+import fs from 'fs';
+import fsExtra from 'fs-extra';
+import path from 'path';
+import axios from 'axios';
+import moment from 'moment';
+import jwt_decode from 'jwt-decode';
+
+import {b1gUserAgent, okHttpUserAgent} from './user-agent';
+import {configPath} from './config';
+import {useB1GPlus} from './networks';
+import {IEntry, IHeaders} from './shared-interfaces';
+import {db} from './database';
+
+interface IEventCategory {
+ name: string;
+}
+
+interface IEventTeam {
+ name: string;
+ shortName: string;
+ fullName: string;
+}
+
+interface IEventMetadata {
+ name: string;
+ type: {
+ name: string;
+ };
+}
+
+interface IEventImage {
+ path: string;
+}
+
+interface IEventContent {
+ id: number;
+ enableDrmProtection: boolean;
+}
+
+interface IB1GEvent {
+ id: number;
+ startTime: string;
+ category1: IEventCategory;
+ category2: IEventCategory;
+ category3: IEventCategory;
+ homeCompetitor: IEventTeam;
+ awayCompetitor: IEventTeam;
+ clientMetadata: IEventMetadata[];
+ images: IEventImage[];
+ content: IEventContent[];
+}
+
+interface IGameData {
+ name: string;
+ sport: string;
+ image: string;
+ categories: string[];
+}
+
+const getEventData = (event: IB1GEvent): IGameData => {
+ let sport = 'B1G+ Event';
+ const categories: string[] = ['B1G+', 'B1G'];
+
+ event.clientMetadata.forEach(e => {
+ if (e.type.name === 'Sport') {
+ sport = e.name;
+ categories.push(e.name);
+ }
+
+ if (e.type.name === 'sports') {
+ categories.push(e.name);
+ }
+ });
+
+ const awayTeam = `${event.awayCompetitor.name} ${event.awayCompetitor.fullName}`;
+ const homeTeam = `${event.homeCompetitor.name} ${event.homeCompetitor.fullName}`;
+
+ categories.push(awayTeam);
+ categories.push(homeTeam);
+
+ return {
+ categories: [...new Set(categories)],
+ image: `https://www.bigtenplus.com/image/original/${event.images[0].path}`,
+ name: `${awayTeam} vs ${homeTeam}`,
+ sport,
+ };
+};
+
+const parseAirings = async (events: IB1GEvent[]) => {
+ const now = moment();
+
+ for (const event of events) {
+ if (!event || !event.id) {
+ return;
+ }
+
+ const gameData = getEventData(event);
+
+ for (const content of event.content) {
+ const entryExists = await db.entries.findOne({id: `b1g-${content.id}`});
+
+ if (!entryExists) {
+ const start = moment(event.startTime);
+ const end = moment(event.startTime).add(5, 'hours');
+
+ if (end.isBefore(now) || content.enableDrmProtection) {
+ continue;
+ }
+
+ console.log('Adding event: ', gameData.name);
+
+ await db.entries.insert({
+ categories: gameData.categories,
+ duration: end.diff(start, 'seconds'),
+ end: end.valueOf(),
+ from: 'b1g+',
+ id: `b1g-${content.id}`,
+ image: gameData.image,
+ name: gameData.name,
+ network: 'B1G+',
+ sport: gameData.sport,
+ start: start.valueOf(),
+ });
+ }
+ }
+ }
+};
+
+class B1GHandler {
+ public access_token?: string;
+ public expires_at?: number;
+
+ public initialize = async () => {
+ if (!useB1GPlus) {
+ return;
+ }
+
+ // Load tokens from local file and make sure they are valid
+ this.load();
+
+ if (!this.expires_at || !this.access_token || moment(this.expires_at).isBefore(moment().add(100, 'days'))) {
+ await this.login();
+ }
+ };
+
+ public refreshTokens = async () => {
+ if (!useB1GPlus) {
+ return;
+ }
+
+ if (!this.expires_at || moment(this.expires_at).isBefore(moment().add(100, 'days'))) {
+ await this.login();
+ }
+ };
+
+ public getSchedule = async (): Promise => {
+ if (!useB1GPlus) {
+ return;
+ }
+
+ console.log('Looking for B1G+ events...');
+
+ try {
+ let hasNextPage = true;
+ let page = 1;
+ let events: IB1GEvent[] = [];
+
+ while (hasNextPage) {
+ const url = [
+ 'https://',
+ 'www.bigtenplus.com',
+ '/api/v2',
+ '/events',
+ '?sort_direction=asc',
+ '&device_category_id=2',
+ '&language=en',
+ `&metadata_id=${encodeURIComponent('159283,167702')}`,
+ `&date_time_from=${encodeURIComponent(moment().format())}`,
+ `&date_time_to=${encodeURIComponent(moment().add(3, 'days').format())}`,
+ page > 1 ? `&page=${page}` : '',
+ ].join('');
+
+ const {data} = await axios.get(url, {
+ headers: {
+ 'user-agent': okHttpUserAgent,
+ },
+ });
+
+ if (data.meta.last_page === page) {
+ hasNextPage = false;
+ }
+
+ events = events.concat(data.data);
+ page += 1;
+ }
+
+ await parseAirings(events);
+ } catch (e) {
+ console.error(e);
+ console.log('Could not parse B1G+ events');
+ }
+ };
+
+ public getEventData = async (eventId: string): Promise<[string, IHeaders]> => {
+ const id = eventId.replace('b1g-', '');
+
+ try {
+ await this.extendToken();
+
+ const accessToken = await this.checkAccess(id);
+ const {user_id}: {user_id: string} = jwt_decode(accessToken);
+ const streamUrl = await this.getStream(id, user_id, accessToken);
+
+ return [streamUrl, {}];
+ } catch (e) {
+ console.error(e);
+ console.log('Could not start playback');
+ }
+ };
+
+ private extendToken = async (): Promise => {
+ try {
+ const url = 'https://www.bigtenplus.com/api/v3/cleeng/extend_token';
+ const headers = {
+ Authorization: `Bearer ${this.access_token}`,
+ 'User-Agent': b1gUserAgent,
+ accept: 'application/json',
+ };
+
+ const {data} = await axios.post(
+ url,
+ {},
+ {
+ headers,
+ },
+ );
+
+ this.access_token = data.token;
+ this.expires_at = moment().add(399, 'days').valueOf();
+
+ this.save();
+ } catch (e) {
+ console.error(e);
+ console.log('Could not extend token for B1G+');
+ }
+ };
+
+ private checkAccess = async (eventId: string): Promise => {
+ try {
+ const url = `https://www.bigtenplus.com/api/v3/contents/${eventId}/check-access`;
+ const headers = {
+ Authorization: `Bearer ${this.access_token}`,
+ 'User-Agent': b1gUserAgent,
+ accept: 'application/json',
+ 'content-type': 'application/json',
+ };
+
+ const params = {
+ type: 'cleeng',
+ };
+
+ const {data} = await axios.post(url, params, {
+ headers,
+ });
+
+ return data.data;
+ } catch (e) {
+ console.error(e);
+ console.log('Could not get playback access token');
+ }
+ };
+
+ private getStream = async (eventId: string, userId: string, accessToken: string): Promise => {
+ try {
+ const url = [
+ 'https://',
+ 'www.bigtenplus.com',
+ '/api/v3',
+ '/contents',
+ `/${eventId}`,
+ '/access/hls',
+ `?csid=${userId}`,
+ ].join('');
+
+ const headers = {
+ Authorization: `Bearer ${accessToken}`,
+ 'User-Agent': okHttpUserAgent,
+ 'content-type': 'application/json',
+ };
+
+ const {data} = await axios.post(
+ url,
+ {},
+ {
+ headers,
+ },
+ );
+
+ return data.data.stream;
+ } catch (e) {
+ console.error(e);
+ console.log('Could not get playback access token');
+ }
+ };
+
+ private login = async (): Promise => {
+ try {
+ const url = 'https://www.bigtenplus.com/api/v3/cleeng/login';
+ const headers = {
+ 'User-Agent': b1gUserAgent,
+ accept: 'application/json',
+ 'content-type': 'application/json',
+ };
+
+ const params = {
+ email: process.env.B1GPLUS_USER,
+ password: process.env.B1GPLUS_PASS,
+ };
+
+ const {data} = await axios.post(url, params, {
+ headers,
+ });
+
+ this.access_token = data.token;
+ this.expires_at = moment().add(399, 'days').valueOf();
+
+ this.save();
+ } catch (e) {
+ console.error(e);
+ console.log('Could not login to B1G+');
+ }
+ };
+
+ private save = () => {
+ fsExtra.writeJSONSync(path.join(configPath, 'b1g_tokens.json'), this, {spaces: 2});
+ };
+
+ private load = () => {
+ if (fs.existsSync(path.join(configPath, 'b1g_tokens.json'))) {
+ const {access_token, expires_at} = fsExtra.readJSONSync(path.join(configPath, 'b1g_tokens.json'));
+
+ this.access_token = access_token;
+ this.expires_at = expires_at;
+ }
+ };
+}
+
+export const b1gHandler = new B1GHandler();
diff --git a/services/channels.ts b/services/channels.ts
index 5f82908..1ec5b0f 100644
--- a/services/channels.ts
+++ b/services/channels.ts
@@ -7,7 +7,7 @@ if (_.isNaN(startChannel)) {
let numOfChannels = _.toNumber(process.env.NUM_OF_CHANNELS);
if (_.isNaN(numOfChannels)) {
- numOfChannels = 150;
+ numOfChannels = 200;
}
export const START_CHANNEL = startChannel;
diff --git a/services/launch-channel.ts b/services/launch-channel.ts
index 2ff8e98..1bcaed9 100644
--- a/services/launch-channel.ts
+++ b/services/launch-channel.ts
@@ -3,11 +3,12 @@ import {espnHandler} from './espn-handler';
import {foxHandler} from './fox-handler';
import {mlbHandler} from './mlb-handler';
import {paramountHandler} from './paramount-handler';
+import {b1gHandler} from './b1g-handler';
+import {msgHandler} from './msg-handler';
import {IEntry, IHeaders} from './shared-interfaces';
import {PlaylistHandler} from './playlist-handler';
import {appStatus} from './app-status';
import {removeChannelStatus} from './shared-helpers';
-import {msgHandler} from './msg-handler';
const checkingStream = {};
@@ -40,6 +41,9 @@ const startChannelStream = async (channelId: string, appUrl: string) => {
case 'msg+':
[url, headers] = await msgHandler.getEventData(appStatus.channels[channelId].current);
break;
+ case 'b1g+':
+ [url, headers] = await b1gHandler.getEventData(appStatus.channels[channelId].current);
+ break;
default:
[url, headers] = await espnHandler.getEventData(appStatus.channels[channelId].current);
}
diff --git a/services/networks.ts b/services/networks.ts
index 7398b5f..15f207b 100644
--- a/services/networks.ts
+++ b/services/networks.ts
@@ -16,6 +16,8 @@ export const useFoxOnly4k = process.env.FOX_ONLY_4K?.toLowerCase() === 'true' ?
export const useMLBtv = process.env.MLBTV?.toLowerCase() === 'true' ? true : false;
+export const useB1GPlus = process.env.B1GPLUS?.toLowerCase() === 'true' ? true : false;
+
export const useParamountPlus = process.env.PARAMOUNTPLUS?.toLowerCase() === 'true' ? true : false;
export const useMsgPlus = process.env.MSGPLUS?.toLowerCase() === 'true' ? true : false;
diff --git a/services/user-agent.ts b/services/user-agent.ts
index d818edd..d0e9124 100644
--- a/services/user-agent.ts
+++ b/services/user-agent.ts
@@ -14,6 +14,8 @@ export const okHttpUserAgent = 'okhttp/4.11.0';
export const oktaUserAgent = 'okta-auth-js/7.0.2 okta-signin-widget-7.14.0';
+export const b1gUserAgent = 'Ktor client';
+
export const androidMlbUserAgent = 'BAMSDK/v4.3.0 (mlbaseball-7993996e 8.1.0; v2.0/v4.3.0; android; tv)';
// Will generate one random User Agent for the session