Full Code of m0ngr31/EPlusTV for AI

master 68c67fb062c6 cached
162 files
685.3 KB
190.6k tokens
324 symbols
1 requests
Download .txt
Showing preview only (732K chars total). Download the full file or copy to clipboard to get everything.
Repository: m0ngr31/EPlusTV
Branch: master
Commit: 68c67fb062c6
Files: 162
Total size: 685.3 KB

Directory structure:
gitextract_rv9tbtv8/

├── .dockerignore
├── .editorconfig
├── .eslintrc.json
├── .github/
│   └── workflows/
│       ├── push-docker-hub.yml
│       └── release.yml
├── .gitignore
├── .husky/
│   └── pre-commit
├── .nvmrc
├── .prettierrc.json
├── Dockerfile
├── LICENSE
├── README.md
├── entrypoint.sh
├── index.tsx
├── package.json
├── services/
│   ├── adobe-helpers.ts
│   ├── app-status.ts
│   ├── b1g-handler.ts
│   ├── bally-handler.ts
│   ├── build-schedule.ts
│   ├── caching.ts
│   ├── cbs-handler.ts
│   ├── channels.ts
│   ├── config.ts
│   ├── database.ts
│   ├── debug.ts
│   ├── espn-handler.ts
│   ├── flo-handler.ts
│   ├── fox-handler.ts
│   ├── foxone-handler.ts
│   ├── generate-m3u.ts
│   ├── generate-xmltv.ts
│   ├── gotham-channels.ts
│   ├── gotham-handler.ts
│   ├── hudl-handler.ts
│   ├── init-directories.ts
│   ├── jsdom-helper.ts
│   ├── kbo-handler.ts
│   ├── ksl-handler.ts
│   ├── launch-channel.ts
│   ├── midco-handler.ts
│   ├── misc-db-service.ts
│   ├── mlb-handler.ts
│   ├── mw-handler.ts
│   ├── networks.ts
│   ├── nfl-handler.ts
│   ├── nhltv-handler.ts
│   ├── nwsl-handler.ts
│   ├── outside-handler.ts
│   ├── paramount-handler.ts
│   ├── playlist-handler.ts
│   ├── port.ts
│   ├── providers/
│   │   ├── b1g/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── bally/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       └── index.tsx
│   │   ├── cbs-sports/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── espn/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── espn-plus/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── flosports/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── fox/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── foxone/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── gotham/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       ├── TveLogin.tsx
│   │   │       └── index.tsx
│   │   ├── hudl/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       └── index.tsx
│   │   ├── index.ts
│   │   ├── kbo/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       └── index.tsx
│   │   ├── ksl/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       └── index.tsx
│   │   ├── midco/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── mlb/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── mw/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       └── index.tsx
│   │   ├── nfl/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── nhl-tv/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── nwsl/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── outside/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── paramount/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── pwhl/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       └── index.tsx
│   │   ├── victory/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── wnba/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── wsn/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       └── index.tsx
│   │   └── zeam/
│   │       ├── index.tsx
│   │       └── views/
│   │           └── index.tsx
│   ├── pwhl-handler.ts
│   ├── shared-helpers.ts
│   ├── shared-interfaces.ts
│   ├── template-handler.ts
│   ├── tubi-helper.ts
│   ├── user-agent.ts
│   ├── victory-handler.ts
│   ├── wnba-handler.ts
│   ├── wsn-handler.ts
│   ├── yt-dlp-helper.ts
│   └── zeam-handler.ts
├── tsconfig.json
└── views/
    ├── Header.tsx
    ├── Layout.tsx
    ├── Links.tsx
    ├── Main.tsx
    ├── Options.tsx
    ├── Providers.tsx
    ├── Script.tsx
    ├── Style.tsx
    └── Tools.tsx

================================================
FILE CONTENTS
================================================

================================================
FILE: .dockerignore
================================================
node_modules/
config/
tmp/
playroom/
run*.sh
.DS_Store

================================================
FILE: .editorconfig
================================================
# http://editorconfig.org

root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

[*.md]
insert_final_newline = false
trim_trailing_whitespace = false


================================================
FILE: .eslintrc.json
================================================
{
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 2018,
    "sourceType": "module",
    "warnOnUnsupportedTypeScriptVersion": false
  },
  "plugins": [
    "@typescript-eslint",
    "sort-keys-custom-order-fix"
  ],
  "extends": [
    "plugin:@typescript-eslint/recommended"
  ],
  "rules": {
    "sort-keys-custom-order-fix/sort-keys-custom-order-fix": "warn",
    "@typescript-eslint/no-non-null-assertion": "off",
    "@typescript-eslint/no-explicit-any": "off",
    "no-unused-vars": "off",
    "@typescript-eslint/no-unused-vars": [
      "error"
    ],
    "object-shorthand": ["error", "always"]
  }
}


================================================
FILE: .github/workflows/push-docker-hub.yml
================================================
name: ci

on:
  push:
    tags:
      - '*'

jobs:
  docker:
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
    steps:
      -
        name: Checkout
        uses: actions/checkout@v3
      -
        name: Set up QEMU
        uses: docker/setup-qemu-action@v2
      -
        name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@v2
      -
        name: Login to DockerHub
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      -
        name: Docker meta
        id: meta
        uses: docker/metadata-action@v3
        with:
          images: |
            ${{ secrets.DOCKERHUB_USERNAME }}/eplustv
          tags: |
            type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }}
            type=ref,event=tag
      -
        name: Build and push
        uses: docker/build-push-action@v2
        with:
          context: .
          platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
      -
        name: Update repo description
        uses: peter-evans/dockerhub-description@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_PASSWORD }}
          repository: ${{ secrets.DOCKERHUB_USERNAME }}/eplustv
      -
        name: Login to new DockerHub
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.NEW_DOCKERHUB_USERNAME }}
          password: ${{ secrets.NEW_DOCKERHUB_TOKEN }}
      -
        name: Docker new meta
        id: new_meta
        uses: docker/metadata-action@v3
        with:
          images: |
            ${{ secrets.NEW_DOCKERHUB_USERNAME }}/eplustv
          tags: |
            type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }}
            type=ref,event=tag
      -
        name: Build and push new
        uses: docker/build-push-action@v2
        with:
          context: .
          platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.new_meta.outputs.tags }}
          labels: ${{ steps.new_meta.outputs.labels }}
      -
        name: Update new repo description
        uses: peter-evans/dockerhub-description@v2
        with:
          username: ${{ secrets.NEW_DOCKERHUB_USERNAME }}
          password: ${{ secrets.NEW_DOCKERHUB_PASSWORD }}
          repository: ${{ secrets.NEW_DOCKERHUB_USERNAME }}/eplustv


================================================
FILE: .github/workflows/release.yml
================================================
name: Create Release on Tag Push

on:
  push:
    tags:
      - '*'

jobs:
  create_release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
    steps:
      - name: Print a simple message
        run: echo "Checking out..."
        
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
          
      - name: Print a simple message
        run: echo "Creating release..."
          
      - name: Print a simple message
        run: echo "Using release name and tag ${{ github.ref_name }}"

      #- name: Get latest commit message
        #id: get_commit_message
        #run: |
        #  COMMIT_MESSAGE=$(git log -1 --pretty=%B)
        #  echo "commit_message<<EOF" >> $GITHUB_OUTPUT
        #  echo "$COMMIT_MESSAGE" >> $GITHUB_OUTPUT
        #  echo "EOF" >> $GITHUB_OUTPUT

      - name: Create Release
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tag_name: ${{ github.ref_name }}
          release_name: ${{ github.ref_name }}
          #body: ${{ steps.get_commit_message.outputs.commit_message }}
          draft: false
          prerelease: false


================================================
FILE: .gitignore
================================================

# Created by https://www.toptal.com/developers/gitignore/api/node,visualstudiocode
# Edit at https://www.toptal.com/developers/gitignore?templates=node,visualstudiocode

### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# Snowpack dependency directory (https://snowpack.dev/)
web_modules/

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variables file
.env
.env.test
.env.production

# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache

# Next.js build output
.next
out

# Nuxt.js build / generate output
.nuxt
dist

# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public

# vuepress build output
.vuepress/dist

# Serverless directories
.serverless/

# FuseBox cache
.fusebox/

# DynamoDB Local files
.dynamodb/

# TernJS port file
.tern-port

# Stores VSCode versions used for testing VSCode extensions
.vscode-test

# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace

# Local History for Visual Studio Code
.history/

### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide

# End of https://www.toptal.com/developers/gitignore/api/node,visualstudiocode
tmp/
run*.sh
.DS_Store
config*/
playroom/


================================================
FILE: .husky/pre-commit
================================================
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npx lint-staged


================================================
FILE: .nvmrc
================================================
18


================================================
FILE: .prettierrc.json
================================================
{
  "arrowParens": "avoid",
  "bracketSpacing": false,
  "embeddedLanguageFormatting": "auto",
  "htmlWhitespaceSensitivity": "css",
  "insertPragma": false,
  "printWidth": 120,
  "proseWrap": "preserve",
  "quoteProps": "as-needed",
  "requirePragma": false,
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "all",
  "useTabs": false
}


================================================
FILE: Dockerfile
================================================
FROM alpine:latest

RUN mkdir -p /etc/udhcpc ; echo 'RESOLV_CONF="no"' >> /etc/udhcpc/udhcpc.conf

RUN apk add --update nodejs npm su-exec shadow yt-dlp

RUN rm -rf /var/cache/apk/*

RUN mkdir /app
WORKDIR /app

COPY . .

RUN npm ci

RUN chmod +x entrypoint.sh

ENTRYPOINT ["./entrypoint.sh"]


================================================
FILE: LICENSE
================================================
The MIT License (MIT)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.


================================================
FILE: README.md
================================================
<p align="center">
  <img src="https://i.imgur.com/FIGZdR3.png">
</p>

Current version: **4.15.6**

# About
This takes programming from various providers 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 streaming apps currently provide.
* The Mouse might not like it and it could be taken down at any minute. Enjoy it while it lasts. ¯\\_(ツ)_/¯

# Using
The server exposes 4 main endpoints:

| Endpoint | Description |
|---|---|
| /channels.m3u | The channel list you'll import into your client |
| /xmltv.xml | The schedule that you'll import into your client |
| /linear-channels.m3u | The linear channel list you'll import into your client (only used when using the dedicated linear channels option) |
| /linear-xmltv.xml | The linear schedule that you'll import into your client (only used when using the dedicated linear channels option) - Not needed for Channels DVR |

# Running
The recommended way of running is to pull the image from [Docker Hub](https://hub.docker.com/r/tonywagner/eplustv).

## Environment Variables
| Environment Variable | Description | Required? | Default |
|---|---|---|---|
| BASE_URL | If using a reverse proxy, m3u will be generated with this as the base. | No | - |
| 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 | - |
| PORT | Port the API will be served on. You can set this if it conflicts with another service in your environment. | No | 8000 |

### Available Providers

#### Bally

Available for free

#### B1G+

Available to login with B1G+ credentials (or for free with certain ISP providers)

#### CBS Sports

Available to login with TV Provider. Please note that there is no token refresh option here. It will require re-authenticating every 30 days.

#### ESPN

Limited free content. Also available to login with TV Provider

##### Linear Channels

Will create dedicated linear channels if using dedicated linear channels, otherwise will schedule events normally

| Network Name | Description |
|---|---|
| ESPN | Set if your TV provider supports it |
| ESPN2 | Set if your TV provider supports it |
| ESPNU | Set if your TV provider supports it |
| SEC Network | Set if your TV provider supports it |
| ACC Network | Set if your TV provider supports it |
| ESPNews | Set if your TV provider supports it |
| ESPN Deportes | Set if your TV provider supports it |

#### ESPN Account

Formerly ESPN+. Still available to login with ESPN credentials, but does not provide access to events anymore.

##### Extras
| Name | Description |
|---|---|
| ESPN+ PPV | Schedule ESPN+ PPV events |

#### FloSports

Available to login with FloSports credentials

#### FOXOne

Available to login with TV Provider - Direct Subscription or ESPN Subscription Not Currently Supported

##### Linear Channels

Will create dedicated linear channels if using dedicated linear channels, otherwise will schedule events normally

| Network Name |
|---|
| FOX | Set if your TV provider supports it |
| MyNetwork TV | Set if your TV provider supports it |
| FS1 | Set if your TV provider supports it |
| FS2 | Set if your TV provider supports it |
| B1G Network | Set if your TV provider supports it |
| FOX Deportes | Set if your TV provider supports it |
| FOX News Channel | Set if your TV provider supports it |
| FOX Business Network | Set if your TV provider supports it |
| TMZ | Set if your TV provider supports it |
| Masked Singer | Set if your TV provider supports it |
| FOX Soul | Set if your TV provider supports it |
| FOX Weather | Set if your TV provider supports it |
| FOX Live Now | Set if your TV provider supports it |

#### FOX Sports

Available to login with TV Provider

##### Linear Channels

Some events are on linear channels and some aren't. If you use dedicated linear channels, only events that are on FOX will be scheduled normally. All other events will be scheduled to linear channels

| Network Name |
|---|
| FS1 | Set if your TV provider supports it |
| FS2 | Set if your TV provider supports it |
| B1G Network | Set if your TV provider supports it |
| FOX Soccer Plus | Set if your TV provider supports it |
| FOX Deportes | Set if your TV provider supports it |

#### Gotham Sports

Available to login with Gotham Sports or TV Provider

##### Linear Channels

Will create dedicated linear channels if using dedicated linear channels, otherwise will schedule events normally

| Network Name | Description |
|---|---|
| MSG | MSG (If in your supported zone) |
| MSGSN | MSG Sportsnet HD (If in your supported zone) |
| MSG2 | MSG2 HD (If in your supported zone) |
| MSGSN2 | MSG Sportsnet 2 HD (If in your supported zone) |
| YES | Yes Network (If in your supported zone) |

#### Hudl

Various small college conferences, available for free

#### KBO

Available for free

#### KSL Sports

Available for free

#### LOVB

Use Victory+ instead

#### Midco Sports

Available to login with Midco Sports credentials

#### MLB.tv

Available to login with MLB.tv credentials

##### Extras
| Name | Description |
|---|---|
| Only free games | If you have a free account, only 1 free game per day will be scheduled |

##### Linear Channels

| Network Name | Description |
|---|---|
| Big Inning | Will create a dedicated linear channel if using dedicated linear channels, otherwise will schedule Big Inning normally |
| MLB Network | Only available if you have MLB Network as part of your MLB.tv account or have linked TVE Provider that provides access |
| SNY | Only available if you have SNY as part of your MLB.tv account or have linked TVE Provider that provides access |
| SNLA | Only available if you have SNLA+ as part of your MLB.tv account or have linked TVE Provider that provides access |

#### Mountain West

Available for free

#### NHL.tv

Available to login with NHL.tv account (Europe only)

#### NFL

Available to login with NFL.com credentials

This integration works with NFL+ or using other providers (TVE, Amazon Prime, Peacock, Sunday Ticket) to access games.

##### Extra Providers

If you don't have an NFL+ subscription, you can use these providers to access games.

| Provider Name | Description |
|---|---|
| Amazon Prime | Get TNF games from Amazon Prime |
| Peacock | Get SNF games from Peacock |
| TV Provider | Get in-market games from your TV Provider |
| Sunday Ticket | Get out-of-market games from Youtube |

##### Linear Channels

If you have access to NFL RedZone, it will be scheduled. If dedicated linear channels is set, it will be on its own channel

| Network Name | Description |
|---|---|
| NFL Network | NFL+ or TV Provider access |
| NFL RedZone | NFL+ Premium or TV Provider access |
| NFL Channel | Free channel for all accounts |

#### NWSL+

Available to login with NWSL+ credentials

#### Outside TV

Available to login with Outside TV credentials (free account)

##### Linear Channels

Dedicated linear channels - Will only schedule when dedicated linear channels is set

| Network Name |
|---|
| Outside |

#### Paramount+

Available to login with Paramount+ credentials

##### Linear Channels

Dedicated linear channels - Will only schedule when dedicated linear channels is set

| Network Name | Description |
|---|---|
| CBS Sports HQ | Set if your TV provider supports it |
| Golazo Network | Set if your TV provider supports it |

#### PWHL

Available for free

#### Victory+

Available to login with Victory+ credentials.

#### WNBA League Pass

Available to login with WNBA League Pass credentials

#### Women's Sports Network

Available for free - only linear channel

##### Linear Channels

| Network Name | Description |
|---|---|
| WSN | Women's Sports Network |

#### Zeam Live Events

Available for free

## Volumes
| Volume Name | Description | Required? |
|---|---|---|
| /app/config | Used to store DB and application state | Yes |


## Docker Run
By default, the easiest way to get running is:

```bash
docker run -p 8000:8000 -v config_dir:/app/config tonywagner/eplustv
```

If you run into permissions issues:

```bash
docker run -p 8000:8000 -v config_dir:/app/config -e PUID=$(id -u $USER) -e PGID=$(id -g $USER) tonywagner/eplustv
```

Open the service in your web browser at `http://<ip>:8000`


================================================
FILE: entrypoint.sh
================================================
#!/bin/sh

if [ -z "$PUID" ] || [ -z "$PGID" ]; then
  exec npm start
else
  adduser -u $PUID -D abc
  groupmod -g $PGID abc

  chown abc:abc -R /app

  exec su-exec abc npm start
fi


================================================
FILE: index.tsx
================================================
import {Context, Hono} from 'hono';
import {serve} from '@hono/node-server';
import {serveStatic} from '@hono/node-server/serve-static';
import {BlankEnv, BlankInput} from 'hono/types';
import {html} from 'hono/html';
import moment from 'moment';
import _ from 'lodash';
import axios from 'axios';

import fs from 'fs';
import {createServer} from 'node:https';

import {generateM3u, generateEventChannelsM3u} from './services/generate-m3u';
import {initDirectories} from './services/init-directories';
import {generateXml} from './services/generate-xmltv';
import {launchChannel} from './services/launch-channel';
import {scheduleEntries} from './services/build-schedule';
import {espnHandler} from './services/espn-handler';
import {foxHandler} from './services/fox-handler';
import {foxOneHandler} from './services/foxone-handler';
import {mlbHandler} from './services/mlb-handler';
import {b1gHandler} from './services/b1g-handler';
import {floSportsHandler} from './services/flo-handler';
import {paramountHandler} from './services/paramount-handler';
import {nflHandler} from './services/nfl-handler';
import {gothamHandler} from './services/gotham-handler';
import {mwHandler} from './services/mw-handler';
import {pwhlHandler} from './services/pwhl-handler';
import {ballyHandler} from './services/bally-handler';
import {wsnHandler} from './services/wsn-handler';
import {nwslHandler} from './services/nwsl-handler';
import {midcoHandler} from './services/midco-handler';
import {hudlHandler} from './services/hudl-handler';
import {cbsHandler} from './services/cbs-handler';
import {nhlHandler} from './services/nhltv-handler';
import {victoryHandler} from './services/victory-handler';
import {kboHandler} from './services/kbo-handler';
import {kslHandler} from './services/ksl-handler';
import {zeamHandler} from './services/zeam-handler';
import {outsideHandler} from './services/outside-handler';
import {wnbaHandler} from './services/wnba-handler';
import {
  cleanEntries,
  clearChannels,
  removeAllEntries,
  removeChannelStatus,
  resetSchedule,
  latestRelease,
} from './services/shared-helpers';
import {appStatus} from './services/app-status';
import {SERVER_PORT} from './services/port';
import {providers} from './services/providers';

import {version} from './package.json';

import {Layout} from './views/Layout';
import {Header} from './views/Header';
import {Main} from './views/Main';
import {Links} from './views/Links';
import {Style} from './views/Style';
import {Providers} from './views/Providers';
import {Script} from './views/Script';
import {Tools} from './views/Tools';
import {Options} from './views/Options';

import {CBSSports} from './services/providers/cbs-sports/views';
import {MntWest} from './services/providers/mw/views';
import {Hudl} from './services/providers/hudl/views';
import {Paramount} from './services/providers/paramount/views';
import {FloSports} from './services/providers/flosports/views';
import {MlbTv} from './services/providers/mlb/views';
import {FoxSports} from './services/providers/fox/views';
import {FoxOne} from './services/providers/foxone/views';
import {B1G} from './services/providers/b1g/views';
import {NFL} from './services/providers/nfl/views';
import {ESPN} from './services/providers/espn/views';
import {ESPNPlus} from './services/providers/espn-plus/views';
import {Gotham} from './services/providers/gotham/views';
import {WSN} from './services/providers/wsn/views';
import {PWHL} from './services/providers/pwhl/views';
import {Bally} from './services/providers/bally/views';
import {Nwsl} from './services/providers/nwsl/views';
import {Midco} from './services/providers/midco/views';
import {NHL} from './services/providers/nhl-tv/views';
import {Victory} from './services/providers/victory/views';
import {KBO} from './services/providers/kbo/views';
import {KSL} from './services/providers/ksl/views';
import {Zeam} from './services/providers/zeam/views';
import {Outside} from './services/providers/outside/views';
import {WNBA} from './services/providers/wnba/views';

import {
  initMiscDb,
  resetLinearStartChannel,
  setLinear,
  setNumberofChannels,
  setProxySegments,
  setStartChannel,
  usesLinear,
  setXmltvPadding,
  setHideStudio,
  setEventFilters,
  getLatestVersion,
  getLastModified,
  setLastModified,
} from './services/misc-db-service';

// Check for SSL environment variables
const sslCertificatePath = process.env.SSL_CERTIFICATE_PATH;
const sslPrivateKeyPath = process.env.SSL_PRIVATEKEY_PATH;

// Set timeout of requests to 1 minute
axios.defaults.timeout = 1000 * 60;

const notFound = (c: Context<BlankEnv, '', BlankInput>) => {
  return c.text('404 not found', 404, {
    'X-Tuner-Error': 'EPlusTV: Error getting content',
  });
};

const shutDown = () => process.exit(0);

const getUri = (c: Context<BlankEnv, '', BlankInput>): string => {
  if (process.env.BASE_URL) {
    return process.env.BASE_URL;
  }

  const protocol = c.req.header('x-forwarded-proto') || 'http';
  const host = c.req.header('host') || '';

  return `${protocol}://${host}`;
};

const checkVersion = async () => {
  const latest_version = await getLatestVersion();
  const latest_release = await latestRelease();
  if ( latest_release && (latest_release != '') && (latest_version != latest_release) && (version != latest_release.slice(1)) ) {
    console.log(`=== Newer version ${latest_release} available, consider updating ===`);
  }
}

const schedule = async () => {
  await checkVersion();

  console.log('=== Getting events ===');

  await Promise.all([
    espnHandler.getSchedule(),
    foxHandler.getSchedule(),
    foxOneHandler.getSchedule(),
    mlbHandler.getSchedule(),
    b1gHandler.getSchedule(),
    floSportsHandler.getSchedule(),
    mwHandler.getSchedule(),
    wsnHandler.getSchedule(),
    pwhlHandler.getSchedule(),
    ballyHandler.getSchedule(),
    hudlHandler.getSchedule(),
    nflHandler.getSchedule(),
    nwslHandler.getSchedule(),
    midcoHandler.getSchedule(),
    paramountHandler.getSchedule(),
    gothamHandler.getSchedule(),
    cbsHandler.getSchedule(),
    nhlHandler.getSchedule(),
    victoryHandler.getSchedule(),
    kboHandler.getSchedule(),
    kslHandler.getSchedule(),
    zeamHandler.getSchedule(),
    outsideHandler.getSchedule(),
    wnbaHandler.getSchedule(),
  ]);

  console.log('=== Done getting events ===');
  console.log('=== Building the schedule ===');

  await cleanEntries();
  await scheduleEntries();

  console.log('=== Done building the schedule ===');
};

const app = new Hono();

app.use('/node_modules/*', serveStatic({root: './'}));
app.use('/favicon.ico', serveStatic({root: './'}));

app.route('/', providers);

app.get('/', async c => {
  return c.html(
    html`<!DOCTYPE html>${(
        <Layout>
          <Header />
          <Main>
            <Links baseUrl={getUri(c)} />
            <Tools />
            <Options />
            <Providers>
              <Bally />
              <B1G />
              <CBSSports />
              <ESPN />
              <ESPNPlus />
              <FloSports />
              <FoxOne />
              <FoxSports />
              <Gotham />
              <Hudl />
              <KBO />
              <KSL />
              <Midco />
              <MlbTv />
              <MntWest />
              <NHL />
              <NFL />
              <Nwsl />
              <Outside />
              <Paramount />
              <PWHL />
              <Victory />
              <WNBA />
              <WSN />
              <Zeam />
            </Providers>
          </Main>
          <Style />
          <Script />
        </Layout>
      )}`,
  );
});

app.post('/rebuild-epg', async c => {
  await removeAllEntries();
  await schedule();

  return c.html(<Tools />, 200, {
    'HX-Trigger': `{"HXToast":{"type":"success","body":"Successfully rebuilt EPG"}}`,
  });
});

app.post('/reset-channels', async c => {
  clearChannels();

  return c.html(<Tools />, 200, {
    'HX-Trigger': `{"HXToast":{"type":"success","body":"Successfully cleared channels"}}`,
  });
});

app.post('/start-channel', async c => {
  const body = await c.req.parseBody();
  const startChannel = _.toNumber(body['start-channel']);

  if (_.isNaN(startChannel) || startChannel < 1) {
    return c.html(<Options />, 200, {
      'HX-Trigger': `{"HXToast":{"type":"error","body":"Starting channel must be a valid number"}}`,
    });
  }

  await setStartChannel(startChannel);
  await resetLinearStartChannel();
  await resetSchedule();
  await scheduleEntries();

  return c.html(<Options />, 200, {
    'HX-Trigger': `{"HXToast":{"type":"success","body":"Successfully saved starting channel number"}}`,
  });
});

app.post('/num-of-channels', async c => {
  const body = await c.req.parseBody();
  const numChannels = _.toNumber(body['num-of-channels']);

  if (_.isNaN(numChannels) || numChannels < 0 || numChannels > 5000) {
    return c.html(<Options />, 200, {
      'HX-Trigger': `{"HXToast":{"type":"error","body":"Number of channels must be a valid number"}}`,
    });
  }

  await setNumberofChannels(numChannels);
  await resetLinearStartChannel();
  await resetSchedule();
  await scheduleEntries();

  return c.html(<Options />, 200, {
    'HX-Trigger': `{"HXToast":{"type":"success","body":"Successfully saved number of channels"}}`,
  });
});

app.post('/linear-channels', async c => {
  const body = await c.req.parseBody();
  const enabled = body['linear-channels'] === 'on';

  await setLinear(enabled);

  if (enabled) {
    await removeAllEntries();
    await schedule();
  } else {
    await scheduleEntries();
  }

  return c.html(
    <input
      hx-target="this"
      hx-swap="outerHTML"
      hx-trigger="change"
      hx-post="/linear-channels"
      name="linear-channels"
      type="checkbox"
      role="switch"
      checked={enabled}
      data-enabled={enabled ? 'true' : 'false'}
    />,
    200,
    {
      'HX-Trigger': `{"HXRefresh": true, "HXToast":{"type":"success","body":"Successfully ${
        enabled ? 'enabled' : 'disabled'
      } dedicated linear channels. Page will refresh momentarily"}}`,
    },
  );
});

app.post('/proxy-segments', async c => {
  const body = await c.req.parseBody();
  const enabled = body['proxy-segments'] === 'on';

  await setProxySegments(enabled);

  return c.html(
    <input
      hx-post="/proxy-segments"
      hx-target="this"
      hx-swap="outerHTML"
      hx-trigger="change"
      name="proxy-segments"
      type="checkbox"
      role="switch"
      checked={enabled}
      data-enabled={enabled ? 'true' : 'false'}
    />,
    200,
    {
      'HX-Trigger': `{"HXToast":{"type":"success","body":"Successfully ${
        enabled ? 'enabled' : 'disabled'
      } proxying of segment files"}}`,
    },
  );
});

app.post('/xmltv-padding', async c => {
  const body = await c.req.parseBody();
  const enabled = body['xmltv-padding'] === 'on';

  await setXmltvPadding(enabled);

  return c.html(
    <input
      hx-post="/xmltv-padding"
      hx-target="this"
      hx-swap="outerHTML"
      hx-trigger="change"
      name="xmltv-padding"
      type="checkbox"
      role="switch"
      checked={enabled}
      data-enabled={enabled ? 'true' : 'false'}
    />,
    200,
    {
      'HX-Trigger': `{"HXToast":{"type":"success","body":"Successfully ${
        enabled ? 'enabled' : 'disabled'
      } XMLTV padding"}}`,
    },
  );
});

app.post('/hide-studio', async c => {
  const body = await c.req.parseBody();
  const enabled = body['hide-studio'] === 'on';

  await setHideStudio(enabled);

  return c.html(
    <input
      hx-post="/hide-studio"
      hx-target="this"
      hx-swap="outerHTML"
      hx-trigger="change"
      name="hide-studio"
      type="checkbox"
      role="switch"
      checked={enabled}
      data-enabled={enabled ? 'true' : 'false'}
    />,
    200,
    {
      'HX-Trigger': `{"HXToast":{"type":"success","body":"Successfully ${
        enabled ? 'enabled' : 'disabled'
      } hiding studio shows"}}`,
    },
  );
});

app.put('/event-filters', async c => {
  const body = await c.req.parseBody();
  const category_filter = body['category-filter'].toString();
  const title_filter = body['title-filter'].toString();

  await setEventFilters(category_filter, title_filter);
  await resetSchedule();
  await scheduleEntries();

  return c.html(
    <button type="submit" id="event-filters-button">
      Save and Apply Event Filters
    </button>,
    200,
    {
      'HX-Trigger': `{"HXToast":{"type":"success","body":"Successfully saved and applied event filters"}}`,
    },
  );
});

app.get('/channels.m3u', async c => {
  const m3uFile = await generateM3u(getUri(c));

  if (!m3uFile) {
    return notFound(c);
  }

  return c.body(m3uFile, 200, {
    'Content-Type': 'application/x-mpegurl',
  });
});

app.get('/event-channels.m3u', async c => {
  const m3uFile = await generateEventChannelsM3u(getUri(c));

  if (!m3uFile) {
    return notFound(c);
  }

  return c.body(m3uFile, 200, {'Content-Type': 'application/x-mpegurl'});
});

app.get('/linear-channels.m3u', async c => {
  const useLinear = await usesLinear();

  if (!useLinear) {
    return notFound(c);
  }

  const m3uFile = await generateM3u(getUri(c), true, c.req.query('gracenote') === 'exclude');

  if (!m3uFile) {
    return notFound(c);
  }

  return c.body(m3uFile, 200, {
    'Content-Type': 'application/x-mpegurl',
  });
});

app.get('/xmltv.xml', async c => {
  const xmlFile = await generateXml();

  if (!xmlFile) {
    return notFound(c);
  }

  return c.body(xmlFile, 200, {
    'Content-Type': 'application/xml',
  });
});

app.get('/linear-xmltv.xml', async c => {
  const useLinear = await usesLinear();

  if (!useLinear) {
    return notFound(c);
  }

  const xmlFile = await generateXml(true);

  if (!xmlFile) {
    return notFound(c);
  }

  return c.body(xmlFile, 200, {
    'Content-Type': 'application/xml',
  });
});

app.get('/channels/:id{.+\\.m3u8$}', async c => {
  const id = c.req.param('id').split('.m3u8')[0];

  let contents: string | undefined;

  // Channel data needs initial object
  if (!appStatus.channels[id]) {
    appStatus.channels[id] = {};
  }

  const uri = getUri(c);

  if (!appStatus.channels[id].player?.playlist) {
    try {
      await launchChannel(id, uri);
    } catch (e) {}
  }

  try {
    contents = appStatus.channels[id].player?.playlist;
  } catch (e) {}

  if (!contents) {
    console.log(
      `Could not get a playlist for channel #${id}. Please make sure there is an event scheduled and you have access to it.`,
    );

    removeChannelStatus(id);

    return notFound(c);
  }

  appStatus.channels[id].heartbeat = new Date();

  return c.body(contents, 200, {
    'Cache-Control': 'no-cache',
    'Content-Type': 'application/vnd.apple.mpegurl',
  });
});

app.get('/chunklist/:id/:chunklistid{.+\\.m3u8$}', async c => {
  const id = c.req.param('id');
  const chunklistid = c.req.param('chunklistid').split('.m3u8')[0];

  let contents: string | undefined;

  if (!appStatus.channels[id]?.player?.playlist) {
    return notFound(c);
  }

  try {
    contents = await appStatus.channels[id].player.cacheChunklist(chunklistid);
  } catch (e) {}

  if (!contents) {
    console.log(`Could not get chunklist for channel #${id}.`);
    removeChannelStatus(id);
    return notFound(c);
  }

  appStatus.channels[id].heartbeat = new Date();

  return c.body(contents, 200, {
    'Cache-Control': 'no-cache',
    'Content-Type': 'application/vnd.apple.mpegurl',
  });
});

app.get('/channels/:id/:part{.+\\.key$}', async c => {
  const id = c.req.param('id');
  const part = c.req.param('part').split('.key')[0];

  let contents: ArrayBuffer | undefined;

  try {
    contents = await appStatus.channels[id].player?.getSegmentOrKey(part);
  } catch (e) {
    return notFound(c);
  }

  if (!contents) {
    return notFound(c);
  }

  appStatus.channels[id].heartbeat = new Date();

  return c.body(contents, 200, {
    'Cache-Control': 'no-cache',
    'Content-Type': 'application/octet-stream',
  });
});

app.get('/channels/:id/:part{.+\\.ts$}', async c => {
  const id = c.req.param('id');
  const part = c.req.param('part').split('.ts')[0];

  let contents: ArrayBuffer | undefined;

  try {
    contents = await appStatus.channels[id].player?.getSegmentOrKey(part);
  } catch (e) {
    return notFound(c);
  }

  if (!contents) {
    return notFound(c);
  }

  return c.body(contents, 200, {
    'Cache-Control': 'no-cache',
    'Content-Type': 'video/MP2T',
  });
});

app.get('/channels/:id/:part{.+\\.m4i$}', async c => {
  const id = c.req.param('id');
  const part = c.req.param('part').split('.m4i')[0];

  let contents: ArrayBuffer | undefined;

  try {
    contents = await appStatus.channels[id].player?.getSegmentOrKey(part);
  } catch (e) {
    return notFound(c);
  }

  if (!contents) {
    return notFound(c);
  }

  return c.body(contents, 200, {
    'Cache-Control': 'no-cache',
    'Content-Type': 'video/MP2T',
  });
});

// 404 Handler
app.notFound(notFound);

process.on('SIGTERM', shutDown);
process.on('SIGINT', shutDown);

(async () => {
  console.log(`=== E+TV v${version} starting ===`);
  initDirectories();

  await initMiscDb();

  await checkVersion();

  await Promise.all([
    espnHandler.initialize(),
    foxHandler.initialize(),
    foxOneHandler.initialize(),
    mlbHandler.initialize(),
    b1gHandler.initialize(),
    floSportsHandler.initialize(),
    nflHandler.initialize(),
    nwslHandler.initialize(),
    midcoHandler.initialize(),
    paramountHandler.initialize(),
    gothamHandler.initialize(),
    cbsHandler.initialize(),
    victoryHandler.initialize(),
    nhlHandler.initialize(),
    mwHandler.initialize(),
    wsnHandler.initialize(),
    pwhlHandler.initialize(),
    ballyHandler.initialize(),
    hudlHandler.initialize(),
    kboHandler.initialize(),
    kslHandler.initialize(),
    zeamHandler.initialize(),
    outsideHandler.initialize(),
    wnbaHandler.initialize(),
  ]);

  await Promise.all([
    espnHandler.refreshTokens(),
    foxHandler.refreshTokens(),
    foxOneHandler.refreshTokens(),
    mlbHandler.refreshTokens(),
    b1gHandler.refreshTokens(),
    floSportsHandler.refreshTokens(),
    nflHandler.refreshTokens(),
    nwslHandler.refreshTokens(),
    paramountHandler.refreshTokens(),
    gothamHandler.refreshTokens(),
    cbsHandler.refreshTokens(),
    victoryHandler.refreshTokens(),
    nhlHandler.refreshTokens(),
    wnbaHandler.refreshTokens(),
  ]);

  if (sslCertificatePath && sslPrivateKeyPath) {
    serve(
      {
        createServer,
        fetch: app.fetch,
        port: SERVER_PORT,
        serverOptions: {
          cert: fs.readFileSync(sslCertificatePath),
          key: fs.readFileSync(sslPrivateKeyPath),
        },
      },
      () => {
        console.log(`HTTPS server started on port ${SERVER_PORT}`);
        schedule();
      },
    );
  } else {
    // Fall back to HTTP if SSL env variables are not provided
    serve(
      {
        fetch: app.fetch,
        port: SERVER_PORT,
      },
      () => {
        console.log(`HTTP server started on port ${SERVER_PORT}`);
        schedule();
      },
    );
  }
})();

// Check for events every 4 hours and set the schedule
setInterval(async () => {
  await schedule();
}, 1000 * 60 * 60 * 4);

// Check for updated refresh tokens 30 minutes
setInterval(
  () =>
    Promise.all([
      espnHandler.refreshTokens(),
      foxHandler.refreshTokens(),
      foxOneHandler.refreshTokens(),
      mlbHandler.refreshTokens(),
      b1gHandler.refreshTokens(),
      floSportsHandler.refreshTokens(),
      nflHandler.refreshTokens(),
      nwslHandler.refreshTokens(),
      paramountHandler.refreshTokens(),
      gothamHandler.refreshTokens(),
      cbsHandler.refreshTokens(),
      victoryHandler.refreshTokens(),
      nhlHandler.refreshTokens(),
      wnbaHandler.refreshTokens(),
    ]),
  1000 * 60 * 30,
);

// Remove idle playlists
setInterval(() => {
  const now = moment();

  for (const key of Object.keys(appStatus.channels)) {
    if (appStatus.channels[key] && appStatus.channels[key].heartbeat) {
      const channelHeartbeat = moment(appStatus.channels[key].heartbeat);

      if (now.diff(channelHeartbeat, 'minutes') > 5) {
        console.log(`Channel #${key} has been idle for more than 5 minutes. Removing playlist info.`);
        removeChannelStatus(key);
      }
    } else {
      console.log(`Channel #${key} was setup improperly... Removing.`);
      removeChannelStatus(key);
    }
  }
}, 1000 * 60);


================================================
FILE: package.json
================================================
{
  "name": "eplustv",
  "version": "4.15.6",
  "description": "",
  "scripts": {
    "start": "ts-node -r tsconfig-paths/register index.tsx",
    "prepare": "husky install"
  },
  "keywords": [],
  "license": "MIT",
  "dependencies": {
    "@hono/node-server": "^1.13.1",
    "@picocss/pico": "^2.0.6",
    "@seald-io/nedb": "^4.0.4",
    "axios": "^1.2.2",
    "axios-curlirize": "^1.3.7",
    "cheerio": "^1.0.0",
    "crypto-js": "^4.2.0",
    "fast-xml-parser": "^4.5.0",
    "fs-extra": "^10.0.0",
    "hls-parser": "^0.10.6",
    "hono": "^4.6.3",
    "htmx-toaster": "^0.0.18",
    "htmx.org": "^2.0.0",
    "jsdom": "^26.0.0",
    "jwt-decode": "^3.1.2",
    "lodash": "^4.17.21",
    "moment": "^2.29.1",
    "moment-timezone": "^0.5.45",
    "sharp": "^0.33.5",
    "sockette": "^2.0.6",
    "tsconfig-paths": "^4.2.0",
    "ws": "^8.9.0",
    "xml": "^1.0.1",
    "yt-dlp-wrap": "^2.3.12"
  },
  "devDependencies": {
    "@types/axios-curlirize": "^1.3.5",
    "@types/crypto-js": "^4.2.1",
    "@types/express": "^4.17.13",
    "@types/lodash": "^4.14.174",
    "@types/node": "^16.10.1",
    "@typescript-eslint/eslint-plugin": "^4.11.1",
    "@typescript-eslint/parser": "^4.11.1",
    "eslint": "^7.17.0",
    "eslint-plugin-sort-keys-custom-order-fix": "^0.1.1",
    "husky": "^7.0.1",
    "lint-staged": "^11.1.1",
    "prettier": "2.3.2",
    "ts-node": "^10.2.1",
    "typed-htmx": "^0.3.1",
    "typescript": "^4.4.3"
  },
  "lint-staged": {
    "!(slate/**).{ts,tsx}": [
      "prettier --write",
      "eslint --fix --format stylish"
    ]
  }
}


================================================
FILE: services/adobe-helpers.ts
================================================
import crypto from 'crypto';

import {getRandomHex} from './shared-helpers';

export interface IAdobeAuth {
  expires: string;
  mvpd: string;
  requestor: string;
  userId: string;
}

export interface IAdobeAuthFox {
  accessToken: string;
  tokenExpiration: number;
  mvpd: string;
  authn_expire: number;
}

export interface IAdobeAuthFoxOne {
  accessToken: string;
  tokenExpiration: number;
  mvpd: string;
  authn_expire: number;
}

export const createAdobeAuthHeader = (
  method = 'POST',
  path: string,
  privateKey: string,
  publicKey: string,
  requestor = 'ESPN',
): string => {
  const now = new Date().valueOf();
  const nonce = getRandomHex();

  let message = `${method} requestor_id=${requestor}, nonce=${nonce}, signature_method=HMAC-SHA1, request_time=${now}, request_uri=${path}`;
  const signature = crypto.createHmac('sha1', privateKey).update(message).digest().toString('base64');
  message = `${message}, public_key=${publicKey}, signature=${signature}`;

  return message;
};

export const isAdobeTokenValid = (token?: IAdobeAuth): boolean => {
  if (!token) {
    return false;
  }

  try {
    const parsedExp = parseInt(token.expires, 10);
    return new Date().valueOf() < new Date(parsedExp).valueOf();
  } catch (e) {
    return false;
  }
};

export const isAdobeFoxTokenValid = (token?: IAdobeAuthFox): boolean => {
  if (!token) {
    return false;
  }

  const now = new Date().valueOf();

  try {
    return now < token.authn_expire && now < token.tokenExpiration;
  } catch (e) {
    return false;
  }
};

export const isAdobeFoxOneTokenValid = (token?: IAdobeAuthFoxOne): boolean => {
  if (!token) {
    return false;
  }

  const now = new Date().valueOf();

  try {
    return now < token.authn_expire && now < token.tokenExpiration;
  } catch (e) {
    return false;
  }
};

export const willAdobeTokenExpire = (token?: IAdobeAuth): boolean => {
  if (!token) return true;

  try {
    const parsedExp = parseInt(token.expires, 10);
    // Will the token expire in the next day?
    return new Date().valueOf() + 3600 * 1000 * 24 > new Date(parsedExp).valueOf();
  } catch (e) {
    return true;
  }
};


================================================
FILE: services/app-status.ts
================================================
import {IAppStatus} from './shared-interfaces';

export const appStatus: IAppStatus = {
  channels: {},
};


================================================
FILE: services/b1g-handler.ts
================================================
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 {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';
import {db} from './database';
import {debug} from './debug';
import {normalTimeRange} from './shared-helpers';

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;
  title?: string;
  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[];
}

interface IB1GMeta {
  username: string;
  password: string;
}

const b1gConfigPath = path.join(configPath, 'b1g_tokens.json');

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);
    }
  });

  let awayTeam: string;
  let homeTeam: string;

  try {
    awayTeam = `${event.awayCompetitor.name} ${event.awayCompetitor.fullName}`;
    categories.push(awayTeam);
  } catch (e) {}

  try {
    homeTeam = `${event.homeCompetitor.name} ${event.homeCompetitor.fullName}`;
    categories.push(homeTeam);
  } catch (e) {}

  const eventName = event.title ? event.title : `${awayTeam} at ${homeTeam}`;

  return {
    categories: [...new Set(categories)],
    image: `https://www.bigtenplus.com/image/original/${event.images[0].path}`,
    name: eventName,
    sport,
  };
};

const parseAirings = async (events: IB1GEvent[]) => {
  const [now, endDate] = normalTimeRange();

  for (const event of events) {
    if (!event || !event.id) {
      continue;
    }

    const gameData = getEventData(event);

    for (const content of event.content) {
      const entryExists = await db.entries.findOneAsync<IEntry>({id: `b1g-${content.id}`});

      if (!entryExists) {
        const start = moment(event.startTime);
        const end = moment(event.startTime).add(4, 'hours');
        const originalEnd = moment(start).add(3, 'hours');

        if (end.isBefore(now) || start.isAfter(endDate) || content.enableDrmProtection) {
          continue;
        }

        console.log('Adding event: ', gameData.name);

        await db.entries.insertAsync<IEntry>({
          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+',
          originalEnd: originalEnd.valueOf(),
          sport: gameData.sport,
          start: start.valueOf(),
        });
      }
    }
  }
};

class B1GHandler {
  public access_token?: string;
  public expires_at?: number;

  public initialize = async () => {
    const setup = (await db.providers.countAsync({name: 'b1g'})) > 0 ? true : false;

    if (!setup) {
      const data: TB1GTokens = {};

      if (useB1GPlus) {
        this.loadJSON();

        data.access_token = this.access_token;
        data.expires_at = this.expires_at;
      }

      await db.providers.insertAsync<IProvider<TB1GTokens, IB1GMeta>>({
        enabled: useB1GPlus,
        meta: {
          password: process.env.B1GPLUS_PASS,
          username: process.env.B1GPLUS_USER,
        },
        name: 'b1g',
        tokens: data,
      });

      if (fs.existsSync(b1gConfigPath)) {
        fs.rmSync(b1gConfigPath);
      }
    }

    if (useB1GPlus) {
      console.log('Using B1GPLUS variable is no longer needed. Please use the UI going forward');
    }
    if (process.env.B1GPLUS_USER) {
      console.log('Using B1GPLUS_USER variable is no longer needed. Please use the UI going forward');
    }
    if (process.env.B1GPLUS_PASS) {
      console.log('Using B1GPLUS_PASS variable is no longer needed. Please use the UI going forward');
    }

    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'b1g'});

    if (!enabled) {
      return;
    }

    // Load tokens from local file and make sure they are valid
    await this.load();
  };

  public refreshTokens = async () => {
    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'b1g'});

    if (!enabled) {
      return;
    }

    if (!this.expires_at || moment(this.expires_at).isBefore(moment().add(100, 'days'))) {
      await this.login();
    }
  };

  public getSchedule = async (): Promise<void> => {
    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'b1g'});

    if (!enabled) {
      return;
    }

    console.log('Looking for B1G+ events...');

    try {
      let hasNextPage = true;
      let page = 1;
      let events: IB1GEvent[] = [];

      const [fromDate, toDate] = normalTimeRange();

      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(fromDate.format())}`,
          `&date_time_to=${encodeURIComponent(toDate.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;
      }

      debug.saveRequestData(events, 'b1g+', 'epg');

      await parseAirings(events);
    } catch (e) {
      console.error(e);
      console.log('Could not parse B1G+ events');
    }
  };

  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {
    const id = eventId.replace('b1g-', '');

    try {
      if (this.access_token) {
        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<void> => {
    try {
      const url = ['https://', 'www.bigtenplus.com', '/api/v3/cleeng/extend_token'].join('');
      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();

      await this.save();
    } catch (e) {
      console.error(e);
      console.log('Could not extend token for B1G+');
    }
  };

  private checkAccess = async (eventId: string, hideError?: boolean): Promise<string> => {
    try {
      const url = `https://www.bigtenplus.com/api/v3/contents/${eventId}/check-access`;
      const headers = {
        'User-Agent': b1gUserAgent,
        accept: 'application/json',
        'content-type': 'application/json',
      };
      if (this.access_token) {
        headers['Authorization'] = `Bearer ${this.access_token}`;
      }

      const params = {
        type: 'cleeng',
      };

      const {data} = await axios.post(url, params, {
        headers,
      });

      return data.data;
    } catch (e) {
      if (!hideError) {
        console.error(e);
        console.log('Could not get playback access token');
      }
    }
  };

  public ispAccess = async (): Promise<boolean> => {
    try {
      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())}`,
        '&limit=1',
      ].join('');

      const {data} = await axios.get(url, {
        headers: {
          'user-agent': okHttpUserAgent,
        },
      });

      const id = data.data[0].content[0].id;

      const accessToken = await this.checkAccess(id, true);

      if (accessToken) {
        console.log('Detected ISP access');
        return true;
      }
      console.log('Did not detect ISP access');
    } catch (e) {
      console.log('Could not check ISP access');
    }
    return false;
  };

  private getStream = async (eventId: string, userId: string, accessToken: string): Promise<string> => {
    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');
    }
  };

  public login = async (username?: string, password?: string): Promise<boolean> => {
    try {
      const url = ['https://', 'www.bigtenplus.com', '/api/v3/cleeng/login'].join('');
      const headers = {
        'User-Agent': b1gUserAgent,
        accept: 'application/json',
        'content-type': 'application/json',
      };

      const {meta} = await db.providers.findOneAsync<IProvider<any, IB1GMeta>>({name: 'b1g'});
      if (username == '' || !meta.username || meta.username == '') return true;

      const params = {
        email: username || meta.username,
        password: password || meta.password,
      };

      const {data} = await axios.post(url, params, {
        headers,
      });

      this.access_token = data.token;
      this.expires_at = moment().add(399, 'days').valueOf();

      await this.save();

      return true;
    } catch (e) {
      console.error(e);
      console.log('Could not login to B1G+');

      return false;
    }
  };

  private save = async (): Promise<void> => {
    await db.providers.updateAsync({name: 'b1g'}, {$set: {tokens: this}});
  };

  private load = async (): Promise<void> => {
    const {tokens} = await db.providers.findOneAsync<IProvider<TB1GTokens>>({name: 'b1g'});
    const {access_token, expires_at} = tokens;

    this.access_token = access_token;
    this.expires_at = expires_at;
  };

  private loadJSON = () => {
    if (fs.existsSync(b1gConfigPath)) {
      const {access_token, expires_at} = fsExtra.readJSONSync(path.join(configPath, 'b1g_tokens.json'));

      this.access_token = access_token;
      this.expires_at = expires_at;
    }
  };
}

export type TB1GTokens = ClassTypeWithoutMethods<B1GHandler>;

export const b1gHandler = new B1GHandler();


================================================
FILE: services/bally-handler.ts
================================================
import axios from 'axios';
import moment from 'moment';

import {userAgent} from './user-agent';
import {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';
import {db} from './database';
import {normalTimeRange} from './shared-helpers';
import {debug} from './debug';
import {usesLinear} from './misc-db-service';

interface IBallyTeam {
  name: string;
  logo_svg: string;
  color_base: string;
}

interface IBallyEvent {
  id: number;
  date_time: string;
  channel_name: string;
  public_cdn_url: string;
  home_team: IBallyTeam;
  away_team: IBallyTeam;
}

interface IBallyLinearEvent {
  id: number;
  title: string;
  since: string;
  till: string;
  channelUuid: number;
}

interface IBallyEPGRes {
  games: IBallyEvent[];
}

interface IBallyLinearEPGRes {
  events: IBallyLinearEvent[];
}

const API_KEY = [
  '9',
  '9',
  'c',
  '1',
  'd',
  '4',
  'f',
  'a',
  '-',
  'u',
  't',
  'x',
  'j',
  '-',
  '9',
  '9',
  '3',
  '6',
  '-',
  'f',
  '9',
  'a',
  'e',
  '-',
  'a',
  'd',
  '3',
  'c',
  '4',
  '3',
  'b',
  '8',
  'e',
  '4',
  'f',
  '5',
].join('');

const CHANNEL_IMAGE_MAP = {
  1001: 'https://tmsimg.fancybits.co/assets/s131359_ll_h9_aa.png?w=360&h=270',
  15: 'https://tmsimg.fancybits.co/assets/s104950_ll_h15_aa.png?w=360&h=270',
  2: 'https://assets-stratosphere.cdn.ballys.tv/images/MiLB_New_Logo_23.png',
  29: 'https://assets-stratosphere.cdn.ballys.tv/images/BananaBall_SB_01.png',
  6: 'https://assets-stratosphere.ballys.tv/images/BallyPoker_Channel_V3.png',
} as const;

const CHANNEL_MAP = {
  1001: 'GLORY',
  15: 'STADIUM',
  2: 'MiLB',
  29: 'bananaball',
  6: 'ballypoker',
} as const;

const CHANNEL_MAP_SWAP = {
  GLORY: 1001,
  MiLB: 2,
  STADIUM: 15,
  ballypoker: 6,
  bananaball: 29,
} as const;

const parseAirings = async (events: IBallyEvent[]) => {
  const [now, endDate] = normalTimeRange();

  for (const event of events) {
    if (!event || !event.id) {
      continue;
    }

    const entryExists = await db.entries.findOneAsync<IEntry>({id: `bally-${event.id}`});

    if (!entryExists) {
      const start = moment(event.date_time);
      const end = moment(start).add(4, 'hours');
      const originalEnd = moment(start).add(3, 'hours');

      if (end.isBefore(now) || start.isAfter(endDate)) {
        continue;
      }

      const eventName = `${event.away_team.name} at ${event.home_team.name}`;

      console.log('Adding event: ', eventName);

      await db.entries.insertAsync<IEntry>({
        categories: ['MiLB', 'Baseball', event.away_team.name, event.home_team.name, 'Bally Sports'],
        duration: end.diff(start, 'seconds'),
        end: end.valueOf(),
        from: 'bally',
        id: `bally-${event.id}`,
        image: 'https://img.mlbstatic.com/milb-images/image/upload/t_16x9/t_w2208/milb/omagzj463xltjyijyzzr',
        name: eventName,
        network: 'Bally Sports Live',
        originalEnd: originalEnd.valueOf(),
        sport: 'MiLB',
        start: start.valueOf(),
        url: event.public_cdn_url,
      });
    }
  }
};

const parseLinearAirings = async (events: IBallyLinearEvent[]) => {
  const [now, endDate] = normalTimeRange();

  for (const event of events) {
    if (!event || !event.id) {
      continue;
    }

    const entryExists = await db.entries.findOneAsync<IEntry>({id: `bally-live-${event.id}`});

    if (!entryExists) {
      const start = moment(event.since);
      const end = moment(event.till);

      if (end.isBefore(now) || start.isAfter(endDate)) {
        continue;
      }

      console.log('Adding event: ', event.title);

      await db.entries.insertAsync<IEntry>({
        categories: ['Bally Sports'],
        channel: CHANNEL_MAP[event.channelUuid],
        duration: end.diff(start, 'seconds'),
        end: end.valueOf(),
        from: 'bally',
        id: `bally-live-${event.id}`,
        image: CHANNEL_IMAGE_MAP[event.channelUuid],
        linear: true,
        name: event.title,
        network: 'Bally Sports Live',
        start: start.valueOf(),
      });
    }
  }
};

class BallyHandler {
  public initialize = async () => {
    const setup = (await db.providers.countAsync({name: 'bally'})) > 0 ? true : false;

    // First time setup
    if (!setup) {
      const useLinear = await usesLinear();

      await db.providers.insertAsync<IProvider<TBallyTokens>>({
        enabled: false,
        linear_channels: [
          {
            enabled: useLinear,
            id: 'STADIUM',
            name: 'Stadium HD',
            tmsId: '104950',
          },
          {
            enabled: useLinear,
            id: 'MiLB',
            name: 'MiLB',
          },
          {
            enabled: useLinear,
            id: 'bananaball',
            name: 'Banana Ball',
          },
          {
            enabled: useLinear,
            id: 'ballypoker',
            name: 'Bally Poker',
          },
          {
            enabled: useLinear,
            id: 'GLORY',
            name: 'GLORY Kickboxing',
            tmsId: '131359',
          },
        ],
        name: 'bally',
      });
    }

    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'bally'});

    if (!enabled) {
      return;
    }
  };

  public getSchedule = async (): Promise<void> => {
    const {enabled, linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'bally'});

    if (!enabled) {
      return;
    }

    console.log('Looking for Bally Sports events...');

    const entries: IBallyEvent[] = [];
    const linearEntries: IBallyLinearEvent[] = [];

    const [now, endSchedule] = normalTimeRange();

    try {
      const url = [
        'https://',
        'api-prod.prod2.ballylive.app',
        '/main/api/v1',
        '/content-service',
        '/mlb/schedule',
        '?startDate=',
        now.format('YYYY-MM-DD'),
        '&endDate=',
        endSchedule.format('YYYY-MM-DD'),
        '&includeFakeGames=false',
      ].join('');

      const {data} = await axios.get<IBallyEPGRes[]>(url, {
        headers: {
          'Content-Type': 'application/json',
          'User-Agent': userAgent,
          'x-api-key': API_KEY,
        },
      });

      debug.saveRequestData(data, 'bally', 'epg');

      data.forEach(e => e.games.forEach(g => entries.push(g)));

      const useLinear = await usesLinear();

      if (useLinear) {
        const linearUrl = ['https://', 'api-prod.prod2.ballylive.app', '/main/video/epg'].join('');

        const {data: linearData} = await axios.get<IBallyLinearEPGRes>(linearUrl, {
          headers: {
            'Content-Type': 'application/json',
            'User-Agent': userAgent,
            'x-api-key': API_KEY,
          },
        });

        const linearChannelMap = new Map();

        linearData.events.forEach(e => {
          const channelId = CHANNEL_MAP[e.channelUuid];

          let enabled = false;

          if (!linearChannelMap.has(channelId)) {
            enabled = linear_channels.find(c => c.id === channelId)?.enabled;
            linearChannelMap.set(channelId, enabled);
          } else {
            enabled = linearChannelMap.get(channelId);
          }

          if (enabled) {
            linearEntries.push(e);
          }
        });
      }
    } catch (e) {
      console.error(e);
      console.log('Could not parse Bally Sports events');
    }

    await parseAirings(entries);
    await parseLinearAirings(linearEntries);
  };

  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {
    const event = await db.entries.findOneAsync<IEntry>({id: eventId});

    try {
      if (eventId.indexOf('bally-live-') > -1) {
        const {channel} = event;

        const channelId = CHANNEL_MAP_SWAP[channel];
        const url = ['https://', 'api-prod.prod2.ballylive.app', '/main/video/linear-channels'].join('');

        const {data} = await axios.get(url, {
          headers: {
            'Content-Type': 'application/json',
            'User-Agent': userAgent,
            'x-api-key': API_KEY,
          },
        });

        const channelData = data.channels.find(c => c.uuid === channelId);

        if (channelData) {
          return [channelData.stream_info.connected_tv.default_abr, {}];
        } else {
          throw new Error('Could not start playback');
        }
      }

      let streamUrl: string;

      if (event.url) {
        streamUrl = event.url;
      }

      return [streamUrl, {}];
    } catch (e) {
      console.error(e);
      console.log('Could not start playback');
    }
  };
}

export type TBallyTokens = ClassTypeWithoutMethods<BallyHandler>;

export const ballyHandler = new BallyHandler();


================================================
FILE: services/build-schedule.ts
================================================
import {db, IDocument} from './database';
import {getNumberOfChannels, getStartChannel, usesLinear, getCategoryFilter, getTitleFilter} from './misc-db-service';
import {IChannel, IEntry} from './shared-interfaces';
import {formatEntryName, usesMultiple} from './generate-xmltv';

export const removeEntriesProvider = async (providerName: string): Promise<void> => {
  await db.entries.removeAsync({from: providerName}, {multi: true});
};

export const removeEntriesNetwork = async (networkName: string): Promise<void> => {
  await db.entries.removeAsync({network: networkName}, {multi: true});
};

const scheduleEntry = async (entry: IEntry & IDocument, startChannel: number, numOfChannels: number): Promise<void> => {
  let channelNum: number;

  const availableChannels = await db.schedule
    .findAsync<IChannel & IDocument>({channel: {$gte: startChannel}, endsAt: {$lt: entry.start}})
    .sort({channel: 1});

  if (!availableChannels || !availableChannels.length) {
    const channelNums = await db.schedule.countAsync({});

    if (channelNums > numOfChannels - 1) {
      return;
    }

    channelNum = channelNums + startChannel;

    await db.schedule.insertAsync<IChannel>({
      channel: channelNum,
      endsAt: entry.end,
    });
  } else {
    channelNum = +availableChannels[0].channel;

    await db.schedule.updateAsync<IChannel & IDocument, any>(
      {_id: availableChannels[0]._id},
      {$set: {endsAt: entry.end}},
    );
  }

  await db.entries.updateAsync<IEntry, any>({_id: entry._id}, {$set: {channel: channelNum}});
};

export const scheduleEntries = async (): Promise<void> => {
  let needReschedule = false;

  const useLinear = await usesLinear();
  const startChannel = await getStartChannel();
  const numOfChannels = await getNumberOfChannels();

  if (!useLinear) {
    const linearEntries = await db.entries.countAsync({linear: {$exists: true}});

    if (linearEntries > 0) {
      needReschedule = true;
    }
  }

  if (needReschedule) {
    console.log('');
    console.log('====================================================================');
    console.log('===                                                              ===');
    console.log('===   Need to rebuild the schedule because the linear channels   ===');
    console.log('===            variable is no longer being used.                 ===');
    console.log('===                                                              ===');
    console.log('====================================================================');
    console.log('===  THIS WILL BREAK SCHEDULED RECORDINGS IN YOUR DVR SOFTWARE   ===');
    console.log('====================================================================');
    console.log('');

    // Remove schedule
    await db.schedule.removeAsync({}, {multi: true});

    // Remove all dedicated linear channel entries
    await db.entries.removeAsync(
      {$or: [{channel: 'cbssportshq'}, {channel: 'golazo'}, {channel: 'NFLNETWORK'}, {channel: 'NFLDIGITAL1_OO_v3'}]},
      {multi: true},
    );

    // Remove channel and linear props from existing entries
    await db.entries.updateAsync<IEntry, any>({}, {$unset: {channel: true, linear: true}}, {multi: true});

    return await scheduleEntries();
  }

  const unscheduledEntries = await db.entries
    .findAsync<IEntry & IDocument>({channel: {$exists: false}})
    .sort({start: 1});

  const useMultiple = await usesMultiple();

  const categoryFilter = await getCategoryFilter();

  const normalized_category_filters =
    categoryFilter && categoryFilter.trim().length > 0
      ? categoryFilter.split(',').map(category => category.toLowerCase().trim())
      : [];

  const titleFilter = await getTitleFilter();

  const normalized_title_filter =
    titleFilter && titleFilter.trim().length > 0 && new RegExp(titleFilter);

  let scheduledEntryCount = 0;
  for (const entry of unscheduledEntries) {
    const formattedEntryName = formatEntryName(entry, useMultiple);

    if (
      normalized_category_filters.length > 0 &&
      !normalized_category_filters.some(v => entry.categories.map(category => category.toLowerCase()).includes(v))
    ) {
      continue;
    }

    if (normalized_title_filter && !formattedEntryName.match(normalized_title_filter)) {
      continue;
    }

    console.log('Scheduling event: ', formattedEntryName);
    await scheduleEntry(entry, startChannel, numOfChannels);
    scheduledEntryCount++;
  }

  scheduledEntryCount > 0 && console.log(`Scheduled ${scheduledEntryCount} entries...`);
};


================================================
FILE: services/caching.ts
================================================
import axios, {AxiosResponse} from 'axios';

import {generateRandom} from './shared-helpers';
import {IHeaders} from './shared-interfaces';
import {userAgent} from './user-agent';

// Set a max memory size of 128MB
const MAX_SIZE = 1024 * 1024 * 128;

interface IPromiseMap {
  promise: Promise<any>;
  ttl: number;
}

class PromiseCache {
  private mapper = new Map<string, IPromiseMap>();

  public getPromise<T>(keyId: string, call: Promise<any>, ttl: number): Promise<T> {
    const now = new Date().valueOf();

    const mappedPromse = this.mapper.get(keyId);

    if (mappedPromse && mappedPromse.ttl > now) {
      return this.mapper.get(keyId).promise.catch(e => {
        console.error(e);
        // Remove promise from cache if it has failed
        this.removePromise(keyId);
      });
    }

    this.mapper.set(keyId, {
      promise: call,
      ttl: now + ttl,
    });

    return call;
  }

  public removePromise(keyId: string) {
    this.mapper.delete(keyId);
  }
}

export const promiseCache = new PromiseCache();

class CacheLayer {
  private keyMap = new Map<string, string>();
  private chunklistMap = new Map<string, string>();

  private fifo: string[] = [];
  private size = 0;

  public getChunklistFromUrl(url: string, prefix = ''): string {
    if (this.chunklistMap.has(url)) {
      return this.chunklistMap.get(url);
    }

    const randomId = generateRandom(8, prefix);

    this.chunklistMap.set(url, randomId);
    this.chunklistMap.set(randomId, url);

    return randomId;
  }

  public getChunklistFromId(id: string): string {
    if (this.chunklistMap.has(id)) {
      return this.chunklistMap.get(id);
    }

    throw new Error(`Could not find URL for: ${id}`);
  }

  public getSegmentFromUrl(url: string, prefix = ''): string {
    if (this.keyMap.has(url)) {
      return this.keyMap.get(url);
    }

    const randomId = generateRandom(8, prefix);

    this.keyMap.set(url, randomId);
    this.keyMap.set(randomId, url);

    return randomId;
  }

  public async getDataFromSegment(segment: string, headers: IHeaders, network?: string): Promise<ArrayBuffer> {
    const url = this.keyMap.get(segment);

    if (!url) {
      throw new Error(`Could not find URL for: ${segment}`);
    }

    try {
      const isKey = segment.includes('-key-');
      const isFoxOne = network === 'foxone';
      const cacheTTL = (isFoxOne && isKey) ? 1000 * 30 : 1000 * 60 * 3; 

      const res = await promiseCache.getPromise<AxiosResponse<ArrayBuffer>>(
        segment,
        axios.get<ArrayBuffer>(url, {
          headers: {
            'User-Agent': userAgent,
            ...headers,
          },
          responseType: 'arraybuffer',
        }),
        cacheTTL,
      );

      if (!res) {
        throw new Error('Cached segment or key failed to resolve!');
      }

      const {data} = res;

      const size = (data as any).length;

      if (!(isFoxOne && isKey)) {
        while (this.size + size > MAX_SIZE) {
          const url = this.fifo.shift();
          const segmentId = this.keyMap.get(url);

          process.nextTick(() => {
            promiseCache.removePromise(segmentId);
            this.keyMap.delete(url);
            this.keyMap.delete(segmentId);
          });

          this.size -= size;
        }

        this.fifo.push(url);
        this.size += size;
      }

      return data;
    } catch (e) {
      if (network === 'foxone' && segment.includes('-key-')) {
        promiseCache.removePromise(segment);
      }
      console.error(`Error fetching ${segment}:`, e.message || e);
      throw new Error(`Could not fetch data for: ${segment}`);
    }
  }
}

export const cacheLayer = new CacheLayer();

================================================
FILE: services/cbs-handler.ts
================================================
import fs from 'fs';
import fsExtra from 'fs-extra';
import path from 'path';
import axios from 'axios';
import moment from 'moment';
import _ from 'lodash';
import crypto from 'crypto';

import {cbsSportsUserAgent, userAgent} from './user-agent';
import {configPath} from './config';
import {useCBSSports} from './networks';
import {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';
import {db} from './database';
import {getRandomUUID, normalTimeRange} from './shared-helpers';
import {createAdobeAuthHeader} from './adobe-helpers';
import {debug} from './debug';

interface ICBSEvent {
  id: number;
  video?: {
    sources?: {
      hls?: {
        url: string;
        urlNoAd: string;
      };
    };
    about: {
      duration: number;
      images: {
        baseImage2x3?: string;
        baseImage16X9: string;
        baseImage16X5?: string;
      };
      prefix: string;
      description: string;
      title: string;
      shortTitle: string;
    };
    network: string;
    schedule: {
      videoStartDate: number;
      videoEndDate: number;
    };
    ads: {
      dai?: {
        daiAssetKey?: string;
      };
    };
    analytics: {
      nielsonGenre: string;
    };
    properties: {
      type: string;
      sport: string;
      league?: string;
      leagueDisplayName?: string;
      tagSlugs?: string[];
      tags?: string[];
    };
    authentication: string[];
  };
}

interface IGameData {
  name: string;
  sport: string;
  image: string;
  categories: string[];
}

const API_KEY = [
  'l',
  'y',
  'R',
  '2',
  'U',
  '3',
  '7',
  'S',
  'i',
  'e',
  '8',
  '0',
  'c',
  '0',
  '2',
  'c',
  'J',
  'M',
  'p',
  'O',
  'H',
  '4',
  'C',
  '3',
  'g',
  'J',
  'e',
  't',
  'y',
  '4',
  'L',
  'O',
  '1',
  'W',
  'n',
  'L',
  'A',
  '1',
  'F',
  'O',
].join('');

const ADOBE_KEY = ['w', 'G', 'x', 'd', 'a', 'c', 'C', 'K', 'M', 'S', '8', 't', 'X', 'n', 'A', 'S'].join('');

const ADOBE_PUBLIC_KEY = [
  'G',
  'F',
  '6',
  'q',
  'D',
  '5',
  'q',
  'a',
  't',
  '3',
  'l',
  'L',
  'w',
  '9',
  'a',
  'y',
  '8',
  'I',
  'j',
  'g',
  '8',
  '0',
  'b',
  '3',
  'N',
  'P',
  'H',
  '7',
  'c',
  'F',
  'E',
  'G',
].join('');

const SYNCBAK_KEY = [
  '0',
  'e',
  'f',
  'b',
  'e',
  '7',
  '9',
  'd',
  '9',
  '6',
  'f',
  '2',
  '4',
  'f',
  '9',
  '2',
  '8',
  'd',
  '9',
  '1',
  'f',
  '5',
  'f',
  'd',
  '8',
  '9',
  '5',
  '5',
  'd',
  '1',
  '4',
  '3',
].join('');

const SYNCBAK_PUBLIC_KEY = [
  '1',
  'b',
  '3',
  'c',
  '7',
  '2',
  '7',
  'c',
  'a',
  '1',
  '1',
  '6',
  '4',
  'a',
  '1',
  '9',
  '8',
  '5',
  '1',
  'a',
  '1',
  '0',
  '2',
  'e',
  'a',
  '6',
  '5',
  '0',
  'e',
  '4',
  '9',
  'd',
].join('');

const CHANNEL_MAP = {
  CBSCHAMPIONSLEAGUE: 'ydKcHHYQSt27vbSP38xMVw',
  CBSSGOLAZO: '7f3Wv6f7QEKfQna22jHqLQ',
  CBSSHQ: '9Lq0ERvoSR-z9AwvFS-xYA',
} as const;

const cbsConfigPath = path.join(configPath, 'cbs_tokens.json');

const getEventData = (event: ICBSEvent): IGameData => {
  const sport = event.video.properties.sport;
  const categories: string[] = [
    'CBS Sports',
    'CBS',
    sport,
    ...(event.video.properties.tagSlugs || []),
    event.video.properties.league,
    event.video.properties.leagueDisplayName,
  ];

  return {
    categories: [...new Set(categories)].filter(a => a),
    image:
      event.video.about.images.baseImage16X9 ||
      event.video.about.images.baseImage2x3 ||
      event.video.about.images.baseImage16X5,
    name: event.video.about.title,
    sport,
  };
};

const parseAirings = async (events: ICBSEvent[]) => {
  const [now, endDate] = normalTimeRange();

  for (const event of events) {
    if (!event || !event.id) {
      continue;
    }

    const gameData = getEventData(event);

    const entryExists = await db.entries.findOneAsync<IEntry>({id: event.id});

    if (!entryExists) {
      const start = moment(event.video.schedule.videoStartDate * 1000);
      const end = moment(event.video.schedule.videoEndDate * 1000).add(1, 'hour');
      const originalEnd = moment(event.video.schedule.videoEndDate * 1000);

      if (end.isBefore(now) || start.isAfter(endDate)) {
        continue;
      }

      console.log('Adding event: ', gameData.name);

      await db.entries.insertAsync<IEntry>({
        categories: gameData.categories,
        duration: end.diff(start, 'seconds'),
        end: end.valueOf(),
        feed: event.video.network,
        from: 'cbssports',
        id: `${event.id}`,
        image: gameData.image,
        name: gameData.name,
        network: 'CBS Sports',
        originalEnd: originalEnd.valueOf(),
        sport: gameData.sport,
        start: start.valueOf(),
        ...((event.video.sources.hls.urlNoAd || event.video.sources.hls.url) && {
          url: event.video.sources.hls.urlNoAd || event.video.sources.hls.url,
        }),
      });
    }
  }
};

class CBSHandler {
  public device_id?: string;
  public user_id?: string;
  public mvpd_id?: string;
  public expires_at?: string;

  public initialize = async () => {
    const setup = (await db.providers.countAsync({name: 'cbs'})) > 0 ? true : false;

    // First time setup
    if (!setup) {
      const data: TCBSTokens = {};

      if (useCBSSports) {
        this.loadJSON();

        data.device_id = this.device_id;
        data.user_id = this.user_id;
        data.mvpd_id = this.mvpd_id;
      }

      await db.providers.insertAsync<IProvider<TCBSTokens>>({
        enabled: useCBSSports,
        name: 'cbs',
        tokens: data,
      });

      if (fs.existsSync(cbsConfigPath)) {
        fs.rmSync(cbsConfigPath);
      }
    }

    if (useCBSSports) {
      console.log('Using CBSSPORTS variable is no longer needed. Please use the UI going forward');
    }

    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'cbs'});

    if (!enabled) {
      return;
    }

    // Load tokens from local file and make sure they are valid
    await this.load();
  };

  public refreshTokens = async () => {
    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'cbs'});

    if (!enabled) {
      return;
    }

    await this.adobeAuthN();
  };

  public getSchedule = async (): Promise<void> => {
    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'cbs'});

    if (!enabled) {
      return;
    }

    const dma = await this.getDMACode();

    console.log('Looking for CBS Sports events...');

    const entries: ICBSEvent[] = [];

    const [now, endSchedule] = normalTimeRange();
    now.subtract(12, 'hours');

    try {
      const url = [
        'https://',
        'video-api.cbssports.com',
        '/vms/events/v5/',
        '?device=firetv',
        '&transform=ottv5',
        '&dma=',
        dma,
      ].join('');

      const {data} = await axios.get<ICBSEvent[]>(url, {
        headers: {
          'Content-Type': 'application/json',
          'User-Agent': cbsSportsUserAgent,
          'x-api-key': API_KEY,
        },
      });

      debug.saveRequestData(data, 'cbssports', 'epg');

      data.forEach(e => {
        if (
          (e.video?.authentication.includes('adobe') || _.isEqual(e.video?.authentication, [])) &&
          moment(e.video.schedule.videoStartDate * 1000).isBefore(endSchedule) &&
          // Some events have a crazy old start date
          moment(e.video.schedule.videoStartDate * 1000).isAfter(now) &&
          moment(e.video.schedule.videoEndDate * 1000).isAfter(now)
        ) {
          entries.push(e);
        }
      });
    } catch (e) {
      console.error(e);
      console.log('Could not parse CBS Sports events');
    }

    await parseAirings(entries);
  };

  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {
    const event = await db.entries.findOneAsync<IEntry>({id: eventId});

    try {
      let streamUrl: string;

      if (event.url) {
        streamUrl = event.url;
      }

      // CBSSN || CBSE
      if (!CHANNEL_MAP[event.feed]) {
        const dma = await this.getDMACode();
        const token = this.generateTimedToken();

        const network = event.feed === 'CBSSN' ? 'CBS_SPORTS_NETWORK' : 'CBS_ENTERTAINMENT';

        const url = [
          'https://',
          'www.cbssports.com',
          '/api/content',
          '/video/syncbak/get-secure-url',
          '/1b3c727ca1164a19851a102ea650e49d/',
          token,
          `/${network}/`,
          this.mvpd_id,
          '/8/',
          dma,
          '/?as=json&version=4',
        ].join('');

        const {data} = await axios.get(url, {
          headers: {
            'User-Agent': cbsSportsUserAgent,
          },
        });

        streamUrl = data.SUCCESS;
      } else if (!streamUrl) {
        const url = ['https://', 'pubads.g.doubleclick.net', '/ssai/event/', CHANNEL_MAP[event.feed], '/streams'].join(
          '',
        );

        const {data} = await axios.post(
          url,
          {},
          {
            headers: {
              'Content-Type': 'application/x-www-form-urlencoded',
              'User-Agent': userAgent,
            },
          },
        );

        streamUrl = data.stream_manifest;
      }

      return [streamUrl, {}];
    } catch (e) {
      console.error(e);
      console.log('Could not start playback');
    }
  };

  private generateTimedToken = (): string =>
    crypto
      .createHmac('sha1', SYNCBAK_KEY)
      .update(`${Math.floor(Date.now() / 1000)}${SYNCBAK_PUBLIC_KEY}`)
      .digest('hex');

  private getDMACode = async (): Promise<string> => {
    try {
      const url = ['https://', 'video-api-geo.cbssports.com/'].join('');

      const {data} = await axios.get(url, {
        headers: {
          'Content-Type': 'application/json',
          'User-Agent': cbsSportsUserAgent,
          'x-api-key': API_KEY,
        },
      });

      return data.dmaId;
    } catch (e) {
      console.error(e);
      console.log('Could not get DMA Code for CBS Sports');
    }
  };

  public getAuthCode = async (): Promise<string> => {
    this.device_id = getRandomUUID();

    try {
      const url = [
        'https://',
        'video-api.cbssports.com',
        '/vms',
        '/shortcode',
        '/v1',
        '?deviceId=',
        this.device_id,
        '&deviceType=androidtv',
        '&authTypes=adobe',
        '&currentSubscriptions=',
      ].join('');

      const {data} = await axios.get(url, {
        headers: {
          'Content-Type': 'application/json',
          'User-Agent': cbsSportsUserAgent,
          'x-api-key': API_KEY,
        },
      });

      return data.code;
    } catch (e) {
      console.error(e);
      console.log('Could not login to CBS Sports');
    }
  };

  public authenticateRegCode = async (code: string): Promise<boolean> => {
    try {
      const url = ['https://', 'video-api.cbssports.com', '/vms/shortcode/v1', '/status?shortcode=', code].join('');

      const {data} = await axios.get(url, {
        headers: {
          'Content-Type': 'application/json',
          'User-Agent': cbsSportsUserAgent,
          'x-api-key': API_KEY,
        },
      });

      if (!data || data?.subscriptions.adobe !== 'y') {
        return false;
      }

      await this.adobeAuthN();

      return true;
    } catch (e) {
      return false;
    }
  };

  private adobeAuthN = async (): Promise<void> => {
    try {
      const url = [
        'https://',
        'api.auth.adobe.com',
        '/api/v1/tokens/authn',
        '?requestor=CBS_SPORTS',
        '&deviceId=',
        this.device_id,
        '&deviceType=androidtv',
      ].join('');

      const {data} = await axios.get(url, {
        headers: {
          Authorization: createAdobeAuthHeader('GET', '/authn', ADOBE_KEY, ADOBE_PUBLIC_KEY, 'CBS_SPORTS'),
          'Content-Type': 'application/json',
          'User-Agent': cbsSportsUserAgent,
        },
      });

      this.user_id = data.userId;
      this.mvpd_id = data.mvpd;
      this.expires_at = data.expires;

      await this.save();
    } catch (e) {
      console.error(e);
      console.log('Could not lauthenticate with Adobe (CBS Sports)');
    }
  };

  private save = async (): Promise<void> => {
    await db.providers.updateAsync({name: 'cbs'}, {$set: {tokens: this}});
  };

  private loadJSON = () => {
    if (fs.existsSync(cbsConfigPath)) {
      const {device_id, user_id, mvpd_id} = fsExtra.readJSONSync(cbsConfigPath);

      this.device_id = device_id;
      this.user_id = user_id;
      this.mvpd_id = mvpd_id;
    }
  };

  private load = async (): Promise<void> => {
    const {tokens} = await db.providers.findOneAsync<IProvider<TCBSTokens>>({name: 'cbs'});
    const {device_id, user_id, mvpd_id, expires_at} = tokens || {};

    this.device_id = device_id;
    this.user_id = user_id;
    this.mvpd_id = mvpd_id;
    this.expires_at = expires_at;
  };
}

export type TCBSTokens = ClassTypeWithoutMethods<CBSHandler>;

export const cbsHandler = new CBSHandler();


================================================
FILE: services/channels.ts
================================================
import _ from 'lodash';

import {foxOneHandler} from './foxone-handler';
import {db} from './database';
import {IProvider} from './shared-interfaces';
import {getLinearStartChannel, usesLinear} from './misc-db-service';
import {gothamHandler} from './gotham-handler';

async function startApp() {
  await foxOneHandler.initialize(); // Ensures stationMap is populated
}

export const checkChannelEnabled = async (provider: string, channelId: string): Promise<boolean> => {
  const {enabled, linear_channels} = await db.providers.findOneAsync<IProvider>({name: provider});

  if (!enabled || !linear_channels || !linear_channels.length) {
    return false;
  }

  const network = linear_channels.find(c => c.id === channelId);

  return network?.enabled;
};

// Function to get dynamic stationId and callSign from foxOneHandler
const getFoxOneChannelData = async () => {

  const stationMap = await foxOneHandler.getStationMap();

  //console.log('getFoxOneChannelData Station Map:    ', stationMap)
  // Check if stationMap is empty or missing required keys
  // if (!stationMap['FOX'] || !stationMap['MNTV']) {
  //   await foxOneHandler.getEvents(); // Populate stationMap if empty
  // }

  return {
    foxStationId: stationMap['FOX']?.stationId,
    foxCallSign: stationMap['FOX']?.callSign,
    mnStationId: stationMap['MNTV']?.stationId,
    mnCallSign: stationMap['MNTV']?.callSign,
  };
};

/* eslint-disable sort-keys-custom-order-fix/sort-keys-custom-order-fix */
export const CHANNELS = {
  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
  get MAP() {
    return {
      0: {
        checkChannelEnabled: () => checkChannelEnabled('espn', 'espn1'),
        id: 'espn1',
        logo: 'https://tmsimg.fancybits.co/assets/s32645_h3_aa.png?w=360&h=270',
        name: 'ESPN',
        stationId: '32645',
        tvgName: 'ESPNHD',
        provider: 'espn',
      },
      1: {
        checkChannelEnabled: () => checkChannelEnabled('espn', 'espn2'),
        id: 'espn2',
        logo: 'https://tmsimg.fancybits.co/assets/s45507_ll_h15_aa.png?w=360&h=270',
        name: 'ESPN2',
        stationId: '45507',
        tvgName: 'ESPN2HD',
        provider: 'espn',
      },
      2: {
        checkChannelEnabled: () => checkChannelEnabled('espn', 'espnu'),
        id: 'espnu',
        logo: 'https://tmsimg.fancybits.co/assets/s60696_ll_h15_aa.png?w=360&h=270',
        name: 'ESPNU',
        stationId: '60696',
        tvgName: 'ESPNUHD',
        provider: 'espn',
      },
      3: {
        checkChannelEnabled: () => checkChannelEnabled('espn', 'sec'),
        id: 'sec',
        logo: 'https://tmsimg.fancybits.co/assets/s89714_ll_h15_aa.png?w=360&h=270',
        name: 'SEC Network',
        stationId: '89714',
        tvgName: 'SECH',
        provider: 'espn',
      },
      4: {
        checkChannelEnabled: () => checkChannelEnabled('espn', 'acc'),
        id: 'acc',
        logo: 'https://tmsimg.fancybits.co/assets/s111871_ll_h15_ac.png?w=360&h=270',
        name: 'ACC Network',
        stationId: '111871',
        tvgName: 'ACC',
        provider: 'espn',
      },
      5: {
        checkChannelEnabled: () => checkChannelEnabled('espn', 'espnews'),
        id: 'espnews',
        logo: 'https://tmsimg.fancybits.co/assets/s59976_ll_h15_aa.png?w=360&h=270',
        name: 'ESPNews',
        stationId: '59976',
        tvgName: 'ESPNWHD',
        provider: 'espn',
      },
      6: {
        checkChannelEnabled: () => checkChannelEnabled('espn', 'espndeportes'),
        id: 'espndeportes',
        logo: 'https://tmsimg.fancybits.co/assets/s71914_ll_h15_aa.png?w=360&h=270',
        name: 'ESPN Deportes',
        stationId: '71914',
        tvgName: 'ESPNDHD',
      },
      10: {
        checkChannelEnabled: () => checkChannelEnabled('foxsports', 'fs1'),
        id: 'fs1',
        logo: 'https://tmsimg.fancybits.co/assets/s82547_ll_h15_aa.png?w=360&h=270',
        name: 'FS1',
        stationId: '82547',
        tvgName: 'FS1HD',
        provider: 'foxsports',
      },
      11: {
        checkChannelEnabled: () => checkChannelEnabled('foxsports', 'fs2'),
        id: 'fs2',
        logo: 'https://tmsimg.fancybits.co/assets/s59305_ll_h15_aa.png?w=360&h=270',
        name: 'FS2',
        stationId: '59305',
        tvgName: 'FS2HD',
        provider: 'foxsports',
      },
      12: {
        checkChannelEnabled: () => checkChannelEnabled('foxsports', 'btn'),
        id: 'btn',
        logo: 'https://tmsimg.fancybits.co/assets/s58321_ll_h15_ac.png?w=360&h=270',
        name: 'B1G Network',
        stationId: '58321',
        tvgName: 'BIG10HD',
        provider: 'foxsports',
      },
      13: {
        checkChannelEnabled: () => checkChannelEnabled('foxsports', 'fox-soccer-plus'),
        id: 'fox-soccer-plus',
        logo: 'https://tmsimg.fancybits.co/assets/s66880_ll_h15_aa.png?w=360&h=270',
        name: 'FOX Soccer Plus',
        stationId: '66880',
        tvgName: 'FSCPLHD',
        provider: 'foxsports',
      },
      14: {
        checkChannelEnabled: () => checkChannelEnabled('foxsports', 'foxdep'),
        id: 'foxdep',
        logo: 'https://tmsimg.fancybits.co/assets/s15377_ll_h15_aa.png?w=360&h=270',
        name: 'FOX Deportes',
        stationId: '72189',
        tvgName: 'FXDEPHD',
        provider: 'foxsports',
      },
      20: {
        checkChannelEnabled: () => checkChannelEnabled('paramount', 'cbssportshq'),
        id: 'cbssportshq',
        logo: 'https://tmsimg.fancybits.co/assets/s108919_ll_h15_aa.png?w=360&h=270',
        name: 'CBS Sports HQ',
        stationId: '108919',
        tvgName: 'CBSSPHQ',
        provider: 'paramount',
      },
      21: {
        checkChannelEnabled: () => checkChannelEnabled('paramount', 'golazo'),
        id: 'golazo',
        logo: 'https://tmsimg.fancybits.co/assets/s133691_ll_h15_aa.png?w=360&h=270',
        name: 'GOLAZO Network',
        stationId: '133691',
        tvgName: 'GOLAZO',
        provider: 'paramount',
      },
      30: {
        checkChannelEnabled: () => checkChannelEnabled('nfl', 'NFLNETWORK'),
        id: 'NFLNETWORK',
        logo: 'https://tmsimg.fancybits.co/assets/s45399_ll_h15_aa.png?w=360&h=270',
        name: 'NFL Network',
        stationId: '45399',
        tvgName: 'NFLHD',
        provider: 'nfl',
      },
      31: {
        checkChannelEnabled: () => checkChannelEnabled('nfl', 'NFLNRZ'),
        id: 'NFLNRZ',
        logo: 'https://tmsimg.fancybits.co/assets/s65025_ll_h9_aa.png?w=360&h=270',
        name: 'NFL RedZone',
        stationId: '65025',
        tvgName: 'NFLNRZD',
        provider: 'nfl',
      },
      32: {
        checkChannelEnabled: () => checkChannelEnabled('nfl', 'NFLDIGITAL1_OO_v3'),
        id: 'NFLDIGITAL1_OO_v3',
        logo: 'https://tmsimg.fancybits.co/assets/s121705_ll_h15_aa.png?w=360&h=270',
        name: 'NFL Channel',
        stationId: '121705',
        tvgName: 'NFLDC1',
        provider: 'nfl',
      },
      40: {
        checkChannelEnabled: () => checkChannelEnabled('mlbtv', 'MLBTVBI'),
        id: 'MLBTVBI',
        logo: 'https://tmsimg.fancybits.co/assets/s119153_ll_h15_aa.png?w=360&h=270',
        name: 'MLB Big Inning',
        stationId: '119153',
        tvgName: 'MLBTVBI',
        provider: 'mlbtv',
      },
      41: {
        checkChannelEnabled: () => checkChannelEnabled('mlbtv', 'MLBN'),
        id: 'MLBN',
        logo: 'https://tmsimg.fancybits.co/assets/s62079_ll_h15_aa.png?w=360&h=270',
        name: 'MLB Network',
        stationId: '62079',
        tvgName: 'MLBN',
        provider: 'mlbtv',
      },
      42: {
        checkChannelEnabled: () => checkChannelEnabled('mlbtv', 'SNY'),
        id: 'SNY',
        logo: 'https://tmsimg.fancybits.co/assets/s49603_ll_h9_aa.png?w=360&h=270',
        name: 'SportsNet New York',
        stationId: '49603',
        tvgName: 'SNY',
        provider: 'mlbtv',
      },
      43: {
        checkChannelEnabled: () => checkChannelEnabled('mlbtv', 'SNLA'),
        id: 'SNLA',
        logo: 'https://tmsimg.fancybits.co/assets/s87024_ll_h15_aa.png?w=360&h=270',
        name: 'Spectrum SportsNet LA HD',
        stationId: '87024',
        tvgName: 'SNLA',
        provider: 'mlbtv',
      },
      ...gothamHandler.getLinearChannels(),
      70: {
        checkChannelEnabled: async (): Promise<boolean> =>
          (await db.providers.findOneAsync<IProvider>({name: 'wsn'}))?.enabled,
        id: 'WSN',
        logo: 'https://tmsimg.fancybits.co/assets/s124636_ll_h15_aa.png?w=360&h=270',
        name: "Women's Sports Network",
        stationId: '124636',
        tvgName: 'WSN',
        provider: 'wsn',
      },
      80: {
        checkChannelEnabled: () => checkChannelEnabled('nwsl', 'NWSL+'),
        id: 'NWSL+',
        logo: 'https://img.dge-prod.dicelaboratory.com/original/2024/11/22101220-tgwkrv9kdmvdqo2o.png',
        name: 'NWSL+ 24/7',
        provider: 'nwsl',
      },
      90: {
        checkChannelEnabled: () => checkChannelEnabled('bally', 'STADIUM'),
        id: 'STADIUM',
        logo: 'https://tmsimg.fancybits.co/assets/s104950_ll_h15_aa.png?w=360&h=270',
        name: 'Stadium HD',
        stationId: '104950',
        tvgName: 'STADIUM',
        provider: 'bally',
      },
      91: {
        checkChannelEnabled: () => checkChannelEnabled('bally', 'MiLB'),
        id: 'MiLB',
        logo: 'https://assets-stratosphere.cdn.ballys.tv/images/MiLB_New_Logo_23.png',
        name: 'MiLB',
        provider: 'bally',
      },
      92: {
        checkChannelEnabled: () => checkChannelEnabled('bally', 'bananaball'),
        id: 'bananaball',
        logo: 'https://assets-stratosphere.cdn.ballys.tv/images/BananaBall_SB_01.png',
        name: 'Banana Ball',
        provider: 'bally',
      },
      93: {
        checkChannelEnabled: () => checkChannelEnabled('bally', 'ballypoker'),
        id: 'ballypoker',
        logo: 'https://assets-stratosphere.ballys.tv/images/BallyPoker_Channel_V3.png',
        name: 'Bally Poker',
        provider: 'bally',
      },
      94: {
        checkChannelEnabled: () => checkChannelEnabled('bally', 'GLORY'),
        id: 'GLORY',
        logo: 'https://tmsimg.fancybits.co/assets/s131359_ll_h9_aa.png?w=360&h=270',
        name: 'GLORY Kickboxing',
        stationId: '131359',
        tvgName: 'GLORY',
        provider: 'bally',
      },
      100: {
        checkChannelEnabled: () => checkChannelEnabled('outside', 'OTVSTR'),
        id: 'OTVSTR',
        logo: 'https://tmsimg.fancybits.co/assets/s114313_ll_h15_ab.png?w=360&h=270',
        name: 'Outside',
        stationId: '114313',
        tvgName: 'OTVSTR',
        provider: 'outside',
      },
      110: {
        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FOX'),
        id: 'FOX',
        logo: 'https://tmsimg.fancybits.co/assets/s28719_ll_h15_ac.png?w=360&h=270',
        name: 'FOX',
        stationId: async() => (await getFoxOneChannelData()).foxStationId,
        tvgName: async () => `${(await getFoxOneChannelData()).foxCallSign}`,
        provider: 'foxone',
      },
      111: {
        checkChannelEnabled: () => checkChannelEnabled('foxone', 'MNTV'),
        id: 'MNTV',
        logo: 'https://tmsimg.fancybits.co/assets/GNLZZGG0028Y3ZQ.png?w=360&h=270',
        name: 'MyNetwork TV',
        stationId: async () => (await getFoxOneChannelData()).mnStationId, // Dynamic stationId
        tvgName: async () => `${(await getFoxOneChannelData()).mnCallSign}`, // Dynamic callSign
        provider: 'foxone',
      },
      112: {
        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FS1'),
        id: 'FS1',
        logo: 'https://tmsimg.fancybits.co/assets/s82547_ll_h15_aa.png?w=360&h=270',
        name: 'FS1',
        stationId: '82547',
        tvgName: 'FS1HD',
        provider: 'foxone',
      },
      113: {
        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FS2'),
        id: 'FS2',
        logo: 'https://tmsimg.fancybits.co/assets/s59305_ll_h15_aa.png?w=360&h=270',
        name: 'FS2',
        stationId: '59305',
        tvgName: 'FS2HD',
        provider: 'foxone',
      },
      114: {
        checkChannelEnabled: () => checkChannelEnabled('foxone', 'Big Ten Network'),
        id: 'Big Ten Network',
        logo: 'https://tmsimg.fancybits.co/assets/s58321_ll_h15_ac.png?w=360&h=270',
        name: 'B1G Network',
        stationId: '58321',
        tvgName: 'BIG10HD',
        provider: 'foxone',
      },
      115: {
        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FOX Deportes'),
        id: 'FOX Deportes',
        logo: 'https://tmsimg.fancybits.co/assets/s15377_ll_h15_aa.png?w=360&h=270',
        name: 'FOX Deportes',
        stationId: '72189',
        tvgName: 'FXDEPHD',
        provider: 'foxone',
      }, 
        116: {
        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FOX News'),
        id: 'FOX News',
        logo: 'https://tmsimg.fancybits.co/assets/s60179_ll_h15_ab.png?w=360&h=270',
        name: 'FOX News Channel',
        stationId: '60179',
        tvgName: 'FNCHD',
        provider: 'foxone',
      },
        117: {
        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FOX Business'),
        id: 'FOX Business',
        logo: 'https://tmsimg.fancybits.co/assets/s58718_ll_h15_ac.png?w=360&h=270',
        name: 'FOX Business Network',
        stationId: '58718',
        tvgName: 'FBNHD',
        provider: 'foxone',
      },
        118: {
        checkChannelEnabled: () => checkChannelEnabled('foxone', 'TMZ'),
        id: 'TMZ',
        logo: 'https://tmsimg.fancybits.co/assets/s149408_ll_h15_aa.png?w=360&h=270',
        name: 'TMZ',
        stationId: '149408',
        tvgName: 'TMZFAST',
        provider: 'foxone',
      },
      119: {
        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FOX Digital'),
        id: 'FOX Digital',
        logo: 'https://tmsimg.fancybits.co/assets/GNLZZGG0027SNRC.png?w=360&h=270',
        name: 'Masked Singer',
        provider: 'foxone',
      }, 
      120: {
        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FOX Soul'),
        id: 'FOX Soul',
        logo: 'https://tmsimg.fancybits.co/assets/s119212_ll_h15_aa.png?w=360&h=270',
        name: 'Fox Soul',
        stationId: '119212',
        tvgName: 'FOXSOUL',
        provider: 'foxone',
      },
      121: {
        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FOX Weather'),
        id: 'FOX Weather',
        logo: 'https://tmsimg.fancybits.co/assets/GNLZZGG0029CYRH.png?w=360&h=270',
        name: 'Fox Weather',
        stationId: '121307',
        tvgName: 'FWX',
        provider: 'foxone',
      },
      122: {
        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FOX LOCAL'),
        id: 'FOX LOCAL',
        logo: 'https://tmsimg.fancybits.co/assets/GNLZZGG0029CYRH.png?w=360&h=270',
        name: 'Fox Live Now',
        stationId: '119219',
        tvgName: 'LIVENOW',
        provider: 'foxone',
      },                     
    };
  },
};
/* eslint-enable sort-keys-custom-order-fix/sort-keys-custom-order-fix */

export const calculateChannelNumber = async (channelNum: string): Promise<number | string> => {
  const useLinear = await usesLinear();
  const linearStartChannel = await getLinearStartChannel();

  const chanNum = parseInt(channelNum, 10);

  if (!useLinear || chanNum < linearStartChannel) {
    return channelNum;
  }

  const linearChannel = CHANNELS.MAP[chanNum - linearStartChannel];

  if (linearChannel) {
    return linearChannel.id;
  }

  return channelNum;
};

export const calculateChannelFromName = async (channelName: string): Promise<number> => {
  const isNumber = Number.isFinite(parseInt(channelName, 10));

  if (isNumber) {
    return parseInt(channelName, 10);
  }

  const linearStartChannel = await getLinearStartChannel();

  let channelNum = Number.MAX_SAFE_INTEGER;

  _.forOwn(CHANNELS.MAP, (val, key) => {
    if (val.id === channelName) {
      channelNum = parseInt(key, 10) + linearStartChannel;
    }
  });

  return channelNum;
};

export const XMLTV_PADDING = process.env.XMLTV_PADDING?.toLowerCase() === 'false' ? false : true;
export interface Channel {
  stationId: () => Promise<string>;
  tvgName: () => Promise<string>;
}



================================================
FILE: services/config.ts
================================================
import path from 'path';

export const configPath = path.join(process.cwd(), 'config');


================================================
FILE: services/database.ts
================================================
import fs from 'fs';
import path from 'path';
import Datastore from '@seald-io/nedb';

import {configPath} from './config';

export const entriesDb = path.join(configPath, 'entries.db');
export const scheduleDb = path.join(configPath, 'schedule.db');
export const providersDb = path.join(configPath, 'providers.db');
export const miscDb = path.join(configPath, 'misc.db');

export interface IDocument {
  _id: string;
}

export const db = {
  entries: new Datastore({autoload: true, filename: entriesDb}),
  misc: new Datastore({autoload: true, filename: miscDb}),
  providers: new Datastore({autoload: true, filename: providersDb}),
  schedule: new Datastore({autoload: true, filename: scheduleDb}),
};

export const initializeEntries = (): void => fs.writeFileSync(entriesDb, '');
export const initializeSchedule = (): void => fs.writeFileSync(scheduleDb, '');
export const initializeProviders = (): void => fs.writeFileSync(providersDb, '');
export const initializeMisc = (): void => fs.writeFileSync(miscDb, '');


================================================
FILE: services/debug.ts
================================================
import path from 'path';
import fsExtra from 'fs-extra';

import {configPath} from './config';

export const debugPath = path.join(configPath, 'debug');

class Debug {
  enabled: boolean;

  constructor() {
    this.enabled = process.env.DEBUGGING?.toLowerCase() === 'true' ? true : false;
  }

  public saveRequestData = (data: any, provider: string, type: string): void => {
    if (!this.enabled) {
      return;
    }

    fsExtra.writeJSON(path.join(debugPath, `${provider}-${type}-${new Date().valueOf()}.json`), data, {
      spaces: 2,
    });
  };
}

export const debug = new Debug();


================================================
FILE: services/espn-handler.ts
================================================
import fs from 'fs';
import https from 'https';
import fsExtra from 'fs-extra';
import path from 'path';
import axios from 'axios';
import Sockette from 'sockette';
import ws from 'ws';
import jwt_decode from 'jwt-decode';
import _ from 'lodash';
import url from 'url';
import moment from 'moment';

import {userAgent} from './user-agent';
import {configPath} from './config';
import {
  useEspnPlus,
  requiresEspnProvider,
  useAccN,
  useAccNx,
  useEspn1,
  useEspn2,
  useEspn3,
  useEspnU,
  useSec,
  useSecPlus,
  useEspnPpv,
  useEspnews,
} from './networks';
import {IAdobeAuth, willAdobeTokenExpire, createAdobeAuthHeader} from './adobe-helpers';
import {getRandomHex, normalTimeRange} from './shared-helpers';
import {
  ClassTypeWithoutMethods,
  IEntry,
  IHeaders,
  IJWToken,
  IProvider,
  TChannelPlaybackInfo,
} from './shared-interfaces';
import {db} from './database';
import {debug} from './debug';
import {usesLinear, hideStudio} from './misc-db-service';
import {removeEntriesNetwork} from './build-schedule';

global.WebSocket = ws;

const espnPlusTokens = path.join(configPath, 'espn_plus_tokens.json');
const espnLinearTokens = path.join(configPath, 'espn_linear_tokens.json');

const httpsAgent = new https.Agent({
  rejectUnauthorized: false,
});

// For `watch.graph.api.espn.com` URLs
const instance = axios.create({
  httpsAgent,
});

interface IAuthResources {
  [key: string]: boolean;
}

interface IEndpoint {
  href: string;
  headers: {
    [key: string]: string;
  };
  method: 'POST' | 'GET';
}

interface IAppConfig {
  services: {
    account: {
      client: {
        endpoints: {
          createAccountGrant: IEndpoint;
        };
      };
    };
    token: {
      client: {
        endpoints: {
          exchange: IEndpoint;
        };
      };
    };
    device: {
      client: {
        endpoints: {
          createAccountGrant: IEndpoint;
          createDeviceGrant: IEndpoint;
        };
      };
    };
  };
}

interface IToken {
  access_token: string;
  refresh_token: string;
  expires_in: number;
}

interface IGrant {
  grant_type: string;
  assertion: string;
}

interface ITokens extends IToken {
  ttl: number;
  refresh_ttl: number;
  swid: string;
  id_token: string;
}

export interface IEspnPlusMeta {
  use_ppv?: boolean;
  zip_code?: string;
  in_market_teams?: string;
}

export interface IEspnMeta {
  sec_plus?: boolean;
  accnx?: boolean;
  espn3?: boolean;
  espn3isp?: boolean;
  espn_free?: boolean;
}

const ADOBE_KEY = ['g', 'B', '8', 'H', 'Y', 'd', 'E', 'P', 'y', 'e', 'z', 'e', 'Y', 'b', 'R', '1'].join('');

const ADOBE_PUBLIC_KEY = [
  'y',
  'K',
  'p',
  's',
  'H',
  'Y',
  'd',
  '8',
  'T',
  'O',
  'I',
  'T',
  'd',
  'T',
  'M',
  'J',
  'H',
  'm',
  'k',
  'J',
  'O',
  'V',
  'm',
  'g',
  'b',
  'b',
  '2',
  'D',
  'y',
  'k',
  'N',
  'K',
].join('');

const ANDROID_ID = 'ESPN-OTT.GC.ANDTV-PROD';

const DISNEY_ROOT_URL = 'https://registerdisney.go.com/jgc/v6/client';
const API_KEY_URL = '/{id-provider}/api-key?langPref=en-US';
const LICENSE_PLATE_URL = '/{id-provider}/license-plate';
const REFRESH_AUTH_URL = '/{id-provider}/guest/refresh-auth?langPref=en-US';

const BAM_API_KEY = 'ZXNwbiZicm93c2VyJjEuMC4w.ptUt7QxsteaRruuPmGZFaJByOoqKvDP2a5YkInHrc7c';
const BAM_APP_CONFIG =
  'https://bam-sdk-configs.bamgrid.com/bam-sdk/v2.0/espn-a9b93989/browser/v3.4/linux/chrome/prod.json';

const LINEAR_NETWORKS = ['espn1', 'espn2', 'espnu', 'sec', 'acc', 'espnews', 'espndeportes'];

const urlBuilder = (endpoint: string, provider: string) =>
  `${DISNEY_ROOT_URL}${endpoint}`.replace('{id-provider}', provider);

const isTokenValid = (token?: string): boolean => {
  if (!token) {
    return false;
  }

  try {
    const decoded: IJWToken = jwt_decode(token);
    return new Date().valueOf() / 1000 < decoded.exp;
  } catch (e) {
    return false;
  }
};

const willTokenExpire = (token?: string): boolean => {
  if (!token) {
    return true;
  }

  try {
    const decoded: IJWToken = jwt_decode(token);
    // Will the token expire in the next hour?
    return Math.floor(new Date().valueOf() / 1000) + 3600 > decoded.exp;
  } catch (e) {
    return true;
  }
};

const willTimestampExpire = (timestamp?: number): boolean => {
  if (!timestamp) {
    return true;
  }

  return moment(timestamp).isBefore(moment().add(2, 'hour'));
};

const getApiKey = async (provider: string) => {
  try {
    const {headers} = await axios.post(urlBuilder(API_KEY_URL, provider));
    return headers['api-key'];
  } catch (e) {
    console.error(e);
    console.log('Could not get API key');
  }
};

const fixHeaderKey = (headerVal: string, authToken = '') =>
  headerVal.replace('{apiKey}', BAM_API_KEY).replace('{accessToken}', authToken);

const makeApiCall = async (endpoint: IEndpoint, body: any, authToken = '') => {
  const headers = {};
  let reqBody: any = _.cloneDeep(body);

  Object.entries(endpoint.headers).forEach(([key, value]) => {
    headers[key] = fixHeaderKey(value, authToken);
  });

  if (
    headers['Content-Type'] === 'application/x-www-form-urlencoded' ||
    headers['content-type'] === 'application/x-www-form-urlencoded'
  ) {
    reqBody = new url.URLSearchParams(reqBody).toString();
  }

  if (endpoint.method === 'POST') {
    const {data} = await axios.post(endpoint.href, reqBody, {headers});
    return data;
  } else {
    const {data} = await axios.get(endpoint.href, {headers});
    return data;
  }
};

const getNetworkInfo = (network?: string) => {
  let networks = 'null';
  let packages = '["espn_plus"]';

  if (network === 'espn1') {
    networks = '["e748f3c0-3f7c-3088-a90a-0ccb2588e0ed"]';
    packages = 'null';
  } else if (network === 'espn2') {
    networks = '["017f41a2-ef4f-39d3-9f45-f680b88cd23b"]';
    packages = 'null';
  } else if (network === 'espn3') {
    networks = '["3e99c57a-516c-385d-9c22-2e40aebc7129"]';
    packages = 'null';
  } else if (network === 'espnU') {
    networks = '["500b1f7c-dad5-33f9-907c-87427babe201"]';
    packages = 'null';
  } else if (network === 'secn') {
    networks = '["74459ca3-cf85-381d-b90d-a95ff6e7a207"]';
    packages = 'null';
  } else if (network === 'secnPlus') {
    networks = '["19644d95-cc83-38ed-bdf9-50b9f2e9ebfc"]';
    packages = 'null';
  } else if (network === 'accn') {
    networks = '["76b92674-175c-4ff1-8989-380aa514eb87"]';
    packages = 'null';
  } else if (network === 'accnx') {
    networks = '["9f538e0b-a896-3325-a417-79034e03a248"]';
    packages = 'null';
  } else if (network === 'espnews') {
    networks = '["1e760a1c-c204-339d-8317-8e615c9cc0e0"]';
    packages = 'null';
  } else if (network === 'espndeportes') {
    networks = '["bba8fb76-57ff-3c63-998b-90fef8f4f8b6"]';
    packages = 'null';
  } else if (network === 'espn_free') {
    networks = '["8cc0ae94-324d-3123-859f-7b6a229b1b89"]';
    packages = 'null';
  } else if (network === 'espn_ppv') {
    networks = '["d41c5aaf-e100-4726-841f-1e453af347f9"]';
    packages = 'null';
  }

  return [networks, packages];
};

class WebSocketPlus {
  public wsToken?: ITokens;
  private wsClient?: Sockette;

  public closeWebSocket = (): void => {
    if (this.wsClient) {
      try {
        this.wsClient.close();
        this.wsClient = undefined;
      } catch (e) {}
    }

    this.wsToken = undefined;
  };

  public initializeWebSocket = (wsUrl: string, licensePlate: any): void => {
    this.closeWebSocket();

    this.wsClient = new Sockette(wsUrl, {
      maxAttempts: 10,
      onerror: e => {
        console.error(e);
        console.log('Could not start authentication for ESPN+');

        this.closeWebSocket();
      },
      onmessage: e => {
        const wsData = JSON.parse(e.data);

        if (wsData.op) {
          if (wsData.op === 'C') {
            this.wsClient.json({
              op: 'S',
              rc: 200,
              sid: wsData.sid,
              tc: licensePlate.data.fastCastTopic,
            });
          } else if (wsData.op === 'P') {
            this.wsToken = JSON.parse(wsData.pl);
          }
        }
      },
      onopen: () => {
        this.wsClient.json({
          op: 'C',
        });
      },
      timeout: 5e3,
    });
  };
}

const wsPlus = new WebSocketPlus();

const authorizedResources: IAuthResources = {};

const parseCategories = event => {
  const categories = ['ESPN'];
  for (const classifier of [event.category, event.subcategory, event.sport, event.league]) {
    if (classifier !== null && classifier.name !== null) {
      categories.push(classifier.name);
    }
  }

  return [...new Set(categories)];
};

const parseAirings = async events => {
  const useLinear = await usesLinear();
  const hide_studio = await hideStudio();

  const [now, endSchedule] = normalTimeRange();

  const {meta: plusMeta} = await db.providers.findOneAsync<IProvider<TESPNPlusTokens, IEspnPlusMeta>>({
    name: 'espnplus',
  });

  const in_market_team_filter =
    plusMeta?.in_market_teams && plusMeta?.in_market_teams.length > 0 ? plusMeta?.in_market_teams.split(',') : [];
    
  const in_market_feed_filter =
    plusMeta?.in_market_teams && plusMeta?.in_market_teams.length > 0 ? plusMeta?.in_market_teams.split(',').map(item => {
    const words = item.trim().split(' ');
    return words.length > 0 ? words[words.length - 1] : ''; 
  }) : [];

  for (const event of events) {
    const entryExists = await db.entries.findOneAsync<IEntry>({id: event.id});

    if (!entryExists) {
      const isLinear = useLinear && event.network?.id && LINEAR_NETWORKS.some(n => n === event.network?.id);

      if (!isLinear && hide_studio && event.program?.isStudio) {
        continue;
      }

      const start = moment(event.startDateTime);
      const end = moment(event.startDateTime).add(event.duration, 'seconds');
      const originalEnd = moment(end);

      if (!isLinear) {
        end.add(1, 'hour');
      }

      if (end.isBefore(now) || start.isAfter(endSchedule)) {
        continue;
      }

      if (event.network?.id === 'bam_dtc' && in_market_team_filter.some(tn => event.name.indexOf(tn) > -1)) {
        const feeds = events.filter((obj) => obj.name === event.name && obj.start === event.start);
        if (feeds.length > 1 || in_market_feed_filter.some(tn => event.feedName.indexOf(tn) > -1)) {
          continue;
        }
      }

      console.log('Adding event: ', event.name);

      await db.entries.insertAsync<IEntry>({
        categories: parseCategories(event),
        duration: end.diff(start, 'seconds'),
        end: end.valueOf(),
        feed: event.feedName,
        from: 'espn',
        id: event.id,
        image: event.image?.url,
        name: event.name,
        network: event.network?.name || 'ESPN+',
        sport: event.subcategory?.name,
        start: start.valueOf(),
        url: event.source?.url,
        ...(isLinear && {
          channel: event.network?.id,
          linear: true,
        }),
        originalEnd: originalEnd.valueOf(),
      });
    }
  }
};

const isEnabled = async (which?: string): Promise<boolean> => {
  const {enabled: espnPlusEnabled, meta: plusMeta} = await db.providers.findOneAsync<
    IProvider<TESPNPlusTokens, IEspnPlusMeta>
  >({name: 'espnplus'});
  const {
    enabled: espnLinearEnabled,
    linear_channels,
    meta: linearMeta,
  } = await db.providers.findOneAsync<IProvider<TESPNTokens, IEspnMeta>>({name: 'espn'});

  if (which === 'linear') {
    return espnLinearEnabled && _.some(linear_channels, c => c.enabled);
  } else if (which === 'plus') {
    return espnPlusEnabled;
  } else if (which === 'ppv') {
    return (plusMeta?.use_ppv ? true : false) && espnPlusEnabled;
  } else if (which === 'espn3') {
    return (linearMeta?.espn3 ? true : false) && espnLinearEnabled;
  } else if (which === 'espn3isp') {
    return (linearMeta?.espn3isp ? true : false) && espnLinearEnabled;
  } else if (which === 'sec_plus') {
    return (linearMeta?.sec_plus ? true : false) && espnLinearEnabled;
  } else if (which === 'accnx') {
    return (linearMeta?.accnx ? true : false) && espnLinearEnabled;
  } else if (which === 'espn_free') {
    return (linearMeta?.espn_free ? true : false) && espnLinearEnabled;
  }

  return espnPlusEnabled || (espnLinearEnabled && _.some(linear_channels, c => c.enabled));
};

class EspnHandler {
  public tokens?: ITokens;
  public account_token?: IToken;
  public device_token_exchange?: IToken;
  public device_refresh_token?: IToken;
  public device_grant?: IGrant;
  public id_token_grant?: IGrant;
  public device_token_exchange_expires?: number;
  public device_refresh_token_expires?: number;
  public account_token_expires?: number;

  public adobe_device_id?: string;
  public adobe_auth?: IAdobeAuth;

  private appConfig: IAppConfig;
  private graphQlApiKey: string;

  public initialize = async () => {
    const setupPlus = (await db.providers.countAsync({name: 'espnplus'})) > 0 ? true : false;

    if (!setupPlus) {
      const data: TESPNPlusTokens = {};

      if (useEspnPlus) {
        this.loadJSON();

        data.tokens = this.tokens;
        data.device_grant = this.device_grant;
        data.device_token_exchange = this.device_token_exchange;
        data.device_refresh_token = this.device_refresh_token;
        data.id_token_grant = this.id_token_grant;
        data.account_token = this.account_token;
      }

      await db.providers.insertAsync<IProvider<TESPNPlusTokens, IEspnPlusMeta>>({
        enabled: useEspnPlus,
        meta: {
          in_market_teams: '',
          use_ppv: useEspnPpv,
          zip_code: '',
        },
        name: 'espnplus',
        tokens: data,
      });

      if (fs.existsSync(espnPlusTokens)) {
        fs.rmSync(espnPlusTokens);
      }
    }

    const setupLinear = (await db.providers.countAsync({name: 'espn'})) > 0 ? true : false;

    if (!setupLinear) {
      const data: TESPNTokens = {};

      if (requiresEspnProvider) {
        this.loadJSON();

        data.adobe_device_id = this.adobe_device_id;
        data.adobe_auth = this.adobe_auth;
      }

      await db.providers.insertAsync<IProvider<TESPNTokens, IEspnMeta>>({
        enabled: requiresEspnProvider,
        linear_channels: [
          {
            enabled: useEspn1,
            id: 'espn1',
            name: 'ESPN',
            tmsId: '32645',
          },
          {
            enabled: useEspn2,
            id: 'espn2',
            name: 'ESPN2',
            tmsId: '45507',
          },
          {
            enabled: useEspnU,
            id: 'espnu',
            name: 'ESPNU',
            tmsId: '60696',
          },
          {
            enabled: useSec,
            id: 'sec',
            name: 'SEC Network',
            tmsId: '89714',
          },
          {
            enabled: useAccN,
            id: 'acc',
            name: 'ACC Network',
            tmsId: '111871',
          },
          {
            enabled: useEspnews,
            id: 'espnews',
            name: 'ESPNews',
            tmsId: '59976',
          },
        ],
        meta: {
          accnx: useAccNx,
          espn3: useEspn3,
          espn3isp: false,
          sec_plus: useSecPlus,
        },
        name: 'espn',
        tokens: data,
      });

      if (fs.existsSync(espnLinearTokens)) {
        fs.rmSync(espnLinearTokens);
      }
    }

    if (useEspnPpv) {
      console.log('Using ESPN_PPV variable is no longer needed. Please use the UI going forward');
    }
    if (useEspn1) {
      console.log('Using ESPN variable is no longer needed. Please use the UI going forward');
    }
    if (useEspn2) {
      console.log('Using ESPN2 variable is no longer needed. Please use the UI going forward');
    }
    if (useEspn3) {
      console.log('Using ESPN3 variable is no longer needed. Please use the UI going forward');
    }
    if (useEspnU) {
      console.log('Using ESPNU variable is no longer needed. Please use the UI going forward');
    }
    if (useSec) {
      console.log('Using SEC variable is no longer needed. Please use the UI going forward');
    }
    if (useSecPlus) {
      console.log('Using SECPLUS variable is no longer needed. Please use the UI going forward');
    }
    if (useAccN) {
      console.log('Using ACCN variable is no longer needed. Please use the UI going forward');
    }
    if (useAccNx) {
      console.log('Using ACCNX variable is no longer needed. Please use the UI going forward');
    }
    if (useEspnews) {
      console.log('Using ESPNEWS variable is no longer needed. Please use the UI going forward');
    }

    const {linear_channels, meta} = await db.providers.findOneAsync<IProvider>({name: 'espn'});
	
    // update/add Deportes, if necessary
    if ( linear_channels.length <= 6 ) {
      linear_channels.push({
    	  enabled: false,
        id: 'espndeportes',
        name: 'ESPN Deportes',
        tmsId: '71914',
      });
      await db.providers.updateAsync<IProvider<TESPNTokens>, any>(
        {name: 'espn'},
        {
          $set: {
            linear_channels: linear_channels,
          },
        },
      );
    }
	
    // remove ESPN on ABC, if necessary
    if ( linear_channels.length == 8 ) {
      const removed_channel = linear_channels.pop();
      if (removed_channel && removed_channel.name && (removed_channel.name == 'ESPN on ABC')) {
        console.log('Removed ' + removed_channel.name);
        await db.providers.updateAsync<IProvider<TESPNTokens>, any>(
          {name: 'espn'},
          {
            $set: {
              linear_channels: linear_channels,
            },
          },
        );
      }
    }
    if ( !('espn_free' in meta) ) {
      await db.providers.updateAsync({name: 'espn'}, {$set: {meta: {...meta, espn_free: false}}});
    }
    
    /*if (await isEnabled('espn3isp') && await isEnabled('espn3')) {
      console.log('Currently expecting ESPN3 access via ISP, re-authenticate if that is no longer true');
    }*/

    const enabled = await isEnabled();

    if (!enabled) {
      return;
    }
    
    await removeEntriesNetwork('ESPN+');
    
    await removeEntriesNetwork('ESPN3');
    await removeEntriesNetwork('SEC Network +');
    await removeEntriesNetwork('ACCNX');
    await removeEntriesNetwork('@ESPN');

    /*const {meta: plusMeta} = await db.providers.findOneAsync<IProvider<TESPNPlusTokens, IEspnPlusMeta>>({
      name: 'espnplus',
    });

    if (!plusMeta?.zip_code || !plusMeta?.in_market_teams) {
      await this.refreshInMarketTeams();
    }*/

    // Load tokens from local file and make sure they are valid
    await this.load();

    if (!this.appConfig) {
      await this.getAppConfig();
    }
  };

  public refreshTokens = async () => {
    const espnPlusEnabled = await isEnabled('plus');

    if (espnPlusEnabled) {
      await this.updatePlusTokens();
    }

    const espnLinearEnabled = await isEnabled('linear');

    if (espnLinearEnabled && willAdobeTokenExpire(this.adobe_auth)) {
      console.log('Refreshing TV Provider token (ESPN)');
      await this.refreshProviderToken();
    }
  };

  public getSchedule = async (): Promise<void> => {
    const espnPlusEnabled = await isEnabled('plus');
    const espnPpvEnabled = await isEnabled('ppv');
    const espnLinearEnabled = await isEnabled('linear');
    const secPlusEnabled = await isEnabled('sec_plus');
    const espn3Enabled = await isEnabled('espn3');
    const accnxEnabled = await isEnabled('accnx');
    const espnFreeEnabled = await isEnabled('espn_free');

    const {linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'espn'});

    const isChannelEnabled = (channelId: string): boolean =>
      espnLinearEnabled && linear_channels.some(c => c.id === channelId && c.enabled);

    let entries = [];

    try {
      /*if (espnPlusEnabled) {
        console.log('Looking for ESPN+ events...');

        const liveEntries = await this.getLiveEvents();
        entries = [...entries, ...liveEntries];
      }*/

      if (espnLinearEnabled) {
        console.log('Looking for ESPN events');
      }

      if (isChannelEnabled('espn1')) {
        const liveEntries = await this.getLiveEvents('espn1');
        entries = [...entries, ...liveEntries];
      }
      if (isChannelEnabled('espn2')) {
        const liveEntries = await this.getLiveEvents('espn2');
        entries = [...entries, ...liveEntries];
      }
      /*if (espn3Enabled) {
        const liveEntries = await this.getLiveEvents('espn3');
        entries = [...entries, ...liveEntries];
      }*/
      if (isChannelEnabled('espnu')) {
        const liveEntries = await this.getLiveEvents('espnU');
        entries = [...entries, ...liveEntries];
      }
      if (isChannelEnabled('sec')) {
        const liveEntries = await this.getLiveEvents('secn');
        entries = [...entries, ...liveEntries];
      }
      /*if (secPlusEnabled) {
        const liveEntries = await this.getLiveEvents('secnPlus');
        entries = [...entries, ...liveEntries];
      }*/
      if (isChannelEnabled('acc')) {
        const liveEntries = await this.getLiveEvents('accn');
        entries = [...entries, ...liveEntries];
      }
      /*if (accnxEnabled) {
        const liveEntries = await this.getLiveEvents('accnx');
        entries = [...entries, ...liveEntries];
      }*/
      if (isChannelEnabled('espnews')) {
        const liveEntries = await this.getLiveEvents('espnews');
        entries = [...entries, ...liveEntries];
      }
      if (isChannelEnabled('espndeportes')) {
        const liveEntries = await this.getLiveEvents('espndeportes');
        entries = [...entries, ...liveEntries];
      }
      /*if (isChannelEnabled('espnonabc')) {
        const liveEntries = await this.getLiveEvents('espnonabc');
        entries = [...entries, ...liveEntries];
      }*/
      /*if (espnFreeEnabled) {
        const liveEntries = await this.getLiveEvents('espn_free');
        entries = [...entries, ...liveEntries];
      }*/
      if (espnPpvEnabled) {
        const liveEntries = await this.getLiveEvents('espn_ppv');
        entries = [...entries, ...liveEntries];
      }
    } catch (e) {
      console.log('Could not parse ESPN events');
    }

    const today = new Date();

    for (const [i] of [0, 1, 2].entries()) {
      const date = moment(today).add(i, 'days');

      try {
        /*if (espnPlusEnabled) {
          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'));
          entries = [...entries, ...upcomingEntries];
        }*/
        if (isChannelEnabled('espn1')) {
          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espn1');
          entries = [...entries, ...upcomingEntries];
        }
        if (isChannelEnabled('espn2')) {
          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espn2');
          entries = [...entries, ...upcomingEntries];
        }
        /*if (espn3Enabled) {
          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espn3');
          entries = [...entries, ...upcomingEntries];
        }*/
        if (isChannelEnabled('espnu')) {
          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espnU');
          entries = [...entries, ...upcomingEntries];
        }
        if (isChannelEnabled('sec')) {
          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'secn');
          entries = [...entries, ...upcomingEntries];
        }
        /*if (secPlusEnabled) {
          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'secnPlus');
          entries = [...entries, ...upcomingEntries];
        }*/
        if (isChannelEnabled('acc')) {
          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'accn');
          entries = [...entries, ...upcomingEntries];
        }
        /*if (accnxEnabled) {
          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'accnx');
          entries = [...entries, ...upcomingEntries];
        }*/
        if (isChannelEnabled('espnews')) {
          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espnews');
          entries = [...entries, ...upcomingEntries];
        }
        if (isChannelEnabled('espndeportes')) {
          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espndeportes');
          entries = [...entries, ...upcomingEntries];
        }
        /*if (isChannelEnabled('espnonabc')) {
          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espnonabc');
          entries = [...entries, ...upcomingEntries];
        }*/
        /*if (espnFreeEnabled) {
          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espn_free');
          entries = [...entries, ...upcomingEntries];
        }*/
        if (espnPpvEnabled) {
          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espn_ppv');
          entries = [...entries, ...upcomingEntries];
        }
      } catch (e) {
        console.log('Could not parse ESPN events');
      }
    }

    try {
      await parseAirings(entries);
    } catch (e) {
      console.log('Could not parse events');
      console.log(e.message);
    }
  };

  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {
    const espnPlusEnabled = await isEnabled('plus');
    espnPlusEnabled && (await this.getBamAccessToken());
    espnPlusEnabled && (await this.getGraphQlApiKey());

    try {
      const {data: scenarios} = await instance.get('https://watch.graph.api.espn.com/api', {
        params: {
          apiKey: this.graphQlApiKey,
          query: `{airing(id:"${eventId}",countryCode:"us",deviceType:SETTOP,tz:"Z") {id name description mrss:adobeRSS authTypes requiresLinearPlayback status:type startDateTime endDateTime duration source(authorization: SHIELD) { url authorizationType hasEspnId3Heartbeats hasNielsenWatermarks hasPassThroughAds commercialReplacement startSessionUrl } network { id type name adobeResource } image { url } sport { name code uid } league { name uid } program { code categoryCode isStudio } seekInSeconds simulcastAiringId airingId tracking { nielsenCrossId1 trackingId } eventId packages { name } language tier feedName brands { id name type }}}`,
        },
      });

      if (!scenarios?.data?.airing?.source?.url.length || scenarios?.data?.airing?.status !== 'LIVE') {
        // console.log('Event status: ', scenarios?.data?.airing?.status);
        throw new Error('No streaming data available');
      }

      const scenarioUrl = scenarios.data.airing.source.url.replace('{scenario}', 'browser~ssai');

      let isEspnPlus = true;
      let headers: IHeaders = {};
      let uri: string;

      if (scenarios?.data?.airing?.source?.authorizationType === 'SHIELD') {
        // console.log('Scenario: ', scenarios?.data?.airing);
        isEspnPlus = false;
      }
      console.log('scenarioUrl ' + scenarioUrl);

      if (isEspnPlus) {
        const {data} = await axios.get(scenarioUrl, {
          headers: {
            Accept: 'application/vnd.media-service+json; version=2',
            Authorization: this.account_token.access_token,
            Origin: 'https://plus.espn.com',
            'User-Agent': userAgent,
          },
        });

        uri = data.stream.slide ? data.stream.slide : data.stream.complete;
        headers = {
          Authorization: this.account_token.access_token,
        };
      } else {
        let tokenType = 'DEVICE';
        let token = this.adobe_device_id;

        let isFree = false;
        if (
          (scenarios?.data?.airing?.network?.id === 'espn3' && (await isEnabled('espn3isp') && _.some(scenarios?.data?.airing?.authTypes, (authType: string) => authType.toLowerCase() === 'isp')))
          ||
          (scenarios?.data?.airing?.network?.id === 'espn_free' && (await isEnabled('espn_free') && _.some(scenarios?.data?.airing?.authTypes, (authType: string) => authType.toLowerCase() === 'open')))
        ) {
          isFree = true;
        }

        if (
          !isFree &&
          _.some(scenarios?.data?.airing?.authTypes, (authType: string) => authType.toLowerCase() === 'mvpd')
        ) {
          // Try to get the media token, but if it fails, let's just try device authentication
          try {
            await this.authorizeEvent(eventId, scenarios?.data?.airing?.mrss);

            const mediaTokenUrl = [
              'https://',
              'api.auth.adobe.com',
              '/api/v1',
              '/mediatoken',
              '?requestor=ESPN',
              `&deviceId=${this.adobe_device_id}`,
              `&resource=${encodeURIComponent(scenarios?.data?.airing?.mrss)}`,
            ].join('');

            const {data} = await axios.get(mediaTokenUrl, {
              headers: {
                Authorization: createAdobeAuthHeader('GET', mediaTokenUrl, ADOBE_KEY, ADOBE_PUBLIC_KEY),
                'User-Agent': userAgent,
              },
            });

            tokenType = 'ADOBEPASS';
            token = data.serializedToken;
          } catch (e) {
            console.error(e);
            console.log('could not get mediatoken');
          }
        }

        // Get stream data
        const authenticatedUrl = [
          `https://broadband.espn.com/espn3/auth/watchespn/startSession?channel=${scenarios?.data?.airing?.network?.id}&simulcastAiringId=${scenarios?.data?.airing?.simulcastAiringId}`,
          '&partner=watchespn',
          '&playbackScenario=HTTP_CLOUD_HIGH',
          '&platform=chromecast_uplynk',
          '&v=2.0.0',
          `&token=${token}`,
          `&tokenType=${tokenType}`,
          `&resource=${Buffer.from(scenarios?.data?.airing?.mrss, 'utf-8').toString('base64')}`,
        ].join('');

        const {data: authedData} = await axios.get(authenticatedUrl, {
          headers: {
            'User-Agent': userAgent,
          },
        });

        uri = authedData?.session?.playbackUrls?.default;
        headers = {
          Connection: 'keep-alive',
          Cookie: `_mediaAuth: ${authedData?.session?.token}`,
          'User-Agent': userAgent,
        };
      }

      return [uri, headers];
    } catch (e) {
      console.error(e);
      console.log('Could not get stream data. Event might be upcoming, ended, or in blackout...');
    }
  };

  public refreshAuth = async (): Promise<void> => {
    try {
      const {data: refreshTokenData} = await axios.post(urlBuilder(REFRESH_AUTH_URL, ANDROID_ID), {
        refreshToken: this.tokens.refresh_token,
      });

      this.tokens = refreshTokenData.data.token;
      await this.save();
    } catch (e) {
      console.error(e);
      console.log('Could not get auth refresh token (ESPN+)');
    }
  };

  private updatePlusTokens = _.throttle(
    async () => {
      if (!isTokenValid(this.tokens?.id_token) || willTokenExpire(this.tokens?.id_token)) {
        console.log('Refreshing auth token (ESPN+)');
        await this.refreshAuth();
      }

      if (!this.device_token_exchange || willTimestampExpire(this.device_token_exchange_expires)) {
        console.log('Refreshing device token (ESPN+)');
        await this.getDeviceTokenExchange();
      }

      if (!this.device_refresh_token || willTimestampExpire(this.device_refresh_token_expires)) {
        console.log('Refreshing device refresh token (ESPN+)');
        await this.getDeviceRefreshToken();
      }

      if (!this.account_token || willTimestampExpire(this.account_token_expires)) {
        console.log('Refreshing BAM access token (ESPN+)');
        await this.getBamAccessToken();
      }
    },
    60 * 1000,
    {leading: true, trailing: false},
  );

  private getLiveEvents = async (network?: string) => {
    await this.getGraphQlApiKey();

    const [networks, packages] = getNetworkInfo(network);

    const query =
      'query Airings ( $countryCode: String!, $deviceType: DeviceType!, $tz: String!, $type: AiringType, $categories: [String], $networks: [String], $packages: [String], $eventId: String, $packageId: String, $start: String, $end: String, $day: String, $limit: Int ) { airings( countryCode: $countryCode, deviceType: $deviceType, tz: $tz, type: $type, categories: $categories, networks: $networks, packages: $packages, eventId: $eventId, packageId: $packageId, start: $start, end: $end, day: $day, limit: $limit ) { id airingId simulcastAiringId name type startDateTime shortDate: startDate(style: SHORT) authTypes adobeRSS duration feedName purchaseImage { url } image { url } network { id type abbreviation name shortName adobeResource isIpAuth } source { url authorizationType hasPassThroughAds hasNielsenWatermarks hasEspnId3Heartbeats commercialReplacement } packages { name } category { id name } subcategory { id name } sport { id name abbreviation code } league { id name abbreviation code } franchise { id name } program { id code categoryCode isStudio } tracking { nielsenCrossId1 nielsenCrossId2 comscoreC6 trackingId } } }';
    const variables = `{"deviceType":"DESKTOP","countryCode":"US","tz":"UTC+0000","type":"LIVE","networks":${networks},"packages":${packages},"limit":500}`;

    const {data: entryData} = await instance.get(
      encodeURI(
        `https://watch.graph.api.espn.com/api?apiKey=${this.graphQlApiKey}&query=${query}&variables=${variables}`,
      ),
    );

    debug.saveRequestData(entryData, network || 'espn+', 'live-epg');

    return entryData.data.airings;
  };

  private getUpcomingEvents = async (date: string, network?: string) => {
    await this.getGraphQlApiKey();

    const [networks, packages] = getNetworkInfo(network);

    const query =
      'query Airings ( $countryCode: String!, $deviceType: DeviceType!, $tz: String!, $type: AiringType, $categories: [String], $networks: [String], $packages: [String], $eventId: String, $packageId: String, $start: String, $end: String, $day: String, $limit: Int ) { airings( countryCode: $countryCode, deviceType: $deviceType, tz: $tz, type: $type, categories: $categories, networks: $networks, packages: $packages, eventId: $eventId, packageId: $packageId, start: $start, end: $end, day: $day, limit: $limit ) { id airingId simulcastAiringId name type startDateTime shortDate: startDate(style: SHORT) authTypes adobeRSS duration feedName purchaseImage { url } image { url } network { id type abbreviation name shortName adobeResource isIpAuth } source { url authorizationType hasPassThroughAds hasNielsenWatermarks hasEspnId3Heartbeats commercialReplacement } packages { name } category { id name } subcategory { id name } sport { id name abbreviation code } league { id name abbreviation code } franchise { id name } program { id code categoryCode isStudio } tracking { nielsenCrossId1 nielsenCrossId2 comscoreC6 trackingId } } }';
    const variables = `{"deviceType":"DESKTOP","countryCode":"US","tz":"UTC+0000","type":"UPCOMING","networks":${networks},"packages":${packages},"day":"${date}","limit":500}`;

    const {data: entryData} = await instance.get(
      encodeURI(
        `https://watch.graph.api.espn.com/api?apiKey=${this.graphQlApiKey}&query=${query}&variables=${variables}`,
      ),
    );

    debug.saveRequestData(entryData, network || 'espn+', 'upcoming-epg');

    return entryData.data.airings;
  };

  private authorizeEvent = async (eventId: string, mrss: string): Promise<void> => {
    if (mrss && authorizedResources[eventId]) {
      return;
    }

    const authorizeEventTokenUrl = [
      'https://',
      'api.auth.adobe.com',
      '/api/v1',
      '/authorize',
      '?requestor=ESPN',
      `&deviceId=${this.adobe_device_id}`,
      `&resource=${encodeURIComponent(mrss)}`,
    ].join('');

    try {
      await axios.get(authorizeEventTokenUrl, {
        headers: {
          Authorization: createAdobeAuthHeader('GET', authorizeEventTokenUrl, ADOBE_KEY, ADOBE_PUBLIC_KEY),
          'User-Agent': userAgent,
        },
      });

      authorizedResources[eventId] = true;
    } catch (e) {
      console.error(e);
      console.log('Could not authorize event. Might be blacked out or not available from your TV provider');
    }
  };

  public getLinearAuthCode = async (): Promise<string> => {
    if (!this.appConfig) {
      await this.getAppConfig();
    }

    this.adobe_device_id = getRandomHex();

    const regUrl = ['https://', 'api.auth.adobe.com', '/reggie/', 'v1/', 'ESPN', '/regcode'].join('');

    try {
      const {data} = await axios.post(
        regUrl,
        new url.URLSearchParams({
          deviceId: this.adobe_device_id,
          deviceType: 'android_tv',
          ttl: '1800',
        }).toString(),
        {
          headers: {
            Authorization: createAdobeAuthHeader('POST', regUrl, ADOBE_KEY, ADOBE_PUBLIC_KEY),
            'User-Agent': userAgent,
          },
        },
      );

      return data.code;
    } catch (e) {
      console.error(e);
      console.log('Could not start the authentication process!');
    }
  };

  public authenticateLinearRegCode = async (regcode: string): Promise<boolean> => {
    const regUrl = ['https://', 'api.auth.adobe.com', '/api/v1/', 'authenticate/', regcode, '?requestor=ESPN'].join('');

    try {
      const {data} = await axios.get<IAdobeAuth>(regUrl, {
        headers: {
          Authorization: createAdobeAuthHeader('GET', regUrl, ADOBE_KEY, ADOBE_PUBLIC_KEY),
          'User-Agent': userAgent,
        },
      });

      this.adobe_auth = data;
      await this.save();

      return true;
    } catch (e) {
      if (e.response?.status !== 404) {
        console.error(e);
        console.log('Could not get provider token data!');
      }

      return false;
    }
  };

  private refreshProviderToken = async (): Promise<void> => {
    const renewUrl = [
      'https://',
      'api.auth.adobe.com',
      '/api/v1/',
      'tokens/authn',
      '?requestor=ESPN',
      `&deviceId=${this.adobe_device_id}`,
    ].join('');

    try {
      const {data} = await axios.get<IAdobeAuth>(renewUrl, {
        headers: {
          Authorization: createAdobeAuthHeader('GET', renewUrl, ADOBE_KEY, ADOBE_PUBLIC_KEY),
          'User-Agent': userAgent,
        },
      });

      this.adobe_auth = data;
      await this.save();
    } catch (e) {
      console.error(e);
      console.log('Could not refresh provider token data!');
    }
  };

  public getPlusAuthCode = async (): Promise<string> => {
    if (!this.appConfig) {
      await this.getAppConfig();
    }

    const apiKey = await getApiKey(ANDROID_ID);

    try {
      const {data: licensePlate} = await axios.post(
        urlBuilder(LICENSE_PLATE_URL, ANDROID_ID),
        {
          adId: getRandomHex(),
          'correlation-id': getRandomHex(),
          deviceId: getRandomHex(),
          deviceType: 'ANDTV',
          entitlementPath: 'login',
          entitlements: [],
        },
        {
          headers: {
            Authorization: `APIKEY ${apiKey}`,
            'Content-Type': 'application/json',
          },
        },
      );

      const {data: wsInfo} = await axios.get(`${licensePlate.data.fastCastHost}/public/websockethost`);

      wsPlus.initializeWebSocket(
        `wss://${wsInfo.ip}:${wsInfo.securePort}/FastcastService/pubsub/profiles/${licensePlate.data.fastCastProfileId}?TrafficManager-Token=${wsInfo.token}`,
        licensePlate,
      );

      setTimeout(() => wsPlus.closeWebSocket(), 5 * 60 * 1000);

      return licensePlate.data.pairingCode;
    } catch (e) {
      console.error(e);
      console.log('Could not start the authentication process!');
    }
  };

  public authenticatePlusRegCode = async (): Promise<boolean> => {
    if (wsPlus.wsToken) {
      this.tokens = wsPlus.wsToken;

      await this.save();

      wsPlus.closeWebSocket();
      return true;
    }

    return false;
  };

  public refreshInMarketTeams = async () => {
    try {
      const deviceUrl = ['https://', 'espn.api.edge.bamgrid.com', '/graph/v1/', 'device/graphql'].join('');

      const {data: deviceData} = await axios.post(
        deviceUrl,
        {
          operationName: 'registerDevice',
          query:
            '\n    mutation registerDevice($input: RegisterDeviceInput!) {\n        registerDevice(registerDevice: $input) {\n            grant {\n                grantType\n                assertion\n            }\n        }\n    }\n',
          variables: {
            input: {
              applicationRuntime: 'chrome',
              attributes: {
                brand: 'web',
                browserName: 'chrome',
                browserVersion: '128.0.0',
                manufacturer: 'apple',
                model: null,
                operatingSystem: 'macintosh',
                operatingSystemVersion: '10.15.7',
                osDeviceIds: [],
              },
              deviceFamily: 'browser',
              deviceLanguage: 'en-US',
              devicePlatformId: 'browser',
              deviceProfile: 'macosx',
            },
          },
        },
        {
          headers: {
            Authorization: BAM_API_KEY,
            'Content-Type': 'application/json',
            'User-Agent': userAgent,
          },
        },
      );

      const zip_code = deviceData.extensions.sdk.session.location.zipCode;

      await db.providers.updateAsync({name: 'espnplus'}, {$set: {'meta.zip_code': zip_code}});

      const lookupUrl = ['https://', 'api-web.nhle.com', '/v1/postal-lookup/', zip_code].join('');

      const {data: lookupData} = await axios.get(lookupUrl, {
        headers: {
          'Content-Type': 'application/json',
          'User-Agent': userAgent,
        },
      });

      const teams = lookupData.map(team => team.teamName.default);
      const in_market_teams = teams.join(',');
      console.log(`Detected in-market teams ${in_market_teams} (${zip_code})`);

      await db.providers.updateAsync({name: 'espnplus'}, {$set: {'meta.in_market_teams': in_market_teams}});

      return {in_market_teams, zip_code};
    } catch (e) {
      console.error(e);
      console.log('Could not refresh in-market teams data!');
    }
  };

  public ispAccess = async (): Promise<boolean> => {
    try {
      await this.getGraphQlApiKey();

      const [networks, packages] = getNetworkInfo('espn3');

      const query =
        'query Airings ( $countryCode: String!, $deviceType: DeviceType!, $tz: String!, $type: AiringType, $categories: [String], $networks: [String], $packages: [String], $eventId: String, $packageId: String, $start: String, $end: String, $day: String, $limit: Int ) { airings( countryCode: $countryCode, deviceType: $deviceType, tz: $tz, type: $type, categories: $categories, networks: $networks, packages: $packages, eventId: $eventId, packageId: $packageId, start: $start, end: $end, day: $day, limit: $limit ) { id airingId simulcastAiringId name type startDateTime shortDate: startDate(style: SHORT) authTypes adobeRSS duration feedName purchaseImage { url } image { url } network { id type abbreviation name shortName adobeResource isIpAuth } source { url authorizationType hasPassThroughAds hasNielsenWatermarks hasEspnId3Heartbeats commercialReplacement } packages { name } category { id name } subcategory { id name } sport { id name abbreviation code } league { id name abbreviation code } franchise { id name } program { id code categoryCode isStudio } tracking { nielsenCrossId1 nielsenCrossId2 comscoreC6 trackingId } } }';
      const variables = `{"deviceType":"DESKTOP","countryCode":"US","tz":"UTC+0000","type":"REPLAY","networks":${networks},"packages":${packages},"limit":10}`;

      const {data: entryData} = await instance.get(
        encodeURI(
          `https://watch.graph.api.espn.com/api?apiKey=${this.graphQlApiKey}&query=${query}&variables=${variables}`,
        ),
      );

      const apiKey = [
        'u',
        'i',
        'q',
        'l',
        'b',
        'g',
        'z',
        'd',
        'w',
        'u',
        'r',
        'u',
        '1',
        '4',
        'v',
        '6',
        '2',
        '7',
        'v',
        'd',
        'u',
        's',
        's',
        'w',
        'b',
      ].join('');
      const randomInt: number = Math.floor(Math.random() * entryData.data.airings.length);
      const eventUrl = [
        'https://',
        'watch.auth.api.espn.com',
        '/video/auth/',
        'media/',
        entryData.data.airings[randomInt].id,
        '/asset',
        '?apikey=',
        apiKey,
      ].join('');

      try {
        const {data} = await axios.post(eventUrl, {
          headers: {
            'User-Agent': userAgent,
          },
        });

        if (data.stream) {
          console.log('Detected ISP access');
          return true;
        }
      } catch (e) {
        console.log('Did not detect ISP access');
      }
    } catch (e) {
      console.log('Could not check ISP access');
    }
    return false;
  };

  private getAppConfig = async () => {
    try {
      const {data} = await axios.get<IAppConfig>(BAM_APP_CONFIG);
      this.appConfig = data;
    } catch (e) {
      console.error(e);
      console.log('Could not load API app config');
    }
  };

  private getGraphQlApiKey = async () => {
    if (!this.graphQlApiKey) {
      try {
        const {data: espnKeys} = await axios.get(
          'https://a.espncdn.com/connected-devices/app-configurations/espn-js-sdk-web-2.0.config.json',
        );
        this.graphQlApiKey = espnKeys.graphqlapi.apiKey;
      } catch (e) {
        console.error(e);
        console.log('Could not get GraphQL API key');
      }
    }
  };

  private createDeviceGrant = async () => {
    if (!this.device_grant || !isTokenValid(this.device_grant.assertion)) {
      try {
        this.device_grant = await makeApiCall(this.appConfig.services.device.client.endpoints.createDeviceGrant, {
          applicationRuntime: 'chrome',
          attributes: {},
          deviceFamily: 'browser',
          deviceProfile: 'linux',
        });

        await this.save();
      } catch (e) {
        console.error(e);
        console.log('Could not get device grant');
      }
    }
  };

  private createAccountGrant = async () => {
    await this.getDeviceRefreshToken();

    if (!this.id_token_grant || !isTokenValid(this.id_token_grant.assertion)) {
      try {
        this.id_token_grant = await makeApiCall(
          this.appConfig.services.account.client.endpoints.createAccountGrant,
          {
            id_token: this.tokens.id_token,
          },
          this.device_refresh_token.access_token,
        );

        await this.save();
      } catch (e) {
        console.error(e);
        console.log('Could not get account grant');
      }
    }
  };

  private getDeviceTokenExchange = async () => {
    await this.createDeviceGrant();

    if (!this.device_token_exchange || willTimestampExpire(this.device_token_exchange_expires)) {
      try {
        this.device_token_exchange = await makeApiCall(this.appConfig.services.token.client.endpoints.exchange, {
          grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
          latitude: 0,
          longitude: 0,
          platform: 'browser',
          setCookie: false,
          subject_token: this.device_grant.assertion,
          subject_token_type: 'urn:bamtech:params:oauth:token-type:device',
        });
        this.device_token_exchange_expires = moment().add(this.device_token_exchange.expires_in, 'seconds').valueOf();

        await this.save();
      } catch (e) {
        console.error(e);
        console.log('Could not get device token exchange');
      }
    }
  };

  private getDeviceRefreshToken = async () => {
    await this.getDeviceTokenExchange();

    if (!this.device_refresh_token || willTimestampExpire(this.device_refresh_token_expires)) {
      try {
        this.device_refresh_token = await makeApiCall(this.appConfig.services.token.client.endpoints.exchange, {
          grant_type: 'refresh_token',
          latitude: 0,
          longitude: 0,
          platform: 'browser',
          refresh_token: this.device_token_exchange.refresh_token,
          setCookie: false,
        });
        this.device_refresh_token_expires = moment().add(this.device_refresh_token.expires_in, 'seconds').valueOf();

        await this.save();
      } catch (e) {
        console.error(e);
        console.log('Could not get device token exchange');
      }
    }
  };

  private getBamAccessToken = async () => {
    await this.createAccountGrant();

    if (!this.account_token || willTimestampExpire(this.account_token_expires)) {
      try {
        this.account_token = await makeApiCall(this.appConfig.services.token.client.endpoints.exchange, {
          grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
          latitude: 0,
          longitude: 0,
          platform: 'browser',
          setCookie: false,
          subject_token: this.id_token_grant.assertion,
          subject_token_type: 'urn:bamtech:params:oauth:token-type:account',
        });
        this.account_token_expires = moment().add(this.account_token.expires_in, 'seconds').valueOf();

        await this.save();
      } catch (e) {
        console.error(e);
        console.log('Could not get BAM access token');
      }
    }
  };

  private save = async (): Promise<void> => {
    await db.providers.updateAsync(
      {name: 'espnplus'},
      {$set: {tokens: _.omit(this, 'appConfig', 'graphQlApiKey', 'adobe_auth', 'adobe_device_id')}},
    );

    await db.providers.updateAsync({name: 'espn'}, {$set: {tokens: _.pick(this, 'adobe_auth', 'adobe_device_id')}});
  };

  private load = async (): Promise<void> => {
    const {tokens: plusTokens} = await db.providers.findOneAsync<IProvider<TESPNPlusTokens>>({name: 'espnplus'});
    const {
      tokens,
      device_grant,
      device_token_exchange,
      device_refresh_token,
      id_token_grant,
      account_token,
      device_token_exchange_expires,
      device_refresh_token_expires,
      account_token_expires,
    } = plusTokens;

    this.tokens = tokens;
    this.device_grant = device_grant;
    this.device_token_exchange = device_token_exchange;
    this.device_refresh_token = device_refresh_token;
    this.id_token_grant = id_token_grant;
    this.account_token = account_token;
    this.device_token_exchange_expires = device_token_exchange_expires;
    this.device_refresh_token_expires = device_refresh_token_expires;
    this.account_token_expires = account_token_expires;

    const {tokens: linearTokens} = await db.providers.findOneAsync<IProvider<TESPNTokens>>({name: 'espn'});
    const {adobe_device_id, adobe_auth} = linearTokens;

    this.adobe_device_id = adobe_device_id;
    this.adobe_auth = adobe_auth;
  };

  private loadJSON = () => {
    if (fs.existsSync(espnPlusTokens)) {
      const {tokens, device_grant, device_token_exchange, device_refresh_token, id_token_grant, account_token} =
        fsExtra.readJSONSync(espnPlusTokens);

      this.tokens = tokens;
      this.device_grant = device_grant;
      this.device_token_exchange = device_token_exchange;
      this.device_refresh_token = device_refresh_token;
      this.id_token_grant = id_token_grant;
      this.account_token = account_token;
    }

    if (fs.existsSync(espnLinearTokens)) {
      const {adobe_device_id, adobe_auth} = fsExtra.readJSONSync(espnLinearTokens);

      this.adobe_device_id = adobe_device_id;
      this.adobe_auth = adobe_auth;
    }
  };
}

export type TESPNPlusTokens = Omit<ClassTypeWithoutMethods<EspnHandler>, 'adobe_device_id' | 'adobe_auth'>;
export type TESPNTokens = Pick<ClassTypeWithoutMethods<EspnHandler>, 'adobe_device_id' | 'adobe_auth'>;

export const espnHandler = new EspnHandler();


================================================
FILE: services/flo-handler.ts
================================================
import fs from 'fs';
import fsExtra from 'fs-extra';
import path from 'path';
import axios from 'axios';
import moment from 'moment';

import {floSportsUserAgent} from './user-agent';
import {configPath} from './config';
import {useFloSports} from './networks';
import {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';
import {db} from './database';
import {getRandomUUID, normalTimeRange} from './shared-helpers';
import {debug} from './debug';

interface IFloEventsRes {
  sections: {
    id: string;
    title: string;
    items: IFloEvent[];
  }[];
}

interface IFloEvent {
  id: string;
  title: string;
  footer_1: string;
  preview_image: {
    url: string;
  };
  label_1_parts: {
    status: string;
    start_date_time: string;
  };
  action: {
    node_id: number;
    analytics: {
      name: string;
      site_name: string;
    };
  };
  live_event_metadata: {
    live_event_id: number;
    streams: {
      stream_id: number;
      stream_name: string;
    }[];
  };
}

const parseAirings = async (events: IFloEvent[]) => {
  const [now, endSchedule] = normalTimeRange();

  for (const event of events) {
    for (const stream of event.live_event_metadata.streams) {
      const entryExists = await db.entries.findOneAsync<IEntry>({id: `flo-${stream.stream_id}`});

      if (!entryExists) {
        const start = moment(event.label_1_parts.start_date_time);
        const end = moment(event.label_1_parts.start_date_time).add(4, 'hours');
        const originalEnd = moment(start).add(3, 'hours');

        if (end.isBefore(now) || start.isAfter(endSchedule)) {
          continue;
        }

        const gameName = event.action.analytics?.name.replace(/^\d{4}\s+/, '');

        console.log('Adding event: ', gameName);

        await db.entries.insertAsync<IEntry>({
          categories: [...new Set([event.footer_1, 'FloSports', event.action.analytics.site_name])],
          duration: end.diff(start, 'seconds'),
          end: end.valueOf(),
          from: 'flo',
          id: `flo-${stream.stream_id}`,
          image: event.preview_image.url,
          name: gameName,
          network: event.action.analytics.site_name,
          originalEnd: originalEnd.valueOf(),
          sport: event.footer_1,
          start: start.valueOf(),
        });
      }
    }
  }
};

const floSportsConfigPath = path.join(configPath, 'flo_tokens.json');

class FloSportsHandler {
  public access_token?: string;
  public refresh_token?: string;
  public expires_at?: number;
  public refresh_expires_at?: number;
  public device_id?: string;

  public initialize = async () => {
    const setup = (await db.providers.countAsync({name: 'flosports'})) > 0 ? true : false;

    if (!setup) {
      const data: TFloSportsTokens = {};

      if (useFloSports) {
        this.loadJSON();

        data.access_token = this.access_token;
        data.expires_at = this.expires_at;
        data.device_id = this.device_id;
        data.refresh_token = this.refresh_token;
        data.refresh_expires_at = this.refresh_expires_at;
      }

      await db.providers.insertAsync<IProvider<TFloSportsTokens>>({
        enabled: useFloSports,
        name: 'flosports',
        tokens: data,
      });

      if (fs.existsSync(floSportsConfigPath)) {
        fs.rmSync(floSportsConfigPath);
      }
    }

    if (useFloSports) {
      console.log('Using FLOSPORTS variable is no longer needed. Please use the UI going forward');
    }

    const {enabled} = await db.providers.findOneAsync<IProvider<TFloSportsTokens>>({name: 'flosports'});

    if (!enabled) {
      return;
    }

    // Load tokens from local file and make sure they are valid
    await this.load();
  };

  public refreshTokens = async () => {
    const {enabled} = await db.providers.findOneAsync<IProvider<TFloSportsTokens>>({name: 'flosports'});

    if (!enabled) {
      return;
    }

    if (!this.expires_at || moment(this.expires_at).isBefore(moment().add(10, 'days'))) {
      await this.extendToken();
    }
  };

  public getSchedule = async (): Promise<void> => {
    const {enabled} = await db.providers.findOneAsync<IProvider<TFloSportsTokens>>({name: 'flosports'});

    if (!enabled) {
      return;
    }

    console.log('Looking for FloSports events (this can take a while)...');

    try {
      let hasNextPage = true;
      let page = 1;
      const events: IFloEvent[] = [];
      const limit = 100;

      const [, endSchedule] = normalTimeRange();

      while (hasNextPage) {
        const url = [
          'https://api.flosports.tv/api/experiences/tv/events/live-and-upcoming?version=1.22.0&site_id=1%2C2%2C4%2C7%2C8%2C10%2C12%2C14%2C20%2C22%2C23%2C27%2C28%2C29%2C30%2C32%2C33%2C34%2C35%2C36%2C37%2C38%2C41%2C42%2C43',
          `&limit=${limit}`,
          page > 1 ? `&offset=${page * limit}` : '',
        ].join('');

        const {data} = await axios.get<IFloEventsRes>(url, {
          headers: {
            Authorization: `Bearer ${this.access_token}`,
          },
          // This request can take a long time so increasing the timeout
          timeout: 1000 * 60 * 5,
        });

        debug.saveRequestData(data, 'flosports', 'epg');

        data?.sections.forEach(e => {
          if (e.id === 'live-and-upcoming' || e.title === 'Live & Upcoming') {
            e.items.forEach(a => {
              if (a.action && a.label_1_parts && a.label_1_parts.status !== 'CONCLUDED' && !a.title.startsWith('TBA')) {
                if (moment(a.label_1_parts.start_date_time).isBefore(endSchedule)) {
                  events.push(a);
                } else {
                  hasNextPage = false;
                }
              }
            });
          }
        });

        page += 1;
      }

      await parseAirings(events);
    } catch (e) {
      console.error(e);
      console.log('Could not parse FloSports events');
    }
  };

  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {
    const id = eventId.replace('flo-', '');

    try {
      await this.extendToken();

      const url = ['https://', 'live-api-3.flosports.tv', '/streams/', id, '/tokens'].join('');

      const {data} = await axios.post(
        url,
        {
          adTracking: {
            appName: 'flosports-androidtv',
            appStoreUrl: 'https://play.google.com/store/apps/details?id=tv.flosports&hl=en_US',
            appVersion: 'v2.11.0-2220530',
            casting: false,
            deviceModel: 'sdk_google_atv_x86',
            height: 1080,
            isLat: 0,
            os: 'android',
            osVersion: '28',
            rdid: this.device_id,
            width: 1920,
          },
        },
        {
          headers: {
            'Content-Type': 'application/json',
            'User-Agent': floSportsUserAgent,
            authorization: `Bearer ${this.access_token}`,
          },
        },
      );

      return [data.data.uri, {}];
    } catch (e) {
      console.error(e);
      console.log('Could not start playback');
    }
  };

  private extendToken = async (): Promise<void> => {
    try {
      const url = ['https://', 'api.flosports.tv', '/api', '/refresh-tokens'].join('');

      const {data} = await axios.post(
        url,
        {
          token: this.refresh_token,
        },
        {
          headers: {
            'User-Agent': floSportsUserAgent,
          },
        },
      );

      this.access_token = data.token;
      this.expires_at = data.exp * 1000;
      this.refresh_token = data.refresh_token;
      this.refresh_expires_at = data.refresh_token_exp * 1000;
      await this.save();
    } catch (e) {
      console.error(e);
      console.log('Could not extend token for FloSports');
    }
  };

  public getAuthCode = async (): Promise<string> => {
    this.device_id = getRandomUUID();

    try {
      const url = ['https://', 'api.flosports.tv', '/api', '/activation-codes', '/new'].join('');

      const {data} = await axios.post(
        url,
        {},
        {
          headers: {
            'User-Agent': floSportsUserAgent,
          },
        },
      );

      return data.activation_code;
    } catch (e) {
      console.error(e);
      console.log('Could not start the authentication process for FloSports!');
    }
  };

  public authenticateRegCode = async (code: string): Promise<boolean> => {
    try {
      const url = ['https://', 'api.flosports.tv', '/api', '/activation-codes/', code].join('');

      const {data} = await axios.get(url, {
        headers: {
          'User-Agent': floSportsUserAgent,
        },
      });

      if (!data) {
        return false;
      }

      this.access_token = data.token;
      this.expires_at = data.exp * 1000;
      this.refresh_token = data.refresh_token;
      this.refresh_expires_at = data.refresh_token_exp * 1000;
      await this.save();

      return true;
    } catch (e) {
      return false;
    }
  };

  private save = async () => {
    await db.providers.updateAsync({name: 'flosports'}, {$set: {tokens: this}});
  };

  private load = async () => {
    const {tokens} = await db.providers.findOneAsync<IProvider<TFloSportsTokens>>({name: 'flosports'});
    const {device_id, access_token, expires_at, refresh_token, refresh_expires_at} = tokens;

    this.device_id = device_id;
    this.access_token = access_token;
    this.expires_at = expires_at;
    this.refresh_token = refresh_token;
    this.refresh_expires_at = refresh_expires_at;
  };

  private loadJSON = () => {
    if (fs.existsSync(floSportsConfigPath)) {
      const {device_id, access_token, expires_at, refresh_token, refresh_expires_at} =
        fsExtra.readJSONSync(floSportsConfigPath);

      this.device_id = device_id;
      this.access_token = access_token;
      this.expires_at = expires_at;
      this.refresh_token = refresh_token;
      this.refresh_expires_at = refresh_expires_at;
    }
  };
}

export type TFloSportsTokens = ClassTypeWithoutMethods<FloSportsHandler>;

export const floSportsHandler = new FloSportsHandler();


================================================
FILE: services/fox-handler.ts
================================================
import fs from 'fs';
import fsExtra from 'fs-extra';
import path from 'path';
import axios from 'axios';
import _ from 'lodash';
import moment from 'moment';

import {androidFoxUserAgent, userAgent} from './user-agent';
import {configPath} from './config';
import {useFoxOnly4k, useFoxSports} from './networks';
import {IAdobeAuthFox} from './adobe-helpers';
import {getRandomHex, normalTimeRange} from './shared-helpers';
import {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';
import {db} from './database';
import {debug} from './debug';
import {usesLinear, hideStudio} from './misc-db-service';

interface IAppConfig {
  api: {
    content: {
      watch: string;
    };
    key: string;
    auth: {
      accountRegCode: string;
      checkadobeauthn: string;
      getentitlements: string;
    };
    profile: {
      login: string;
    };
  };
  auth: {
    displayActivationUrl: string;
  };
}

interface IAdobePrelimAuthToken {
  accessToken: string;
  tokenExpiration: number;
  viewerId: string;
  deviceId: string;
  profileId: string;
}

interface IFoxEvent {
  airing_type: string;
  audio_only: boolean;
  call_sign: string;
  tags: string[];
  entity_id: string;
  genres: string[];
  title: string;
  description: string;
  sport_uri?: string;
  start_time: string;
  end_time: string;
  network: string;
  stream_types: string[];
  images: {
    logo?: string;
    series_detail?: string;
    series_list?: string;
  };
  isUHD?: boolean;
}

interface IFoxEventsData {
  data: {
    listings: {
	    item_count: number;
      items: IFoxEvent[];
    };
  };
}

interface IFoxMeta {
  only4k?: boolean;
  uhd?: boolean;
  dtc_events?: boolean;
  local_station_call_sign?: string;
}

const EPG_API_KEY = [
  'c',
  'f',
  '2',
  '8',
  '9',
  'e',
  '2',
  '9',
  '9',
  'e',
  'f',
  'd',
  'f',
  'a',
  '3',
  '9',
  'f',
  'b',
  '6',
  '3',
  '1',
  '6',
  'f',
  '2',
  '5',
  '9',
  'd',
  '1',
  'd',
  'e',
  '9',
  '3',
].join('');

const network_entitlement_map = { fox: 'foxSports', btn: 'btn-btn2go', 'fox-soccer-plus': 'fspl' };

const foxConfigPath = path.join(configPath, 'fox_tokens.json');

const getMaxRes = (res: string) => {
  switch (res) {
    case 'UHD/HDR':
      return 'UHD/HDR';
    default:
      return '720p';
  }
};

const parseCategories = (event: IFoxEvent) => {
  const categories = ['FOX Sports', 'FOX'];
  for (const classifier of [...(event.tags || []), ...(event.genres || [])]) {
    if (classifier !== null) {
      categories.push(classifier);
    }
  }

  if (event.sport_uri) {
    categories.push(event.sport_uri);
  }

  if (event.stream_types?.find(resolution => resolution === 'HDR' || resolution === 'SDR') || event.isUHD) {
    categories.push('4K');
  }

  return [...new Set(categories)];
};

const parseAirings = async (events: IFoxEvent[]) => {
  const useLinear = await usesLinear();
  const hide_studio = await hideStudio();

  const [now, inTwoDays] = normalTimeRange();

  const {meta} = await db.providers.findOneAsync<IProvider<any, IFoxMeta>>({name: 'foxsports'});

  for (const event of events) {
    const entryExists = await db.entries.findOneAsync<IEntry>({id: `${event.entity_id.replace('_dtc', '')}`});

    if (!entryExists) {
      const start = moment(event.start_time);
      const end = moment(event.end_time);
      const originalEnd = moment(event.end_time);

      const isLinear = event.network !== 'fox' && useLinear;

      if (!isLinear) {
        end.add(1, 'hour');
      }

      if (end.isBefore(now) || start.isAfter(inTwoDays)) {
        continue;
      }

      const categories = parseCategories(event);

      if (meta.only4k && !_.some(categories, category => category === '4K')) {
        continue;
      }

      const studio_regex = /Sports (Commentary|Highlights|Magazine|talk)/i;
      const isStudio = categories.find(item => item.match(studio_regex));
      if (!isLinear && hide_studio && isStudio) {
        continue;
      }

      const eventName = `${event.sport_uri === 'NFL' ? `${event.sport_uri} - ` : ''}${event.title}`;

      console.log('Adding event: ', eventName);

      await db.entries.insertAsync<IEntry>({
        categories,
        duration: end.diff(start, 'seconds'),
        end: end.valueOf(),
        from: 'foxsports',
        id: event.entity_id.replace('_dtc', ''),
        image: event.images.logo || event.images.series_detail || event.images.series_list,
        name: eventName,
        network: event.call_sign,
        originalEnd: originalEnd.valueOf(),
        replay: event.airing_type !== 'live',
        start: start.valueOf(),
        ...(isLinear && {
          channel: event.network,
          linear: true,
        }),
      });
    }
  }
};

const FOX_APP_CONFIG = 'https://config.foxdcg.com/foxsports/androidtv-native/3.42/info.json';

// Will prelim token expire in the next month?
const willPrelimTokenExpire = (token: IAdobePrelimAuthToken): boolean =>
  new Date().valueOf() + 3600 * 1000 * 24 * 30 > (token?.tokenExpiration || 0);
// Will auth token expire in the next day?
const willAuthTokenExpire = (token: IAdobeAuthFox): boolean =>
  new Date().valueOf() + 3600 * 1000 * 24 > (token?.tokenExpiration || 0);

const checkEventNetwork = (entitlements, event: IFoxEvent): boolean => {
  if ( event.network && (entitlements.includes(event.network) || (network_entitlement_map[event.network] && entitlements.includes(network_entitlement_map[event.network]))) ) {
    return true;
  }

  return false;
};

class FoxHandler {
  public adobe_device_id?: string;
  public adobe_prelim_auth_token?: IAdobePrelimAuthToken;
  public adobe_auth?: IAdobeAuthFox;

  private entitlements: string[] = [];
  private appConfig: IAppConfig;

  public initialize = async () => {
    const setup = (await db.providers.countAsync({name: 'foxsports'})) > 0 ? true : false;

    if (!setup) {
      const data: TFoxTokens = {};

      if (useFoxSports) {
        this.loadJSON();

        data.adobe_auth = this.adobe_auth;
        data.adobe_device_id = this.adobe_device_id;
        data.adobe_prelim_auth_token = this.adobe_prelim_auth_token;
      }

      // see below for update/addition of Soccer Plus and Deportes linear channels
      await db.providers.insertAsync<IProvider<TFoxTokens, IFoxMeta>>({
        enabled: useFoxSports,
        linear_channels: [
          {
            enabled: false,
            id: 'fs1',
            name: 'FS1',
            tmsId: '82547',
          },
          {
            enabled: false,
            id: 'fs2',
            name: 'FS2',
            tmsId: '59305',
          },
          {
            enabled: false,
            id: 'btn',
            name: 'B1G Network',
            tmsId: '58321',
          },
        ],
        meta: {
          only4k: useFoxOnly4k,
          uhd: getMaxRes(process.env.MAX_RESOLUTION) === 'UHD/HDR',
          local_station_call_sign: '',
        },
        name: 'foxsports',
        tokens: data,
      });

      if (fs.existsSync(foxConfigPath)) {
        fs.rmSync(foxConfigPath);
      }
    }

    if (useFoxSports) {
      console.log('Using FOXSPORTS variable is no longer needed. Please use the UI going forward');
    }
    if (useFoxOnly4k) {
      console.log('Using FOX_ONLY_4K variable is no longer needed. Please use the UI going forward');
    }
    if (process.env.MAX_RESOLUTION) {
      console.log('Using MAX_RESOLUTION variable is no longer needed. Please use the UI going forward');
    }

    const {enabled, meta, linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'foxsports'});
	
    // update/add Soccer Plus and Deportes, if necessary
    if ( linear_channels.length <= 4 ) {
      linear_channels[3] = {
    	enabled: false,
        id: 'fox-soccer-plus',
        name: 'FOX Soccer Plus',
        tmsId: '66880',
      };
      linear_channels.push({
        enabled: false,
        id: 'foxdep',
        name: 'FOX Deportes',
        tmsId: '72189',
      });
      await db.providers.updateAsync<IProvider<TFoxTokens>, any>(
        {name: 'foxsports'},
        {
          $set: {
            linear_channels: linear_channels,
          },
        },
      );
    }

    if (!enabled) {
      return;
    }

    if (!meta.dtc_events) {
      const events = await db.entries.findAsync({from: 'foxsports', id: {$regex: /_dtc/}});

      for (const event of events) {
        await db.entries.updateAsync({from: 'foxsports', id: event.id}, {$set: {id: event.id.replace('_dtc', '')}});
      }

      await db.providers.updateAsync({name: 'foxsports'}, {$set: {meta: {...meta, dtc_events: true}}});
    }

    // Load tokens from local file and make sure they are valid
    await this.load();

    await this.getEntitlements();
  };

  public refreshTokens = async () => {
    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'foxsports'});

    if (!enabled) {
      return;
    }

    if (!this.adobe_prelim_auth_token || willPrelimTokenExpire(this.adobe_prelim_auth_token)) {
      console.log('Updating FOX Sports prelim token');
      await this.getPrelimToken();
    }

    if (willAuthTokenExpire(this.adobe_auth)) {
      console.log('Refreshing TV Provider token (FOX Sports)');
      await this.authenticateRegCode();
    }
  };

  public getSchedule = async (): Promise<void> => {
    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'foxsports'});

    if (!enabled) {
      return;
    }

    console.log('Looking for FOX Sports events...');

    try {
      const entries = await this.getEvents();
      await parseAirings(entries);
    } catch (e) {
      console.error(e);
      console.log('Could not parse FOX Sports events');
    }
  };

  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {
    try {
      if (!this.appConfig) {
        await this.getAppConfig();
      }

      let cdn = 'fastly';
      let data;

      // while (cdn !== 'akamai|limelight|fastly') {
      while (cdn === 'fastly') {
        data = await this.getSteamData(eventId);
        cdn = data.trackingData.properties.CDN;
      }

      if (!data || !data?.url) {
        throw new Error('Could not get stream data. Event might be upcoming, ended, or in blackout...');
      }

      const {data: streamData} = await axios.get(data.url, {
        headers: {
          'User-Agent': androidFoxUserAgent,
          'x-api-key': this.appConfig.api.key,
        },
      });

      if (!streamData.playURL) {
        throw new Error('Could not get stream data. Event might be upcoming, ended, or in blackout...');
      }

      return [
        streamData.playURL,
        {
          'User-Agent': androidFoxUserAgent,
        },
      ];
    } catch (e) {
      console.error(e);
      console.log('Could not get stream information!');
    }
  };

  private getSteamData = async (eventId: string): Promise<any> => {
    const {meta} = await db.providers.findOneAsync<IProvider<any, IFoxMeta>>({name: 'foxsports'});
    const {uhd} = meta;

    const streamOrder = ['UHD/HDR', '720p'];

    let resIndex = streamOrder.findIndex(i => i === getMaxRes(uhd ? 'UHD/HDR' : ''));

    if (resIndex < 0) {
      resIndex = 1;
    }

    if (!this.appConfig) {
      await this.getAppConfig();
    }

    let watchData;

    for (let a = resIndex; a < streamOrder.length; a++) {
      try {
        const {data} = await axios.post(
          'https://prod.api.video.fox/v2.0/watch',
          {
            capabilities: ['fsdk/yo/v3'],
            deviceHeight: 2160,
            deviceWidth: 3840,
            maxRes: streamOrder[a],
            os: 'Android',
            osv: '11.0.0',
            streamId: eventId.replace('_dtc', ''),
            streamType: 'live',
          },
          {
            headers: {
              'User-Agent': androidFoxUserAgent,
              authorization: this.adobe_auth.accessToken,
              'x-api-key': this.appConfig.api.key,
            },
          },
        );

        watchData = data;
        break;
      } catch (e) {
        console.log(
          `Could not get stream data for ${streamOrder[a]}. ${
            streamOrder[a + 1] ? `Trying to get ${streamOrder[a + 1]} next...` : ''
          }`,
        );
      }
    }

    return watchData;
  };

  private getEvents = async (): Promise<IFoxEvent[]> => {
    if (!this.appConfig) {
      await this.getAppConfig();
    }

    // get local station call sign
    let local_station_call_sign_parameter = '';
    try {
      const {meta} = await db.providers.findOneAsync<IProvider<any, IFoxMeta>>({name: 'foxsports'});
      if ( !meta.local_station_call_sign || (meta.local_station_call_sign == '') ) {
        console.log('FOX Sports detecting local FOX call sign to pull flagship events');
        let local_station_call_sign = 'none';
        const {data} = await axios.get(
          'https://api-sps.foxsports.com/locator/v1/location',
          {
            headers: {
              'User-Agent': userAgent,
              'x-api-key': EPG_API_KEY,
            },
          },
        );

        if ( data.data.results[0].local_station_call_sign ) {
          local_station_call_sign = data.data.results[0].local_station_call_sign;
          console.log('FOX Sports found local FOX call sign ' + local_station_call_sign);
          local_station_call_sign_parameter = '%2C' +  local_station_call_sign;
        } else {
          console.log('FOX Sports could not find a local FOX call sign');
        }
        await db.providers.updateAsync({name: 'foxsports'}, {$set: {'meta.local_station_call_sign': local_station_call_sign}});
      } else if ( (meta.local_station_call_sign != 'none') ) {
        local_station_call_sign_parameter = '%2C' +  meta.local_station_call_sign;
      }
    } catch (e) {
      console.log(e);
    }

    const useLinear = await usesLinear();
    
    const {linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'foxsports'});

    const events: IFoxEvent[] = [];

    const [now, inTwoDays] = normalTimeRange();

    const startTime = now.unix();
    const endTime = inTwoDays.unix();

    try {
      let max_items_per_page = 50;
      let pages = 1;

      for (let page = 1; page <= pages; page++) {
        const {data} = await axios.get<IFoxEventsData>(
          `https://api.fox.com/fs/product/curated/v1/sporting/keystone/detail/by_filters?callsign=BTN%2CBTN-DIGITAL%2CFOX%2CFOX-DIGITAL%2CFOXDEP%2CFOXDEP-DIGITAL%2CFS1%2CFS1-DIGITAL%2CFS2%2CFS2-DIGITAL%2CFSP${local_station_call_sign_parameter}&end_date=${endTime}&page=${page}&size=${max_items_per_page}&start_date=${startTime}&video_type=listing`,
          {
            headers: {
              'User-Agent': userAgent,
              authorization: `Bearer ${this.adobe_prelim_auth_token.accessToken}`,
              'x-fox-apikey': EPG_API_KEY,
            },
          },
        );

        if ( data.data.listings.item_count ) {
          pages = Math.ceil(data.data.listings.item_count / max_items_per_page);
        }

        debug.saveRequestData(data, 'foxsports', 'epg');

        _.forEach(data.data.listings.items, m => {
          const isChannelEnabled = linear_channels.find(c => c.id === m.network && c.enabled === true);
          if (
            checkEventNetwork(this.entitlements, m) &&
            !m.audio_only &&
            m.start_time &&
            m.end_time &&
            m.entity_id &&
            isChannelEnabled
          ) {
            if (!useLinear) {
              if (m.airing_type === 'live' || m.airing_type === 'new') {
                events.push(m);
              }
            } else {
              events.push(m);
            }
          }
        });
      }
    } catch (e) {
      console.log(e);
    }

    return events;
  };

  private getAppConfig = async () => {
    try {
      const {data} = await axios.get<IAppConfig>(FOX_APP_CONFIG);
      this.appConfig = data;
    } catch (e) {
      console.error(e);
      console.log('Could not load API app config');
    }
  };

  private getEntitlements = async (): Promise<void> => {
    try {
      if (!this.appConfig) {
        await this.getAppConfig();
      }

      const {data} = await axios.get<any>(
        `${this.appConfig.api.auth.getentitlements}?device_type=&device_id=${this.adobe_device_id}&resource=&requestor=`,
        {
          headers: {
            'User-Agent': androidFoxUserAgent,
            authorization: this.adobe_auth.accessToken,
            'x-api-key': this.appConfig.api.key,
          },
        },
      );

      this.entitlements = [];

      _.forOwn(data.entitlements, (_val, key) => {
        if (/^[a-z]/.test(key)) {
          this.entitlements.push(key);
        }
      });
    } catch (e) {
      console.error(e);
    }
  };

  private getPrelimToken = async (): Promise<void> => {
    try {
      if (!this.appConfig) {
        await this.getAppConfig();
      }

      const {data} = await axios.post<IAdobePrelimAuthToken>(
        this.appConfig.api.profile.login,
        {
          deviceId: this.adobe_device_id,
        },
        {
          headers: {
            'User-Agent': androidFoxUserAgent,
            'x-api-key': this.appConfig.api.key,
            'x-signature-enabled': true,
          },
        },
      );

      this.adobe_prelim_auth_token = data;
      await this.save();
    } catch (e) {
      console.error(e);
      console.log('Could not get information to start Fox Sports login flow');
    }
  };

  public getAuthCode = async (): Promise<string> => {
    this.adobe_device_id = _.take(getRandomHex(), 16).join('');
    this.adobe_auth = undefined;

    if (!this.appConfig) {
      await this.getAppConfig();
    }

    await this.getPrelimToken();

    try {
      const {data} = await axios.post(
        this.appConfig.api.auth.accountRegCode,
        {
          deviceID: this.adobe_device_id,
          isMvpd: true,
          selectedMvpdId: '',
        },
        {
          headers: {
            'User-Agent': androidFoxUserAgent,
            authorization: `Bearer ${this.adobe_prelim_auth_token.accessToken}`,
            'x-api-key': this.appConfig.api.key,
          },
        },
      );

      return data.code;
    } catch (e) {
      console.error(e);
      console.log('Could not start the authentication process for Fox Sports!');
    }
  };

  public authenticateRegCode = async (showAuthnError = true): Promise<boolean> => {
    try {
      if (!this.appConfig) {
        await this.getAppConfig();
      }

      const {data} = await axios.get(`${this.appConfig.api.auth.checkadobeauthn}?device_id=${this.adobe_device_id}`, {
        headers: {
          'User-Agent': androidFoxUserAgent,
          authorization: !this.adobe_auth?.accessToken
            ? `Bearer ${this.adobe_prelim_auth_token.accessToken}`
            : this.adobe_auth.accessToken,
          'x-api-key': this.appConfig.api.key,
          'x-signature-enabled': true,
        },
      });

      this.adobe_auth = data;
      await this.save();

      await this.getEntitlements();

      return true;
    } catch (e) {
      if (e.response?.status !== 404) {
        if (showAuthnError) {
          if (e.response?.status === 410) {
            console.error(e);
            console.log('Adobe AuthN token has expired for FOX Sports');
          }
        } else if (e.response?.status !== 410) {
          console.error(e);
          console.log('Could not get provider token data for Fox Sports!');
        }
      }

      return false;
    }
  };

  private save = async () => {
    await db.providers.updateAsync({name: 'foxsports'}, {$set: {tokens: _.omit(this, 'appConfig', 'entitlements')}});
  };

  private load = async (): Promise<void> => {
    const {tokens} = await db.providers.findOneAsync<IProvider<TFoxTokens>>({name: 'foxsports'});
    const {adobe_device_id, adobe_auth, adobe_prelim_auth_token} = tokens;

    this.adobe_device_id = adobe_device_id;
    this.adobe_auth = adobe_auth;
    this.adobe_prelim_auth_token = adobe_prelim_auth_token;
  };

  private loadJSON = () => {
    if (fs.existsSync(foxConfigPath)) {
      const {adobe_device_id, adobe_auth, adobe_prelim_auth_token} = fsExtra.readJSONSync(foxConfigPath);

      this.adobe_device_id = adobe_device_id;
      this.adobe_auth = adobe_auth;
      this.adobe_prelim_auth_token = adobe_prelim_auth_token;
    }
  };
}

export type TFoxTokens = ClassTypeWithoutMethods<FoxHandler>;

export const foxHandler = new FoxHandler();

================================================
FILE: services/foxone-handler.ts
================================================
import fs from 'fs';
import fsExtra from 'fs-extra';
import path from 'path';
import axios from 'axios';
import _ from 'lodash';
import moment from 'moment';

import {androidFoxOneUserAgent, userAgent} from './user-agent';
import {configPath} from './config';
import {useFoxOneOnly4k, useFoxOne} from './networks';
import {IAdobeAuthFoxOne} from './adobe-helpers';
import {getRandomHex, normalTimeRange} from './shared-helpers';
import {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';
import {db} from './database';
import {debug} from './debug';
import {usesLinear, hideStudio} from './misc-db-service';

interface IAppConfig {
  network: {
    identity: {
      host: string;
      entitlementsUrl: string;
      regcodeUrl: string;
      checkAdobeUrl: string;
      loginUrl: string;
    };
    auth: {
      loginWebsiteUrl: string;
    };
    apikey: string;
  };
  playback: {
    baseApiUrl: string;
  };
}

interface IAdobePrelimAuthToken {
  accessToken: string;
  tokenExpiration: number;
  viewerId: string;
  deviceId: string;
  profileId: string;
}

interface IFoxOneEvent {
  airing_type: string;
  audio_only: boolean;
  call_sign: string;
  tags: string[];
  entity_id: string;
  genre_metadata: {
    display_name: string;
  };
  title: string;
  description: string;
  sport_uri?: string;
  start_time: string;
  end_time: string;
  network: string;
  content_sku: string;
  stream_types: string[];
  images: {
    logo?: string;
    series_detail?: string;
    series_list?: string;
  };
  gracenote: {
    station_id?: string;
  };
  is_uhd?: boolean;
  is_multiview?: boolean;
  is_sportingevent?: boolean;
}

interface IFoxOneMeta {
  only4k?: boolean;
  uhd?: boolean;
  dtc_events?: boolean;
  local_station_call_signs?: string[] | string;
}

const foxOneConfigPath = path.join(configPath, 'foxone_tokens.json');

const getMaxRes = (res: string) => {
  switch (res) {
    case 'UHD/HDR':
      return 'UHD/HDR';
    default:
      return 'HD';
  }
};

const parseCategories = (event: IFoxOneEvent) => {
  const categories = ['FOX One', 'FOX'];
  for (const classifier of [...(event.tags || []), ...(event.genre_metadata.display_name || [])]) {
    if (classifier !== null) {
      categories.push(classifier);
    }
  }

  if (event.sport_uri) {
    categories.push(event.sport_uri);
  }

  const hasHDRorSDR = event.stream_types?.some(res => res === 'HDR' || res === 'SDR');
  const isUHD = event.is_uhd;

  if (hasHDRorSDR || isUHD) {
    categories.push('4K');
  }
  return [...new Set(categories)];
};

const parseAirings = async (events: IFoxOneEvent[]) => {
  const useLinear = await usesLinear();
  const hide_studio = await hideStudio();

  const [now, inTwoDays] = normalTimeRange();

  const {meta} = await db.providers.findOneAsync<IProvider<any, IFoxOneMeta>>({name: 'foxone'});

  for (const event of events) {
    const entryExists = await db.entries.findOneAsync<IEntry>({id: `${event.entity_id}`});

    if (!entryExists) {
      const isLinear = useLinear;
      
      if (!isLinear && event.airing_type !== 'live') {
        continue;
      }

      if (!isLinear && hide_studio && !event.is_sportingevent) {
        continue;
      }
      
      const start = moment(event.start_time);
      const end = moment(event.end_time);
      const originalEnd = moment(event.end_time);

      if (!isLinear) {
        end.add(1, 'hour');
      }

      if (end.isBefore(now) || start.isAfter(inTwoDays)) {
        continue;
      }

      const categories = parseCategories(event);

      if (meta.only4k && !_.some(categories, category => category === '4K')) {
        continue;
      }

      const eventName = `${event.sport_uri === 'NFL' ? `${event.sport_uri} - ` : ''}${event.title}`;

      console.log(`Adding event: ${event.call_sign}: ${eventName}`);

      await db.entries.insertAsync<IEntry>({
        categories,
        duration: end.diff(start, 'seconds'),
        end: end.valueOf(),
        from: 'foxone',
        id: event.entity_id,
        image: event.images?.logo || event.images?.series_detail || event.images?.series_list,
        name: eventName,
        network: event.call_sign,
        originalEnd: originalEnd.valueOf(),
        replay: event.airing_type !== 'live' && event.airing_type !== 'new',
        start: start.valueOf(),
        ...(isLinear && {
          channel: event.network,
          linear: true,
        }),
      });
    }
  }
};

const FOXONE_APP_CONFIG = 'https://config.foxplus.com/androidtv/1.3/config/info.json';
// Will prelim token expire in the next month?
const willPrelimTokenExpire = (token: IAdobePrelimAuthToken): boolean =>
  new Date().valueOf() + 3600 * 1000 * 24 * 30 > (token?.tokenExpiration || 0);
// Will auth token expire in the next day?
const willAuthTokenExpire = (token: IAdobeAuthFoxOne): boolean =>
  new Date().valueOf() + 3600 * 1000 * 24 > (token?.tokenExpiration || 0);

const checkEventSku = (entitlements, event: IFoxOneEvent): boolean => {
  if (event.content_sku && Array.isArray(entitlements)) {
    return true;
  }
  return false;
};

class FoxOneHandler {
  public adobe_device_id?: string;
  public adobe_prelim_auth_token?: IAdobePrelimAuthToken;
  public adobe_auth?: IAdobeAuthFoxOne;

  private platform_location?: string;
  private platform_zip?: string;
  private contentEntitlement?: string;
  private homeMetroCode?: string;
  private homeZipCode?: string;
  private entitlements: string[] = [];
  private entArray: string[] = [];
  private contentEnt: any;
  private appConfig: IAppConfig;
  private stationMap: { [key: string]: { network: string; stationId: string; callSign: string } } = {}; // Add stationMap as a class property

  public initialize = async () => {
    const setup = (await db.providers.countAsync({name: 'foxone'})) > 0 ? true : false;

    if (!setup) {
      const data: TFoxOneTokens = {};

      if (useFoxOne) {
        this.loadJSON();

        data.adobe_auth = this.adobe_auth;
        data.adobe_device_id = this.adobe_device_id;
        data.adobe_prelim_auth_token = this.adobe_prelim_auth_token;
      }

      await db.providers.insertAsync<IProvider<TFoxOneTokens, IFoxOneMeta>>({
        enabled: useFoxOne,
        linear_channels: [
          {
            enabled: false,
            id: 'FOX',
            name: 'FOX',
            tmsId: '',
            callSign: '',
          },
          {
            enabled: false,
            id: 'MNTV',
            name: 'MyNetwork TV',
            tmsId: '',
            callSign: '',
          },
          {
            enabled: false,
            id: 'FS1',
            name: 'FS1',
            tmsId: '82547',
          },
          {
            enabled: false,
            id: 'FS2',
            name: 'FS2',
            tmsId: '59305',
          },
          {
            enabled: false,
            id: 'Big Ten Network',
            name: 'B1G Network',
            tmsId: '58321',
          },
          {
            enabled: false,
            id: 'FOX Deportes',
            name: 'FOX Deportes',
            tmsId: '72189',
          },
          {
            enabled: false,
            id: 'FOX News',
            name: 'FOX News Channel',
            tmsId: '60179',
          },
          {
            enabled: false,
            id: 'FOX Business',
            name: 'FOX Business Network',
            tmsId: '58718',
          },
          {
            enabled: false,
            id: 'TMZ',
            name: 'TMZ',
            tmsId: '149408',
          },
          {
            enabled: false,
            id: 'FOX Digital',
            name: 'Masked Singer',
          },
          {
            enabled: false,
            id: 'FOX Soul',
            name: 'Fox Soul',
            tmsId: '119212',
          },
          {
            enabled: false,
            id: 'FOX Weather',
            name: 'Fox Weather',
            tmsId: '121307',
          },
          {
            enabled: false,
            id: 'FOX LOCAL',
            name: 'Fox Live Now',
            tmsId: '119219',
          },
        ],
        meta: {
          only4k: useFoxOneOnly4k,
          uhd: getMaxRes(process.env.MAX_RESOLUTION) === 'UHD/HDR',
          local_station_call_signs: '',
        },
        name: 'foxone',
        tokens: data,
      });

      if (fs.existsSync(foxOneConfigPath)) {
        fs.rmSync(foxOneConfigPath);
      }
    }

    if (useFoxOne) {
      console.log('Using FOXONE variable is no longer needed. Please use the UI going forward');
    }
    if (useFoxOneOnly4k) {
      console.log('Using FOXONE_ONLY_4K variable is no longer needed. Please use the UI going forward');
    }
    if (process.env.MAX_RESOLUTION) {
      console.log('Using MAX_RESOLUTION variable is no longer needed. Please use the UI going forward');
    }

    const {enabled} = await db.providers.findOneAsync<IProvider<TFoxOneTokens, IFoxOneMeta>>({name: 'foxone'});

    if (!enabled) {
      return;
    }

    // Load tokens from local file and make sure they are valid
    await this.load();

    await this.getEntitlements();
    
    // Update linear_channels during initialization
    await this.getEvents();
  };

  public getEvents = async (): Promise<IFoxOneEvent[]> => {
    if (!this.appConfig) {
      await this.getAppConfig();
    }

    const useLinear = await usesLinear();
    const events: IFoxOneEvent[] = [];

    const [now, inTwoDays] = normalTimeRange();

    const startTime = now.unix();
    const endTime = inTwoDays.unix();

    try {
      await this.getLocation();
      await this.getEntitlements();
      await this.getUserEntitlements();

      const { data: initData } = await axios.get<any>(
        'https://api.fox.com/dtc/product/config/v1/init',
        {
          headers: {
            'User-Agent': androidFoxOneUserAgent,
            authorization: `Bearer ${this.adobe_prelim_auth_token.accessToken}`,
            'x-fox-apikey': this.appConfig.network.apikey,
            'x-platform-location': this.platform_location,
            'x-fox-zipcode': this.platform_zip,
            'x-home-zipcode': this.homeZipCode || '',
            'x-fox-home-dma': this.homeMetroCode || '',
            'x-fox-dma': this.homeMetroCode || '',
            'x-fox-content-entitlement': this.contentEntitlement || '',
            'x-fox-userauth': `Bearer ${this.adobe_prelim_auth_token.accessToken}`,
          },
        },
      );

      const navigationUri = initData?.data?.dynamic_uris?.navigation_uri;

      if (!navigationUri) {
        throw new Error('navigation_uri not found in init data');
      }

      const { data: navData } = await axios.get<any>(
        `https://api.fox.com/dtc${navigationUri}?page=1&size=25`,
        {
          headers: {
            'User-Agent': androidFoxOneUserAgent,
            authorization: `Bearer ${this.adobe_prelim_auth_token.accessToken}`,
            'x-fox-apikey': this.appConfig.network.apikey,
            'x-platform-location': this.platform_location,
            'x-fox-zipcode': this.platform_zip,
            'x-home-zipcode': this.homeZipCode || '',
            'x-fox-home-dma': this.homeMetroCode || '',
            'x-fox-dma': this.homeMetroCode || '',
            'x-fox-content-entitlement': this.contentEntitlement || '',
            'x-fox-userauth': `Bearer ${this.adobe_prelim_auth_token.accessToken}`,
          },
        },
      );

      let lSchedUri: string | undefined = undefined;

      if (navData.data?.items) {
        for (const item of navData.data
Download .txt
gitextract_rv9tbtv8/

├── .dockerignore
├── .editorconfig
├── .eslintrc.json
├── .github/
│   └── workflows/
│       ├── push-docker-hub.yml
│       └── release.yml
├── .gitignore
├── .husky/
│   └── pre-commit
├── .nvmrc
├── .prettierrc.json
├── Dockerfile
├── LICENSE
├── README.md
├── entrypoint.sh
├── index.tsx
├── package.json
├── services/
│   ├── adobe-helpers.ts
│   ├── app-status.ts
│   ├── b1g-handler.ts
│   ├── bally-handler.ts
│   ├── build-schedule.ts
│   ├── caching.ts
│   ├── cbs-handler.ts
│   ├── channels.ts
│   ├── config.ts
│   ├── database.ts
│   ├── debug.ts
│   ├── espn-handler.ts
│   ├── flo-handler.ts
│   ├── fox-handler.ts
│   ├── foxone-handler.ts
│   ├── generate-m3u.ts
│   ├── generate-xmltv.ts
│   ├── gotham-channels.ts
│   ├── gotham-handler.ts
│   ├── hudl-handler.ts
│   ├── init-directories.ts
│   ├── jsdom-helper.ts
│   ├── kbo-handler.ts
│   ├── ksl-handler.ts
│   ├── launch-channel.ts
│   ├── midco-handler.ts
│   ├── misc-db-service.ts
│   ├── mlb-handler.ts
│   ├── mw-handler.ts
│   ├── networks.ts
│   ├── nfl-handler.ts
│   ├── nhltv-handler.ts
│   ├── nwsl-handler.ts
│   ├── outside-handler.ts
│   ├── paramount-handler.ts
│   ├── playlist-handler.ts
│   ├── port.ts
│   ├── providers/
│   │   ├── b1g/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── bally/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       └── index.tsx
│   │   ├── cbs-sports/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── espn/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── espn-plus/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── flosports/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── fox/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── foxone/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── gotham/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       ├── TveLogin.tsx
│   │   │       └── index.tsx
│   │   ├── hudl/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       └── index.tsx
│   │   ├── index.ts
│   │   ├── kbo/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       └── index.tsx
│   │   ├── ksl/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       └── index.tsx
│   │   ├── midco/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── mlb/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── mw/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       └── index.tsx
│   │   ├── nfl/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── nhl-tv/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── nwsl/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── outside/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── paramount/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── pwhl/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       └── index.tsx
│   │   ├── victory/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── wnba/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       ├── CardBody.tsx
│   │   │       ├── Login.tsx
│   │   │       └── index.tsx
│   │   ├── wsn/
│   │   │   ├── index.tsx
│   │   │   └── views/
│   │   │       └── index.tsx
│   │   └── zeam/
│   │       ├── index.tsx
│   │       └── views/
│   │           └── index.tsx
│   ├── pwhl-handler.ts
│   ├── shared-helpers.ts
│   ├── shared-interfaces.ts
│   ├── template-handler.ts
│   ├── tubi-helper.ts
│   ├── user-agent.ts
│   ├── victory-handler.ts
│   ├── wnba-handler.ts
│   ├── wsn-handler.ts
│   ├── yt-dlp-helper.ts
│   └── zeam-handler.ts
├── tsconfig.json
└── views/
    ├── Header.tsx
    ├── Layout.tsx
    ├── Links.tsx
    ├── Main.tsx
    ├── Options.tsx
    ├── Providers.tsx
    ├── Script.tsx
    ├── Style.tsx
    └── Tools.tsx
Download .txt
SYMBOL INDEX (324 symbols across 81 files)

FILE: services/adobe-helpers.ts
  type IAdobeAuth (line 5) | interface IAdobeAuth {
  type IAdobeAuthFox (line 12) | interface IAdobeAuthFox {
  type IAdobeAuthFoxOne (line 19) | interface IAdobeAuthFoxOne {

FILE: services/b1g-handler.ts
  type IEventCategory (line 16) | interface IEventCategory {
  type IEventTeam (line 20) | interface IEventTeam {
  type IEventMetadata (line 26) | interface IEventMetadata {
  type IEventImage (line 33) | interface IEventImage {
  type IEventContent (line 37) | interface IEventContent {
  type IB1GEvent (line 42) | interface IB1GEvent {
  type IGameData (line 56) | interface IGameData {
  type IB1GMeta (line 63) | interface IB1GMeta {
  class B1GHandler (line 150) | class B1GHandler {
  type TB1GTokens (line 469) | type TB1GTokens = ClassTypeWithoutMethods<B1GHandler>;

FILE: services/bally-handler.ts
  type IBallyTeam (line 11) | interface IBallyTeam {
  type IBallyEvent (line 17) | interface IBallyEvent {
  type IBallyLinearEvent (line 26) | interface IBallyLinearEvent {
  type IBallyEPGRes (line 34) | interface IBallyEPGRes {
  type IBallyLinearEPGRes (line 38) | interface IBallyLinearEPGRes {
  constant API_KEY (line 42) | const API_KEY = [
  constant CHANNEL_IMAGE_MAP (line 81) | const CHANNEL_IMAGE_MAP = {
  constant CHANNEL_MAP (line 89) | const CHANNEL_MAP = {
  constant CHANNEL_MAP_SWAP (line 97) | const CHANNEL_MAP_SWAP = {
  class BallyHandler (line 183) | class BallyHandler {
  type TBallyTokens (line 355) | type TBallyTokens = ClassTypeWithoutMethods<BallyHandler>;

FILE: services/caching.ts
  constant MAX_SIZE (line 8) | const MAX_SIZE = 1024 * 1024 * 128;
  type IPromiseMap (line 10) | interface IPromiseMap {
  class PromiseCache (line 15) | class PromiseCache {
    method getPromise (line 18) | public getPromise<T>(keyId: string, call: Promise<any>, ttl: number): ...
    method removePromise (line 39) | public removePromise(keyId: string) {
  class CacheLayer (line 46) | class CacheLayer {
    method getChunklistFromUrl (line 53) | public getChunklistFromUrl(url: string, prefix = ''): string {
    method getChunklistFromId (line 66) | public getChunklistFromId(id: string): string {
    method getSegmentFromUrl (line 74) | public getSegmentFromUrl(url: string, prefix = ''): string {
    method getDataFromSegment (line 87) | public async getDataFromSegment(segment: string, headers: IHeaders, ne...

FILE: services/cbs-handler.ts
  type ICBSEvent (line 18) | interface ICBSEvent {
  type IGameData (line 64) | interface IGameData {
  constant API_KEY (line 71) | const API_KEY = [
  constant ADOBE_KEY (line 114) | const ADOBE_KEY = ['w', 'G', 'x', 'd', 'a', 'c', 'C', 'K', 'M', 'S', '8'...
  constant ADOBE_PUBLIC_KEY (line 116) | const ADOBE_PUBLIC_KEY = [
  constant SYNCBAK_KEY (line 151) | const SYNCBAK_KEY = [
  constant SYNCBAK_PUBLIC_KEY (line 186) | const SYNCBAK_PUBLIC_KEY = [
  constant CHANNEL_MAP (line 221) | const CHANNEL_MAP = {
  class CBSHandler (line 295) | class CBSHandler {
  type TCBSTokens (line 608) | type TCBSTokens = ClassTypeWithoutMethods<CBSHandler>;

FILE: services/channels.ts
  function startApp (line 9) | async function startApp() {
  constant CHANNELS (line 45) | const CHANNELS = {
  method MAP (line 47) | get MAP() {
  constant XMLTV_PADDING (line 462) | const XMLTV_PADDING = process.env.XMLTV_PADDING?.toLowerCase() === 'fals...
  type Channel (line 463) | interface Channel {

FILE: services/database.ts
  type IDocument (line 12) | interface IDocument {

FILE: services/debug.ts
  class Debug (line 8) | class Debug {
    method constructor (line 11) | constructor() {

FILE: services/espn-handler.ts
  type IAuthResources (line 58) | interface IAuthResources {
  type IEndpoint (line 62) | interface IEndpoint {
  type IAppConfig (line 70) | interface IAppConfig {
  type IToken (line 97) | interface IToken {
  type IGrant (line 103) | interface IGrant {
  type ITokens (line 108) | interface ITokens extends IToken {
  type IEspnPlusMeta (line 115) | interface IEspnPlusMeta {
  type IEspnMeta (line 121) | interface IEspnMeta {
  constant ADOBE_KEY (line 129) | const ADOBE_KEY = ['g', 'B', '8', 'H', 'Y', 'd', 'E', 'P', 'y', 'e', 'z'...
  constant ADOBE_PUBLIC_KEY (line 131) | const ADOBE_PUBLIC_KEY = [
  constant ANDROID_ID (line 166) | const ANDROID_ID = 'ESPN-OTT.GC.ANDTV-PROD';
  constant DISNEY_ROOT_URL (line 168) | const DISNEY_ROOT_URL = 'https://registerdisney.go.com/jgc/v6/client';
  constant API_KEY_URL (line 169) | const API_KEY_URL = '/{id-provider}/api-key?langPref=en-US';
  constant LICENSE_PLATE_URL (line 170) | const LICENSE_PLATE_URL = '/{id-provider}/license-plate';
  constant REFRESH_AUTH_URL (line 171) | const REFRESH_AUTH_URL = '/{id-provider}/guest/refresh-auth?langPref=en-...
  constant BAM_API_KEY (line 173) | const BAM_API_KEY = 'ZXNwbiZicm93c2VyJjEuMC4w.ptUt7QxsteaRruuPmGZFaJByOo...
  constant BAM_APP_CONFIG (line 174) | const BAM_APP_CONFIG =
  constant LINEAR_NETWORKS (line 177) | const LINEAR_NETWORKS = ['espn1', 'espn2', 'espnu', 'sec', 'acc', 'espne...
  class WebSocketPlus (line 299) | class WebSocketPlus {
  class EspnHandler (line 470) | class EspnHandler {
  type TESPNPlusTokens (line 1571) | type TESPNPlusTokens = Omit<ClassTypeWithoutMethods<EspnHandler>, 'adobe...
  type TESPNTokens (line 1572) | type TESPNTokens = Pick<ClassTypeWithoutMethods<EspnHandler>, 'adobe_dev...

FILE: services/flo-handler.ts
  type IFloEventsRes (line 15) | interface IFloEventsRes {
  type IFloEvent (line 23) | interface IFloEvent {
  class FloSportsHandler (line 90) | class FloSportsHandler {
  type TFloSportsTokens (line 354) | type TFloSportsTokens = ClassTypeWithoutMethods<FloSportsHandler>;

FILE: services/fox-handler.ts
  type IAppConfig (line 18) | interface IAppConfig {
  type IAdobePrelimAuthToken (line 38) | interface IAdobePrelimAuthToken {
  type IFoxEvent (line 46) | interface IFoxEvent {
  type IFoxEventsData (line 68) | interface IFoxEventsData {
  type IFoxMeta (line 77) | interface IFoxMeta {
  constant EPG_API_KEY (line 84) | const EPG_API_KEY = [
  constant FOX_APP_CONFIG (line 214) | const FOX_APP_CONFIG = 'https://config.foxdcg.com/foxsports/androidtv-na...
  class FoxHandler (line 231) | class FoxHandler {
  type TFoxTokens (line 741) | type TFoxTokens = ClassTypeWithoutMethods<FoxHandler>;

FILE: services/foxone-handler.ts
  type IAppConfig (line 18) | interface IAppConfig {
  type IAdobePrelimAuthToken (line 37) | interface IAdobePrelimAuthToken {
  type IFoxOneEvent (line 45) | interface IFoxOneEvent {
  type IFoxOneMeta (line 75) | interface IFoxOneMeta {
  constant FOXONE_APP_CONFIG (line 179) | const FOXONE_APP_CONFIG = 'https://config.foxplus.com/androidtv/1.3/conf...
  class FoxOneHandler (line 194) | class FoxOneHandler {
    method getLocation (line 577) | public async getLocation(): Promise<void> {
    method getUserEntitlements (line 595) | public async getUserEntitlements(): Promise<void> {
  type TFoxOneTokens (line 1017) | type TFoxOneTokens = ClassTypeWithoutMethods<FoxOneHandler>;

FILE: services/gotham-channels.ts
  constant BASE_PERMISSIONS (line 1) | const BASE_PERMISSIONS = ['urn:package:superuser', 'urn:package:dtc:bund...
  constant YES_PERMISSIONS (line 3) | const YES_PERMISSIONS = [
  constant MSG_PERMISSIONS (line 11) | const MSG_PERMISSIONS = [
  type IMSGChannel (line 19) | interface IMSGChannel {
  type IMSGChannelGroup (line 30) | interface IMSGChannelGroup {
  type IMSGChannelMap (line 34) | interface IMSGChannelMap {
  constant YES (line 38) | const YES = {
  constant MSG_LINEAR (line 50) | const MSG_LINEAR: IMSGChannelMap = {

FILE: services/gotham-handler.ts
  constant API_KEY (line 14) | const API_KEY = [
  constant CLIENT_SECRET (line 43) | const CLIENT_SECRET = [
  constant PLAYBACK_CLIENT_SECRET (line 82) | const PLAYBACK_CLIENT_SECRET = [
  constant BASE_API_URL (line 121) | const BASE_API_URL = ['https://', 'api.gothamsports.com', '/proxy'].join...
  constant BASE_ADOBE_URL (line 122) | const BASE_ADOBE_URL = ['https://', 'api.auth', '.adobe.com', '/api/v1']...
  type IAppConfig (line 124) | interface IAppConfig {
  type IEntitlements (line 133) | interface IEntitlements {
  type ISigningRes (line 144) | interface ISigningRes {
  type IAdobeUserMetadata (line 150) | interface IAdobeUserMetadata {
  class GothamHandler (line 228) | class GothamHandler {
  type TGothamTokens (line 1076) | type TGothamTokens = ClassTypeWithoutMethods<GothamHandler>;

FILE: services/hudl-handler.ts
  type IHudlEvent (line 10) | interface IHudlEvent {
  type IHudlConference (line 23) | interface IHudlConference {
  type IHudlSite (line 30) | interface IHudlSite {
  type IHudlMeta (line 37) | interface IHudlMeta {
  class HudlHandler (line 289) | class HudlHandler {

FILE: services/jsdom-helper.ts
  type IDom (line 7) | interface IDom {
  method constructor (line 28) | constructor() {

FILE: services/kbo-handler.ts
  constant SOOP_CATEGORY_ID (line 11) | const SOOP_CATEGORY_ID = '90';
  type IKBOEvent (line 13) | interface IKBOEvent {
  type IKBOMeta (line 21) | interface IKBOMeta {
  class KBOHandler (line 65) | class KBOHandler {

FILE: services/ksl-handler.ts
  type IKSLEvent (line 43) | interface IKSLEvent {
  class KSLHandler (line 98) | class KSLHandler {

FILE: services/midco-handler.ts
  type IMidcoEvent (line 11) | interface IMidcoEvent {
  type IMidcoMeta (line 36) | interface IMidcoMeta {
  constant ORIGIN (line 41) | const ORIGIN = [
  constant REFERRER (line 47) | const REFERRER = [
  constant BASE_API_URL (line 51) | const BASE_API_URL = [
  constant API_COLLECTION (line 57) | const API_COLLECTION = [
  class MidcoHandler (line 137) | class MidcoHandler {
  type TMidcoTokens (line 373) | type TMidcoTokens = ClassTypeWithoutMethods<MidcoHandler>;

FILE: services/misc-db-service.ts
  constant BUFFER_CHANNELS (line 7) | const BUFFER_CHANNELS = 50;

FILE: services/mlb-handler.ts
  type IGameContent (line 17) | interface IGameContent {
  type IMLBNetworkEvent (line 42) | interface IMLBNetworkEvent {
  type TSNYEvent (line 55) | type TSNYEvent = [string, string, string, string, string];
  type ISNYSchedule (line 57) | interface ISNYSchedule {
  type ISNLAProgram (line 66) | interface ISNLAProgram {
  type ISNLAEvent (line 71) | interface ISNLAEvent {
  type ISNLAEventCombined (line 78) | interface ISNLAEventCombined extends ISNLAEvent, ISNLAProgram {}
  type ISNLAScheduleRes (line 80) | interface ISNLAScheduleRes {
  type ITeam (line 87) | interface ITeam {
  type IGame (line 94) | interface IGame {
  type ISchedule (line 105) | interface ISchedule {
  type IVideoFeed (line 111) | interface IVideoFeed {
  type IGameFeed (line 118) | interface IGameFeed {
  type ICombinedGame (line 124) | interface ICombinedGame {
  type IProviderMeta (line 131) | interface IProviderMeta {
  type IEntitlement (line 135) | interface IEntitlement {
  constant CLIENT_ID (line 139) | const CLIENT_ID = [
  constant GRAPHQL_URL (line 162) | const GRAPHQL_URL = ['https://', 'media-gateway.mlb.com', '/graphql'].jo...
  constant LINEAR_CHANNELS (line 164) | const LINEAR_CHANNELS = [
  constant COMMON_HEADERS (line 407) | const COMMON_HEADERS = {
  class MLBHandler (line 424) | class MLBHandler {
  type TMLBTokens (line 1203) | type TMLBTokens = ClassTypeWithoutMethods<MLBHandler>;

FILE: services/mw-handler.ts
  type IMWCategory (line 12) | interface IMWCategory {
  type IMWEvent (line 16) | interface IMWEvent {
  class MountainWestHandler (line 76) | class MountainWestHandler {
  type TMWTokens (line 251) | type TMWTokens = ClassTypeWithoutMethods<MountainWestHandler>;

FILE: services/networks.ts
  method cbsSportsHq (line 32) | get cbsSportsHq(): boolean {
  method golazo (line 35) | get golazo(): boolean {
  method channel (line 49) | get channel(): boolean {
  method network (line 52) | get network(): boolean {
  method network (line 55) | set network(value: boolean) {
  method peacock (line 58) | get peacock(): boolean {
  method prime (line 62) | get prime(): boolean {
  method redZone (line 65) | get redZone(): boolean {
  method redZone (line 68) | set redZone(value: boolean) {
  method sundayTicket (line 71) | get sundayTicket(): boolean {
  method tve (line 74) | get tve(): boolean {

FILE: services/nfl-handler.ts
  type INFLRes (line 17) | interface INFLRes {
  type INFLChannelRes (line 23) | interface INFLChannelRes {
  type INFLEvent (line 27) | interface INFLEvent {
  constant CLIENT_KEY (line 48) | const CLIENT_KEY = [
  constant TV_CLIENT_KEY (line 83) | const TV_CLIENT_KEY = [
  constant CLIENT_SECRET (line 118) | const CLIENT_SECRET = ['q', 'G', 'h', 'E', 'v', '1', 'R', 't', 'I', '2',...
  constant TV_CLIENT_SECRET (line 120) | const TV_CLIENT_SECRET = ['u', 'o', 'C', 'y', 'y', 'k', 'y', 'U', 'w', '...
  constant DEVICE_INFO (line 122) | const DEVICE_INFO = {
  constant TV_DEVICE_INFO (line 141) | const TV_DEVICE_INFO = {
  constant DEFAULT_CATEGORIES (line 160) | const DEFAULT_CATEGORIES = ['NFL', 'NFL+', 'Football'];
  type TOtherAuth (line 164) | type TOtherAuth = 'prime' | 'tve' | 'peacock' | 'sunday_ticket';
  type INFLJwt (line 166) | interface INFLJwt {
  class NflHandler (line 230) | class NflHandler {
  type TNFLTokens (line 1026) | type TNFLTokens = ClassTypeWithoutMethods<NflHandler>;

FILE: services/nhltv-handler.ts
  type ICompetetitor (line 11) | interface ICompetetitor {
  type IImage (line 15) | interface IImage {
  type IContent (line 20) | interface IContent {
  type INHLEvent (line 33) | interface INHLEvent {
  type INHLEventSimple (line 41) | interface INHLEventSimple {
  constant BASE_API (line 50) | const BASE_API = 'https://nhltv.nhl.com/api';
  constant COMMON_HEADERS (line 52) | const COMMON_HEADERS = {
  class NHLHandler (line 119) | class NHLHandler {
  type TNHLTokens (line 356) | type TNHLTokens = ClassTypeWithoutMethods<NHLHandler>;

FILE: services/nwsl-handler.ts
  type INswlLinearRes (line 12) | interface INswlLinearRes {
  type INwslLinearEvent (line 19) | interface INwslLinearEvent {
  type INwslHomeRes (line 27) | interface INwslHomeRes {
  type INwslEvent (line 34) | interface INwslEvent {
  type INwslMeta (line 42) | interface INwslMeta {
  constant BASE_API_URL (line 47) | const BASE_API_URL = 'https://dce-frontoffice.imggaming.com/api';
  constant REFERRER (line 49) | const REFERRER = 'https://plus.nwslsoccer.com/';
  constant REALM (line 50) | const REALM = 'dce.nwsl';
  constant API_KEY (line 52) | const API_KEY = [
  constant APP_VAR (line 91) | const APP_VAR = '6.57.11.b0bf548';
  class NwslHandler (line 142) | class NwslHandler {
  type TNwslTokens (line 468) | type TNwslTokens = ClassTypeWithoutMethods<NwslHandler>;

FILE: services/outside-handler.ts
  constant APP_KEY (line 12) | const APP_KEY = [
  constant APP_ID (line 47) | const APP_ID = '247';
  constant APP_PLATFORM (line 48) | const APP_PLATFORM = 'android_tv';
  constant APP_LANGUAGE (line 49) | const APP_LANGUAGE = 'en';
  constant BASE_API_URL (line 51) | const BASE_API_URL = 'https://api.maz.tv';
  type IOutsideEvent (line 53) | interface IOutsideEvent {
  type IOutsideSchedule (line 69) | interface IOutsideSchedule {
  class OutsideHandler (line 162) | class OutsideHandler {
  type TOutsideTokens (line 403) | type TOutsideTokens = ClassTypeWithoutMethods<OutsideHandler>;

FILE: services/paramount-handler.ts
  constant BASE_THUMB_URL (line 18) | const BASE_THUMB_URL = 'https://wwwimage-us.pplusstatic.com/thumbnails/p...
  constant BASE_URL (line 19) | const BASE_URL = 'https://www.paramountplus.com';
  constant TOKEN (line 20) | const TOKEN = [
  type IParamountUserProfile (line 95) | interface IParamountUserProfile {
  type IParamountUser (line 100) | interface IParamountUser {
  type IParamountEvent (line 105) | interface IParamountEvent {
  type IDma (line 116) | interface IDma {
  type IChannel (line 124) | interface IChannel {
  constant ALLOWED_LOCAL_SPORTS (line 133) | const ALLOWED_LOCAL_SPORTS = ['College Basketball', 'College Football', ...
  class ParamountHandler (line 184) | class ParamountHandler {
  type TParamountTokens (line 764) | type TParamountTokens = ClassTypeWithoutMethods<ParamountHandler>;

FILE: services/playlist-handler.ts
  class PlaylistHandler (line 86) | class PlaylistHandler {
    method constructor (line 100) | constructor(headers: THeaderInfo, appUrl: string, channel: string, net...
    method initialize (line 109) | public async initialize(manifestUrl: string): Promise<void> {
    method getSegmentOrKey (line 114) | public async getSegmentOrKey(segmentId: string): Promise<ArrayBuffer> {
    method parseManifest (line 125) | public async parseManifest(manifestUrl: string, headers: IHeaders): Pr...
    method cacheChunklist (line 326) | public cacheChunklist(chunklistId: string): Promise<string> {
    method proxyChunklist (line 334) | private async proxyChunklist(chunkListId: string): Promise<string> {
    method getHeaders (line 445) | private async getHeaders(): Promise<IHeaders> {

FILE: services/port.ts
  constant SERVER_PORT (line 8) | const SERVER_PORT = serverPort;

FILE: services/providers/b1g/views/CardBody.tsx
  type IB1GBodyProps (line 5) | interface IB1GBodyProps {

FILE: services/providers/b1g/views/Login.tsx
  type ILoginProps (line 3) | interface ILoginProps {

FILE: services/providers/bally/views/CardBody.tsx
  type IBallyBodyProps (line 6) | interface IBallyBodyProps {

FILE: services/providers/cbs-sports/views/CardBody.tsx
  type ICBSBodyProps (line 5) | interface ICBSBodyProps {

FILE: services/providers/cbs-sports/views/Login.tsx
  type ILogin (line 5) | interface ILogin {

FILE: services/providers/espn-plus/views/CardBody.tsx
  type IESPNPlusBodyProps (line 5) | interface IESPNPlusBodyProps {

FILE: services/providers/espn-plus/views/Login.tsx
  type ILoginProps (line 5) | interface ILoginProps {

FILE: services/providers/espn/views/CardBody.tsx
  type IESPNBodyProps (line 6) | interface IESPNBodyProps {

FILE: services/providers/espn/views/Login.tsx
  type ILogin (line 5) | interface ILogin {

FILE: services/providers/flosports/views/CardBody.tsx
  type IFloSportsBodyProps (line 5) | interface IFloSportsBodyProps {

FILE: services/providers/flosports/views/Login.tsx
  type ILogin (line 5) | interface ILogin {

FILE: services/providers/fox/views/CardBody.tsx
  type IFoxBodyProps (line 6) | interface IFoxBodyProps {

FILE: services/providers/fox/views/Login.tsx
  type ILogin (line 5) | interface ILogin {

FILE: services/providers/foxone/views/CardBody.tsx
  type IFoxOneBodyProps (line 6) | interface IFoxOneBodyProps {

FILE: services/providers/foxone/views/Login.tsx
  type ILogin (line 5) | interface ILogin {

FILE: services/providers/gotham/views/CardBody.tsx
  type IGothamBodyProps (line 6) | interface IGothamBodyProps {

FILE: services/providers/gotham/views/Login.tsx
  type ILoginProps (line 3) | interface ILoginProps {

FILE: services/providers/gotham/views/TveLogin.tsx
  type ILogin (line 5) | interface ILogin {

FILE: services/providers/hudl/views/CardBody.tsx
  type IHudlBodyProps (line 5) | interface IHudlBodyProps {

FILE: services/providers/midco/views/CardBody.tsx
  type IMidcoBodyProps (line 5) | interface IMidcoBodyProps {

FILE: services/providers/midco/views/Login.tsx
  type ILoginProps (line 3) | interface ILoginProps {

FILE: services/providers/mlb/views/CardBody.tsx
  type IMLBBodyProps (line 6) | interface IMLBBodyProps {

FILE: services/providers/mlb/views/Login.tsx
  type ILogin (line 5) | interface ILogin {

FILE: services/providers/mw/views/CardBody.tsx
  type IMWBodyProps (line 5) | interface IMWBodyProps {

FILE: services/providers/nfl/views/CardBody.tsx
  type INFLBodyProps (line 7) | interface INFLBodyProps {

FILE: services/providers/nfl/views/Login.tsx
  type ILogin (line 5) | interface ILogin {

FILE: services/providers/nhl-tv/views/CardBody.tsx
  type INHLBodyProps (line 5) | interface INHLBodyProps {

FILE: services/providers/nhl-tv/views/Login.tsx
  type ILogin (line 5) | interface ILogin {

FILE: services/providers/nwsl/views/CardBody.tsx
  type INwslBodyProps (line 7) | interface INwslBodyProps {

FILE: services/providers/nwsl/views/Login.tsx
  type ILoginProps (line 3) | interface ILoginProps {

FILE: services/providers/outside/views/CardBody.tsx
  type IOutsideBodyProps (line 7) | interface IOutsideBodyProps {

FILE: services/providers/outside/views/Login.tsx
  type ILogin (line 5) | interface ILogin {

FILE: services/providers/paramount/views/CardBody.tsx
  type IParamountBodyProps (line 7) | interface IParamountBodyProps {

FILE: services/providers/paramount/views/Login.tsx
  type ILogin (line 5) | interface ILogin {

FILE: services/providers/victory/views/CardBody.tsx
  type IVictoryBodyProps (line 5) | interface IVictoryBodyProps {

FILE: services/providers/victory/views/Login.tsx
  type ILogin (line 5) | interface ILogin {

FILE: services/providers/wnba/views/CardBody.tsx
  type IWNBABodyProps (line 5) | interface IWNBABodyProps {

FILE: services/providers/wnba/views/Login.tsx
  type ILoginProps (line 3) | interface ILoginProps {

FILE: services/pwhl-handler.ts
  constant YT_CHANNEL (line 11) | const YT_CHANNEL = 'UCNKUkQV2R0JKakyE1vuC1lQ';
  type IPWHLEvent (line 13) | interface IPWHLEvent {
  class PWHLHandler (line 65) | class PWHLHandler {

FILE: services/shared-interfaces.ts
  type IChannelStatus (line 1) | interface IChannelStatus {
  type IManifestPlayer (line 8) | interface IManifestPlayer {
  type IHeaders (line 16) | interface IHeaders {
  type IStringObj (line 20) | interface IStringObj {
  type IAppStatus (line 24) | interface IAppStatus {
  type IJWToken (line 30) | interface IJWToken {
  type IEntry (line 36) | interface IEntry {
  type IChannel (line 56) | interface IChannel {
  type IProviderChannel (line 61) | interface IProviderChannel {
  type IProvider (line 69) | interface IProvider<T = any, M = any> {
  type IMiscDbEntry (line 77) | interface IMiscDbEntry<T = string | number | boolean, M = any> {
  type THeaderInfo (line 83) | type THeaderInfo = IHeaders | ((eventId: string | number, currentHeaders...
  type TChannelPlaybackInfo (line 85) | type TChannelPlaybackInfo = [string, THeaderInfo];
  type ClassTypeWithoutMethods (line 87) | type ClassTypeWithoutMethods<T> = Omit<
  type IReleaseData (line 94) | interface IReleaseData {

FILE: services/template-handler.ts
  type ITemplateEvent (line 10) | interface ITemplateEvent {
  class TemplateHandler (line 58) | class TemplateHandler {
  type TTemplateTokens (line 207) | type TTemplateTokens = ClassTypeWithoutMethods<TemplateHandler>;

FILE: services/tubi-helper.ts
  type ITubiRes (line 5) | interface ITubiRes {
  type ITubiEvent (line 15) | interface ITubiEvent {

FILE: services/victory-handler.ts
  type IVictoryEvent (line 10) | interface IVictoryEvent {
  constant BASE_URL (line 23) | const BASE_URL = 'https://api.sports.aparentmedia.com/api/2.0';
  constant DEVICE_INFO (line 25) | const DEVICE_INFO = {
  class VictoryHandler (line 132) | class VictoryHandler {
  type TVictoryTokens (line 341) | type TVictoryTokens = ClassTypeWithoutMethods<VictoryHandler>;

FILE: services/wnba-handler.ts
  type IWNBABroadcast (line 10) | interface IWNBABroadcast {
  type IWNBATeam (line 15) | interface IWNBATeam {
  type IWNBAEvent (line 21) | interface IWNBAEvent {
  type IWNBASchedule (line 33) | interface IWNBASchedule {
  type IWNBAMeta (line 41) | interface IWNBAMeta {
  type IWNBAEventDetail (line 46) | interface IWNBAEventDetail {
  type IWNBAStreamCallback (line 56) | interface IWNBAStreamCallback {
  constant API_KEY (line 62) | const API_KEY = [
  constant APP_VAR (line 101) | const APP_VAR = '18.1.0';
  constant BASE_API_URL (line 103) | const BASE_API_URL = 'https://dce-frontoffice.imggaming.com/api';
  class WNBAHandler (line 185) | class WNBAHandler {
  type TWNBATokens (line 359) | type TWNBATokens = ClassTypeWithoutMethods<WNBAHandler>;

FILE: services/wsn-handler.ts
  class WomensSportsNetworkHandler (line 53) | class WomensSportsNetworkHandler {

FILE: services/yt-dlp-helper.ts
  type ILiveStream (line 5) | interface ILiveStream {

FILE: services/zeam-handler.ts
  type IZeamEvent (line 63) | interface IZeamEvent {
  class ZeamHandler (line 108) | class ZeamHandler {

FILE: views/Layout.tsx
  type ILayoutProps (line 3) | interface ILayoutProps {

FILE: views/Links.tsx
  type ILinksProps (line 5) | interface ILinksProps {

FILE: views/Main.tsx
  type IMainProps (line 3) | interface IMainProps {

FILE: views/Providers.tsx
  type IProvidersProps (line 3) | interface IProvidersProps {
Condensed preview — 162 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (739K chars).
[
  {
    "path": ".dockerignore",
    "chars": 54,
    "preview": "node_modules/\nconfig/\ntmp/\nplayroom/\nrun*.sh\n.DS_Store"
  },
  {
    "path": ".editorconfig",
    "chars": 244,
    "preview": "# http://editorconfig.org\n\nroot = true\n\n[*]\ncharset = utf-8\nindent_style = space\nindent_size = 2\nend_of_line = lf\ninsert"
  },
  {
    "path": ".eslintrc.json",
    "chars": 642,
    "preview": "{\n  \"parser\": \"@typescript-eslint/parser\",\n  \"parserOptions\": {\n    \"ecmaVersion\": 2018,\n    \"sourceType\": \"module\",\n   "
  },
  {
    "path": ".github/workflows/push-docker-hub.yml",
    "chars": 2834,
    "preview": "name: ci\n\non:\n  push:\n    tags:\n      - '*'\n\njobs:\n  docker:\n    runs-on: ubuntu-latest\n    if: github.event_name == 'pu"
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 1293,
    "preview": "name: Create Release on Tag Push\n\non:\n  push:\n    tags:\n      - '*'\n\njobs:\n  create_release:\n    runs-on: ubuntu-latest\n"
  },
  {
    "path": ".gitignore",
    "chars": 2433,
    "preview": "\n# Created by https://www.toptal.com/developers/gitignore/api/node,visualstudiocode\n# Edit at https://www.toptal.com/dev"
  },
  {
    "path": ".husky/pre-commit",
    "chars": 58,
    "preview": "#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\nnpx lint-staged\n"
  },
  {
    "path": ".nvmrc",
    "chars": 3,
    "preview": "18\n"
  },
  {
    "path": ".prettierrc.json",
    "chars": 364,
    "preview": "{\n  \"arrowParens\": \"avoid\",\n  \"bracketSpacing\": false,\n  \"embeddedLanguageFormatting\": \"auto\",\n  \"htmlWhitespaceSensitiv"
  },
  {
    "path": "Dockerfile",
    "chars": 293,
    "preview": "FROM alpine:latest\n\nRUN mkdir -p /etc/udhcpc ; echo 'RESOLV_CONF=\"no\"' >> /etc/udhcpc/udhcpc.conf\n\nRUN apk add --update "
  },
  {
    "path": "LICENSE",
    "chars": 1046,
    "preview": "The MIT License (MIT)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and"
  },
  {
    "path": "README.md",
    "chars": 8652,
    "preview": "<p align=\"center\">\n  <img src=\"https://i.imgur.com/FIGZdR3.png\">\n</p>\n\nCurrent version: **4.15.6**\n\n# About\nThis takes p"
  },
  {
    "path": "entrypoint.sh",
    "chars": 183,
    "preview": "#!/bin/sh\n\nif [ -z \"$PUID\" ] || [ -z \"$PGID\" ]; then\n  exec npm start\nelse\n  adduser -u $PUID -D abc\n  groupmod -g $PGID"
  },
  {
    "path": "index.tsx",
    "chars": 20531,
    "preview": "import {Context, Hono} from 'hono';\nimport {serve} from '@hono/node-server';\nimport {serveStatic} from '@hono/node-serve"
  },
  {
    "path": "package.json",
    "chars": 1569,
    "preview": "{\n  \"name\": \"eplustv\",\n  \"version\": \"4.15.6\",\n  \"description\": \"\",\n  \"scripts\": {\n    \"start\": \"ts-node -r tsconfig-path"
  },
  {
    "path": "services/adobe-helpers.ts",
    "chars": 2148,
    "preview": "import crypto from 'crypto';\n\nimport {getRandomHex} from './shared-helpers';\n\nexport interface IAdobeAuth {\n  expires: s"
  },
  {
    "path": "services/app-status.ts",
    "chars": 107,
    "preview": "import {IAppStatus} from './shared-interfaces';\n\nexport const appStatus: IAppStatus = {\n  channels: {},\n};\n"
  },
  {
    "path": "services/b1g-handler.ts",
    "chars": 11909,
    "preview": "import fs from 'fs';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport moment f"
  },
  {
    "path": "services/bally-handler.ts",
    "chars": 8675,
    "preview": "import axios from 'axios';\nimport moment from 'moment';\n\nimport {userAgent} from './user-agent';\nimport {ClassTypeWithou"
  },
  {
    "path": "services/build-schedule.ts",
    "chars": 4540,
    "preview": "import {db, IDocument} from './database';\nimport {getNumberOfChannels, getStartChannel, usesLinear, getCategoryFilter, g"
  },
  {
    "path": "services/caching.ts",
    "chars": 3677,
    "preview": "import axios, {AxiosResponse} from 'axios';\n\nimport {generateRandom} from './shared-helpers';\nimport {IHeaders} from './"
  },
  {
    "path": "services/cbs-handler.ts",
    "chars": 12996,
    "preview": "import fs from 'fs';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport moment f"
  },
  {
    "path": "services/channels.ts",
    "chars": 16276,
    "preview": "import _ from 'lodash';\n\nimport {foxOneHandler} from './foxone-handler';\nimport {db} from './database';\nimport {IProvide"
  },
  {
    "path": "services/config.ts",
    "chars": 88,
    "preview": "import path from 'path';\n\nexport const configPath = path.join(process.cwd(), 'config');\n"
  },
  {
    "path": "services/database.ts",
    "chars": 1017,
    "preview": "import fs from 'fs';\nimport path from 'path';\nimport Datastore from '@seald-io/nedb';\n\nimport {configPath} from './confi"
  },
  {
    "path": "services/debug.ts",
    "chars": 594,
    "preview": "import path from 'path';\nimport fsExtra from 'fs-extra';\n\nimport {configPath} from './config';\n\nexport const debugPath ="
  },
  {
    "path": "services/espn-handler.ts",
    "chars": 51881,
    "preview": "import fs from 'fs';\nimport https from 'https';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios fr"
  },
  {
    "path": "services/flo-handler.ts",
    "chars": 10057,
    "preview": "import fs from 'fs';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport moment f"
  },
  {
    "path": "services/fox-handler.ts",
    "chars": 20520,
    "preview": "import fs from 'fs';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport _ from '"
  },
  {
    "path": "services/foxone-handler.ts",
    "chars": 31055,
    "preview": "import fs from 'fs';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport _ from '"
  },
  {
    "path": "services/generate-m3u.ts",
    "chars": 3880,
    "preview": "import _ from 'lodash';\nimport moment from 'moment-timezone';\n\nimport {db} from './database';\nimport {CHANNELS} from './"
  },
  {
    "path": "services/generate-xmltv.ts",
    "chars": 26072,
    "preview": "import _ from 'lodash';\nimport xml from 'xml';\nimport moment from 'moment';\n\nimport {db} from './database';\nimport {calc"
  },
  {
    "path": "services/gotham-channels.ts",
    "chars": 10782,
    "preview": "const BASE_PERMISSIONS = ['urn:package:superuser', 'urn:package:dtc:bundle:monthly', 'urn:package:dtc:bundle:annual'];\n\n"
  },
  {
    "path": "services/gotham-handler.ts",
    "chars": 28861,
    "preview": "import axios from 'axios';\nimport _ from 'lodash';\nimport jwt_decode from 'jwt-decode';\nimport moment from 'moment';\nimp"
  },
  {
    "path": "services/hudl-handler.ts",
    "chars": 13715,
    "preview": "import axios from 'axios';\nimport moment from 'moment';\n\nimport {userAgent} from './user-agent';\nimport {IEntry, IProvid"
  },
  {
    "path": "services/init-directories.ts",
    "chars": 732,
    "preview": "import fs from 'fs';\n\nimport {configPath} from './config';\nimport {\n  entriesDb,\n  initializeEntries,\n  scheduleDb,\n  in"
  },
  {
    "path": "services/jsdom-helper.ts",
    "chars": 661,
    "preview": "import jsdom from 'jsdom';\n\nimport {userAgent} from './user-agent';\n\nconst {JSDOM} = jsdom;\n\ninterface IDom {\n  serializ"
  },
  {
    "path": "services/kbo-handler.ts",
    "chars": 8511,
    "preview": "import moment, {Moment} from 'moment-timezone';\nimport * as cheerio from 'cheerio';\n\nimport {userAgent} from './user-age"
  },
  {
    "path": "services/ksl-handler.ts",
    "chars": 4268,
    "preview": "import moment from 'moment';\n\nimport {userAgent} from './user-agent';\nimport {IEntry, IProvider, TChannelPlaybackInfo} f"
  },
  {
    "path": "services/launch-channel.ts",
    "chars": 7330,
    "preview": "import {db} from './database';\nimport {espnHandler} from './espn-handler';\nimport {foxHandler} from './fox-handler';\nimp"
  },
  {
    "path": "services/midco-handler.ts",
    "chars": 8924,
    "preview": "import axios from 'axios';\nimport moment from 'moment';\n\nimport {userAgent} from './user-agent';\nimport {ClassTypeWithou"
  },
  {
    "path": "services/misc-db-service.ts",
    "chars": 9271,
    "preview": "import _ from 'lodash';\n\nimport {db} from './database';\nimport {IMiscDbEntry, IProvider} from './shared-interfaces';\nimp"
  },
  {
    "path": "services/mlb-handler.ts",
    "chars": 34966,
    "preview": "import fs from 'fs';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport moment, "
  },
  {
    "path": "services/mw-handler.ts",
    "chars": 6359,
    "preview": "import axios from 'axios';\nimport _ from 'lodash';\nimport moment from 'moment';\n\nimport {okHttpUserAgent} from './user-a"
  },
  {
    "path": "services/networks.ts",
    "chars": 3632,
    "preview": "export const useEspn1 = process.env.ESPN?.toLowerCase() === 'true' ? true : false;\nexport const useEspn2 = process.env.E"
  },
  {
    "path": "services/nfl-handler.ts",
    "chars": 27525,
    "preview": "import fs from 'fs';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport moment f"
  },
  {
    "path": "services/nhltv-handler.ts",
    "chars": 8739,
    "preview": "import axios from 'axios';\nimport moment from 'moment';\nimport _ from 'lodash';\n\nimport {nhlTvUserAgent} from './user-ag"
  },
  {
    "path": "services/nwsl-handler.ts",
    "chars": 11363,
    "preview": "import axios from 'axios';\nimport moment from 'moment';\nimport jwt_decode from 'jwt-decode';\n\nimport {userAgent} from '."
  },
  {
    "path": "services/outside-handler.ts",
    "chars": 9503,
    "preview": "import axios from 'axios';\nimport moment from 'moment';\n\nimport {okHttpUserAgent} from './user-agent';\nimport {ClassType"
  },
  {
    "path": "services/paramount-handler.ts",
    "chars": 19226,
    "preview": "import fs from 'fs';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport _ from '"
  },
  {
    "path": "services/playlist-handler.ts",
    "chars": 17751,
    "preview": "import HLS from 'hls-parser';\nimport axios from 'axios';\nimport _ from 'lodash';\n\nimport {userAgent} from './user-agent'"
  },
  {
    "path": "services/port.ts",
    "chars": 162,
    "preview": "import _ from 'lodash';\n\nlet serverPort = _.toNumber(process.env.PORT);\nif (_.isNaN(serverPort)) {\n  serverPort = 8000;\n"
  },
  {
    "path": "services/providers/b1g/index.tsx",
    "chars": 2496,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {B1GBod"
  },
  {
    "path": "services/providers/b1g/views/CardBody.tsx",
    "chars": 716,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TB1GTokens} from '@/services/b1g-handler';\n\ninterface IB1GBodyProps {\n  enabled: b"
  },
  {
    "path": "services/providers/b1g/views/Login.tsx",
    "chars": 1762,
    "preview": "import {FC} from 'hono/jsx';\n\ninterface ILoginProps {\n  invalid?: boolean;\n}\n\nexport const Login: FC<ILoginProps> = asyn"
  },
  {
    "path": "services/providers/b1g/views/index.tsx",
    "chars": 1203,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/bally/index.tsx",
    "chars": 2199,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {IProvider} from '@/services/shared-interfac"
  },
  {
    "path": "services/providers/bally/views/CardBody.tsx",
    "chars": 1803,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {usesLinear} from '@/services/misc-db-service';\nimport {IProviderChannel} from '@/s"
  },
  {
    "path": "services/providers/bally/views/index.tsx",
    "chars": 1360,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/cbs-sports/index.tsx",
    "chars": 1663,
    "preview": "import {Hono} from 'hono';\n\nimport {Login} from './views/Login';\nimport {CBSBody} from './views/CardBody';\n\nimport {db} "
  },
  {
    "path": "services/providers/cbs-sports/views/CardBody.tsx",
    "chars": 716,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TCBSTokens} from '@/services/cbs-handler';\n\ninterface ICBSBodyProps {\n  enabled: b"
  },
  {
    "path": "services/providers/cbs-sports/views/Login.tsx",
    "chars": 870,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {cbsHandler} from '@/services/cbs-handler';\n\ninterface ILogin {\n  code?: string;\n}\n"
  },
  {
    "path": "services/providers/cbs-sports/views/index.tsx",
    "chars": 1220,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/espn/index.tsx",
    "chars": 5840,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {IProvi"
  },
  {
    "path": "services/providers/espn/views/CardBody.tsx",
    "chars": 1696,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {IEspnMeta, TESPNTokens} from '@/services/espn-handler';\nimport {IProviderChannel} "
  },
  {
    "path": "services/providers/espn/views/Login.tsx",
    "chars": 885,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {espnHandler} from '@/services/espn-handler';\n\ninterface ILogin {\n  code?: string;\n"
  },
  {
    "path": "services/providers/espn/views/index.tsx",
    "chars": 1298,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/espn-plus/index.tsx",
    "chars": 2824,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {IProvi"
  },
  {
    "path": "services/providers/espn-plus/views/CardBody.tsx",
    "chars": 752,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TESPNPlusTokens} from '@/services/espn-handler';\n\ninterface IESPNPlusBodyProps {\n "
  },
  {
    "path": "services/providers/espn-plus/views/Login.tsx",
    "chars": 930,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {espnHandler} from '@/services/espn-handler';\n\ninterface ILoginProps {\n  code?: str"
  },
  {
    "path": "services/providers/espn-plus/views/index.tsx",
    "chars": 3640,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/flosports/index.tsx",
    "chars": 1748,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {FloSpo"
  },
  {
    "path": "services/providers/flosports/views/CardBody.tsx",
    "chars": 758,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TFloSportsTokens} from '@/services/flo-handler';\n\ninterface IFloSportsBodyProps {\n"
  },
  {
    "path": "services/providers/flosports/views/Login.tsx",
    "chars": 886,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {floSportsHandler} from '@/services/flo-handler';\n\ninterface ILogin {\n  code?: stri"
  },
  {
    "path": "services/providers/flosports/views/index.tsx",
    "chars": 1283,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/fox/index.tsx",
    "chars": 4509,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {IProvi"
  },
  {
    "path": "services/providers/fox/views/CardBody.tsx",
    "chars": 1629,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TFoxTokens} from '@/services/fox-handler';\nimport {IProviderChannel} from '@/servi"
  },
  {
    "path": "services/providers/fox/views/Login.tsx",
    "chars": 884,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {foxHandler} from '@/services/fox-handler';\n\ninterface ILogin {\n  code?: string;\n  "
  },
  {
    "path": "services/providers/fox/views/index.tsx",
    "chars": 2426,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/foxone/index.tsx",
    "chars": 4565,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {IProvi"
  },
  {
    "path": "services/providers/foxone/views/CardBody.tsx",
    "chars": 1656,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TFoxOneTokens} from '@/services/foxone-handler';\nimport {IProviderChannel} from '@"
  },
  {
    "path": "services/providers/foxone/views/Login.tsx",
    "chars": 881,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {foxOneHandler} from '@/services/foxone-handler';\n\ninterface ILogin {\n  code?: stri"
  },
  {
    "path": "services/providers/foxone/views/index.tsx",
    "chars": 2346,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/gotham/index.tsx",
    "chars": 4217,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {TVELog"
  },
  {
    "path": "services/providers/gotham/views/CardBody.tsx",
    "chars": 1784,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TGothamTokens} from '@/services/gotham-handler';\nimport {IProviderChannel} from '@"
  },
  {
    "path": "services/providers/gotham/views/Login.tsx",
    "chars": 1792,
    "preview": "import {FC} from 'hono/jsx';\n\ninterface ILoginProps {\n  invalid?: boolean;\n}\n\nexport const Login: FC<ILoginProps> = asyn"
  },
  {
    "path": "services/providers/gotham/views/TveLogin.tsx",
    "chars": 1164,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {gothamHandler} from '@/services/gotham-handler';\n\ninterface ILogin {\n  link?: stri"
  },
  {
    "path": "services/providers/gotham/views/index.tsx",
    "chars": 1527,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/hudl/index.tsx",
    "chars": 2458,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {IProvider} from '@/services/shared-interfac"
  },
  {
    "path": "services/providers/hudl/views/CardBody.tsx",
    "chars": 1289,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {IHudlMeta} from '@/services/hudl-handler';\n\ninterface IHudlBodyProps {\n  enabled: "
  },
  {
    "path": "services/providers/hudl/views/index.tsx",
    "chars": 1094,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/index.ts",
    "chars": 1575,
    "preview": "import {Hono} from 'hono';\n\nimport {cbs} from './cbs-sports';\nimport {mw} from './mw';\nimport {wsn} from './wsn';\nimport"
  },
  {
    "path": "services/providers/kbo/index.tsx",
    "chars": 1008,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {IProvider} from '@/services/shared-interfac"
  },
  {
    "path": "services/providers/kbo/views/index.tsx",
    "chars": 1003,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/ksl/index.tsx",
    "chars": 932,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {IProvider} from '@/services/shared-interfac"
  },
  {
    "path": "services/providers/ksl/views/index.tsx",
    "chars": 1010,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/midco/index.tsx",
    "chars": 1886,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {MidcoB"
  },
  {
    "path": "services/providers/midco/views/CardBody.tsx",
    "chars": 738,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TMidcoTokens} from '@/services/midco-handler';\n\ninterface IMidcoBodyProps {\n  enab"
  },
  {
    "path": "services/providers/midco/views/Login.tsx",
    "chars": 1767,
    "preview": "import {FC} from 'hono/jsx';\n\ninterface ILoginProps {\n  invalid?: boolean;\n}\n\nexport const Login: FC<ILoginProps> = asyn"
  },
  {
    "path": "services/providers/midco/views/index.tsx",
    "chars": 1208,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/mlb/index.tsx",
    "chars": 3846,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {MlbBod"
  },
  {
    "path": "services/providers/mlb/views/CardBody.tsx",
    "chars": 4614,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TMLBTokens} from '@/services/mlb-handler';\nimport {IProviderChannel} from '@/servi"
  },
  {
    "path": "services/providers/mlb/views/Login.tsx",
    "chars": 861,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {mlbHandler} from '@/services/mlb-handler';\n\ninterface ILogin {\n  code?: string;\n}\n"
  },
  {
    "path": "services/providers/mlb/views/index.tsx",
    "chars": 1917,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/mw/index.tsx",
    "chars": 2175,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {IProvider} from '@/services/shared-interfac"
  },
  {
    "path": "services/providers/mw/views/CardBody.tsx",
    "chars": 714,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TMWTokens} from '@/services/mw-handler';\n\ninterface IMWBodyProps {\n  enabled: bool"
  },
  {
    "path": "services/providers/mw/views/index.tsx",
    "chars": 1203,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/nfl/index.tsx",
    "chars": 4795,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interface"
  },
  {
    "path": "services/providers/nfl/views/CardBody.tsx",
    "chars": 4205,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TNFLTokens} from '@/services/nfl-handler';\nimport {IProviderChannel} from '@/servi"
  },
  {
    "path": "services/providers/nfl/views/Login.tsx",
    "chars": 1258,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {nflHandler, TOtherAuth} from '@/services/nfl-handler';\n\ninterface ILogin {\n  code?"
  },
  {
    "path": "services/providers/nfl/views/index.tsx",
    "chars": 1269,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/nhl-tv/index.tsx",
    "chars": 1655,
    "preview": "import {Hono} from 'hono';\n\nimport {Login} from './views/Login';\nimport {NHLBody} from './views/CardBody';\n\nimport {db} "
  },
  {
    "path": "services/providers/nhl-tv/views/CardBody.tsx",
    "chars": 718,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TNHLTokens} from '@/services/nhltv-handler';\n\ninterface INHLBodyProps {\n  enabled:"
  },
  {
    "path": "services/providers/nhl-tv/views/Login.tsx",
    "chars": 849,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {nhlHandler} from '@/services/nhltv-handler';\n\ninterface ILogin {\n  code?: string;\n"
  },
  {
    "path": "services/providers/nhl-tv/views/index.tsx",
    "chars": 1409,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/nwsl/index.tsx",
    "chars": 3158,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {NwslBo"
  },
  {
    "path": "services/providers/nwsl/views/CardBody.tsx",
    "chars": 2263,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TNwslTokens} from '@/services/nwsl-handler';\nimport {usesLinear} from '@/services/"
  },
  {
    "path": "services/providers/nwsl/views/Login.tsx",
    "chars": 1772,
    "preview": "import {FC} from 'hono/jsx';\n\ninterface ILoginProps {\n  invalid?: boolean;\n}\n\nexport const Login: FC<ILoginProps> = asyn"
  },
  {
    "path": "services/providers/nwsl/views/index.tsx",
    "chars": 1232,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/outside/index.tsx",
    "chars": 3193,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {IProvi"
  },
  {
    "path": "services/providers/outside/views/CardBody.tsx",
    "chars": 1949,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TOutsideTokens} from '@/services/outside-handler';\nimport {IProviderChannel} from "
  },
  {
    "path": "services/providers/outside/views/Login.tsx",
    "chars": 1122,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {outsideHandler} from '@/services/outside-handler';\n\ninterface ILogin {\n  code?: st"
  },
  {
    "path": "services/providers/outside/views/index.tsx",
    "chars": 1336,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/paramount/index.tsx",
    "chars": 3147,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {IProvi"
  },
  {
    "path": "services/providers/paramount/views/CardBody.tsx",
    "chars": 1967,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TParamountTokens} from '@/services/paramount-handler';\nimport {IProviderChannel} f"
  },
  {
    "path": "services/providers/paramount/views/Login.tsx",
    "chars": 1053,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {paramountHandler} from '@/services/paramount-handler';\n\ninterface ILogin {\n  code?"
  },
  {
    "path": "services/providers/paramount/views/index.tsx",
    "chars": 1366,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/pwhl/index.tsx",
    "chars": 935,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {IProvider} from '@/services/shared-interfac"
  },
  {
    "path": "services/providers/pwhl/views/index.tsx",
    "chars": 1012,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/victory/index.tsx",
    "chars": 3721,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {Victor"
  },
  {
    "path": "services/providers/victory/views/CardBody.tsx",
    "chars": 748,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TVictoryTokens} from '@/services/victory-handler';\n\ninterface IVictoryBodyProps {\n"
  },
  {
    "path": "services/providers/victory/views/Login.tsx",
    "chars": 877,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {victoryHandler} from '@/services/victory-handler';\n\ninterface ILogin {\n  code?: st"
  },
  {
    "path": "services/providers/victory/views/index.tsx",
    "chars": 3572,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/wnba/index.tsx",
    "chars": 1880,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {WNBABo"
  },
  {
    "path": "services/providers/wnba/views/CardBody.tsx",
    "chars": 724,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {TWNBATokens} from '@/services/wnba-handler';\n\ninterface IWNBABodyProps {\n  enabled"
  },
  {
    "path": "services/providers/wnba/views/Login.tsx",
    "chars": 1772,
    "preview": "import {FC} from 'hono/jsx';\n\ninterface ILoginProps {\n  invalid?: boolean;\n}\n\nexport const Login: FC<ILoginProps> = asyn"
  },
  {
    "path": "services/providers/wnba/views/index.tsx",
    "chars": 1229,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/wsn/index.tsx",
    "chars": 944,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {IProvider} from '@/services/shared-interfac"
  },
  {
    "path": "services/providers/wsn/views/index.tsx",
    "chars": 1224,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/providers/zeam/index.tsx",
    "chars": 935,
    "preview": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {IProvider} from '@/services/shared-interfac"
  },
  {
    "path": "services/providers/zeam/views/index.tsx",
    "chars": 1024,
    "preview": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfa"
  },
  {
    "path": "services/pwhl-handler.ts",
    "chars": 4964,
    "preview": "import axios from 'axios';\nimport moment from 'moment-timezone';\n\nimport {userAgent} from './user-agent';\nimport {IEntry"
  },
  {
    "path": "services/shared-helpers.ts",
    "chars": 5028,
    "preview": "import crypto from 'crypto';\nimport axios from 'axios';\nimport moment, {Moment} from 'moment';\nimport sharp from 'sharp'"
  },
  {
    "path": "services/shared-interfaces.ts",
    "chars": 1869,
    "preview": "interface IChannelStatus {\n  current?: string;\n  player?: IManifestPlayer;\n  heartbeatTimer?: NodeJS.Timer;\n  heartbeat?"
  },
  {
    "path": "services/template-handler.ts",
    "chars": 5108,
    "preview": "import axios from 'axios';\nimport moment from 'moment';\n\nimport {userAgent} from './user-agent';\nimport {ClassTypeWithou"
  },
  {
    "path": "services/tubi-helper.ts",
    "chars": 944,
    "preview": "import axios from 'axios';\n\nimport {userAgent} from './user-agent';\n\nexport interface ITubiRes {\n  video_resources: {\n  "
  },
  {
    "path": "services/user-agent.ts",
    "chars": 1793,
    "preview": "const userAgents = [\n  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/13"
  },
  {
    "path": "services/victory-handler.ts",
    "chars": 9745,
    "preview": "import axios from 'axios';\nimport moment from 'moment';\n\nimport {userAgent} from './user-agent';\nimport {ClassTypeWithou"
  },
  {
    "path": "services/wnba-handler.ts",
    "chars": 8351,
    "preview": "import axios from 'axios';\nimport moment from 'moment';\n\nimport {okHttpUserAgent, userAgent} from './user-agent';\nimport"
  },
  {
    "path": "services/wsn-handler.ts",
    "chars": 2804,
    "preview": "import moment from 'moment';\n\nimport {IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} f"
  },
  {
    "path": "services/yt-dlp-helper.ts",
    "chars": 1629,
    "preview": "import ytdlpWrap from 'yt-dlp-wrap';\n\nconst ytdlp = new ytdlpWrap();\n\ninterface ILiveStream {\n  id: string;\n  title: str"
  },
  {
    "path": "services/zeam-handler.ts",
    "chars": 4251,
    "preview": "import moment from 'moment';\n\nimport {userAgent} from './user-agent';\nimport {IEntry, IProvider, TChannelPlaybackInfo} f"
  },
  {
    "path": "tsconfig.json",
    "chars": 457,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"es6\",\n    \"jsx\": \"react-jsx\",\n    \"jsxImportSource\": \"hono/jsx\",\n    \"module\": \""
  },
  {
    "path": "views/Header.tsx",
    "chars": 768,
    "preview": "import type {FC} from 'hono/jsx';\n\nimport {version} from '../package.json';\n\nimport {latestRelease} from '@/services/sha"
  },
  {
    "path": "views/Layout.tsx",
    "chars": 742,
    "preview": "import type {FC, ReactNode} from 'hono/jsx';\n\nexport interface ILayoutProps {\n  children: ReactNode;\n}\n\nexport const Lay"
  },
  {
    "path": "views/Links.tsx",
    "chars": 3835,
    "preview": "import type {FC} from 'hono/jsx';\n\nimport {usesLinear} from '@/services/misc-db-service';\n\nexport interface ILinksProps "
  },
  {
    "path": "views/Main.tsx",
    "chars": 196,
    "preview": "import type {FC, ReactNode} from 'hono/jsx';\n\nexport interface IMainProps {\n  children: ReactNode;\n}\nexport const Main: "
  },
  {
    "path": "views/Options.tsx",
    "chars": 8072,
    "preview": "import type {FC} from 'hono/jsx';\n\nimport {\n  getNumberOfChannels,\n  getStartChannel,\n  proxySegments,\n  usesLinear,\n  x"
  },
  {
    "path": "views/Providers.tsx",
    "chars": 237,
    "preview": "import type {FC, ReactNode} from 'hono/jsx';\n\nexport interface IProvidersProps {\n  children: ReactNode;\n}\n\nexport const "
  },
  {
    "path": "views/Script.tsx",
    "chars": 1031,
    "preview": "import type {FC} from 'hono/jsx';\n\nexport const Script: FC = () => (\n  <script\n    dangerouslySetInnerHTML={{\n      __ht"
  },
  {
    "path": "views/Style.tsx",
    "chars": 882,
    "preview": "import type {FC} from 'hono/jsx';\n\nexport const Style: FC = () => (\n  <style>{`\n    pre {\n      padding: 5px;\n    }\n    "
  },
  {
    "path": "views/Tools.tsx",
    "chars": 1527,
    "preview": "import type {FC} from 'hono/jsx';\n\nexport const Tools: FC = () => (\n  <section hx-swap=\"outerHTML\" hx-target=\"this\">\n   "
  }
]

About this extraction

This page contains the full source code of the m0ngr31/EPlusTV GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 162 files (685.3 KB), approximately 190.6k tokens, and a symbol index with 324 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!