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