diff --git a/README.md b/README.md index 2b93189..02794b6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

-Current version: **4.1.1** +Current version: **4.1.2** # About This takes ESPN+, ESPN, FOX Sports, CBS Sports, Paramount+, MSG+, NFL, B1G+, NESN, Mountain West, FloSports, 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). diff --git a/package-lock.json b/package-lock.json index a41b231..96cdc43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "eplustv", - "version": "4.1.1", + "version": "4.1.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "eplustv", - "version": "4.1.1", + "version": "4.1.2", "license": "MIT", "dependencies": { "@hono/node-server": "^1.13.1", diff --git a/package.json b/package.json index 3f78da0..3087a5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eplustv", - "version": "4.1.1", + "version": "4.1.2", "description": "", "scripts": { "start": "ts-node -r tsconfig-paths/register index.tsx", diff --git a/services/gotham-handler.ts b/services/gotham-handler.ts index 9874bd1..f06182e 100644 --- a/services/gotham-handler.ts +++ b/services/gotham-handler.ts @@ -276,8 +276,9 @@ class GothamHandler { await this.authenticateRegCode(); - // Refresh access token + // Refresh access token and entitlements await this.getAccessToken(); + await this.getEntitlements(); if (moment().add(20, 'hours').isAfter(this.expiresIn)) { console.log('Refreshing Gotham auth token'); diff --git a/services/nfl-handler.ts b/services/nfl-handler.ts index 3e516e1..3fece83 100644 --- a/services/nfl-handler.ts +++ b/services/nfl-handler.ts @@ -5,7 +5,7 @@ import axios from 'axios'; import moment from 'moment'; import jwt_decode from 'jwt-decode'; -import {okHttpUserAgent} from './user-agent'; +import {okHttpUserAgent, userAgent} from './user-agent'; import {configPath} from './config'; import {useNfl} from './networks'; import {ClassTypeWithoutMethods, IEntry, IHeaders, IProvider} from './shared-interfaces'; @@ -42,6 +42,7 @@ interface INFLEvent { linear: boolean; networks: string[]; broadcastAiringType?: string; + hostNetwork?: string; } const CLIENT_KEY = [ @@ -118,6 +119,39 @@ const CLIENT_SECRET = ['q', 'G', 'h', 'E', 'v', '1', 'R', 't', 'I', '2', 'S', 'f const TV_CLIENT_SECRET = ['u', 'o', 'C', 'y', 'y', 'k', 'y', 'U', 'w', 'D', 'b', 'f', 'Q', 'Z', 'r', '2'].join(''); +const TWITCH_CLIENT_ID = [ + 'k', + 'i', + 'm', + 'n', + 'e', + '7', + '8', + 'k', + 'x', + '3', + 'n', + 'c', + 'x', + '6', + 'b', + 'r', + 'g', + 'o', + '4', + 'm', + 'v', + '6', + 'w', + 'k', + 'i', + '5', + 'h', + '1', + 'k', + 'o', +].join(''); + const DEVICE_INFO = { capabilities: {}, ctvDevice: 'AndroidTV', @@ -160,7 +194,7 @@ const DEFAULT_CATEGORIES = ['NFL', 'NFL+', 'Football']; const nflConfigPath = path.join(configPath, 'nfl_tokens.json'); -export type TOtherAuth = 'prime' | 'tve' | 'peacock' | 'sunday_ticket'; +export type TOtherAuth = 'prime' | 'tve' | 'peacock' | 'sunday_ticket' | 'twitch'; interface INFLJwt { dmaCode: string; @@ -168,6 +202,19 @@ interface INFLJwt { networks?: {[key: string]: string}; } +interface ITwitchAccessTokenRes { + data: ITwitchAccessToken; +} + +interface ITwitchAccessToken { + streamPlaybackAccessToken: ITwitchStreamToken; +} + +interface ITwitchStreamToken { + value: string; + signature: string; +} + const parseAirings = async (events: INFLEvent[]) => { const now = moment(); const endDate = moment().add(2, 'days').endOf('day'); @@ -244,6 +291,7 @@ class NflHandler { public peacockUUID?: string; public youTubeUserId?: string; public youTubeUUID?: string; + public twitchDeviceId?: string; public initialize = async () => { const setup = (await db.providers.count({name: 'nfl'})) > 0 ? true : false; @@ -343,9 +391,7 @@ class NflHandler { return; } - if (!this.expires_at || moment(this.expires_at * 1000).isBefore(moment())) { - await this.extendTokens(); - } + await this.extendTokens(); }; public getSchedule = async (): Promise => { @@ -398,10 +444,24 @@ class NflHandler { this.checkTVEEventAccess(i) || // Peacock (i.authorizations.peacock && this.checkPeacockAccess()) || - // Prime - (i.authorizations.amazon_prime && this.checkPrimeAccess()) + // Prime || Twitch.tv + (i.authorizations.amazon_prime && (this.checkPrimeAccess() || this.checkTwitchAccess())) ) { - events.push(i); + if (i.authorizations.amazon_prime) { + if (this.checkTwitchAccess()) { + events.push({ + ...i, + externalId: `${i.externalId}-twitch`, + networks: ['Twitch'], + }); + } + + if (this.checkPrimeAccess()) { + events.push(i); + } + } else { + events.push(i); + } } } else if ( i.callSign === 'NFLNRZ' && @@ -417,7 +477,7 @@ class NflHandler { i.contentType === 'GAME' && i.language.find(l => l === 'en') && i.authorizations.sunday_ticket && - this.checkSundayTicket() + this.checkSundayTicketAccess() ) { events.push(i); } @@ -460,31 +520,97 @@ class NflHandler { const isGame = event.channel !== 'NFLNETWORK' && event.channel !== 'NFLDIGITAL1_OO_v3' && event.channel !== 'NFLNRZ'; - const url = ['https://', 'api.nfl.com/', 'play/v1/asset/', id].join(''); + const isTwitch = event.feed === 'Twitch'; - const {data} = await axios.post( - url, + if (!isTwitch) { + const url = ['https://', 'api.nfl.com/', 'play/v1/asset/', id].join(''); + + const {data} = await axios.post( + url, + { + ...(this.checkTVEAccess() && { + idp: this.mvpdIdp, + mvpdUUID: this.mvpdUUID, + mvpdUserId: this.mvpdUserId, + networks: event.feed || 'NFLN', + }), + }, + { + headers: { + 'Content-Type': 'application/json', + 'User-Agent': okHttpUserAgent, + authorization: `Bearer ${isGame ? this.access_token : this.tv_access_token}`, + }, + }, + ); + + return [data.accessUrl, {}]; + } else { + try { + const channel = event.name.indexOf('Vision') > -1 ? 'primevision' : 'primevideo'; + + const accessToken = await this.getTwitchAccessToken(channel); + + const url = [ + 'https://usher.ttvnw.net', + '/api/channel/hls/', + `${channel}.m3u8`, + '?client_id=', + TWITCH_CLIENT_ID, + '&token=', + accessToken.value, + '&sig=', + accessToken.signature, + '&allow_source=true', + '&allow_audio_only=false', + ].join(''); + + return [url, {}]; + } catch (e) { + console.error(e); + console.log('Could not start playback from Twitch'); + } + } + } catch (e) { + console.error(e); + console.log('Could not start playback'); + } + }; + + private getTwitchAccessToken = async (channel: string): Promise => { + try { + const {data} = await axios.post( + 'https://gql.twitch.tv/gql', { - ...(this.checkTVEAccess() && { - idp: this.mvpdIdp, - mvpdUUID: this.mvpdUUID, - mvpdUserId: this.mvpdUserId, - networks: event.feed || 'NFLN', - }), + extensions: { + persistedQuery: { + sha256Hash: 'ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9', + version: 1, + }, + }, + operationName: 'PlaybackAccessToken', + variables: { + isLive: true, + isVod: false, + login: channel, + platform: 'web', + playerType: 'site', + vodID: '', + }, }, { headers: { - 'Content-Type': 'application/json', - 'User-Agent': okHttpUserAgent, - authorization: `Bearer ${isGame ? this.access_token : this.tv_access_token}`, + 'Client-id': TWITCH_CLIENT_ID, + 'User-Agent': userAgent, + 'X-Device-Id': this.twitchDeviceId, }, }, ); - return [data.accessUrl, {}]; + return data.data.streamPlaybackAccessToken; } catch (e) { console.error(e); - console.log('Could not start playback'); + console.log('Could not get Twitch access token'); } }; @@ -571,7 +697,8 @@ class NflHandler { private checkTVEAccess = (): boolean => (this.mvpdIdp ? true : false); private checkPeacockAccess = (): boolean => (this.peacockUserId ? true : false); private checkPrimeAccess = (): boolean => (this.amazonPrimeUserId ? true : false); - private checkSundayTicket = (): boolean => (this.youTubeUserId ? true : false); + private checkSundayTicketAccess = (): boolean => (this.youTubeUserId ? true : false); + private checkTwitchAccess = (): boolean => (this.twitchDeviceId ? true : false); private checkTVEEventAccess = (event: INFLEvent): boolean => { let hasChannel = false; @@ -684,14 +811,6 @@ class NflHandler { mvpdUUID: this.mvpdUUID, mvpdUserId: this.mvpdUserId, }), - ...(this.amazonPrimeUserId && { - amazonPrimeUUID: this.amazonPrimeUUID, - amazonPrimeUserId: this.amazonPrimeUserId, - }), - ...(this.peacockUserId && { - peacockUUID: this.peacockUUID, - peacockUserId: this.peacockUserId, - }), ...(this.youTubeUserId && { youTubeUUID: this.youTubeUUID, youTubeUserId: this.youTubeUserId, @@ -708,20 +827,7 @@ class NflHandler { this.tv_refresh_token = data.refreshToken; this.tv_expires_at = data.expiresIn; - if (data.additionalInfo) { - data.additionalInfo.forEach(ai => { - if (ai.data) { - if (ai.data.idp === 'amazon') { - this.amazonPrimeUUID = ai.data.newUUID; - } - } - }); - } - await this.save(); - - await this.checkRedZoneAccess(); - await this.checkNetworkAccess(); } catch (e) { console.error(e); console.log('Could not refresh token for NFL'); @@ -962,6 +1068,7 @@ class NflHandler { peacockUUID, youTubeUserId, youTubeUUID, + twitchDeviceId, } = tokens; this.device_id = device_id; @@ -983,6 +1090,7 @@ class NflHandler { this.peacockUUID = peacockUUID; this.youTubeUUID = youTubeUUID; this.youTubeUserId = youTubeUserId; + this.twitchDeviceId = twitchDeviceId; }; private loadJSON = () => { diff --git a/services/providers/nfl/index.tsx b/services/providers/nfl/index.tsx index 1df2400..64bf21d 100644 --- a/services/providers/nfl/index.tsx +++ b/services/providers/nfl/index.tsx @@ -1,11 +1,12 @@ import {Hono} from 'hono'; import { db } from '@/services/database'; - -import { Login } from './views/Login'; import { IProvider } from '@/services/shared-interfaces'; import { removeEntriesProvider, scheduleEntries } from '@/services/build-schedule'; import { nflHandler, TNFLTokens, TOtherAuth } from '@/services/nfl-handler'; +import { getRandomHex } from '@/services/shared-helpers'; + +import { Login } from './views/Login'; import { NFLBody } from './views/CardBody'; export const nfl = new Hono().basePath('/nfl'); @@ -61,8 +62,17 @@ nfl.put('/auth/:provider', async c => { delete updatedTokens.youTubeUUID; delete updatedTokens.youTubeUserId; break; - } + case 'twitch': + delete updatedTokens.twitchDeviceId; + break; + } + } else { + if (provider === 'twitch') { + updatedTokens.twitchDeviceId = getRandomHex(); + } + } + if (!enabled || provider === 'twitch') { const {linear_channels, tokens} = await db.providers.update>({name: 'nfl'}, {$set: {tokens: updatedTokens}}, {returnUpdatedDocs: true}); return c.html(); @@ -101,7 +111,7 @@ nfl.get('/login/:code/:other', async c => { ? ' (Peacock)' : otherAuth === 'sunday_ticket' ? ' (Youtube)' - : ''; + : otherAuth === 'twitch' ? ' (Twitch)' : ''; const message = `NFL${otherAuthName}`; diff --git a/services/providers/nfl/views/CardBody.tsx b/services/providers/nfl/views/CardBody.tsx index 83e1c40..e6afb03 100644 --- a/services/providers/nfl/views/CardBody.tsx +++ b/services/providers/nfl/views/CardBody.tsx @@ -110,6 +110,24 @@ export const NFLBody: FC = ({enabled, tokens, open, channels}) => +
+
Twitch:
+
+ +
+
Sunday Ticket: