[
  {
    "path": ".dockerignore",
    "content": "node_modules/\nconfig/\ntmp/\nplayroom/\nrun*.sh\n.DS_Store"
  },
  {
    "path": ".editorconfig",
    "content": "# http://editorconfig.org\n\nroot = true\n\n[*]\ncharset = utf-8\nindent_style = space\nindent_size = 2\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.md]\ninsert_final_newline = false\ntrim_trailing_whitespace = false\n"
  },
  {
    "path": ".eslintrc.json",
    "content": "{\n  \"parser\": \"@typescript-eslint/parser\",\n  \"parserOptions\": {\n    \"ecmaVersion\": 2018,\n    \"sourceType\": \"module\",\n    \"warnOnUnsupportedTypeScriptVersion\": false\n  },\n  \"plugins\": [\n    \"@typescript-eslint\",\n    \"sort-keys-custom-order-fix\"\n  ],\n  \"extends\": [\n    \"plugin:@typescript-eslint/recommended\"\n  ],\n  \"rules\": {\n    \"sort-keys-custom-order-fix/sort-keys-custom-order-fix\": \"warn\",\n    \"@typescript-eslint/no-non-null-assertion\": \"off\",\n    \"@typescript-eslint/no-explicit-any\": \"off\",\n    \"no-unused-vars\": \"off\",\n    \"@typescript-eslint/no-unused-vars\": [\n      \"error\"\n    ],\n    \"object-shorthand\": [\"error\", \"always\"]\n  }\n}\n"
  },
  {
    "path": ".github/workflows/push-docker-hub.yml",
    "content": "name: ci\n\non:\n  push:\n    tags:\n      - '*'\n\njobs:\n  docker:\n    runs-on: ubuntu-latest\n    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')\n    steps:\n      -\n        name: Checkout\n        uses: actions/checkout@v3\n      -\n        name: Set up QEMU\n        uses: docker/setup-qemu-action@v2\n      -\n        name: Set up Docker Buildx\n        id: buildx\n        uses: docker/setup-buildx-action@v2\n      -\n        name: Login to DockerHub\n        if: github.event_name != 'pull_request'\n        uses: docker/login-action@v1\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n      -\n        name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v3\n        with:\n          images: |\n            ${{ secrets.DOCKERHUB_USERNAME }}/eplustv\n          tags: |\n            type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }}\n            type=ref,event=tag\n      -\n        name: Build and push\n        uses: docker/build-push-action@v2\n        with:\n          context: .\n          platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n      -\n        name: Update repo description\n        uses: peter-evans/dockerhub-description@v2\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_PASSWORD }}\n          repository: ${{ secrets.DOCKERHUB_USERNAME }}/eplustv\n      -\n        name: Login to new DockerHub\n        if: github.event_name != 'pull_request'\n        uses: docker/login-action@v1\n        with:\n          username: ${{ secrets.NEW_DOCKERHUB_USERNAME }}\n          password: ${{ secrets.NEW_DOCKERHUB_TOKEN }}\n      -\n        name: Docker new meta\n        id: new_meta\n        uses: docker/metadata-action@v3\n        with:\n          images: |\n            ${{ secrets.NEW_DOCKERHUB_USERNAME }}/eplustv\n          tags: |\n            type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }}\n            type=ref,event=tag\n      -\n        name: Build and push new\n        uses: docker/build-push-action@v2\n        with:\n          context: .\n          platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.new_meta.outputs.tags }}\n          labels: ${{ steps.new_meta.outputs.labels }}\n      -\n        name: Update new repo description\n        uses: peter-evans/dockerhub-description@v2\n        with:\n          username: ${{ secrets.NEW_DOCKERHUB_USERNAME }}\n          password: ${{ secrets.NEW_DOCKERHUB_PASSWORD }}\n          repository: ${{ secrets.NEW_DOCKERHUB_USERNAME }}/eplustv\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Create Release on Tag Push\n\non:\n  push:\n    tags:\n      - '*'\n\njobs:\n  create_release:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')\n    steps:\n      - name: Print a simple message\n        run: echo \"Checking out...\"\n        \n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          \n      - name: Print a simple message\n        run: echo \"Creating release...\"\n          \n      - name: Print a simple message\n        run: echo \"Using release name and tag ${{ github.ref_name }}\"\n\n      #- name: Get latest commit message\n        #id: get_commit_message\n        #run: |\n        #  COMMIT_MESSAGE=$(git log -1 --pretty=%B)\n        #  echo \"commit_message<<EOF\" >> $GITHUB_OUTPUT\n        #  echo \"$COMMIT_MESSAGE\" >> $GITHUB_OUTPUT\n        #  echo \"EOF\" >> $GITHUB_OUTPUT\n\n      - name: Create Release\n        uses: actions/create-release@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          tag_name: ${{ github.ref_name }}\n          release_name: ${{ github.ref_name }}\n          #body: ${{ steps.get_commit_message.outputs.commit_message }}\n          draft: false\n          prerelease: false\n"
  },
  {
    "path": ".gitignore",
    "content": "\n# Created by https://www.toptal.com/developers/gitignore/api/node,visualstudiocode\n# Edit at https://www.toptal.com/developers/gitignore?templates=node,visualstudiocode\n\n### Node ###\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n.pnpm-debug.log*\n\n# Diagnostic reports (https://nodejs.org/api/report.html)\nreport.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n*.lcov\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# Snowpack dependency directory (https://snowpack.dev/)\nweb_modules/\n\n# TypeScript cache\n*.tsbuildinfo\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Microbundle cache\n.rpt2_cache/\n.rts2_cache_cjs/\n.rts2_cache_es/\n.rts2_cache_umd/\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n.env\n.env.test\n.env.production\n\n# parcel-bundler cache (https://parceljs.org/)\n.cache\n.parcel-cache\n\n# Next.js build output\n.next\nout\n\n# Nuxt.js build / generate output\n.nuxt\ndist\n\n# Gatsby files\n.cache/\n# Comment in the public line in if your project uses Gatsby and not Next.js\n# https://nextjs.org/blog/next-9-1#public-directory-support\n# public\n\n# vuepress build output\n.vuepress/dist\n\n# Serverless directories\n.serverless/\n\n# FuseBox cache\n.fusebox/\n\n# DynamoDB Local files\n.dynamodb/\n\n# TernJS port file\n.tern-port\n\n# Stores VSCode versions used for testing VSCode extensions\n.vscode-test\n\n# yarn v2\n.yarn/cache\n.yarn/unplugged\n.yarn/build-state.yml\n.yarn/install-state.gz\n.pnp.*\n\n### VisualStudioCode ###\n.vscode/*\n!.vscode/settings.json\n!.vscode/tasks.json\n!.vscode/launch.json\n!.vscode/extensions.json\n*.code-workspace\n\n# Local History for Visual Studio Code\n.history/\n\n### VisualStudioCode Patch ###\n# Ignore all local history of files\n.history\n.ionide\n\n# End of https://www.toptal.com/developers/gitignore/api/node,visualstudiocode\ntmp/\nrun*.sh\n.DS_Store\nconfig*/\nplayroom/\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\nnpx lint-staged\n"
  },
  {
    "path": ".nvmrc",
    "content": "18\n"
  },
  {
    "path": ".prettierrc.json",
    "content": "{\n  \"arrowParens\": \"avoid\",\n  \"bracketSpacing\": false,\n  \"embeddedLanguageFormatting\": \"auto\",\n  \"htmlWhitespaceSensitivity\": \"css\",\n  \"insertPragma\": false,\n  \"printWidth\": 120,\n  \"proseWrap\": \"preserve\",\n  \"quoteProps\": \"as-needed\",\n  \"requirePragma\": false,\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"tabWidth\": 2,\n  \"trailingComma\": \"all\",\n  \"useTabs\": false\n}\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM alpine:latest\n\nRUN mkdir -p /etc/udhcpc ; echo 'RESOLV_CONF=\"no\"' >> /etc/udhcpc/udhcpc.conf\n\nRUN apk add --update nodejs npm su-exec shadow yt-dlp\n\nRUN rm -rf /var/cache/apk/*\n\nRUN mkdir /app\nWORKDIR /app\n\nCOPY . .\n\nRUN npm ci\n\nRUN chmod +x entrypoint.sh\n\nENTRYPOINT [\"./entrypoint.sh\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<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 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).\n\n## Notes\n* 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.\n* The Mouse might not like it and it could be taken down at any minute. Enjoy it while it lasts. ¯\\\\_(ツ)_/¯\n\n# Using\nThe server exposes 4 main endpoints:\n\n| Endpoint | Description |\n|---|---|\n| /channels.m3u | The channel list you'll import into your client |\n| /xmltv.xml | The schedule that you'll import into your client |\n| /linear-channels.m3u | The linear channel list you'll import into your client (only used when using the dedicated linear channels option) |\n| /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 |\n\n# Running\nThe recommended way of running is to pull the image from [Docker Hub](https://hub.docker.com/r/tonywagner/eplustv).\n\n## Environment Variables\n| Environment Variable | Description | Required? | Default |\n|---|---|---|---|\n| BASE_URL | If using a reverse proxy, m3u will be generated with this as the base. | No | - |\n| PUID | Current user ID. Use if you have permission issues. Needs to be combined with PGID. | No | - |\n| PGID | Current group ID. Use if you have permission issues. Needs to be combined with PUID. | No | - |\n| PORT | Port the API will be served on. You can set this if it conflicts with another service in your environment. | No | 8000 |\n\n### Available Providers\n\n#### Bally\n\nAvailable for free\n\n#### B1G+\n\nAvailable to login with B1G+ credentials (or for free with certain ISP providers)\n\n#### CBS Sports\n\nAvailable to login with TV Provider. Please note that there is no token refresh option here. It will require re-authenticating every 30 days.\n\n#### ESPN\n\nLimited free content. Also available to login with TV Provider\n\n##### Linear Channels\n\nWill create dedicated linear channels if using dedicated linear channels, otherwise will schedule events normally\n\n| Network Name | Description |\n|---|---|\n| ESPN | Set if your TV provider supports it |\n| ESPN2 | Set if your TV provider supports it |\n| ESPNU | Set if your TV provider supports it |\n| SEC Network | Set if your TV provider supports it |\n| ACC Network | Set if your TV provider supports it |\n| ESPNews | Set if your TV provider supports it |\n| ESPN Deportes | Set if your TV provider supports it |\n\n#### ESPN Account\n\nFormerly ESPN+. Still available to login with ESPN credentials, but does not provide access to events anymore.\n\n##### Extras\n| Name | Description |\n|---|---|\n| ESPN+ PPV | Schedule ESPN+ PPV events |\n\n#### FloSports\n\nAvailable to login with FloSports credentials\n\n#### FOXOne\n\nAvailable to login with TV Provider - Direct Subscription or ESPN Subscription Not Currently Supported\n\n##### Linear Channels\n\nWill create dedicated linear channels if using dedicated linear channels, otherwise will schedule events normally\n\n| Network Name |\n|---|\n| FOX | Set if your TV provider supports it |\n| MyNetwork TV | Set if your TV provider supports it |\n| FS1 | Set if your TV provider supports it |\n| FS2 | Set if your TV provider supports it |\n| B1G Network | Set if your TV provider supports it |\n| FOX Deportes | Set if your TV provider supports it |\n| FOX News Channel | Set if your TV provider supports it |\n| FOX Business Network | Set if your TV provider supports it |\n| TMZ | Set if your TV provider supports it |\n| Masked Singer | Set if your TV provider supports it |\n| FOX Soul | Set if your TV provider supports it |\n| FOX Weather | Set if your TV provider supports it |\n| FOX Live Now | Set if your TV provider supports it |\n\n#### FOX Sports\n\nAvailable to login with TV Provider\n\n##### Linear Channels\n\nSome 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\n\n| Network Name |\n|---|\n| FS1 | Set if your TV provider supports it |\n| FS2 | Set if your TV provider supports it |\n| B1G Network | Set if your TV provider supports it |\n| FOX Soccer Plus | Set if your TV provider supports it |\n| FOX Deportes | Set if your TV provider supports it |\n\n#### Gotham Sports\n\nAvailable to login with Gotham Sports or TV Provider\n\n##### Linear Channels\n\nWill create dedicated linear channels if using dedicated linear channels, otherwise will schedule events normally\n\n| Network Name | Description |\n|---|---|\n| MSG | MSG (If in your supported zone) |\n| MSGSN | MSG Sportsnet HD (If in your supported zone) |\n| MSG2 | MSG2 HD (If in your supported zone) |\n| MSGSN2 | MSG Sportsnet 2 HD (If in your supported zone) |\n| YES | Yes Network (If in your supported zone) |\n\n#### Hudl\n\nVarious small college conferences, available for free\n\n#### KBO\n\nAvailable for free\n\n#### KSL Sports\n\nAvailable for free\n\n#### LOVB\n\nUse Victory+ instead\n\n#### Midco Sports\n\nAvailable to login with Midco Sports credentials\n\n#### MLB.tv\n\nAvailable to login with MLB.tv credentials\n\n##### Extras\n| Name | Description |\n|---|---|\n| Only free games | If you have a free account, only 1 free game per day will be scheduled |\n\n##### Linear Channels\n\n| Network Name | Description |\n|---|---|\n| Big Inning | Will create a dedicated linear channel if using dedicated linear channels, otherwise will schedule Big Inning normally |\n| MLB Network | Only available if you have MLB Network as part of your MLB.tv account or have linked TVE Provider that provides access |\n| SNY | Only available if you have SNY as part of your MLB.tv account or have linked TVE Provider that provides access |\n| SNLA | Only available if you have SNLA+ as part of your MLB.tv account or have linked TVE Provider that provides access |\n\n#### Mountain West\n\nAvailable for free\n\n#### NHL.tv\n\nAvailable to login with NHL.tv account (Europe only)\n\n#### NFL\n\nAvailable to login with NFL.com credentials\n\nThis integration works with NFL+ or using other providers (TVE, Amazon Prime, Peacock, Sunday Ticket) to access games.\n\n##### Extra Providers\n\nIf you don't have an NFL+ subscription, you can use these providers to access games.\n\n| Provider Name | Description |\n|---|---|\n| Amazon Prime | Get TNF games from Amazon Prime |\n| Peacock | Get SNF games from Peacock |\n| TV Provider | Get in-market games from your TV Provider |\n| Sunday Ticket | Get out-of-market games from Youtube |\n\n##### Linear Channels\n\nIf you have access to NFL RedZone, it will be scheduled. If dedicated linear channels is set, it will be on its own channel\n\n| Network Name | Description |\n|---|---|\n| NFL Network | NFL+ or TV Provider access |\n| NFL RedZone | NFL+ Premium or TV Provider access |\n| NFL Channel | Free channel for all accounts |\n\n#### NWSL+\n\nAvailable to login with NWSL+ credentials\n\n#### Outside TV\n\nAvailable to login with Outside TV credentials (free account)\n\n##### Linear Channels\n\nDedicated linear channels - Will only schedule when dedicated linear channels is set\n\n| Network Name |\n|---|\n| Outside |\n\n#### Paramount+\n\nAvailable to login with Paramount+ credentials\n\n##### Linear Channels\n\nDedicated linear channels - Will only schedule when dedicated linear channels is set\n\n| Network Name | Description |\n|---|---|\n| CBS Sports HQ | Set if your TV provider supports it |\n| Golazo Network | Set if your TV provider supports it |\n\n#### PWHL\n\nAvailable for free\n\n#### Victory+\n\nAvailable to login with Victory+ credentials.\n\n#### WNBA League Pass\n\nAvailable to login with WNBA League Pass credentials\n\n#### Women's Sports Network\n\nAvailable for free - only linear channel\n\n##### Linear Channels\n\n| Network Name | Description |\n|---|---|\n| WSN | Women's Sports Network |\n\n#### Zeam Live Events\n\nAvailable for free\n\n## Volumes\n| Volume Name | Description | Required? |\n|---|---|---|\n| /app/config | Used to store DB and application state | Yes |\n\n\n## Docker Run\nBy default, the easiest way to get running is:\n\n```bash\ndocker run -p 8000:8000 -v config_dir:/app/config tonywagner/eplustv\n```\n\nIf you run into permissions issues:\n\n```bash\ndocker run -p 8000:8000 -v config_dir:/app/config -e PUID=$(id -u $USER) -e PGID=$(id -g $USER) tonywagner/eplustv\n```\n\nOpen the service in your web browser at `http://<ip>:8000`\n"
  },
  {
    "path": "entrypoint.sh",
    "content": "#!/bin/sh\n\nif [ -z \"$PUID\" ] || [ -z \"$PGID\" ]; then\n  exec npm start\nelse\n  adduser -u $PUID -D abc\n  groupmod -g $PGID abc\n\n  chown abc:abc -R /app\n\n  exec su-exec abc npm start\nfi\n"
  },
  {
    "path": "index.tsx",
    "content": "import {Context, Hono} from 'hono';\nimport {serve} from '@hono/node-server';\nimport {serveStatic} from '@hono/node-server/serve-static';\nimport {BlankEnv, BlankInput} from 'hono/types';\nimport {html} from 'hono/html';\nimport moment from 'moment';\nimport _ from 'lodash';\nimport axios from 'axios';\n\nimport fs from 'fs';\nimport {createServer} from 'node:https';\n\nimport {generateM3u, generateEventChannelsM3u} from './services/generate-m3u';\nimport {initDirectories} from './services/init-directories';\nimport {generateXml} from './services/generate-xmltv';\nimport {launchChannel} from './services/launch-channel';\nimport {scheduleEntries} from './services/build-schedule';\nimport {espnHandler} from './services/espn-handler';\nimport {foxHandler} from './services/fox-handler';\nimport {foxOneHandler} from './services/foxone-handler';\nimport {mlbHandler} from './services/mlb-handler';\nimport {b1gHandler} from './services/b1g-handler';\nimport {floSportsHandler} from './services/flo-handler';\nimport {paramountHandler} from './services/paramount-handler';\nimport {nflHandler} from './services/nfl-handler';\nimport {gothamHandler} from './services/gotham-handler';\nimport {mwHandler} from './services/mw-handler';\nimport {pwhlHandler} from './services/pwhl-handler';\nimport {ballyHandler} from './services/bally-handler';\nimport {wsnHandler} from './services/wsn-handler';\nimport {nwslHandler} from './services/nwsl-handler';\nimport {midcoHandler} from './services/midco-handler';\nimport {hudlHandler} from './services/hudl-handler';\nimport {cbsHandler} from './services/cbs-handler';\nimport {nhlHandler} from './services/nhltv-handler';\nimport {victoryHandler} from './services/victory-handler';\nimport {kboHandler} from './services/kbo-handler';\nimport {kslHandler} from './services/ksl-handler';\nimport {zeamHandler} from './services/zeam-handler';\nimport {outsideHandler} from './services/outside-handler';\nimport {wnbaHandler} from './services/wnba-handler';\nimport {\n  cleanEntries,\n  clearChannels,\n  removeAllEntries,\n  removeChannelStatus,\n  resetSchedule,\n  latestRelease,\n} from './services/shared-helpers';\nimport {appStatus} from './services/app-status';\nimport {SERVER_PORT} from './services/port';\nimport {providers} from './services/providers';\n\nimport {version} from './package.json';\n\nimport {Layout} from './views/Layout';\nimport {Header} from './views/Header';\nimport {Main} from './views/Main';\nimport {Links} from './views/Links';\nimport {Style} from './views/Style';\nimport {Providers} from './views/Providers';\nimport {Script} from './views/Script';\nimport {Tools} from './views/Tools';\nimport {Options} from './views/Options';\n\nimport {CBSSports} from './services/providers/cbs-sports/views';\nimport {MntWest} from './services/providers/mw/views';\nimport {Hudl} from './services/providers/hudl/views';\nimport {Paramount} from './services/providers/paramount/views';\nimport {FloSports} from './services/providers/flosports/views';\nimport {MlbTv} from './services/providers/mlb/views';\nimport {FoxSports} from './services/providers/fox/views';\nimport {FoxOne} from './services/providers/foxone/views';\nimport {B1G} from './services/providers/b1g/views';\nimport {NFL} from './services/providers/nfl/views';\nimport {ESPN} from './services/providers/espn/views';\nimport {ESPNPlus} from './services/providers/espn-plus/views';\nimport {Gotham} from './services/providers/gotham/views';\nimport {WSN} from './services/providers/wsn/views';\nimport {PWHL} from './services/providers/pwhl/views';\nimport {Bally} from './services/providers/bally/views';\nimport {Nwsl} from './services/providers/nwsl/views';\nimport {Midco} from './services/providers/midco/views';\nimport {NHL} from './services/providers/nhl-tv/views';\nimport {Victory} from './services/providers/victory/views';\nimport {KBO} from './services/providers/kbo/views';\nimport {KSL} from './services/providers/ksl/views';\nimport {Zeam} from './services/providers/zeam/views';\nimport {Outside} from './services/providers/outside/views';\nimport {WNBA} from './services/providers/wnba/views';\n\nimport {\n  initMiscDb,\n  resetLinearStartChannel,\n  setLinear,\n  setNumberofChannels,\n  setProxySegments,\n  setStartChannel,\n  usesLinear,\n  setXmltvPadding,\n  setHideStudio,\n  setEventFilters,\n  getLatestVersion,\n  getLastModified,\n  setLastModified,\n} from './services/misc-db-service';\n\n// Check for SSL environment variables\nconst sslCertificatePath = process.env.SSL_CERTIFICATE_PATH;\nconst sslPrivateKeyPath = process.env.SSL_PRIVATEKEY_PATH;\n\n// Set timeout of requests to 1 minute\naxios.defaults.timeout = 1000 * 60;\n\nconst notFound = (c: Context<BlankEnv, '', BlankInput>) => {\n  return c.text('404 not found', 404, {\n    'X-Tuner-Error': 'EPlusTV: Error getting content',\n  });\n};\n\nconst shutDown = () => process.exit(0);\n\nconst getUri = (c: Context<BlankEnv, '', BlankInput>): string => {\n  if (process.env.BASE_URL) {\n    return process.env.BASE_URL;\n  }\n\n  const protocol = c.req.header('x-forwarded-proto') || 'http';\n  const host = c.req.header('host') || '';\n\n  return `${protocol}://${host}`;\n};\n\nconst checkVersion = async () => {\n  const latest_version = await getLatestVersion();\n  const latest_release = await latestRelease();\n  if ( latest_release && (latest_release != '') && (latest_version != latest_release) && (version != latest_release.slice(1)) ) {\n    console.log(`=== Newer version ${latest_release} available, consider updating ===`);\n  }\n}\n\nconst schedule = async () => {\n  await checkVersion();\n\n  console.log('=== Getting events ===');\n\n  await Promise.all([\n    espnHandler.getSchedule(),\n    foxHandler.getSchedule(),\n    foxOneHandler.getSchedule(),\n    mlbHandler.getSchedule(),\n    b1gHandler.getSchedule(),\n    floSportsHandler.getSchedule(),\n    mwHandler.getSchedule(),\n    wsnHandler.getSchedule(),\n    pwhlHandler.getSchedule(),\n    ballyHandler.getSchedule(),\n    hudlHandler.getSchedule(),\n    nflHandler.getSchedule(),\n    nwslHandler.getSchedule(),\n    midcoHandler.getSchedule(),\n    paramountHandler.getSchedule(),\n    gothamHandler.getSchedule(),\n    cbsHandler.getSchedule(),\n    nhlHandler.getSchedule(),\n    victoryHandler.getSchedule(),\n    kboHandler.getSchedule(),\n    kslHandler.getSchedule(),\n    zeamHandler.getSchedule(),\n    outsideHandler.getSchedule(),\n    wnbaHandler.getSchedule(),\n  ]);\n\n  console.log('=== Done getting events ===');\n  console.log('=== Building the schedule ===');\n\n  await cleanEntries();\n  await scheduleEntries();\n\n  console.log('=== Done building the schedule ===');\n};\n\nconst app = new Hono();\n\napp.use('/node_modules/*', serveStatic({root: './'}));\napp.use('/favicon.ico', serveStatic({root: './'}));\n\napp.route('/', providers);\n\napp.get('/', async c => {\n  return c.html(\n    html`<!DOCTYPE html>${(\n        <Layout>\n          <Header />\n          <Main>\n            <Links baseUrl={getUri(c)} />\n            <Tools />\n            <Options />\n            <Providers>\n              <Bally />\n              <B1G />\n              <CBSSports />\n              <ESPN />\n              <ESPNPlus />\n              <FloSports />\n              <FoxOne />\n              <FoxSports />\n              <Gotham />\n              <Hudl />\n              <KBO />\n              <KSL />\n              <Midco />\n              <MlbTv />\n              <MntWest />\n              <NHL />\n              <NFL />\n              <Nwsl />\n              <Outside />\n              <Paramount />\n              <PWHL />\n              <Victory />\n              <WNBA />\n              <WSN />\n              <Zeam />\n            </Providers>\n          </Main>\n          <Style />\n          <Script />\n        </Layout>\n      )}`,\n  );\n});\n\napp.post('/rebuild-epg', async c => {\n  await removeAllEntries();\n  await schedule();\n\n  return c.html(<Tools />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully rebuilt EPG\"}}`,\n  });\n});\n\napp.post('/reset-channels', async c => {\n  clearChannels();\n\n  return c.html(<Tools />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully cleared channels\"}}`,\n  });\n});\n\napp.post('/start-channel', async c => {\n  const body = await c.req.parseBody();\n  const startChannel = _.toNumber(body['start-channel']);\n\n  if (_.isNaN(startChannel) || startChannel < 1) {\n    return c.html(<Options />, 200, {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"error\",\"body\":\"Starting channel must be a valid number\"}}`,\n    });\n  }\n\n  await setStartChannel(startChannel);\n  await resetLinearStartChannel();\n  await resetSchedule();\n  await scheduleEntries();\n\n  return c.html(<Options />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully saved starting channel number\"}}`,\n  });\n});\n\napp.post('/num-of-channels', async c => {\n  const body = await c.req.parseBody();\n  const numChannels = _.toNumber(body['num-of-channels']);\n\n  if (_.isNaN(numChannels) || numChannels < 0 || numChannels > 5000) {\n    return c.html(<Options />, 200, {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"error\",\"body\":\"Number of channels must be a valid number\"}}`,\n    });\n  }\n\n  await setNumberofChannels(numChannels);\n  await resetLinearStartChannel();\n  await resetSchedule();\n  await scheduleEntries();\n\n  return c.html(<Options />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully saved number of channels\"}}`,\n  });\n});\n\napp.post('/linear-channels', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['linear-channels'] === 'on';\n\n  await setLinear(enabled);\n\n  if (enabled) {\n    await removeAllEntries();\n    await schedule();\n  } else {\n    await scheduleEntries();\n  }\n\n  return c.html(\n    <input\n      hx-target=\"this\"\n      hx-swap=\"outerHTML\"\n      hx-trigger=\"change\"\n      hx-post=\"/linear-channels\"\n      name=\"linear-channels\"\n      type=\"checkbox\"\n      role=\"switch\"\n      checked={enabled}\n      data-enabled={enabled ? 'true' : 'false'}\n    />,\n    200,\n    {\n      'HX-Trigger': `{\"HXRefresh\": true, \"HXToast\":{\"type\":\"success\",\"body\":\"Successfully ${\n        enabled ? 'enabled' : 'disabled'\n      } dedicated linear channels. Page will refresh momentarily\"}}`,\n    },\n  );\n});\n\napp.post('/proxy-segments', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['proxy-segments'] === 'on';\n\n  await setProxySegments(enabled);\n\n  return c.html(\n    <input\n      hx-post=\"/proxy-segments\"\n      hx-target=\"this\"\n      hx-swap=\"outerHTML\"\n      hx-trigger=\"change\"\n      name=\"proxy-segments\"\n      type=\"checkbox\"\n      role=\"switch\"\n      checked={enabled}\n      data-enabled={enabled ? 'true' : 'false'}\n    />,\n    200,\n    {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully ${\n        enabled ? 'enabled' : 'disabled'\n      } proxying of segment files\"}}`,\n    },\n  );\n});\n\napp.post('/xmltv-padding', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['xmltv-padding'] === 'on';\n\n  await setXmltvPadding(enabled);\n\n  return c.html(\n    <input\n      hx-post=\"/xmltv-padding\"\n      hx-target=\"this\"\n      hx-swap=\"outerHTML\"\n      hx-trigger=\"change\"\n      name=\"xmltv-padding\"\n      type=\"checkbox\"\n      role=\"switch\"\n      checked={enabled}\n      data-enabled={enabled ? 'true' : 'false'}\n    />,\n    200,\n    {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully ${\n        enabled ? 'enabled' : 'disabled'\n      } XMLTV padding\"}}`,\n    },\n  );\n});\n\napp.post('/hide-studio', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['hide-studio'] === 'on';\n\n  await setHideStudio(enabled);\n\n  return c.html(\n    <input\n      hx-post=\"/hide-studio\"\n      hx-target=\"this\"\n      hx-swap=\"outerHTML\"\n      hx-trigger=\"change\"\n      name=\"hide-studio\"\n      type=\"checkbox\"\n      role=\"switch\"\n      checked={enabled}\n      data-enabled={enabled ? 'true' : 'false'}\n    />,\n    200,\n    {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully ${\n        enabled ? 'enabled' : 'disabled'\n      } hiding studio shows\"}}`,\n    },\n  );\n});\n\napp.put('/event-filters', async c => {\n  const body = await c.req.parseBody();\n  const category_filter = body['category-filter'].toString();\n  const title_filter = body['title-filter'].toString();\n\n  await setEventFilters(category_filter, title_filter);\n  await resetSchedule();\n  await scheduleEntries();\n\n  return c.html(\n    <button type=\"submit\" id=\"event-filters-button\">\n      Save and Apply Event Filters\n    </button>,\n    200,\n    {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully saved and applied event filters\"}}`,\n    },\n  );\n});\n\napp.get('/channels.m3u', async c => {\n  const m3uFile = await generateM3u(getUri(c));\n\n  if (!m3uFile) {\n    return notFound(c);\n  }\n\n  return c.body(m3uFile, 200, {\n    'Content-Type': 'application/x-mpegurl',\n  });\n});\n\napp.get('/event-channels.m3u', async c => {\n  const m3uFile = await generateEventChannelsM3u(getUri(c));\n\n  if (!m3uFile) {\n    return notFound(c);\n  }\n\n  return c.body(m3uFile, 200, {'Content-Type': 'application/x-mpegurl'});\n});\n\napp.get('/linear-channels.m3u', async c => {\n  const useLinear = await usesLinear();\n\n  if (!useLinear) {\n    return notFound(c);\n  }\n\n  const m3uFile = await generateM3u(getUri(c), true, c.req.query('gracenote') === 'exclude');\n\n  if (!m3uFile) {\n    return notFound(c);\n  }\n\n  return c.body(m3uFile, 200, {\n    'Content-Type': 'application/x-mpegurl',\n  });\n});\n\napp.get('/xmltv.xml', async c => {\n  const xmlFile = await generateXml();\n\n  if (!xmlFile) {\n    return notFound(c);\n  }\n\n  return c.body(xmlFile, 200, {\n    'Content-Type': 'application/xml',\n  });\n});\n\napp.get('/linear-xmltv.xml', async c => {\n  const useLinear = await usesLinear();\n\n  if (!useLinear) {\n    return notFound(c);\n  }\n\n  const xmlFile = await generateXml(true);\n\n  if (!xmlFile) {\n    return notFound(c);\n  }\n\n  return c.body(xmlFile, 200, {\n    'Content-Type': 'application/xml',\n  });\n});\n\napp.get('/channels/:id{.+\\\\.m3u8$}', async c => {\n  const id = c.req.param('id').split('.m3u8')[0];\n\n  let contents: string | undefined;\n\n  // Channel data needs initial object\n  if (!appStatus.channels[id]) {\n    appStatus.channels[id] = {};\n  }\n\n  const uri = getUri(c);\n\n  if (!appStatus.channels[id].player?.playlist) {\n    try {\n      await launchChannel(id, uri);\n    } catch (e) {}\n  }\n\n  try {\n    contents = appStatus.channels[id].player?.playlist;\n  } catch (e) {}\n\n  if (!contents) {\n    console.log(\n      `Could not get a playlist for channel #${id}. Please make sure there is an event scheduled and you have access to it.`,\n    );\n\n    removeChannelStatus(id);\n\n    return notFound(c);\n  }\n\n  appStatus.channels[id].heartbeat = new Date();\n\n  return c.body(contents, 200, {\n    'Cache-Control': 'no-cache',\n    'Content-Type': 'application/vnd.apple.mpegurl',\n  });\n});\n\napp.get('/chunklist/:id/:chunklistid{.+\\\\.m3u8$}', async c => {\n  const id = c.req.param('id');\n  const chunklistid = c.req.param('chunklistid').split('.m3u8')[0];\n\n  let contents: string | undefined;\n\n  if (!appStatus.channels[id]?.player?.playlist) {\n    return notFound(c);\n  }\n\n  try {\n    contents = await appStatus.channels[id].player.cacheChunklist(chunklistid);\n  } catch (e) {}\n\n  if (!contents) {\n    console.log(`Could not get chunklist for channel #${id}.`);\n    removeChannelStatus(id);\n    return notFound(c);\n  }\n\n  appStatus.channels[id].heartbeat = new Date();\n\n  return c.body(contents, 200, {\n    'Cache-Control': 'no-cache',\n    'Content-Type': 'application/vnd.apple.mpegurl',\n  });\n});\n\napp.get('/channels/:id/:part{.+\\\\.key$}', async c => {\n  const id = c.req.param('id');\n  const part = c.req.param('part').split('.key')[0];\n\n  let contents: ArrayBuffer | undefined;\n\n  try {\n    contents = await appStatus.channels[id].player?.getSegmentOrKey(part);\n  } catch (e) {\n    return notFound(c);\n  }\n\n  if (!contents) {\n    return notFound(c);\n  }\n\n  appStatus.channels[id].heartbeat = new Date();\n\n  return c.body(contents, 200, {\n    'Cache-Control': 'no-cache',\n    'Content-Type': 'application/octet-stream',\n  });\n});\n\napp.get('/channels/:id/:part{.+\\\\.ts$}', async c => {\n  const id = c.req.param('id');\n  const part = c.req.param('part').split('.ts')[0];\n\n  let contents: ArrayBuffer | undefined;\n\n  try {\n    contents = await appStatus.channels[id].player?.getSegmentOrKey(part);\n  } catch (e) {\n    return notFound(c);\n  }\n\n  if (!contents) {\n    return notFound(c);\n  }\n\n  return c.body(contents, 200, {\n    'Cache-Control': 'no-cache',\n    'Content-Type': 'video/MP2T',\n  });\n});\n\napp.get('/channels/:id/:part{.+\\\\.m4i$}', async c => {\n  const id = c.req.param('id');\n  const part = c.req.param('part').split('.m4i')[0];\n\n  let contents: ArrayBuffer | undefined;\n\n  try {\n    contents = await appStatus.channels[id].player?.getSegmentOrKey(part);\n  } catch (e) {\n    return notFound(c);\n  }\n\n  if (!contents) {\n    return notFound(c);\n  }\n\n  return c.body(contents, 200, {\n    'Cache-Control': 'no-cache',\n    'Content-Type': 'video/MP2T',\n  });\n});\n\n// 404 Handler\napp.notFound(notFound);\n\nprocess.on('SIGTERM', shutDown);\nprocess.on('SIGINT', shutDown);\n\n(async () => {\n  console.log(`=== E+TV v${version} starting ===`);\n  initDirectories();\n\n  await initMiscDb();\n\n  await checkVersion();\n\n  await Promise.all([\n    espnHandler.initialize(),\n    foxHandler.initialize(),\n    foxOneHandler.initialize(),\n    mlbHandler.initialize(),\n    b1gHandler.initialize(),\n    floSportsHandler.initialize(),\n    nflHandler.initialize(),\n    nwslHandler.initialize(),\n    midcoHandler.initialize(),\n    paramountHandler.initialize(),\n    gothamHandler.initialize(),\n    cbsHandler.initialize(),\n    victoryHandler.initialize(),\n    nhlHandler.initialize(),\n    mwHandler.initialize(),\n    wsnHandler.initialize(),\n    pwhlHandler.initialize(),\n    ballyHandler.initialize(),\n    hudlHandler.initialize(),\n    kboHandler.initialize(),\n    kslHandler.initialize(),\n    zeamHandler.initialize(),\n    outsideHandler.initialize(),\n    wnbaHandler.initialize(),\n  ]);\n\n  await Promise.all([\n    espnHandler.refreshTokens(),\n    foxHandler.refreshTokens(),\n    foxOneHandler.refreshTokens(),\n    mlbHandler.refreshTokens(),\n    b1gHandler.refreshTokens(),\n    floSportsHandler.refreshTokens(),\n    nflHandler.refreshTokens(),\n    nwslHandler.refreshTokens(),\n    paramountHandler.refreshTokens(),\n    gothamHandler.refreshTokens(),\n    cbsHandler.refreshTokens(),\n    victoryHandler.refreshTokens(),\n    nhlHandler.refreshTokens(),\n    wnbaHandler.refreshTokens(),\n  ]);\n\n  if (sslCertificatePath && sslPrivateKeyPath) {\n    serve(\n      {\n        createServer,\n        fetch: app.fetch,\n        port: SERVER_PORT,\n        serverOptions: {\n          cert: fs.readFileSync(sslCertificatePath),\n          key: fs.readFileSync(sslPrivateKeyPath),\n        },\n      },\n      () => {\n        console.log(`HTTPS server started on port ${SERVER_PORT}`);\n        schedule();\n      },\n    );\n  } else {\n    // Fall back to HTTP if SSL env variables are not provided\n    serve(\n      {\n        fetch: app.fetch,\n        port: SERVER_PORT,\n      },\n      () => {\n        console.log(`HTTP server started on port ${SERVER_PORT}`);\n        schedule();\n      },\n    );\n  }\n})();\n\n// Check for events every 4 hours and set the schedule\nsetInterval(async () => {\n  await schedule();\n}, 1000 * 60 * 60 * 4);\n\n// Check for updated refresh tokens 30 minutes\nsetInterval(\n  () =>\n    Promise.all([\n      espnHandler.refreshTokens(),\n      foxHandler.refreshTokens(),\n      foxOneHandler.refreshTokens(),\n      mlbHandler.refreshTokens(),\n      b1gHandler.refreshTokens(),\n      floSportsHandler.refreshTokens(),\n      nflHandler.refreshTokens(),\n      nwslHandler.refreshTokens(),\n      paramountHandler.refreshTokens(),\n      gothamHandler.refreshTokens(),\n      cbsHandler.refreshTokens(),\n      victoryHandler.refreshTokens(),\n      nhlHandler.refreshTokens(),\n      wnbaHandler.refreshTokens(),\n    ]),\n  1000 * 60 * 30,\n);\n\n// Remove idle playlists\nsetInterval(() => {\n  const now = moment();\n\n  for (const key of Object.keys(appStatus.channels)) {\n    if (appStatus.channels[key] && appStatus.channels[key].heartbeat) {\n      const channelHeartbeat = moment(appStatus.channels[key].heartbeat);\n\n      if (now.diff(channelHeartbeat, 'minutes') > 5) {\n        console.log(`Channel #${key} has been idle for more than 5 minutes. Removing playlist info.`);\n        removeChannelStatus(key);\n      }\n    } else {\n      console.log(`Channel #${key} was setup improperly... Removing.`);\n      removeChannelStatus(key);\n    }\n  }\n}, 1000 * 60);\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"eplustv\",\n  \"version\": \"4.15.6\",\n  \"description\": \"\",\n  \"scripts\": {\n    \"start\": \"ts-node -r tsconfig-paths/register index.tsx\",\n    \"prepare\": \"husky install\"\n  },\n  \"keywords\": [],\n  \"license\": \"MIT\",\n  \"dependencies\": {\n    \"@hono/node-server\": \"^1.13.1\",\n    \"@picocss/pico\": \"^2.0.6\",\n    \"@seald-io/nedb\": \"^4.0.4\",\n    \"axios\": \"^1.2.2\",\n    \"axios-curlirize\": \"^1.3.7\",\n    \"cheerio\": \"^1.0.0\",\n    \"crypto-js\": \"^4.2.0\",\n    \"fast-xml-parser\": \"^4.5.0\",\n    \"fs-extra\": \"^10.0.0\",\n    \"hls-parser\": \"^0.10.6\",\n    \"hono\": \"^4.6.3\",\n    \"htmx-toaster\": \"^0.0.18\",\n    \"htmx.org\": \"^2.0.0\",\n    \"jsdom\": \"^26.0.0\",\n    \"jwt-decode\": \"^3.1.2\",\n    \"lodash\": \"^4.17.21\",\n    \"moment\": \"^2.29.1\",\n    \"moment-timezone\": \"^0.5.45\",\n    \"sharp\": \"^0.33.5\",\n    \"sockette\": \"^2.0.6\",\n    \"tsconfig-paths\": \"^4.2.0\",\n    \"ws\": \"^8.9.0\",\n    \"xml\": \"^1.0.1\",\n    \"yt-dlp-wrap\": \"^2.3.12\"\n  },\n  \"devDependencies\": {\n    \"@types/axios-curlirize\": \"^1.3.5\",\n    \"@types/crypto-js\": \"^4.2.1\",\n    \"@types/express\": \"^4.17.13\",\n    \"@types/lodash\": \"^4.14.174\",\n    \"@types/node\": \"^16.10.1\",\n    \"@typescript-eslint/eslint-plugin\": \"^4.11.1\",\n    \"@typescript-eslint/parser\": \"^4.11.1\",\n    \"eslint\": \"^7.17.0\",\n    \"eslint-plugin-sort-keys-custom-order-fix\": \"^0.1.1\",\n    \"husky\": \"^7.0.1\",\n    \"lint-staged\": \"^11.1.1\",\n    \"prettier\": \"2.3.2\",\n    \"ts-node\": \"^10.2.1\",\n    \"typed-htmx\": \"^0.3.1\",\n    \"typescript\": \"^4.4.3\"\n  },\n  \"lint-staged\": {\n    \"!(slate/**).{ts,tsx}\": [\n      \"prettier --write\",\n      \"eslint --fix --format stylish\"\n    ]\n  }\n}\n"
  },
  {
    "path": "services/adobe-helpers.ts",
    "content": "import crypto from 'crypto';\n\nimport {getRandomHex} from './shared-helpers';\n\nexport interface IAdobeAuth {\n  expires: string;\n  mvpd: string;\n  requestor: string;\n  userId: string;\n}\n\nexport interface IAdobeAuthFox {\n  accessToken: string;\n  tokenExpiration: number;\n  mvpd: string;\n  authn_expire: number;\n}\n\nexport interface IAdobeAuthFoxOne {\n  accessToken: string;\n  tokenExpiration: number;\n  mvpd: string;\n  authn_expire: number;\n}\n\nexport const createAdobeAuthHeader = (\n  method = 'POST',\n  path: string,\n  privateKey: string,\n  publicKey: string,\n  requestor = 'ESPN',\n): string => {\n  const now = new Date().valueOf();\n  const nonce = getRandomHex();\n\n  let message = `${method} requestor_id=${requestor}, nonce=${nonce}, signature_method=HMAC-SHA1, request_time=${now}, request_uri=${path}`;\n  const signature = crypto.createHmac('sha1', privateKey).update(message).digest().toString('base64');\n  message = `${message}, public_key=${publicKey}, signature=${signature}`;\n\n  return message;\n};\n\nexport const isAdobeTokenValid = (token?: IAdobeAuth): boolean => {\n  if (!token) {\n    return false;\n  }\n\n  try {\n    const parsedExp = parseInt(token.expires, 10);\n    return new Date().valueOf() < new Date(parsedExp).valueOf();\n  } catch (e) {\n    return false;\n  }\n};\n\nexport const isAdobeFoxTokenValid = (token?: IAdobeAuthFox): boolean => {\n  if (!token) {\n    return false;\n  }\n\n  const now = new Date().valueOf();\n\n  try {\n    return now < token.authn_expire && now < token.tokenExpiration;\n  } catch (e) {\n    return false;\n  }\n};\n\nexport const isAdobeFoxOneTokenValid = (token?: IAdobeAuthFoxOne): boolean => {\n  if (!token) {\n    return false;\n  }\n\n  const now = new Date().valueOf();\n\n  try {\n    return now < token.authn_expire && now < token.tokenExpiration;\n  } catch (e) {\n    return false;\n  }\n};\n\nexport const willAdobeTokenExpire = (token?: IAdobeAuth): boolean => {\n  if (!token) return true;\n\n  try {\n    const parsedExp = parseInt(token.expires, 10);\n    // Will the token expire in the next day?\n    return new Date().valueOf() + 3600 * 1000 * 24 > new Date(parsedExp).valueOf();\n  } catch (e) {\n    return true;\n  }\n};\n"
  },
  {
    "path": "services/app-status.ts",
    "content": "import {IAppStatus} from './shared-interfaces';\n\nexport const appStatus: IAppStatus = {\n  channels: {},\n};\n"
  },
  {
    "path": "services/b1g-handler.ts",
    "content": "import fs from 'fs';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport moment from 'moment';\nimport jwt_decode from 'jwt-decode';\n\nimport {b1gUserAgent, okHttpUserAgent} from './user-agent';\nimport {configPath} from './config';\nimport {useB1GPlus} from './networks';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {debug} from './debug';\nimport {normalTimeRange} from './shared-helpers';\n\ninterface IEventCategory {\n  name: string;\n}\n\ninterface IEventTeam {\n  name: string;\n  shortName: string;\n  fullName: string;\n}\n\ninterface IEventMetadata {\n  name: string;\n  type: {\n    name: string;\n  };\n}\n\ninterface IEventImage {\n  path: string;\n}\n\ninterface IEventContent {\n  id: number;\n  enableDrmProtection: boolean;\n}\n\ninterface IB1GEvent {\n  id: number;\n  title?: string;\n  startTime: string;\n  category1: IEventCategory;\n  category2: IEventCategory;\n  category3: IEventCategory;\n  homeCompetitor: IEventTeam;\n  awayCompetitor: IEventTeam;\n  clientMetadata: IEventMetadata[];\n  images: IEventImage[];\n  content: IEventContent[];\n}\n\ninterface IGameData {\n  name: string;\n  sport: string;\n  image: string;\n  categories: string[];\n}\n\ninterface IB1GMeta {\n  username: string;\n  password: string;\n}\n\nconst b1gConfigPath = path.join(configPath, 'b1g_tokens.json');\n\nconst getEventData = (event: IB1GEvent): IGameData => {\n  let sport = 'B1G+ Event';\n  const categories: string[] = ['B1G+', 'B1G'];\n\n  event.clientMetadata.forEach(e => {\n    if (e.type.name === 'Sport') {\n      sport = e.name;\n      categories.push(e.name);\n    }\n\n    if (e.type.name === 'sports') {\n      categories.push(e.name);\n    }\n  });\n\n  let awayTeam: string;\n  let homeTeam: string;\n\n  try {\n    awayTeam = `${event.awayCompetitor.name} ${event.awayCompetitor.fullName}`;\n    categories.push(awayTeam);\n  } catch (e) {}\n\n  try {\n    homeTeam = `${event.homeCompetitor.name} ${event.homeCompetitor.fullName}`;\n    categories.push(homeTeam);\n  } catch (e) {}\n\n  const eventName = event.title ? event.title : `${awayTeam} at ${homeTeam}`;\n\n  return {\n    categories: [...new Set(categories)],\n    image: `https://www.bigtenplus.com/image/original/${event.images[0].path}`,\n    name: eventName,\n    sport,\n  };\n};\n\nconst parseAirings = async (events: IB1GEvent[]) => {\n  const [now, endDate] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.id) {\n      continue;\n    }\n\n    const gameData = getEventData(event);\n\n    for (const content of event.content) {\n      const entryExists = await db.entries.findOneAsync<IEntry>({id: `b1g-${content.id}`});\n\n      if (!entryExists) {\n        const start = moment(event.startTime);\n        const end = moment(event.startTime).add(4, 'hours');\n        const originalEnd = moment(start).add(3, 'hours');\n\n        if (end.isBefore(now) || start.isAfter(endDate) || content.enableDrmProtection) {\n          continue;\n        }\n\n        console.log('Adding event: ', gameData.name);\n\n        await db.entries.insertAsync<IEntry>({\n          categories: gameData.categories,\n          duration: end.diff(start, 'seconds'),\n          end: end.valueOf(),\n          from: 'b1g+',\n          id: `b1g-${content.id}`,\n          image: gameData.image,\n          name: gameData.name,\n          network: 'B1G+',\n          originalEnd: originalEnd.valueOf(),\n          sport: gameData.sport,\n          start: start.valueOf(),\n        });\n      }\n    }\n  }\n};\n\nclass B1GHandler {\n  public access_token?: string;\n  public expires_at?: number;\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'b1g'})) > 0 ? true : false;\n\n    if (!setup) {\n      const data: TB1GTokens = {};\n\n      if (useB1GPlus) {\n        this.loadJSON();\n\n        data.access_token = this.access_token;\n        data.expires_at = this.expires_at;\n      }\n\n      await db.providers.insertAsync<IProvider<TB1GTokens, IB1GMeta>>({\n        enabled: useB1GPlus,\n        meta: {\n          password: process.env.B1GPLUS_PASS,\n          username: process.env.B1GPLUS_USER,\n        },\n        name: 'b1g',\n        tokens: data,\n      });\n\n      if (fs.existsSync(b1gConfigPath)) {\n        fs.rmSync(b1gConfigPath);\n      }\n    }\n\n    if (useB1GPlus) {\n      console.log('Using B1GPLUS variable is no longer needed. Please use the UI going forward');\n    }\n    if (process.env.B1GPLUS_USER) {\n      console.log('Using B1GPLUS_USER variable is no longer needed. Please use the UI going forward');\n    }\n    if (process.env.B1GPLUS_PASS) {\n      console.log('Using B1GPLUS_PASS variable is no longer needed. Please use the UI going forward');\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'b1g'});\n\n    if (!enabled) {\n      return;\n    }\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n  };\n\n  public refreshTokens = async () => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'b1g'});\n\n    if (!enabled) {\n      return;\n    }\n\n    if (!this.expires_at || moment(this.expires_at).isBefore(moment().add(100, 'days'))) {\n      await this.login();\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'b1g'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log('Looking for B1G+ events...');\n\n    try {\n      let hasNextPage = true;\n      let page = 1;\n      let events: IB1GEvent[] = [];\n\n      const [fromDate, toDate] = normalTimeRange();\n\n      while (hasNextPage) {\n        const url = [\n          'https://',\n          'www.bigtenplus.com',\n          '/api/v2',\n          '/events',\n          '?sort_direction=asc',\n          '&device_category_id=2',\n          '&language=en',\n          `&metadata_id=${encodeURIComponent('159283,167702')}`,\n          `&date_time_from=${encodeURIComponent(fromDate.format())}`,\n          `&date_time_to=${encodeURIComponent(toDate.format())}`,\n          page > 1 ? `&page=${page}` : '',\n        ].join('');\n\n        const {data} = await axios.get(url, {\n          headers: {\n            'user-agent': okHttpUserAgent,\n          },\n        });\n\n        if (data.meta.last_page === page) {\n          hasNextPage = false;\n        }\n\n        events = events.concat(data.data);\n        page += 1;\n      }\n\n      debug.saveRequestData(events, 'b1g+', 'epg');\n\n      await parseAirings(events);\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse B1G+ events');\n    }\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    const id = eventId.replace('b1g-', '');\n\n    try {\n      if (this.access_token) {\n        await this.extendToken();\n      }\n\n      const accessToken = await this.checkAccess(id);\n      const {user_id}: {user_id: string} = jwt_decode(accessToken);\n      const streamUrl = await this.getStream(id, user_id, accessToken);\n\n      return [streamUrl, {}];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start playback');\n    }\n  };\n\n  private extendToken = async (): Promise<void> => {\n    try {\n      const url = ['https://', 'www.bigtenplus.com', '/api/v3/cleeng/extend_token'].join('');\n      const headers = {\n        Authorization: `Bearer ${this.access_token}`,\n        'User-Agent': b1gUserAgent,\n        accept: 'application/json',\n      };\n\n      const {data} = await axios.post(\n        url,\n        {},\n        {\n          headers,\n        },\n      );\n\n      this.access_token = data.token;\n      this.expires_at = moment().add(399, 'days').valueOf();\n\n      await this.save();\n    } catch (e) {\n      console.error(e);\n      console.log('Could not extend token for B1G+');\n    }\n  };\n\n  private checkAccess = async (eventId: string, hideError?: boolean): Promise<string> => {\n    try {\n      const url = `https://www.bigtenplus.com/api/v3/contents/${eventId}/check-access`;\n      const headers = {\n        'User-Agent': b1gUserAgent,\n        accept: 'application/json',\n        'content-type': 'application/json',\n      };\n      if (this.access_token) {\n        headers['Authorization'] = `Bearer ${this.access_token}`;\n      }\n\n      const params = {\n        type: 'cleeng',\n      };\n\n      const {data} = await axios.post(url, params, {\n        headers,\n      });\n\n      return data.data;\n    } catch (e) {\n      if (!hideError) {\n        console.error(e);\n        console.log('Could not get playback access token');\n      }\n    }\n  };\n\n  public ispAccess = async (): Promise<boolean> => {\n    try {\n      const url = [\n        'https://',\n        'www.bigtenplus.com',\n        '/api/v2',\n        '/events',\n        '?sort_direction=asc',\n        '&device_category_id=2',\n        '&language=en',\n        `&metadata_id=${encodeURIComponent('159283,167702')}`,\n        `&date_time_from=${encodeURIComponent(moment().format())}`,\n        '&limit=1',\n      ].join('');\n\n      const {data} = await axios.get(url, {\n        headers: {\n          'user-agent': okHttpUserAgent,\n        },\n      });\n\n      const id = data.data[0].content[0].id;\n\n      const accessToken = await this.checkAccess(id, true);\n\n      if (accessToken) {\n        console.log('Detected ISP access');\n        return true;\n      }\n      console.log('Did not detect ISP access');\n    } catch (e) {\n      console.log('Could not check ISP access');\n    }\n    return false;\n  };\n\n  private getStream = async (eventId: string, userId: string, accessToken: string): Promise<string> => {\n    try {\n      const url = [\n        'https://',\n        'www.bigtenplus.com',\n        '/api/v3',\n        '/contents',\n        `/${eventId}`,\n        '/access/hls',\n        `?csid=${userId}`,\n      ].join('');\n\n      const headers = {\n        Authorization: `Bearer ${accessToken}`,\n        'User-Agent': okHttpUserAgent,\n        'content-type': 'application/json',\n      };\n\n      const {data} = await axios.post(\n        url,\n        {},\n        {\n          headers,\n        },\n      );\n\n      return data.data.stream;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get playback access token');\n    }\n  };\n\n  public login = async (username?: string, password?: string): Promise<boolean> => {\n    try {\n      const url = ['https://', 'www.bigtenplus.com', '/api/v3/cleeng/login'].join('');\n      const headers = {\n        'User-Agent': b1gUserAgent,\n        accept: 'application/json',\n        'content-type': 'application/json',\n      };\n\n      const {meta} = await db.providers.findOneAsync<IProvider<any, IB1GMeta>>({name: 'b1g'});\n      if (username == '' || !meta.username || meta.username == '') return true;\n\n      const params = {\n        email: username || meta.username,\n        password: password || meta.password,\n      };\n\n      const {data} = await axios.post(url, params, {\n        headers,\n      });\n\n      this.access_token = data.token;\n      this.expires_at = moment().add(399, 'days').valueOf();\n\n      await this.save();\n\n      return true;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not login to B1G+');\n\n      return false;\n    }\n  };\n\n  private save = async (): Promise<void> => {\n    await db.providers.updateAsync({name: 'b1g'}, {$set: {tokens: this}});\n  };\n\n  private load = async (): Promise<void> => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TB1GTokens>>({name: 'b1g'});\n    const {access_token, expires_at} = tokens;\n\n    this.access_token = access_token;\n    this.expires_at = expires_at;\n  };\n\n  private loadJSON = () => {\n    if (fs.existsSync(b1gConfigPath)) {\n      const {access_token, expires_at} = fsExtra.readJSONSync(path.join(configPath, 'b1g_tokens.json'));\n\n      this.access_token = access_token;\n      this.expires_at = expires_at;\n    }\n  };\n}\n\nexport type TB1GTokens = ClassTypeWithoutMethods<B1GHandler>;\n\nexport const b1gHandler = new B1GHandler();\n"
  },
  {
    "path": "services/bally-handler.ts",
    "content": "import axios from 'axios';\nimport moment from 'moment';\n\nimport {userAgent} from './user-agent';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {normalTimeRange} from './shared-helpers';\nimport {debug} from './debug';\nimport {usesLinear} from './misc-db-service';\n\ninterface IBallyTeam {\n  name: string;\n  logo_svg: string;\n  color_base: string;\n}\n\ninterface IBallyEvent {\n  id: number;\n  date_time: string;\n  channel_name: string;\n  public_cdn_url: string;\n  home_team: IBallyTeam;\n  away_team: IBallyTeam;\n}\n\ninterface IBallyLinearEvent {\n  id: number;\n  title: string;\n  since: string;\n  till: string;\n  channelUuid: number;\n}\n\ninterface IBallyEPGRes {\n  games: IBallyEvent[];\n}\n\ninterface IBallyLinearEPGRes {\n  events: IBallyLinearEvent[];\n}\n\nconst API_KEY = [\n  '9',\n  '9',\n  'c',\n  '1',\n  'd',\n  '4',\n  'f',\n  'a',\n  '-',\n  'u',\n  't',\n  'x',\n  'j',\n  '-',\n  '9',\n  '9',\n  '3',\n  '6',\n  '-',\n  'f',\n  '9',\n  'a',\n  'e',\n  '-',\n  'a',\n  'd',\n  '3',\n  'c',\n  '4',\n  '3',\n  'b',\n  '8',\n  'e',\n  '4',\n  'f',\n  '5',\n].join('');\n\nconst CHANNEL_IMAGE_MAP = {\n  1001: 'https://tmsimg.fancybits.co/assets/s131359_ll_h9_aa.png?w=360&h=270',\n  15: 'https://tmsimg.fancybits.co/assets/s104950_ll_h15_aa.png?w=360&h=270',\n  2: 'https://assets-stratosphere.cdn.ballys.tv/images/MiLB_New_Logo_23.png',\n  29: 'https://assets-stratosphere.cdn.ballys.tv/images/BananaBall_SB_01.png',\n  6: 'https://assets-stratosphere.ballys.tv/images/BallyPoker_Channel_V3.png',\n} as const;\n\nconst CHANNEL_MAP = {\n  1001: 'GLORY',\n  15: 'STADIUM',\n  2: 'MiLB',\n  29: 'bananaball',\n  6: 'ballypoker',\n} as const;\n\nconst CHANNEL_MAP_SWAP = {\n  GLORY: 1001,\n  MiLB: 2,\n  STADIUM: 15,\n  ballypoker: 6,\n  bananaball: 29,\n} as const;\n\nconst parseAirings = async (events: IBallyEvent[]) => {\n  const [now, endDate] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.id) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `bally-${event.id}`});\n\n    if (!entryExists) {\n      const start = moment(event.date_time);\n      const end = moment(start).add(4, 'hours');\n      const originalEnd = moment(start).add(3, 'hours');\n\n      if (end.isBefore(now) || start.isAfter(endDate)) {\n        continue;\n      }\n\n      const eventName = `${event.away_team.name} at ${event.home_team.name}`;\n\n      console.log('Adding event: ', eventName);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: ['MiLB', 'Baseball', event.away_team.name, event.home_team.name, 'Bally Sports'],\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'bally',\n        id: `bally-${event.id}`,\n        image: 'https://img.mlbstatic.com/milb-images/image/upload/t_16x9/t_w2208/milb/omagzj463xltjyijyzzr',\n        name: eventName,\n        network: 'Bally Sports Live',\n        originalEnd: originalEnd.valueOf(),\n        sport: 'MiLB',\n        start: start.valueOf(),\n        url: event.public_cdn_url,\n      });\n    }\n  }\n};\n\nconst parseLinearAirings = async (events: IBallyLinearEvent[]) => {\n  const [now, endDate] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.id) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `bally-live-${event.id}`});\n\n    if (!entryExists) {\n      const start = moment(event.since);\n      const end = moment(event.till);\n\n      if (end.isBefore(now) || start.isAfter(endDate)) {\n        continue;\n      }\n\n      console.log('Adding event: ', event.title);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: ['Bally Sports'],\n        channel: CHANNEL_MAP[event.channelUuid],\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'bally',\n        id: `bally-live-${event.id}`,\n        image: CHANNEL_IMAGE_MAP[event.channelUuid],\n        linear: true,\n        name: event.title,\n        network: 'Bally Sports Live',\n        start: start.valueOf(),\n      });\n    }\n  }\n};\n\nclass BallyHandler {\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'bally'})) > 0 ? true : false;\n\n    // First time setup\n    if (!setup) {\n      const useLinear = await usesLinear();\n\n      await db.providers.insertAsync<IProvider<TBallyTokens>>({\n        enabled: false,\n        linear_channels: [\n          {\n            enabled: useLinear,\n            id: 'STADIUM',\n            name: 'Stadium HD',\n            tmsId: '104950',\n          },\n          {\n            enabled: useLinear,\n            id: 'MiLB',\n            name: 'MiLB',\n          },\n          {\n            enabled: useLinear,\n            id: 'bananaball',\n            name: 'Banana Ball',\n          },\n          {\n            enabled: useLinear,\n            id: 'ballypoker',\n            name: 'Bally Poker',\n          },\n          {\n            enabled: useLinear,\n            id: 'GLORY',\n            name: 'GLORY Kickboxing',\n            tmsId: '131359',\n          },\n        ],\n        name: 'bally',\n      });\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'bally'});\n\n    if (!enabled) {\n      return;\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled, linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'bally'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log('Looking for Bally Sports events...');\n\n    const entries: IBallyEvent[] = [];\n    const linearEntries: IBallyLinearEvent[] = [];\n\n    const [now, endSchedule] = normalTimeRange();\n\n    try {\n      const url = [\n        'https://',\n        'api-prod.prod2.ballylive.app',\n        '/main/api/v1',\n        '/content-service',\n        '/mlb/schedule',\n        '?startDate=',\n        now.format('YYYY-MM-DD'),\n        '&endDate=',\n        endSchedule.format('YYYY-MM-DD'),\n        '&includeFakeGames=false',\n      ].join('');\n\n      const {data} = await axios.get<IBallyEPGRes[]>(url, {\n        headers: {\n          'Content-Type': 'application/json',\n          'User-Agent': userAgent,\n          'x-api-key': API_KEY,\n        },\n      });\n\n      debug.saveRequestData(data, 'bally', 'epg');\n\n      data.forEach(e => e.games.forEach(g => entries.push(g)));\n\n      const useLinear = await usesLinear();\n\n      if (useLinear) {\n        const linearUrl = ['https://', 'api-prod.prod2.ballylive.app', '/main/video/epg'].join('');\n\n        const {data: linearData} = await axios.get<IBallyLinearEPGRes>(linearUrl, {\n          headers: {\n            'Content-Type': 'application/json',\n            'User-Agent': userAgent,\n            'x-api-key': API_KEY,\n          },\n        });\n\n        const linearChannelMap = new Map();\n\n        linearData.events.forEach(e => {\n          const channelId = CHANNEL_MAP[e.channelUuid];\n\n          let enabled = false;\n\n          if (!linearChannelMap.has(channelId)) {\n            enabled = linear_channels.find(c => c.id === channelId)?.enabled;\n            linearChannelMap.set(channelId, enabled);\n          } else {\n            enabled = linearChannelMap.get(channelId);\n          }\n\n          if (enabled) {\n            linearEntries.push(e);\n          }\n        });\n      }\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse Bally Sports events');\n    }\n\n    await parseAirings(entries);\n    await parseLinearAirings(linearEntries);\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    const event = await db.entries.findOneAsync<IEntry>({id: eventId});\n\n    try {\n      if (eventId.indexOf('bally-live-') > -1) {\n        const {channel} = event;\n\n        const channelId = CHANNEL_MAP_SWAP[channel];\n        const url = ['https://', 'api-prod.prod2.ballylive.app', '/main/video/linear-channels'].join('');\n\n        const {data} = await axios.get(url, {\n          headers: {\n            'Content-Type': 'application/json',\n            'User-Agent': userAgent,\n            'x-api-key': API_KEY,\n          },\n        });\n\n        const channelData = data.channels.find(c => c.uuid === channelId);\n\n        if (channelData) {\n          return [channelData.stream_info.connected_tv.default_abr, {}];\n        } else {\n          throw new Error('Could not start playback');\n        }\n      }\n\n      let streamUrl: string;\n\n      if (event.url) {\n        streamUrl = event.url;\n      }\n\n      return [streamUrl, {}];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start playback');\n    }\n  };\n}\n\nexport type TBallyTokens = ClassTypeWithoutMethods<BallyHandler>;\n\nexport const ballyHandler = new BallyHandler();\n"
  },
  {
    "path": "services/build-schedule.ts",
    "content": "import {db, IDocument} from './database';\nimport {getNumberOfChannels, getStartChannel, usesLinear, getCategoryFilter, getTitleFilter} from './misc-db-service';\nimport {IChannel, IEntry} from './shared-interfaces';\nimport {formatEntryName, usesMultiple} from './generate-xmltv';\n\nexport const removeEntriesProvider = async (providerName: string): Promise<void> => {\n  await db.entries.removeAsync({from: providerName}, {multi: true});\n};\n\nexport const removeEntriesNetwork = async (networkName: string): Promise<void> => {\n  await db.entries.removeAsync({network: networkName}, {multi: true});\n};\n\nconst scheduleEntry = async (entry: IEntry & IDocument, startChannel: number, numOfChannels: number): Promise<void> => {\n  let channelNum: number;\n\n  const availableChannels = await db.schedule\n    .findAsync<IChannel & IDocument>({channel: {$gte: startChannel}, endsAt: {$lt: entry.start}})\n    .sort({channel: 1});\n\n  if (!availableChannels || !availableChannels.length) {\n    const channelNums = await db.schedule.countAsync({});\n\n    if (channelNums > numOfChannels - 1) {\n      return;\n    }\n\n    channelNum = channelNums + startChannel;\n\n    await db.schedule.insertAsync<IChannel>({\n      channel: channelNum,\n      endsAt: entry.end,\n    });\n  } else {\n    channelNum = +availableChannels[0].channel;\n\n    await db.schedule.updateAsync<IChannel & IDocument, any>(\n      {_id: availableChannels[0]._id},\n      {$set: {endsAt: entry.end}},\n    );\n  }\n\n  await db.entries.updateAsync<IEntry, any>({_id: entry._id}, {$set: {channel: channelNum}});\n};\n\nexport const scheduleEntries = async (): Promise<void> => {\n  let needReschedule = false;\n\n  const useLinear = await usesLinear();\n  const startChannel = await getStartChannel();\n  const numOfChannels = await getNumberOfChannels();\n\n  if (!useLinear) {\n    const linearEntries = await db.entries.countAsync({linear: {$exists: true}});\n\n    if (linearEntries > 0) {\n      needReschedule = true;\n    }\n  }\n\n  if (needReschedule) {\n    console.log('');\n    console.log('====================================================================');\n    console.log('===                                                              ===');\n    console.log('===   Need to rebuild the schedule because the linear channels   ===');\n    console.log('===            variable is no longer being used.                 ===');\n    console.log('===                                                              ===');\n    console.log('====================================================================');\n    console.log('===  THIS WILL BREAK SCHEDULED RECORDINGS IN YOUR DVR SOFTWARE   ===');\n    console.log('====================================================================');\n    console.log('');\n\n    // Remove schedule\n    await db.schedule.removeAsync({}, {multi: true});\n\n    // Remove all dedicated linear channel entries\n    await db.entries.removeAsync(\n      {$or: [{channel: 'cbssportshq'}, {channel: 'golazo'}, {channel: 'NFLNETWORK'}, {channel: 'NFLDIGITAL1_OO_v3'}]},\n      {multi: true},\n    );\n\n    // Remove channel and linear props from existing entries\n    await db.entries.updateAsync<IEntry, any>({}, {$unset: {channel: true, linear: true}}, {multi: true});\n\n    return await scheduleEntries();\n  }\n\n  const unscheduledEntries = await db.entries\n    .findAsync<IEntry & IDocument>({channel: {$exists: false}})\n    .sort({start: 1});\n\n  const useMultiple = await usesMultiple();\n\n  const categoryFilter = await getCategoryFilter();\n\n  const normalized_category_filters =\n    categoryFilter && categoryFilter.trim().length > 0\n      ? categoryFilter.split(',').map(category => category.toLowerCase().trim())\n      : [];\n\n  const titleFilter = await getTitleFilter();\n\n  const normalized_title_filter =\n    titleFilter && titleFilter.trim().length > 0 && new RegExp(titleFilter);\n\n  let scheduledEntryCount = 0;\n  for (const entry of unscheduledEntries) {\n    const formattedEntryName = formatEntryName(entry, useMultiple);\n\n    if (\n      normalized_category_filters.length > 0 &&\n      !normalized_category_filters.some(v => entry.categories.map(category => category.toLowerCase()).includes(v))\n    ) {\n      continue;\n    }\n\n    if (normalized_title_filter && !formattedEntryName.match(normalized_title_filter)) {\n      continue;\n    }\n\n    console.log('Scheduling event: ', formattedEntryName);\n    await scheduleEntry(entry, startChannel, numOfChannels);\n    scheduledEntryCount++;\n  }\n\n  scheduledEntryCount > 0 && console.log(`Scheduled ${scheduledEntryCount} entries...`);\n};\n"
  },
  {
    "path": "services/caching.ts",
    "content": "import axios, {AxiosResponse} from 'axios';\n\nimport {generateRandom} from './shared-helpers';\nimport {IHeaders} from './shared-interfaces';\nimport {userAgent} from './user-agent';\n\n// Set a max memory size of 128MB\nconst MAX_SIZE = 1024 * 1024 * 128;\n\ninterface IPromiseMap {\n  promise: Promise<any>;\n  ttl: number;\n}\n\nclass PromiseCache {\n  private mapper = new Map<string, IPromiseMap>();\n\n  public getPromise<T>(keyId: string, call: Promise<any>, ttl: number): Promise<T> {\n    const now = new Date().valueOf();\n\n    const mappedPromse = this.mapper.get(keyId);\n\n    if (mappedPromse && mappedPromse.ttl > now) {\n      return this.mapper.get(keyId).promise.catch(e => {\n        console.error(e);\n        // Remove promise from cache if it has failed\n        this.removePromise(keyId);\n      });\n    }\n\n    this.mapper.set(keyId, {\n      promise: call,\n      ttl: now + ttl,\n    });\n\n    return call;\n  }\n\n  public removePromise(keyId: string) {\n    this.mapper.delete(keyId);\n  }\n}\n\nexport const promiseCache = new PromiseCache();\n\nclass CacheLayer {\n  private keyMap = new Map<string, string>();\n  private chunklistMap = new Map<string, string>();\n\n  private fifo: string[] = [];\n  private size = 0;\n\n  public getChunklistFromUrl(url: string, prefix = ''): string {\n    if (this.chunklistMap.has(url)) {\n      return this.chunklistMap.get(url);\n    }\n\n    const randomId = generateRandom(8, prefix);\n\n    this.chunklistMap.set(url, randomId);\n    this.chunklistMap.set(randomId, url);\n\n    return randomId;\n  }\n\n  public getChunklistFromId(id: string): string {\n    if (this.chunklistMap.has(id)) {\n      return this.chunklistMap.get(id);\n    }\n\n    throw new Error(`Could not find URL for: ${id}`);\n  }\n\n  public getSegmentFromUrl(url: string, prefix = ''): string {\n    if (this.keyMap.has(url)) {\n      return this.keyMap.get(url);\n    }\n\n    const randomId = generateRandom(8, prefix);\n\n    this.keyMap.set(url, randomId);\n    this.keyMap.set(randomId, url);\n\n    return randomId;\n  }\n\n  public async getDataFromSegment(segment: string, headers: IHeaders, network?: string): Promise<ArrayBuffer> {\n    const url = this.keyMap.get(segment);\n\n    if (!url) {\n      throw new Error(`Could not find URL for: ${segment}`);\n    }\n\n    try {\n      const isKey = segment.includes('-key-');\n      const isFoxOne = network === 'foxone';\n      const cacheTTL = (isFoxOne && isKey) ? 1000 * 30 : 1000 * 60 * 3; \n\n      const res = await promiseCache.getPromise<AxiosResponse<ArrayBuffer>>(\n        segment,\n        axios.get<ArrayBuffer>(url, {\n          headers: {\n            'User-Agent': userAgent,\n            ...headers,\n          },\n          responseType: 'arraybuffer',\n        }),\n        cacheTTL,\n      );\n\n      if (!res) {\n        throw new Error('Cached segment or key failed to resolve!');\n      }\n\n      const {data} = res;\n\n      const size = (data as any).length;\n\n      if (!(isFoxOne && isKey)) {\n        while (this.size + size > MAX_SIZE) {\n          const url = this.fifo.shift();\n          const segmentId = this.keyMap.get(url);\n\n          process.nextTick(() => {\n            promiseCache.removePromise(segmentId);\n            this.keyMap.delete(url);\n            this.keyMap.delete(segmentId);\n          });\n\n          this.size -= size;\n        }\n\n        this.fifo.push(url);\n        this.size += size;\n      }\n\n      return data;\n    } catch (e) {\n      if (network === 'foxone' && segment.includes('-key-')) {\n        promiseCache.removePromise(segment);\n      }\n      console.error(`Error fetching ${segment}:`, e.message || e);\n      throw new Error(`Could not fetch data for: ${segment}`);\n    }\n  }\n}\n\nexport const cacheLayer = new CacheLayer();"
  },
  {
    "path": "services/cbs-handler.ts",
    "content": "import fs from 'fs';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport moment from 'moment';\nimport _ from 'lodash';\nimport crypto from 'crypto';\n\nimport {cbsSportsUserAgent, userAgent} from './user-agent';\nimport {configPath} from './config';\nimport {useCBSSports} from './networks';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {getRandomUUID, normalTimeRange} from './shared-helpers';\nimport {createAdobeAuthHeader} from './adobe-helpers';\nimport {debug} from './debug';\n\ninterface ICBSEvent {\n  id: number;\n  video?: {\n    sources?: {\n      hls?: {\n        url: string;\n        urlNoAd: string;\n      };\n    };\n    about: {\n      duration: number;\n      images: {\n        baseImage2x3?: string;\n        baseImage16X9: string;\n        baseImage16X5?: string;\n      };\n      prefix: string;\n      description: string;\n      title: string;\n      shortTitle: string;\n    };\n    network: string;\n    schedule: {\n      videoStartDate: number;\n      videoEndDate: number;\n    };\n    ads: {\n      dai?: {\n        daiAssetKey?: string;\n      };\n    };\n    analytics: {\n      nielsonGenre: string;\n    };\n    properties: {\n      type: string;\n      sport: string;\n      league?: string;\n      leagueDisplayName?: string;\n      tagSlugs?: string[];\n      tags?: string[];\n    };\n    authentication: string[];\n  };\n}\n\ninterface IGameData {\n  name: string;\n  sport: string;\n  image: string;\n  categories: string[];\n}\n\nconst API_KEY = [\n  'l',\n  'y',\n  'R',\n  '2',\n  'U',\n  '3',\n  '7',\n  'S',\n  'i',\n  'e',\n  '8',\n  '0',\n  'c',\n  '0',\n  '2',\n  'c',\n  'J',\n  'M',\n  'p',\n  'O',\n  'H',\n  '4',\n  'C',\n  '3',\n  'g',\n  'J',\n  'e',\n  't',\n  'y',\n  '4',\n  'L',\n  'O',\n  '1',\n  'W',\n  'n',\n  'L',\n  'A',\n  '1',\n  'F',\n  'O',\n].join('');\n\nconst ADOBE_KEY = ['w', 'G', 'x', 'd', 'a', 'c', 'C', 'K', 'M', 'S', '8', 't', 'X', 'n', 'A', 'S'].join('');\n\nconst ADOBE_PUBLIC_KEY = [\n  'G',\n  'F',\n  '6',\n  'q',\n  'D',\n  '5',\n  'q',\n  'a',\n  't',\n  '3',\n  'l',\n  'L',\n  'w',\n  '9',\n  'a',\n  'y',\n  '8',\n  'I',\n  'j',\n  'g',\n  '8',\n  '0',\n  'b',\n  '3',\n  'N',\n  'P',\n  'H',\n  '7',\n  'c',\n  'F',\n  'E',\n  'G',\n].join('');\n\nconst SYNCBAK_KEY = [\n  '0',\n  'e',\n  'f',\n  'b',\n  'e',\n  '7',\n  '9',\n  'd',\n  '9',\n  '6',\n  'f',\n  '2',\n  '4',\n  'f',\n  '9',\n  '2',\n  '8',\n  'd',\n  '9',\n  '1',\n  'f',\n  '5',\n  'f',\n  'd',\n  '8',\n  '9',\n  '5',\n  '5',\n  'd',\n  '1',\n  '4',\n  '3',\n].join('');\n\nconst SYNCBAK_PUBLIC_KEY = [\n  '1',\n  'b',\n  '3',\n  'c',\n  '7',\n  '2',\n  '7',\n  'c',\n  'a',\n  '1',\n  '1',\n  '6',\n  '4',\n  'a',\n  '1',\n  '9',\n  '8',\n  '5',\n  '1',\n  'a',\n  '1',\n  '0',\n  '2',\n  'e',\n  'a',\n  '6',\n  '5',\n  '0',\n  'e',\n  '4',\n  '9',\n  'd',\n].join('');\n\nconst CHANNEL_MAP = {\n  CBSCHAMPIONSLEAGUE: 'ydKcHHYQSt27vbSP38xMVw',\n  CBSSGOLAZO: '7f3Wv6f7QEKfQna22jHqLQ',\n  CBSSHQ: '9Lq0ERvoSR-z9AwvFS-xYA',\n} as const;\n\nconst cbsConfigPath = path.join(configPath, 'cbs_tokens.json');\n\nconst getEventData = (event: ICBSEvent): IGameData => {\n  const sport = event.video.properties.sport;\n  const categories: string[] = [\n    'CBS Sports',\n    'CBS',\n    sport,\n    ...(event.video.properties.tagSlugs || []),\n    event.video.properties.league,\n    event.video.properties.leagueDisplayName,\n  ];\n\n  return {\n    categories: [...new Set(categories)].filter(a => a),\n    image:\n      event.video.about.images.baseImage16X9 ||\n      event.video.about.images.baseImage2x3 ||\n      event.video.about.images.baseImage16X5,\n    name: event.video.about.title,\n    sport,\n  };\n};\n\nconst parseAirings = async (events: ICBSEvent[]) => {\n  const [now, endDate] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.id) {\n      continue;\n    }\n\n    const gameData = getEventData(event);\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: event.id});\n\n    if (!entryExists) {\n      const start = moment(event.video.schedule.videoStartDate * 1000);\n      const end = moment(event.video.schedule.videoEndDate * 1000).add(1, 'hour');\n      const originalEnd = moment(event.video.schedule.videoEndDate * 1000);\n\n      if (end.isBefore(now) || start.isAfter(endDate)) {\n        continue;\n      }\n\n      console.log('Adding event: ', gameData.name);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: gameData.categories,\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        feed: event.video.network,\n        from: 'cbssports',\n        id: `${event.id}`,\n        image: gameData.image,\n        name: gameData.name,\n        network: 'CBS Sports',\n        originalEnd: originalEnd.valueOf(),\n        sport: gameData.sport,\n        start: start.valueOf(),\n        ...((event.video.sources.hls.urlNoAd || event.video.sources.hls.url) && {\n          url: event.video.sources.hls.urlNoAd || event.video.sources.hls.url,\n        }),\n      });\n    }\n  }\n};\n\nclass CBSHandler {\n  public device_id?: string;\n  public user_id?: string;\n  public mvpd_id?: string;\n  public expires_at?: string;\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'cbs'})) > 0 ? true : false;\n\n    // First time setup\n    if (!setup) {\n      const data: TCBSTokens = {};\n\n      if (useCBSSports) {\n        this.loadJSON();\n\n        data.device_id = this.device_id;\n        data.user_id = this.user_id;\n        data.mvpd_id = this.mvpd_id;\n      }\n\n      await db.providers.insertAsync<IProvider<TCBSTokens>>({\n        enabled: useCBSSports,\n        name: 'cbs',\n        tokens: data,\n      });\n\n      if (fs.existsSync(cbsConfigPath)) {\n        fs.rmSync(cbsConfigPath);\n      }\n    }\n\n    if (useCBSSports) {\n      console.log('Using CBSSPORTS variable is no longer needed. Please use the UI going forward');\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'cbs'});\n\n    if (!enabled) {\n      return;\n    }\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n  };\n\n  public refreshTokens = async () => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'cbs'});\n\n    if (!enabled) {\n      return;\n    }\n\n    await this.adobeAuthN();\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'cbs'});\n\n    if (!enabled) {\n      return;\n    }\n\n    const dma = await this.getDMACode();\n\n    console.log('Looking for CBS Sports events...');\n\n    const entries: ICBSEvent[] = [];\n\n    const [now, endSchedule] = normalTimeRange();\n    now.subtract(12, 'hours');\n\n    try {\n      const url = [\n        'https://',\n        'video-api.cbssports.com',\n        '/vms/events/v5/',\n        '?device=firetv',\n        '&transform=ottv5',\n        '&dma=',\n        dma,\n      ].join('');\n\n      const {data} = await axios.get<ICBSEvent[]>(url, {\n        headers: {\n          'Content-Type': 'application/json',\n          'User-Agent': cbsSportsUserAgent,\n          'x-api-key': API_KEY,\n        },\n      });\n\n      debug.saveRequestData(data, 'cbssports', 'epg');\n\n      data.forEach(e => {\n        if (\n          (e.video?.authentication.includes('adobe') || _.isEqual(e.video?.authentication, [])) &&\n          moment(e.video.schedule.videoStartDate * 1000).isBefore(endSchedule) &&\n          // Some events have a crazy old start date\n          moment(e.video.schedule.videoStartDate * 1000).isAfter(now) &&\n          moment(e.video.schedule.videoEndDate * 1000).isAfter(now)\n        ) {\n          entries.push(e);\n        }\n      });\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse CBS Sports events');\n    }\n\n    await parseAirings(entries);\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    const event = await db.entries.findOneAsync<IEntry>({id: eventId});\n\n    try {\n      let streamUrl: string;\n\n      if (event.url) {\n        streamUrl = event.url;\n      }\n\n      // CBSSN || CBSE\n      if (!CHANNEL_MAP[event.feed]) {\n        const dma = await this.getDMACode();\n        const token = this.generateTimedToken();\n\n        const network = event.feed === 'CBSSN' ? 'CBS_SPORTS_NETWORK' : 'CBS_ENTERTAINMENT';\n\n        const url = [\n          'https://',\n          'www.cbssports.com',\n          '/api/content',\n          '/video/syncbak/get-secure-url',\n          '/1b3c727ca1164a19851a102ea650e49d/',\n          token,\n          `/${network}/`,\n          this.mvpd_id,\n          '/8/',\n          dma,\n          '/?as=json&version=4',\n        ].join('');\n\n        const {data} = await axios.get(url, {\n          headers: {\n            'User-Agent': cbsSportsUserAgent,\n          },\n        });\n\n        streamUrl = data.SUCCESS;\n      } else if (!streamUrl) {\n        const url = ['https://', 'pubads.g.doubleclick.net', '/ssai/event/', CHANNEL_MAP[event.feed], '/streams'].join(\n          '',\n        );\n\n        const {data} = await axios.post(\n          url,\n          {},\n          {\n            headers: {\n              'Content-Type': 'application/x-www-form-urlencoded',\n              'User-Agent': userAgent,\n            },\n          },\n        );\n\n        streamUrl = data.stream_manifest;\n      }\n\n      return [streamUrl, {}];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start playback');\n    }\n  };\n\n  private generateTimedToken = (): string =>\n    crypto\n      .createHmac('sha1', SYNCBAK_KEY)\n      .update(`${Math.floor(Date.now() / 1000)}${SYNCBAK_PUBLIC_KEY}`)\n      .digest('hex');\n\n  private getDMACode = async (): Promise<string> => {\n    try {\n      const url = ['https://', 'video-api-geo.cbssports.com/'].join('');\n\n      const {data} = await axios.get(url, {\n        headers: {\n          'Content-Type': 'application/json',\n          'User-Agent': cbsSportsUserAgent,\n          'x-api-key': API_KEY,\n        },\n      });\n\n      return data.dmaId;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get DMA Code for CBS Sports');\n    }\n  };\n\n  public getAuthCode = async (): Promise<string> => {\n    this.device_id = getRandomUUID();\n\n    try {\n      const url = [\n        'https://',\n        'video-api.cbssports.com',\n        '/vms',\n        '/shortcode',\n        '/v1',\n        '?deviceId=',\n        this.device_id,\n        '&deviceType=androidtv',\n        '&authTypes=adobe',\n        '&currentSubscriptions=',\n      ].join('');\n\n      const {data} = await axios.get(url, {\n        headers: {\n          'Content-Type': 'application/json',\n          'User-Agent': cbsSportsUserAgent,\n          'x-api-key': API_KEY,\n        },\n      });\n\n      return data.code;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not login to CBS Sports');\n    }\n  };\n\n  public authenticateRegCode = async (code: string): Promise<boolean> => {\n    try {\n      const url = ['https://', 'video-api.cbssports.com', '/vms/shortcode/v1', '/status?shortcode=', code].join('');\n\n      const {data} = await axios.get(url, {\n        headers: {\n          'Content-Type': 'application/json',\n          'User-Agent': cbsSportsUserAgent,\n          'x-api-key': API_KEY,\n        },\n      });\n\n      if (!data || data?.subscriptions.adobe !== 'y') {\n        return false;\n      }\n\n      await this.adobeAuthN();\n\n      return true;\n    } catch (e) {\n      return false;\n    }\n  };\n\n  private adobeAuthN = async (): Promise<void> => {\n    try {\n      const url = [\n        'https://',\n        'api.auth.adobe.com',\n        '/api/v1/tokens/authn',\n        '?requestor=CBS_SPORTS',\n        '&deviceId=',\n        this.device_id,\n        '&deviceType=androidtv',\n      ].join('');\n\n      const {data} = await axios.get(url, {\n        headers: {\n          Authorization: createAdobeAuthHeader('GET', '/authn', ADOBE_KEY, ADOBE_PUBLIC_KEY, 'CBS_SPORTS'),\n          'Content-Type': 'application/json',\n          'User-Agent': cbsSportsUserAgent,\n        },\n      });\n\n      this.user_id = data.userId;\n      this.mvpd_id = data.mvpd;\n      this.expires_at = data.expires;\n\n      await this.save();\n    } catch (e) {\n      console.error(e);\n      console.log('Could not lauthenticate with Adobe (CBS Sports)');\n    }\n  };\n\n  private save = async (): Promise<void> => {\n    await db.providers.updateAsync({name: 'cbs'}, {$set: {tokens: this}});\n  };\n\n  private loadJSON = () => {\n    if (fs.existsSync(cbsConfigPath)) {\n      const {device_id, user_id, mvpd_id} = fsExtra.readJSONSync(cbsConfigPath);\n\n      this.device_id = device_id;\n      this.user_id = user_id;\n      this.mvpd_id = mvpd_id;\n    }\n  };\n\n  private load = async (): Promise<void> => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TCBSTokens>>({name: 'cbs'});\n    const {device_id, user_id, mvpd_id, expires_at} = tokens || {};\n\n    this.device_id = device_id;\n    this.user_id = user_id;\n    this.mvpd_id = mvpd_id;\n    this.expires_at = expires_at;\n  };\n}\n\nexport type TCBSTokens = ClassTypeWithoutMethods<CBSHandler>;\n\nexport const cbsHandler = new CBSHandler();\n"
  },
  {
    "path": "services/channels.ts",
    "content": "import _ from 'lodash';\n\nimport {foxOneHandler} from './foxone-handler';\nimport {db} from './database';\nimport {IProvider} from './shared-interfaces';\nimport {getLinearStartChannel, usesLinear} from './misc-db-service';\nimport {gothamHandler} from './gotham-handler';\n\nasync function startApp() {\n  await foxOneHandler.initialize(); // Ensures stationMap is populated\n}\n\nexport const checkChannelEnabled = async (provider: string, channelId: string): Promise<boolean> => {\n  const {enabled, linear_channels} = await db.providers.findOneAsync<IProvider>({name: provider});\n\n  if (!enabled || !linear_channels || !linear_channels.length) {\n    return false;\n  }\n\n  const network = linear_channels.find(c => c.id === channelId);\n\n  return network?.enabled;\n};\n\n// Function to get dynamic stationId and callSign from foxOneHandler\nconst getFoxOneChannelData = async () => {\n\n  const stationMap = await foxOneHandler.getStationMap();\n\n  //console.log('getFoxOneChannelData Station Map:    ', stationMap)\n  // Check if stationMap is empty or missing required keys\n  // if (!stationMap['FOX'] || !stationMap['MNTV']) {\n  //   await foxOneHandler.getEvents(); // Populate stationMap if empty\n  // }\n\n  return {\n    foxStationId: stationMap['FOX']?.stationId,\n    foxCallSign: stationMap['FOX']?.callSign,\n    mnStationId: stationMap['MNTV']?.stationId,\n    mnCallSign: stationMap['MNTV']?.callSign,\n  };\n};\n\n/* eslint-disable sort-keys-custom-order-fix/sort-keys-custom-order-fix */\nexport const CHANNELS = {\n  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types\n  get MAP() {\n    return {\n      0: {\n        checkChannelEnabled: () => checkChannelEnabled('espn', 'espn1'),\n        id: 'espn1',\n        logo: 'https://tmsimg.fancybits.co/assets/s32645_h3_aa.png?w=360&h=270',\n        name: 'ESPN',\n        stationId: '32645',\n        tvgName: 'ESPNHD',\n        provider: 'espn',\n      },\n      1: {\n        checkChannelEnabled: () => checkChannelEnabled('espn', 'espn2'),\n        id: 'espn2',\n        logo: 'https://tmsimg.fancybits.co/assets/s45507_ll_h15_aa.png?w=360&h=270',\n        name: 'ESPN2',\n        stationId: '45507',\n        tvgName: 'ESPN2HD',\n        provider: 'espn',\n      },\n      2: {\n        checkChannelEnabled: () => checkChannelEnabled('espn', 'espnu'),\n        id: 'espnu',\n        logo: 'https://tmsimg.fancybits.co/assets/s60696_ll_h15_aa.png?w=360&h=270',\n        name: 'ESPNU',\n        stationId: '60696',\n        tvgName: 'ESPNUHD',\n        provider: 'espn',\n      },\n      3: {\n        checkChannelEnabled: () => checkChannelEnabled('espn', 'sec'),\n        id: 'sec',\n        logo: 'https://tmsimg.fancybits.co/assets/s89714_ll_h15_aa.png?w=360&h=270',\n        name: 'SEC Network',\n        stationId: '89714',\n        tvgName: 'SECH',\n        provider: 'espn',\n      },\n      4: {\n        checkChannelEnabled: () => checkChannelEnabled('espn', 'acc'),\n        id: 'acc',\n        logo: 'https://tmsimg.fancybits.co/assets/s111871_ll_h15_ac.png?w=360&h=270',\n        name: 'ACC Network',\n        stationId: '111871',\n        tvgName: 'ACC',\n        provider: 'espn',\n      },\n      5: {\n        checkChannelEnabled: () => checkChannelEnabled('espn', 'espnews'),\n        id: 'espnews',\n        logo: 'https://tmsimg.fancybits.co/assets/s59976_ll_h15_aa.png?w=360&h=270',\n        name: 'ESPNews',\n        stationId: '59976',\n        tvgName: 'ESPNWHD',\n        provider: 'espn',\n      },\n      6: {\n        checkChannelEnabled: () => checkChannelEnabled('espn', 'espndeportes'),\n        id: 'espndeportes',\n        logo: 'https://tmsimg.fancybits.co/assets/s71914_ll_h15_aa.png?w=360&h=270',\n        name: 'ESPN Deportes',\n        stationId: '71914',\n        tvgName: 'ESPNDHD',\n      },\n      10: {\n        checkChannelEnabled: () => checkChannelEnabled('foxsports', 'fs1'),\n        id: 'fs1',\n        logo: 'https://tmsimg.fancybits.co/assets/s82547_ll_h15_aa.png?w=360&h=270',\n        name: 'FS1',\n        stationId: '82547',\n        tvgName: 'FS1HD',\n        provider: 'foxsports',\n      },\n      11: {\n        checkChannelEnabled: () => checkChannelEnabled('foxsports', 'fs2'),\n        id: 'fs2',\n        logo: 'https://tmsimg.fancybits.co/assets/s59305_ll_h15_aa.png?w=360&h=270',\n        name: 'FS2',\n        stationId: '59305',\n        tvgName: 'FS2HD',\n        provider: 'foxsports',\n      },\n      12: {\n        checkChannelEnabled: () => checkChannelEnabled('foxsports', 'btn'),\n        id: 'btn',\n        logo: 'https://tmsimg.fancybits.co/assets/s58321_ll_h15_ac.png?w=360&h=270',\n        name: 'B1G Network',\n        stationId: '58321',\n        tvgName: 'BIG10HD',\n        provider: 'foxsports',\n      },\n      13: {\n        checkChannelEnabled: () => checkChannelEnabled('foxsports', 'fox-soccer-plus'),\n        id: 'fox-soccer-plus',\n        logo: 'https://tmsimg.fancybits.co/assets/s66880_ll_h15_aa.png?w=360&h=270',\n        name: 'FOX Soccer Plus',\n        stationId: '66880',\n        tvgName: 'FSCPLHD',\n        provider: 'foxsports',\n      },\n      14: {\n        checkChannelEnabled: () => checkChannelEnabled('foxsports', 'foxdep'),\n        id: 'foxdep',\n        logo: 'https://tmsimg.fancybits.co/assets/s15377_ll_h15_aa.png?w=360&h=270',\n        name: 'FOX Deportes',\n        stationId: '72189',\n        tvgName: 'FXDEPHD',\n        provider: 'foxsports',\n      },\n      20: {\n        checkChannelEnabled: () => checkChannelEnabled('paramount', 'cbssportshq'),\n        id: 'cbssportshq',\n        logo: 'https://tmsimg.fancybits.co/assets/s108919_ll_h15_aa.png?w=360&h=270',\n        name: 'CBS Sports HQ',\n        stationId: '108919',\n        tvgName: 'CBSSPHQ',\n        provider: 'paramount',\n      },\n      21: {\n        checkChannelEnabled: () => checkChannelEnabled('paramount', 'golazo'),\n        id: 'golazo',\n        logo: 'https://tmsimg.fancybits.co/assets/s133691_ll_h15_aa.png?w=360&h=270',\n        name: 'GOLAZO Network',\n        stationId: '133691',\n        tvgName: 'GOLAZO',\n        provider: 'paramount',\n      },\n      30: {\n        checkChannelEnabled: () => checkChannelEnabled('nfl', 'NFLNETWORK'),\n        id: 'NFLNETWORK',\n        logo: 'https://tmsimg.fancybits.co/assets/s45399_ll_h15_aa.png?w=360&h=270',\n        name: 'NFL Network',\n        stationId: '45399',\n        tvgName: 'NFLHD',\n        provider: 'nfl',\n      },\n      31: {\n        checkChannelEnabled: () => checkChannelEnabled('nfl', 'NFLNRZ'),\n        id: 'NFLNRZ',\n        logo: 'https://tmsimg.fancybits.co/assets/s65025_ll_h9_aa.png?w=360&h=270',\n        name: 'NFL RedZone',\n        stationId: '65025',\n        tvgName: 'NFLNRZD',\n        provider: 'nfl',\n      },\n      32: {\n        checkChannelEnabled: () => checkChannelEnabled('nfl', 'NFLDIGITAL1_OO_v3'),\n        id: 'NFLDIGITAL1_OO_v3',\n        logo: 'https://tmsimg.fancybits.co/assets/s121705_ll_h15_aa.png?w=360&h=270',\n        name: 'NFL Channel',\n        stationId: '121705',\n        tvgName: 'NFLDC1',\n        provider: 'nfl',\n      },\n      40: {\n        checkChannelEnabled: () => checkChannelEnabled('mlbtv', 'MLBTVBI'),\n        id: 'MLBTVBI',\n        logo: 'https://tmsimg.fancybits.co/assets/s119153_ll_h15_aa.png?w=360&h=270',\n        name: 'MLB Big Inning',\n        stationId: '119153',\n        tvgName: 'MLBTVBI',\n        provider: 'mlbtv',\n      },\n      41: {\n        checkChannelEnabled: () => checkChannelEnabled('mlbtv', 'MLBN'),\n        id: 'MLBN',\n        logo: 'https://tmsimg.fancybits.co/assets/s62079_ll_h15_aa.png?w=360&h=270',\n        name: 'MLB Network',\n        stationId: '62079',\n        tvgName: 'MLBN',\n        provider: 'mlbtv',\n      },\n      42: {\n        checkChannelEnabled: () => checkChannelEnabled('mlbtv', 'SNY'),\n        id: 'SNY',\n        logo: 'https://tmsimg.fancybits.co/assets/s49603_ll_h9_aa.png?w=360&h=270',\n        name: 'SportsNet New York',\n        stationId: '49603',\n        tvgName: 'SNY',\n        provider: 'mlbtv',\n      },\n      43: {\n        checkChannelEnabled: () => checkChannelEnabled('mlbtv', 'SNLA'),\n        id: 'SNLA',\n        logo: 'https://tmsimg.fancybits.co/assets/s87024_ll_h15_aa.png?w=360&h=270',\n        name: 'Spectrum SportsNet LA HD',\n        stationId: '87024',\n        tvgName: 'SNLA',\n        provider: 'mlbtv',\n      },\n      ...gothamHandler.getLinearChannels(),\n      70: {\n        checkChannelEnabled: async (): Promise<boolean> =>\n          (await db.providers.findOneAsync<IProvider>({name: 'wsn'}))?.enabled,\n        id: 'WSN',\n        logo: 'https://tmsimg.fancybits.co/assets/s124636_ll_h15_aa.png?w=360&h=270',\n        name: \"Women's Sports Network\",\n        stationId: '124636',\n        tvgName: 'WSN',\n        provider: 'wsn',\n      },\n      80: {\n        checkChannelEnabled: () => checkChannelEnabled('nwsl', 'NWSL+'),\n        id: 'NWSL+',\n        logo: 'https://img.dge-prod.dicelaboratory.com/original/2024/11/22101220-tgwkrv9kdmvdqo2o.png',\n        name: 'NWSL+ 24/7',\n        provider: 'nwsl',\n      },\n      90: {\n        checkChannelEnabled: () => checkChannelEnabled('bally', 'STADIUM'),\n        id: 'STADIUM',\n        logo: 'https://tmsimg.fancybits.co/assets/s104950_ll_h15_aa.png?w=360&h=270',\n        name: 'Stadium HD',\n        stationId: '104950',\n        tvgName: 'STADIUM',\n        provider: 'bally',\n      },\n      91: {\n        checkChannelEnabled: () => checkChannelEnabled('bally', 'MiLB'),\n        id: 'MiLB',\n        logo: 'https://assets-stratosphere.cdn.ballys.tv/images/MiLB_New_Logo_23.png',\n        name: 'MiLB',\n        provider: 'bally',\n      },\n      92: {\n        checkChannelEnabled: () => checkChannelEnabled('bally', 'bananaball'),\n        id: 'bananaball',\n        logo: 'https://assets-stratosphere.cdn.ballys.tv/images/BananaBall_SB_01.png',\n        name: 'Banana Ball',\n        provider: 'bally',\n      },\n      93: {\n        checkChannelEnabled: () => checkChannelEnabled('bally', 'ballypoker'),\n        id: 'ballypoker',\n        logo: 'https://assets-stratosphere.ballys.tv/images/BallyPoker_Channel_V3.png',\n        name: 'Bally Poker',\n        provider: 'bally',\n      },\n      94: {\n        checkChannelEnabled: () => checkChannelEnabled('bally', 'GLORY'),\n        id: 'GLORY',\n        logo: 'https://tmsimg.fancybits.co/assets/s131359_ll_h9_aa.png?w=360&h=270',\n        name: 'GLORY Kickboxing',\n        stationId: '131359',\n        tvgName: 'GLORY',\n        provider: 'bally',\n      },\n      100: {\n        checkChannelEnabled: () => checkChannelEnabled('outside', 'OTVSTR'),\n        id: 'OTVSTR',\n        logo: 'https://tmsimg.fancybits.co/assets/s114313_ll_h15_ab.png?w=360&h=270',\n        name: 'Outside',\n        stationId: '114313',\n        tvgName: 'OTVSTR',\n        provider: 'outside',\n      },\n      110: {\n        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FOX'),\n        id: 'FOX',\n        logo: 'https://tmsimg.fancybits.co/assets/s28719_ll_h15_ac.png?w=360&h=270',\n        name: 'FOX',\n        stationId: async() => (await getFoxOneChannelData()).foxStationId,\n        tvgName: async () => `${(await getFoxOneChannelData()).foxCallSign}`,\n        provider: 'foxone',\n      },\n      111: {\n        checkChannelEnabled: () => checkChannelEnabled('foxone', 'MNTV'),\n        id: 'MNTV',\n        logo: 'https://tmsimg.fancybits.co/assets/GNLZZGG0028Y3ZQ.png?w=360&h=270',\n        name: 'MyNetwork TV',\n        stationId: async () => (await getFoxOneChannelData()).mnStationId, // Dynamic stationId\n        tvgName: async () => `${(await getFoxOneChannelData()).mnCallSign}`, // Dynamic callSign\n        provider: 'foxone',\n      },\n      112: {\n        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FS1'),\n        id: 'FS1',\n        logo: 'https://tmsimg.fancybits.co/assets/s82547_ll_h15_aa.png?w=360&h=270',\n        name: 'FS1',\n        stationId: '82547',\n        tvgName: 'FS1HD',\n        provider: 'foxone',\n      },\n      113: {\n        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FS2'),\n        id: 'FS2',\n        logo: 'https://tmsimg.fancybits.co/assets/s59305_ll_h15_aa.png?w=360&h=270',\n        name: 'FS2',\n        stationId: '59305',\n        tvgName: 'FS2HD',\n        provider: 'foxone',\n      },\n      114: {\n        checkChannelEnabled: () => checkChannelEnabled('foxone', 'Big Ten Network'),\n        id: 'Big Ten Network',\n        logo: 'https://tmsimg.fancybits.co/assets/s58321_ll_h15_ac.png?w=360&h=270',\n        name: 'B1G Network',\n        stationId: '58321',\n        tvgName: 'BIG10HD',\n        provider: 'foxone',\n      },\n      115: {\n        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FOX Deportes'),\n        id: 'FOX Deportes',\n        logo: 'https://tmsimg.fancybits.co/assets/s15377_ll_h15_aa.png?w=360&h=270',\n        name: 'FOX Deportes',\n        stationId: '72189',\n        tvgName: 'FXDEPHD',\n        provider: 'foxone',\n      }, \n        116: {\n        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FOX News'),\n        id: 'FOX News',\n        logo: 'https://tmsimg.fancybits.co/assets/s60179_ll_h15_ab.png?w=360&h=270',\n        name: 'FOX News Channel',\n        stationId: '60179',\n        tvgName: 'FNCHD',\n        provider: 'foxone',\n      },\n        117: {\n        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FOX Business'),\n        id: 'FOX Business',\n        logo: 'https://tmsimg.fancybits.co/assets/s58718_ll_h15_ac.png?w=360&h=270',\n        name: 'FOX Business Network',\n        stationId: '58718',\n        tvgName: 'FBNHD',\n        provider: 'foxone',\n      },\n        118: {\n        checkChannelEnabled: () => checkChannelEnabled('foxone', 'TMZ'),\n        id: 'TMZ',\n        logo: 'https://tmsimg.fancybits.co/assets/s149408_ll_h15_aa.png?w=360&h=270',\n        name: 'TMZ',\n        stationId: '149408',\n        tvgName: 'TMZFAST',\n        provider: 'foxone',\n      },\n      119: {\n        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FOX Digital'),\n        id: 'FOX Digital',\n        logo: 'https://tmsimg.fancybits.co/assets/GNLZZGG0027SNRC.png?w=360&h=270',\n        name: 'Masked Singer',\n        provider: 'foxone',\n      }, \n      120: {\n        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FOX Soul'),\n        id: 'FOX Soul',\n        logo: 'https://tmsimg.fancybits.co/assets/s119212_ll_h15_aa.png?w=360&h=270',\n        name: 'Fox Soul',\n        stationId: '119212',\n        tvgName: 'FOXSOUL',\n        provider: 'foxone',\n      },\n      121: {\n        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FOX Weather'),\n        id: 'FOX Weather',\n        logo: 'https://tmsimg.fancybits.co/assets/GNLZZGG0029CYRH.png?w=360&h=270',\n        name: 'Fox Weather',\n        stationId: '121307',\n        tvgName: 'FWX',\n        provider: 'foxone',\n      },\n      122: {\n        checkChannelEnabled: () => checkChannelEnabled('foxone', 'FOX LOCAL'),\n        id: 'FOX LOCAL',\n        logo: 'https://tmsimg.fancybits.co/assets/GNLZZGG0029CYRH.png?w=360&h=270',\n        name: 'Fox Live Now',\n        stationId: '119219',\n        tvgName: 'LIVENOW',\n        provider: 'foxone',\n      },                     \n    };\n  },\n};\n/* eslint-enable sort-keys-custom-order-fix/sort-keys-custom-order-fix */\n\nexport const calculateChannelNumber = async (channelNum: string): Promise<number | string> => {\n  const useLinear = await usesLinear();\n  const linearStartChannel = await getLinearStartChannel();\n\n  const chanNum = parseInt(channelNum, 10);\n\n  if (!useLinear || chanNum < linearStartChannel) {\n    return channelNum;\n  }\n\n  const linearChannel = CHANNELS.MAP[chanNum - linearStartChannel];\n\n  if (linearChannel) {\n    return linearChannel.id;\n  }\n\n  return channelNum;\n};\n\nexport const calculateChannelFromName = async (channelName: string): Promise<number> => {\n  const isNumber = Number.isFinite(parseInt(channelName, 10));\n\n  if (isNumber) {\n    return parseInt(channelName, 10);\n  }\n\n  const linearStartChannel = await getLinearStartChannel();\n\n  let channelNum = Number.MAX_SAFE_INTEGER;\n\n  _.forOwn(CHANNELS.MAP, (val, key) => {\n    if (val.id === channelName) {\n      channelNum = parseInt(key, 10) + linearStartChannel;\n    }\n  });\n\n  return channelNum;\n};\n\nexport const XMLTV_PADDING = process.env.XMLTV_PADDING?.toLowerCase() === 'false' ? false : true;\nexport interface Channel {\n  stationId: () => Promise<string>;\n  tvgName: () => Promise<string>;\n}\n\n"
  },
  {
    "path": "services/config.ts",
    "content": "import path from 'path';\n\nexport const configPath = path.join(process.cwd(), 'config');\n"
  },
  {
    "path": "services/database.ts",
    "content": "import fs from 'fs';\nimport path from 'path';\nimport Datastore from '@seald-io/nedb';\n\nimport {configPath} from './config';\n\nexport const entriesDb = path.join(configPath, 'entries.db');\nexport const scheduleDb = path.join(configPath, 'schedule.db');\nexport const providersDb = path.join(configPath, 'providers.db');\nexport const miscDb = path.join(configPath, 'misc.db');\n\nexport interface IDocument {\n  _id: string;\n}\n\nexport const db = {\n  entries: new Datastore({autoload: true, filename: entriesDb}),\n  misc: new Datastore({autoload: true, filename: miscDb}),\n  providers: new Datastore({autoload: true, filename: providersDb}),\n  schedule: new Datastore({autoload: true, filename: scheduleDb}),\n};\n\nexport const initializeEntries = (): void => fs.writeFileSync(entriesDb, '');\nexport const initializeSchedule = (): void => fs.writeFileSync(scheduleDb, '');\nexport const initializeProviders = (): void => fs.writeFileSync(providersDb, '');\nexport const initializeMisc = (): void => fs.writeFileSync(miscDb, '');\n"
  },
  {
    "path": "services/debug.ts",
    "content": "import path from 'path';\nimport fsExtra from 'fs-extra';\n\nimport {configPath} from './config';\n\nexport const debugPath = path.join(configPath, 'debug');\n\nclass Debug {\n  enabled: boolean;\n\n  constructor() {\n    this.enabled = process.env.DEBUGGING?.toLowerCase() === 'true' ? true : false;\n  }\n\n  public saveRequestData = (data: any, provider: string, type: string): void => {\n    if (!this.enabled) {\n      return;\n    }\n\n    fsExtra.writeJSON(path.join(debugPath, `${provider}-${type}-${new Date().valueOf()}.json`), data, {\n      spaces: 2,\n    });\n  };\n}\n\nexport const debug = new Debug();\n"
  },
  {
    "path": "services/espn-handler.ts",
    "content": "import fs from 'fs';\nimport https from 'https';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport Sockette from 'sockette';\nimport ws from 'ws';\nimport jwt_decode from 'jwt-decode';\nimport _ from 'lodash';\nimport url from 'url';\nimport moment from 'moment';\n\nimport {userAgent} from './user-agent';\nimport {configPath} from './config';\nimport {\n  useEspnPlus,\n  requiresEspnProvider,\n  useAccN,\n  useAccNx,\n  useEspn1,\n  useEspn2,\n  useEspn3,\n  useEspnU,\n  useSec,\n  useSecPlus,\n  useEspnPpv,\n  useEspnews,\n} from './networks';\nimport {IAdobeAuth, willAdobeTokenExpire, createAdobeAuthHeader} from './adobe-helpers';\nimport {getRandomHex, normalTimeRange} from './shared-helpers';\nimport {\n  ClassTypeWithoutMethods,\n  IEntry,\n  IHeaders,\n  IJWToken,\n  IProvider,\n  TChannelPlaybackInfo,\n} from './shared-interfaces';\nimport {db} from './database';\nimport {debug} from './debug';\nimport {usesLinear, hideStudio} from './misc-db-service';\nimport {removeEntriesNetwork} from './build-schedule';\n\nglobal.WebSocket = ws;\n\nconst espnPlusTokens = path.join(configPath, 'espn_plus_tokens.json');\nconst espnLinearTokens = path.join(configPath, 'espn_linear_tokens.json');\n\nconst httpsAgent = new https.Agent({\n  rejectUnauthorized: false,\n});\n\n// For `watch.graph.api.espn.com` URLs\nconst instance = axios.create({\n  httpsAgent,\n});\n\ninterface IAuthResources {\n  [key: string]: boolean;\n}\n\ninterface IEndpoint {\n  href: string;\n  headers: {\n    [key: string]: string;\n  };\n  method: 'POST' | 'GET';\n}\n\ninterface IAppConfig {\n  services: {\n    account: {\n      client: {\n        endpoints: {\n          createAccountGrant: IEndpoint;\n        };\n      };\n    };\n    token: {\n      client: {\n        endpoints: {\n          exchange: IEndpoint;\n        };\n      };\n    };\n    device: {\n      client: {\n        endpoints: {\n          createAccountGrant: IEndpoint;\n          createDeviceGrant: IEndpoint;\n        };\n      };\n    };\n  };\n}\n\ninterface IToken {\n  access_token: string;\n  refresh_token: string;\n  expires_in: number;\n}\n\ninterface IGrant {\n  grant_type: string;\n  assertion: string;\n}\n\ninterface ITokens extends IToken {\n  ttl: number;\n  refresh_ttl: number;\n  swid: string;\n  id_token: string;\n}\n\nexport interface IEspnPlusMeta {\n  use_ppv?: boolean;\n  zip_code?: string;\n  in_market_teams?: string;\n}\n\nexport interface IEspnMeta {\n  sec_plus?: boolean;\n  accnx?: boolean;\n  espn3?: boolean;\n  espn3isp?: boolean;\n  espn_free?: boolean;\n}\n\nconst ADOBE_KEY = ['g', 'B', '8', 'H', 'Y', 'd', 'E', 'P', 'y', 'e', 'z', 'e', 'Y', 'b', 'R', '1'].join('');\n\nconst ADOBE_PUBLIC_KEY = [\n  'y',\n  'K',\n  'p',\n  's',\n  'H',\n  'Y',\n  'd',\n  '8',\n  'T',\n  'O',\n  'I',\n  'T',\n  'd',\n  'T',\n  'M',\n  'J',\n  'H',\n  'm',\n  'k',\n  'J',\n  'O',\n  'V',\n  'm',\n  'g',\n  'b',\n  'b',\n  '2',\n  'D',\n  'y',\n  'k',\n  'N',\n  'K',\n].join('');\n\nconst ANDROID_ID = 'ESPN-OTT.GC.ANDTV-PROD';\n\nconst DISNEY_ROOT_URL = 'https://registerdisney.go.com/jgc/v6/client';\nconst API_KEY_URL = '/{id-provider}/api-key?langPref=en-US';\nconst LICENSE_PLATE_URL = '/{id-provider}/license-plate';\nconst REFRESH_AUTH_URL = '/{id-provider}/guest/refresh-auth?langPref=en-US';\n\nconst BAM_API_KEY = 'ZXNwbiZicm93c2VyJjEuMC4w.ptUt7QxsteaRruuPmGZFaJByOoqKvDP2a5YkInHrc7c';\nconst BAM_APP_CONFIG =\n  'https://bam-sdk-configs.bamgrid.com/bam-sdk/v2.0/espn-a9b93989/browser/v3.4/linux/chrome/prod.json';\n\nconst LINEAR_NETWORKS = ['espn1', 'espn2', 'espnu', 'sec', 'acc', 'espnews', 'espndeportes'];\n\nconst urlBuilder = (endpoint: string, provider: string) =>\n  `${DISNEY_ROOT_URL}${endpoint}`.replace('{id-provider}', provider);\n\nconst isTokenValid = (token?: string): boolean => {\n  if (!token) {\n    return false;\n  }\n\n  try {\n    const decoded: IJWToken = jwt_decode(token);\n    return new Date().valueOf() / 1000 < decoded.exp;\n  } catch (e) {\n    return false;\n  }\n};\n\nconst willTokenExpire = (token?: string): boolean => {\n  if (!token) {\n    return true;\n  }\n\n  try {\n    const decoded: IJWToken = jwt_decode(token);\n    // Will the token expire in the next hour?\n    return Math.floor(new Date().valueOf() / 1000) + 3600 > decoded.exp;\n  } catch (e) {\n    return true;\n  }\n};\n\nconst willTimestampExpire = (timestamp?: number): boolean => {\n  if (!timestamp) {\n    return true;\n  }\n\n  return moment(timestamp).isBefore(moment().add(2, 'hour'));\n};\n\nconst getApiKey = async (provider: string) => {\n  try {\n    const {headers} = await axios.post(urlBuilder(API_KEY_URL, provider));\n    return headers['api-key'];\n  } catch (e) {\n    console.error(e);\n    console.log('Could not get API key');\n  }\n};\n\nconst fixHeaderKey = (headerVal: string, authToken = '') =>\n  headerVal.replace('{apiKey}', BAM_API_KEY).replace('{accessToken}', authToken);\n\nconst makeApiCall = async (endpoint: IEndpoint, body: any, authToken = '') => {\n  const headers = {};\n  let reqBody: any = _.cloneDeep(body);\n\n  Object.entries(endpoint.headers).forEach(([key, value]) => {\n    headers[key] = fixHeaderKey(value, authToken);\n  });\n\n  if (\n    headers['Content-Type'] === 'application/x-www-form-urlencoded' ||\n    headers['content-type'] === 'application/x-www-form-urlencoded'\n  ) {\n    reqBody = new url.URLSearchParams(reqBody).toString();\n  }\n\n  if (endpoint.method === 'POST') {\n    const {data} = await axios.post(endpoint.href, reqBody, {headers});\n    return data;\n  } else {\n    const {data} = await axios.get(endpoint.href, {headers});\n    return data;\n  }\n};\n\nconst getNetworkInfo = (network?: string) => {\n  let networks = 'null';\n  let packages = '[\"espn_plus\"]';\n\n  if (network === 'espn1') {\n    networks = '[\"e748f3c0-3f7c-3088-a90a-0ccb2588e0ed\"]';\n    packages = 'null';\n  } else if (network === 'espn2') {\n    networks = '[\"017f41a2-ef4f-39d3-9f45-f680b88cd23b\"]';\n    packages = 'null';\n  } else if (network === 'espn3') {\n    networks = '[\"3e99c57a-516c-385d-9c22-2e40aebc7129\"]';\n    packages = 'null';\n  } else if (network === 'espnU') {\n    networks = '[\"500b1f7c-dad5-33f9-907c-87427babe201\"]';\n    packages = 'null';\n  } else if (network === 'secn') {\n    networks = '[\"74459ca3-cf85-381d-b90d-a95ff6e7a207\"]';\n    packages = 'null';\n  } else if (network === 'secnPlus') {\n    networks = '[\"19644d95-cc83-38ed-bdf9-50b9f2e9ebfc\"]';\n    packages = 'null';\n  } else if (network === 'accn') {\n    networks = '[\"76b92674-175c-4ff1-8989-380aa514eb87\"]';\n    packages = 'null';\n  } else if (network === 'accnx') {\n    networks = '[\"9f538e0b-a896-3325-a417-79034e03a248\"]';\n    packages = 'null';\n  } else if (network === 'espnews') {\n    networks = '[\"1e760a1c-c204-339d-8317-8e615c9cc0e0\"]';\n    packages = 'null';\n  } else if (network === 'espndeportes') {\n    networks = '[\"bba8fb76-57ff-3c63-998b-90fef8f4f8b6\"]';\n    packages = 'null';\n  } else if (network === 'espn_free') {\n    networks = '[\"8cc0ae94-324d-3123-859f-7b6a229b1b89\"]';\n    packages = 'null';\n  } else if (network === 'espn_ppv') {\n    networks = '[\"d41c5aaf-e100-4726-841f-1e453af347f9\"]';\n    packages = 'null';\n  }\n\n  return [networks, packages];\n};\n\nclass WebSocketPlus {\n  public wsToken?: ITokens;\n  private wsClient?: Sockette;\n\n  public closeWebSocket = (): void => {\n    if (this.wsClient) {\n      try {\n        this.wsClient.close();\n        this.wsClient = undefined;\n      } catch (e) {}\n    }\n\n    this.wsToken = undefined;\n  };\n\n  public initializeWebSocket = (wsUrl: string, licensePlate: any): void => {\n    this.closeWebSocket();\n\n    this.wsClient = new Sockette(wsUrl, {\n      maxAttempts: 10,\n      onerror: e => {\n        console.error(e);\n        console.log('Could not start authentication for ESPN+');\n\n        this.closeWebSocket();\n      },\n      onmessage: e => {\n        const wsData = JSON.parse(e.data);\n\n        if (wsData.op) {\n          if (wsData.op === 'C') {\n            this.wsClient.json({\n              op: 'S',\n              rc: 200,\n              sid: wsData.sid,\n              tc: licensePlate.data.fastCastTopic,\n            });\n          } else if (wsData.op === 'P') {\n            this.wsToken = JSON.parse(wsData.pl);\n          }\n        }\n      },\n      onopen: () => {\n        this.wsClient.json({\n          op: 'C',\n        });\n      },\n      timeout: 5e3,\n    });\n  };\n}\n\nconst wsPlus = new WebSocketPlus();\n\nconst authorizedResources: IAuthResources = {};\n\nconst parseCategories = event => {\n  const categories = ['ESPN'];\n  for (const classifier of [event.category, event.subcategory, event.sport, event.league]) {\n    if (classifier !== null && classifier.name !== null) {\n      categories.push(classifier.name);\n    }\n  }\n\n  return [...new Set(categories)];\n};\n\nconst parseAirings = async events => {\n  const useLinear = await usesLinear();\n  const hide_studio = await hideStudio();\n\n  const [now, endSchedule] = normalTimeRange();\n\n  const {meta: plusMeta} = await db.providers.findOneAsync<IProvider<TESPNPlusTokens, IEspnPlusMeta>>({\n    name: 'espnplus',\n  });\n\n  const in_market_team_filter =\n    plusMeta?.in_market_teams && plusMeta?.in_market_teams.length > 0 ? plusMeta?.in_market_teams.split(',') : [];\n    \n  const in_market_feed_filter =\n    plusMeta?.in_market_teams && plusMeta?.in_market_teams.length > 0 ? plusMeta?.in_market_teams.split(',').map(item => {\n    const words = item.trim().split(' ');\n    return words.length > 0 ? words[words.length - 1] : ''; \n  }) : [];\n\n  for (const event of events) {\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: event.id});\n\n    if (!entryExists) {\n      const isLinear = useLinear && event.network?.id && LINEAR_NETWORKS.some(n => n === event.network?.id);\n\n      if (!isLinear && hide_studio && event.program?.isStudio) {\n        continue;\n      }\n\n      const start = moment(event.startDateTime);\n      const end = moment(event.startDateTime).add(event.duration, 'seconds');\n      const originalEnd = moment(end);\n\n      if (!isLinear) {\n        end.add(1, 'hour');\n      }\n\n      if (end.isBefore(now) || start.isAfter(endSchedule)) {\n        continue;\n      }\n\n      if (event.network?.id === 'bam_dtc' && in_market_team_filter.some(tn => event.name.indexOf(tn) > -1)) {\n        const feeds = events.filter((obj) => obj.name === event.name && obj.start === event.start);\n        if (feeds.length > 1 || in_market_feed_filter.some(tn => event.feedName.indexOf(tn) > -1)) {\n          continue;\n        }\n      }\n\n      console.log('Adding event: ', event.name);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: parseCategories(event),\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        feed: event.feedName,\n        from: 'espn',\n        id: event.id,\n        image: event.image?.url,\n        name: event.name,\n        network: event.network?.name || 'ESPN+',\n        sport: event.subcategory?.name,\n        start: start.valueOf(),\n        url: event.source?.url,\n        ...(isLinear && {\n          channel: event.network?.id,\n          linear: true,\n        }),\n        originalEnd: originalEnd.valueOf(),\n      });\n    }\n  }\n};\n\nconst isEnabled = async (which?: string): Promise<boolean> => {\n  const {enabled: espnPlusEnabled, meta: plusMeta} = await db.providers.findOneAsync<\n    IProvider<TESPNPlusTokens, IEspnPlusMeta>\n  >({name: 'espnplus'});\n  const {\n    enabled: espnLinearEnabled,\n    linear_channels,\n    meta: linearMeta,\n  } = await db.providers.findOneAsync<IProvider<TESPNTokens, IEspnMeta>>({name: 'espn'});\n\n  if (which === 'linear') {\n    return espnLinearEnabled && _.some(linear_channels, c => c.enabled);\n  } else if (which === 'plus') {\n    return espnPlusEnabled;\n  } else if (which === 'ppv') {\n    return (plusMeta?.use_ppv ? true : false) && espnPlusEnabled;\n  } else if (which === 'espn3') {\n    return (linearMeta?.espn3 ? true : false) && espnLinearEnabled;\n  } else if (which === 'espn3isp') {\n    return (linearMeta?.espn3isp ? true : false) && espnLinearEnabled;\n  } else if (which === 'sec_plus') {\n    return (linearMeta?.sec_plus ? true : false) && espnLinearEnabled;\n  } else if (which === 'accnx') {\n    return (linearMeta?.accnx ? true : false) && espnLinearEnabled;\n  } else if (which === 'espn_free') {\n    return (linearMeta?.espn_free ? true : false) && espnLinearEnabled;\n  }\n\n  return espnPlusEnabled || (espnLinearEnabled && _.some(linear_channels, c => c.enabled));\n};\n\nclass EspnHandler {\n  public tokens?: ITokens;\n  public account_token?: IToken;\n  public device_token_exchange?: IToken;\n  public device_refresh_token?: IToken;\n  public device_grant?: IGrant;\n  public id_token_grant?: IGrant;\n  public device_token_exchange_expires?: number;\n  public device_refresh_token_expires?: number;\n  public account_token_expires?: number;\n\n  public adobe_device_id?: string;\n  public adobe_auth?: IAdobeAuth;\n\n  private appConfig: IAppConfig;\n  private graphQlApiKey: string;\n\n  public initialize = async () => {\n    const setupPlus = (await db.providers.countAsync({name: 'espnplus'})) > 0 ? true : false;\n\n    if (!setupPlus) {\n      const data: TESPNPlusTokens = {};\n\n      if (useEspnPlus) {\n        this.loadJSON();\n\n        data.tokens = this.tokens;\n        data.device_grant = this.device_grant;\n        data.device_token_exchange = this.device_token_exchange;\n        data.device_refresh_token = this.device_refresh_token;\n        data.id_token_grant = this.id_token_grant;\n        data.account_token = this.account_token;\n      }\n\n      await db.providers.insertAsync<IProvider<TESPNPlusTokens, IEspnPlusMeta>>({\n        enabled: useEspnPlus,\n        meta: {\n          in_market_teams: '',\n          use_ppv: useEspnPpv,\n          zip_code: '',\n        },\n        name: 'espnplus',\n        tokens: data,\n      });\n\n      if (fs.existsSync(espnPlusTokens)) {\n        fs.rmSync(espnPlusTokens);\n      }\n    }\n\n    const setupLinear = (await db.providers.countAsync({name: 'espn'})) > 0 ? true : false;\n\n    if (!setupLinear) {\n      const data: TESPNTokens = {};\n\n      if (requiresEspnProvider) {\n        this.loadJSON();\n\n        data.adobe_device_id = this.adobe_device_id;\n        data.adobe_auth = this.adobe_auth;\n      }\n\n      await db.providers.insertAsync<IProvider<TESPNTokens, IEspnMeta>>({\n        enabled: requiresEspnProvider,\n        linear_channels: [\n          {\n            enabled: useEspn1,\n            id: 'espn1',\n            name: 'ESPN',\n            tmsId: '32645',\n          },\n          {\n            enabled: useEspn2,\n            id: 'espn2',\n            name: 'ESPN2',\n            tmsId: '45507',\n          },\n          {\n            enabled: useEspnU,\n            id: 'espnu',\n            name: 'ESPNU',\n            tmsId: '60696',\n          },\n          {\n            enabled: useSec,\n            id: 'sec',\n            name: 'SEC Network',\n            tmsId: '89714',\n          },\n          {\n            enabled: useAccN,\n            id: 'acc',\n            name: 'ACC Network',\n            tmsId: '111871',\n          },\n          {\n            enabled: useEspnews,\n            id: 'espnews',\n            name: 'ESPNews',\n            tmsId: '59976',\n          },\n        ],\n        meta: {\n          accnx: useAccNx,\n          espn3: useEspn3,\n          espn3isp: false,\n          sec_plus: useSecPlus,\n        },\n        name: 'espn',\n        tokens: data,\n      });\n\n      if (fs.existsSync(espnLinearTokens)) {\n        fs.rmSync(espnLinearTokens);\n      }\n    }\n\n    if (useEspnPpv) {\n      console.log('Using ESPN_PPV variable is no longer needed. Please use the UI going forward');\n    }\n    if (useEspn1) {\n      console.log('Using ESPN variable is no longer needed. Please use the UI going forward');\n    }\n    if (useEspn2) {\n      console.log('Using ESPN2 variable is no longer needed. Please use the UI going forward');\n    }\n    if (useEspn3) {\n      console.log('Using ESPN3 variable is no longer needed. Please use the UI going forward');\n    }\n    if (useEspnU) {\n      console.log('Using ESPNU variable is no longer needed. Please use the UI going forward');\n    }\n    if (useSec) {\n      console.log('Using SEC variable is no longer needed. Please use the UI going forward');\n    }\n    if (useSecPlus) {\n      console.log('Using SECPLUS variable is no longer needed. Please use the UI going forward');\n    }\n    if (useAccN) {\n      console.log('Using ACCN variable is no longer needed. Please use the UI going forward');\n    }\n    if (useAccNx) {\n      console.log('Using ACCNX variable is no longer needed. Please use the UI going forward');\n    }\n    if (useEspnews) {\n      console.log('Using ESPNEWS variable is no longer needed. Please use the UI going forward');\n    }\n\n    const {linear_channels, meta} = await db.providers.findOneAsync<IProvider>({name: 'espn'});\n\t\n    // update/add Deportes, if necessary\n    if ( linear_channels.length <= 6 ) {\n      linear_channels.push({\n    \t  enabled: false,\n        id: 'espndeportes',\n        name: 'ESPN Deportes',\n        tmsId: '71914',\n      });\n      await db.providers.updateAsync<IProvider<TESPNTokens>, any>(\n        {name: 'espn'},\n        {\n          $set: {\n            linear_channels: linear_channels,\n          },\n        },\n      );\n    }\n\t\n    // remove ESPN on ABC, if necessary\n    if ( linear_channels.length == 8 ) {\n      const removed_channel = linear_channels.pop();\n      if (removed_channel && removed_channel.name && (removed_channel.name == 'ESPN on ABC')) {\n        console.log('Removed ' + removed_channel.name);\n        await db.providers.updateAsync<IProvider<TESPNTokens>, any>(\n          {name: 'espn'},\n          {\n            $set: {\n              linear_channels: linear_channels,\n            },\n          },\n        );\n      }\n    }\n    if ( !('espn_free' in meta) ) {\n      await db.providers.updateAsync({name: 'espn'}, {$set: {meta: {...meta, espn_free: false}}});\n    }\n    \n    /*if (await isEnabled('espn3isp') && await isEnabled('espn3')) {\n      console.log('Currently expecting ESPN3 access via ISP, re-authenticate if that is no longer true');\n    }*/\n\n    const enabled = await isEnabled();\n\n    if (!enabled) {\n      return;\n    }\n    \n    await removeEntriesNetwork('ESPN+');\n    \n    await removeEntriesNetwork('ESPN3');\n    await removeEntriesNetwork('SEC Network +');\n    await removeEntriesNetwork('ACCNX');\n    await removeEntriesNetwork('@ESPN');\n\n    /*const {meta: plusMeta} = await db.providers.findOneAsync<IProvider<TESPNPlusTokens, IEspnPlusMeta>>({\n      name: 'espnplus',\n    });\n\n    if (!plusMeta?.zip_code || !plusMeta?.in_market_teams) {\n      await this.refreshInMarketTeams();\n    }*/\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n\n    if (!this.appConfig) {\n      await this.getAppConfig();\n    }\n  };\n\n  public refreshTokens = async () => {\n    const espnPlusEnabled = await isEnabled('plus');\n\n    if (espnPlusEnabled) {\n      await this.updatePlusTokens();\n    }\n\n    const espnLinearEnabled = await isEnabled('linear');\n\n    if (espnLinearEnabled && willAdobeTokenExpire(this.adobe_auth)) {\n      console.log('Refreshing TV Provider token (ESPN)');\n      await this.refreshProviderToken();\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const espnPlusEnabled = await isEnabled('plus');\n    const espnPpvEnabled = await isEnabled('ppv');\n    const espnLinearEnabled = await isEnabled('linear');\n    const secPlusEnabled = await isEnabled('sec_plus');\n    const espn3Enabled = await isEnabled('espn3');\n    const accnxEnabled = await isEnabled('accnx');\n    const espnFreeEnabled = await isEnabled('espn_free');\n\n    const {linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'espn'});\n\n    const isChannelEnabled = (channelId: string): boolean =>\n      espnLinearEnabled && linear_channels.some(c => c.id === channelId && c.enabled);\n\n    let entries = [];\n\n    try {\n      /*if (espnPlusEnabled) {\n        console.log('Looking for ESPN+ events...');\n\n        const liveEntries = await this.getLiveEvents();\n        entries = [...entries, ...liveEntries];\n      }*/\n\n      if (espnLinearEnabled) {\n        console.log('Looking for ESPN events');\n      }\n\n      if (isChannelEnabled('espn1')) {\n        const liveEntries = await this.getLiveEvents('espn1');\n        entries = [...entries, ...liveEntries];\n      }\n      if (isChannelEnabled('espn2')) {\n        const liveEntries = await this.getLiveEvents('espn2');\n        entries = [...entries, ...liveEntries];\n      }\n      /*if (espn3Enabled) {\n        const liveEntries = await this.getLiveEvents('espn3');\n        entries = [...entries, ...liveEntries];\n      }*/\n      if (isChannelEnabled('espnu')) {\n        const liveEntries = await this.getLiveEvents('espnU');\n        entries = [...entries, ...liveEntries];\n      }\n      if (isChannelEnabled('sec')) {\n        const liveEntries = await this.getLiveEvents('secn');\n        entries = [...entries, ...liveEntries];\n      }\n      /*if (secPlusEnabled) {\n        const liveEntries = await this.getLiveEvents('secnPlus');\n        entries = [...entries, ...liveEntries];\n      }*/\n      if (isChannelEnabled('acc')) {\n        const liveEntries = await this.getLiveEvents('accn');\n        entries = [...entries, ...liveEntries];\n      }\n      /*if (accnxEnabled) {\n        const liveEntries = await this.getLiveEvents('accnx');\n        entries = [...entries, ...liveEntries];\n      }*/\n      if (isChannelEnabled('espnews')) {\n        const liveEntries = await this.getLiveEvents('espnews');\n        entries = [...entries, ...liveEntries];\n      }\n      if (isChannelEnabled('espndeportes')) {\n        const liveEntries = await this.getLiveEvents('espndeportes');\n        entries = [...entries, ...liveEntries];\n      }\n      /*if (isChannelEnabled('espnonabc')) {\n        const liveEntries = await this.getLiveEvents('espnonabc');\n        entries = [...entries, ...liveEntries];\n      }*/\n      /*if (espnFreeEnabled) {\n        const liveEntries = await this.getLiveEvents('espn_free');\n        entries = [...entries, ...liveEntries];\n      }*/\n      if (espnPpvEnabled) {\n        const liveEntries = await this.getLiveEvents('espn_ppv');\n        entries = [...entries, ...liveEntries];\n      }\n    } catch (e) {\n      console.log('Could not parse ESPN events');\n    }\n\n    const today = new Date();\n\n    for (const [i] of [0, 1, 2].entries()) {\n      const date = moment(today).add(i, 'days');\n\n      try {\n        /*if (espnPlusEnabled) {\n          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'));\n          entries = [...entries, ...upcomingEntries];\n        }*/\n        if (isChannelEnabled('espn1')) {\n          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espn1');\n          entries = [...entries, ...upcomingEntries];\n        }\n        if (isChannelEnabled('espn2')) {\n          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espn2');\n          entries = [...entries, ...upcomingEntries];\n        }\n        /*if (espn3Enabled) {\n          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espn3');\n          entries = [...entries, ...upcomingEntries];\n        }*/\n        if (isChannelEnabled('espnu')) {\n          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espnU');\n          entries = [...entries, ...upcomingEntries];\n        }\n        if (isChannelEnabled('sec')) {\n          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'secn');\n          entries = [...entries, ...upcomingEntries];\n        }\n        /*if (secPlusEnabled) {\n          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'secnPlus');\n          entries = [...entries, ...upcomingEntries];\n        }*/\n        if (isChannelEnabled('acc')) {\n          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'accn');\n          entries = [...entries, ...upcomingEntries];\n        }\n        /*if (accnxEnabled) {\n          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'accnx');\n          entries = [...entries, ...upcomingEntries];\n        }*/\n        if (isChannelEnabled('espnews')) {\n          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espnews');\n          entries = [...entries, ...upcomingEntries];\n        }\n        if (isChannelEnabled('espndeportes')) {\n          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espndeportes');\n          entries = [...entries, ...upcomingEntries];\n        }\n        /*if (isChannelEnabled('espnonabc')) {\n          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espnonabc');\n          entries = [...entries, ...upcomingEntries];\n        }*/\n        /*if (espnFreeEnabled) {\n          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espn_free');\n          entries = [...entries, ...upcomingEntries];\n        }*/\n        if (espnPpvEnabled) {\n          const upcomingEntries = await this.getUpcomingEvents(date.format('YYYY-MM-DD'), 'espn_ppv');\n          entries = [...entries, ...upcomingEntries];\n        }\n      } catch (e) {\n        console.log('Could not parse ESPN events');\n      }\n    }\n\n    try {\n      await parseAirings(entries);\n    } catch (e) {\n      console.log('Could not parse events');\n      console.log(e.message);\n    }\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    const espnPlusEnabled = await isEnabled('plus');\n    espnPlusEnabled && (await this.getBamAccessToken());\n    espnPlusEnabled && (await this.getGraphQlApiKey());\n\n    try {\n      const {data: scenarios} = await instance.get('https://watch.graph.api.espn.com/api', {\n        params: {\n          apiKey: this.graphQlApiKey,\n          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 }}}`,\n        },\n      });\n\n      if (!scenarios?.data?.airing?.source?.url.length || scenarios?.data?.airing?.status !== 'LIVE') {\n        // console.log('Event status: ', scenarios?.data?.airing?.status);\n        throw new Error('No streaming data available');\n      }\n\n      const scenarioUrl = scenarios.data.airing.source.url.replace('{scenario}', 'browser~ssai');\n\n      let isEspnPlus = true;\n      let headers: IHeaders = {};\n      let uri: string;\n\n      if (scenarios?.data?.airing?.source?.authorizationType === 'SHIELD') {\n        // console.log('Scenario: ', scenarios?.data?.airing);\n        isEspnPlus = false;\n      }\n      console.log('scenarioUrl ' + scenarioUrl);\n\n      if (isEspnPlus) {\n        const {data} = await axios.get(scenarioUrl, {\n          headers: {\n            Accept: 'application/vnd.media-service+json; version=2',\n            Authorization: this.account_token.access_token,\n            Origin: 'https://plus.espn.com',\n            'User-Agent': userAgent,\n          },\n        });\n\n        uri = data.stream.slide ? data.stream.slide : data.stream.complete;\n        headers = {\n          Authorization: this.account_token.access_token,\n        };\n      } else {\n        let tokenType = 'DEVICE';\n        let token = this.adobe_device_id;\n\n        let isFree = false;\n        if (\n          (scenarios?.data?.airing?.network?.id === 'espn3' && (await isEnabled('espn3isp') && _.some(scenarios?.data?.airing?.authTypes, (authType: string) => authType.toLowerCase() === 'isp')))\n          ||\n          (scenarios?.data?.airing?.network?.id === 'espn_free' && (await isEnabled('espn_free') && _.some(scenarios?.data?.airing?.authTypes, (authType: string) => authType.toLowerCase() === 'open')))\n        ) {\n          isFree = true;\n        }\n\n        if (\n          !isFree &&\n          _.some(scenarios?.data?.airing?.authTypes, (authType: string) => authType.toLowerCase() === 'mvpd')\n        ) {\n          // Try to get the media token, but if it fails, let's just try device authentication\n          try {\n            await this.authorizeEvent(eventId, scenarios?.data?.airing?.mrss);\n\n            const mediaTokenUrl = [\n              'https://',\n              'api.auth.adobe.com',\n              '/api/v1',\n              '/mediatoken',\n              '?requestor=ESPN',\n              `&deviceId=${this.adobe_device_id}`,\n              `&resource=${encodeURIComponent(scenarios?.data?.airing?.mrss)}`,\n            ].join('');\n\n            const {data} = await axios.get(mediaTokenUrl, {\n              headers: {\n                Authorization: createAdobeAuthHeader('GET', mediaTokenUrl, ADOBE_KEY, ADOBE_PUBLIC_KEY),\n                'User-Agent': userAgent,\n              },\n            });\n\n            tokenType = 'ADOBEPASS';\n            token = data.serializedToken;\n          } catch (e) {\n            console.error(e);\n            console.log('could not get mediatoken');\n          }\n        }\n\n        // Get stream data\n        const authenticatedUrl = [\n          `https://broadband.espn.com/espn3/auth/watchespn/startSession?channel=${scenarios?.data?.airing?.network?.id}&simulcastAiringId=${scenarios?.data?.airing?.simulcastAiringId}`,\n          '&partner=watchespn',\n          '&playbackScenario=HTTP_CLOUD_HIGH',\n          '&platform=chromecast_uplynk',\n          '&v=2.0.0',\n          `&token=${token}`,\n          `&tokenType=${tokenType}`,\n          `&resource=${Buffer.from(scenarios?.data?.airing?.mrss, 'utf-8').toString('base64')}`,\n        ].join('');\n\n        const {data: authedData} = await axios.get(authenticatedUrl, {\n          headers: {\n            'User-Agent': userAgent,\n          },\n        });\n\n        uri = authedData?.session?.playbackUrls?.default;\n        headers = {\n          Connection: 'keep-alive',\n          Cookie: `_mediaAuth: ${authedData?.session?.token}`,\n          'User-Agent': userAgent,\n        };\n      }\n\n      return [uri, headers];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get stream data. Event might be upcoming, ended, or in blackout...');\n    }\n  };\n\n  public refreshAuth = async (): Promise<void> => {\n    try {\n      const {data: refreshTokenData} = await axios.post(urlBuilder(REFRESH_AUTH_URL, ANDROID_ID), {\n        refreshToken: this.tokens.refresh_token,\n      });\n\n      this.tokens = refreshTokenData.data.token;\n      await this.save();\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get auth refresh token (ESPN+)');\n    }\n  };\n\n  private updatePlusTokens = _.throttle(\n    async () => {\n      if (!isTokenValid(this.tokens?.id_token) || willTokenExpire(this.tokens?.id_token)) {\n        console.log('Refreshing auth token (ESPN+)');\n        await this.refreshAuth();\n      }\n\n      if (!this.device_token_exchange || willTimestampExpire(this.device_token_exchange_expires)) {\n        console.log('Refreshing device token (ESPN+)');\n        await this.getDeviceTokenExchange();\n      }\n\n      if (!this.device_refresh_token || willTimestampExpire(this.device_refresh_token_expires)) {\n        console.log('Refreshing device refresh token (ESPN+)');\n        await this.getDeviceRefreshToken();\n      }\n\n      if (!this.account_token || willTimestampExpire(this.account_token_expires)) {\n        console.log('Refreshing BAM access token (ESPN+)');\n        await this.getBamAccessToken();\n      }\n    },\n    60 * 1000,\n    {leading: true, trailing: false},\n  );\n\n  private getLiveEvents = async (network?: string) => {\n    await this.getGraphQlApiKey();\n\n    const [networks, packages] = getNetworkInfo(network);\n\n    const query =\n      '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 } } }';\n    const variables = `{\"deviceType\":\"DESKTOP\",\"countryCode\":\"US\",\"tz\":\"UTC+0000\",\"type\":\"LIVE\",\"networks\":${networks},\"packages\":${packages},\"limit\":500}`;\n\n    const {data: entryData} = await instance.get(\n      encodeURI(\n        `https://watch.graph.api.espn.com/api?apiKey=${this.graphQlApiKey}&query=${query}&variables=${variables}`,\n      ),\n    );\n\n    debug.saveRequestData(entryData, network || 'espn+', 'live-epg');\n\n    return entryData.data.airings;\n  };\n\n  private getUpcomingEvents = async (date: string, network?: string) => {\n    await this.getGraphQlApiKey();\n\n    const [networks, packages] = getNetworkInfo(network);\n\n    const query =\n      '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 } } }';\n    const variables = `{\"deviceType\":\"DESKTOP\",\"countryCode\":\"US\",\"tz\":\"UTC+0000\",\"type\":\"UPCOMING\",\"networks\":${networks},\"packages\":${packages},\"day\":\"${date}\",\"limit\":500}`;\n\n    const {data: entryData} = await instance.get(\n      encodeURI(\n        `https://watch.graph.api.espn.com/api?apiKey=${this.graphQlApiKey}&query=${query}&variables=${variables}`,\n      ),\n    );\n\n    debug.saveRequestData(entryData, network || 'espn+', 'upcoming-epg');\n\n    return entryData.data.airings;\n  };\n\n  private authorizeEvent = async (eventId: string, mrss: string): Promise<void> => {\n    if (mrss && authorizedResources[eventId]) {\n      return;\n    }\n\n    const authorizeEventTokenUrl = [\n      'https://',\n      'api.auth.adobe.com',\n      '/api/v1',\n      '/authorize',\n      '?requestor=ESPN',\n      `&deviceId=${this.adobe_device_id}`,\n      `&resource=${encodeURIComponent(mrss)}`,\n    ].join('');\n\n    try {\n      await axios.get(authorizeEventTokenUrl, {\n        headers: {\n          Authorization: createAdobeAuthHeader('GET', authorizeEventTokenUrl, ADOBE_KEY, ADOBE_PUBLIC_KEY),\n          'User-Agent': userAgent,\n        },\n      });\n\n      authorizedResources[eventId] = true;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not authorize event. Might be blacked out or not available from your TV provider');\n    }\n  };\n\n  public getLinearAuthCode = async (): Promise<string> => {\n    if (!this.appConfig) {\n      await this.getAppConfig();\n    }\n\n    this.adobe_device_id = getRandomHex();\n\n    const regUrl = ['https://', 'api.auth.adobe.com', '/reggie/', 'v1/', 'ESPN', '/regcode'].join('');\n\n    try {\n      const {data} = await axios.post(\n        regUrl,\n        new url.URLSearchParams({\n          deviceId: this.adobe_device_id,\n          deviceType: 'android_tv',\n          ttl: '1800',\n        }).toString(),\n        {\n          headers: {\n            Authorization: createAdobeAuthHeader('POST', regUrl, ADOBE_KEY, ADOBE_PUBLIC_KEY),\n            'User-Agent': userAgent,\n          },\n        },\n      );\n\n      return data.code;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start the authentication process!');\n    }\n  };\n\n  public authenticateLinearRegCode = async (regcode: string): Promise<boolean> => {\n    const regUrl = ['https://', 'api.auth.adobe.com', '/api/v1/', 'authenticate/', regcode, '?requestor=ESPN'].join('');\n\n    try {\n      const {data} = await axios.get<IAdobeAuth>(regUrl, {\n        headers: {\n          Authorization: createAdobeAuthHeader('GET', regUrl, ADOBE_KEY, ADOBE_PUBLIC_KEY),\n          'User-Agent': userAgent,\n        },\n      });\n\n      this.adobe_auth = data;\n      await this.save();\n\n      return true;\n    } catch (e) {\n      if (e.response?.status !== 404) {\n        console.error(e);\n        console.log('Could not get provider token data!');\n      }\n\n      return false;\n    }\n  };\n\n  private refreshProviderToken = async (): Promise<void> => {\n    const renewUrl = [\n      'https://',\n      'api.auth.adobe.com',\n      '/api/v1/',\n      'tokens/authn',\n      '?requestor=ESPN',\n      `&deviceId=${this.adobe_device_id}`,\n    ].join('');\n\n    try {\n      const {data} = await axios.get<IAdobeAuth>(renewUrl, {\n        headers: {\n          Authorization: createAdobeAuthHeader('GET', renewUrl, ADOBE_KEY, ADOBE_PUBLIC_KEY),\n          'User-Agent': userAgent,\n        },\n      });\n\n      this.adobe_auth = data;\n      await this.save();\n    } catch (e) {\n      console.error(e);\n      console.log('Could not refresh provider token data!');\n    }\n  };\n\n  public getPlusAuthCode = async (): Promise<string> => {\n    if (!this.appConfig) {\n      await this.getAppConfig();\n    }\n\n    const apiKey = await getApiKey(ANDROID_ID);\n\n    try {\n      const {data: licensePlate} = await axios.post(\n        urlBuilder(LICENSE_PLATE_URL, ANDROID_ID),\n        {\n          adId: getRandomHex(),\n          'correlation-id': getRandomHex(),\n          deviceId: getRandomHex(),\n          deviceType: 'ANDTV',\n          entitlementPath: 'login',\n          entitlements: [],\n        },\n        {\n          headers: {\n            Authorization: `APIKEY ${apiKey}`,\n            'Content-Type': 'application/json',\n          },\n        },\n      );\n\n      const {data: wsInfo} = await axios.get(`${licensePlate.data.fastCastHost}/public/websockethost`);\n\n      wsPlus.initializeWebSocket(\n        `wss://${wsInfo.ip}:${wsInfo.securePort}/FastcastService/pubsub/profiles/${licensePlate.data.fastCastProfileId}?TrafficManager-Token=${wsInfo.token}`,\n        licensePlate,\n      );\n\n      setTimeout(() => wsPlus.closeWebSocket(), 5 * 60 * 1000);\n\n      return licensePlate.data.pairingCode;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start the authentication process!');\n    }\n  };\n\n  public authenticatePlusRegCode = async (): Promise<boolean> => {\n    if (wsPlus.wsToken) {\n      this.tokens = wsPlus.wsToken;\n\n      await this.save();\n\n      wsPlus.closeWebSocket();\n      return true;\n    }\n\n    return false;\n  };\n\n  public refreshInMarketTeams = async () => {\n    try {\n      const deviceUrl = ['https://', 'espn.api.edge.bamgrid.com', '/graph/v1/', 'device/graphql'].join('');\n\n      const {data: deviceData} = await axios.post(\n        deviceUrl,\n        {\n          operationName: 'registerDevice',\n          query:\n            '\\n    mutation registerDevice($input: RegisterDeviceInput!) {\\n        registerDevice(registerDevice: $input) {\\n            grant {\\n                grantType\\n                assertion\\n            }\\n        }\\n    }\\n',\n          variables: {\n            input: {\n              applicationRuntime: 'chrome',\n              attributes: {\n                brand: 'web',\n                browserName: 'chrome',\n                browserVersion: '128.0.0',\n                manufacturer: 'apple',\n                model: null,\n                operatingSystem: 'macintosh',\n                operatingSystemVersion: '10.15.7',\n                osDeviceIds: [],\n              },\n              deviceFamily: 'browser',\n              deviceLanguage: 'en-US',\n              devicePlatformId: 'browser',\n              deviceProfile: 'macosx',\n            },\n          },\n        },\n        {\n          headers: {\n            Authorization: BAM_API_KEY,\n            'Content-Type': 'application/json',\n            'User-Agent': userAgent,\n          },\n        },\n      );\n\n      const zip_code = deviceData.extensions.sdk.session.location.zipCode;\n\n      await db.providers.updateAsync({name: 'espnplus'}, {$set: {'meta.zip_code': zip_code}});\n\n      const lookupUrl = ['https://', 'api-web.nhle.com', '/v1/postal-lookup/', zip_code].join('');\n\n      const {data: lookupData} = await axios.get(lookupUrl, {\n        headers: {\n          'Content-Type': 'application/json',\n          'User-Agent': userAgent,\n        },\n      });\n\n      const teams = lookupData.map(team => team.teamName.default);\n      const in_market_teams = teams.join(',');\n      console.log(`Detected in-market teams ${in_market_teams} (${zip_code})`);\n\n      await db.providers.updateAsync({name: 'espnplus'}, {$set: {'meta.in_market_teams': in_market_teams}});\n\n      return {in_market_teams, zip_code};\n    } catch (e) {\n      console.error(e);\n      console.log('Could not refresh in-market teams data!');\n    }\n  };\n\n  public ispAccess = async (): Promise<boolean> => {\n    try {\n      await this.getGraphQlApiKey();\n\n      const [networks, packages] = getNetworkInfo('espn3');\n\n      const query =\n        '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 } } }';\n      const variables = `{\"deviceType\":\"DESKTOP\",\"countryCode\":\"US\",\"tz\":\"UTC+0000\",\"type\":\"REPLAY\",\"networks\":${networks},\"packages\":${packages},\"limit\":10}`;\n\n      const {data: entryData} = await instance.get(\n        encodeURI(\n          `https://watch.graph.api.espn.com/api?apiKey=${this.graphQlApiKey}&query=${query}&variables=${variables}`,\n        ),\n      );\n\n      const apiKey = [\n        'u',\n        'i',\n        'q',\n        'l',\n        'b',\n        'g',\n        'z',\n        'd',\n        'w',\n        'u',\n        'r',\n        'u',\n        '1',\n        '4',\n        'v',\n        '6',\n        '2',\n        '7',\n        'v',\n        'd',\n        'u',\n        's',\n        's',\n        'w',\n        'b',\n      ].join('');\n      const randomInt: number = Math.floor(Math.random() * entryData.data.airings.length);\n      const eventUrl = [\n        'https://',\n        'watch.auth.api.espn.com',\n        '/video/auth/',\n        'media/',\n        entryData.data.airings[randomInt].id,\n        '/asset',\n        '?apikey=',\n        apiKey,\n      ].join('');\n\n      try {\n        const {data} = await axios.post(eventUrl, {\n          headers: {\n            'User-Agent': userAgent,\n          },\n        });\n\n        if (data.stream) {\n          console.log('Detected ISP access');\n          return true;\n        }\n      } catch (e) {\n        console.log('Did not detect ISP access');\n      }\n    } catch (e) {\n      console.log('Could not check ISP access');\n    }\n    return false;\n  };\n\n  private getAppConfig = async () => {\n    try {\n      const {data} = await axios.get<IAppConfig>(BAM_APP_CONFIG);\n      this.appConfig = data;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not load API app config');\n    }\n  };\n\n  private getGraphQlApiKey = async () => {\n    if (!this.graphQlApiKey) {\n      try {\n        const {data: espnKeys} = await axios.get(\n          'https://a.espncdn.com/connected-devices/app-configurations/espn-js-sdk-web-2.0.config.json',\n        );\n        this.graphQlApiKey = espnKeys.graphqlapi.apiKey;\n      } catch (e) {\n        console.error(e);\n        console.log('Could not get GraphQL API key');\n      }\n    }\n  };\n\n  private createDeviceGrant = async () => {\n    if (!this.device_grant || !isTokenValid(this.device_grant.assertion)) {\n      try {\n        this.device_grant = await makeApiCall(this.appConfig.services.device.client.endpoints.createDeviceGrant, {\n          applicationRuntime: 'chrome',\n          attributes: {},\n          deviceFamily: 'browser',\n          deviceProfile: 'linux',\n        });\n\n        await this.save();\n      } catch (e) {\n        console.error(e);\n        console.log('Could not get device grant');\n      }\n    }\n  };\n\n  private createAccountGrant = async () => {\n    await this.getDeviceRefreshToken();\n\n    if (!this.id_token_grant || !isTokenValid(this.id_token_grant.assertion)) {\n      try {\n        this.id_token_grant = await makeApiCall(\n          this.appConfig.services.account.client.endpoints.createAccountGrant,\n          {\n            id_token: this.tokens.id_token,\n          },\n          this.device_refresh_token.access_token,\n        );\n\n        await this.save();\n      } catch (e) {\n        console.error(e);\n        console.log('Could not get account grant');\n      }\n    }\n  };\n\n  private getDeviceTokenExchange = async () => {\n    await this.createDeviceGrant();\n\n    if (!this.device_token_exchange || willTimestampExpire(this.device_token_exchange_expires)) {\n      try {\n        this.device_token_exchange = await makeApiCall(this.appConfig.services.token.client.endpoints.exchange, {\n          grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',\n          latitude: 0,\n          longitude: 0,\n          platform: 'browser',\n          setCookie: false,\n          subject_token: this.device_grant.assertion,\n          subject_token_type: 'urn:bamtech:params:oauth:token-type:device',\n        });\n        this.device_token_exchange_expires = moment().add(this.device_token_exchange.expires_in, 'seconds').valueOf();\n\n        await this.save();\n      } catch (e) {\n        console.error(e);\n        console.log('Could not get device token exchange');\n      }\n    }\n  };\n\n  private getDeviceRefreshToken = async () => {\n    await this.getDeviceTokenExchange();\n\n    if (!this.device_refresh_token || willTimestampExpire(this.device_refresh_token_expires)) {\n      try {\n        this.device_refresh_token = await makeApiCall(this.appConfig.services.token.client.endpoints.exchange, {\n          grant_type: 'refresh_token',\n          latitude: 0,\n          longitude: 0,\n          platform: 'browser',\n          refresh_token: this.device_token_exchange.refresh_token,\n          setCookie: false,\n        });\n        this.device_refresh_token_expires = moment().add(this.device_refresh_token.expires_in, 'seconds').valueOf();\n\n        await this.save();\n      } catch (e) {\n        console.error(e);\n        console.log('Could not get device token exchange');\n      }\n    }\n  };\n\n  private getBamAccessToken = async () => {\n    await this.createAccountGrant();\n\n    if (!this.account_token || willTimestampExpire(this.account_token_expires)) {\n      try {\n        this.account_token = await makeApiCall(this.appConfig.services.token.client.endpoints.exchange, {\n          grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',\n          latitude: 0,\n          longitude: 0,\n          platform: 'browser',\n          setCookie: false,\n          subject_token: this.id_token_grant.assertion,\n          subject_token_type: 'urn:bamtech:params:oauth:token-type:account',\n        });\n        this.account_token_expires = moment().add(this.account_token.expires_in, 'seconds').valueOf();\n\n        await this.save();\n      } catch (e) {\n        console.error(e);\n        console.log('Could not get BAM access token');\n      }\n    }\n  };\n\n  private save = async (): Promise<void> => {\n    await db.providers.updateAsync(\n      {name: 'espnplus'},\n      {$set: {tokens: _.omit(this, 'appConfig', 'graphQlApiKey', 'adobe_auth', 'adobe_device_id')}},\n    );\n\n    await db.providers.updateAsync({name: 'espn'}, {$set: {tokens: _.pick(this, 'adobe_auth', 'adobe_device_id')}});\n  };\n\n  private load = async (): Promise<void> => {\n    const {tokens: plusTokens} = await db.providers.findOneAsync<IProvider<TESPNPlusTokens>>({name: 'espnplus'});\n    const {\n      tokens,\n      device_grant,\n      device_token_exchange,\n      device_refresh_token,\n      id_token_grant,\n      account_token,\n      device_token_exchange_expires,\n      device_refresh_token_expires,\n      account_token_expires,\n    } = plusTokens;\n\n    this.tokens = tokens;\n    this.device_grant = device_grant;\n    this.device_token_exchange = device_token_exchange;\n    this.device_refresh_token = device_refresh_token;\n    this.id_token_grant = id_token_grant;\n    this.account_token = account_token;\n    this.device_token_exchange_expires = device_token_exchange_expires;\n    this.device_refresh_token_expires = device_refresh_token_expires;\n    this.account_token_expires = account_token_expires;\n\n    const {tokens: linearTokens} = await db.providers.findOneAsync<IProvider<TESPNTokens>>({name: 'espn'});\n    const {adobe_device_id, adobe_auth} = linearTokens;\n\n    this.adobe_device_id = adobe_device_id;\n    this.adobe_auth = adobe_auth;\n  };\n\n  private loadJSON = () => {\n    if (fs.existsSync(espnPlusTokens)) {\n      const {tokens, device_grant, device_token_exchange, device_refresh_token, id_token_grant, account_token} =\n        fsExtra.readJSONSync(espnPlusTokens);\n\n      this.tokens = tokens;\n      this.device_grant = device_grant;\n      this.device_token_exchange = device_token_exchange;\n      this.device_refresh_token = device_refresh_token;\n      this.id_token_grant = id_token_grant;\n      this.account_token = account_token;\n    }\n\n    if (fs.existsSync(espnLinearTokens)) {\n      const {adobe_device_id, adobe_auth} = fsExtra.readJSONSync(espnLinearTokens);\n\n      this.adobe_device_id = adobe_device_id;\n      this.adobe_auth = adobe_auth;\n    }\n  };\n}\n\nexport type TESPNPlusTokens = Omit<ClassTypeWithoutMethods<EspnHandler>, 'adobe_device_id' | 'adobe_auth'>;\nexport type TESPNTokens = Pick<ClassTypeWithoutMethods<EspnHandler>, 'adobe_device_id' | 'adobe_auth'>;\n\nexport const espnHandler = new EspnHandler();\n"
  },
  {
    "path": "services/flo-handler.ts",
    "content": "import fs from 'fs';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport moment from 'moment';\n\nimport {floSportsUserAgent} from './user-agent';\nimport {configPath} from './config';\nimport {useFloSports} from './networks';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {getRandomUUID, normalTimeRange} from './shared-helpers';\nimport {debug} from './debug';\n\ninterface IFloEventsRes {\n  sections: {\n    id: string;\n    title: string;\n    items: IFloEvent[];\n  }[];\n}\n\ninterface IFloEvent {\n  id: string;\n  title: string;\n  footer_1: string;\n  preview_image: {\n    url: string;\n  };\n  label_1_parts: {\n    status: string;\n    start_date_time: string;\n  };\n  action: {\n    node_id: number;\n    analytics: {\n      name: string;\n      site_name: string;\n    };\n  };\n  live_event_metadata: {\n    live_event_id: number;\n    streams: {\n      stream_id: number;\n      stream_name: string;\n    }[];\n  };\n}\n\nconst parseAirings = async (events: IFloEvent[]) => {\n  const [now, endSchedule] = normalTimeRange();\n\n  for (const event of events) {\n    for (const stream of event.live_event_metadata.streams) {\n      const entryExists = await db.entries.findOneAsync<IEntry>({id: `flo-${stream.stream_id}`});\n\n      if (!entryExists) {\n        const start = moment(event.label_1_parts.start_date_time);\n        const end = moment(event.label_1_parts.start_date_time).add(4, 'hours');\n        const originalEnd = moment(start).add(3, 'hours');\n\n        if (end.isBefore(now) || start.isAfter(endSchedule)) {\n          continue;\n        }\n\n        const gameName = event.action.analytics?.name.replace(/^\\d{4}\\s+/, '');\n\n        console.log('Adding event: ', gameName);\n\n        await db.entries.insertAsync<IEntry>({\n          categories: [...new Set([event.footer_1, 'FloSports', event.action.analytics.site_name])],\n          duration: end.diff(start, 'seconds'),\n          end: end.valueOf(),\n          from: 'flo',\n          id: `flo-${stream.stream_id}`,\n          image: event.preview_image.url,\n          name: gameName,\n          network: event.action.analytics.site_name,\n          originalEnd: originalEnd.valueOf(),\n          sport: event.footer_1,\n          start: start.valueOf(),\n        });\n      }\n    }\n  }\n};\n\nconst floSportsConfigPath = path.join(configPath, 'flo_tokens.json');\n\nclass FloSportsHandler {\n  public access_token?: string;\n  public refresh_token?: string;\n  public expires_at?: number;\n  public refresh_expires_at?: number;\n  public device_id?: string;\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'flosports'})) > 0 ? true : false;\n\n    if (!setup) {\n      const data: TFloSportsTokens = {};\n\n      if (useFloSports) {\n        this.loadJSON();\n\n        data.access_token = this.access_token;\n        data.expires_at = this.expires_at;\n        data.device_id = this.device_id;\n        data.refresh_token = this.refresh_token;\n        data.refresh_expires_at = this.refresh_expires_at;\n      }\n\n      await db.providers.insertAsync<IProvider<TFloSportsTokens>>({\n        enabled: useFloSports,\n        name: 'flosports',\n        tokens: data,\n      });\n\n      if (fs.existsSync(floSportsConfigPath)) {\n        fs.rmSync(floSportsConfigPath);\n      }\n    }\n\n    if (useFloSports) {\n      console.log('Using FLOSPORTS variable is no longer needed. Please use the UI going forward');\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider<TFloSportsTokens>>({name: 'flosports'});\n\n    if (!enabled) {\n      return;\n    }\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n  };\n\n  public refreshTokens = async () => {\n    const {enabled} = await db.providers.findOneAsync<IProvider<TFloSportsTokens>>({name: 'flosports'});\n\n    if (!enabled) {\n      return;\n    }\n\n    if (!this.expires_at || moment(this.expires_at).isBefore(moment().add(10, 'days'))) {\n      await this.extendToken();\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider<TFloSportsTokens>>({name: 'flosports'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log('Looking for FloSports events (this can take a while)...');\n\n    try {\n      let hasNextPage = true;\n      let page = 1;\n      const events: IFloEvent[] = [];\n      const limit = 100;\n\n      const [, endSchedule] = normalTimeRange();\n\n      while (hasNextPage) {\n        const url = [\n          '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',\n          `&limit=${limit}`,\n          page > 1 ? `&offset=${page * limit}` : '',\n        ].join('');\n\n        const {data} = await axios.get<IFloEventsRes>(url, {\n          headers: {\n            Authorization: `Bearer ${this.access_token}`,\n          },\n          // This request can take a long time so increasing the timeout\n          timeout: 1000 * 60 * 5,\n        });\n\n        debug.saveRequestData(data, 'flosports', 'epg');\n\n        data?.sections.forEach(e => {\n          if (e.id === 'live-and-upcoming' || e.title === 'Live & Upcoming') {\n            e.items.forEach(a => {\n              if (a.action && a.label_1_parts && a.label_1_parts.status !== 'CONCLUDED' && !a.title.startsWith('TBA')) {\n                if (moment(a.label_1_parts.start_date_time).isBefore(endSchedule)) {\n                  events.push(a);\n                } else {\n                  hasNextPage = false;\n                }\n              }\n            });\n          }\n        });\n\n        page += 1;\n      }\n\n      await parseAirings(events);\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse FloSports events');\n    }\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    const id = eventId.replace('flo-', '');\n\n    try {\n      await this.extendToken();\n\n      const url = ['https://', 'live-api-3.flosports.tv', '/streams/', id, '/tokens'].join('');\n\n      const {data} = await axios.post(\n        url,\n        {\n          adTracking: {\n            appName: 'flosports-androidtv',\n            appStoreUrl: 'https://play.google.com/store/apps/details?id=tv.flosports&hl=en_US',\n            appVersion: 'v2.11.0-2220530',\n            casting: false,\n            deviceModel: 'sdk_google_atv_x86',\n            height: 1080,\n            isLat: 0,\n            os: 'android',\n            osVersion: '28',\n            rdid: this.device_id,\n            width: 1920,\n          },\n        },\n        {\n          headers: {\n            'Content-Type': 'application/json',\n            'User-Agent': floSportsUserAgent,\n            authorization: `Bearer ${this.access_token}`,\n          },\n        },\n      );\n\n      return [data.data.uri, {}];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start playback');\n    }\n  };\n\n  private extendToken = async (): Promise<void> => {\n    try {\n      const url = ['https://', 'api.flosports.tv', '/api', '/refresh-tokens'].join('');\n\n      const {data} = await axios.post(\n        url,\n        {\n          token: this.refresh_token,\n        },\n        {\n          headers: {\n            'User-Agent': floSportsUserAgent,\n          },\n        },\n      );\n\n      this.access_token = data.token;\n      this.expires_at = data.exp * 1000;\n      this.refresh_token = data.refresh_token;\n      this.refresh_expires_at = data.refresh_token_exp * 1000;\n      await this.save();\n    } catch (e) {\n      console.error(e);\n      console.log('Could not extend token for FloSports');\n    }\n  };\n\n  public getAuthCode = async (): Promise<string> => {\n    this.device_id = getRandomUUID();\n\n    try {\n      const url = ['https://', 'api.flosports.tv', '/api', '/activation-codes', '/new'].join('');\n\n      const {data} = await axios.post(\n        url,\n        {},\n        {\n          headers: {\n            'User-Agent': floSportsUserAgent,\n          },\n        },\n      );\n\n      return data.activation_code;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start the authentication process for FloSports!');\n    }\n  };\n\n  public authenticateRegCode = async (code: string): Promise<boolean> => {\n    try {\n      const url = ['https://', 'api.flosports.tv', '/api', '/activation-codes/', code].join('');\n\n      const {data} = await axios.get(url, {\n        headers: {\n          'User-Agent': floSportsUserAgent,\n        },\n      });\n\n      if (!data) {\n        return false;\n      }\n\n      this.access_token = data.token;\n      this.expires_at = data.exp * 1000;\n      this.refresh_token = data.refresh_token;\n      this.refresh_expires_at = data.refresh_token_exp * 1000;\n      await this.save();\n\n      return true;\n    } catch (e) {\n      return false;\n    }\n  };\n\n  private save = async () => {\n    await db.providers.updateAsync({name: 'flosports'}, {$set: {tokens: this}});\n  };\n\n  private load = async () => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TFloSportsTokens>>({name: 'flosports'});\n    const {device_id, access_token, expires_at, refresh_token, refresh_expires_at} = tokens;\n\n    this.device_id = device_id;\n    this.access_token = access_token;\n    this.expires_at = expires_at;\n    this.refresh_token = refresh_token;\n    this.refresh_expires_at = refresh_expires_at;\n  };\n\n  private loadJSON = () => {\n    if (fs.existsSync(floSportsConfigPath)) {\n      const {device_id, access_token, expires_at, refresh_token, refresh_expires_at} =\n        fsExtra.readJSONSync(floSportsConfigPath);\n\n      this.device_id = device_id;\n      this.access_token = access_token;\n      this.expires_at = expires_at;\n      this.refresh_token = refresh_token;\n      this.refresh_expires_at = refresh_expires_at;\n    }\n  };\n}\n\nexport type TFloSportsTokens = ClassTypeWithoutMethods<FloSportsHandler>;\n\nexport const floSportsHandler = new FloSportsHandler();\n"
  },
  {
    "path": "services/fox-handler.ts",
    "content": "import fs from 'fs';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport _ from 'lodash';\nimport moment from 'moment';\n\nimport {androidFoxUserAgent, userAgent} from './user-agent';\nimport {configPath} from './config';\nimport {useFoxOnly4k, useFoxSports} from './networks';\nimport {IAdobeAuthFox} from './adobe-helpers';\nimport {getRandomHex, normalTimeRange} from './shared-helpers';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {debug} from './debug';\nimport {usesLinear, hideStudio} from './misc-db-service';\n\ninterface IAppConfig {\n  api: {\n    content: {\n      watch: string;\n    };\n    key: string;\n    auth: {\n      accountRegCode: string;\n      checkadobeauthn: string;\n      getentitlements: string;\n    };\n    profile: {\n      login: string;\n    };\n  };\n  auth: {\n    displayActivationUrl: string;\n  };\n}\n\ninterface IAdobePrelimAuthToken {\n  accessToken: string;\n  tokenExpiration: number;\n  viewerId: string;\n  deviceId: string;\n  profileId: string;\n}\n\ninterface IFoxEvent {\n  airing_type: string;\n  audio_only: boolean;\n  call_sign: string;\n  tags: string[];\n  entity_id: string;\n  genres: string[];\n  title: string;\n  description: string;\n  sport_uri?: string;\n  start_time: string;\n  end_time: string;\n  network: string;\n  stream_types: string[];\n  images: {\n    logo?: string;\n    series_detail?: string;\n    series_list?: string;\n  };\n  isUHD?: boolean;\n}\n\ninterface IFoxEventsData {\n  data: {\n    listings: {\n\t    item_count: number;\n      items: IFoxEvent[];\n    };\n  };\n}\n\ninterface IFoxMeta {\n  only4k?: boolean;\n  uhd?: boolean;\n  dtc_events?: boolean;\n  local_station_call_sign?: string;\n}\n\nconst EPG_API_KEY = [\n  'c',\n  'f',\n  '2',\n  '8',\n  '9',\n  'e',\n  '2',\n  '9',\n  '9',\n  'e',\n  'f',\n  'd',\n  'f',\n  'a',\n  '3',\n  '9',\n  'f',\n  'b',\n  '6',\n  '3',\n  '1',\n  '6',\n  'f',\n  '2',\n  '5',\n  '9',\n  'd',\n  '1',\n  'd',\n  'e',\n  '9',\n  '3',\n].join('');\n\nconst network_entitlement_map = { fox: 'foxSports', btn: 'btn-btn2go', 'fox-soccer-plus': 'fspl' };\n\nconst foxConfigPath = path.join(configPath, 'fox_tokens.json');\n\nconst getMaxRes = (res: string) => {\n  switch (res) {\n    case 'UHD/HDR':\n      return 'UHD/HDR';\n    default:\n      return '720p';\n  }\n};\n\nconst parseCategories = (event: IFoxEvent) => {\n  const categories = ['FOX Sports', 'FOX'];\n  for (const classifier of [...(event.tags || []), ...(event.genres || [])]) {\n    if (classifier !== null) {\n      categories.push(classifier);\n    }\n  }\n\n  if (event.sport_uri) {\n    categories.push(event.sport_uri);\n  }\n\n  if (event.stream_types?.find(resolution => resolution === 'HDR' || resolution === 'SDR') || event.isUHD) {\n    categories.push('4K');\n  }\n\n  return [...new Set(categories)];\n};\n\nconst parseAirings = async (events: IFoxEvent[]) => {\n  const useLinear = await usesLinear();\n  const hide_studio = await hideStudio();\n\n  const [now, inTwoDays] = normalTimeRange();\n\n  const {meta} = await db.providers.findOneAsync<IProvider<any, IFoxMeta>>({name: 'foxsports'});\n\n  for (const event of events) {\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `${event.entity_id.replace('_dtc', '')}`});\n\n    if (!entryExists) {\n      const start = moment(event.start_time);\n      const end = moment(event.end_time);\n      const originalEnd = moment(event.end_time);\n\n      const isLinear = event.network !== 'fox' && useLinear;\n\n      if (!isLinear) {\n        end.add(1, 'hour');\n      }\n\n      if (end.isBefore(now) || start.isAfter(inTwoDays)) {\n        continue;\n      }\n\n      const categories = parseCategories(event);\n\n      if (meta.only4k && !_.some(categories, category => category === '4K')) {\n        continue;\n      }\n\n      const studio_regex = /Sports (Commentary|Highlights|Magazine|talk)/i;\n      const isStudio = categories.find(item => item.match(studio_regex));\n      if (!isLinear && hide_studio && isStudio) {\n        continue;\n      }\n\n      const eventName = `${event.sport_uri === 'NFL' ? `${event.sport_uri} - ` : ''}${event.title}`;\n\n      console.log('Adding event: ', eventName);\n\n      await db.entries.insertAsync<IEntry>({\n        categories,\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'foxsports',\n        id: event.entity_id.replace('_dtc', ''),\n        image: event.images.logo || event.images.series_detail || event.images.series_list,\n        name: eventName,\n        network: event.call_sign,\n        originalEnd: originalEnd.valueOf(),\n        replay: event.airing_type !== 'live',\n        start: start.valueOf(),\n        ...(isLinear && {\n          channel: event.network,\n          linear: true,\n        }),\n      });\n    }\n  }\n};\n\nconst FOX_APP_CONFIG = 'https://config.foxdcg.com/foxsports/androidtv-native/3.42/info.json';\n\n// Will prelim token expire in the next month?\nconst willPrelimTokenExpire = (token: IAdobePrelimAuthToken): boolean =>\n  new Date().valueOf() + 3600 * 1000 * 24 * 30 > (token?.tokenExpiration || 0);\n// Will auth token expire in the next day?\nconst willAuthTokenExpire = (token: IAdobeAuthFox): boolean =>\n  new Date().valueOf() + 3600 * 1000 * 24 > (token?.tokenExpiration || 0);\n\nconst checkEventNetwork = (entitlements, event: IFoxEvent): boolean => {\n  if ( event.network && (entitlements.includes(event.network) || (network_entitlement_map[event.network] && entitlements.includes(network_entitlement_map[event.network]))) ) {\n    return true;\n  }\n\n  return false;\n};\n\nclass FoxHandler {\n  public adobe_device_id?: string;\n  public adobe_prelim_auth_token?: IAdobePrelimAuthToken;\n  public adobe_auth?: IAdobeAuthFox;\n\n  private entitlements: string[] = [];\n  private appConfig: IAppConfig;\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'foxsports'})) > 0 ? true : false;\n\n    if (!setup) {\n      const data: TFoxTokens = {};\n\n      if (useFoxSports) {\n        this.loadJSON();\n\n        data.adobe_auth = this.adobe_auth;\n        data.adobe_device_id = this.adobe_device_id;\n        data.adobe_prelim_auth_token = this.adobe_prelim_auth_token;\n      }\n\n      // see below for update/addition of Soccer Plus and Deportes linear channels\n      await db.providers.insertAsync<IProvider<TFoxTokens, IFoxMeta>>({\n        enabled: useFoxSports,\n        linear_channels: [\n          {\n            enabled: false,\n            id: 'fs1',\n            name: 'FS1',\n            tmsId: '82547',\n          },\n          {\n            enabled: false,\n            id: 'fs2',\n            name: 'FS2',\n            tmsId: '59305',\n          },\n          {\n            enabled: false,\n            id: 'btn',\n            name: 'B1G Network',\n            tmsId: '58321',\n          },\n        ],\n        meta: {\n          only4k: useFoxOnly4k,\n          uhd: getMaxRes(process.env.MAX_RESOLUTION) === 'UHD/HDR',\n          local_station_call_sign: '',\n        },\n        name: 'foxsports',\n        tokens: data,\n      });\n\n      if (fs.existsSync(foxConfigPath)) {\n        fs.rmSync(foxConfigPath);\n      }\n    }\n\n    if (useFoxSports) {\n      console.log('Using FOXSPORTS variable is no longer needed. Please use the UI going forward');\n    }\n    if (useFoxOnly4k) {\n      console.log('Using FOX_ONLY_4K variable is no longer needed. Please use the UI going forward');\n    }\n    if (process.env.MAX_RESOLUTION) {\n      console.log('Using MAX_RESOLUTION variable is no longer needed. Please use the UI going forward');\n    }\n\n    const {enabled, meta, linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'foxsports'});\n\t\n    // update/add Soccer Plus and Deportes, if necessary\n    if ( linear_channels.length <= 4 ) {\n      linear_channels[3] = {\n    \tenabled: false,\n        id: 'fox-soccer-plus',\n        name: 'FOX Soccer Plus',\n        tmsId: '66880',\n      };\n      linear_channels.push({\n        enabled: false,\n        id: 'foxdep',\n        name: 'FOX Deportes',\n        tmsId: '72189',\n      });\n      await db.providers.updateAsync<IProvider<TFoxTokens>, any>(\n        {name: 'foxsports'},\n        {\n          $set: {\n            linear_channels: linear_channels,\n          },\n        },\n      );\n    }\n\n    if (!enabled) {\n      return;\n    }\n\n    if (!meta.dtc_events) {\n      const events = await db.entries.findAsync({from: 'foxsports', id: {$regex: /_dtc/}});\n\n      for (const event of events) {\n        await db.entries.updateAsync({from: 'foxsports', id: event.id}, {$set: {id: event.id.replace('_dtc', '')}});\n      }\n\n      await db.providers.updateAsync({name: 'foxsports'}, {$set: {meta: {...meta, dtc_events: true}}});\n    }\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n\n    await this.getEntitlements();\n  };\n\n  public refreshTokens = async () => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'foxsports'});\n\n    if (!enabled) {\n      return;\n    }\n\n    if (!this.adobe_prelim_auth_token || willPrelimTokenExpire(this.adobe_prelim_auth_token)) {\n      console.log('Updating FOX Sports prelim token');\n      await this.getPrelimToken();\n    }\n\n    if (willAuthTokenExpire(this.adobe_auth)) {\n      console.log('Refreshing TV Provider token (FOX Sports)');\n      await this.authenticateRegCode();\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'foxsports'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log('Looking for FOX Sports events...');\n\n    try {\n      const entries = await this.getEvents();\n      await parseAirings(entries);\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse FOX Sports events');\n    }\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    try {\n      if (!this.appConfig) {\n        await this.getAppConfig();\n      }\n\n      let cdn = 'fastly';\n      let data;\n\n      // while (cdn !== 'akamai|limelight|fastly') {\n      while (cdn === 'fastly') {\n        data = await this.getSteamData(eventId);\n        cdn = data.trackingData.properties.CDN;\n      }\n\n      if (!data || !data?.url) {\n        throw new Error('Could not get stream data. Event might be upcoming, ended, or in blackout...');\n      }\n\n      const {data: streamData} = await axios.get(data.url, {\n        headers: {\n          'User-Agent': androidFoxUserAgent,\n          'x-api-key': this.appConfig.api.key,\n        },\n      });\n\n      if (!streamData.playURL) {\n        throw new Error('Could not get stream data. Event might be upcoming, ended, or in blackout...');\n      }\n\n      return [\n        streamData.playURL,\n        {\n          'User-Agent': androidFoxUserAgent,\n        },\n      ];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get stream information!');\n    }\n  };\n\n  private getSteamData = async (eventId: string): Promise<any> => {\n    const {meta} = await db.providers.findOneAsync<IProvider<any, IFoxMeta>>({name: 'foxsports'});\n    const {uhd} = meta;\n\n    const streamOrder = ['UHD/HDR', '720p'];\n\n    let resIndex = streamOrder.findIndex(i => i === getMaxRes(uhd ? 'UHD/HDR' : ''));\n\n    if (resIndex < 0) {\n      resIndex = 1;\n    }\n\n    if (!this.appConfig) {\n      await this.getAppConfig();\n    }\n\n    let watchData;\n\n    for (let a = resIndex; a < streamOrder.length; a++) {\n      try {\n        const {data} = await axios.post(\n          'https://prod.api.video.fox/v2.0/watch',\n          {\n            capabilities: ['fsdk/yo/v3'],\n            deviceHeight: 2160,\n            deviceWidth: 3840,\n            maxRes: streamOrder[a],\n            os: 'Android',\n            osv: '11.0.0',\n            streamId: eventId.replace('_dtc', ''),\n            streamType: 'live',\n          },\n          {\n            headers: {\n              'User-Agent': androidFoxUserAgent,\n              authorization: this.adobe_auth.accessToken,\n              'x-api-key': this.appConfig.api.key,\n            },\n          },\n        );\n\n        watchData = data;\n        break;\n      } catch (e) {\n        console.log(\n          `Could not get stream data for ${streamOrder[a]}. ${\n            streamOrder[a + 1] ? `Trying to get ${streamOrder[a + 1]} next...` : ''\n          }`,\n        );\n      }\n    }\n\n    return watchData;\n  };\n\n  private getEvents = async (): Promise<IFoxEvent[]> => {\n    if (!this.appConfig) {\n      await this.getAppConfig();\n    }\n\n    // get local station call sign\n    let local_station_call_sign_parameter = '';\n    try {\n      const {meta} = await db.providers.findOneAsync<IProvider<any, IFoxMeta>>({name: 'foxsports'});\n      if ( !meta.local_station_call_sign || (meta.local_station_call_sign == '') ) {\n        console.log('FOX Sports detecting local FOX call sign to pull flagship events');\n        let local_station_call_sign = 'none';\n        const {data} = await axios.get(\n          'https://api-sps.foxsports.com/locator/v1/location',\n          {\n            headers: {\n              'User-Agent': userAgent,\n              'x-api-key': EPG_API_KEY,\n            },\n          },\n        );\n\n        if ( data.data.results[0].local_station_call_sign ) {\n          local_station_call_sign = data.data.results[0].local_station_call_sign;\n          console.log('FOX Sports found local FOX call sign ' + local_station_call_sign);\n          local_station_call_sign_parameter = '%2C' +  local_station_call_sign;\n        } else {\n          console.log('FOX Sports could not find a local FOX call sign');\n        }\n        await db.providers.updateAsync({name: 'foxsports'}, {$set: {'meta.local_station_call_sign': local_station_call_sign}});\n      } else if ( (meta.local_station_call_sign != 'none') ) {\n        local_station_call_sign_parameter = '%2C' +  meta.local_station_call_sign;\n      }\n    } catch (e) {\n      console.log(e);\n    }\n\n    const useLinear = await usesLinear();\n    \n    const {linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'foxsports'});\n\n    const events: IFoxEvent[] = [];\n\n    const [now, inTwoDays] = normalTimeRange();\n\n    const startTime = now.unix();\n    const endTime = inTwoDays.unix();\n\n    try {\n      let max_items_per_page = 50;\n      let pages = 1;\n\n      for (let page = 1; page <= pages; page++) {\n        const {data} = await axios.get<IFoxEventsData>(\n          `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`,\n          {\n            headers: {\n              'User-Agent': userAgent,\n              authorization: `Bearer ${this.adobe_prelim_auth_token.accessToken}`,\n              'x-fox-apikey': EPG_API_KEY,\n            },\n          },\n        );\n\n        if ( data.data.listings.item_count ) {\n          pages = Math.ceil(data.data.listings.item_count / max_items_per_page);\n        }\n\n        debug.saveRequestData(data, 'foxsports', 'epg');\n\n        _.forEach(data.data.listings.items, m => {\n          const isChannelEnabled = linear_channels.find(c => c.id === m.network && c.enabled === true);\n          if (\n            checkEventNetwork(this.entitlements, m) &&\n            !m.audio_only &&\n            m.start_time &&\n            m.end_time &&\n            m.entity_id &&\n            isChannelEnabled\n          ) {\n            if (!useLinear) {\n              if (m.airing_type === 'live' || m.airing_type === 'new') {\n                events.push(m);\n              }\n            } else {\n              events.push(m);\n            }\n          }\n        });\n      }\n    } catch (e) {\n      console.log(e);\n    }\n\n    return events;\n  };\n\n  private getAppConfig = async () => {\n    try {\n      const {data} = await axios.get<IAppConfig>(FOX_APP_CONFIG);\n      this.appConfig = data;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not load API app config');\n    }\n  };\n\n  private getEntitlements = async (): Promise<void> => {\n    try {\n      if (!this.appConfig) {\n        await this.getAppConfig();\n      }\n\n      const {data} = await axios.get<any>(\n        `${this.appConfig.api.auth.getentitlements}?device_type=&device_id=${this.adobe_device_id}&resource=&requestor=`,\n        {\n          headers: {\n            'User-Agent': androidFoxUserAgent,\n            authorization: this.adobe_auth.accessToken,\n            'x-api-key': this.appConfig.api.key,\n          },\n        },\n      );\n\n      this.entitlements = [];\n\n      _.forOwn(data.entitlements, (_val, key) => {\n        if (/^[a-z]/.test(key)) {\n          this.entitlements.push(key);\n        }\n      });\n    } catch (e) {\n      console.error(e);\n    }\n  };\n\n  private getPrelimToken = async (): Promise<void> => {\n    try {\n      if (!this.appConfig) {\n        await this.getAppConfig();\n      }\n\n      const {data} = await axios.post<IAdobePrelimAuthToken>(\n        this.appConfig.api.profile.login,\n        {\n          deviceId: this.adobe_device_id,\n        },\n        {\n          headers: {\n            'User-Agent': androidFoxUserAgent,\n            'x-api-key': this.appConfig.api.key,\n            'x-signature-enabled': true,\n          },\n        },\n      );\n\n      this.adobe_prelim_auth_token = data;\n      await this.save();\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get information to start Fox Sports login flow');\n    }\n  };\n\n  public getAuthCode = async (): Promise<string> => {\n    this.adobe_device_id = _.take(getRandomHex(), 16).join('');\n    this.adobe_auth = undefined;\n\n    if (!this.appConfig) {\n      await this.getAppConfig();\n    }\n\n    await this.getPrelimToken();\n\n    try {\n      const {data} = await axios.post(\n        this.appConfig.api.auth.accountRegCode,\n        {\n          deviceID: this.adobe_device_id,\n          isMvpd: true,\n          selectedMvpdId: '',\n        },\n        {\n          headers: {\n            'User-Agent': androidFoxUserAgent,\n            authorization: `Bearer ${this.adobe_prelim_auth_token.accessToken}`,\n            'x-api-key': this.appConfig.api.key,\n          },\n        },\n      );\n\n      return data.code;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start the authentication process for Fox Sports!');\n    }\n  };\n\n  public authenticateRegCode = async (showAuthnError = true): Promise<boolean> => {\n    try {\n      if (!this.appConfig) {\n        await this.getAppConfig();\n      }\n\n      const {data} = await axios.get(`${this.appConfig.api.auth.checkadobeauthn}?device_id=${this.adobe_device_id}`, {\n        headers: {\n          'User-Agent': androidFoxUserAgent,\n          authorization: !this.adobe_auth?.accessToken\n            ? `Bearer ${this.adobe_prelim_auth_token.accessToken}`\n            : this.adobe_auth.accessToken,\n          'x-api-key': this.appConfig.api.key,\n          'x-signature-enabled': true,\n        },\n      });\n\n      this.adobe_auth = data;\n      await this.save();\n\n      await this.getEntitlements();\n\n      return true;\n    } catch (e) {\n      if (e.response?.status !== 404) {\n        if (showAuthnError) {\n          if (e.response?.status === 410) {\n            console.error(e);\n            console.log('Adobe AuthN token has expired for FOX Sports');\n          }\n        } else if (e.response?.status !== 410) {\n          console.error(e);\n          console.log('Could not get provider token data for Fox Sports!');\n        }\n      }\n\n      return false;\n    }\n  };\n\n  private save = async () => {\n    await db.providers.updateAsync({name: 'foxsports'}, {$set: {tokens: _.omit(this, 'appConfig', 'entitlements')}});\n  };\n\n  private load = async (): Promise<void> => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TFoxTokens>>({name: 'foxsports'});\n    const {adobe_device_id, adobe_auth, adobe_prelim_auth_token} = tokens;\n\n    this.adobe_device_id = adobe_device_id;\n    this.adobe_auth = adobe_auth;\n    this.adobe_prelim_auth_token = adobe_prelim_auth_token;\n  };\n\n  private loadJSON = () => {\n    if (fs.existsSync(foxConfigPath)) {\n      const {adobe_device_id, adobe_auth, adobe_prelim_auth_token} = fsExtra.readJSONSync(foxConfigPath);\n\n      this.adobe_device_id = adobe_device_id;\n      this.adobe_auth = adobe_auth;\n      this.adobe_prelim_auth_token = adobe_prelim_auth_token;\n    }\n  };\n}\n\nexport type TFoxTokens = ClassTypeWithoutMethods<FoxHandler>;\n\nexport const foxHandler = new FoxHandler();"
  },
  {
    "path": "services/foxone-handler.ts",
    "content": "import fs from 'fs';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport _ from 'lodash';\nimport moment from 'moment';\n\nimport {androidFoxOneUserAgent, userAgent} from './user-agent';\nimport {configPath} from './config';\nimport {useFoxOneOnly4k, useFoxOne} from './networks';\nimport {IAdobeAuthFoxOne} from './adobe-helpers';\nimport {getRandomHex, normalTimeRange} from './shared-helpers';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {debug} from './debug';\nimport {usesLinear, hideStudio} from './misc-db-service';\n\ninterface IAppConfig {\n  network: {\n    identity: {\n      host: string;\n      entitlementsUrl: string;\n      regcodeUrl: string;\n      checkAdobeUrl: string;\n      loginUrl: string;\n    };\n    auth: {\n      loginWebsiteUrl: string;\n    };\n    apikey: string;\n  };\n  playback: {\n    baseApiUrl: string;\n  };\n}\n\ninterface IAdobePrelimAuthToken {\n  accessToken: string;\n  tokenExpiration: number;\n  viewerId: string;\n  deviceId: string;\n  profileId: string;\n}\n\ninterface IFoxOneEvent {\n  airing_type: string;\n  audio_only: boolean;\n  call_sign: string;\n  tags: string[];\n  entity_id: string;\n  genre_metadata: {\n    display_name: string;\n  };\n  title: string;\n  description: string;\n  sport_uri?: string;\n  start_time: string;\n  end_time: string;\n  network: string;\n  content_sku: string;\n  stream_types: string[];\n  images: {\n    logo?: string;\n    series_detail?: string;\n    series_list?: string;\n  };\n  gracenote: {\n    station_id?: string;\n  };\n  is_uhd?: boolean;\n  is_multiview?: boolean;\n  is_sportingevent?: boolean;\n}\n\ninterface IFoxOneMeta {\n  only4k?: boolean;\n  uhd?: boolean;\n  dtc_events?: boolean;\n  local_station_call_signs?: string[] | string;\n}\n\nconst foxOneConfigPath = path.join(configPath, 'foxone_tokens.json');\n\nconst getMaxRes = (res: string) => {\n  switch (res) {\n    case 'UHD/HDR':\n      return 'UHD/HDR';\n    default:\n      return 'HD';\n  }\n};\n\nconst parseCategories = (event: IFoxOneEvent) => {\n  const categories = ['FOX One', 'FOX'];\n  for (const classifier of [...(event.tags || []), ...(event.genre_metadata.display_name || [])]) {\n    if (classifier !== null) {\n      categories.push(classifier);\n    }\n  }\n\n  if (event.sport_uri) {\n    categories.push(event.sport_uri);\n  }\n\n  const hasHDRorSDR = event.stream_types?.some(res => res === 'HDR' || res === 'SDR');\n  const isUHD = event.is_uhd;\n\n  if (hasHDRorSDR || isUHD) {\n    categories.push('4K');\n  }\n  return [...new Set(categories)];\n};\n\nconst parseAirings = async (events: IFoxOneEvent[]) => {\n  const useLinear = await usesLinear();\n  const hide_studio = await hideStudio();\n\n  const [now, inTwoDays] = normalTimeRange();\n\n  const {meta} = await db.providers.findOneAsync<IProvider<any, IFoxOneMeta>>({name: 'foxone'});\n\n  for (const event of events) {\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `${event.entity_id}`});\n\n    if (!entryExists) {\n      const isLinear = useLinear;\n      \n      if (!isLinear && event.airing_type !== 'live') {\n        continue;\n      }\n\n      if (!isLinear && hide_studio && !event.is_sportingevent) {\n        continue;\n      }\n      \n      const start = moment(event.start_time);\n      const end = moment(event.end_time);\n      const originalEnd = moment(event.end_time);\n\n      if (!isLinear) {\n        end.add(1, 'hour');\n      }\n\n      if (end.isBefore(now) || start.isAfter(inTwoDays)) {\n        continue;\n      }\n\n      const categories = parseCategories(event);\n\n      if (meta.only4k && !_.some(categories, category => category === '4K')) {\n        continue;\n      }\n\n      const eventName = `${event.sport_uri === 'NFL' ? `${event.sport_uri} - ` : ''}${event.title}`;\n\n      console.log(`Adding event: ${event.call_sign}: ${eventName}`);\n\n      await db.entries.insertAsync<IEntry>({\n        categories,\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'foxone',\n        id: event.entity_id,\n        image: event.images?.logo || event.images?.series_detail || event.images?.series_list,\n        name: eventName,\n        network: event.call_sign,\n        originalEnd: originalEnd.valueOf(),\n        replay: event.airing_type !== 'live' && event.airing_type !== 'new',\n        start: start.valueOf(),\n        ...(isLinear && {\n          channel: event.network,\n          linear: true,\n        }),\n      });\n    }\n  }\n};\n\nconst FOXONE_APP_CONFIG = 'https://config.foxplus.com/androidtv/1.3/config/info.json';\n// Will prelim token expire in the next month?\nconst willPrelimTokenExpire = (token: IAdobePrelimAuthToken): boolean =>\n  new Date().valueOf() + 3600 * 1000 * 24 * 30 > (token?.tokenExpiration || 0);\n// Will auth token expire in the next day?\nconst willAuthTokenExpire = (token: IAdobeAuthFoxOne): boolean =>\n  new Date().valueOf() + 3600 * 1000 * 24 > (token?.tokenExpiration || 0);\n\nconst checkEventSku = (entitlements, event: IFoxOneEvent): boolean => {\n  if (event.content_sku && Array.isArray(entitlements)) {\n    return true;\n  }\n  return false;\n};\n\nclass FoxOneHandler {\n  public adobe_device_id?: string;\n  public adobe_prelim_auth_token?: IAdobePrelimAuthToken;\n  public adobe_auth?: IAdobeAuthFoxOne;\n\n  private platform_location?: string;\n  private platform_zip?: string;\n  private contentEntitlement?: string;\n  private homeMetroCode?: string;\n  private homeZipCode?: string;\n  private entitlements: string[] = [];\n  private entArray: string[] = [];\n  private contentEnt: any;\n  private appConfig: IAppConfig;\n  private stationMap: { [key: string]: { network: string; stationId: string; callSign: string } } = {}; // Add stationMap as a class property\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'foxone'})) > 0 ? true : false;\n\n    if (!setup) {\n      const data: TFoxOneTokens = {};\n\n      if (useFoxOne) {\n        this.loadJSON();\n\n        data.adobe_auth = this.adobe_auth;\n        data.adobe_device_id = this.adobe_device_id;\n        data.adobe_prelim_auth_token = this.adobe_prelim_auth_token;\n      }\n\n      await db.providers.insertAsync<IProvider<TFoxOneTokens, IFoxOneMeta>>({\n        enabled: useFoxOne,\n        linear_channels: [\n          {\n            enabled: false,\n            id: 'FOX',\n            name: 'FOX',\n            tmsId: '',\n            callSign: '',\n          },\n          {\n            enabled: false,\n            id: 'MNTV',\n            name: 'MyNetwork TV',\n            tmsId: '',\n            callSign: '',\n          },\n          {\n            enabled: false,\n            id: 'FS1',\n            name: 'FS1',\n            tmsId: '82547',\n          },\n          {\n            enabled: false,\n            id: 'FS2',\n            name: 'FS2',\n            tmsId: '59305',\n          },\n          {\n            enabled: false,\n            id: 'Big Ten Network',\n            name: 'B1G Network',\n            tmsId: '58321',\n          },\n          {\n            enabled: false,\n            id: 'FOX Deportes',\n            name: 'FOX Deportes',\n            tmsId: '72189',\n          },\n          {\n            enabled: false,\n            id: 'FOX News',\n            name: 'FOX News Channel',\n            tmsId: '60179',\n          },\n          {\n            enabled: false,\n            id: 'FOX Business',\n            name: 'FOX Business Network',\n            tmsId: '58718',\n          },\n          {\n            enabled: false,\n            id: 'TMZ',\n            name: 'TMZ',\n            tmsId: '149408',\n          },\n          {\n            enabled: false,\n            id: 'FOX Digital',\n            name: 'Masked Singer',\n          },\n          {\n            enabled: false,\n            id: 'FOX Soul',\n            name: 'Fox Soul',\n            tmsId: '119212',\n          },\n          {\n            enabled: false,\n            id: 'FOX Weather',\n            name: 'Fox Weather',\n            tmsId: '121307',\n          },\n          {\n            enabled: false,\n            id: 'FOX LOCAL',\n            name: 'Fox Live Now',\n            tmsId: '119219',\n          },\n        ],\n        meta: {\n          only4k: useFoxOneOnly4k,\n          uhd: getMaxRes(process.env.MAX_RESOLUTION) === 'UHD/HDR',\n          local_station_call_signs: '',\n        },\n        name: 'foxone',\n        tokens: data,\n      });\n\n      if (fs.existsSync(foxOneConfigPath)) {\n        fs.rmSync(foxOneConfigPath);\n      }\n    }\n\n    if (useFoxOne) {\n      console.log('Using FOXONE variable is no longer needed. Please use the UI going forward');\n    }\n    if (useFoxOneOnly4k) {\n      console.log('Using FOXONE_ONLY_4K variable is no longer needed. Please use the UI going forward');\n    }\n    if (process.env.MAX_RESOLUTION) {\n      console.log('Using MAX_RESOLUTION variable is no longer needed. Please use the UI going forward');\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider<TFoxOneTokens, IFoxOneMeta>>({name: 'foxone'});\n\n    if (!enabled) {\n      return;\n    }\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n\n    await this.getEntitlements();\n    \n    // Update linear_channels during initialization\n    await this.getEvents();\n  };\n\n  public getEvents = async (): Promise<IFoxOneEvent[]> => {\n    if (!this.appConfig) {\n      await this.getAppConfig();\n    }\n\n    const useLinear = await usesLinear();\n    const events: IFoxOneEvent[] = [];\n\n    const [now, inTwoDays] = normalTimeRange();\n\n    const startTime = now.unix();\n    const endTime = inTwoDays.unix();\n\n    try {\n      await this.getLocation();\n      await this.getEntitlements();\n      await this.getUserEntitlements();\n\n      const { data: initData } = await axios.get<any>(\n        'https://api.fox.com/dtc/product/config/v1/init',\n        {\n          headers: {\n            'User-Agent': androidFoxOneUserAgent,\n            authorization: `Bearer ${this.adobe_prelim_auth_token.accessToken}`,\n            'x-fox-apikey': this.appConfig.network.apikey,\n            'x-platform-location': this.platform_location,\n            'x-fox-zipcode': this.platform_zip,\n            'x-home-zipcode': this.homeZipCode || '',\n            'x-fox-home-dma': this.homeMetroCode || '',\n            'x-fox-dma': this.homeMetroCode || '',\n            'x-fox-content-entitlement': this.contentEntitlement || '',\n            'x-fox-userauth': `Bearer ${this.adobe_prelim_auth_token.accessToken}`,\n          },\n        },\n      );\n\n      const navigationUri = initData?.data?.dynamic_uris?.navigation_uri;\n\n      if (!navigationUri) {\n        throw new Error('navigation_uri not found in init data');\n      }\n\n      const { data: navData } = await axios.get<any>(\n        `https://api.fox.com/dtc${navigationUri}?page=1&size=25`,\n        {\n          headers: {\n            'User-Agent': androidFoxOneUserAgent,\n            authorization: `Bearer ${this.adobe_prelim_auth_token.accessToken}`,\n            'x-fox-apikey': this.appConfig.network.apikey,\n            'x-platform-location': this.platform_location,\n            'x-fox-zipcode': this.platform_zip,\n            'x-home-zipcode': this.homeZipCode || '',\n            'x-fox-home-dma': this.homeMetroCode || '',\n            'x-fox-dma': this.homeMetroCode || '',\n            'x-fox-content-entitlement': this.contentEntitlement || '',\n            'x-fox-userauth': `Bearer ${this.adobe_prelim_auth_token.accessToken}`,\n          },\n        },\n      );\n\n      let lSchedUri: string | undefined = undefined;\n\n      if (navData.data?.items) {\n        for (const item of navData.data.items) {\n          if (item.subitems) {\n            for (const subitem of item.subitems) {\n              if (subitem.subitems) {\n                const foundDeepSubitem = subitem.subitems.find(\n                  (deepSub: any) => deepSub.item_key === \"guide\"\n                );\n\n                if (foundDeepSubitem) {\n                  lSchedUri = foundDeepSubitem.page_uri;\n                  break;\n                }\n              }\n            }\n          }\n          if (lSchedUri) {\n            break;\n          }\n        }\n      }\n      const liveScheduleUri = lSchedUri;\n\n      if (!liveScheduleUri) {\n        throw new Error('live_schedule_page_uri not found in init data');\n      }\n\n      const { data: scheduleData } = await axios.get<any>(\n        `https://api.fox.com/dtc${liveScheduleUri}`,\n        {\n          headers: {\n            'User-Agent': androidFoxOneUserAgent,\n            authorization: `Bearer ${this.adobe_prelim_auth_token.accessToken}`,\n            'x-fox-apikey': this.appConfig.network.apikey,\n            'x-platform-location': this.platform_location,\n            'x-fox-zipcode': this.platform_zip,\n            'x-home-zipcode': this.homeZipCode || '',\n            'x-fox-home-dma': this.homeMetroCode || '',\n            'x-fox-dma': this.homeMetroCode || '',\n            'x-fox-content-entitlement': this.contentEntitlement || '',\n            'x-fox-userauth': `Bearer ${this.adobe_prelim_auth_token.accessToken}`,\n          },\n        },\n      );\n\n      const containerUris: string[] =\n        scheduleData?.data.containers?.map((c: any) => c.uri) || [];\n\n      const allContainerData: any[] = [];\n\n      for (const uri of containerUris) {\n        try {\n          const { data } = await axios.get<any>(\n            `https://api.fox.com/dtc${uri}`,\n            {\n              headers: {\n                'User-Agent': androidFoxOneUserAgent,\n                authorization: `Bearer ${this.adobe_prelim_auth_token.accessToken}`,\n                'x-fox-apikey': this.appConfig.network.apikey,\n                'x-platform-location': this.platform_location,\n                'x-fox-zipcode': this.platform_zip,\n                'x-home-zipcode': this.homeZipCode || '',\n                'x-fox-home-dma': this.homeMetroCode || '',\n                'x-fox-dma': this.homeMetroCode || '',\n                'x-fox-content-entitlement': this.contentEntitlement || '',\n                'x-fox-userauth': `Bearer ${this.adobe_prelim_auth_token.accessToken}`,\n              },\n            },\n          );\n\n          if (data?.data?.items && Array.isArray(data.data.items)) {\n            allContainerData.push(...data.data.items);\n          } else {\n            console.warn(`No items found in container ${uri}`);\n          }\n\n        } catch (err) {\n          console.warn(`Failed to fetch container ${uri}:`, err);\n        }\n      }\n\n      const allEntityIds: string[] = allContainerData\n        .filter(item => item && typeof item === 'object' && item.entity_id)\n        .map(item => item.entity_id);\n\n      const allEvents = allContainerData.map(event => {\n        if (!event.genre_metadata) {\n          event.genre_metadata = [];\n        }\n        return event;\n      });\n\n      const stationMap = {};\n      for (const event of allEvents) {\n        const { call_sign, network, gracenote } = event;\n\n        if (call_sign && network && gracenote?.station_id) {\n          if (!stationMap[network]) {\n            stationMap[network] = {\n              network,\n              stationId: gracenote.station_id,\n              callSign: call_sign,\n            };\n          }\n        }\n      }\n      this.stationMap = stationMap;\n      //console.log('station Mapping:    ', stationMap);\n\n      // Update linear_channels with static tmsId except for FOX and MNTV\n      const {linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'foxone'});\n      const updatedChannels = linear_channels.map(channel => {\n        if (channel.id === 'FOX') {\n          return {...channel, tmsId: this.stationMap['FOX']?.stationId, callSign: this.stationMap['FOX']?.callSign};\n        } else if (channel.id === 'MNTV') {\n          return {...channel, tmsId: this.stationMap['MNTV']?.stationId, callSign: this.stationMap['MNTV']?.callSign};\n        } \n        return channel;\n      });\n      await db.providers.updateAsync<IProvider<TFoxOneTokens>, any>({name: 'foxone'}, {$set: {linear_channels: updatedChannels}});\n      //console.log('getEvents: Updated linear_channels with stationMap');\n      const provider = await db.providers.findOneAsync({name: 'foxone'});\n      //console.log('linear_channels:', provider.linear_channels);\n\n      _.forEach(allEvents, m => {\n        if (!m.content_sku) {\n          return;\n        }\n\n        const hasEntitlement = this.entitlements.some(entitlement => {\n          return m.content_sku.includes(entitlement) ||\n                 entitlement.includes(m.content_sku);\n        });\n        \n        const isChannelEnabled = linear_channels.find(c => c.id === m.network && c.enabled === true);\n\n        if (\n          m.call_sign &&\n          hasEntitlement &&\n          isChannelEnabled &&\n          m.is_multiview !== true &&\n          !m.audio_only &&\n          m.start_time &&\n          m.end_time &&\n          m.entity_id &&\n          !m.entity_id.includes(\"-long-\")\n        ) {\n          events.push(m);\n        }\n      });\n\n    } catch (e) {\n      console.error('Error while loading FoxOne events:', e);\n    }\n    return events;\n  };\n  \npublic getStationMap = async (): Promise<typeof this.stationMap> => {\n  try {\n    //await this.getEvents();\n    //console.log('getStationMap call to this.stationMap:', this.stationMap);\n    return this.stationMap;\n  } catch (e) {\n    console.error('getStationMap failed:', e);\n    throw e;\n  }\n};\n\n\n  public async getLocation(): Promise<void> {\n    const { data: locatorData } = await axios.get<any>(\n      'https://ent.fox.com/locator/v1/location',\n      {\n        headers: {\n          'User-Agent': androidFoxOneUserAgent,\n          'x-api-key': this.appConfig.network.apikey,\n        },\n      }\n    );\n\n    const locationData = locatorData?.data?.metadata;\n    const zipCodeData = (locatorData?.data?.results || [])[0];\n\n    this.platform_location = locationData?.['x-platform-location'] || 'Unknown Location';\n    this.platform_zip = zipCodeData?.['zip_code'] || '00000';\n  }\n\n  public async getUserEntitlements(): Promise<void> {\n    try {\n      await this.getLocation();\n\n      if (!this.appConfig) {\n        await this.getAppConfig();\n      }\n\n      try {\n        const response = await axios.put(\n          'https://ent.fox.com/user-preferences/v1/home-location',\n          { home_zip_code: this.platform_zip },\n          {\n            headers: {\n              'user-agent': androidFoxOneUserAgent,\n              'authorization': `bearer ${this.adobe_auth.accessToken}`,\n              'x-api-key': this.appConfig.network.apikey,\n              'content-type': 'application/json'\n            }\n          }\n        );\n      } catch (error) {\n        console.error('Error updating zip code:', error.response?.data || error.message);\n        throw error;\n      }\n      const { data: userEnt } = await axios.get<any>(\n        'https://ent.fox.com/user-preferences/v1/preferences',\n        {\n          headers: {\n            'User-Agent': androidFoxOneUserAgent,\n            'x-api-key': this.appConfig.network.apikey || '',\n            authorization: `Bearer ${this.adobe_prelim_auth_token.accessToken}`,\n            'x-platform-location': this.platform_location || '',\n          },\n        }\n      );\n\n      const results = userEnt?.data?.results || [];\n      for (const item of results) {\n        if (item.key === 'HOME_LOCATION' && item.value) {\n          this.homeMetroCode = item.value.home_metro_code || null;\n          this.homeZipCode = item.value.home_zip_code || null;\n          break;\n        }\n      }\n\n      const { data: userData } = await axios.post<any>(\n        'https://api.fox.com/dtc/product/config/v1/keygen/secondary_info',\n        this.contentEnt,\n        {\n          headers: {\n            'User-Agent': androidFoxOneUserAgent,\n            'x-fox-apikey': this.appConfig.network.apikey,\n            authorization: `Bearer ${this.adobe_prelim_auth_token.accessToken}`,\n            'x-platform-location': this.platform_location || '',\n            'x-fox-zipcode': this.platform_zip || '',\n            'x-home-zipcode': this.homeZipCode || '',\n            'x-fox-home-dma': this.homeMetroCode || '',\n            'x-fox-dma': this.homeMetroCode || '',\n          },\n        }\n      );\n\n      const headers = userData?.data?.headers || [];\n      const entitlementHeader = headers.find((h: any) => h.key === 'x-fox-content-entitlement');\n\n      if (entitlementHeader && entitlementHeader.value) {\n        this.contentEntitlement = entitlementHeader.value;\n      } else {\n        console.warn('x-fox-content-entitlement not found in response');\n      }\n\n    } catch (e) {\n      console.error('Error in getUserEntitlements:', e);\n    }\n  }\n\n  public refreshTokens = async () => {\n    const {enabled} = await db.providers.findOneAsync<IProvider<TFoxOneTokens, IFoxOneMeta>>({name: 'foxone'});\n\n    if (!enabled) {\n      return;\n    }\n\n    if (!this.adobe_prelim_auth_token || willPrelimTokenExpire(this.adobe_prelim_auth_token)) {\n      console.log('Updating FOX One prelim token');\n      await this.getPrelimToken();\n    }\n\n    if (willAuthTokenExpire(this.adobe_auth)) {\n      console.log('Refreshing TV Provider token (FOX One)');\n      await this.authenticateRegCode();\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider<TFoxOneTokens, IFoxOneMeta>>({name: 'foxone'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log('Looking for FOX One events...');\n\n    try {\n      const entries = await this.getEvents();\n      await parseAirings(entries);\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse FOX One events');\n    }\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    try {\n      if (!this.appConfig) {\n        await this.getAppConfig();\n      }\n\n      let cdn = '';  ///// leaving cdn blank here and in the while loop will select the first stream offered up.  Changing to akamai, fastly or cloudfront here and as !== in the while loop will default to that cdn\n      let data;\n\n      while (cdn === '') { ///// If only want one use (cdn !== 'selected cdn of choice') can be akamai, cloudfront or fastly\n        data = await this.getStreamData(eventId);\n        cdn = data.stream.cdn;\n      }\n\n      if (!data || !data?.stream?.playbackUrl) {\n        throw new Error('Could not get stream data. Event might be upcoming, ended, or in blackout...');\n      }\n\n      const playURL = data.stream.playbackUrl;\n      \n      ///////////////// Debugging CDNs////////////////////\n      // console.log('Playback Info:', {\n      //   PlaybackURL: data.stream.playbackUrl,\n      //   CDN: data.stream.cdn})\n\n      if (!playURL) {\n        throw new Error('Could not get stream data. Event might be upcoming, ended, or in blackout...');\n      }\n\n      return [\n        playURL,\n        {\n          'Accept-Encoding': 'gzip, deflate, br, zstd',\n          Origin: 'https://www.fox.com',\n          Referer: 'https://www.fox.com/',\n          'User-Agent': androidFoxOneUserAgent,\n        },\n      ];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get stream information!');\n    }\n  };\n\n  private getStreamData = async (eventId: string): Promise<any> => {\n    const {meta} = await db.providers.findOneAsync<IProvider<any, IFoxOneMeta>>({name: 'foxone'});\n    const {uhd} = meta;\n\n    const streamOrder = ['UHD/HDR', 'HD'];\n\n    let resIndex = streamOrder.findIndex(i => i === getMaxRes(uhd ? 'UHD/HDR' : ''));\n\n    if (resIndex < 0) {\n      resIndex = 1;\n    }\n\n    if (!this.appConfig) {\n      await this.getAppConfig();\n    }\n\n    let watchData;\n\n    for (let a = resIndex; a < streamOrder.length; a++) {\n      let deviceCapabilities: string;\n      if (streamOrder[a] === 'UHD/HDR') {\n        deviceCapabilities = 'color/HDR,maxRes/UHD';\n      } else {\n        deviceCapabilities = 'color/SDR,maxRes/HD';\n      }\n\n      try {\n        const {data} = await axios.post(\n          'https://prod.api.digitalvideoplatform.com/foxdtc/v3.0/watchlive',\n          {\n            asset: {\n              id: eventId,\n            },\n            device: {\n              height: 2160,\n              model: 'onn. 4K Streaming Box',\n              os: 'android',\n              osv: '12',\n              width: 3840,\n            },\n            stream: {\n              type: 'Live',\n            }\n          },\n          {\n            headers: {\n              'Accept-Encoding': 'gzip, deflate, br, zstd',\n              'User-Agent': androidFoxOneUserAgent,\n              authorization: this.adobe_auth.accessToken,\n              'x-api-key': this.appConfig.network.apikey,\n              'x-platform-location': this.platform_location,\n              'x-device-capabilities': deviceCapabilities,\n            },\n          },\n        );\n\n        watchData = data;\n        break;\n      } catch (e) {\n        console.log(\n          `Could not get stream data for ${streamOrder[a]}. ${\n            streamOrder[a + 1] ? `Trying to get ${streamOrder[a + 1]} next...` : ''\n          }`,\n        );\n      }\n    }\n    return watchData;\n  };\n\n  private getAppConfig = async () => {\n    try {\n      const {data} = await axios.get<IAppConfig>(FOXONE_APP_CONFIG);\n      this.appConfig = data;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not load API app config');\n    }\n  };\n\n  private getEntitlements = async (): Promise<void> => {\n    try {\n      if (!this.appConfig) {\n        await this.getAppConfig();\n      }\n\n      await this.getLocation();\n\n      const {data} = await axios.get<any>(\n        `https://ent.fox.com${this.appConfig.network.identity.entitlementsUrl}?device_type=&device_id=${this.adobe_device_id}&resource=&requestor=`,\n        {\n          headers: {\n            'User-Agent': androidFoxOneUserAgent,\n            authorization: this.adobe_auth.accessToken,\n            'x-api-key': this.appConfig.network.apikey,\n            'x-platform-location': this.platform_location,\n            'x-fox-zipcode': this.platform_zip,\n          },\n        },\n      );\n\n      this.entitlements = [];\n      this.entArray = [];\n      this.contentEnt = [];\n\n      const results = data?.data?.results;\n      if (Array.isArray(results)) {\n        this.entArray = results;\n        this.entitlements = results\n          .map((item: any) => item.contentSku)\n          .filter((sku: any) => typeof sku === 'string');\n      }\n\n      if (Array.isArray(this.entArray) && this.entArray.length > 0) {\n        const transformedEntitlements = this.entArray.map((item: any) => ({\n          content_sku: item.contentSku,\n          proxied_entitlement: item.proxiedEntitlement,\n          entitlement_types: item.entitlementType || []\n        }));\n\n        const finalEntitlements = {\n          favorites: {\n            teams: [],\n            series: [],\n            leagues: [],\n            movies: [],\n            specials: [],\n          },\n          user_entitlement: transformedEntitlements,\n          user_onboarding_preferences: []\n        };\n\n        this.contentEnt = finalEntitlements;\n      }\n    } catch (e) {\n      console.error(e);\n    }\n  };\n\n  private getPrelimToken = async (): Promise<void> => {\n    try {\n      if (!this.appConfig) {\n        await this.getAppConfig();\n      }\n\n      const {data} = await axios.post<IAdobePrelimAuthToken>(\n        `https://id.fox.com${this.appConfig.network.identity.loginUrl}`,\n        {\n          deviceId: this.adobe_device_id,\n        },\n        {\n          headers: {\n            'User-Agent': androidFoxOneUserAgent,\n            'x-api-key': this.appConfig.network.apikey,\n            'x-signature-enabled': true,\n          },\n        },\n      );\n\n      this.adobe_prelim_auth_token = data;\n      await this.save();\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get information to start Fox One login flow');\n    }\n  };\n\n  public getAuthCode = async (): Promise<string> => {\n    this.adobe_device_id = _.take(getRandomHex(), 16).join('');\n    this.adobe_auth = undefined;\n\n    if (!this.appConfig) {\n      await this.getAppConfig();\n    }\n\n    await this.getPrelimToken();\n\n    try {\n      const {data} = await axios.post(\n        `https://id.fox.com${this.appConfig.network.identity.regcodeUrl}`,\n        {\n          deviceID: this.adobe_device_id,\n          isMvpd: true,\n          selectedMvpdId: '',\n        },\n        {\n          headers: {\n            'User-Agent': androidFoxOneUserAgent,\n            authorization: `Bearer ${this.adobe_prelim_auth_token.accessToken}`,\n            'x-api-key': this.appConfig.network.apikey,\n          },\n        },\n      );\n      console.log(data.code)\n\n      return data.code;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start the authentication process for Fox One!');\n    }\n  };\n\n  public authenticateRegCode = async (showAuthnError = true): Promise<boolean> => {\n    try {\n      if (!this.appConfig) {\n        await this.getAppConfig();\n      }\n\n      const {data} = await axios.get(`https://id.fox.com/v2.0/checkadobeauthn/v2/?device_id=${this.adobe_device_id}`, {\n        headers: {\n          'User-Agent': androidFoxOneUserAgent,\n          authorization: !this.adobe_auth?.accessToken\n            ? `Bearer ${this.adobe_prelim_auth_token.accessToken}`\n            : this.adobe_auth.accessToken,\n          'x-api-key': this.appConfig.network.apikey,\n          'x-signature-enabled': true,\n        },\n      });\n\n      this.adobe_auth = data;\n      await this.save();\n\n      await this.getEntitlements();\n\n      return true;\n    } catch (e) {\n      if (e.response?.status !== 404) {\n        if (showAuthnError) {\n          if (e.response?.status === 410) {\n            console.error(e);\n            console.log('Adobe AuthN token has expired for FOX One');\n          }\n        } else if (e.response?.status !== 410) {\n          console.error(e);\n          console.log('Could not get provider token data for Fox One!');\n        }\n      }\n\n      return false;\n    }\n  };\n\n  private save = async () => {\n    await db.providers.updateAsync({name: 'foxone'}, {$set: {tokens: _.omit(this, 'appConfig', 'entitlements', 'entArray', 'foxStationId', 'mnStationId', 'platform_location', 'platform_zip', 'contentEnt', 'homeMetroCode', 'homeZipCode','contentEntitlement', 'stationMap')}});\n  };\n\n  private load = async (): Promise<void> => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TFoxOneTokens>>({name: 'foxone'});\n    const {adobe_device_id, adobe_auth, adobe_prelim_auth_token} = tokens;\n\n    this.adobe_device_id = adobe_device_id;\n    this.adobe_auth = adobe_auth;\n    this.adobe_prelim_auth_token = adobe_prelim_auth_token;\n  };\n\n  private loadJSON = () => {\n    if (fs.existsSync(foxOneConfigPath)) {\n      const {adobe_device_id, adobe_auth, adobe_prelim_auth_token} = fsExtra.readJSONSync(foxOneConfigPath);\n\n      this.adobe_device_id = adobe_device_id;\n      this.adobe_auth = adobe_auth;\n      this.adobe_prelim_auth_token = adobe_prelim_auth_token;\n    }\n  };\n}\n\nexport type TFoxOneTokens = ClassTypeWithoutMethods<FoxOneHandler>;\nexport const foxOneHandler = new FoxOneHandler();"
  },
  {
    "path": "services/generate-m3u.ts",
    "content": "import _ from 'lodash';\nimport moment from 'moment-timezone';\n\nimport {db} from './database';\nimport {CHANNELS} from './channels';\nimport {getLinearStartChannel, getNumberOfChannels, getStartChannel} from './misc-db-service';\nimport {IEntry} from './shared-interfaces';\n\nexport const generateM3u = async (uri: string, linear = false, excludeGracenote = false): Promise<string> => {\n  const startChannel = await getStartChannel();\n  const numOfChannels = await getNumberOfChannels();\n  const linearStartChannel = await getLinearStartChannel();\n\n  let m3uFile = '#EXTM3U';\n\n  if (linear) {\n    for (const key in CHANNELS.MAP) {\n      const val = CHANNELS.MAP[key];\n\n      if (val.checkChannelEnabled) {\n        const enabled = await val.checkChannelEnabled();\n\n        if (!enabled) {\n          continue;\n        }\n      }\n\n      // Resolve tvgName and stationId, handling async functions\n      const updatedTvgName = typeof val.tvgName === 'function' ? await val.tvgName() : val.tvgName;\n      const updatedStationId = typeof val.stationId === 'function' ? await val.stationId() : val.stationId;\n\n      if (excludeGracenote && (updatedStationId || updatedTvgName)) {\n        continue;\n      } else if (!excludeGracenote && (!updatedStationId || !updatedTvgName)) {\n        continue;\n      }\n\n      const channelNum = parseInt(key, 10) + linearStartChannel;\n\n      if (excludeGracenote) {\n        m3uFile = `${m3uFile}\\n#EXTINF:0 tvg-id=\"${channelNum}.eplustv\" channel-number=\"${channelNum}\" tvg-chno=\"${channelNum}\" tvg-name=\"${val.id}\" group-title=\"EPlusTV\", ${val.name}`;\n      } else {\n        m3uFile = `${m3uFile}\\n#EXTINF:0 tvg-id=\"${channelNum}.eplustv\" channel-id=\"${val.provider}.${val.name}\" channel-number=\"${channelNum}\" tvg-chno=\"${channelNum}\" tvg-name=\"${updatedTvgName}\" tvc-guide-stationid=\"${updatedStationId}\" group-title=\"EPlusTV\", ${val.name}`;\n      }\n\n      m3uFile = `${m3uFile}\\n${uri}/channels/${channelNum}.m3u8\\n`;\n    }\n  } else {\n    _.times(numOfChannels, i => {\n      const channelNum = startChannel + i;\n      m3uFile = `${m3uFile}\\n#EXTINF:0 tvg-id=\"${channelNum}.eplustv\" channel-number=\"${channelNum}\" tvg-chno=\"${channelNum}\" tvg-name=\"EPlusTV ${channelNum}\" group-title=\"EPlusTV\", EPlusTV ${channelNum}`;\n      m3uFile = `${m3uFile}\\n${uri}/channels/${channelNum}.m3u8\\n`;\n    });\n  }\n\n  return m3uFile;\n};\n\nexport const generateEventChannelsM3u = async (uri: string): Promise<string> => {\n  const now = Date.now();\n\n  // Only include events that haven't ended yet\n  const entries = await db.entries\n    .findAsync<IEntry>({\n      channel: {$exists: true},\n      linear: {$exists: false},\n      end: {$gt: now},\n    })\n    .sort({start: 1});\n\n  let m3uFile = '#EXTM3U';\n\n  entries.forEach((entry, i) => {\n    const channelNum = i + 1;\n    const time = moment(entry.start).tz(moment.tz.guess()).format('MMM D hh:mm A z');\n    const league = entry.sport || entry.network;\n\n    // Normalize MLB-style \"Team A @ Team B - HOME\" name suffix into a feed label\n    let baseName = entry.name;\n    let feedLabel = entry.feed || null;\n    if (!feedLabel) {\n      const homeAwayMatch = baseName.match(/\\s+-\\s+(HOME|AWAY)$/i);\n      if (homeAwayMatch) {\n        baseName = baseName.slice(0, homeAwayMatch.index);\n        feedLabel = homeAwayMatch[1].charAt(0).toUpperCase() + homeAwayMatch[1].slice(1).toLowerCase() + ' Feed';\n      }\n    }\n\n    const rawName = feedLabel ? `${baseName} (${feedLabel})` : baseName;\n    const eventName = rawName.replace(/\\bat\\b/gi, '@');\n\n    const displayName = `${league}: ${eventName} @ ${time} (${entry.network})`;\n\n    m3uFile = `${m3uFile}\\n#EXTINF:0 tvg-id=\"${entry.id}\" channel-number=\"${channelNum}\" tvg-chno=\"${channelNum}\" tvg-name=\"${displayName}\" tvg-logo=\"${entry.image}\" group-title=\"EPlusTV\", ${displayName}`;\n    m3uFile = `${m3uFile}\\n${uri}/channels/${entry.channel}.m3u8\\n`;\n  });\n\n  return m3uFile;\n};\n"
  },
  {
    "path": "services/generate-xmltv.ts",
    "content": "import _ from 'lodash';\nimport xml from 'xml';\nimport moment from 'moment';\n\nimport {db} from './database';\nimport {calculateChannelFromName, CHANNELS} from './channels';\nimport {IEntry} from './shared-interfaces';\nimport {getLinearStartChannel, getNumberOfChannels, getStartChannel, xmltvPadding} from './misc-db-service';\n\nconst baseCategories = ['HD', 'HDTV', 'Sports event', 'Sports', 'E+TV', 'EPlusTV'];\n\nexport const usesMultiple = async (): Promise<boolean> => {\n  const enabledProviders = await db.providers.countAsync({enabled: true});\n\n  return enabledProviders > 1;\n};\n\nexport const formatEntryName = (entry: IEntry, usesMultiple: boolean): string => {\n  let entryName = entry.name;\n\n  if (entry.feed) {\n    entryName = `${entryName} (${entry.feed})`;\n  }\n\n  if (usesMultiple && !entry.linear) {\n    entryName = `${entryName} - ${entry.network}`;\n  }\n\n  if (entry.sport && !entry.linear) {\n    entryName = `${entry.sport} - ${entryName}`;\n  }\n\n  return entryName;\n};\n\nconst formatCategories = (categories: string[] = []) =>\n  [...new Set([...baseCategories, ...categories])].map(category => ({\n    category: [\n      {\n        _attr: {\n          lang: 'en',\n        },\n      },\n      category,\n    ],\n  }));\n\nexport const generateXml = async (linear = false): Promise<xml> => {\n  const startChannel = await getStartChannel();\n  const numOfChannels = await getNumberOfChannels();\n  const linearStartChannel = await getLinearStartChannel();\n  const xmltvPadded = await xmltvPadding();\n\n  const wrap: any = {\n    tv: [\n      {\n        _attr: {\n          'generator-info-name': 'eplustv',\n        },\n      },\n    ],\n  };\n\n  const useMultiple = await usesMultiple();\n\n  if (linear) {\n    for (const key in CHANNELS.MAP) {\n      const val = CHANNELS.MAP[key];\n\n      if (val.checkChannelEnabled) {\n        const enabled = await val.checkChannelEnabled();\n\n        if (!enabled) {\n          continue;\n        }\n      }\n\n      const channelNum = parseInt(key, 10) + linearStartChannel;\n\n      wrap.tv.push({\n        channel: [\n          {\n            _attr: {\n              id: `${channelNum}.eplustv`,\n            },\n          },\n          {\n            'display-name': [\n              {\n                _attr: {\n                  lang: 'en',\n                },\n              },\n              val.name,\n            ],\n          },\n          {\n            icon: [\n              {\n                _attr: {\n                  src: val.logo,\n                },\n              },\n            ],\n          },\n        ],\n      });\n    }\n  } else {\n    _.times(numOfChannels, i => {\n      const channelNum = startChannel + i;\n\n      wrap.tv.push({\n        channel: [\n          {\n            _attr: {\n              id: `${channelNum}.eplustv`,\n            },\n          },\n          {\n            'display-name': [\n              {\n                _attr: {\n                  lang: 'en',\n                },\n              },\n              `EPlusTV ${channelNum}`,\n            ],\n          },\n          {\n            icon: [\n              {\n                _attr: {\n                  src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAtAAAAIcCAYAAADffZlTAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAylpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDkuMS1jMDAzIDc5Ljk2OTBhODdmYywgMjAyNS8wMy8wNi0yMDo1MDoxNiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDI2LjEwIChNYWNpbnRvc2gpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjc0QjE1RUY4OTExMzExRjBBMjY5ODA3ODc4ODM3ODMyIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjc0QjE1RUY5OTExMzExRjBBMjY5ODA3ODc4ODM3ODMyIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6RTk2Nzk0ODk5MTBDMTFGMEEyNjk4MDc4Nzg4Mzc4MzIiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6RTk2Nzk0OEE5MTBDMTFGMEEyNjk4MDc4Nzg4Mzc4MzIiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz5Xn3tVAAA6yklEQVR42uzdB/QkVZU44DvDkHNWRjKIiIqCIIiKKBjWHBcxLMY1rHl3jWva/5rzmjGgriJrwIAiJswiCAgGQF0QAclpGGCGSf8q+81xHGd+obuq3uvq7zvnHRFmuruquqpvvbrv3jkrVqwIAABgZubaBQAAIIAGAAABNAAACKABAEAADQAAAmgAABBA2wUAACCABgAAATQAAAigAQBAAA0AAAJoAAAQQNsFAAAggAYAAAE0AAAIoAEAQAANAAACaAAAEEDbBQAAIIAGAAABNAAACKABAEAADQAAAmgAABBA2wUAACCABgAAATQAAAigAQBAAA0AAAJoAAAQQNsFAAAggAYAAAE0AAAIoAEAQAANAAACaAAAEEDbBQAAIIAGAAABNAAACKABAEAADQAAAmgAABBA2wUAACCABgAAATQAAAigAQBAAA0AAAJoAAAQQNsFAAAggAYAAAE0AAAIoAEAQAANAAACaAAAEEDbBQAAIIAGAAABNAAACKABAEAADQAAAmgAABBA2wUAACCABgAAATQAAAigAQBAAA0AAAJoAAAQQNsFAAAggAYAAAE0AAAIoAEAQAANAAACaAAAEEDbBQAAIIAGAAABNAAACKABAEAADQAAAmgAABBA2wUAACCABgAAATQAAAigAQBAAA0AAAJoAAAQQNsFAAAggAYAAAE0AAAIoAEAQAANAAACaAAAEEDbBQAAIIAGAAABNAAACKABAEAADQAAAmgAABBA2wUAACCABgAAATQAAAigAQBAAA0AAAJoAAAQQNsFAAAggAYAAAE0AAAIoAEAQAANAAACaAAAEEDbBQAAIIAGAAABNAAACKABAEAADQAAAmgAABBA2wUAACCABgAAATQAAAigAQBAAA0AAAJoAAAQQNsFAAAggAYAAAE0AAAIoAEAQAANAAACaAAAEEDbBQAAIIAGAAABNAAACKABAEAADQAAAmgAABBA2wUAACCABgAAATQAAAigAQBAAA0AAAJoAAAQQNsFAAAggAYAAAE0AAAIoAEAQAANAAACaAAAEEDbBQAAIIAGAAABNAAACKABAEAADQAAAmgAABBA2wUAACCABgAAATQAAAigAQBAAA0AAAJoAAAQQNsFAAAggAYAAAE0AAAIoAEAQAANAAACaAAAEEDbBQAAIIAGAAABNAAACKABAEAADQAAAmgAABBA2wUAACCABgAAATQAAAigAQBAAA0AAAJoAAAQQNsFAAAggAYAAAE0AAAIoAEAQAANAAACaAAAEEDbBQAAIIAGAAABNAAACKABAEAADQAAAmgAABBA2wUAACCABgAAATQAAAigAQBAAA0AAAJoAAAQQNsFAAAwc3N+WcbneEE1nlqNRQ5JpzavxnOq8YMGX3OHanyuGutO6A3p1dVYWI1L07iyGr+rxjXVuLAay8Zoe/arxv9U44YM772iGq+qxilOU7q2cTWOrMYZ3b7tPtU4pv5ddgSKV/++fbAaH1vt33+8GnsX9DmXV+OF1fhFT07LT1fjtiXEztW4aV4hO+YB1birczKL3zb8evevxiGebvyNxdW4pRp/rkZ9z/qTNM6txq0Ff+6nZfwxcDNNNvUd8Buq8dB0J9eRO1TjYHt/bFyzhn9XX+cPKuxzbt+T/X2vajyqoM/zqVKCnAOci1ksqMbNDd+VHSB4/jvrV2OLatyxGkdV4/3VOLUaP6vGi6qxb6Gf+2EZ37v+IbrMV4fINL20YzWe2e3b3tGeHxtXVePsNfz7Ewr8rBtFP2b8S9q39VPZN5YS6GznfMzi4mg2pWD9dJfI9DaIQYrEu2IwG11fHI6oxjqFfL69U9AfGWegr/A1IZeVOURv6u4t97XXx8blMUjJW913Cvyse/Zgf9epTRsW9HmeUI3zSwig7+Nc7E0AXd/p3s1ujWFyux5ZjW9U4wsxmMXPnV51YDU2jbwz0Nf5apAzgL42Bo9hDo/OUjgYD+eP0Wfda8z39fxq3K+gz1OnYZ4UhTxqf5JzMZuLGg6gb2+XjmReCqTr9I4PZD4/D8q8mOlMXweigFSOehXW87o5Gfaxx8fGWdP8rpZkjzHf13ePQUZVKd4ahZSxm5d2DpFlguX89PvQ5BedaKSaR51++eNq3CNT3PDgyP/IDqKEMgZ3bz+faRN7eqz8aor/dm5hn/X2Yz6p9OmCPs9Xq/HFUgLo+s5oF+diFktiMNMZDZc9ozkHpxN2xwwpJTtn3vZvOfyUos4l+no19m/vLZ5jL4+VqZ6Q/bGwz7rNGO/nr0XeVMJYbdLxcbFK5azcAXQdPG/pXIxcpdV+JoAu3naphud9O3zP3CUlf+6wE4XNQte/4q9Pd5fRzqIkxselU/y3PxT4ebcaw328ewzW4pTi7auXnZ1bwEp/8viOVeRjFUR/KAaNb2ICykp+1CGnNMvSjM9jmn/pbaOsHE9ipN4J50WZTbHGzX0KCvzrh1DvicJaeVs0kc97Gn6929ql0fZK6nfGZMxAn+NwU2oQ/Z/VeF2zP54HdXhzzOj+e5r//qcCP/NdYvy6JL+zoM/znDU9dcgdQN/JuZjNDxp+vafapdFFZ8BXRDe515Gxuc8NDjVRcGm7Bze7SGCf1CiC8fDjmH628koB9Ei+GXn7EMRqKYXHx1pW+4cZ6InzwxZe8952ayeeVY3bRPu5Z7lcX40bHWai4NJ2G6fpse2bWwvEePh9DJqoTKW+x7ogymuDPS5uX1hFr5fFFOWyclK6J48vRPM5umpAR2cLb/dtOf95buZcswUOMyVbGoO+219sZppsD3s0xqmBytXT/Jmbo7xKHOO0iPDIKKcj7zenmnDM+UP5UOdiNv/X8OvVM6K72a0xrvnrq8pd//maaix0iCndLam38GEC6LCA8O/8JMrrLTAO6rVUL428TbxWujKlpq4ocac+17mYxU3VuCq0oI0xX1D4xBZed70YLGiKQhsUQJTW5eFN1fjn0V5mZ3syximFIzKlSY4aQI/DE//PV2OzQj7Lh2OadJ1cAfQ6agZnDaCvDuXrxt1rWnjNzdrtFTEjxzm0xBgtKlxUjVdWY33B8yQ4b0wrCc0tqCHJ2tRZUYcU9HneUuq0/kFjcDDNQM/cne3Wzm0dg9zzaDiXfbvQRAVititfHxI6EIYUjlgtHS0KWvu6WeH79nkF3Rc/McVKRQbQ9UKljZyL2QLohWagx1494XW76Fepo684rMSYzkS/bri7z2fYe2Pl2pjdgsMoaAZ6s8K7Dh5ZyGc5sRqfLTWxvL4T2sV5GKXWsBzGTnZrlgB6h4Zf826Zt+kTDivjar2UNDmL0nbzYzxbLPvtnJnfSOGYsY8VdC68tfSVmVYdR9bE+Gh4QRvdqxsvbBP9qsv+M4eVGOOqHHdMU1dbz+yvPDzKqDbAzLw32llw2FUAXWq3y/+qxqGFfJYfzeZGKdcMtIUT+fyy4dd7pl2aTdP5yveMvI9GlzikxBg3WKmD6B2rcZ+Z/fF72Wtj5Vuz/PN/MAM9rS2jnC7GdQv2x41DbUBNNyJbUfCmPcRuzWbnFi5muVwqgKYPbk6l7Z46fcWbA+ytsXFaNRbP8u/8rrD7uxLL2N0jmn+SOqx6PcIVMcsyll3bPKWL0b0PRfOpONtEWS3Kvx2D9IaY4dqfndJ5sHOaPJo/Rt/PrdOFcUUDr3VY5m25rBq3OkWJnqRz/Es1Ppf+OdbchXdPe2psnBqDioUxyxnNkgLoLQrcr2+cxe91m76bYocoPYB+dJS1ePrrE/LDXR/rs6L5nNmSAui6g9EvYvja5BumtIjDY1Bq8XGFF59vsgrHkyP/4zMBNNGXdI4VaXrt+2v+I3eyl2KciqwMM5u8JKVx7FFQl7+mJlya8ObIv3B9pZcPG1R17V8KOjHqcm4Pc30YWmkzKKPU3VyWvg/1+Ega9WrcZ1fjBT1f7LNB5gtZfUE/1+lE9GyV77ur8aoYzNKsZn97aGzUvw0XDhlAn1lQAL1TSttdVsBnqWfDX1LIfjm2GmfEGPRHr0tv3bWgE+Nzrg0xzlUbYrUc2pui+a5TL6rGCwu6a1/V8oZeZ9c0cm7Ht51ORM+mLevHWu8LtfPHXH19umTIwPvsgrZjl/SVLMGjM03gxhqefL5o2N/3rgPoBxR2YnzQtSFGLX4eBc0+L27ptf87Bt16S/N/DQX2u2YucVSnbvzK6UT0cOpyQTX2izU2E2N8AugLh/y79XVtaZSTwjG3gM+xUUFPdeuHRDfECHmxkxxAn+XaMJI7FPRZrlj7ep1oKl/rqYVVkFnYkycJn3IqET2eiX5feox1+l//9VdX+c+5bZjWQGxU0O/y9wtKm6tv8G8cYVtuLWS2ddtCAuhXRxlPYOp44V0x4sKy6HAR244FXdf+7NLeyAlZUgB9awdPLN5V0Db/saHX2TvzdhzjVKLD36ENU6WdeSloXJECi52mCNqur8Z18dc1ggvT37spXXeWTVd66rgYlBw4Nv4ypfmigvZJXXno6VFWs68P9+T7dkn6fpRyc3KbalwQees+v6KA/VBXVHlUExeT6HCh0g4FfbEvCEZxcGGf53sdvMeJhQXQ1zb0OnfMvB1nOJ2IZtfvbZmekO2aAsRdU9nHrVPloM1SEL3dKmsJ5s5wzcHcNAFTB9CXpyD6qnQ+XpL++VcpxerqaixZkqKoV6d0js+mPzBvlag8o+0LmSGNNNPbt+vBhQVVm9g3c+zz7EL2wxeqcfI4BdDrFVby7PfBKP65sM9zYgfvcVVarDi/gO1dmmbFmnCnzIs/o+EmTYdlasoyNwVMX27xPe6cbl6XZqrW8rVqXFzYub9Z6qK5ZwpU6u/zXjHovDanwbVAq/65HVaZvV1bsF33VPltNX5dje9WH+QnVT70RQ+q/s+9U9Rdv+DH0hdmQYuLOKZxZGGTAmdHv5xbUAB9YDVOiHyzz08qZD+8NRp6nNXlxfd2hX2pGd5jCrvoXtXB+yxOswmlBNBNTV5tHP15EvTUYWt6RnO58m0G0M/LfPP6zSijzHKdPlbHog+PQbnl+YWVmpybasgfmMZTU87lV6rcjxOq//jlTdNN3muq8ZQYlAM4Oc+CgOcUtN9+0cOOpOcX9FlyPm18RQFPO2tfiYYWrc/ruAPhulHOqloz0MPbv7Bufe/qcFH9tVHOwparetDY6IKelVb8UPS3dOTy1DEyl03S7Hv9GPh+UWZntamC/joP9gn1qHbkz6r//UA1/rcqH3TrFunx7KHpruB/qvGN6KwyQ0nNovq4oLikWGO/yJd7/W8FbP9vqvFPTd4ld+WxUdaMpQocw7t3QTdDkVIKo6PcylLy+JekdIEY85y081vI8cvp+pZf/+6Zb3aWZ3jfbdKP3mnV+Fa66Run4DnWsobk0zFI8bjXsvR466oU4RyTkjTvkZontOhJhe2Xr/bw9/LcnnavnY1Smqa8J0YoW5czgH5GQV+iK6txkTg4hp1J2aOwx6WXRnd5/DsXss2LU+WRGLGx0V0i74zmedF8t63IuFhoSQepcLlc1HEXszpn8qgYVH87NvJXi4mWaumfnCrdzZuTygNcmRK5P1+N4+OvCd3R79KyC3r6mznp6aJbpCcvUUDmwSej4Tyt6Cj/eaeCDugJQYyQ9rNblFVNZXmHF4JSSvf9NpqZBds882xtkwuGDiigZNWyHi/2+lOH59pT0uPWz8Sgg1qfbZRy2z+2csJ5ZSB9TbprqNM5PhGDqfeNm+0MXFIp0rN7enwXF/Z5uk7ZeUeUsf7tIdFwqduuAujSHhN9OBjWOoUtBr2owx/1+xS03W+OZio6bJC5e2STOdBHFxBgtlkd48mRv+542+faXim14ZMpP3eS1DcNZ8Yq60vmpJWHN6YZ6TemHfPI5iYENi9o+3/b42P7g4I+y1Ydvlf9xPZpUUbqxsnRwkrhLjyssC/zxUGfAuiuunk9vKDt/m4Dr7FH5m04vuHXu2fkTyVa1uKs0V6Rd9HqqdH+2opTorwJl+i4SsIHYi2dHxalQPq/YrAia6fRA+jNQmfgLvw0ykqp6Mq/FdKx9xNtxAlzJzDgOj+IEVM4toyyitQv7yj3u5TSfe9oaHv2jP48Cdo7c356PfP8wxZff++0kj0ydu5qcxbtDemmcNJmnWMtM9EvmOpALE05HyemxUW7xNA55pvLFe7E6TF5HYRv02TFixF8P1pKD5rb0eOCLQTQvXFoQZ9lSUddq+rz5OMFbfdXornHa5E5Z7gpu2S+sVuaZk+jxZnJnPW6/xDt5HLW2/TKavxHlFXZJzJ3UnzPVCULV6QFBPX/vrYa742hF9yuU8g2X93zJ8MXpUycmJAZ6Hqi7Z2Rv0TiJW1WgJvbUf3nTcNjor54fkGfpe709aPopmnM0YVs8zWpfXCMeQrH/0XzubOROcC8OdrNDc7pvdFOVZvPpIwE/t7bpvsDK9IFoV49e9DsX/8foqyJrT4H0H+McnoIdJEDff/IX3mjvmF5VJuLOOd1dLdT0mOiTdNqzLnRv1mLb6QnfG06vKBtvqSD0kfrdNioZSbqRhZ/bujGNmcznF83/Hp3yXxcPhDtL67L6ZMtvGYdOD9CnLxWD06x8ekzuSi8vRr3mt3rHxVldSBc2uNjeW261ymhDOrWMT5PSUfxmfS9inEOoLcrrGvdS6Kcot7RQp5um46IyaqmskNqyzy/oG2uF3LdFM20vI4erbi/Q+bt+UTLr79n5gWE0fCTz/emNF6m9pYYzBQvmm6HznImYbfCUma+OAHH8qKMnQCjwxSOo9rv/zMjb40Ocjvb9iDXwE78rIP3+IfCtvlDLd+lnxT5awuv7qUNvU7uxR2/avj1bh95c/EX9biFd9M3O3eLshprleywaOfpwwsK284fTcCxPH8CFhHOLaSKzvtjUGBg7APoSS5JFD16hBwFzPLFal2FlrR4ATijgLSANQXPTaSsbBh5W14vb+HHZOuM2/P7ll9/w8wpZ79v+KnON6OMGapx8Ziel5adlK7Af4j+LyJ8ZEo9iswVT14eHa2UbPtAbRl04RvRfveibXtcdL/OdT4kBqkNTykwR75eoPbZnvx4XtDwgqEH9XxmKffTgt9EczNf367GNpH3acG1qVv2tWlB7pxUDWSnlK61aWHpDXUVgdfM5AIWM1+8uUnoQBgZSq6WYJvUQGtRT1Nx3pFqP499AP1ccW0nbmohTzHWUG5qm4K2+dcN5IvXs2B3r8aBKQg7rKN1ATFkis510Y+OinUAfVWDr/fMngfQua+jTc1AvzgG5fgiU/WlY2PQ6e/MdL1ctlpzhXXSk4w7pFm0RxWweDNSDfBpbV+Nh8agNnRMX75ufZWxOndeIZ9j+9Q+vukAuoSOg9e30KArWwB9VBAdPQJb2kEAvXVB23xlatCzzjRVntZPF4x65mn3GDwVqWeZ7pp+HHcpqBbqVD7eUDmeeQUsijwp+tVivc0UjrpT3K6Rt4vXeWP8A1t3h3x2DJq03DLNn12WritXpqY4b47BgvMXFNDL4Oh0A7BWV8wseI7U4GK9wipwTII/F/I5tk5pYU17dgHbdr/ouKtctNiFRlepbvypxRbCsUoKR0mP/Z4eM8uvXyfdbS8f47zLT0Vz6RsbFNAZ9FMNvtbBmRuM3NLyzNKh6ZhFxvbkv2mou972HX/2c6rxjyMcnxti0KfkM2mNyf0jb3fCY6f7wa1zs742/WvNLyyAnpQUjpXXiw0jfwrHhi0Ez/tn3q46zemXfQmg7xmD2ROikyLtbc9A37uwbd44c+DUlcUNN3FZL5WWzOW6hhsKHJj5B+nylrth7p85rehXDTz5qHOKPxLdpwkd0FBq2+9i0EDq1xnXRuyWJqQuiylqDV49s9fataCnbn9ONyqT4ssFNBiJhntzrJtuMOdE3qYpx6+WkhXjXIVjn4LzSfsYQLf9xflnuzly1Rdu8tiunyohRA8actQX7F0if/7z4hab+OyeefuObyj3ueunP89teF3IuTHNDHC0X3rstlPlqi2YWS3TOZln0lf3w4bq2o+LbxbyOZosi/mMzMHzyuvU77p+0zYD3F2C6KjJwQ87eJ8729VZboz+M5pPxZmXuT5nkwH0HtG/Dn2rBtA7Zt6+L8ToTzwO7vgzX5Fynpv2nGg+j3txWvi0KMXAV6VZ5pXj8rQ9N0xVBm3DlK8yw3Mm95qBVeP+0ztIPyzJGYV8jrs1mEr3sgL2aZaF5PNa7Ii3m/gnuirL1HYTlYfbzVkc2HC1isjcwGJFChaavM7kbo37uZYD6J0ybtuChlKG9u74c3++paDs1jSD+KAZ1Dm/MQXFt6TMiotS+tJlKa/8qpTKtDAFyAuHrbIzJ/2Qv3hmf/yAgvKf6/30nQm7ptftvC+J/OtQ7tTQ6/xr5mvUkpwdTducidoj6MLJHbzHM+3mzr29heA5Gs6nnq2LG25+MzdzikPbtUbXyfwk78xoJj9y5wwLH9tKaTsl3dhek2aEb0gB8BUpMFqQvhcXrzKjvKztLjsnzPyPv7Cga9yymU+c98b16WYqdwDdRFO0ei3N6zKnb5yfcxFqmwH07YIufLjl198oY+3WSVU3m/h/LbzuVpkXEF7WcAA9J30/c2n7x3+HzD9OTVTfOCjD567LIb+tpcD1fWks7aD2fszkkc4VqXPEDD0+VN/IaVEKoA/J/Dlu19DEWu6F/C9qqSFM5FxEuHvQlW+1/Pp3zBx0TZqvVuMB0c7K9CMjf7nFJhfcPb5n3TCjsDr6p8d41n4+JDVBiZY6gt5cSvBcl1L4rxgkSsfMFhCvo/5zdj/uyXbkzn3+92hnrUP2APpF4qBOXBvdPOrZxK7uLCB7Souv/4jIX7KqyVnB52XenjNbfv3c23duA6/x4Eyf/dhqPDXyN0CJNh8fXzC7sg57F7YJk5a+sdKPCvkco5QZfkUMylPmckmqUBV9DKCPFgvFuHdAazJXiphRa/LHt1gTdZvMT4bqCbNTG37Ng6K/M9DbpmMWGet1XxbN1GuPTHXiP55mOV8dg9JtvepLsEVqkRjjWxnr9IiJvdZHIS29h40J/i3zZz8xrUPIfhPbtJ0mpMFFCbqoe3h7u7l1/xeDUl8LW27EsEvmFfffaPD1jsh8zBakWZC2PDLy56deEaPncOe2eyoFWX//LozB4r6fxiC/+6J0DK+cQZvvYqxIZTROTCuoZmGvwjbllzG5rkkttXPaKoav5LRl5F2M/ryum6Z0FUA/IogxWuQjgM7rZ2kiaWEHgcQ6mdMBFkR/AszfpGoPbcnd7OLnMXqe79KCzrOVFVvqcd9VbuquSQH0xemG4bep7Nyv0jl5VZqNL6ZW8TrpA7969h9q14KOxx8mrP7zmq6H98r8GXYYoR56LvX6g0PTuRt9DKD3ExNFn2ag97Gbo80uXId29F65G+F8uuFgaIcCive3tfp73REer0ZBHdNK70Q7N6XKbLuW69ySNEP95xRE/yalzZ2VYtgrGq4qEzPN7XnT7J9fb5pqQEdBi6VDAB3jNgP96oyVj1akhYMXRkHrEKJHbYInyXUdBNDra8femvqx8rs6fL/cC4g+Fs2Wvr1t5M/Ba8smBVS++X40s2h0nK2bZm13XaU83ko3poD6jzGoqnBuyre+sc1Hy/XF+MvVOC6GWjB21yingUpfKlHECDPwuW07yz9/cFo8GBnT5k6IwhbyNmk9Jc+iy3a1badwPM5ubuUi8OK0wCkmJIBe1vCCjw0KuM6c3HLt9Zz5kRc23Kp6/R6ex5umRawHrVIe8sJUWaLO7f16+ufFTU6/1Ynabxnur9892isaEENM/kxiDehouMLNqG4Ts58E2Shzz4s/l/YIKxqesZwvRurEKR28x0vs5kZ9OeWUfzzDe+/Vo2oxuWegL4v2K0hsG/2oz/uFCTq/d01rgF5bjZ+kIPH11Tg8Gpqd+naa8o7xyluNNZRfvSCULM1tNlV+7hd5J2G+XUDd6dYD6A0yX/gnyUc7+BG3gDAaa3bz5Gr8Y4xe2SDGsCvoBS18NzeI/pbf2j7zbGGTs4PvnNBzft100/qaGDytOCkGdanXG/YF67IHrxz+8zwwJmvtTozBE+TcbjvLlMPIuHDwFVFoLfboac3gRWmn9/FkXb+DJg736+mj1+i4TFN9o/P+zJ/jGZnf//xovlVz9LgF8ZMyb99p0WyJxlvSU4NJVd8MPSiNf4pB5+2vzfZFbknlQ74fQ5WMjsIqD026pake9J0ibynxmcYCB0beJ5jnTEIA/eSCtu3qlDNzi3N1KAdYQDj0hfH09N07vsVKDbPxhOhXw5/n9LwF8VE96rC4KO2ve7s0/MWhaRwTg7UQN830L9Z/8JnDBdD7TGD51dItSRMsOQPoTVJO880xdbrcuzLHAkflqHaTI4XjKQVt23mC5xil1OgudsOs/CkGuc31o9J7VuOThQTPO0fetKpF6VxsypzI21ExUhmztuyYfthyuXI2QV3MbBHhd6OApgeFeWZaSPZPMYuVuPOHi2TuXNi2n+Xw/+Vw5p5V3WQGpeweUo27ZPyMT48y8sVbD6BLu8s91jk6UgC9s90wbWBYLyb7SAxym++eTvbvFfY590sVAyLjgqHTezSbXjfWuL7lwCoyL9a5teHXfHOYdVzbzdKxM514mpNKszxv9u9zp8K2+yKHPlam+C0pPIB+TeTt0Pvlkg/gvB49dlzdZ5yfIwXQqqnE36RlLEqL4er811PTqE/wGwr/7Ptkfvx2fsNdFnOXVvxJy08WHhJ5u0T/pIUuX4tTZ8UrXEpibQvC60fpH4oZTFu+vBrvibGqAR9mn2NtaymWpAWnOWw8TUvuwyLv04vj0gTMRATQJeW43ezcHPl7sesEbOfi1XLSVt71XpdGnXpweQweIV2YxvIx28Y9M7//MdHsE7NdMgeYP2px1mi9zOkbi9L2RUupIfXTmme5vMaaKnZ8MAaP9H86ky/hujP/Em5T2NPE4xzuvzknlmaeKFtbKbut03cyMlY6+o9xCJSaupMpaaXvOc7NGOcyWqv/XnwolYFq+uLxx/T6c9LF7OaUN780TfZET2rT5nR8g6+1ZeZ0lPp78YMWX3+7zNu3OFUGaMurqrFTDKpREGtMdXngdGt3bk5lWj4x8xvokiZDvuswx+ppCnfL+P5rK1V7ZObeAQ+IMZlpbMLmaYQuP71wZJSVa1x3QDrDYRnKLhnf+9aGZ+y3Si2JI+PK+TNabq27SeZGP9FyZaQHV+OL1Xi0UzPW9BT3TdV40XQzChvMbg3E3ILaV1/iMMfqxQ7uVuATyn/J+Jm+kDr2xjjUp2zCZpl/2Fb3K+flSJ5WWAAteI6RFipFxkUyTdo683XmpGi/sUHOGeiu6pXXlSdOcGqu0Qtjmjb1S2eXl/WEKGu29UqHOEqa7FvTLPP9M/b0qH/v3x1jkio5t8Efti0E0L2wZWGP/L7jkAztzj1rV7tN5uY+7472awRHj+tbr7QwVa551mrrEIjpK7Esnd2z9UMK2q7fO7RRWlfGPVb7/ztlLsDwkhgsZI5JCqD3KGib6qn/i52XQ3tUYZ/n/Q7J0J7fswA6d4D54x4fr6ui+3SYj6YuZ18KdaJjNrPGi2deAz6sTRJAx9TrLmK1rrXbR75eCh+MMWsx2oTHF/aFVGdyeEcU9nl+4JDEuJaWPKtHLa6va/n1Hxt5212fk2mBcF114jExaM/+0XHJfWzZTlM9PVo6xcqv1dy1sO0636H9O7+OMp46RwHrn949bgevqQD6Hwq7y13kvIxhyyndpqDPc71DEqPktm2c8f1vjGYXDG2S+bvZ9kzRuyJ/fmpO34hB6sK+MWgs8rUUTE/izPSmU5WFnTPzEnZ31cI7xqGld+5837uuMhGaq+zpGQ2XPB2bAPqwwrbp887JkS7c24Vc9j74hwICsj81+HrPiPyt2tvy6AJuXEtpl/vHany6Gg9PP+aHV+Nt1fhWuimbFIfHFNP2M1wIcPfCtukal+XIkRo2nX1TFbVjI1950EdGsw23YlzK2D2psG36pvNxaBtO05koJiw/LCwgjFG6bDXZ0OipmbfnsmivmcFjMneLLPVcqys2fC+NOSm1Yd+0MK7+fu9f2A1/k+4+VbedU2PsOhD+0CU5ploof5+M73+3dB7lSiF7RIxpecN5PcuZvcq5GKM+Jt82PPIbd/UE1e2iXx3H5vd09mzdArq4Xl7QDHRMMfF6URpfTQH1VqlVfR1UHxCDko17Z1wEFV2Un6wD6J/P7HzZqqDt+W+X5SknG3K6Z7S/xiOmyAH/SYxxy+YYsf7zBgVtz+nOxRi1TNi8UPaoDwH0Dpk/w8nR7KKqdTNvzy3RXsetHTNv2xUxfguvV6Sbmh/GX2c356aZtJWL8A6MQUnOPdP5sN4EXQMOKaw3w/ddlmOqtKUbIl8zuj0yrjX4eNr2iQygS7sone1cjHGu2hCrPU04zyEZynqZc2qva6G1/Lo9PE63K6ShSF/S3pan2fR6nBaDDqaRnqptl2aq75bSI26bguu+BtX3iEF6UAl+0XA6V/Swnfe1mbs5z8nwnicUsHg6awC9febGBtFy57NJ88SCPstlqfUrMdQM9NYZ3//UaL6sV+4Aep0WUjc+Wkib5Q/3/Hy4Ko06Jeyz6d/VFWp2r8ZBMZipPjSlPWwY4//DPSd1kyvFzwXQU7opBdC7TtA2L85cMq+IAPp2hQXQZzoXY5T0jc1D/ec+2Df6VT1lfgGpRfNbKB1VSpBz4YQGLeek8ZEUOO+dqgE8PpVanpPx8108Vemsq6e/Ods3ynkq8OMgZpCuuP8Ebe9xM6/GGL0tY7dH5ovMqq5WJmckzy7s83zUIYlxLfn2y4YfLd4xyqir3ZS7VOPbhaw3uMDpEitz3OsJmNfEIMXjXaXehNYrvY+PsamMVQfQ3/X1st5ntZvXt/WhxvvcEX/YHhhlzVje6DyMPjTDidD2Nca4HftZDadO3KeAfXrfhl7n9il43tx5Vqy6Hu1LY9DQJdeP/GkxRYeiafxLlFX7WXWsKL6RUXS81uq3fdiQUWdADi5sWx5fSE5hdJST+YuGSr3NSykcpbgkiBG7SkXGfNMrotkZ6L0L6dJZrxh/+ghB1dHVeH2UVbu45LS3Oek6t3HK6a+vUVvEoNPsDzvqjvi7hp8+zMStsZZ1BPXOeEPMqK5vKc5wSQ5PguJvCj18tS8bM2+MZ7lW97AYFOSeJE9vKIC+TXoyGBaDjr2nFPCDeVODr7dLQfv26JQL/aRZzqrVa0WeX42XFFYmMlId1hyB8VapBOp2aZ9umq5BO6aqGdunShmbpT+zbspTXi+lBBwe3cyeXpYhgL52bWkPc6fv+7xDYd8v65IE0Kt6d582ZpSL+fOjf23JY8zyiJr68atndzYqaNvOCoaVeyHKqWkGLQpLnWgq8KvrNp9UjX+PQd35G6e4tt41/fmXFtbUYlXnNfhEbJO0ndumf94yBcC7pIB4+xQ03yEGi8/npT8328oX+0S3bYZzBJ1L15Zbcu7Uf3e30CRkHF06AdtYp659ptQP94j0aKurAPq+vvORu/nBOQ1W4Ng4zECPu40zt2Jfni6S0XCb2RJvUr6dFv78KP3vgrT9O6QnOvdM+c7rR9kLl64asnPZw1apN75b+u5tnGaSN2+5vvKmaSHmOR2Ug9wiw3H5Uqylc8yC6XMiSgugTw1m6sb03Y4er7NaWtqHqi/Yj6vGq9INahcB9Fa+61HCLO2ihl7rtoU9WtZAZThbZF6cVv/G/7Th17xTlLsAe6/o/vF+NJy+cXUMl773r5lvFOs0mpe1vMhv20wNiT4fa5ni/3PMqDJWWM8y9QXl12WejydW4wk9/W16T4nBc51T95gY5IhdOkRJubkjlK8jr882+FoPjbLaml7t8A5l88wz0G2sJN/IYY3Saq2fV8hvX9s3i4fFIH+9S59JE81/p859OTmmTaPZp6Dv1/dK+8LvlAoQP6ugNo2r+HpPrzPXR2G5z/tV4x0xyEPePOXDzukwb3h3vz3ZfanB13p0lJUzp+xRDD0DvWnPHtfu77C25gtD/r2bC6lC9LwWX/+x1Ti2422qJ5hfGWt5tFNPh39r6r+/WWEpTydGQSWr/rMab6/Gsmq8PAbtNwtrO9nX1MUXx2BiLEpYxPLCarwlBo/Rrh9xWnzYAHpPvz1ZLehZm+RV/bQPBdZj/Jt9hMWfvbZihIVLC6YvBtGJV0Q7TXaOqMYnMixMrydH/7Sm/1AnlH8wpn00t3VB7aBvSWVWs6tn7j9UjSdW486rfIHvG4Ncmc3KOSev7WEJ17Mz3Iiu8TuwaTqpXxCDvOcFDXQBnDtCMwCiF80P/rWwbTvB4R3aU0PJKqL1+rw3VGNxlLFo9kcp4I2GUqA+EIM6tTnKer5jqgBgBtO5jyno+3VdZJ5RXRkc1bPNh6aAafFqFU3qu403x2CGem4ZAfSFPbvO/HsJF4m6dvo3q3GvGKzUXJKx9Nvmha6MnyS/afC1/jG0NO2Le/ewaYKgvLxjdXWaYSzBVim2fOkIs9E7ptSJn1TjOSlejQyz6ZfFWh4V1Dv8oulf47lRVpWorJ2B61qJX4tB8fa15RzVgdSDYlCX8rD8+2zx2p5AjKnvRuY27vulckkPSk8aFkb+OtCbt/TYjOj8Ufl6hZWv07VqeIdmfv/Lo9kGKrHKrAxlXUNWHutSqjGtl9Jb35DW2Z2UWgVfmmbLV6xWmm67VHnojum39YDM23LcVLPP9VT462aWb7NTQd+v03O++b3SqrVNUp5rTPM4pe7k8870Bfpi3v32px4tHDw6pZx37uDUlvqBMcg1W9ZSztkwAfT9/fZEX1I4do6yFiOr/zy8Z2d+/7bas54W3XSdmzQ/H/HH8bo0c1uSjdLaoEel38xr13BTt26aBCqp8+rr1vZUeW7aiBl0nyit/vOvIlNtyYdX440pYFo0w1SPJemH8N2p/eT38+23C3tU8/mSXD+EL0rfhUUtL9YYJoXj3/z2ZHXNzMqBCqAnzCGRf/FnG77j0EaJrYP/s/DtWycVrthltTG/sOD5KdX4XUwx+/yJGMt66VkC6E9V470pIJ5tdYVlKVXmTTF4JLCDGehh1alQP+v6xqk+Xh+JQS7W4pS2s7zA9td7++2J3E1GLo/mKnCUFED/zOGNYRc/rZ+5KswZLV6Mo6dVMH4X+RZ7LmqgBN4fnHoxSpWKugHap6f6QzfNvNbgHQrbvl90fbf0H9W4RwxmmFaMsPBwk1Q7838jSxedi8f8e708ZcN0ps7F2jcGuc73HfH4tx1AP8x1L7szG1wBv3MZi4/DAsLRryHrZ15x31Zzr2W5F6JEe/n+P4p8DVSaWIj+Tw0uaJ80r58uNq4vzO+PGXeV2rew7bupqze6R2pz95QRGmKsfsG5ORXVf18MagOGFI6Z+nSXlbTqahbHpzddno7bnOh25ns2nui6F+Pa/CDWkAt4cJRVWeQWh3csA+hvtvjay6ZvwDZ2/qca9+n+t/kvlqZZ/RUNpe180ek3K0vSgse3xDQzoYtS/eIYv+ZmnX0n6raLb4vBStCFLZwod6nGN2KwKHFOd08mxtWitHCw9Qng7VI3yf9Nq3+XZlqtOHeWTzd2cf3L7ocNvU4dcN0vymr7utThHcr8TKW3VvpYtJ8bv7gnx+rEtM5lbqY0y8UNz3x/wOkXs10wOO06oq1n131ij8IC6M+0/Qbbptm8L6V9tbClAPfmtDL12LQwLawFmsqbuniTp8eg5uMr0hOHnEHD3Fm2Cd7C9S+rSxt8rbtGOfnPyxqcFZtEu/e8ZNWZPXk68aoYNLu4Kf0ubxt50m2ubPD16mD8y07BaS1MVdLeGDPI5z195osHVwbQ2xSynTemEoLRZr5znf/y/2Iw5bmkgx+n+uC9JAb93Ttw0hh+vy9La/has2m60NQX0YfEoPxgbnNnuVBpc9fBrE5t8LWeF2UtqLGAcHgPy3zhjA4qz4xzkLYszTrWj+1vjb82yNq2J5VujhzTH/2u3JxunF4bM1hZujQFh7O4Y3xAQdv6uzYrSayfUioetIYC3227OgXuR0frj45OHMPv+NOiueIGf3fD9PbUdn2ftGL95kI2ejYB9JZhBjp61ML7yIK268aedWCKjheg7x/96IoZ07QpH8euhJenpz1vXy1Nb6NM5dTOaCkt5AUF/a5FQZVWTkrr3L4VM2zM8MvZH6QnFbTNF7X1tOjRMVgMsWOm2cc56ebmtWnRYosn76/H7Hv+42hpHcze6YUfkI77wsI2fLYpHBsEfQig7xLlVRZhOI+N/GUVu/KsMQvSTkjrDH69lmZZ0aP6vH9Ia62Uthu4Kga1sh8104CojrZvF0N1RNq2oO1uJfg7PFUk2S49wpkT+e6I6uD9zimw27Kdt7k1Vx3tIb286Re8S6rx+MV0TqxIqTpzYnwD6Hu7JkZfUjgeG+WV9CLGsgPhLzr+nnxyDI7J1emRfT1pdu5a/sxzIt9sUbTYHvyBPeqmFkNWIjgxtQd/7WwWv24WgyTpxbNfyxKFVVOKJgvc3z9V2rh8lfynEg7y9tU4Jga1ouc2X6nlnDH5vh/XZPrlOulxzeeq8V8xmPFfXPDiqNkc9yeLVSL3o+CFPZ2B/o3DO/T5e2hMVu3u50bHhfpjdhNUn0ozsV+a5s/mSru5ItrvcHjfmNUauF5YnoKJw2KwJuGimGV6QH0n+vEYqhRuFNboqzHvTKX85hU4+1j/GN8xBqto3x6Nr5k4bwy+81ekCZxGGv6tl473celGqYtOgqOaN4tt20O8ktUPGsot2yhPg6XI0Qa67w7P/P6XRJ7c9Vem8p9HF3QsTotBF+HjZnDdf3CMdwnMmEE74n9OMeFz09qf6PECwU+lkrSnDHvXtV16JHHDcNWUStJIBY6tU8rGXVosURcN5ETfmmZIH5nuHN8XjU6YLS+s0dnq3pPW9MWoDSleVo0D0w3JDYUe7xhhBvrIIDLPbJ3WUK3wjVLwUZJLHOKhHJr5WvPHTMducVr1fVQ1rs98DC5IlZUOTvVvl8+wg1+McROmmOFj6A+k38WXpfSOPrkw3cjdM8W+p8QIaQrHDD+LsF+U1SRm5LK8B6dKG/tlzneeTSBdR5HPj0EKTjRX3WhZwZu9uImJ99tX46vVeGYMarEuHKPgOWYxA/3vYpXsF6am2hlvktK3wuxzjHv1jTtk/gynRN6byuNSjvFLUzA9t8P3PjUFpO8bIjXzTpEnbfPMTDO0b43BbFWdF35EDNbT7D6G59y56Xr1+fTk/uampmzfMXzsVtJ+PGbUF3hRqq+6aAxLutyS2onvnUrSjFjf88qCMxjq690/jlKCe5u0uvr16WJ63Zj+CM+bxcKTs8bs5qBP6i/s2Q2+1omF5OXPm0GuKLHWp0c5G4xsMLtmadFmXeMnp5zbJ6eqB5u3dBN7RroOfjGlVC0dcr+dnKHb2LWRtzxWPWP12TQ2TxONB6eW5nvFoB19afHQn9Jxqs+zn6djvyBamEEY8kVvmyZrS6iOtfEojTTqlI0nxGCm7qox7qh1farQUdcufPFoMwwXpQmCdaO8Cfc6P/srw77Aw2OwSOJR6Xs/zt3T5vwyAKJPXRkPisGj9XunKkhbDrGI55pUku2C9PTnrDRZuMQubvwmui7DtlsMnqjsW41d0nHbKv23DVp6urAsdYW8PE2CXZSqH5yXbjau6CJF6EfpUcYPJvDgb5/KlT1ulfrOc3ryeHCDtDr+AzMtAt5z90n1nJ+c7k4X9WCbBNBAX61cMDs/BWTzU7WweSlIW5CetM5N1/T6ny+NQRm6OoC+2C7MZt1047NVSjurF7Lvmv7bHVLwu2mKwZZP86TmshQoRzq+t6aOeYvTd+Dq9L/Lc21ovSEfrsbXo/tHE7kC57qW6uPTSbm4p3emG6RxSsr7+cYEnsj1jPwTY9Dt5+Z0IvYllUEADQCRN6F/5V3Cx1Nu1EU93M7t0qP7o1L+yZIJeaSzcbpRqvMVvxeDnNjro98LdG6b0lgem+5YF/YwB1gADQAFBdJzUzOJt6ep8WVjvl3bpNzXp6WcnEbKdYzhsd00/W+dVP8/MUjbWRD9yse6RwqaHxp/rZfd18VzAmgAKDDYWpxWfL8mBdFLxzBwflYMOstslWYilzm2f0nrWCetSj4+BquKb4nxnnE+IJXmeHD6/wsnoOqEABoACrVZqkzx3RRsnT0Gn/k2KWh+Zsp3viEFjisczr+xfgo2T4/BQtIvj+E+OjCl5NwvLTpZOEHHTwANAFH2DN/GqVTIyakW4K8L/JzzU0D16hjkO19vxnlGdeHWS+kPP01B9Alj8Ln3j0FHqHulm7xxaL0tgAaAmMzi7xunot7fiUEJvDkFtaZ7a0rbuH4Cg6kmAun102LDU1KHvhJvPpam/OZDYpBmdMsEH2sBNADEeM1Ir19Yl406ALzRjHMjNozBrHQU3NlNPvvMOxECAAVYNoatrolZtcG8xW6IcXgiBAAACKABAEAADQAAAmgAABBAAwBATE4Vjrq83xHV2MQuAgCgx75fjT+NGkDXwfN51djB/gQAoOcurMYB1bgmRkjheKLgGQCACbFrNTaPEXOgD7QfAQCYEJdU44ZRA+g97UcAACbEWaMG0POrsbP9CADAhPhpNZaOEkDvVI0d7UcAACbE12PEOtBH2IcAAEyIZdX41agB9FH2IwAAE+L8aKAT4V72IwAAE+JnowbQgmcAACbJ2aMG0PvZhwAATJDTRg2g97EPAQCYIOeMGkAfYB8CADAh/lCNxaMG0HvbjwAATIiTqrFilAD6DtXYzH4EAGACLK/Gz0cNoPcTQAMAMCEWxJAl7FYNoO9VjTn2JQAAE+DGalwwagD9cPsRAIAJ8ZtR/nIdQG9fjfn2IwAAE+LMUQPo3exDAAAmyAmj/OV5MUigPrYaR1RjHfsTAICeur4ax1TjF6O8yJwVK1bYlQAAELNvpAIAAAigAQBAAA0AAAJoAAAQQAMAgAAaAAAE0AAAgAAaAAAE0AAAIIAGAAABNAAACKABAEAADQAACKABAEAADQAAAmgAABBAAwCAABoAAATQAACAABoAAATQAAAggAYAAAE0AAAIoAEAQAANAAAIoAEAQAANAAACaAAAEEADAIAAGgAABNAAAIAAGgAABNAAACCABgAAATQAAAigAQBAAA0AAAigAQBAAA0AAAJoAAAQQAMAgAAaAAAE0AAAgAAaAAAE0AAAIIAGAAABNAAACKABAEAADQAACKABAEAADQAAAmgAABBAAwCAABoAAATQAACAABoAAATQAAAggAYAAAE0AAAIoAEAQAANAAAIoAEAQAANAAACaAAAEEADAIAAGgAABNAAAIAAGgAABNAAACCABgAAATQAAAigAQBAAA0AAAigAQBAAA0AAAJoAAAQQAMAgAAaAAAE0AAAgAAaAAAE0AAAIIAGAAABNAAACKABAEAADQAACKABAEAADQAAAmgAABBAAwCAABoAAATQAACAABoAAATQAAAggAYAAAE0AAAIoAEAQAANAAAIoAEAQAANAAACaAAAEEADAIAAGgAABNAAAIAAGgAABNAAACCABgAAATQAAAigAQBAAA0AAAigAQBAAA0AAAJoAAAQQAMAgAAaAAAE0AAAgAAaAAAE0AAAIIAGAAABNAAACKABAEAADQAACKABAEAADQAAAmgAABBAAwCAABoAAATQAACAABoAAATQAAAggAYAAAE0AAAIoAEAQAANAAAIoAEAQAANAAACaAAAEEADAIAAGgAABNAAAIAAGgAABNAAACCABgAAATQAAAigAQBAAA0AAAigAQBAAA0AAAJoAAAQQAMAgAAaAAAE0AAAgAAaAAAE0AAAIIAGAAABNAAACKABAEAADQAACKABAEAADQAAAmgAABBAAwCAABoAAATQAACAABoAAATQAAAggAYAAAE0AAAIoAEAQAANAAAIoAEAQAANAAACaAAAEEADAIAAGgAABNAAAIAAGgAABNAAACCABgAAATQAAAigAQBAAA0AAAigAQBAAA0AAAJoAAAQQAMAgAAaAAAE0AAAgAAaAAAE0AAAIIAGAAABNAAACKABAEAADQAATOP/CzAAbRNxenYyiPAAAAAASUVORK5CYII=',\n                },\n              },\n            ],\n          },\n        ],\n      });\n    });\n  }\n\n  const scheduledEntries = await db.entries\n    .findAsync<IEntry>({channel: {$exists: true}, linear: linear ? true : {$exists: false}})\n    .sort({start: 1});\n\n  for (const entry of scheduledEntries) {\n    const channelNum = await calculateChannelFromName(`${entry.channel}`);\n\n    const entryName = formatEntryName(entry, useMultiple);\n\n    const end = xmltvPadded || !entry.originalEnd ? entry.end : entry.originalEnd;\n\n    wrap.tv.push({\n      programme: [\n        {\n          _attr: {\n            channel: `${channelNum}.eplustv`,\n            start: moment(entry.start).format('YYYYMMDDHHmmss ZZ'),\n            stop: moment(end).format('YYYYMMDDHHmmss ZZ'),\n          },\n        },\n        {\n          title: [\n            {\n              _attr: {\n                lang: 'en',\n              },\n            },\n            entryName,\n          ],\n        },\n        {\n          video: {\n            quality: 'HDTV',\n          },\n        },\n        {\n          desc: [\n            {\n              _attr: {\n                lang: 'en',\n              },\n            },\n            !entry.description ? entryName : entry.description,\n          ],\n        },\n        {\n          icon: [\n            {\n              _attr: {\n                src: entry.image,\n              },\n            },\n          ],\n        },\n        {\n          live: [{}, ''],\n        },\n        ...(!entry.replay\n          ? [\n              {\n                new: [{}, ''],\n              },\n            ]\n          : []),\n        ...formatCategories(entry.categories),\n      ],\n    });\n  }\n\n  return xml(wrap);\n};\n"
  },
  {
    "path": "services/gotham-channels.ts",
    "content": "const BASE_PERMISSIONS = ['urn:package:superuser', 'urn:package:dtc:bundle:monthly', 'urn:package:dtc:bundle:annual'];\n\nexport const YES_PERMISSIONS = [\n  ...BASE_PERMISSIONS,\n  'urn:package:dtc:yes:monthly',\n  'urn:package:dtc:yes:annual',\n  'urn:package:tve:yes:yesn',\n  'urn:package:yes:superuser',\n];\n\nexport const MSG_PERMISSIONS = [\n  ...BASE_PERMISSIONS,\n  'urn:package:dtc:msg:monthly',\n  'urn:package:dtc:msg:annual',\n  'urn:package:tve:msg:msggo',\n  'urn:package:msg:superuser',\n];\n\nexport interface IMSGChannel {\n  channelId: string;\n  checkChannelEnabled?: () => Promise<boolean> | boolean;\n  id: string;\n  logo: string;\n  name: string;\n  stationId: string;\n  tvgName: string;\n  provider: string;\n}\n\nexport interface IMSGChannelGroup {\n  [channelNumber: number]: IMSGChannel;\n}\n\ninterface IMSGChannelMap {\n  [key: string]: IMSGChannelGroup;\n}\n\nconst YES = {\n  64: {\n    channelId: 'BD50D13C-CC01-4518-AD42-B3EFACF1DBF5',\n    id: 'YES',\n    logo: 'https://tmsimg.fancybits.co/assets/s30017_ll_h15_aa.png?w=360&h=270',\n    name: 'Yes Network',\n    stationId: '30017',\n    tvgName: 'YES',\n    provider: 'gotham',\n  },\n} as const;\n\nexport const MSG_LINEAR: IMSGChannelMap = {\n  'zone-1': {\n    60: {\n      channelId: '057E6429-044F-49E6-9E97-64D617B4D3CD',\n      id: 'MSG',\n      logo: 'https://tmsimg.fancybits.co/assets/s10979_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG',\n      stationId: '10979',\n      tvgName: 'MSG',\n      provider: 'gotham',\n    },\n    61: {\n      channelId: '6D250945-BB55-44D4-A5A9-3DF45DBE134E',\n      id: 'MSGSN',\n      logo: 'https://tmsimg.fancybits.co/assets/s11105_ll_h15_ac.png?w=360&h=270',\n      name: 'MSG Sportsnet HD',\n      stationId: '15273',\n      tvgName: 'MSGSNNP',\n      provider: 'gotham',\n    },\n    62: {\n      channelId: 'F1DA3786-A8A2-4C3D-B18E-F400F9C6EE0B',\n      id: 'MSG2',\n      logo: 'https://tmsimg.fancybits.co/assets/s70283_ll_h15_aa.png?w=360&h=270',\n      name: 'MSG2 HD',\n      stationId: '70283',\n      tvgName: 'MSG2HD',\n      provider: 'gotham',\n    },\n    63: {\n      channelId: '0135EBDF-184F-41FA-B36C-46CDA4FC9B33',\n      id: 'MSGSN2',\n      logo: 'https://tmsimg.fancybits.co/assets/s70285_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG Sportsnet 2 HD',\n      stationId: '70285',\n      tvgName: 'MSG2SNH',\n      provider: 'gotham',\n    },\n    ...YES,\n  },\n  'zone-2': {\n    60: {\n      channelId: '02A9C85D-8E30-4D13-9EE1-FB137CF6C66C',\n      id: 'MSG',\n      logo: 'https://tmsimg.fancybits.co/assets/s10979_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG Zone 2',\n      stationId: '42110',\n      tvgName: 'MSGZN2',\n      provider: 'gotham',\n    },\n    61: {\n      channelId: '89648E11-56FB-4A68-9B0B-D3ECC48FA75E',\n      id: 'MSGSN',\n      logo: 'https://tmsimg.fancybits.co/assets/s12605_ll_h15_aa.png?w=360&h=270',\n      name: 'MSG Sportsnet Zone 2',\n      stationId: '12605',\n      tvgName: 'MSGSNZ2',\n      provider: 'gotham',\n    },\n    62: {\n      channelId: '4A064EDA-8704-441A-B462-7F4DE770FE96',\n      id: 'MSG2',\n      logo: 'https://tmsimg.fancybits.co/assets/s70283_ll_h15_aa.png?w=360&h=270',\n      name: 'MSG2 Zone 2',\n      stationId: '70283',\n      tvgName: 'MSG2HD',\n      provider: 'gotham',\n    },\n    63: {\n      channelId: 'F0A73CE5-6429-48A0-8551-FAFEA19C0A2B',\n      id: 'MSGSN2',\n      logo: 'https://tmsimg.fancybits.co/assets/s70285_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG Sportsnet 2 Zone 2',\n      stationId: '65623',\n      tvgName: 'MSGSN22',\n      provider: 'gotham',\n    },\n    ...YES,\n  },\n  'zone-3': {\n    60: {\n      channelId: '4988B12E-F8D3-4E1B-A541-6072469122BE',\n      id: 'MSG',\n      logo: 'https://tmsimg.fancybits.co/assets/s10979_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG Zone 3',\n      stationId: '42111',\n      tvgName: 'MSGZN3',\n      provider: 'gotham',\n    },\n    61: {\n      channelId: '9A3CCB0A-49D1-41A5-A4FA-58C1B815625E',\n      id: 'MSGSN',\n      logo: 'https://tmsimg.fancybits.co/assets/s11105_ll_h15_ac.png?w=360&h=270',\n      name: 'MSG Sportsnet Zone 3',\n      stationId: '12338',\n      tvgName: 'MSGSNZ3',\n      provider: 'gotham',\n    },\n    ...YES,\n  },\n  'zone-4': {\n    60: {\n      channelId: 'F2986D08-F7D4-46A6-9C8E-A9B3C886730D',\n      id: 'MSG',\n      logo: 'https://tmsimg.fancybits.co/assets/s10979_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG Zone 4',\n      stationId: '35555',\n      tvgName: 'MSG4',\n      provider: 'gotham',\n    },\n    61: {\n      channelId: '7B321549-610E-4CBC-94FF-76F50E29D972',\n      id: 'MSGSN',\n      logo: 'https://tmsimg.fancybits.co/assets/s11105_ll_h15_ac.png?w=360&h=270',\n      name: 'MSG Sportsnet Zone 4',\n      stationId: '15231',\n      tvgName: 'MSGSNZ4',\n      provider: 'gotham',\n    },\n    ...YES,\n  },\n  'zone-5': {\n    60: {\n      channelId: '8279CA06-43E9-4064-AB51-E29696F200E1',\n      id: 'MSG',\n      logo: 'https://tmsimg.fancybits.co/assets/s10979_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG Zone 5',\n      stationId: '35555',\n      tvgName: 'MSGZN5',\n      provider: 'gotham',\n    },\n    61: {\n      channelId: '41AFBC0C-219E-4FF1-826B-024E32167684',\n      id: 'MSGSN',\n      logo: 'https://tmsimg.fancybits.co/assets/s11105_ll_h15_ac.png?w=360&h=270',\n      name: 'MSG Sportsnet Zone 5',\n      stationId: '71133',\n      tvgName: 'MSGSNZ5',\n      provider: 'gotham',\n    },\n    ...YES,\n  },\n  'zone-6': {\n    60: {\n      channelId: 'D3C34F36-33B2-4909-A4C4-FF75B37D39C1',\n      id: 'MSG',\n      logo: 'https://tmsimg.fancybits.co/assets/s10979_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG Zone 6',\n      stationId: '106048',\n      tvgName: 'MSGZN6',\n      provider: 'gotham',\n    },\n    ...YES,\n  },\n  'zone-7': {\n    60: {\n      channelId: '297036E6-08E6-431F-A644-B2E003DACA48',\n      id: 'MSG',\n      logo: 'https://tmsimg.fancybits.co/assets/s10979_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG Zone 3',\n      stationId: '42111',\n      tvgName: 'MSGZN3',\n      provider: 'gotham',\n    },\n  },\n  'zone-8': {\n    60: {\n      channelId: '43CA0781-CAD2-4D46-A8CE-6E67F2CB8DAE',\n      id: 'MSG',\n      logo: 'https://tmsimg.fancybits.co/assets/s10979_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG',\n      stationId: '10979',\n      tvgName: 'MSG',\n      provider: 'gotham',\n    },\n    62: {\n      channelId: '7C2382DE-6FE1-4DAD-B7EA-F5C6FEDAA460',\n      id: 'MSG2',\n      logo: 'https://tmsimg.fancybits.co/assets/s70283_ll_h15_aa.png?w=360&h=270',\n      name: 'MSG2 HD',\n      stationId: '70283',\n      tvgName: 'MSG2HD',\n      provider: 'gotham',\n    },\n    ...YES,\n  },\n  'zone-9': {\n    60: {\n      channelId: 'B91A0053-DB0C-470A-B031-32EBD3F61C77',\n      id: 'MSG',\n      logo: 'https://tmsimg.fancybits.co/assets/s10979_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG Zone 10',\n      stationId: '101378',\n      tvgName: 'MSGZN10',\n      provider: 'gotham',\n    },\n    61: {\n      channelId: '6444D94B-8D03-433B-B593-C321185BBA45',\n      id: 'MSGSN',\n      logo: 'https://tmsimg.fancybits.co/assets/s11105_ll_h15_ac.png?w=360&h=270',\n      name: 'MSG Sportsnet Zone 3',\n      stationId: '12338',\n      tvgName: 'MSGSNZ3',\n      provider: 'gotham',\n    },\n    62: {\n      channelId: '386186BD-E57C-4BC0-9A49-C6EBDE9FD5E3',\n      id: 'MSG2',\n      logo: 'https://tmsimg.fancybits.co/assets/s70283_ll_h15_aa.png?w=360&h=270',\n      name: 'MSG2 HD',\n      stationId: '70283',\n      tvgName: 'MSG2HD',\n      provider: 'gotham',\n    },\n    ...YES,\n  },\n  // eslint-disable-next-line sort-keys-custom-order-fix/sort-keys-custom-order-fix\n  'zone-10': {\n    60: {\n      channelId: 'FCA11159-7246-4EAA-9298-74D131367BFB',\n      id: 'MSG',\n      logo: 'https://tmsimg.fancybits.co/assets/s10979_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG Zone 10',\n      stationId: '101378',\n      tvgName: 'MSGZN10',\n      provider: 'gotham',\n    },\n    61: {\n      channelId: '7B347484-D367-44EE-8BE8-49849D85CD58',\n      id: 'MSGSN',\n      logo: 'https://tmsimg.fancybits.co/assets/s11105_ll_h15_ac.png?w=360&h=270',\n      name: 'MSG Sportsnet Zone 10',\n      stationId: '100342',\n      tvgName: 'MSGSN10',\n      provider: 'gotham',\n    },\n    62: {\n      channelId: '7AAC51AF-7AD9-4B9B-B0A3-04969DFD578E',\n      id: 'MSG2',\n      logo: 'https://tmsimg.fancybits.co/assets/s70283_ll_h15_aa.png?w=360&h=270',\n      name: 'MSG2 Zone 2',\n      stationId: '70283',\n      tvgName: 'MSG2HD',\n      provider: 'gotham',\n    },\n    63: {\n      channelId: '635C4873-0F80-42E1-BA01-F910EFE8B0BE',\n      id: 'MSGSN2',\n      logo: 'https://tmsimg.fancybits.co/assets/s70285_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG Sportsnet 2 Zone 2',\n      stationId: '65623',\n      tvgName: 'MSGSN22',\n      provider: 'gotham',\n    },\n    ...YES,\n  },\n  'zone-11': {\n    60: {\n      channelId: '1455B565-32F1-4F27-8590-CEF2989B72DD',\n      id: 'MSG',\n      logo: 'https://tmsimg.fancybits.co/assets/s10979_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG National',\n      stationId: '80169',\n      tvgName: 'MSGN',\n      provider: 'gotham',\n    },\n    61: {\n      channelId: '417E2B8-B100-49EB-B5CE-1B077888D253',\n      id: 'MSGSN',\n      logo: 'https://tmsimg.fancybits.co/assets/s11105_ll_h15_ac.png?w=360&h=270',\n      name: 'MSG Sportsnet HD',\n      stationId: '15273',\n      tvgName: 'MSGSNNP',\n      provider: 'gotham',\n    },\n    ...YES,\n  },\n  'zone-12': {\n    ...YES,\n  },\n  'zone-13': {\n    ...YES,\n  },\n  'zone-14': {\n    ...YES,\n  },\n  'zone-15': {\n    60: {\n      channelId: 'F2986D08-F7D4-46A6-9C8E-A9B3C886730D',\n      id: 'MSG',\n      logo: 'https://tmsimg.fancybits.co/assets/s10979_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG Zone 4',\n      stationId: '35555',\n      tvgName: 'MSG4',\n      provider: 'gotham',\n    },\n    61: {\n      channelId: '7B321549-610E-4CBC-94FF-76F50E29D972',\n      id: 'MSGSN',\n      logo: 'https://tmsimg.fancybits.co/assets/s11105_ll_h15_ac.png?w=360&h=270',\n      name: 'MSG Sportsnet Zone 4',\n      stationId: '15231',\n      tvgName: 'MSGSNZ4',\n      provider: 'gotham',\n    },\n    ...YES,\n  },\n  'zone-17': {\n    60: {\n      channelId: 'D3C34F36-33B2-4909-A4C4-FF75B37D39C1',\n      id: 'MSG',\n      logo: 'https://tmsimg.fancybits.co/assets/s10979_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG Zone 6',\n      stationId: '106048',\n      tvgName: 'MSGZN6',\n      provider: 'gotham',\n    },\n  },\n  'zone-18': {\n    60: {\n      channelId: '8279CA06-43E9-4064-AB51-E29696F200E1',\n      id: 'MSG',\n      logo: 'https://tmsimg.fancybits.co/assets/s10979_ll_h15_ab.png?w=360&h=270',\n      name: 'MSG',\n      stationId: '10979',\n      tvgName: 'MSG',\n      provider: 'gotham',\n    },\n    61: {\n      channelId: '41AFBC0C-219E-4FF1-826B-024E32167684',\n      id: 'MSGSN',\n      logo: 'https://tmsimg.fancybits.co/assets/s11105_ll_h15_ac.png?w=360&h=270',\n      name: 'MSG Sportsnet Zone 5',\n      stationId: '71133',\n      tvgName: 'MSGSNZ5',\n      provider: 'gotham',\n    },\n  },\n} as const;\n"
  },
  {
    "path": "services/gotham-handler.ts",
    "content": "import axios from 'axios';\nimport _ from 'lodash';\nimport jwt_decode from 'jwt-decode';\nimport moment from 'moment';\nimport CryptoJS from 'crypto-js';\n\nimport {getRandomUUID, normalTimeRange} from './shared-helpers';\nimport {db} from './database';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {okHttpUserAgent} from './user-agent';\nimport {usesLinear} from './misc-db-service';\nimport {IMSGChannel, IMSGChannelGroup, MSG_LINEAR, MSG_PERMISSIONS, YES_PERMISSIONS} from './gotham-channels';\n\nconst API_KEY = [\n  'G',\n  'o',\n  't',\n  '9',\n  'd',\n  '3',\n  '@',\n  'Y',\n  'E',\n  '2',\n  '4',\n  'D',\n  'E',\n  'V',\n  'M',\n  'S',\n  '4',\n  '2',\n  '#',\n  'a',\n  'p',\n  'n',\n  '9',\n  '7',\n  '7',\n  '6',\n].join('');\n\nconst CLIENT_SECRET = [\n  'd',\n  '5',\n  'c',\n  '8',\n  'c',\n  '7',\n  '6',\n  '7',\n  '-',\n  '2',\n  '9',\n  '9',\n  'a',\n  '-',\n  '4',\n  'd',\n  'a',\n  '7',\n  '-',\n  'a',\n  '6',\n  '1',\n  '2',\n  '-',\n  '4',\n  '1',\n  '6',\n  '4',\n  'c',\n  '5',\n  '4',\n  'a',\n  '0',\n  'e',\n  '0',\n  '9',\n].join('');\n\nconst PLAYBACK_CLIENT_SECRET = [\n  '5',\n  '4',\n  'a',\n  'a',\n  '1',\n  'a',\n  'b',\n  '4',\n  '-',\n  '0',\n  '1',\n  'c',\n  '5',\n  '-',\n  '4',\n  '3',\n  '2',\n  'e',\n  '-',\n  '8',\n  '0',\n  '7',\n  'a',\n  '-',\n  '5',\n  'c',\n  '4',\n  'b',\n  '8',\n  'f',\n  '7',\n  '3',\n  '0',\n  '5',\n  '8',\n  'b',\n].join('');\n\nconst BASE_API_URL = ['https://', 'api.gothamsports.com', '/proxy'].join('');\nconst BASE_ADOBE_URL = ['https://', 'api.auth', '.adobe.com', '/api/v1'].join('');\n\ninterface IAppConfig {\n  adobe: {\n    SoftwareStatement: string;\n    adobePassEnvURL: string;\n  };\n  RSNid: string;\n  gameBackend: string;\n}\n\ninterface IEntitlements {\n  message: string;\n  ovatToken: string;\n  dmaID: string;\n  AccountServiceMessage: {\n    validityTill: number;\n    ovpSKU: string;\n    [key: string]: string | number | boolean;\n  }[];\n}\n\ninterface ISigningRes {\n  secret: string;\n  expiry: number;\n  deviceId: string;\n}\n\ninterface IAdobeUserMetadata {\n  zip: string;\n  hba_status: string;\n  userID: string;\n  mvpd: string;\n}\n\nconst extractSidFromJWT = (accessToken: string) => {\n  const {sid}: {sid: string} = jwt_decode(accessToken);\n  return sid;\n};\n\n// Function to replace characters for Base64 URL encoding\nconst base64UrlEncode = (text: string) => text.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n\n// Function to convert a string to Base64 URL format\nconst utf8ToBase64Url = (text: string) => base64UrlEncode(CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(text)));\n\nconst JWTEncode = (header, payload, secretKey) => {\n  // Create an array to hold the JWT segments\n  const jwtSegments = [utf8ToBase64Url(JSON.stringify(header)), utf8ToBase64Url(JSON.stringify(payload))];\n\n  // Calculate the HMAC signature based on the chosen algorithm\n  const signature = base64UrlEncode(\n    CryptoJS.HmacSHA256(jwtSegments.join('.'), secretKey).toString(CryptoJS.enc.Base64),\n  );\n\n  // Add the signature to the JWT segments and return the final JWT string\n  jwtSegments.push(signature);\n\n  return jwtSegments.join('.');\n};\n\nconst parseAirings = async (events: any[]) => {\n  const useLinear = await usesLinear();\n\n  const [now, inTwoDays] = normalTimeRange();\n\n  for (const event of events) {\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `${event.contentId}`});\n\n    if (!entryExists) {\n      const start = moment(event.start);\n      const end = moment(event.end);\n      const originalEnd = moment(event.end);\n\n      if (!useLinear) {\n        start.subtract(30, 'minutes'); // For Pre-game\n        end.add(1, 'hour');\n      }\n\n      if (end.isBefore(now) || start.isAfter(inTwoDays)) {\n        continue;\n      }\n\n      console.log('Adding event: ', event.title);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: event.categories.filter(a => a),\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'gotham',\n        id: event.contentId,\n        image: event.artwork,\n        name: event.title,\n        network: event.network || 'MSG',\n        originalEnd: originalEnd.valueOf(),\n        sport: event.sport,\n        start: start.valueOf(),\n        ...(event.linear && {\n          channel: event.channel,\n          linear: true,\n        }),\n      });\n    }\n  }\n};\n\nclass GothamHandler {\n  private access_token?: string;\n  private entitlement_token?: string;\n  private appConfig?: IAppConfig;\n  private dma_id?: string;\n  private entitlement_access: string[] = [];\n\n  public device_id?: string;\n  public auth_token?: string;\n  public refresh_token?: string;\n  public expiresIn?: number;\n  public adobe_token?: string;\n  public adobe_token_expires?: number;\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'gotham'})) > 0 ? true : false;\n\n    if (!setup) {\n      await db.providers.insertAsync<IProvider<TGothamTokens>>({\n        enabled: false,\n        name: 'gotham',\n        tokens: {},\n      });\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'gotham'});\n\n    if (!enabled) {\n      return;\n    }\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n\n    await this.gothamInit();\n  };\n\n  private gothamInit = async () => {\n    if (!this.appConfig) {\n      await this.getAppConfig();\n    }\n\n    if (!this.access_token) {\n      await this.getAccessToken();\n    }\n\n    if (!this.entitlement_token) {\n      await this.getEntitlements();\n    }\n  };\n\n  public refreshTokens = async () => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'gotham'});\n\n    if (!enabled) {\n      return;\n    }\n\n    if (this.adobe_token) {\n      await this.authenticateRegCode();\n    }\n\n    // Refresh access token and entitlements\n    await this.getAccessToken();\n    await this.getEntitlements();\n\n    if (moment().add(20, 'hours').isAfter(this.expiresIn)) {\n      console.log('Refreshing Gotham auth token');\n      await this.getNewTokens();\n    }\n\n    if (this.adobe_token_expires && moment().isAfter(this.adobe_token_expires)) {\n      console.log('Refreshing Gotham Adobe token');\n      const didUpdate = await this.authenticateRegCode();\n\n      if (!didUpdate) {\n        this.adobe_token = undefined;\n        this.adobe_token_expires = undefined;\n        this.save();\n\n        console.log('Gotham needs to reauthenticate with your TV Provider');\n      }\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'gotham'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log('Looking for Gotham events...');\n\n    const useLinear = await usesLinear();\n\n    const [now, end] = normalTimeRange();\n\n    const entries: any[] = [];\n\n    try {\n      if (useLinear) {\n        if (this.dma_id) {\n          for (const channel of Object.values(MSG_LINEAR[this.dma_id])) {\n            if (\n              !channel ||\n              _.intersection(this.entitlement_access, channel.id === 'YES' ? YES_PERMISSIONS : MSG_PERMISSIONS)\n                .length === 0\n            ) {\n              continue;\n            }\n\n            const url = [\n              BASE_API_URL,\n              '/content/epg',\n              '?reg=',\n              this.dma_id,\n              '&dt=androidtv',\n              '&channel=',\n              channel.channelId,\n              '&client=game-gotham-androidtv',\n              '&start=',\n              now.format('YYYY-MM-DDTHH:mm:ss[Z]'),\n              '&end=',\n              end.format('YYYY-MM-DDTHH:mm:ss[Z]'),\n            ].join('');\n\n            const {data} = await axios.get(url, {\n              headers: {\n                'gg-rsn-id': this.appConfig.RSNid,\n                'user-agent': okHttpUserAgent,\n              },\n            });\n\n            if ( !data.data ) {\n              continue;\n            }\n\n            data.data.forEach(d => {\n              d.airing.forEach(airing => {\n                const eventName = airing.pgm.lon[0].n.replace(/\\n/g, '');\n\n                if (eventName !== 'NO PROGRAMMING - OFF AIR') {\n                  entries.push({\n                    artwork: `https://image-resizer-cloud-cdn.api.gamecms.quickplay.com/image/${airing.cid}/0-16x9.png?width=400`,\n                    categories: [\n                      'Gotham',\n                      'HD',\n                      'Sports',\n                      airing.net || 'MSG',\n                      airing.aw_tm,\n                      airing.hm_tm,\n                      airing.pgm.spt_lg,\n                      airing.pgm.spt_ty,\n                    ],\n                    channel: channel.id,\n                    contentId: `${airing.id}----${airing.cid}`,\n                    end: airing.sc_ed_dt,\n                    linear: true,\n                    network: airing.net,\n                    sport: airing.pgm.spt_lg,\n                    start: airing.sc_st_dt,\n                    title: eventName,\n                  });\n                }\n              });\n            });\n          }\n        }\n      } else {\n        const url = [\n          BASE_API_URL,\n          '/content/liveevent/filter',\n          '?reg=',\n          this.dma_id,\n          '&dt=androidtv',\n          '&client=game-gotham-androidtv',\n          '&pageNumber=1',\n          '&pageSize=40',\n          '&team=',\n          '&start=',\n          now.format('YYYY-MM-DDTHH:mm:ss[Z]'),\n          '&end=',\n          end.format('YYYY-MM-DDTHH:mm:ss[Z]'),\n        ].join('');\n\n        const {data} = await axios.get(url, {\n          headers: {\n            'gg-rsn-id': this.appConfig.RSNid,\n            'user-agent': okHttpUserAgent,\n          },\n        });\n\n        data.data.forEach(airing => {\n          if (!_.some(airing.ent, a => _.intersection(this.entitlement_access, a.sp).length > 0)) {\n            return;\n          }\n\n          const eventName = airing.loen[0].n.replace(/\\n/g, '');\n\n          entries.push({\n            artwork: `https://image-resizer-cloud-cdn.api.gamecms.quickplay.com/image/${airing.cid}/0-16x9.png?width=400`,\n            categories: ['Gotham', 'HD', 'Sports', airing.net || 'MSG', airing.aw_tm, airing.hm_tm, airing.spt_lg],\n            contentId: `${airing.id}----${airing.cid}`,\n            end: airing.ev_ed_dt,\n            network: `${airing.pn}`.toUpperCase(),\n            sport: airing.spt_lg,\n            start: airing.ev_st_dt,\n            title: eventName,\n          });\n        });\n      }\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get Gotham Sports Schedule');\n    }\n\n    await parseAirings(entries);\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    try {\n      const [, channelId] = eventId.split('----');\n\n      const event = await db.entries.findOneAsync<IEntry>({id: eventId});\n\n      const network = event.network === 'YES' ? 'YESN' : 'MSGGO';\n\n      const authToken = await this.getPlaybackToken();\n      const deviceIdToken = await this.getDeviceIdToken(authToken);\n\n      let adobeMediaToken: string;\n\n      if (this.adobe_token) {\n        await this.pingAdobeAuth(network);\n        adobeMediaToken = await this.getAdobeMediaToken(network);\n      }\n\n      const authUrl = [BASE_API_URL, '/media/content/authorize'].join('');\n\n      const {data} = await axios.post(\n        `${authUrl}`,\n        {\n          catalogType: 'channel',\n          contentId: channelId,\n          contentTypeId: 'live',\n          delivery: 'streaming',\n          deviceId: this.device_id,\n          deviceName: 'web',\n          disableSsai: false,\n          drm: 'fairplay',\n          mediaFormat: 'hls',\n          playbackMode: 'live',\n          urlParameters: {},\n        },\n        {\n          headers: {\n            Authorization: `Bearer ${authToken}`,\n            'content-type': 'application/json',\n            'gg-rsn-id': this.appConfig.RSNid,\n            'user-agent': okHttpUserAgent,\n            ...(adobeMediaToken && {\n              'x-adobe-authorization': adobeMediaToken,\n            }),\n            'x-authorization': this.entitlement_token,\n            'x-client-id': 'game-gotham-web',\n            'x-device-id': deviceIdToken,\n          },\n        },\n      );\n\n      if (!data) {\n        throw new Error('Could not get stream data. Event might be upcoming, ended, or in blackout...');\n      }\n\n      return [data.data.contentUrl, {}];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get stream information!');\n    }\n  };\n\n  private getPlaybackToken = async (): Promise<string> => {\n    try {\n      const url = [BASE_API_URL, '/oauth2/token'].join('');\n\n      const params = new URLSearchParams({\n        audience: 'edge-service',\n        client_id: 'webclient-ui-app-game',\n        client_secret: PLAYBACK_CLIENT_SECRET,\n        grant_type: 'client_credentials',\n        scope: 'openid',\n      });\n\n      const {data} = await axios.post(url, params, {\n        headers: {\n          'Content-Type': 'application/x-www-form-urlencoded',\n          'gg-rsn-id': this.appConfig.RSNid,\n          'user-agent': okHttpUserAgent,\n        },\n      });\n\n      return data.access_token;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get ');\n    }\n  };\n\n  private registerDevice = async (): Promise<[string, string]> => {\n    try {\n      const url = ['https://', 'api.auth.adobe.com', '/o/client/register'].join('');\n\n      const {data} = await axios.post(\n        url,\n        {\n          software_statement: this.appConfig.adobe.SoftwareStatement,\n        },\n        {\n          headers: {\n            'Content-Type': 'application/json',\n            'user-agent': okHttpUserAgent,\n          },\n        },\n      );\n\n      return [data.client_id, data.client_secret];\n    } catch (e) {\n      throw new Error('Could not register Adobe device');\n    }\n  };\n\n  private getAccessToken = async (): Promise<void> => {\n    const [client_id, client_secret] = await this.registerDevice();\n\n    try {\n      const url = ['https://', 'api.auth.adobe.com', '/o/client/token'].join('');\n\n      const params = new URLSearchParams({\n        client_id,\n        client_secret,\n        grant_type: 'client_credentials',\n      });\n\n      const {data} = await axios.post(url, params, {\n        headers: {\n          'Content-Type': 'application/x-www-form-urlencoded',\n          'user-agent': okHttpUserAgent,\n        },\n      });\n\n      this.access_token = data.access_token;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get MSG+ access token');\n    }\n  };\n\n  private adobeParams = (resource: string): URLSearchParams => {\n    return new URLSearchParams({\n      deviceId: this.device_id,\n      requestor: 'Gotham',\n      resource,\n    });\n  };\n\n  private pingAdobeAuth = async (resource: string): Promise<void> => {\n    await this.authenticateRegCode();\n\n    try {\n      await axios.get(`${BASE_ADOBE_URL}/authorize?${this.adobeParams(resource)}`, {\n        headers: {\n          Authorization: `Bearer ${this.access_token}`,\n          'user-agent': okHttpUserAgent,\n        },\n      });\n    } catch (e) {\n      console.error(e);\n      console.log('Could not ping Adobe');\n    }\n  };\n\n  private getAdobeMediaToken = async (resource: string): Promise<string> => {\n    await this.authenticateRegCode();\n\n    try {\n      const {data} = await axios.get(`${BASE_ADOBE_URL}/tokens/media?${this.adobeParams(resource)}`, {\n        headers: {\n          Authorization: `Bearer ${this.access_token}`,\n          'user-agent': okHttpUserAgent,\n        },\n      });\n\n      return data.serializedToken;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get Adobe Media token');\n    }\n  };\n\n  private getAppConfig = async (): Promise<void> => {\n    try {\n      const url = ['https://', 'config.gothamsports.com', '/Configurations/v2/build.json'].join('');\n\n      const {data} = await axios.get<IAppConfig>(url, {\n        headers: {\n          'user-agent': okHttpUserAgent,\n        },\n      });\n\n      this.appConfig = data;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get Gotham app config');\n    }\n  };\n\n  public login = async (username: string, password: string): Promise<boolean> => {\n    this.device_id = getRandomUUID();\n\n    await this.gothamInit();\n\n    try {\n      const {data} = await axios.post(\n        `${BASE_API_URL}/getOAuthAccessTokenv2`,\n        {\n          GetOAuthAccessTokenv2RequestMessage: {\n            apiKey: API_KEY,\n            channelPartnerID: 'GOTHAM',\n            contactPassword: password,\n            contactUserName: username,\n            deviceMessage: {\n              deviceName: 'onn. 4K Streaming Box',\n              deviceType: 'AndroidTV',\n              modelNo: 'onn onn. 4K Streaming Box',\n              serialNo: this.device_id,\n              userAgent: '',\n            },\n          },\n        },\n        {\n          headers: {\n            'gg-rsn-id': this.appConfig.RSNid,\n            'user-agent': okHttpUserAgent,\n          },\n        },\n      );\n\n      if (data.gameError?.description && !data.GetOAuthAccessTokenv2ResponseMessage?.accessToken) {\n        throw new Error(data.gameError?.description || 'Wrong Username or Password');\n      }\n\n      this.auth_token = data.GetOAuthAccessTokenv2ResponseMessage.accessToken;\n      this.refresh_token = data.GetOAuthAccessTokenv2ResponseMessage.refreshToken;\n      this.expiresIn = +data.GetOAuthAccessTokenv2ResponseMessage.expiresIn;\n\n      await this.save();\n      await this.getEntitlements();\n\n      return true;\n    } catch (e) {\n      console.error(e, JSON.stringify(e));\n      console.log('Could not login to Gotham with provided credentials!');\n    }\n\n    return false;\n  };\n\n  private getNewTokens = async (): Promise<void> => {\n    try {\n      const {data} = await axios.post(\n        `${BASE_API_URL}/refreshToken`,\n        {\n          RefreshTokenRequestMessage: {\n            apiKey: API_KEY,\n            channelPartnerID: 'GOTHAM',\n            refreshToken: this.refresh_token,\n          },\n        },\n        {\n          headers: {\n            'Content-Type': 'application/json',\n            authorization: `Bearer ${this.auth_token}`,\n            'gg-rsn-id': this.appConfig.RSNid,\n            'user-agent': okHttpUserAgent,\n          },\n        },\n      );\n\n      this.auth_token = data.RefreshTokenResponseMessage.accessToken;\n      this.refresh_token = data.RefreshTokenResponseMessage.refreshToken;\n      this.expiresIn = +data.RefreshTokenResponseMessage.expiresIn;\n\n      this.save();\n    } catch (e) {\n      console.error(e);\n      console.log('Could not refresh tokens for Gotham!');\n    }\n  };\n\n  private getEntitlements = async (): Promise<IEntitlements> => {\n    if (!this.auth_token) {\n      return;\n    }\n\n    try {\n      const {data} = await axios.post<{GetEntitlementsResponseMessage: IEntitlements}>(\n        `${BASE_API_URL}/getEntitlements`,\n        {\n          GetEntitlementsRequestMessage: {\n            apiKey: API_KEY,\n            channelPartnerID: 'GOTHAM',\n          },\n        },\n        {\n          headers: {\n            authorization: `Bearer ${this.auth_token}`,\n            'gg-rsn-id': this.appConfig.RSNid,\n            'user-agent': okHttpUserAgent,\n          },\n        },\n      );\n\n      if (data?.GetEntitlementsResponseMessage?.message !== 'SUCCESS') {\n        throw new Error('Could not get entitlements for Gotham');\n      }\n\n      this.entitlement_token = data.GetEntitlementsResponseMessage.ovatToken;\n      this.dma_id = data.GetEntitlementsResponseMessage.dmaID;\n\n      this.entitlement_access = data.GetEntitlementsResponseMessage.AccountServiceMessage.filter(a =>\n        moment(a.validityTill).isAfter(moment()),\n      ).map(a => a.ovpSKU);\n\n      return data.GetEntitlementsResponseMessage;\n    } catch (e) {\n      console.error(e);\n    }\n  };\n\n  private getDeviceIdToken = async (authToken: string): Promise<string> => {\n    const secretRes = await this.getSigningSecret(authToken);\n\n    const now = moment();\n    const secretKey = CryptoJS.enc.Base64.parse(secretRes.secret);\n\n    /* eslint-disable sort-keys-custom-order-fix/sort-keys-custom-order-fix */\n    return JWTEncode(\n      {\n        alg: 'HS256',\n        typ: 'JWT',\n      },\n      {\n        deviceId: secretRes.deviceId,\n        aud: 'playback-auth-service',\n        iat: now.unix(),\n        exp: moment(now).add(30, 'seconds').unix(),\n      },\n      secretKey,\n    );\n    /* eslint-enable sort-keys-custom-order-fix/sort-keys-custom-order-fix */\n  };\n\n  private getSigningSecret = async (authToken: string): Promise<ISigningRes> => {\n    try {\n      const {data} = await axios.post(\n        `${BASE_API_URL}/device/app/register`,\n        {\n          uniqueId: this.device_id,\n        },\n        {\n          headers: {\n            authorization: `Bearer ${authToken}`,\n            'gg-rsn-id': this.appConfig.RSNid,\n            'user-agent': okHttpUserAgent,\n            'x-authorization': this.entitlement_token,\n            'x-client-id': 'game-gotham-web',\n          },\n        },\n      );\n\n      return data.data;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not register device for Gotham');\n    }\n  };\n\n  public getAuthCode = async (): Promise<string> => {\n    try {\n      const adobeUrl = ['https://', 'api.auth.adobe.com', '/reggie/v1', '/Gotham/regcode'].join('');\n\n      const {data: gothamData} = await axios.post(\n        `${BASE_API_URL}/generateDeviceActivationCode`,\n        {\n          GenerateDeviceActivationCodeRequestMessage: {\n            apiKey: API_KEY,\n            channelPartnerID: 'GOTHAM',\n            deviceDetails: {\n              deviceName: 'onn. 4K Streaming Box',\n              deviceType: 'androidtv',\n              modelNo: 'onn onn. 4K Streaming Box',\n              serialNo: this.device_id,\n            },\n          },\n        },\n        {\n          headers: {\n            authorization: `Bearer ${this.auth_token}`,\n            'gg-rsn-id': this.appConfig.RSNid,\n            'user-agent': okHttpUserAgent,\n          },\n        },\n      );\n\n      const gothamCode = gothamData.GenerateDeviceActivationCodeResponseMessage.activationCode;\n\n      if (!gothamCode) {\n        return 'Loading...';\n      }\n\n      const adobeParams = new URLSearchParams({\n        deviceId: this.device_id,\n      });\n\n      const {data: adobeData} = await axios.post(adobeUrl, adobeParams, {\n        headers: {\n          'Content-Type': 'application/x-www-form-urlencoded',\n          authorization: `Bearer ${this.access_token}`,\n          'user-agent': okHttpUserAgent,\n        },\n      });\n\n      const adobeCode = adobeData.code;\n\n      const sid = extractSidFromJWT(this.auth_token);\n      const hashedSid = Buffer.from(sid).toString('base64');\n\n      return `https://auth.gothamsports.com/authenticate/${adobeCode}/androidtv/${gothamCode}?spAccountId=${hashedSid}`;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start the authentication process for Gotham!');\n    }\n  };\n\n  public authenticateRegCode = async (): Promise<boolean> => {\n    try {\n      const entitlements = await this.getEntitlements();\n\n      if (!entitlements.AccountServiceMessage.length) {\n        return false;\n      }\n\n      const {data} = await axios.post(\n        `${BASE_API_URL}/generateDeviceActivationCode`,\n        {\n          GenerateDeviceActivationCodeRequestMessage: {\n            apiKey: API_KEY,\n            channelPartnerID: 'GOTHAM',\n            deviceDetails: {\n              deviceName: 'onn. 4K Streaming Box',\n              deviceType: 'androidtv',\n              modelNo: 'onn onn. 4K Streaming Box',\n              serialNo: this.device_id,\n            },\n          },\n        },\n        {\n          headers: {\n            authorization: `Bearer ${this.auth_token}`,\n            'gg-rsn-id': this.appConfig.RSNid,\n            'user-agent': okHttpUserAgent,\n          },\n        },\n      );\n\n      if (\n        !data.GenerateDeviceActivationCodeResponseMessage ||\n        data.GenerateDeviceActivationCodeResponseMessage?.message !== 'SUCCESS' ||\n        !data.GenerateDeviceActivationCodeResponseMessage?.accessToken\n      ) {\n        return false;\n      }\n\n      this.adobe_token_expires = +data.GenerateDeviceActivationCodeResponseMessage.expiresIn;\n      this.adobe_token = data.GenerateDeviceActivationCodeResponseMessage.accessToken;\n\n      this.save();\n\n      const adobeId = await this.getAdobeId();\n      const adobeUserMeta = await this.getUserMetadata();\n\n      await this.preAuthDevice();\n      await this.addTVESubscription(adobeId, adobeUserMeta);\n      await this.getEntitlements();\n\n      return true;\n    } catch (e) {\n      return false;\n    }\n  };\n\n  public getLinearChannels = _.throttle((): IMSGChannelGroup => {\n    const channelObj = _.cloneDeep(MSG_LINEAR[this.dma_id]);\n\n    if (!channelObj) {\n      return {};\n    }\n\n    for (const channelNum of Object.keys(channelObj)) {\n      const channel: IMSGChannel = channelObj[channelNum];\n\n      if (\n        !channel ||\n        _.intersection(this.entitlement_access, channel.id === 'YES' ? YES_PERMISSIONS : MSG_PERMISSIONS).length === 0\n      ) {\n        delete channelObj[channelNum];\n        continue;\n      }\n\n      channel.checkChannelEnabled = async () => {\n        const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'gotham'});\n\n        return enabled;\n      };\n    }\n\n    return channelObj;\n  }, 15 * 1000);\n\n  private getAdobeId = async (): Promise<string> => {\n    try {\n      const {data} = await axios.post(\n        `${BASE_API_URL}/getContact`,\n        {\n          GetContactRequestMessage: {\n            apiKey: API_KEY,\n            channelPartnerID: 'GOTHAM',\n          },\n        },\n        {\n          headers: {\n            authorization: `Bearer ${this.auth_token}`,\n            'gg-rsn-id': this.appConfig.RSNid,\n            'user-agent': okHttpUserAgent,\n          },\n        },\n      );\n\n      return data.GetContactResponseMessage.adobeID;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get Adobe ID for Gotham!');\n    }\n  };\n\n  private getUserMetadata = async (): Promise<IAdobeUserMetadata> => {\n    try {\n      const url = [BASE_ADOBE_URL, '/tokens/usermetadata', '?deviceId=', this.device_id, '&requestor=Gotham'].join('');\n\n      const {data} = await axios.get<{data: IAdobeUserMetadata}>(url, {\n        headers: {\n          Authorization: `Bearer ${this.access_token}`,\n          'User-Agent': okHttpUserAgent,\n        },\n      });\n\n      return data.data;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get user meta for Gotham!');\n    }\n  };\n\n  private preAuthDevice = async (): Promise<void> => {\n    try {\n      const url = [\n        'https://',\n        'api.auth.adobe.com',\n        '/api/v1/preauthorize',\n        '?deviceId=',\n        this.device_id,\n        '&requestor=Gotham',\n        '&resource=YESN,MSGGO',\n      ].join('');\n\n      await axios.get(url, {\n        headers: {\n          authorization: `Bearer ${this.access_token}`,\n          'gg-rsn-id': this.appConfig.RSNid,\n          'user-agent': okHttpUserAgent,\n        },\n      });\n    } catch (e) {\n      console.error(e);\n      console.log('Could not pre-auth device for Gotham!');\n    }\n  };\n\n  private addTVESubscription = async (adobeId: string, adobeUserMeta: IAdobeUserMetadata): Promise<void> => {\n    try {\n      await axios.post(\n        `${BASE_API_URL}/addTVESubscription`,\n        {\n          AddTVESubscriptionRequestMessage: {\n            adobeId,\n            adobeResource: ['MSGGO', 'YESN'],\n            apiKey: API_KEY,\n            channelPartnerID: 'GOTHAM',\n            deviceID: this.device_id,\n            encryptedZip: adobeUserMeta.zip,\n            mvpdID: adobeUserMeta.mvpd,\n          },\n        },\n        {\n          headers: {\n            authorization: `Bearer ${this.auth_token}`,\n            'gg-rsn-id': this.appConfig.RSNid,\n            'user-agent': okHttpUserAgent,\n          },\n        },\n      );\n    } catch (e) {\n      console.error(e);\n      console.log('Could not add TVE subscription for Gotham!');\n    }\n  };\n\n  private save = async () => {\n    await db.providers.updateAsync(\n      {name: 'gotham'},\n      {$set: {tokens: _.omit(this, 'appConfig', 'access_token', 'entitlement_token', 'dma_id', 'entitlement_access')}},\n    );\n  };\n\n  private load = async () => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TGothamTokens>>({name: 'gotham'});\n    const {device_id, auth_token, refresh_token, expiresIn, adobe_token_expires, adobe_token} = tokens || {};\n\n    this.device_id = device_id;\n    this.auth_token = auth_token;\n    this.refresh_token = refresh_token;\n    this.expiresIn = expiresIn;\n    this.adobe_token_expires = adobe_token_expires;\n    this.adobe_token = adobe_token;\n  };\n}\n\nexport type TGothamTokens = ClassTypeWithoutMethods<GothamHandler>;\n\nexport const gothamHandler = new GothamHandler();\n"
  },
  {
    "path": "services/hudl-handler.ts",
    "content": "import axios from 'axios';\nimport moment from 'moment';\n\nimport {userAgent} from './user-agent';\nimport {IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {debug} from './debug';\nimport {normalTimeRange} from './shared-helpers';\n\ninterface IHudlEvent {\n  id: string;\n  site_id: string;\n  site_title: string;\n  section_title: string;\n  title: string;\n  description: string;\n  date: string;\n  expected_duration: number;\n  large_image: string;\n  shared_sites: string[];\n}\n\ninterface IHudlConference {\n  slug: string;\n  short_name: string;\n  full_name: string;\n  enabled?: boolean;\n}\n\ninterface IHudlSite {\n  id: string;\n  title: string;\n  slug: string;\n  conference_short_name: string;\n}\n\nexport interface IHudlMeta {\n  conferences: IHudlConference[];\n  sites: IHudlSite[];\n}\n\n// to add or remove a conference, update this array\n// slug must exactly match the URL slug used to look up sites and events\n// short_name (acronym) and full_name can be anything\n// UI alphabetizes them based on short_name\nconst all_conferences = [\n  {\n    short_name: 'ARC',\n    full_name: 'American Rivers Conference',\n    slug: 'americanriverssportsnetwork',\n  },\n  {\n    short_name: 'CCIW',\n    full_name: 'College Conference of Illinois and Wisconsin',\n    slug: 'CCIW',\n  },\n  {\n    short_name: 'Centennial Conference',\n    full_name: 'Centennial Conference',\n    slug: 'Centennial',\n  },\n  {\n    short_name: 'CIAA',\n    full_name: 'Central Intercollegiate Athletic Association',\n    slug: 'ciaa',\n  },\n  {\n    short_name: 'Conference Carolinas',\n    full_name: 'Conference Carolinas',\n    slug: 'conferencecarolinas',\n  },\n  {\n    short_name: 'CNE',\n    full_name: 'Conference of New England',\n    slug: 'cccnetwork',\n  },\n  {\n    short_name: 'Empire 8',\n    full_name: 'Empire 8 Athletic Conference',\n    slug: 'empire8network',\n  },\n  {\n    short_name: 'G-MAC',\n    full_name: 'Great Midwest Athletic Conference',\n    slug: 'gmdn',\n  },\n  {\n    short_name: 'GLVC',\n    full_name: 'Great Lakes Valley Conference',\n    slug: 'glvc',\n  },\n  {\n    short_name: 'GNAC',\n    full_name: 'Great Northwest Athletic Conference',\n    slug: 'gnac',\n  },\n  {\n    short_name: 'GPAC',\n    full_name: 'Great Plains Athletic Conference',\n    slug: 'gpacnetwork',\n  },\n  {\n    short_name: 'HAAC',\n    full_name: 'Heart of America Athletic Conference',\n    slug: 'hoa',\n  },\n  {\n    short_name: 'ICCAC',\n    full_name: 'Iowa Community College Athletic Conference',\n    slug: 'IowaCommunityCollegeAthleticConference',\n  },\n  {\n    short_name: 'KCAC',\n    full_name: 'Kansas Collegiate Athletic Conference',\n    slug: 'kcacnetwork',\n  },\n  {\n    short_name: 'LSC',\n    full_name: 'Lone Star Conference',\n    slug: 'lonestar',\n  },\n  {\n    short_name: 'MAC',\n    full_name: 'Middle Atlantic Conference',\n    slug: 'MAC',\n  },\n  {\n    short_name: 'MASCAC',\n    full_name: 'Massachusetts State Collegiate Athletic Conference',\n    slug: 'mascac',\n  },\n  {\n    short_name: 'MEC',\n    full_name: 'Mountain East Conference',\n    slug: 'mec',\n  },\n  {\n    short_name: 'MIAA',\n    full_name: 'Mid-America Intercollegiate Athletics Association',\n    slug: 'miaa',\n  },\n  {\n    short_name: 'MIAC',\n    full_name: 'Minnesota Intercollegiate Athletic Conference',\n    slug: 'MIAC',\n  },\n  {\n    short_name: 'NCAC',\n    full_name: 'North Coast Athletic Conference',\n    slug: 'ncac',\n  },\n  {\n    short_name: 'NEWMAC',\n    full_name: `New England Women's and Men's Athletic Conference`,\n    slug: 'NEWMAC',\n  },\n  {\n    short_name: 'NSIC',\n    full_name: 'Northern Sun Intercollegiate Conference',\n    slug: 'NSIC',\n  },\n  {\n    short_name: 'NWC',\n    full_name: 'Northwest Conference',\n    slug: 'northwestconferencenetwork',\n  },\n  {\n    short_name: 'ODAC',\n    full_name: 'Old Dominion Athletic Conference',\n    slug: 'odacsn',\n  },\n  {\n    short_name: 'PAC',\n    full_name: `Presidents' Athletic Conference`,\n    slug: 'PresidentsAthleticConference',\n  },\n  {\n    short_name: 'PACWest',\n    full_name: 'Pacific West Conference',\n    slug: 'pacwest',\n  },\n  {\n    short_name: 'PSAC',\n    full_name: 'Pennsylvania State Athletic Conference',\n    slug: 'PSACNetwork',\n  },\n  {\n    short_name: 'RMAC',\n    full_name: 'Rocky Mountain Athletic Conference',\n    slug: 'rmacnetwork',\n  },\n  {\n    short_name: 'SAC',\n    full_name: 'Sooner Athletic Conference',\n    slug: 'sooner',\n  },\n  {\n    short_name: 'SCIAC',\n    full_name: 'Southern California Intercollegiate Athletic Conference',\n    slug: 'SCIACNETWORK',\n  },\n  {\n    short_name: 'SIAC',\n    full_name: 'Southern Intercollegiate Athletic Conference',\n    slug: 'siac',\n  },\n  {\n    short_name: 'TSC',\n    full_name: 'The Sun Conference',\n    slug: 'TheSunConference',\n  },\n  {\n    short_name: 'UMAC',\n    full_name: 'Upper Midwest Athletic Conference',\n    slug: 'umacsportsnetwork',\n  },\n  {\n    short_name: 'WIAC',\n    full_name: 'Wisconsin Intercollegiate Athletic Conference',\n    slug: 'WIAC',\n  },\n];\n\nconst filterSiteTitle = (site_title: string): string => {\n  return site_title.replace(/(University|College|University of|The University of|College of|The College of)/gi, '').trim();\n};\n\nconst parseAirings = async (events: IHudlEvent[], sites: IHudlSite[]) => {\n  const [now, endSchedule] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.id) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `hudl-${event.id}`});\n\n    if (!entryExists) {\n      const start = moment(event.date);\n      if (!event.expected_duration) {\n        event.expected_duration = 3 * 60 * 60;\n      }\n      const end = moment(event.date).add(event.expected_duration, 'seconds').add(1, 'hours');\n      const originalEnd = moment(event.date).add(event.expected_duration, 'seconds');\n\n      if (end.isBefore(now) || start.isAfter(endSchedule)) {\n        continue;\n      }\n      \n      let conference_short_name = 'Hudl';\n      let title = event.title.trim();\n      const site = sites.find(obj => obj.id == event.site_id);\n      const shared_site = sites.find(obj => obj.slug == event.shared_sites[0]);\n      if (site) {\n        conference_short_name = site.conference_short_name;\n        if (shared_site && (event.shared_sites.length == 1)) {\n          title = shared_site.title + ' vs. ' + site.title;\n        } else {\n          if ( !event.title.includes(filterSiteTitle(site.title)) ) {\n            title += ' (' + site.title + ')';\n          }\n        }\n      } else if (shared_site) {\n        conference_short_name = shared_site.conference_short_name;\n        if ( !event.title.includes(filterSiteTitle(shared_site.title)) ) {\n          title += ' (' + shared_site.title + ')';\n        }\n      }\n\n      console.log('Adding event: ', title);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: [...new Set([conference_short_name, event.section_title])],\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'hudl',\n        id: `hudl-${event.id}`,\n        image: event.large_image,\n        name: title,\n        network: conference_short_name,\n        originalEnd: originalEnd.valueOf(),\n        sport: event.section_title,\n        start: start.valueOf(),\n      });\n    }\n  }\n};\n\nclass HudlHandler {\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'hudl'})) > 0 ? true : false;\n\n    // First time setup\n    if (!setup) {\n      await db.providers.insertAsync<IProvider>({\n        enabled: false,\n        meta: {\n          conferences: all_conferences,\n          sites: [],\n        },\n        name: 'hudl',\n      });\n    }\n\n    const {enabled, meta} = await db.providers.findOneAsync<IProvider>({name: 'hudl'});\n    \n    // added conferences\n    const new_conferences = all_conferences.filter(\n      (a) => !meta.conferences.some((b) => a.slug === b.slug),\n    );\n    meta.conferences.push(...new_conferences);\n    \n    // removed conferences\n    meta.conferences = meta.conferences.filter(\n      (a) => all_conferences.some((b) => a.slug === b.slug),\n    );\n    \n    // sort\n    this.saveConferences(meta.conferences);\n\n    if (!enabled) {\n      return;\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled, meta} = await db.providers.findOneAsync<IProvider>({name: 'hudl'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log('Looking for Hudl events...');\n    \n    const [now, inTwoDays] = normalTimeRange();\n    \n    if ( meta.sites && (meta.sites.length > 0) ) {\n      try {\n        const max_items_per_page = 100;\n        let pages = 1;\n          \n        for (let page = 1; page <= pages; page++) {\n          const broadcasts_url = [\n            'https://',\n            'vcloud.hudl.com',\n            '/api/viewer/',\n            'broadcast',\n            '?include_deletions=0',\n            '&page=',\n            page,\n            '&per_page=',\n            max_items_per_page,\n            '&site_id=',\n            encodeURIComponent(meta.sites.map((s) => s.id).join(',')),\n            '&before=',\n            encodeURIComponent(moment(inTwoDays).add(1, 'day').format('ddd, DD MMM YYYY HH:mm:ss [GMT]')),\n            '&viewer_status=3&sort_by=date&sort_dir=asc',\n          ].join('');\n            \n          const {data: broadcasts_data} = await axios.get(broadcasts_url, {\n            headers: {\n              'user-agent': userAgent,\n            },\n          });\n          \n          if ( broadcasts_data.num_pages ) {\n            pages = broadcasts_data.num_pages;\n          }\n          \n          debug.saveRequestData(broadcasts_data, 'hudl', 'epg');\n          await parseAirings(broadcasts_data.broadcasts, meta.sites);\n        }\n      } catch (e) {\n        console.error(e);\n        console.log(`Could not parse Hudl events`);\n      }\n    } else {\n      console.log(`Found no Hudl sites`);\n    }\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    const id = eventId.replace('hudl-', '');\n\n    try {\n      const streamUrl = await this.getStream(id);\n\n      return [streamUrl, {'user-agent': userAgent}];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start playback');\n    }\n  };\n\n  private getStream = async (eventId: string): Promise<string> => {\n    try {\n      const url = ['https://', 'vcloud.hudl.com', '/file/broadcast/', `/${eventId}`, '.m3u8', '?hfr=1'].join('');\n\n      return url;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get stream');\n    }\n  };\n\n  private saveConferences = async (conferences: IHudlConference[]) => {\n    // sort\n    conferences.sort((a, b) => a.short_name.localeCompare(b.short_name));\n    \n    // save\n    await db.providers.updateAsync<IProvider, any>({name: 'hudl'}, {$set: {'meta.conferences': conferences}});\n  };\n  \n  public updateConference = async (conference: IHudlConference): Promise<void> => {\n    // get currently stored conferences\n    const {meta} = await db.providers.findOneAsync<IProvider>({name: 'hudl'});\n    \n    // find requested conference by slug\n    const matchingConference = meta.conferences.filter(obj => obj.slug === conference.slug);\n    \n    // filter it out\n    meta.conferences = meta.conferences.filter(\n      (a) => !matchingConference.some((b) => a.slug === b.slug),\n    );\n    \n    // add back the updated conference\n    meta.conferences.push(...[conference]);\n    \n    // save\n    this.saveConferences(meta.conferences);\n  }\n  \n  public updateConferenceSites = async (conference: IHudlConference): Promise<void> => {\n    console.log(`Updating sites for Hudl ${conference.short_name}...`);\n    \n    try {\n      // get currently stored sites\n      const {meta} = await db.providers.findOneAsync<IProvider>({name: 'hudl'});\n      \n      // if enabled, fetch the sites\n      if (conference.enabled) {\n        const config_url = [\n          'https://',\n          'apps.blueframetech.com',\n          '/api/v1/',\n          'bft/',\n          conference.slug,\n          '/config.json',\n        ].join('');\n          \n        const {data: config_data} = await axios.get(config_url, {\n          headers: {\n            'user-agent': userAgent,\n          },\n        });\n         \n        const sites_url = [\n          'https://',\n          'vcloud.hudl.com',\n          '/api/viewer/',\n          'site?site_ids=',\n          config_data.vCloud.siteIds.join(','),\n          '&per_page=100&page=1',\n        ].join('');\n            \n        const {data: sites_data} = await axios.get(sites_url, {\n          headers: {\n            'user-agent': userAgent,\n          },\n        });\n              \n        const sites: IHudlSite[] = sites_data.sites.map(item => ({\n          id: item.id,\n          title: item.title,\n          slug: item.slug,\n          conference_short_name: conference.short_name,\n        }));\n        \n        console.log(`Adding ${sites.length} new sites`);\n        \n        // add the new sites\n        meta.sites.push(...sites);\n        \n      // if disabled, remove the sites\n      } else {\n        \n        // find requested sites by conference_short_name\n        const matchingSites = meta.sites.filter(obj => obj.conference_short_name === conference.short_name);\n        \n        console.log(`Removing ${matchingSites.length} sites`);\n        \n        // filter them out\n        meta.sites = meta.sites.filter(\n          (a) => !matchingSites.some((b) => a.id == b.id),\n        );\n      }\n    \n      // save\n      await db.providers.updateAsync<IProvider, any>({name: 'hudl'}, {$set: {'meta.sites': meta.sites}});\n    } catch (e) {\n      console.error(e);\n      console.log(`Could not update sites for Hudl ${conference.short_name}`);\n    }\n  }\n}\n\nexport const hudlHandler = new HudlHandler();\n"
  },
  {
    "path": "services/init-directories.ts",
    "content": "import fs from 'fs';\n\nimport {configPath} from './config';\nimport {\n  entriesDb,\n  initializeEntries,\n  scheduleDb,\n  initializeSchedule,\n  providersDb,\n  initializeProviders,\n  miscDb,\n  initializeMisc,\n} from './database';\nimport {debug, debugPath} from './debug';\n\nexport const initDirectories = (): void => {\n  if (!fs.existsSync(configPath)) {\n    fs.mkdirSync(configPath);\n  }\n\n  if (debug.enabled && !fs.existsSync(debugPath)) {\n    fs.mkdirSync(debugPath);\n  }\n\n  if (!fs.existsSync(entriesDb)) {\n    initializeEntries();\n  }\n\n  if (!fs.existsSync(scheduleDb)) {\n    initializeSchedule();\n  }\n\n  if (!fs.existsSync(providersDb)) {\n    initializeProviders();\n  }\n\n  if (!fs.existsSync(miscDb)) {\n    initializeMisc();\n  }\n};\n"
  },
  {
    "path": "services/jsdom-helper.ts",
    "content": "import jsdom from 'jsdom';\n\nimport {userAgent} from './user-agent';\n\nconst {JSDOM} = jsdom;\n\ninterface IDom {\n  serialize(): string;\n  window: {\n    close(): void;\n    MessageChannel: any;\n  };\n}\n\nexport const jsDomHelper = async (url: string): Promise<IDom> => {\n  const dom: IDom = await JSDOM.fromURL(url, {\n    pretendToBeVisual: true,\n    resources: 'usable',\n    runScripts: 'dangerously',\n    userAgent,\n    virtualConsole: new jsdom.VirtualConsole(),\n  });\n\n  dom.window.MessageChannel = class MessageChannel {\n    public port1: any;\n    public port2: any;\n\n    constructor() {\n      this.port1 = {};\n      this.port2 = {};\n    }\n  };\n\n  return dom;\n};\n"
  },
  {
    "path": "services/kbo-handler.ts",
    "content": "import moment, {Moment} from 'moment-timezone';\nimport * as cheerio from 'cheerio';\n\nimport {userAgent} from './user-agent';\nimport {IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {debug} from './debug';\nimport {combineImages, normalTimeRange} from './shared-helpers';\nimport axios from 'axios';\n\nconst SOOP_CATEGORY_ID = '90';\n\ninterface IKBOEvent {\n  awayLogo: string;\n  homeLogo: string;\n  title: string;\n  start: Date;\n  id: string;\n}\n\ninterface IKBOMeta {\n  client_id: string;\n}\n\nconst parseAirings = async (events: IKBOEvent[]) => {\n  const [now, endSchedule] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.id) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: event.id});\n\n    if (!entryExists) {\n      const start = moment(event.start);\n      const end = moment(event.start).add(5.5, 'hours');\n      const originalEnd = moment(event.start).add(3, 'hours');\n\n      if (end.isBefore(now) || start.isAfter(endSchedule)) {\n        continue;\n      }\n\n      console.log('Adding event: ', event.title);\n\n      const image = await combineImages(event.awayLogo, event.homeLogo);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: [...new Set(['KBO', 'Baseball'])],\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'kbo',\n        id: event.id,\n        image,\n        name: event.title,\n        network: 'SOOP Live',\n        originalEnd: originalEnd.valueOf(),\n        sport: 'KBO',\n        start: start.valueOf(),\n      });\n    }\n  }\n};\n\nclass KBOHandler {\n  public client_id?: string;\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'kbo'})) > 0 ? true : false;\n\n    // First time setup\n    if (!setup) {\n      await db.providers.insertAsync<IProvider>({\n        enabled: false,\n        meta: {\n          client_id: '',\n        },\n        name: 'kbo',\n      });\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'kbo'});\n\n    if (!enabled) {\n      return;\n    }\n\n    const {meta} = await db.providers.findOneAsync<IProvider<any, IKBOMeta>>({name: 'kbo'});\n\n    this.client_id = meta.client_id;\n  };\n\n  public getClientId = async (): Promise<void> => {\n    try {\n      const res = await axios.get([\n        'https://',\n        'www.sooplive.com',\n        '/category/',\n        'kbo-league'].join(''),\n        {\n          headers: {\n            'user-agent': userAgent,\n          },\n        },\n      );\n\n      if ( res.headers['set-cookie'] ) {\n        const cookies = res.headers['set-cookie'];\n        const cookie_name = 'client-id';\n        for (var i = 0; i < cookies.length; i++) {\n          if (cookies[i].startsWith(cookie_name+'=')) {\n            this.client_id = cookies[i].split(';')[0].slice(cookie_name.length + 1);\n            break;\n          }\n        }\n      }\n\n      if (this.client_id != '') {\n        await db.providers.updateAsync({name: 'kbo'}, {$set: {'meta.client_id': this.client_id}});\n      } else {\n        console.log('Did not get client ID');\n      }\n    } catch (e) {\n      console.log('Could not get client ID');\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'kbo'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log('Looking for KBO events...');\n\n    const allItems: IKBOEvent[] = [];\n\n    const today = new Date();\n    // schedule is fetched as a 3-day window centered around tomorrow, Korea time\n    const startDate = moment.tz(today, 'Asia/Seoul').add(1, 'days').format('YYYYMMDD');\n\n    try {\n      const {data} = await axios.post(['http://', 'eng.koreabaseball.com', '/Schedule/', 'MainSchedule.aspx'].join(''),\n        {\n          gameDate: startDate,\n          flag: 'NEXT',\n        },\n        {\n          headers: {\n            'user-agent': userAgent,\n            'content-type': 'application/x-www-form-urlencoded',\n          },\n        }\n      );\n      const $ = cheerio.load(data);\n\n      for (var i = 1; i <= 3; i++) {\n        const dateString = $(`#schedule${i}`).find('h4').eq(0).text();\n        const scheduleItems = $(`#schedule${i}`)\n          .find('li');\n\n        scheduleItems.each((k, el) => {\n          const $el = $(el);\n          // upcoming events contain VS\n          if ($el.find('span').eq(2).text() == 'VS') {\n            let awayTeam = $el.find('span').eq(0).text();\n            const awayLogo = this.getLogo(awayTeam);\n            if ( awayTeam.length > 3 ) {\n              awayTeam = awayTeam.charAt(0).toUpperCase() + awayTeam.slice(1).toLowerCase();\n            }\n            let homeTeam = $el.find('span').eq(3).text();\n            const homeLogo = this.getLogo(homeTeam);\n            if ( homeTeam.length > 3 ) {\n              homeTeam = homeTeam.charAt(0).toUpperCase() + homeTeam.slice(1).toLowerCase();\n            }\n            // times are displayed in Korea time\n            let start = moment.tz(dateString + ' ' + $el.find('span').eq(6).text(), 'ddd MMM DD HH:mm', 'Asia/Seoul').utc();\n\n            allItems.push({\n              awayLogo,\n              homeLogo,\n              id: `kbo-${awayTeam}-${homeTeam}-${start.valueOf()}`,\n              start: start.toDate(),\n              title: `${awayTeam} vs ${homeTeam}`,\n            });\n          }\n        });\n      };\n\n      debug.saveRequestData(allItems, 'kbo', 'epg');\n\n      await parseAirings(allItems);\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse KBO events');\n    }\n  };\n\n  public getEventData = async (id: string, retry?: boolean): Promise<TChannelPlaybackInfo> => {\n    try {\n      const event = await db.entries.findOneAsync<IEntry>({id});\n\n      if (this.client_id == '') {\n        await this.getClientId();\n        if (this.client_id == '') {\n          throw new Error('Could not get client id');\n        }\n      }\n\n      const {data} = await axios.get(['https://', 'api.sooplive.com', '/stream'].join(''), {\n        params: {\n          limit: 20,\n          page: 1,\n          sort: 'viewer',\n          languageCodeList: '',\n          categoryIdx: SOOP_CATEGORY_ID,\n        },\n        headers: {\n          'user-agent': userAgent,\n          'accept': 'application/json',\n          'accept-language': 'en-US,en;q=0.9',\n          'authorization': 'Bearer undefined',\n          'cache-control': 'no-cache',\n          'client-id': this.client_id,\n          'dnt': '1',\n          'lang': 'en-US',\n          'origin': 'https://www.sooplive.com',\n          'pragma': 'no-cache',\n          'priority': 'u=1, i',\n          'referer': 'https://www.sooplive.com/category/kbo-league',\n          'region-code': 'NA',\n        },\n      });\n\n      if (data.streamList) {\n        const streamList = data.streamList;\n\n        // live event titles are formatted ALLCAPS vs ALLCAPS\n        const eventTitleSplit = event.name.split(' ');\n        for (const [i, j] of [0, 2].entries()) {\n          eventTitleSplit[j] = eventTitleSplit[j].toUpperCase();\n        }\n        const eventTitle = eventTitleSplit.join(' ');\n\n        for (var i = 0; i < streamList.length; i++) {\n          if (streamList[i].title.startsWith(eventTitle)) {\n            const streamUrl = streamList[i].previewURL;\n            const referer = ['https://', 'www.sooplive.com', '/', streamList[i].channelId].join('');\n            const origin = ['https://', 'www.sooplive.com'].join('');\n            return [streamUrl, {'user-agent': userAgent, 'origin': origin, 'referer': referer}];\n          }\n        }\n        throw new Error('Could not find matching live stream');\n      }\n\n      throw new Error('Could not find live streams');\n    } catch (e) {\n      if (!retry && e.message != 'Could not get client id') {\n        console.log('Could not get event data, attempting to refresh');\n        await this.getClientId();\n        await this.getEventData(id, true);\n      } else {\n        console.error(e);\n        console.log('Could not get event data');\n      }\n    }\n  };\n\n  private getLogo = (team: string): string => {\n    try {\n      // Hanwha's logo is a different size\n      const imageSize = '@2x';\n      const url = ['https://', 'www.ktwiz.co.kr', '/v2/imgs/emblems/', 'ico-100-logo-', team.toLowerCase(), ((team != 'HANWHA') ? imageSize : ''), '.png'].join('');\n\n      return url;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get logo');\n    }\n  };\n}\n\nexport const kboHandler = new KBOHandler();\n"
  },
  {
    "path": "services/ksl-handler.ts",
    "content": "import moment from 'moment';\n\nimport {userAgent} from './user-agent';\nimport {IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {debug} from './debug';\nimport {normalTimeRange} from './shared-helpers';\nimport axios from 'axios';\n\nconst origin = [\n  'https://',\n  'kslsports.',\n  'com'\n].join('');\nconst referer = [origin, '/'].join('');\n\nconst domain = [\n  'b',\n  'o',\n  'n',\n  'n',\n  'e',\n  'v',\n  'i',\n  'l',\n  'l',\n  'e',\n  '.',\n  'd',\n  'i',\n  'r',\n  'e',\n  'c',\n  't',\n  'u',\n  's',\n  '.',\n  'a',\n  'p',\n  'p',\n].join('');\n\ninterface IKSLEvent {\n  archive: boolean;\n  id: string;\n  thumbnail: string;\n  title: string;\n  StartTime: Date;\n  EndTime: Date;\n}\n\nconst parseAirings = async (events: IKSLEvent[]) => {\n  const [now, endSchedule] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.id) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: event.id});\n\n    if (!entryExists) {\n      const start = moment(event.StartTime);\n      const end = moment(event.EndTime).add(1, 'hours');\n      const originalEnd = moment(event.EndTime);\n\n      if (end.isBefore(now) || start.isAfter(endSchedule)) {\n        continue;\n      }\n\n      console.log('Adding event: ', event.title);\n\n      const image = [\n        'https://',\n        domain,\n        '/assets/', \n        event.thumbnail, \n        '?key=',\n        'stream-item-thumb-jpeg'\n      ].join('');\n\n      await db.entries.insertAsync<IEntry>({\n        categories: [...new Set(['KSL Sports'])],\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'ksl',\n        id: event.id,\n        image,\n        name: event.title,\n        network: 'KSL Sports',\n        originalEnd: originalEnd.valueOf(),\n        start: start.valueOf(),\n      });\n    }\n  }\n};\n\nclass KSLHandler {\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'ksl'})) > 0 ? true : false;\n\n    // First time setup\n    if (!setup) {\n      await db.providers.insertAsync<IProvider>({\n        enabled: false,\n        name: 'ksl',\n      });\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'ksl'});\n\n    if (!enabled) {\n      return;\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'ksl'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log('Looking for KSL events...');\n\n    try {\n      const [now, endSchedule] = normalTimeRange();\n      \n      const url = [\n        'https://',\n        domain,\n        '/items/Streams/',\n        '?filter=',\n        '{%22_and%22:%20[{%22hide%22:%20{%22_neq%22:%20true}},%20{%20%22StartTime%22:%20{%22_gt%22:%20%22', \n        moment(now).format('YYYY-MM-DDTHH:mm:ss.SSS[Z]'), \n        '%22%20}},%20{%22StartTime%22:%20{%22_lt%22:%20%22', \n        moment(endSchedule).format('YYYY-MM-DDTHH:mm:ss.SSS[Z]'), \n        '%22}}]}',\n        '&sort[]=',\n        'StartTime'\n      ].join('');\n\n      const {data} = await axios.get<{data: IKSLEvent[]}>(url, {\n        headers: {\n          'user-agent': userAgent,\n          'origin': origin,\n          'referer': referer,\n        },\n      });\n\n      debug.saveRequestData(data, 'ksl', 'epg');\n\n      await parseAirings(data.data);\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse KSL events');\n    }\n  };\n\n  public getEventData = async (id: string): Promise<TChannelPlaybackInfo> => {\n    try {\n      const event = await db.entries.findOneAsync<IEntry>({id});\n\n      const {data} = await axios.get(\n        [\n          'https://',\n          domain,\n          '/items/Streams/', \n          id, \n          '?fields=',\n          'Channels.Channels_id.embedCode'\n        ].join(''), {\n        headers: {\n          'user-agent': userAgent,\n          'origin': origin,\n          'referer': referer,\n        },\n      });\n\n      const streamUrl = data.data.Channels[0].Channels_id.embedCode;\n        \n      return [streamUrl, {'user-agent': userAgent, 'origin': origin, 'referer': referer}];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get event data');\n    }\n  };\n}\n\nexport const kslHandler = new KSLHandler();\n"
  },
  {
    "path": "services/launch-channel.ts",
    "content": "import {db} from './database';\nimport {espnHandler} from './espn-handler';\nimport {foxHandler} from './fox-handler';\nimport {foxOneHandler} from './foxone-handler';\nimport {mlbHandler} from './mlb-handler';\nimport {paramountHandler} from './paramount-handler';\nimport {b1gHandler} from './b1g-handler';\nimport {floSportsHandler} from './flo-handler';\nimport {nflHandler} from './nfl-handler';\nimport {mwHandler} from './mw-handler';\nimport {hudlHandler} from './hudl-handler';\nimport {cbsHandler} from './cbs-handler';\nimport {IEntry, THeaderInfo} from './shared-interfaces';\nimport {PlaylistHandler} from './playlist-handler';\nimport {appStatus} from './app-status';\nimport {removeChannelStatus} from './shared-helpers';\nimport {calculateChannelNumber} from './channels';\nimport {gothamHandler} from './gotham-handler';\nimport {wsnHandler} from './wsn-handler';\nimport {pwhlHandler} from './pwhl-handler';\nimport {ballyHandler} from './bally-handler';\nimport {nhlHandler} from './nhltv-handler';\nimport {victoryHandler} from './victory-handler';\nimport {kboHandler} from './kbo-handler';\nimport {kslHandler} from './ksl-handler';\nimport {zeamHandler} from './zeam-handler';\nimport {nwslHandler} from './nwsl-handler';\nimport {midcoHandler} from './midco-handler';\nimport {outsideHandler} from './outside-handler';\nimport {wnbaHandler} from './wnba-handler';\n\nconst checkingStream = {};\n\nconst startChannelStream = async (channelId: string, appUrl: string) => {\n  if (appStatus.channels[channelId].player || checkingStream[channelId]) {\n    return;\n  }\n\n  checkingStream[channelId] = true;\n\n  let url: string;\n  let headers: THeaderInfo;\n\n  const playingNow = await db.entries.findOneAsync<IEntry>({\n    id: appStatus.channels[channelId].current,\n  });\n\n  if (playingNow) {\n    try {\n      switch (playingNow.from) {\n        case 'foxsports':\n          [url, headers] = await foxHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'foxone':\n          [url, headers] = await foxOneHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'mlbtv':\n          [url, headers] = await mlbHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'paramount+':\n          [url, headers] = await paramountHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'gotham':\n          [url, headers] = await gothamHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'b1g+':\n          [url, headers] = await b1gHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'flo':\n          [url, headers] = await floSportsHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'nfl+':\n          [url, headers] = await nflHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'mountain-west':\n          [url, headers] = await mwHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'wsn':\n          [url, headers] = await wsnHandler.getEventData();\n          break;\n        case 'nhl':\n          [url, headers] = await nhlHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'victory':\n          [url, headers] = await victoryHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'pwhl':\n          [url, headers] = await pwhlHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'nwsl':\n          [url, headers] = await nwslHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'midco':\n          [url, headers] = await midcoHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'bally':\n          [url, headers] = await ballyHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'hudl':\n          [url, headers] = await hudlHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'cbssports':\n          [url, headers] = await cbsHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'kbo':\n          [url, headers] = await kboHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'ksl':\n          [url, headers] = await kslHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'zeam':\n          [url, headers] = await zeamHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'outside':\n          [url, headers] = await outsideHandler.getEventData(appStatus.channels[channelId].current);\n          break;\n        case 'wnba':\n          [url, headers] = await wnbaHandler.getEventData(appStatus.channels[channelId].current);\n        default:\n          [url, headers] = await espnHandler.getEventData(appStatus.channels[channelId].current);\n      }\n    } catch (e) {}\n\n    if (!url) {\n      console.log('Failed to parse the stream');\n\n      // Reset channel state\n      removeChannelStatus(channelId);\n    } else {\n      appStatus.channels[channelId].player = new PlaylistHandler(\n        headers,\n        appUrl,\n        channelId,\n        playingNow.from,\n        appStatus.channels[channelId].current,\n      );\n\n      try {\n        await appStatus.channels[channelId].player.initialize(url);\n        await checkNextStream(channelId);\n      } catch (e) {\n        // Reset channel state\n        removeChannelStatus(channelId);\n      }\n    }\n  }\n\n  checkingStream[channelId] = false;\n};\n\nexport const launchChannel = async (channelId: string, appUrl: string): Promise<void> => {\n  const channelNum = await calculateChannelNumber(channelId);\n  const isNumber = Number.isFinite(parseInt(`${channelNum}`, 10));\n\n  if (appStatus.channels[channelId].player || checkingStream[channelId]) {\n    return;\n  }\n\n  const now = new Date().valueOf();\n  const channel = isNumber ? parseInt(`${channelNum}`, 10) : channelNum;\n\n  // Find the entry with the most recent start time (if there are overlapping)\n  const playingNow = await db.entries\n    .findOneAsync<IEntry>({\n      channel,\n      end: {$gt: now},\n      start: {$lt: now},\n    })\n    .sort({start: -1});\n\n  if (playingNow && playingNow.id) {\n    console.log(`Channel #${channelId} has an active event (${playingNow.name}). Going to start the stream.`);\n    appStatus.channels[channelId].current = playingNow.id;\n    await startChannelStream(channelId, appUrl);\n  } else {\n    // Reset channel state\n    removeChannelStatus(channelId);\n  }\n};\n\nexport const checkNextStream = async (channelId: string): Promise<void> => {\n  if (appStatus.channels[channelId].heartbeatTimer) {\n    return;\n  }\n\n  const now = new Date().valueOf();\n\n  const channel = parseInt(channelId, 10);\n  const entries = await db.entries.findAsync<IEntry>({channel, start: {$gt: now}}).sort({start: 1});\n\n  if (entries && entries.length > 0) {\n    const diff = entries[0].start - now;\n\n    appStatus.channels[channelId].heartbeatTimer = setTimeout(() => {\n      console.log(`Channel #${channelId} is scheduled to finish. Removing playlist info.`);\n      removeChannelStatus(channelId);\n    }, diff);\n  }\n};\n"
  },
  {
    "path": "services/midco-handler.ts",
    "content": "import axios from 'axios';\nimport moment from 'moment';\n\nimport {userAgent} from './user-agent';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {hideStudio} from './misc-db-service';\nimport {getRandomUUID, normalTimeRange} from './shared-helpers';\nimport {debug} from './debug';\n\ninterface IMidcoEvent {\n  cast: {\n    actors: string[];\n  };\n  genres: string[];\n  id: number;\n  images: {\n    poster: {\n      landscape: {\n        url: string;\n      }[];\n    };\n  };\n  schedule: {\n    startsAt: string;\n    endsAt: string;\n  };\n  shortDescription: string;\n  title: string;\n  video: {\n    accessLevel: string;\n    entitlementTags: string[];\n  };\n}\n\ninterface IMidcoMeta {\n  email: string;\n  password: string;\n}\n\nconst ORIGIN = [\n  'https://',\n  'www',\n  '.midcosportsplus',\n  '.com',\n].join('');\nconst REFERRER = [\n  ORIGIN,\n  '/',\n].join('');\nconst BASE_API_URL = [\n  REFERRER,\n  'api/',\n  'core/',\n].join('');\n\nconst API_COLLECTION = [\n  '0',\n  '1',\n  'E',\n  'K',\n  'A',\n  'G',\n  'Z',\n  'F',\n  'F',\n  'M',\n  '1',\n  'M',\n  'H',\n  'X',\n  'E',\n  'V',\n  '3',\n  '7',\n  'K',\n  'Y',\n  '8',\n  'J',\n  'V',\n  '3',\n  '1',\n  '3'\n].join('');\n\nconst cookieToken = (token: string): string => {\n  return 'one-token=' + token + Buffer.from(token.substring(0, 24)).toString('base64');\n};\n\nconst parseAirings = async (events: IMidcoEvent[]) => {\n  const hide_studio = await hideStudio();\n\n  const [now, endDate] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.id) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `midco-${event.id}`});\n\n    if (!entryExists) {\n      if ( hide_studio && event.title.toLowerCase().endsWith(' show') ) {\n        continue;\n      }\n\n      const start = moment(event.schedule.startsAt);\n      // endsAt is inaccurate, and startAt can be 30 minutes early\n      // so we just assume 3.5-5 hour duration for all events\n      const end = moment(event.schedule.startsAt).add(5, 'hours');\n      const originalEnd = moment(event.schedule.startsAt).add(3.5, 'hours');\n\n      if (end.isBefore(now) || start.isAfter(endDate)) {\n        continue;\n      }\n\n      console.log('Adding event: ', event.title);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: event.cast.actors,\n        description: event.shortDescription,\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'midco',\n        id: `midco-${event.id}`,\n        image: event.images.poster.landscape[0].url,\n        name: event.title,\n        network: 'Midco Sports',\n        originalEnd: originalEnd.valueOf(),\n        sport: event.genres.join(' - '),\n        start: start.valueOf(),\n      });\n    }\n  }\n};\n\nclass MidcoHandler {\n  public token?: string;\n  public refreshToken?: string;\n  public expiration?: number;\n  public entitlements?: string[];\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'midco'})) > 0 ? true : false;\n\n    // First time setup\n    if (!setup) {\n      const data: TMidcoTokens = {};\n\n      await db.providers.insertAsync<IProvider<TMidcoTokens>>({\n        enabled: false,\n        name: 'midco',\n        tokens: data,\n      });\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'midco'});\n\n    if (!enabled) {\n      return;\n    }\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n  };\n\n  public refreshTokens = async () => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'midco'});\n\n    if (!enabled) {\n      return;\n    }\n\n    if (!this.expiration) {\n      await this.login();\n    }\n\n    if (moment().isBefore(this.expiration)) {\n      return;\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'midco'});\n\n    if (!enabled) {\n      return;\n    }\n\n    await this.refreshTokens();\n\n    console.log('Looking for Midco Sports events...');\n\n    const entries: IMidcoEvent[] = [];\n\n    const [now, endSchedule] = normalTimeRange();\n\n    try {\n      const url = [\n        BASE_API_URL,\n        'catalog/',\n        'collection/',\n        API_COLLECTION,\n        '?page=1',\n        '&pageSize=100',\n        '&locale=en',\n      ].join('');\n\n      const {data} = await axios.get(url, {\n        headers: {\n          Accept: 'application/json, text/plain, */*',\n          'Accept-Language': 'en-US,en;q=0.9',\n          'Accept-Encoding': 'gzip, deflate, br, zstd',\n          'Content-Type': 'application/json',\n          Cookie: cookieToken(this.token),\n          Referer: REFERRER,\n          'User-Agent': userAgent,\n        },\n      });\n\n      debug.saveRequestData(data, 'midco', 'epg');\n\n      data.data.forEach(e => {\n        if ( (e.video.accessLevel != 'ENTITLEMENT_REQUIRED') || this.entitlements.some(entitlement => e.video.entitlementTags.includes(entitlement)) ) {\n          entries.push(e);\n        }\n      });\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse Midco Sports events');\n    }\n\n    await parseAirings(entries);\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    await this.refreshTokens();\n\n    const eventRealId = eventId.split('midco-')[1];\n\n    const url = [\n      BASE_API_URL,\n      'play/',\n      'item/',\n      eventRealId,\n      '?via=1.0.',\n      API_COLLECTION,\n      '&include=contentObject',\n      '&locale=en',\n    ].join('');\n\n    try {\n      const {data} = await axios.get(url, {\n        headers: {\n          Accept: 'application/json, text/plain, */*',\n          'Accept-Language': 'en-US,en;q=0.9',\n          'Accept-Encoding': 'gzip, deflate, br, zstd',\n          'Content-Type': 'application/json',\n          Cookie: cookieToken(this.token),\n          Referer: REFERRER,\n          'User-Agent': userAgent,\n        },\n      });\n\n      return [data.playbackInfo.videoStreams[0].url, {}];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start playback');\n    }\n  };\n\n  public login = async (email?: string, password?: string): Promise<boolean> => {\n    const url = [\n      BASE_API_URL,\n      'auth/',\n      'login',\n      '?locale=en'\n    ].join('');\n\n    const device_id = getRandomUUID();\n\n    try {\n      const {meta} = await db.providers.findOneAsync<IProvider<any, IMidcoMeta>>({name: 'midco'});\n\n      const params = {\n        deviceInfo: {\n          id: device_id,\n          hardware: {\n            manufacturer: 'UNKNOWN/UNKNOWN',\n            model: 'Firefox',\n            version: '148.0',\n          },\n          os: {\n            name: 'Windows',\n            version: '11',\n          },\n          display: {\n            width: 2165,\n            height: 939,\n            formFactor: 'DESKTOP',\n          },\n          legal: {},\n        },\n        values: {\n          email: email || meta.email,\n          password: password || meta.password,\n        },\n      };\n\n      const {data} = await axios.post(url, params, {\n        headers: {\n          Accept: 'application/json, text/plain, */*',\n          'Accept-Language': 'en-US,en;q=0.9',\n          'Accept-Encoding': 'gzip, deflate, br, zstd',\n          'Content-Type': 'application/json',\n          Origin: ORIGIN,\n          Referer: REFERRER,\n          'User-Agent': userAgent,\n        },\n      });\n\n      this.token = data._meta.auth.token;\n      this.refreshToken = data._meta.auth.refreshToken;\n      this.expiration = data._meta.auth.expiration;\n\n\n      const entitlements_url = [\n        BASE_API_URL,\n        'user/',\n        'profile',\n        '?locale=en'\n      ].join('');\n\n      const {data: entitlements_data} = await axios.get(entitlements_url, {\n        headers: {\n          Accept: 'application/json, text/plain, */*',\n          'Accept-Language': 'en-US,en;q=0.9',\n          'Accept-Encoding': 'gzip, deflate, br, zstd',\n          Cookie: cookieToken(this.token),\n          Referer: REFERRER,\n          'User-Agent': userAgent,\n        },\n      });\n\n      this.entitlements = entitlements_data.entitlements.grantedEntitlementTags;\n\n      await this.save();\n\n      return true;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not login to Midco');\n\n      return false;\n    }\n  };\n\n  private save = async (): Promise<void> => {\n    await db.providers.updateAsync({name: 'midco'}, {$set: {tokens: this}});\n  };\n\n  private load = async (): Promise<void> => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TMidcoTokens>>({name: 'midco'});\n    const {token, refreshToken, expiration, entitlements} = tokens || {};\n\n    this.token = token;\n    this.refreshToken = refreshToken;\n    this.expiration = expiration;\n    this.entitlements = entitlements;\n  };\n}\n\nexport type TMidcoTokens = ClassTypeWithoutMethods<MidcoHandler>;\n\nexport const midcoHandler = new MidcoHandler();\n"
  },
  {
    "path": "services/misc-db-service.ts",
    "content": "import _ from 'lodash';\n\nimport {db} from './database';\nimport {IMiscDbEntry, IProvider} from './shared-interfaces';\nimport {removeEntriesProvider} from './build-schedule';\n\nconst BUFFER_CHANNELS = 50;\n\nconst linearChannelsEnv = process.env.LINEAR_CHANNELS?.toLowerCase();\nconst startChannelEnv = process.env.START_CHANNEL;\nconst numOfChannelsEnv = process.env.NUM_OF_CHANNELS;\nconst proxySegmentsEnv = process.env.PROXY_SEGMENTS?.toLowerCase();\n\nconst nextStartChannel = (end: number, buffer: number): number => {\n  const sum = end + buffer;\n\n  // Round up to the next hundred\n  let nextHundred = Math.ceil(sum / 100) * 100;\n\n  // Check if the result is at least 50 more than X\n  if (nextHundred - end < buffer) {\n    nextHundred += 100;\n  }\n\n  return nextHundred;\n};\n\nexport const initMiscDb = async (): Promise<void> => {\n  const setupLinear = (await db.misc.countAsync({name: 'use_linear'})) > 0 ? true : false;\n\n  if (!setupLinear) {\n    await db.misc.insertAsync<IMiscDbEntry<boolean>>({\n      name: 'use_linear',\n      value: linearChannelsEnv === 'true' ? true : false,\n    });\n  }\n\n  const setupStartChannel = (await db.misc.countAsync({name: 'start_channel'})) > 0 ? true : false;\n\n  if (!setupStartChannel) {\n    let startChannel = _.toNumber(startChannelEnv);\n\n    if (_.isNaN(startChannel)) {\n      startChannel = 1;\n    }\n\n    await db.misc.insertAsync<IMiscDbEntry<number>>({\n      name: 'start_channel',\n      value: startChannel,\n    });\n  }\n\n  const setupNumOfChannels = (await db.misc.countAsync({name: 'num_channels'})) > 0 ? true : false;\n\n  if (!setupNumOfChannels) {\n    let numOfChannels = _.toNumber(numOfChannelsEnv);\n\n    if (_.isNaN(numOfChannels)) {\n      numOfChannels = 200;\n    }\n\n    await db.misc.insertAsync<IMiscDbEntry<number>>({\n      name: 'num_channels',\n      value: numOfChannels,\n    });\n  }\n\n  const setupLinearStartChannel = (await db.misc.countAsync({name: 'linear_start_channel'})) > 0 ? true : false;\n\n  if (!setupLinearStartChannel) {\n    const startChannel = await getStartChannel();\n    const numOfChannels = await getNumberOfChannels();\n\n    await db.misc.insertAsync<IMiscDbEntry<number>>({\n      name: 'linear_start_channel',\n      value: nextStartChannel(startChannel + numOfChannels, BUFFER_CHANNELS),\n    });\n  }\n\n  const setupProxySegments = (await db.misc.countAsync({name: 'proxy_segments'})) > 0 ? true : false;\n\n  if (!setupProxySegments) {\n    await db.misc.insertAsync<IMiscDbEntry<boolean>>({\n      name: 'proxy_segments',\n      value: proxySegmentsEnv === 'true' ? true : false,\n    });\n  }\n\n  const setupXmltvPadding = (await db.misc.countAsync({name: 'xmltv_padding'})) > 0 ? true : false;\n\n  if (!setupXmltvPadding) {\n    await db.misc.insertAsync<IMiscDbEntry<boolean>>({\n      name: 'xmltv_padding',\n      value: true,\n    });\n  }\n\n  const setupHideStudio = (await db.misc.countAsync({name: 'hide_studio'})) > 0 ? true : false;\n\n  if (!setupHideStudio) {\n    await db.misc.insertAsync<IMiscDbEntry<boolean>>({\n      name: 'hide_studio',\n      value: false,\n    });\n  }\n\n  const setupEventFilters = (await db.misc.countAsync({name: 'category_filter'}) + await db.misc.countAsync({name: 'title_filter'})) == 2 ? true : false;\n\n  if (!setupEventFilters) {\n    await db.misc.insertAsync<IMiscDbEntry<string>>({\n      name: 'category_filter',\n      value: '',\n    });\n    await db.misc.insertAsync<IMiscDbEntry<string>>({\n      name: 'title_filter',\n      value: '',\n    });\n  }\n\n  const setupLatestVersion = (await db.misc.countAsync({name: 'latest_version'})) > 0 ? true : false;\n\n  if (!setupLatestVersion) {\n    await db.misc.insertAsync<IMiscDbEntry<string>>({\n      name: 'latest_version',\n      value: '',\n    });\n  } else {\n    await db.misc.updateAsync({name: 'latest_version'}, {$set: {value: ''}});\n  }\n\n  const setupLastModified = (await db.misc.countAsync({name: 'last_modified'})) > 0 ? true : false;\n\n  if (!setupLastModified) {\n    await db.misc.insertAsync<IMiscDbEntry<string>>({\n      name: 'last_modified',\n      value: '',\n    });\n  } else {\n    await db.misc.updateAsync({name: 'last_modified'}, {$set: {value: ''}});\n  }\n\n  if (linearChannelsEnv) {\n    console.log('Using LINEAR_CHANNELS variable is no longer needed. Please use the UI going forward');\n  }\n  if (startChannelEnv) {\n    console.log('Using START_CHANNEL variable is no longer needed. Please use the UI going forward');\n  }\n  if (numOfChannelsEnv) {\n    console.log('Using NUM_OF_CHANNELS variable is no longer needed. Please use the UI going forward');\n  }\n  if (proxySegmentsEnv) {\n    console.log('Using PROXY_SEGMENTS variable is no longer needed. Please use the UI going forward');\n  }\n\n  // force disabling of removed providers and their schedules\n  const removedProviders = ['nesn', 'nsic', 'lovb'];\n  const removedSchedules = ['nesn', 'northern-sun', 'lovb'];\n  for (var i=0; i<removedProviders.length; i++) {\n    try {\n      const {enabled} = await db.providers.findOneAsync<IProvider>({name: removedProviders[i]});\n      if (enabled) {\n        console.log('Force disabling removed provider ' + removedProviders[i]);\n        await db.providers.updateAsync<IProvider, any>({name: removedProviders[i]}, {$set: {enabled: false}});\n        await removeEntriesProvider(removedSchedules[i]);\n      }\n    } catch (e) {\n      // do nothing\n    }\n  }\n};\n\nexport const usesLinear = async (): Promise<boolean> => {\n  const {value} = await db.misc.findOneAsync<IMiscDbEntry<boolean>>({name: 'use_linear'});\n\n  return value;\n};\n\nexport const setLinear = async (value: boolean): Promise<number> =>\n  (await db.misc.updateAsync<IMiscDbEntry<boolean>, any>({name: 'use_linear'}, {$set: {value}})).numAffected;\n\nexport const getStartChannel = async (): Promise<number> => {\n  const {value} = await db.misc.findOneAsync<IMiscDbEntry<number>>({name: 'start_channel'});\n\n  return value;\n};\n\nexport const setStartChannel = async (channelNum: number): Promise<number> =>\n  (await db.misc.updateAsync({name: 'start_channel'}, {$set: {value: channelNum}})).numAffected;\n\nexport const getLinearStartChannel = async (): Promise<number> => {\n  const {value} = await db.misc.findOneAsync<IMiscDbEntry<number>>({name: 'linear_start_channel'});\n\n  return value;\n};\n\nexport const resetLinearStartChannel = async (): Promise<void> => {\n  const startChannel = await getStartChannel();\n  const numOfChannels = await getNumberOfChannels();\n\n  await db.misc.updateAsync<IMiscDbEntry<number>, any>(\n    {\n      name: 'linear_start_channel',\n    },\n    {\n      $set: {\n        value: nextStartChannel(startChannel + numOfChannels, BUFFER_CHANNELS),\n      },\n    },\n  );\n};\n\nexport const getNumberOfChannels = async (): Promise<number> => {\n  const {value} = await db.misc.findOneAsync<IMiscDbEntry<number>>({name: 'num_channels'});\n\n  return value;\n};\n\nexport const setNumberofChannels = async (numChannels: number): Promise<number> =>\n  (await db.misc.updateAsync({name: 'num_channels'}, {$set: {value: numChannels}})).numAffected;\n\nexport const proxySegments = async (): Promise<boolean> => {\n  const {value} = await db.misc.findOneAsync<IMiscDbEntry<boolean>>({name: 'proxy_segments'});\n\n  return value;\n};\n\nexport const setProxySegments = async (value: boolean): Promise<number> =>\n  (await db.misc.updateAsync({name: 'proxy_segments'}, {$set: {value}})).numAffected;\n\nexport const xmltvPadding = async (): Promise<boolean> => {\n  const {value} = await db.misc.findOneAsync<IMiscDbEntry<boolean>>({name: 'xmltv_padding'});\n\n  return value;\n};\n\nexport const setXmltvPadding = async (value: boolean): Promise<number> =>\n  (await db.misc.updateAsync({name: 'xmltv_padding'}, {$set: {value}})).numAffected;\n\nexport const hideStudio = async (): Promise<boolean> => {\n  const {value} = await db.misc.findOneAsync<IMiscDbEntry<boolean>>({name: 'hide_studio'});\n\n  return value;\n};\n\nexport const setHideStudio = async (value: boolean): Promise<number> =>\n  (await db.misc.updateAsync({name: 'hide_studio'}, {$set: {value}})).numAffected;\n\nexport const getCategoryFilter = async () => {\n  const {value} = await db.misc.findOneAsync<IMiscDbEntry<string>>({name: 'category_filter'});\n\n  return value;\n};\n\nexport const getTitleFilter = async () => {\n  const {value} = await db.misc.findOneAsync<IMiscDbEntry<string>>({name: 'title_filter'});\n\n  return value;\n};\n\nexport const setEventFilters = async (categoryFilter: string, titleFilter: string): Promise<number> =>\n  (await db.misc.updateAsync({name: 'category_filter'}, {$set: {value: categoryFilter}})).numAffected +\n  (await db.misc.updateAsync({name: 'title_filter'}, {$set: {value: titleFilter}})).numAffected;\n\nexport const getLatestVersion = async () => {\n  const {value} = await db.misc.findOneAsync<IMiscDbEntry<string>>({name: 'latest_version'});\n\n  return value;\n};\n\nexport const setLatestVersion = async (latest_version: string): Promise<number> =>\n  (await db.misc.updateAsync({name: 'latest_version'}, {$set: {value: latest_version}})).numAffected;\n\nexport const getLastModified = async () => {\n  const {value} = await db.misc.findOneAsync<IMiscDbEntry<string>>({name: 'last_modified'});\n\n  return value;\n};\n\nexport const setLastModified = async (last_modified: string): Promise<number> =>\n  (await db.misc.updateAsync({name: 'last_modified'}, {$set: {value: last_modified}})).numAffected;\n"
  },
  {
    "path": "services/mlb-handler.ts",
    "content": "import fs from 'fs';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport moment, {Moment} from 'moment-timezone';\nimport _ from 'lodash';\n\nimport {okHttpUserAgent, userAgent, androidMlbUserAgent} from './user-agent';\nimport {configPath} from './config';\nimport {useMLBtv, useMLBtvOnlyFree} from './networks';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {debug} from './debug';\nimport {usesLinear} from './misc-db-service';\nimport {normalTimeRange} from './shared-helpers';\n\ninterface IGameContent {\n  media: {\n    epg: {\n      title: string;\n      items: {\n        callLetters: string;\n        espnAuthRequired: boolean;\n        tbsAuthRequired: boolean;\n        espn2AuthRequired: boolean;\n        contentId: string;\n        fs1AuthRequired: boolean;\n        mediaId: string;\n        mediaFeedType: string;\n        mlbnAuthRequired: boolean;\n        foxAuthRequired: boolean;\n        freeGame: boolean;\n        id: number;\n        abcAuthRequired: boolean;\n      }[];\n    }[];\n    freeGame: boolean;\n    enhancedGame: boolean;\n  };\n}\n\ninterface IMLBNetworkEvent {\n  utcDate: string;\n  originalShowSynopsis: string;\n  synopsis: string;\n  episodetitle: string;\n  seriestitle: string;\n  live: string;\n  startdate: string;\n  starttime: string;\n  enddate: string;\n  endtime: string;\n}\n\ntype TSNYEvent = [string, string, string, string, string];\n\ninterface ISNYSchedule {\n  [key: string]: {\n    title: string;\n    data: {\n      rows: TSNYEvent[];\n    };\n  };\n}\n\ninterface ISNLAProgram {\n  thumbnail: string;\n  title: string;\n}\n\ninterface ISNLAEvent {\n  startTime: number;\n  endTime: number;\n  programId: string;\n  id: string;\n}\n\ninterface ISNLAEventCombined extends ISNLAEvent, ISNLAProgram {}\n\ninterface ISNLAScheduleRes {\n  programs: {\n    [key: string]: ISNLAProgram;\n  };\n  events: ISNLAEvent[];\n}\n\ninterface ITeam {\n  team: {\n    name: string;\n    id: number;\n  };\n}\n\ninterface IGame {\n  gamePk: number;\n  description: string;\n  gameDate: string;\n  teams: {\n    away: ITeam;\n    home: ITeam;\n  };\n  content: IGameContent;\n}\n\ninterface ISchedule {\n  dates: {\n    games: IGame[];\n  }[];\n}\n\ninterface IVideoFeed {\n  mediaId: string;\n  mediaFeedType: string;\n  callLetters: string;\n  freeGame: boolean;\n}\n\ninterface IGameFeed {\n  gamePk: string;\n  blackedOutVideo?: boolean;\n  videoFeeds: IVideoFeed[];\n}\n\ninterface ICombinedGame {\n  [key: string]: {\n    feed?: IGameFeed;\n    entry?: IGame;\n  };\n}\n\ninterface IProviderMeta {\n  onlyFree?: boolean;\n}\n\ninterface IEntitlement {\n  code: string;\n}\n\nconst CLIENT_ID = [\n  '0',\n  'o',\n  'a',\n  '3',\n  'e',\n  '1',\n  'n',\n  'u',\n  't',\n  'A',\n  '1',\n  'H',\n  'L',\n  'z',\n  'A',\n  'K',\n  'G',\n  '3',\n  '5',\n  '6',\n].join('');\n\nconst GRAPHQL_URL = ['https://', 'media-gateway.mlb.com', '/graphql'].join('');\n\nconst LINEAR_CHANNELS = [\n  {\n    enabled: false,\n    id: 'MLBTVBI',\n    name: 'MLB Big Inning',\n    tmsId: '119153',\n  },\n  {\n    enabled: false,\n    id: 'MLBN',\n    name: 'MLB Network',\n    tmsId: '62079',\n  },\n  {\n    enabled: false,\n    id: 'SNY',\n    name: 'SportsNet New York',\n    stationId: '49603',\n  },\n  {\n    enabled: false,\n    id: 'SNLA',\n    name: 'Spectrum SportsNet LA HD',\n    stationId: '87024',\n  },\n];\n\nconst generateThumb = (home: ITeam, away: ITeam): string =>\n  `https://img.mlbstatic.com/mlb-photos/image/upload/ar_167:215,c_crop/fl_relative,l_team:${home.team.id}:fill:spot.png,w_1.0,h_1,x_0.5,y_0,fl_no_overflow,e_distort:100p:0:200p:0:200p:100p:0:100p/fl_relative,l_team:${away.team.id}:logo:spot:current,w_0.38,x_-0.25,y_-0.16/fl_relative,l_team:${home.team.id}:logo:spot:current,w_0.38,x_0.25,y_0.16/w_750/team/${away.team.id}/fill/spot.png`;\n\nconst parseAirings = async (events: ICombinedGame) => {\n  const [now, endDate] = normalTimeRange();\n\n  const {meta} = await db.providers.findOneAsync<IProvider<TMLBTokens, IProviderMeta>>({name: 'mlbtv'});\n  const onlyFree = meta?.onlyFree ?? false;\n\n  for (const pk in events) {\n    if (!events[pk].feed || !events[pk].entry || events[pk].feed.blackedOutVideo) {\n      continue;\n    }\n\n    const event = events[pk].entry;\n    const eventFeed = events[pk].feed;\n\n    for (const epg of eventFeed.videoFeeds) {\n      if (epg.mediaId) {\n        const entryExists = await db.entries.findOneAsync<IEntry>({id: epg.mediaId});\n\n        if (!entryExists) {\n          if (onlyFree && !epg.freeGame) {\n            continue;\n          }\n\n          const start = moment(event.gameDate);\n          const end = moment(event.gameDate).add(4, 'hours');\n          const originalEnd = moment(event.gameDate).add(3, 'hours');\n\n          if (end.isBefore(now) || start.isAfter(endDate)) {\n            continue;\n          }\n\n          const gameName = `${event.teams.away.team.name} @ ${event.teams.home.team.name} - ${epg.mediaFeedType}`;\n\n          console.log('Adding event: ', gameName);\n\n          await db.entries.insertAsync<IEntry>({\n            categories: ['Baseball', 'MLB', event.teams.home.team.name, event.teams.away.team.name],\n            duration: end.diff(start, 'seconds'),\n            end: end.valueOf(),\n            from: 'mlbtv',\n            id: epg.mediaId,\n            image: generateThumb(event.teams.home, event.teams.away),\n            name: gameName,\n            network: epg.callLetters,\n            originalEnd: originalEnd.valueOf(),\n            sport: 'MLB',\n            start: start.valueOf(),\n          });\n        }\n      }\n    }\n  }\n};\n\nconst parseBigInnings = async (dates: Moment[][]) => {\n  const useLinear = await usesLinear();\n\n  const [now, endDate] = normalTimeRange();\n\n  for (const day of dates) {\n    const [start, end] = day;\n    const gameName = `Big Inning - ${start.format('dddd, MMMM Do YYYY')}`;\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: gameName});\n\n    if (start.isAfter(endDate) || end.isBefore(now) || entryExists) {\n      continue;\n    }\n\n    console.log('Adding event: ', gameName);\n\n    await db.entries.insertAsync<IEntry>({\n      categories: ['Baseball', 'MLB', 'Big Inning'],\n      duration: end.diff(start, 'seconds'),\n      end: end.valueOf(),\n      from: 'mlbtv',\n      id: gameName,\n      image: 'https://tmsimg.fancybits.co/assets/s119153_ll_h15_aa.png?w=360&h=270',\n      name: gameName,\n      network: 'MLBTVBI',\n      sport: 'MLB',\n      start: start.valueOf(),\n      ...(useLinear && {\n        channel: 'MLBTVBI',\n        linear: true,\n      }),\n    });\n  }\n};\n\nconst parseMlbNetwork = async (events: IMLBNetworkEvent[]): Promise<void> => {\n  const [now, endDate] = normalTimeRange();\n\n  for (const event of events) {\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `MLB Network - ${event.utcDate}`});\n\n    if (!entryExists) {\n      const start = moment(`${event.startdate} ${event.starttime}`, 'MM/DD/YYYY h:mm A');\n      const end = moment(`${event.enddate} ${event.endtime}`, 'MM/DD/YYYY h:mm A');\n\n      const duration = moment.duration(end.diff(start)).asSeconds();\n\n      if (end.isBefore(now) || start.isAfter(endDate)) {\n        continue;\n      }\n\n      let name = 'MLB Network Event';\n\n      if (event.episodetitle && event.seriestitle) {\n        name = `${event.seriestitle}: ${event.episodetitle}`;\n      } else if ((!event.episodetitle || event.episodetitle.length === 0) && event.seriestitle) {\n        name = event.seriestitle;\n      }\n\n      console.log('Adding event: ', name);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: ['MLB Network', 'MLB', 'Baseball'],\n        channel: 'MLBN',\n        duration,\n        end: end.valueOf(),\n        from: 'mlbtv',\n        id: `MLB Network - ${event.utcDate}`,\n        image: 'https://tmsimg.fancybits.co/assets/s62079_ll_h15_aa.png?w=360&h=270',\n        linear: true,\n        name,\n        network: 'MLBN',\n        sport: 'MLB',\n        start: start.valueOf(),\n      });\n    }\n  }\n};\n\nconst parseSny = async (events: TSNYEvent[]): Promise<void> => {\n  const [now, endDate] = normalTimeRange();\n\n  for (const event of events) {\n    const [, date, startTime, endTime, name] = event;\n\n    const eventStart = moment.tz(`${date} ${startTime}`, 'MM/DD/YYYY hh:mm A', 'America/New_York').startOf('minute');\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `SNY - ${eventStart.valueOf()}`});\n\n    if (!entryExists) {\n      const start = moment(eventStart);\n      const end = moment.tz(`${date} ${endTime}`, 'MM/DD/YYYY hh:mm A', 'America/New_York').startOf('minute');\n\n      if (startTime.includes('PM') && endTime.includes('AM')) {\n        end.add(1, 'day');\n      }\n\n      const duration = moment.duration(end.diff(start)).asSeconds();\n\n      if (end.isBefore(now) || start.isAfter(endDate)) {\n        continue;\n      }\n\n      console.log('Adding event: ', name);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: ['SNY'],\n        channel: 'SNY',\n        duration,\n        end: end.valueOf(),\n        from: 'mlbtv',\n        id: `SNY - ${eventStart.valueOf()}`,\n        image: 'https://tmsimg.fancybits.co/assets/s49603_ll_h9_aa.png?w=360&h=270',\n        linear: true,\n        name,\n        network: 'SNY',\n        sport: 'MLB',\n        start: start.valueOf(),\n      });\n    }\n  }\n};\n\nconst parseSnla = async (events: ISNLAEventCombined[]): Promise<void> => {\n  const [now, endDate] = normalTimeRange();\n\n  for (const event of events) {\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `SNLA - ${event.startTime}`});\n\n    if (!entryExists) {\n      const start = moment(event.startTime);\n      const end = moment(event.endTime);\n\n      const duration = moment.duration(end.diff(start)).asSeconds();\n\n      if (end.isBefore(now) || start.isAfter(endDate)) {\n        continue;\n      }\n\n      console.log('Adding event: ', event.title);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: ['SNLA'],\n        channel: 'SNLA',\n        duration,\n        end: end.valueOf(),\n        from: 'mlbtv',\n        id: `SNLA - ${event.startTime}`,\n        image: event.thumbnail,\n        linear: true,\n        name: event.title,\n        network: 'SNLA',\n        sport: 'MLB',\n        start: start.valueOf(),\n      });\n    }\n  }\n};\n\nconst COMMON_HEADERS = {\n  'cache-control': 'no-cache',\n  origin: 'https://www.mlb.com',\n  pragma: 'no-cache',\n  priority: 'u=1, i',\n  referer: 'https://www.mlb.com/',\n  'sec-ch-ua': '\"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\", \"Not-A.Brand\";v=\"8\"',\n  'sec-ch-ua-mobile': '?0',\n  'sec-ch-ua-platform': '\"macOS\"',\n  'sec-fetch-dest': 'empty',\n  'sec-fetch-mode': 'cors',\n  'sec-fetch-site': 'same-site',\n  'user-agent': userAgent,\n};\n\nconst mlbConfigPath = path.join(configPath, 'mlb_tokens.json');\n\nclass MLBHandler {\n  public device_id?: string;\n  public refresh_token?: string;\n  public expires_at?: number;\n  public access_token?: string;\n  public session_id?: string;\n  public entitlements?: IEntitlement[];\n\n  private playback_token?: string;\n  private playback_token_exp?: Moment;\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'mlbtv'})) > 0 ? true : false;\n\n    if (!setup) {\n      const data: TMLBTokens = {};\n\n      if (useMLBtv) {\n        this.loadJSON();\n\n        data.access_token = this.access_token;\n        data.device_id = this.device_id;\n        data.expires_at = this.expires_at;\n        data.refresh_token = this.refresh_token;\n        data.session_id = this.session_id;\n      }\n\n      await db.providers.insertAsync<IProvider<TMLBTokens, IProviderMeta>>({\n        enabled: useMLBtv,\n        linear_channels: LINEAR_CHANNELS,\n        meta: {\n          onlyFree: useMLBtvOnlyFree,\n        },\n        name: 'mlbtv',\n        tokens: data,\n      });\n\n      if (fs.existsSync(mlbConfigPath)) {\n        fs.rmSync(mlbConfigPath);\n      }\n    }\n\n    if (useMLBtv) {\n      console.log('Using MLBTV variable is no longer needed. Please use the UI going forward');\n    }\n    if (useMLBtvOnlyFree) {\n      console.log('Using MLBTV_ONLY_FREE variable is no longer needed. Please use the UI going forward');\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider<TMLBTokens>>({name: 'mlbtv'});\n\n    if (!enabled) {\n      return;\n    }\n\n    await this.load();\n\n    // Fix for me being a silly goose!\n    const {linear_channels} = await db.providers.findOneAsync<IProvider<TMLBTokens>>({name: 'mlbtv'});\n\n    if (linear_channels.length < 4) {\n      await db.providers.updateAsync({name: 'mlbtv'}, {$set: {linear_channels: LINEAR_CHANNELS}});\n\n      await this.checkMlbBigInningAccess();\n      await this.checkMlbNetworkAccess();\n      await this.checkSnyAccess();\n      await this.checkSnlaAccess();\n    }\n  };\n\n  public refreshTokens = async () => {\n    const {enabled} = await db.providers.findOneAsync<IProvider<TMLBTokens>>({name: 'mlbtv'});\n\n    if (!enabled) {\n      return;\n    }\n\n    if (!this.expires_at || moment(this.expires_at).isBefore(moment().add(30, 'minutes'))) {\n      await this.refreshToken();\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider<TMLBTokens, IProviderMeta>>({name: 'mlbtv'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log('Looking for MLB.tv events...');\n\n    try {\n      const entries = await this.getEvents();\n      const feeds = await this.getFeeds();\n\n      debug.saveRequestData(entries, 'mlb', 'entries');\n      debug.saveRequestData(feeds, 'mlb', 'feeds');\n\n      const combinedEntries: ICombinedGame = {};\n\n      for (const feed of feeds) {\n        if (!combinedEntries[feed.gamePk]) {\n          combinedEntries[feed.gamePk] = {};\n        }\n\n        combinedEntries[feed.gamePk] = {\n          ...combinedEntries[feed.gamePk],\n          feed,\n        };\n      }\n\n      for (const entry of entries) {\n        if (!combinedEntries[entry.gamePk]) {\n          combinedEntries[entry.gamePk] = {};\n        }\n\n        combinedEntries[entry.gamePk] = {\n          ...combinedEntries[entry.gamePk],\n          entry,\n        };\n      }\n\n      await parseAirings(combinedEntries);\n\n      const bigInningsEnabled = await this.checkMlbBigInningAccess();\n\n      if (bigInningsEnabled) {\n        const bigInnings = await this.getBigInnings();\n        await parseBigInnings(bigInnings);\n      }\n\n      const mlbNetworkEnabled = await this.checkMlbNetworkAccess();\n\n      if (mlbNetworkEnabled) {\n        const mlbNetworkSchedule = await this.getMlbNetworkSchedule();\n        await parseMlbNetwork(mlbNetworkSchedule);\n      }\n\n      const snyEnabled = await this.checkSnyAccess();\n\n      if (snyEnabled) {\n        const snyEvents = await this.getSnySchedule();\n        await parseSny(snyEvents);\n      }\n\n      const snlaEnabled = await this.checkSnlaAccess();\n\n      if (snlaEnabled) {\n        const snlaEvents = await this.getSnlaSchedule();\n        await parseSnla(snlaEvents);\n      }\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse MLB.tv events');\n    }\n  };\n\n  public getEventData = async (mediaId: string, adCapabilities = 'NONE'): Promise<TChannelPlaybackInfo> => {\n    try {\n      await this.getSession();\n\n      if (mediaId.indexOf('Big Inning - ') > -1) {\n        const streamInfoUrl = await this.getBigInningInfo();\n        const streamUrl = await this.getBigInningStream(streamInfoUrl);\n\n        return [streamUrl, {}];\n      } else if (mediaId.indexOf('MLB Network - ') > -1) {\n        const streamUrl = await this.getMlbNetworkStream();\n\n        return [streamUrl, {}];\n      } else if (mediaId.indexOf('SNY - ') > -1) {\n        return this.getStream('SNY_LIVE');\n      } else if (mediaId.indexOf('SNLA - ') > -1) {\n        return this.getStream('SNLA_LIVE');\n      }\n\n      const params = {\n        operationName: 'initPlaybackSession',\n        query:\n          'mutation initPlaybackSession(\\n        $adCapabilities: [AdExperienceType]\\n        $mediaId: String!\\n        $deviceId: String!\\n        $sessionId: String!\\n        $quality: PlaybackQuality\\n    ) {\\n        initPlaybackSession(\\n            adCapabilities: $adCapabilities\\n            mediaId: $mediaId\\n            deviceId: $deviceId\\n            sessionId: $sessionId\\n            quality: $quality\\n        ) {\\n            playbackSessionId\\n            playback {\\n                url\\n                token\\n                expiration\\n                cdn\\n            }\\n            adScenarios {\\n                adParamsObj\\n                adScenarioType\\n                adExperienceType\\n            }\\n            adExperience {\\n                adExperienceTypes\\n                adEngineIdentifiers {\\n                    name\\n                    value\\n                }\\n                adsEnabled\\n            }\\n            heartbeatInfo {\\n                url\\n                interval\\n            }\\n            trackingObj\\n        }\\n    }',\n        variables: {\n          adCapabilities: [adCapabilities],\n          deviceId: this.device_id,\n          mediaId,\n          quality: 'PLACEHOLDER',\n          sessionId: this.session_id,\n        },\n      };\n\n      const {data} = await axios.post(GRAPHQL_URL, params, {\n        headers: {\n          ...COMMON_HEADERS,\n          ...this.getGraphQlHeaders(),\n        },\n      });\n\n      const playbackUrl = data.data.initPlaybackSession.playback.url;\n      const token = data.data.initPlaybackSession.playback.token;\n\n      if (token) {\n        this.playback_token = token;\n        this.playback_token_exp = moment(data.data.initPlaybackSession.playback.expiration);\n      }\n\n      return [\n        playbackUrl,\n        {\n          accept: 'application/json, text/plain, */*',\n          'accept-encoding': 'identity',\n          'accept-language': 'en-US,en;q=0.5',\n          connection: 'keep-alive',\n        },\n      ];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start playback');\n    }\n  };\n\n  private updateChannelAccess = async (index: number, enabled: boolean): Promise<void> => {\n    const {linear_channels} = await db.providers.findOneAsync<IProvider<TMLBTokens>>({name: 'mlbtv'});\n\n    const updatedChannels = linear_channels.map((c, i) => {\n      if (i !== index) {\n        return c;\n      }\n\n      c.enabled = enabled;\n      return c;\n    });\n\n    await db.providers.updateAsync({name: 'mlbtv'}, {$set: {linear_channels: updatedChannels}});\n  };\n\n  private checkMlbBigInningAccess = async (): Promise<boolean> => {\n    if (!this.entitlements) {\n      await this.getSession();\n    }\n\n    let enabled = false;\n\n    if ((this.entitlements || [])?.length > 0) {\n      enabled = true;\n    }\n\n    await this.updateChannelAccess(0, enabled);\n\n    return enabled;\n  };\n\n  private getBigInningInfo = async (): Promise<string> => {\n    try {\n      const url = ['https://', 'dapi.mlbinfra.com', '/v2', '/content', '/en-us', '/vsmcontents', '/big-inning'].join(\n        '',\n      );\n\n      const {data} = await axios.get(url, {\n        headers: {\n          'User-Agent': androidMlbUserAgent,\n        },\n      });\n\n      if (data.references?.video.length > 0) {\n        return data.references.video[0].fields.url;\n      } else {\n        throw new Error('Big Inning data not ready yet');\n      }\n    } catch (e) {\n      console.error(e);\n      console.log('Big Inning data not ready yet');\n    }\n  };\n\n  private getBigInningStream = async (url: string): Promise<string> => {\n    try {\n      const {data} = await axios.get(url, {\n        headers: {\n          Authorization: `Bearer ${this.access_token}`,\n          'User-Agent': androidMlbUserAgent,\n        },\n      });\n\n      return data.data[0].value;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get Big Inning stream info');\n    }\n  };\n\n  private getBigInnings = async (): Promise<Moment[][]> => {\n    const bigInnings: Moment[][] = [];\n\n    try {\n      const {data} = await axios.get(\n        'https://watch.product.api.espn.com/api/product/v3/watchespn/web/catalog/ae4eb028-0af3-42e7-8965-9304c5817969?lang=en&features=continueWatching%2Csfb-all%2Cpbov7%2Chigh-volume-row%2Csc4u%2Cguide-menu-header%2Ccutl%2Cheader-quickserve%2Cautoplay%2Cwatch-web-redesign%2CimageRatio58x13%2CpromoTiles%2CopenAuthz%2Cvideo-header%2Cexplore-row%2Cbutton-service%2Cinline-header%2Cflagship&deviceBrand=web&streamMenu=true&headerBgImageWidth=1280&countryCode=US&entitlements=no&tz=UTC-0400&userab=espn_watch_for_you_web-392*watch-fy-a-1642', {\n        headers: {\n          'Accept': '*/*',\n          'Accept-Encoding': 'gzip, deflate, br, zstd',\n          'Origin': 'https://www.espn.com',\n          'Referer': 'https://www.espn.com/',\n          'User-Agent': userAgent,\n        },\n      });\n\n      data.page.buckets[0].contents.forEach(c => {\n        c.streams.some(s => {\n          const start = moment(c.utc);\n          const end = moment(c.utc).add(s.durationInSeconds, 'seconds');\n\n          bigInnings.push([start, end]);\n          return true;\n        });\n      });\n\n      return bigInnings;\n    } catch (e) {\n      // console.error(e);\n      console.log('Could not get Big Inning data');\n    }\n  };\n\n  public checkMlbNetworkAccess = async (getEntitlements = false): Promise<boolean> => {\n    if (!this.entitlements || getEntitlements) {\n      await this.getSession();\n    }\n\n    const useLinear = await usesLinear();\n\n    let enabled = false;\n\n    if (this.entitlements?.some(n => n.code === 'MLBN') && useLinear) {\n      enabled = true;\n    }\n\n    await this.updateChannelAccess(1, enabled);\n\n    return enabled;\n  };\n\n  private getMlbNetworkSchedule = async (): Promise<IMLBNetworkEvent[]> => {\n    try {\n      const url = 'https://mlbn.mlbstatic.com/schedule.json';\n\n      const {data} = await axios.get<{shows: IMLBNetworkEvent[]}>(url, {\n        headers: {\n          'User-Agent': userAgent,\n          'x-requested-with': 'com.bamnetworks.mobile.android.gameday.atbat',\n        },\n      });\n\n      return data.shows;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get MLB Network schedule');\n    }\n  };\n\n  private getMlbNetworkStream = async (): Promise<string> => {\n    try {\n      const url = ['https://', 'falcon.mlbinfra.com', '/api/v1/', 'mvpds/mlbn/feeds'].join('');\n\n      const {data} = await axios.get(url, {\n        headers: {\n          Authorization: `Bearer ${this.access_token}`,\n          'User-Agent': userAgent,\n        },\n      });\n\n      return data.url;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get MLB Network stream info');\n    }\n  };\n\n  public checkSnyAccess = async (getEntitlements = false): Promise<boolean> => {\n    if (!this.entitlements || getEntitlements) {\n      await this.getSession();\n    }\n\n    const useLinear = await usesLinear();\n\n    let enabled = false;\n\n    if (this.entitlements?.some(n => n.code === 'SNY_121') && useLinear) {\n      enabled = true;\n    }\n\n    await this.updateChannelAccess(2, enabled);\n\n    return enabled;\n  };\n\n  private getSnySchedule = async (): Promise<TSNYEvent[]> => {\n    let events: TSNYEvent[] = [];\n\n    try {\n      const url = ['https://', 'production-api.sny.tv', '/production/', 'api/cms', '/schedule'].join('');\n\n      const {data} = await axios.get<{schedule: ISNYSchedule}>(url, {\n        headers: {\n          'user-agent': userAgent,\n        },\n      });\n\n      const [now] = normalTimeRange();\n\n      for (const addDay of [0, 1, 2]) {\n        const momentDate = moment(now).add(addDay, 'day');\n        const formattedDate = momentDate.format('MM/DD/YYYY');\n\n        const scheduleDay = data.schedule[formattedDate];\n\n        if (scheduleDay) {\n          events = [...events, ...scheduleDay.data.rows];\n        }\n      }\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get SNY schedule');\n    }\n\n    return events;\n  };\n\n  private getStream = async (network: string): Promise<TChannelPlaybackInfo> => {\n    try {\n      const params = {\n        operationName: 'contentCollections',\n        query:\n          'query contentCollections(\\n        $categories: [ContentGroupCategory!]\\n        $includeRestricted: Boolean = false\\n        $includeSpoilers: Boolean = false\\n        $limit: Int = 10,\\n        $skip: Int = 0\\n    ) {\\n        contentCollections(\\n            categories: $categories\\n            includeRestricted: $includeRestricted\\n            includeSpoilers: $includeSpoilers\\n            limit: $limit\\n            skip: $skip\\n        ) {\\n            title\\n            category\\n            contents {\\n                assetTrackingKey\\n                contentDate\\n                contentId\\n                contentRestrictions\\n                description\\n                duration\\n                language\\n                mediaId\\n                officialDate\\n                title\\n                mediaState {\\n                    state\\n                    mediaType\\n                }\\n                thumbnails {\\n                    thumbnailType\\n                    templateUrl\\n                    thumbnailUrl\\n                }\\n            }\\n        }\\n    }',\n        variables: {\n          categories: [network],\n          limit: 25,\n        },\n      };\n      const {data} = await axios.post(GRAPHQL_URL, params, {\n        headers: {\n          ...COMMON_HEADERS,\n          ...this.getGraphQlHeaders(),\n        },\n      });\n\n      const availableStreams = data?.data?.contentCollections?.[0]?.contents;\n\n      let [url, headers]: Partial<TChannelPlaybackInfo> = [, {}];\n      let hasValidStream = false;\n\n      for (const stream of availableStreams) {\n        if (hasValidStream) {\n          continue;\n        }\n\n        try {\n          [url, headers] = await this.getEventData(stream.mediaId);\n\n          await axios.get(url, {\n            headers: {\n              ...headers,\n            },\n          });\n\n          hasValidStream = true;\n        } catch (e) {}\n      }\n\n      if (hasValidStream && url) {\n        return [url, headers];\n      }\n\n      throw new Error(`Could not find stream for ${network}!`);\n    } catch (e) {\n      console.log(`Could not find stream for ${network}!`);\n    }\n  };\n\n  public checkSnlaAccess = async (getEntitlements = false): Promise<boolean> => {\n    if (!this.entitlements || getEntitlements) {\n      await this.getSession();\n    }\n\n    const useLinear = await usesLinear();\n\n    let enabled = false;\n\n    if (this.entitlements?.some(n => n.code === 'SNLA_119') && useLinear) {\n      enabled = true;\n    }\n\n    await this.updateChannelAccess(3, enabled);\n\n    return enabled;\n  };\n\n  private getSnlaSchedule = async (): Promise<ISNLAEventCombined[]> => {\n    const snlaEvents: ISNLAEventCombined[] = [];\n\n    try {\n      const url = [\n        'https://',\n        'spectrumsportsnet.com',\n        '/services/sports',\n        '/v1/schedule-data',\n        '.networkId_87024',\n      ].join('');\n\n      const {data} = await axios.get<{87024: ISNLAScheduleRes}>(url, {\n        headers: {\n          'user-agent': userAgent,\n        },\n      });\n\n      const {programs, events} = data[87024];\n\n      events.forEach(e => {\n        if (programs[e.programId]) {\n          snlaEvents.push({\n            ...e,\n            ...programs[e.programId],\n          });\n        }\n      });\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get SNLA schedule');\n    }\n\n    return snlaEvents;\n  };\n\n  private getEvents = async (): Promise<any[]> => {\n    let entries = [];\n\n    try {\n      const [startDate, endDate] = normalTimeRange();\n\n      const url = [\n        'https://statsapi.mlb.com',\n        '/api/v1/schedule',\n        '?hydrate=game(content(media(all))),team,flags,gameInfo',\n        '&sportId=1',\n        `&startDate=${startDate.format('YYYY-MM-DD')}`,\n        `&endDate=${endDate.format('YYYY-MM-DD')}`,\n      ].join('');\n\n      const {data} = await axios.get<ISchedule>(url, {\n        headers: {\n          'User-Agent': okHttpUserAgent,\n        },\n      });\n\n      data.dates.forEach(date => (entries = [...entries, ...date.games]));\n    } catch (e) {\n      throw new Error(e);\n    }\n\n    return entries;\n  };\n\n  private getFeeds = async (): Promise<IGameFeed[]> => {\n    try {\n      const [startDate, endDate] = normalTimeRange();\n\n      const url = [\n        'https://mastapi.mobile.mlbinfra.com/api/epg/v3/search?exp=MLB',\n        `&startDate=${startDate.format('YYYY-MM-DD')}`,\n        `&endDate=${endDate.format('YYYY-MM-DD')}`,\n      ].join('');\n\n      let oktaToken: string | undefined;\n\n      try {\n        oktaToken = await this.getOktaToken();\n      } catch (e) {}\n\n      const {data} = await axios.get<{results: IGameFeed[]}>(url, {\n        headers: {\n          accept: '*/*',\n          'accept-language': 'en-US,en;q=0.9',\n          'content-type': 'application/json',\n          ...(this.access_token &&\n            oktaToken && {\n              authorization: 'Bearer ' + this.access_token,\n              'x-okta-id': oktaToken,\n            }),\n        },\n      });\n\n      return data.results;\n    } catch (e) {\n      throw new Error(e);\n    }\n  };\n\n  private getOktaToken = async (): Promise<string | undefined> => {\n    if (!this.playback_token || !this.playback_token_exp || moment().isAfter(this.playback_token_exp)) {\n      await this.getEventData('b7f0fff7-266f-4171-aa2d-af7988dc9302');\n    }\n\n    const encoded_okta_id = this.playback_token.split('_')[1];\n\n    if (encoded_okta_id && encoded_okta_id.length > 0) {\n      return Buffer.from(`${encoded_okta_id}==`, 'base64').toString('ascii');\n    }\n  };\n\n  private refreshToken = async (): Promise<void> => {\n    try {\n      const url = 'https://ids.mlb.com/oauth2/aus1m088yK07noBfh356/v1/token';\n      const headers = {\n        'User-Agent': androidMlbUserAgent,\n        'accept-language': 'en',\n        'content-type': 'application/x-www-form-urlencoded',\n      };\n\n      const params = new URLSearchParams({\n        client_id: CLIENT_ID,\n        grant_type: 'refresh_token',\n        refresh_token: this.refresh_token,\n        scope: 'offline_access openid profile',\n      });\n\n      const {data} = await axios.post(url, params, {\n        headers,\n      });\n\n      this.access_token = data.access_token;\n      this.expires_at = moment().add(data.expires_in, 'seconds').valueOf();\n      this.refresh_token = data.refresh_token;\n\n      await this.save();\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get refresh token for MLB.tv');\n    }\n  };\n\n  public authenticateRegCode = async (): Promise<boolean> => {\n    try {\n      const url = 'https://ids.mlb.com/oauth2/aus1m088yK07noBfh356/v1/token';\n      const headers = {\n        'User-Agent': androidMlbUserAgent,\n        'accept-language': 'en',\n        'content-type': 'application/x-www-form-urlencoded',\n      };\n\n      const params = new URLSearchParams({\n        client_id: CLIENT_ID,\n        device_code: this.device_id,\n        grant_type: 'urn:ietf:params:oauth:grant-type:device_code',\n      });\n\n      const {data} = await axios.post(url, params, {\n        headers,\n      });\n\n      this.access_token = data.access_token;\n      this.expires_at = moment().add(data.expires_in, 'seconds').valueOf();\n      this.refresh_token = data.refresh_token;\n\n      await this.save();\n\n      await this.getSession();\n\n      await this.checkMlbBigInningAccess();\n      await this.checkMlbNetworkAccess();\n      await this.checkSnyAccess();\n      await this.checkSnlaAccess();\n      await this.getOktaToken();\n\n      return true;\n    } catch (e) {\n      return false;\n    }\n  };\n\n  public getAuthCode = async (): Promise<string> => {\n    try {\n      const url = 'https://ids.mlb.com/oauth2/aus1m088yK07noBfh356/v1/device/authorize';\n      const headers = {\n        'User-Agent': androidMlbUserAgent,\n        'accept-language': 'en',\n        'content-type': 'application/x-www-form-urlencoded',\n      };\n\n      const params = new URLSearchParams({\n        client_id: CLIENT_ID,\n        scope: 'openid profile offline_access',\n      });\n\n      const {data} = await axios.post(url, params, {\n        headers,\n      });\n\n      this.device_id = data.device_code;\n\n      return data.user_code;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start the authentication process for MLB.tv');\n    }\n  };\n\n  private getSession = async (): Promise<void> => {\n    try {\n      const params = {\n        operationName: 'initSession',\n        query:\n          'mutation initSession($device: InitSessionInput!, $clientType: ClientType!, $experience: ExperienceTypeInput) {\\n    initSession(device: $device, clientType: $clientType, experience: $experience) {\\n        deviceId\\n        sessionId\\n        entitlements {\\n            code\\n        }\\n        location {\\n            countryCode\\n            regionName\\n            zipCode\\n            latitude\\n            longitude\\n        }\\n        clientExperience\\n        features\\n    }\\n  }',\n        variables: {\n          clientType: 'WEB',\n          device: {\n            appVersion: '7.8.1',\n            deviceFamily: 'desktop',\n            knownDeviceId: this.device_id,\n            languagePreference: 'ENGLISH',\n            manufacturer: 'Apple',\n            model: 'Macintosh',\n            os: 'macos',\n            osVersion: '10.15',\n          },\n        },\n      };\n\n      const {data} = await axios.post(GRAPHQL_URL, params, {\n        headers: {\n          ...COMMON_HEADERS,\n          ...this.getGraphQlHeaders(),\n        },\n      });\n\n      this.session_id = data.data.initSession.sessionId;\n      this.entitlements = data.data.initSession.entitlements;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get session id');\n    }\n  };\n\n  private getGraphQlHeaders = () => ({\n    accept: 'application/json, text/plain, */*',\n    'accept-encoding': 'gzip, deflate, br',\n    'accept-language': 'en-US,en;q=0.5',\n    authorization: 'Bearer ' + this.access_token,\n    connection: 'keep-alive',\n    'content-type': 'application/json',\n    'x-client-name': 'WEB',\n    'x-client-version': '7.8.1',\n  });\n\n  private save = async () => {\n    await db.providers.updateAsync(\n      {name: 'mlbtv'},\n      {$set: {tokens: _.omit(this, 'entitlements', 'session_id', 'playback_token', 'playback_token_exp')}},\n    );\n  };\n\n  private load = async (): Promise<void> => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TMLBTokens>>({name: 'mlbtv'});\n    const {device_id, access_token, expires_at, refresh_token} = tokens;\n\n    this.device_id = device_id;\n    this.access_token = access_token;\n    this.expires_at = expires_at;\n    this.refresh_token = refresh_token;\n  };\n\n  private loadJSON = () => {\n    if (fs.existsSync(mlbConfigPath)) {\n      const {device_id, access_token, expires_at, refresh_token} = fsExtra.readJSONSync(mlbConfigPath);\n\n      this.device_id = device_id;\n      this.access_token = access_token;\n      this.expires_at = expires_at;\n      this.refresh_token = refresh_token;\n    }\n  };\n}\n\nexport type TMLBTokens = ClassTypeWithoutMethods<MLBHandler>;\n\nexport const mlbHandler = new MLBHandler();\n"
  },
  {
    "path": "services/mw-handler.ts",
    "content": "import axios from 'axios';\nimport _ from 'lodash';\nimport moment from 'moment';\n\nimport {okHttpUserAgent} from './user-agent';\nimport {useMountainWest} from './networks';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {debug} from './debug';\nimport {normalTimeRange, generateRandom} from './shared-helpers';\n\ninterface IMWCategory {\n  name: string;\n}\n\ninterface IMWEvent {\n  image: {\n    image: string;\n  };\n  title: string;\n  start_date: string;\n  start_time: string\n  end_date: string;\n  end_time: string;\n  video?: {\n    data?: {\n      url?: string;\n    };\n  };\n  sport_categories: IMWCategory[];\n  id: string;\n  post_format: string;\n}\n\nconst time_zone = 'America/Denver';\n\nconst parseAirings = async (events: IMWEvent[]) => {\n  const [now, endSchedule] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.id) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `mw-${event.id}`});\n\n    if (!entryExists) {\n      const start = moment.tz([event.start_date, event.start_time].join(' '), time_zone);\n      const end = moment.tz([event.end_date, event.end_time].join(' '), time_zone).add(1, 'hours');\n      const originalEnd = moment.tz([event.end_date, event.end_time].join(' '), time_zone);\n\n      if (end.isBefore(now) || event.post_format !== 'video' || start.isAfter(endSchedule)) {\n        continue;\n      }\n\n      console.log('Adding event: ', event.title);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: [...new Set(['Mountain West', 'The MW', event.sport_categories[0].name])],\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'mountain-west',\n        id: `mw-${event.id}`,\n        image: event.image.image,\n        name: event.title,\n        network: 'MW',\n        originalEnd: originalEnd.valueOf(),\n        sport: event.sport_categories[0].name,\n        start: start.valueOf(),\n        url: event.video.data.url,\n      });\n    }\n  }\n};\n\nclass MountainWestHandler {\n  public user?: string;\n  public token?: string;\n  \n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'mw'})) > 0 ? true : false;\n\n    // First time setup\n    if (!setup) {\n      const data: TMWTokens = {};\n      \n      await db.providers.insertAsync<IProvider<TMWTokens>>({\n        enabled: useMountainWest,\n        name: 'mw',\n        tokens: data,\n      });\n    }\n\n    if (useMountainWest) {\n      console.log('Using MTNWEST variable is no longer needed. Please use the UI going forward');\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'mw'});\n\n    if (!enabled) {\n      return;\n    }\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n    \n    // Register user if token doesn't exist\n    if (!this.token) {\n      await this.registerUser();\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'mw'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log('Looking for Mountain West events...');\n    \n    const events: IMWEvent[] = [];\n      \n    const mountain_time = moment.tz(time_zone).format('YYYY-MM-DD HH:mm:ss');\n\n    const [now, inTwoDays] = normalTimeRange();\n\n    try {\n      let pages = 1;\n\n      for (let page = 1; page <= pages; page++) {\n        const url = [\n          'https://',\n          'mobile.',\n          'themw.',\n          'wmt.',\n          'digital',\n          '/api/tv/',\n          'videos',\n          '?order_by%5Bfield%5D=start_date_time',\n          '&order_by%5Bordering%5D=asc',\n          '&page=',\n          page,\n          '&per_page=25',\n          '&end_after=',\n          encodeURIComponent(mountain_time),\n        ].join('');\n        \n        const {data} = await axios.get(url, {\n          headers: {\n             authorization: `Bearer ${this.token}`,\n            'user-agent': okHttpUserAgent,\n          },\n        });\n        \n        debug.saveRequestData(data, 'mtnwest', 'epg');\n        \n        pages = data.meta.pagination.total_pages;\n\n        _.forEach(data.data, m => {\n          let event_start = moment.tz([m.start_date, m.start_time].join(' '), 'America/Denver');\n          if ( event_start <= inTwoDays ) {\n            events.push(m);\n          } else {\n            pages = page;\n          }\n        });\n      }\n\n      await parseAirings(events);\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse Mountain West events');\n    }\n  };\n\n  public getEventData = async (id: string): Promise<TChannelPlaybackInfo> => {\n    try {\n      const event = await db.entries.findOneAsync<IEntry>({id});\n\n      if (event) {\n        return [event.url, {}];\n      }\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get event data');\n    }\n  };\n\n  public registerUser = async (): Promise<boolean> => {\n    const url = [\n      'https://',\n      'mobile.',\n      'themw.',\n      'wmt.',\n      'digital',\n      '/api',\n      '/tv',\n      '/auth',\n      '/register',\n     ].join('');\n\n    console.log('Registering user for Mountain West...');\n    try {\n      const randomId = generateRandom(18);\n      \n      const {data} = await axios.post(\n        url,\n        {\n          email: [randomId, '@domain.com'].join(''),\n          name: randomId,\n          password: randomId, \n          password_confirmation: randomId,\n        },\n        {\n          headers: {\n            'Content-Type': 'application/json',\n            'User-Agent': okHttpUserAgent,\n          },\n        },\n      );\n\n      if (!data || !data?.token) {\n        return false;\n      }\n\n      this.user = randomId;\n      this.token = data.token;\n\n      await this.save();\n\n      return true;\n    } catch (e) {\n      return false;\n    }\n  };\n\n  private save = async (): Promise<void> => {\n    await db.providers.updateAsync({name: 'mw'}, {$set: {tokens: this}});\n  };\n\n  private load = async (): Promise<void> => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TMWTokens>>({name: 'mw'});\n    const {user, token} = tokens || {};\n\n    this.user = user;\n    this.token = token;\n  };\n}\n\nexport type TMWTokens = ClassTypeWithoutMethods<MountainWestHandler>;\n\nexport const mwHandler = new MountainWestHandler();\n"
  },
  {
    "path": "services/networks.ts",
    "content": "export const useEspn1 = process.env.ESPN?.toLowerCase() === 'true' ? true : false;\nexport const useEspn2 = process.env.ESPN2?.toLowerCase() === 'true' ? true : false;\nexport const useEspn3 = process.env.ESPN3?.toLowerCase() === 'true' ? true : false;\nexport const useEspnU = process.env.ESPNU?.toLowerCase() === 'true' ? true : false;\nexport const useSec = process.env.SEC?.toLowerCase() === 'true' ? true : false;\nexport const useSecPlus = process.env.SECPLUS?.toLowerCase() === 'true' ? true : false;\nexport const useAccN = process.env.ACCN?.toLowerCase() === 'true' ? true : false;\nexport const useAccNx = process.env.ACCNX?.toLowerCase() === 'true' ? true : false;\nexport const useEspnews = process.env.ESPNEWS?.toLowerCase() === 'true' ? true : false;\nexport const useEspnPpv = process.env.ESPN_PPV?.toLowerCase() === 'true' ? true : false;\nexport const useEspnPlus = process.env.ESPNPLUS?.toLowerCase() === 'true' ? true : false;\n\nexport const useFoxSports = process.env.FOXSPORTS?.toLowerCase() === 'true' ? true : false;\nexport const useFoxOnly4k = process.env.FOX_ONLY_4K?.toLowerCase() === 'true' ? true : false;\n\nexport const useFoxOne = process.env.FOXONE?.toLowerCase() === 'true' ? true : false;\nexport const useFoxOneOnly4k = process.env.FOXONE_ONLY_4K?.toLowerCase() === 'true' ? true : false;\n\n\nexport const useMLBtv = process.env.MLBTV?.toLowerCase() === 'true' ? true : false;\nexport const useMLBtvOnlyFree = process.env.MLBTV_ONLY_FREE?.toLowerCase() === 'true' ? true : false;\n\nexport const useB1GPlus = process.env.B1GPLUS?.toLowerCase() === 'true' ? true : false;\n\nexport const useCBSSports = process.env.CBSSPORTS?.toLowerCase() === 'true' ? true : false;\n\nexport const useFloSports = process.env.FLOSPORTS?.toLowerCase() === 'true' ? true : false;\n\nexport const useParamount = {\n  _cbsSportsHq: process.env.CBSSPORTSHQ?.toLowerCase() === 'true' ? true : false,\n  _golazo: process.env.GOLAZO?.toLowerCase() === 'true' ? true : false,\n  get cbsSportsHq(): boolean {\n    return this._cbsSportsHq && this.plus;\n  },\n  get golazo(): boolean {\n    return this._golazo && this.plus;\n  },\n  plus: process.env.PARAMOUNTPLUS?.toLowerCase() === 'true' ? true : false,\n};\n\nexport const useNfl = {\n  _channel: process.env.NFLCHANNEL?.toLowerCase() === 'true' ? true : false,\n  _network: process.env.NFLNETWORK?.toLowerCase() === 'true' ? true : false,\n  _peacock: process.env.NFL_PEACOCK?.toLowerCase() === 'true' ? true : false,\n  _prime: process.env.NFL_PRIME?.toLowerCase() === 'true' ? true : false,\n  _redZone: false,\n  _sunday_ticket: process.env.NFL_SUNDAY_TICKET?.toLowerCase() === 'true' ? true : false,\n  _tve: process.env.NFL_TVE?.toLowerCase() === 'true' ? true : false,\n  get channel(): boolean {\n    return this._channel && this.plus;\n  },\n  get network(): boolean {\n    return this._network && this.plus;\n  },\n  set network(value: boolean) {\n    this._network = value;\n  },\n  get peacock(): boolean {\n    return this._peacock && this.plus;\n  },\n  plus: process.env.NFLPLUS?.toLowerCase() === 'true' ? true : false,\n  get prime(): boolean {\n    return this._prime && this.plus;\n  },\n  get redZone(): boolean {\n    return this._redZone && this.plus;\n  },\n  set redZone(value: boolean) {\n    this._redZone = value;\n  },\n  get sundayTicket(): boolean {\n    return this._sunday_ticket && this.plus;\n  },\n  get tve(): boolean {\n    return this._tve && this.plus;\n  },\n};\n\nexport const useMountainWest = process.env.MTNWEST?.toLowerCase() === 'true' ? true : false;\n\nexport const requiresEspnProvider =\n  useEspn1 || useEspn2 || useEspn3 || useEspnU || useSec || useSecPlus || useAccN || useAccNx || useEspnews;\n"
  },
  {
    "path": "services/nfl-handler.ts",
    "content": "import fs from 'fs';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport moment from 'moment';\nimport jwt_decode from 'jwt-decode';\n\nimport {okHttpUserAgent} from './user-agent';\nimport {configPath} from './config';\nimport {useNfl} from './networks';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {getRandomUUID, normalTimeRange} from './shared-helpers';\nimport {debug} from './debug';\nimport {usesLinear} from './misc-db-service';\n\ninterface INFLRes {\n  data: {\n    items: INFLEvent[];\n  };\n}\n\ninterface INFLChannelRes {\n  items: INFLEvent[];\n}\n\ninterface INFLEvent {\n  authorizations: {\n    [key: string]: any;\n  };\n  title: string;\n  startTime: string;\n  endTime: string;\n  preferredImage: string;\n  externalId: string;\n  duration: number;\n  dmaCodes: string[];\n  contentType: string;\n  language: string[];\n  description: string;\n  callSign: string;\n  linear: boolean;\n  networks: string[];\n  broadcastAiringType?: string;\n  hostNetwork?: string;\n}\n\nconst CLIENT_KEY = [\n  '0',\n  'q',\n  '1',\n  'p',\n  '5',\n  'K',\n  'S',\n  's',\n  'v',\n  't',\n  'u',\n  '2',\n  'V',\n  'J',\n  'f',\n  'k',\n  '5',\n  'v',\n  'Q',\n  '5',\n  'E',\n  'd',\n  'p',\n  'm',\n  'N',\n  'N',\n  'G',\n  'r',\n  'C',\n  'G',\n  'U',\n  '7',\n].join('');\n\nconst TV_CLIENT_KEY = [\n  'A',\n  '3',\n  'b',\n  '7',\n  '4',\n  'w',\n  'O',\n  'i',\n  'S',\n  'D',\n  'M',\n  'r',\n  'h',\n  'J',\n  'K',\n  'e',\n  'X',\n  'A',\n  'E',\n  'I',\n  'q',\n  'g',\n  'R',\n  'I',\n  'C',\n  'B',\n  'i',\n  'B',\n  'N',\n  'o',\n  '7',\n  'o',\n].join('');\n\nconst CLIENT_SECRET = ['q', 'G', 'h', 'E', 'v', '1', 'R', 't', 'I', '2', 'S', 'f', 'R', 'Q', 'O', 'e'].join('');\n\nconst TV_CLIENT_SECRET = ['u', 'o', 'C', 'y', 'y', 'k', 'y', 'U', 'w', 'D', 'b', 'f', 'Q', 'Z', 'r', '2'].join('');\n\nconst DEVICE_INFO = {\n  capabilities: {},\n  ctvDevice: 'AndroidTV',\n  diskCapacity: 118550667264,\n  displayHeight: 2340,\n  displayWidth: 1080,\n  idfv: 'unknown',\n  isLocationEnabled: true,\n  manufacturer: 'Google',\n  memory: 7824363520,\n  model: 'Pixel_5',\n  networkType: 'wifi',\n  osName: 'Android',\n  osVersion: '13',\n  vendor: 'google',\n  version: 'redfin',\n  versionName: '59.0.29.1346644',\n};\n\nconst TV_DEVICE_INFO = {\n  capabilities: {},\n  ctvDevice: 'AndroidTV',\n  diskCapacity: 6964142080,\n  displayHeight: 2160,\n  displayWidth: 3840,\n  idfv: 'unknown',\n  isLocationEnabled: true,\n  manufacturer: 'onn',\n  memory: 2063581184,\n  model: 'onn. 4K Streaming Box',\n  networkType: 'wifi',\n  osName: 'Android',\n  osVersion: '12',\n  vendor: 'onn',\n  version: 'goldfish',\n  versionName: '18.0.65.101385778',\n};\n\nconst DEFAULT_CATEGORIES = ['NFL', 'NFL+', 'Football'];\n\nconst nflConfigPath = path.join(configPath, 'nfl_tokens.json');\n\nexport type TOtherAuth = 'prime' | 'tve' | 'peacock' | 'sunday_ticket';\n\ninterface INFLJwt {\n  dmaCode: string;\n  plans: {plan: string; status: string; expirationDate: string}[];\n  networks?: {[key: string]: string};\n}\n\nconst parseAirings = async (events: INFLEvent[]) => {\n  const useLinear = await usesLinear();\n\n  const [now, endDate] = normalTimeRange();\n\n  for (const event of events) {\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: event.externalId});\n\n    if (!entryExists) {\n      const start = moment(event.startTime);\n      const end = moment(start).add(event.duration, 'seconds');\n      const originalEnd = moment(end);\n\n      const isLinear =\n        useLinear &&\n        (event.callSign === 'NFLNETWORK' || event.callSign === 'NFLNRZ' || event.callSign === 'NFLDIGITAL1_OO_v3');\n\n      if (!isLinear) {\n        end.add(1, 'hour');\n      }\n\n      if (end.isBefore(now) || start.isAfter(endDate)) {\n        continue;\n      }\n\n      const gameName = event.title;\n      console.log('Adding event: ', gameName);\n\n      const categories = [...DEFAULT_CATEGORIES];\n\n      if (gameName.indexOf(' at ') > -1) {\n        const [home, away] = gameName.split(' at ');\n        categories.push(home, away);\n      }\n\n      await db.entries.insertAsync<IEntry>({\n        categories: [...new Set(categories)],\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        feed: event.networks?.[0],\n        from: 'nfl+',\n        id: event.externalId,\n        image: event.preferredImage,\n        name: gameName,\n        network: 'NFL+',\n        originalEnd: originalEnd.valueOf(),\n        sport: 'NFL',\n        start: start.valueOf(),\n        ...(isLinear && {\n          channel: event.callSign,\n          linear: true,\n          replay: event.callSign === 'NFLDIGITAL1_OO_v3' || event.broadcastAiringType === 'REAIR',\n        }),\n      });\n    }\n  }\n};\n\nclass NflHandler {\n  public access_token?: string;\n  public refresh_token?: string;\n  public expires_at?: number;\n  public device_id?: string;\n  public device_info?: string;\n  public uid?: string;\n  public tv_access_token?: string;\n  public tv_refresh_token?: string;\n  public tv_expires_at?: number;\n\n  // Supplemental Auth\n  public mvpdIdp?: string;\n  public mvpdUserId?: string;\n  public mvpdUUID?: string;\n  public amazonPrimeUserId?: string;\n  public amazonPrimeUUID?: string;\n  public peacockUserId?: string;\n  public peacockUUID?: string;\n  public youTubeUserId?: string;\n  public youTubeUUID?: string;\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'nfl'})) > 0 ? true : false;\n\n    if (!setup) {\n      const data: TNFLTokens = {};\n\n      if (useNfl.plus) {\n        this.loadJSON();\n\n        data.device_id = this.device_id;\n        data.access_token = this.access_token;\n        data.expires_at = this.expires_at;\n        data.refresh_token = this.refresh_token;\n        data.tv_access_token = this.tv_access_token;\n        data.tv_expires_at = this.tv_expires_at;\n        data.tv_refresh_token = this.tv_refresh_token;\n        data.uid = this.uid;\n        data.mvpdIdp = this.mvpdIdp;\n        data.mvpdUserId = this.mvpdUserId;\n        data.mvpdUUID = this.mvpdUUID;\n        data.amazonPrimeUUID = this.amazonPrimeUUID;\n        data.amazonPrimeUserId = this.amazonPrimeUserId;\n        data.peacockUserId = this.peacockUserId;\n        data.peacockUUID = this.peacockUUID;\n        data.youTubeUUID = this.youTubeUUID;\n        data.youTubeUserId = this.youTubeUserId;\n      }\n\n      await db.providers.insertAsync<IProvider<TNFLTokens>>({\n        enabled: useNfl.plus,\n        linear_channels: [\n          {\n            enabled: useNfl.network,\n            id: 'NFLNETWORK',\n            name: 'NFL Network',\n            tmsId: '45399',\n          },\n          {\n            enabled: useNfl.redZone,\n            id: 'NFLNRZ',\n            name: 'NFL RedZone',\n            tmsId: '65025',\n          },\n          {\n            enabled: useNfl.channel,\n            id: 'NFLDIGITAL1_OO_v3',\n            name: 'NFL Channel',\n            tmsId: '121705',\n          },\n        ],\n        name: 'nfl',\n        tokens: data,\n      });\n\n      if (fs.existsSync(nflConfigPath)) {\n        fs.rmSync(nflConfigPath);\n      }\n    }\n\n    if (useNfl.plus) {\n      console.log('Using NFLPLUS variable is no longer needed. Please use the UI going forward');\n    }\n    if (useNfl.network) {\n      console.log('Using NFLNETWORK variable is no longer needed. Please use the UI going forward');\n    }\n    if (useNfl.channel) {\n      console.log('Using NFLCHANNEL variable is no longer needed. Please use the UI going forward');\n    }\n    if (useNfl.tve) {\n      console.log('Using NFL_TVE variable is no longer needed. Please use the UI going forward');\n    }\n    if (useNfl.peacock) {\n      console.log('Using NFL_PEACOCK variable is no longer needed. Please use the UI going forward');\n    }\n    if (useNfl.prime) {\n      console.log('Using NFL_PRIME variable is no longer needed. Please use the UI going forward');\n    }\n    if (useNfl.sundayTicket) {\n      console.log('Using NFL_SUNDAY_TICKET variable is no longer needed. Please use the UI going forward');\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'nfl'});\n\n    if (!enabled) {\n      return;\n    }\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n  };\n\n  public refreshTokens = async () => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'nfl'});\n\n    if (!enabled) {\n      return;\n    }\n\n    await this.extendTokens();\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'nfl'});\n\n    if (!enabled) {\n      return;\n    }\n\n    const useLinear = await usesLinear();\n\n    const {dmaCode}: INFLJwt = jwt_decode(this.access_token);\n\n    const redZoneAccess = await this.checkRedZoneAccess();\n    const nflNetworkAccess = await this.checkNetworkAccess();\n    const nflChannelAccess = await this.checkChannelAccess();\n    const hasPlus = this.checkPlusAccess();\n\n    if (!dmaCode) {\n      console.log('DMA Code not found for NFL+. Not searching for events');\n      return;\n    }\n\n    console.log('Looking for NFL events...');\n    const events: INFLEvent[] = [];\n\n    try {\n      const [now, endSchedule] = normalTimeRange();\n      now.subtract(12, 'hours');\n\n      const url = ['https://', 'api.nfl.com', '/experience/v1/livestreams'].join('');\n\n      const {data} = await axios.get<INFLRes>(url, {\n        headers: {\n          Authorization: `Bearer ${this.access_token}`,\n        },\n      });\n\n      debug.saveRequestData(data, 'nfl', 'epg');\n\n      data.data.items.forEach(i => {\n        if (moment(i.startTime).isBefore(endSchedule)) {\n          if (\n            i.contentType === 'GAME' &&\n            i.dmaCodes.find(dc => dc === `${dmaCode}`) &&\n            i.language.find(l => l === 'en')\n          ) {\n            if (\n              // If you have NFL+, you get the game\n              hasPlus ||\n              // TVE\n              this.checkTVEEventAccess(i) ||\n              // Peacock\n              (i.authorizations.peacock && this.checkPeacockAccess()) ||\n              // Prime\n              (i.authorizations.amazon_prime && this.checkPrimeAccess())\n            ) {\n              events.push(i);\n            }\n          } else if (\n            i.callSign === 'NFLNRZ' &&\n            i.title === 'NFL RedZone' &&\n            // NFL+ Premium or TVE supports RedZone\n            redZoneAccess\n          ) {\n            events.push(i);\n          } else if (i.callSign === 'NFLNETWORK' && nflNetworkAccess && i.contentType !== 'AUDIO') {\n            events.push(i);\n          } else if (\n            // Sunday Ticket\n            i.contentType === 'GAME' &&\n            i.language.find(l => l === 'en') &&\n            i.authorizations.sunday_ticket &&\n            this.checkSundayTicketAccess()\n          ) {\n            events.push(i);\n          }\n        }\n      });\n\n      if (nflChannelAccess && useLinear) {\n        const url = [\n          'https://',\n          'api.nfl.com',\n          '/live/v1/nflchannel',\n          '?starttime=',\n          now.toISOString(),\n          '&endtime=',\n          endSchedule.toISOString(),\n        ].join('');\n\n        const {data: nflChannelData} = await axios.get<INFLChannelRes>(url, {\n          headers: {\n            Authorization: `Bearer ${this.access_token}`,\n          },\n        });\n\n        nflChannelData.items.forEach(i => events.push(i));\n      }\n\n      await parseAirings(events);\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse NFL events');\n    }\n  };\n\n  public getEventData = async (id: string): Promise<TChannelPlaybackInfo> => {\n    try {\n      await this.refreshTokens();\n\n      const event = await db.entries.findOneAsync<IEntry>({id});\n\n      const isGame =\n        event.channel !== 'NFLNETWORK' && event.channel !== 'NFLDIGITAL1_OO_v3' && event.channel !== 'NFLNRZ';\n\n      const url = ['https://', 'api.nfl.com/', 'play/v1/asset/', id].join('');\n\n      const {data} = await axios.post(\n        url,\n        {\n          ...((this.checkTVEAccess() || this.checkSundayTicketAccess()) && {\n            idp: this.mvpdIdp || 'youtube',\n            mvpdUUID: this.mvpdUUID || this.youTubeUUID,\n            mvpdUserId: this.mvpdUserId || this.youTubeUserId,\n            networks: event.feed || 'NFLN',\n          }),\n        },\n        {\n          headers: {\n            'Content-Type': 'application/json',\n            'User-Agent': okHttpUserAgent,\n            authorization: `Bearer ${isGame ? this.access_token : this.tv_access_token}`,\n          },\n        },\n      );\n\n      return [data.accessUrl, {}];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start playback');\n    }\n  };\n\n  private updateChannelAccess = async (index: number, enabled: boolean): Promise<void> => {\n    const {linear_channels} = await db.providers.findOneAsync<IProvider<TNFLTokens>>({name: 'nfl'});\n\n    const updatedChannels = linear_channels.map((c, i) => {\n      if (i !== index) {\n        return c;\n      }\n\n      c.enabled = enabled;\n      return c;\n    });\n\n    await db.providers.updateAsync({name: 'nfl'}, {$set: {linear_channels: updatedChannels}});\n  };\n\n  private checkRedZoneAccess = async (): Promise<boolean> => {\n    try {\n      const {plans, networks}: INFLJwt = jwt_decode(this.access_token);\n\n      if (plans) {\n        const redZoneAccess =\n          plans.findIndex(\n            p =>\n              p.plan === 'NFL_PLUS_PREMIUM' &&\n              (p.status === 'ACTIVE' || (p.status === 'CANCELLED' && moment(p.expirationDate).isAfter(moment()))),\n          ) > -1 || networks?.NFLRZ\n            ? true\n            : false;\n\n        await this.updateChannelAccess(1, redZoneAccess);\n\n        return redZoneAccess;\n      }\n    } catch (e) {\n      await this.updateChannelAccess(1, false);\n    }\n\n    return false;\n  };\n\n  private checkNetworkAccess = async (): Promise<boolean> => {\n    const useLinear = await usesLinear();\n\n    try {\n      const {plans, networks}: INFLJwt = jwt_decode(this.tv_access_token);\n\n      if (plans) {\n        const networkAccess = (this.checkPlusAccess() || networks?.NFLN) && useLinear ? true : false;\n        await this.updateChannelAccess(0, networkAccess);\n\n        return networkAccess;\n      }\n    } catch (e) {\n      await this.updateChannelAccess(0, false);\n    }\n\n    return false;\n  };\n\n  private checkChannelAccess = async (): Promise<boolean> => {\n    try {\n      const {linear_channels} = await db.providers.findOneAsync<IProvider<TNFLTokens>>({name: 'nfl'});\n\n      return linear_channels[2].enabled;\n    } catch (e) {}\n\n    return false;\n  };\n\n  private checkPlusAccess = (): boolean => {\n    let hasPlus = false;\n\n    try {\n      const {plans}: INFLJwt = jwt_decode(this.access_token);\n\n      if (plans) {\n        hasPlus =\n          plans.findIndex(\n            p =>\n              (p.plan === 'NFL_PLUS' || p.plan === 'NFL_PLUS_PREMIUM') &&\n              (p.status === 'ACTIVE' || (p.status === 'CANCELLED' && moment(p.expirationDate).isAfter(moment()))),\n          ) > -1\n            ? true\n            : false;\n      }\n    } catch (e) {}\n\n    return hasPlus;\n  };\n\n  private checkTVEAccess = (): boolean => (this.mvpdIdp ? true : false);\n  private checkPeacockAccess = (): boolean => (this.peacockUserId ? true : false);\n  private checkPrimeAccess = (): boolean => (this.amazonPrimeUserId ? true : false);\n  private checkSundayTicketAccess = (): boolean => (this.youTubeUserId ? true : false);\n\n  private checkTVEEventAccess = (event: INFLEvent): boolean => {\n    let hasChannel = false;\n\n    try {\n      const {networks}: INFLJwt = jwt_decode(this.access_token);\n\n      event.networks.forEach(n => {\n        if (networks[n]) {\n          hasChannel = true;\n        }\n      });\n    } catch (e) {}\n\n    return hasChannel;\n  };\n\n  private extendTokens = async (): Promise<void> => {\n    await this.extendToken();\n    await this.extendTvToken();\n  };\n\n  private extendToken = async (uidSignature?: string, signatureTimestamp?: string): Promise<void> => {\n    try {\n      const url = ['https://', 'api.nfl.com', '/identity/v3/token/refresh'].join('');\n\n      const {data} = await axios.post(\n        url,\n        {\n          clientKey: CLIENT_KEY,\n          clientSecret: CLIENT_SECRET,\n          deviceId: this.device_id,\n          deviceInfo: Buffer.from(JSON.stringify(DEVICE_INFO), 'utf-8').toString('base64'),\n          networkType: 'wifi',\n          refreshToken: this.refresh_token,\n          uid: this.uid,\n          ...(uidSignature && {\n            signatureTimestamp,\n            uidSignature,\n          }),\n          ...(this.mvpdIdp && {\n            mvpdIdp: this.mvpdIdp,\n            mvpdUUID: this.mvpdUUID,\n            mvpdUserId: this.mvpdUserId,\n          }),\n          ...(this.amazonPrimeUserId && {\n            amazonPrimeUUID: this.amazonPrimeUUID,\n            amazonPrimeUserId: this.amazonPrimeUserId,\n          }),\n          ...(this.peacockUserId && {\n            peacockUUID: this.peacockUUID,\n            peacockUserId: this.peacockUserId,\n          }),\n          ...(this.youTubeUserId && {\n            youTubeUUID: this.youTubeUUID,\n            youTubeUserId: this.youTubeUserId,\n          }),\n        },\n        {\n          headers: {\n            'User-Agent': okHttpUserAgent,\n          },\n        },\n      );\n\n      this.access_token = data.accessToken;\n      this.refresh_token = data.refreshToken;\n      this.expires_at = data.expiresIn;\n\n      if (data.additionalInfo) {\n        data.additionalInfo.forEach(ai => {\n          if (ai.data) {\n            if (ai.data.idp === 'amazon') {\n              this.amazonPrimeUUID = ai.data.newUUID;\n            }\n          }\n        });\n      }\n\n      await this.save();\n\n      await this.checkRedZoneAccess();\n      await this.checkNetworkAccess();\n    } catch (e) {\n      console.error(e);\n      console.log('Could not refresh token for NFL');\n    }\n  };\n\n  private extendTvToken = async (uidSignature?: string, signatureTimestamp?: string): Promise<void> => {\n    try {\n      const url = ['https://', 'api.nfl.com', '/identity/v3/token/refresh'].join('');\n\n      const {data} = await axios.post(\n        url,\n        {\n          clientKey: TV_CLIENT_KEY,\n          clientSecret: TV_CLIENT_SECRET,\n          deviceId: this.device_id,\n          deviceInfo: Buffer.from(JSON.stringify(TV_DEVICE_INFO), 'utf-8').toString('base64'),\n          networkType: 'wifi',\n          refreshToken: this.tv_refresh_token,\n          uid: this.uid,\n          ...(uidSignature && {\n            signatureTimestamp,\n            uidSignature,\n          }),\n          ...(this.mvpdIdp && {\n            mvpdIdp: this.mvpdIdp,\n            mvpdUUID: this.mvpdUUID,\n            mvpdUserId: this.mvpdUserId,\n          }),\n          ...(this.youTubeUserId && {\n            youTubeUUID: this.youTubeUUID,\n            youTubeUserId: this.youTubeUserId,\n          }),\n        },\n        {\n          headers: {\n            'User-Agent': okHttpUserAgent,\n          },\n        },\n      );\n\n      this.tv_access_token = data.accessToken;\n      this.tv_refresh_token = data.refreshToken;\n      this.tv_expires_at = data.expiresIn;\n\n      await this.save();\n    } catch (e) {\n      console.error(e);\n      console.log('Could not refresh token for NFL');\n    }\n  };\n\n  private getTokens = async (): Promise<void> => {\n    await this.getToken();\n    await this.getTvToken();\n  };\n\n  private getToken = async (): Promise<void> => {\n    try {\n      const url = ['https://', 'api.nfl.com', '/identity/v3/token'].join('');\n\n      const {data} = await axios.post(\n        url,\n        {\n          clientKey: CLIENT_KEY,\n          clientSecret: CLIENT_SECRET,\n          deviceId: this.device_id,\n          deviceInfo: Buffer.from(JSON.stringify(DEVICE_INFO), 'utf-8').toString('base64'),\n          networkType: 'wifi',\n        },\n        {\n          headers: {\n            'User-Agent': okHttpUserAgent,\n          },\n        },\n      );\n\n      this.access_token = data.accessToken;\n      this.refresh_token = data.refreshToken;\n\n      if (this.uid) {\n        this.expires_at = data.expiresIn;\n        await this.save();\n      }\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get token for NFL');\n    }\n  };\n\n  private getTvToken = async (): Promise<void> => {\n    try {\n      const url = ['https://', 'api.nfl.com', '/identity/v3/token'].join('');\n\n      const {data} = await axios.post(\n        url,\n        {\n          clientKey: TV_CLIENT_KEY,\n          clientSecret: TV_CLIENT_SECRET,\n          deviceId: this.device_id,\n          deviceInfo: Buffer.from(JSON.stringify(TV_DEVICE_INFO), 'utf-8').toString('base64'),\n          networkType: 'wifi',\n        },\n        {\n          headers: {\n            'User-Agent': okHttpUserAgent,\n          },\n        },\n      );\n\n      this.tv_access_token = data.accessToken;\n      this.tv_refresh_token = data.refreshToken;\n\n      if (this.uid) {\n        this.tv_expires_at = data.expiresIn;\n        await this.save();\n      }\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get TV token for NFL');\n    }\n  };\n\n  public getAuthCode = async (otherAuth?: TOtherAuth): Promise<[string, string?]> => {\n    // Reset state\n    if (!otherAuth) {\n      this.device_id = getRandomUUID();\n      this.access_token = undefined;\n      this.expires_at = undefined;\n      this.refresh_token = undefined;\n      this.tv_access_token = undefined;\n      this.tv_expires_at = undefined;\n      this.tv_refresh_token = undefined;\n      this.uid = undefined;\n      this.mvpdIdp = undefined;\n      this.mvpdUserId = undefined;\n      this.mvpdUUID = undefined;\n      this.amazonPrimeUUID = undefined;\n      this.amazonPrimeUserId = undefined;\n      this.peacockUserId = undefined;\n      this.peacockUUID = undefined;\n      this.youTubeUUID = undefined;\n      this.youTubeUserId = undefined;\n    }\n\n    try {\n      await this.getTokens();\n\n      const url = ['https://', 'api.nfl.com', '/utilities/v1/regcode'].join('');\n      const {data} = await axios.get(url, {\n        headers: {\n          Authorization: `Bearer ${this.access_token}`,\n          'Content-Type': 'application/json',\n          'User-Agent': okHttpUserAgent,\n        },\n      });\n\n      const code = data.regCode;\n\n      const putUrl = ['https://', 'api.nfl.com', '/keystore/v1/mvpd/', code, '?ttl=600000'].join('');\n\n      await axios.put(\n        putUrl,\n        {\n          ctvDevice: 'AndroidTV',\n          deviceId: this.device_id,\n          expiresIn: 600,\n          platform: 'ctv',\n          regCode: code,\n          ...(!otherAuth && {\n            nflAccount: true,\n            nflToken: true,\n          }),\n          ...(otherAuth === 'tve' && {\n            idp: 'TV_PROVIDER',\n            nflAccount: false,\n          }),\n          ...(otherAuth === 'prime' && {\n            idp: 'AMAZON',\n            nflAccount: false,\n          }),\n          ...(otherAuth === 'peacock' && {\n            idp: 'PEACOCK',\n            nflAccount: false,\n          }),\n          ...(otherAuth === 'sunday_ticket' && {\n            idp: 'YOUTUBE',\n            nflAccount: false,\n          }),\n        },\n        {\n          headers: {\n            Authorization: `Bearer ${this.access_token}`,\n            'Content-Type': 'application/json',\n            'User-Agent': okHttpUserAgent,\n          },\n        },\n      );\n\n      return [code, otherAuth];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start the authentication process for NFL!');\n    }\n  };\n\n  public authenticateRegCode = async (code: string, otherAuth?: TOtherAuth): Promise<boolean> => {\n    try {\n      const url = ['https://', 'api.nfl.com', '/keystore/v1/mvpd/', code].join('');\n\n      const {data} = await axios.get(url, {\n        headers: {\n          Authorization: `Bearer ${this.access_token}`,\n          'User-Agent': okHttpUserAgent,\n        },\n      });\n\n      if (!data) {\n        return false;\n      }\n\n      if (otherAuth) {\n        if (!data.userId) {\n          return false;\n        }\n\n        if (otherAuth === 'tve') {\n          this.mvpdIdp = data.idp;\n          this.mvpdUserId = data.userId;\n          this.mvpdUUID = data.uuid;\n        } else if (otherAuth === 'prime') {\n          this.amazonPrimeUserId = data.userId;\n          this.amazonPrimeUUID = data.uuid;\n        } else if (otherAuth === 'peacock') {\n          this.peacockUserId = data.userId;\n          this.peacockUUID = data.uuid;\n        } else if (otherAuth === 'sunday_ticket') {\n          this.youTubeUserId = data.userId;\n          this.youTubeUUID = data.uuid;\n        }\n\n        await this.save();\n\n        await this.extendToken();\n        await this.extendTvToken();\n      } else {\n        if (!data.uidSignature) {\n          return false;\n        }\n\n        this.uid = data.uid;\n\n        await this.extendToken(data.uidSignature, data.signatureTimestamp);\n        await this.extendTvToken(data.uidSignature, data.signatureTimestamp);\n      }\n\n      return true;\n    } catch (e) {\n      return false;\n    }\n  };\n\n  private save = async (): Promise<void> => {\n    await db.providers.updateAsync({name: 'nfl'}, {$set: {tokens: this}});\n  };\n\n  private load = async (): Promise<void> => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TNFLTokens>>({name: 'nfl'});\n    const {\n      device_id,\n      access_token,\n      expires_at,\n      refresh_token,\n      uid,\n      tv_access_token,\n      tv_expires_at,\n      tv_refresh_token,\n      mvpdIdp,\n      mvpdUserId,\n      mvpdUUID,\n      amazonPrimeUserId,\n      amazonPrimeUUID,\n      peacockUserId,\n      peacockUUID,\n      youTubeUserId,\n      youTubeUUID,\n    } = tokens;\n\n    this.device_id = device_id;\n    this.access_token = access_token;\n    this.expires_at = expires_at;\n    this.refresh_token = refresh_token;\n    this.tv_access_token = tv_access_token;\n    this.tv_expires_at = tv_expires_at;\n    this.tv_refresh_token = tv_refresh_token;\n    this.uid = uid;\n\n    // Supplemental Auth\n    this.mvpdIdp = mvpdIdp;\n    this.mvpdUserId = mvpdUserId;\n    this.mvpdUUID = mvpdUUID;\n    this.amazonPrimeUUID = amazonPrimeUUID;\n    this.amazonPrimeUserId = amazonPrimeUserId;\n    this.peacockUserId = peacockUserId;\n    this.peacockUUID = peacockUUID;\n    this.youTubeUUID = youTubeUUID;\n    this.youTubeUserId = youTubeUserId;\n  };\n\n  private loadJSON = () => {\n    if (fs.existsSync(nflConfigPath)) {\n      const {\n        device_id,\n        access_token,\n        expires_at,\n        refresh_token,\n        uid,\n        tv_access_token,\n        tv_expires_at,\n        tv_refresh_token,\n        mvpdIdp,\n        mvpdUserId,\n        mvpdUUID,\n        amazonPrimeUserId,\n        amazonPrimeUUID,\n        peacockUserId,\n        peacockUUID,\n        youTubeUserId,\n        youTubeUUID,\n      } = fsExtra.readJSONSync(nflConfigPath);\n\n      this.device_id = device_id;\n      this.access_token = access_token;\n      this.expires_at = expires_at;\n      this.refresh_token = refresh_token;\n      this.tv_access_token = tv_access_token;\n      this.tv_expires_at = tv_expires_at;\n      this.tv_refresh_token = tv_refresh_token;\n      this.uid = uid;\n\n      // Supplemental Auth\n      this.mvpdIdp = mvpdIdp;\n      this.mvpdUserId = mvpdUserId;\n      this.mvpdUUID = mvpdUUID;\n      this.amazonPrimeUUID = amazonPrimeUUID;\n      this.amazonPrimeUserId = amazonPrimeUserId;\n      this.peacockUserId = peacockUserId;\n      this.peacockUUID = peacockUUID;\n      this.youTubeUUID = youTubeUUID;\n      this.youTubeUserId = youTubeUserId;\n    }\n  };\n}\n\nexport type TNFLTokens = ClassTypeWithoutMethods<NflHandler>;\n\nexport const nflHandler = new NflHandler();\n"
  },
  {
    "path": "services/nhltv-handler.ts",
    "content": "import axios from 'axios';\nimport moment from 'moment';\nimport _ from 'lodash';\n\nimport {nhlTvUserAgent} from './user-agent';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {getRandomUUID, normalTimeRange} from './shared-helpers';\nimport {debug} from './debug';\n\ninterface ICompetetitor {\n  name: string;\n}\n\ninterface IImage {\n  path: string;\n  manipulations: string[];\n}\n\ninterface IContent {\n  id: string;\n  path?: string | null;\n  playtime: number;\n  distributionType: {\n    name: 'Live' | 'VOD';\n  };\n  clientContentMetadata?: {\n    id: string;\n    name: 'HOME' | 'AWAY' | string;\n  }[];\n}\n\ninterface INHLEvent {\n  homeCompetitor: ICompetetitor;\n  awayCompetitor: ICompetetitor;\n  images: IImage[];\n  startTime: string;\n  content: IContent[];\n}\n\ninterface INHLEventSimple {\n  id: string;\n  duration: number;\n  start: string;\n  name: string;\n  image: string;\n  feed: string;\n}\n\nconst BASE_API = 'https://nhltv.nhl.com/api';\n\nconst COMMON_HEADERS = {\n  'Content-Type': 'application/json',\n  'User-Agent': nhlTvUserAgent,\n};\n\nconst getGameName = (event: INHLEvent): string =>\n  `${_.startCase(event.homeCompetitor.name.toLowerCase())} vs ${_.startCase(event.awayCompetitor.name.toLowerCase())}`;\n\nconst getBroadcastName = (event: INHLEvent, broadcast: 'HOME' | 'AWAY' | string): string => {\n  if (broadcast !== 'HOME' && broadcast !== 'AWAY') {\n    return broadcast;\n  }\n\n  return `${_.startCase(\n    broadcast === 'HOME' ? event.homeCompetitor.name.toLowerCase() : event.awayCompetitor.name.toLowerCase(),\n  )} Feed`;\n};\n\nconst getGameImage = (event: INHLEvent): string => {\n  const image = event.images.find(i => i.manipulations.some(m => m === 'original'));\n\n  if (image) {\n    return `https://nhltv.nhl.com/image/original/${image.path}`;\n  }\n\n  return '';\n};\n\nconst parseAirings = async (events: INHLEventSimple[]) => {\n  const [now, endDate] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.id) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `nhl-${event.id}`});\n\n    if (!entryExists) {\n      const start = moment(event.start);\n      const end = moment(event.start).add(4, 'hours');\n      const originalEnd = moment(event.start).add(event.duration, 'seconds');\n\n      if (end.isBefore(now) || start.isAfter(endDate)) {\n        continue;\n      }\n\n      console.log('Adding event: ', event.name);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: ['NHL', 'Ice Hockey'],\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        feed: event.feed,\n        from: 'nhl',\n        id: `nhl-${event.id}`,\n        image: event.image,\n        name: event.name,\n        network: 'NHL.tv',\n        originalEnd: originalEnd.valueOf(),\n        sport: 'NHL',\n        start: start.valueOf(),\n      });\n    }\n  }\n};\n\nclass NHLHandler {\n  public device_id?: string;\n  public session_token?: string;\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'nhl'})) > 0 ? true : false;\n\n    // First time setup\n    if (!setup) {\n      const data: TNHLTokens = {};\n\n      await db.providers.insertAsync<IProvider<TNHLTokens>>({\n        enabled: false,\n        name: 'nhl',\n        tokens: data,\n      });\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'nhl'});\n\n    if (!enabled) {\n      return;\n    }\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n  };\n\n  public refreshTokens = async () => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'nhl'});\n\n    if (!enabled) {\n      return;\n    }\n\n    await this.extendSessionToken();\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'nhl'});\n\n    if (!enabled) {\n      return;\n    }\n\n    await this.extendSessionToken();\n\n    console.log('Looking for NHL.tv events...');\n\n    const entries: INHLEventSimple[] = [];\n\n    const [now, endSchedule] = normalTimeRange();\n\n    try {\n      const url = [\n        BASE_API,\n        '/v2/events',\n        '?date_time_from=',\n        now.format(),\n        '&date_time_to=',\n        endSchedule.format(),\n        '&metadata_id=259346',\n        '&sort_direction=asc',\n        '&limit=100',\n      ].join('');\n\n      const {data} = await axios.get<{data: INHLEvent[]}>(url, {\n        headers: {\n          ...COMMON_HEADERS,\n          cookie: `token=${this.session_token}`,\n        },\n      });\n\n      debug.saveRequestData(data.data, 'nhl', 'epg');\n\n      data.data.forEach(game => {\n        game.content.forEach(feed => {\n          if (\n            feed.distributionType?.name === 'Live' &&\n            feed.clientContentMetadata.length > 0 &&\n            feed.clientContentMetadata[0].name !== 'FRENCH'\n          ) {\n            entries.push({\n              duration: feed.playtime || 240 * 60,\n              feed: getBroadcastName(game, feed.clientContentMetadata[0].name),\n              id: feed.id,\n              image: getGameImage(game),\n              name: getGameName(game),\n              start: game.startTime,\n            });\n          }\n        });\n      });\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse NHL Sports events');\n    }\n\n    await parseAirings(entries);\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    await this.extendSessionToken();\n\n    const realEventId = eventId.replace('nhl-', '');\n\n    try {\n      const checkAccessUrl = [BASE_API, '/v3/contents/', realEventId, '/check-access'].join('');\n\n      const {data} = await axios.post(\n        checkAccessUrl,\n        {},\n        {\n          headers: {\n            ...COMMON_HEADERS,\n            authorization: `Bearer ${this.session_token}`,\n            cookie: `token=${this.session_token}`,\n          },\n        },\n      );\n\n      const playbackToken: string = data.data;\n\n      const playbackInfoUrl = [BASE_API, '/v2/content/', realEventId, '/access/hls'].join('');\n\n      const {data: playbackData} = await axios.post(\n        playbackInfoUrl,\n        {},\n        {\n          headers: {\n            ...COMMON_HEADERS,\n            authorization: `Bearer ${playbackToken}`,\n            cookie: `token=${this.session_token}`,\n          },\n        },\n      );\n\n      return [playbackData.data.stream, {}];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start playback');\n    }\n  };\n\n  public getAuthCode = async (): Promise<string> => {\n    this.device_id = getRandomUUID();\n\n    try {\n      const url = [BASE_API, '/v3/sso/nhl', '/request-signin-code'].join('');\n\n      const {data} = await axios.post(\n        url,\n        {\n          device_id: this.device_id,\n        },\n        {\n          headers: {\n            ...COMMON_HEADERS,\n          },\n        },\n      );\n\n      return data.code;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not login to NHL Sports');\n    }\n  };\n\n  public authenticateRegCode = async (code: string): Promise<boolean> => {\n    try {\n      const url = [BASE_API, '/v3/sso/nhl', '/signin-with-code'].join('');\n\n      const {data} = await axios.post(\n        url,\n        {\n          code,\n          device_id: this.device_id,\n        },\n        {\n          headers: {\n            ...COMMON_HEADERS,\n          },\n        },\n      );\n\n      if (!data || !data?.token) {\n        return false;\n      }\n\n      this.session_token = data.token;\n      await this.save();\n\n      return true;\n    } catch (e) {\n      return false;\n    }\n  };\n\n  private extendSessionToken = async (): Promise<void> => {\n    const url = [BASE_API, '/v3/sso/nhl', '/extend_token'].join('');\n\n    try {\n      const {data} = await axios.post(\n        url,\n        {},\n        {\n          headers: {\n            ...COMMON_HEADERS,\n            authorization: `Bearer: ${this.session_token}`,\n            cookie: `token=${this.session_token}`,\n          },\n        },\n      );\n\n      this.session_token = data.token;\n\n      await this.save();\n    } catch (e) {\n      console.error(e);\n      console.log('Could not extend NHL TV token');\n    }\n  };\n\n  private save = async (): Promise<void> => {\n    await db.providers.updateAsync({name: 'nhl'}, {$set: {tokens: this}});\n  };\n\n  private load = async (): Promise<void> => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TNHLTokens>>({name: 'nhl'});\n    const {device_id, session_token} = tokens || {};\n\n    this.device_id = device_id;\n    this.session_token = session_token;\n  };\n}\n\nexport type TNHLTokens = ClassTypeWithoutMethods<NHLHandler>;\n\nexport const nhlHandler = new NHLHandler();\n"
  },
  {
    "path": "services/nwsl-handler.ts",
    "content": "import axios from 'axios';\nimport moment from 'moment';\nimport jwt_decode from 'jwt-decode';\n\nimport {userAgent} from './user-agent';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {normalTimeRange} from './shared-helpers';\nimport {debug} from './debug';\nimport {usesLinear} from './misc-db-service';\n\ninterface INswlLinearRes {\n  channels: {\n    name: string;\n    programmes: INwslLinearEvent[];\n  }[];\n}\n\ninterface INwslLinearEvent {\n  id: number;\n  startDate: string;\n  endDate: string;\n  thumbnailUrl: string;\n  episode: string;\n}\n\ninterface INwslHomeRes {\n  buckets: {\n    type: string;\n    contentList: INwslEvent[];\n  }[];\n}\n\ninterface INwslEvent {\n  id: number;\n  startDate: number;\n  endDate: number;\n  thumbnailUrl: string;\n  title: string;\n}\n\ninterface INwslMeta {\n  username: string;\n  password: string;\n}\n\nconst BASE_API_URL = 'https://dce-frontoffice.imggaming.com/api';\n\nconst REFERRER = 'https://plus.nwslsoccer.com/';\nconst REALM = 'dce.nwsl';\n\nconst API_KEY = [\n  '8',\n  '5',\n  '7',\n  'a',\n  '1',\n  'e',\n  '5',\n  'd',\n  '-',\n  'e',\n  '3',\n  '5',\n  'e',\n  '-',\n  '4',\n  'f',\n  'd',\n  'f',\n  '-',\n  '8',\n  '0',\n  '5',\n  'b',\n  '-',\n  'a',\n  '8',\n  '7',\n  'b',\n  '6',\n  'f',\n  '8',\n  '3',\n  '6',\n  '4',\n  'b',\n  'f',\n].join('');\n\nconst APP_VAR = '6.57.11.b0bf548';\n\nconst parseAirings = async (events: (INwslLinearEvent | INwslEvent)[], linear = false) => {\n  const [now, endDate] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.id) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `nwsl-${event.id}`});\n\n    if (!entryExists) {\n      const start = moment(event.startDate);\n      const end = moment(event.endDate);\n      const originalEnd = moment(event.endDate);\n\n      if (!linear) {\n        end.add(1, 'hour');\n      }\n\n      if (end.isBefore(now) || start.isAfter(endDate)) {\n        continue;\n      }\n\n      console.log(\n        'Adding event: ',\n        (event as INwslEvent).title ? (event as INwslEvent).title : (event as INwslLinearEvent).episode,\n      );\n\n      await db.entries.insertAsync<IEntry>({\n        categories: ['Soccer', 'NWSL', 'NWSL+', \"Woman's Soccer\", \"Women's Sports\"],\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'nwsl',\n        id: `nwsl-${event.id}`,\n        image: event.thumbnailUrl,\n        name: (event as INwslEvent).title ? (event as INwslEvent).title : (event as INwslLinearEvent).episode,\n        network: 'NWSL+',\n        originalEnd: originalEnd.valueOf(),\n        sport: \"Woman's Soccer\",\n        start: start.valueOf(),\n        ...(linear && {\n          channel: 'NWSL+',\n          linear: true,\n        }),\n      });\n    }\n  }\n};\n\nclass NwslHandler {\n  public token?: string;\n  public tokenExp?: number;\n  public refreshToken?: string;\n  public refreshTokenExp?: number;\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'nwsl'})) > 0 ? true : false;\n\n    // First time setup\n    if (!setup) {\n      const useLinear = await usesLinear();\n\n      const data: TNwslTokens = {};\n\n      await db.providers.insertAsync<IProvider<TNwslTokens>>({\n        enabled: false,\n        linear_channels: [\n          {\n            enabled: useLinear,\n            id: 'NWSL+',\n            name: 'NWSL+ 24/7',\n          },\n        ],\n        name: 'nwsl',\n        tokens: data,\n      });\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'nwsl'});\n\n    if (!enabled) {\n      return;\n    }\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n  };\n\n  public refreshTokens = async () => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'nwsl'});\n\n    if (!enabled) {\n      return;\n    }\n\n    if (!this.refreshTokenExp || moment().isAfter(this.refreshTokenExp)) {\n      await this.login();\n    }\n\n    if (moment().isBefore(this.tokenExp)) {\n      return;\n    }\n\n    try {\n      const url = [BASE_API_URL, '/v2/token/refresh'].join('');\n\n      const {data} = await axios.post(\n        url,\n        {\n          refreshToken: this.refreshToken,\n        },\n        {\n          headers: {\n            Authorization: `Bearer ${this.token}`,\n            'Content-Type': 'application/json',\n            Realm: REALM,\n            Referer: REFERRER,\n            'User-Agent': userAgent,\n            'x-api-key': API_KEY,\n            'x-app-var': APP_VAR,\n          },\n        },\n      );\n\n      this.token = data.authorisationToken;\n\n      const {exp: tokenExp}: {exp: number} = jwt_decode(this.token);\n      this.tokenExp = tokenExp * 1000;\n\n      await this.save();\n    } catch (e) {\n      console.error(e);\n      console.log('Could not renew tokens for NWSL+');\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled, linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'nwsl'});\n\n    if (!enabled) {\n      return;\n    }\n\n    await this.refreshTokens();\n\n    console.log('Looking for NWSL+ events...');\n\n    const entries: INwslEvent[] = [];\n    const linearEntries: INwslLinearEvent[] = [];\n\n    const [now, endSchedule] = normalTimeRange();\n\n    try {\n      const url = [\n        BASE_API_URL,\n        '/v4/content/home',\n        '?bpp=10',\n        '&rpp=12',\n        '&displaySectionLinkBuckets=SHOW',\n        '&displayEpgBuckets=HIDE',\n        '&displayEmptyBucketShortcuts=SHOW',\n        '&displayContentAvailableOnSignIn=SHOW',\n        '&displayGeoblocked=SHOW',\n        '&bspp=20',\n        '&premiereEventContentDisplay=SHOW',\n      ].join('');\n\n      const {data} = await axios.get<INwslHomeRes>(url, {\n        headers: {\n          Authorization: `Bearer ${this.token}`,\n          'Content-Type': 'application/json',\n          Realm: REALM,\n          Referer: REFERRER,\n          'User-Agent': userAgent,\n          'x-api-key': API_KEY,\n          'x-app-var': APP_VAR,\n        },\n      });\n\n      debug.saveRequestData(data, 'nwsl', 'epg');\n\n      data.buckets.forEach(e => {\n        if (e.type === 'UPCOMING') {\n          e.contentList.forEach(a => entries.push(a));\n        }\n      });\n\n      const useLinear = await usesLinear();\n\n      if (useLinear && linear_channels[0].enabled) {\n        const linearUrl = [\n          BASE_API_URL,\n          '/v4/epg',\n          '?from=',\n          now.toISOString(),\n          '&to=',\n          endSchedule.toISOString(),\n          '&rpp=20',\n          '&channel=1140',\n        ].join('');\n\n        const {data: linearData} = await axios.get<INswlLinearRes>(linearUrl, {\n          headers: {\n            Authorization: `Bearer ${this.token}`,\n            'Content-Type': 'application/json',\n            Realm: REALM,\n            Referer: REFERRER,\n            'User-Agent': userAgent,\n            'x-api-key': API_KEY,\n            'x-app-var': APP_VAR,\n          },\n        });\n\n        debug.saveRequestData(data, 'nwsl', 'epg-linear');\n\n        linearData.channels.forEach(c => {\n          if (c.name === 'NWSL+ 24/7') {\n            c.programmes.forEach(e => linearEntries.push(e));\n          }\n        });\n      }\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse NWSL+ Sports events');\n    }\n\n    await parseAirings(entries);\n    await parseAirings(linearEntries, true);\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    await this.refreshTokens();\n\n    const event = await db.entries.findOneAsync<IEntry>({id: eventId});\n\n    let eventRealId = '275408';\n\n    if (!event.linear) {\n      eventRealId = eventId.split('nwsl-')[1];\n    }\n\n    const url = [\n      BASE_API_URL,\n      '/v4/event/',\n      eventRealId,\n      '?includePlaybackDetails=URL',\n      '&displayGeoblocked=SHOW',\n    ].join('');\n\n    try {\n      const {data: initialData} = await axios.get(url, {\n        headers: {\n          Authorization: `Bearer ${this.token}`,\n          'Content-Type': 'application/json',\n          Realm: REALM,\n          Referer: REFERRER,\n          'User-Agent': userAgent,\n          'x-api-key': API_KEY,\n          'x-app-var': APP_VAR,\n        },\n      });\n\n      const {playerUrlCallback} = initialData;\n\n      const {data: playbackData} = await axios.get(playerUrlCallback, {\n        headers: {\n          'Content-Type': 'application/json',\n          'User-Agent': userAgent,\n        },\n      });\n\n      return [playbackData.hlsUrl, {}];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start playback');\n    }\n  };\n\n  public login = async (username?: string, password?: string): Promise<boolean> => {\n    const url = [BASE_API_URL, '/v2/login'].join('');\n\n    const authToken = await this.getBaseAuthToken();\n\n    try {\n      const {meta} = await db.providers.findOneAsync<IProvider<any, INwslMeta>>({name: 'nwsl'});\n\n      const params = {\n        id: username || meta.username,\n        secret: password || meta.password,\n      };\n\n      const {data} = await axios.post(url, params, {\n        headers: {\n          Authorization: `Bearer ${authToken}`,\n          Realm: REALM,\n          Referer: REFERRER,\n          'User-Agent': userAgent,\n          accept: 'application/json',\n          'content-type': 'application/json',\n          'x-api-key': API_KEY,\n          'x-app-var': APP_VAR,\n        },\n      });\n\n      this.token = data.authorisationToken;\n      this.refreshToken = data.refreshToken;\n\n      const {exp: tokenExp}: {exp: number} = jwt_decode(this.token);\n      const {exp: refreshExp}: {exp: number} = jwt_decode(this.refreshToken);\n\n      this.tokenExp = tokenExp * 1000;\n      this.refreshTokenExp = refreshExp * 1000;\n\n      await this.save();\n\n      return true;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not login to NWSL+');\n\n      return false;\n    }\n  };\n\n  private getBaseAuthToken = async (): Promise<string> => {\n    const url = [\n      BASE_API_URL,\n      '/v1/init/',\n      '?lk=language',\n      '&pk=subTitleLanguage',\n      '&pk=audioLanguage',\n      '&pk=autoAdvance',\n      '&pk=pluginAccessTokens',\n      '&pk=videoBackgroundAutoPlay',\n      '&readLicences=true',\n      '&countEvents=LIVE',\n      '&menuTargetPlatform=WEB',\n    ].join('');\n\n    try {\n      const {data} = await axios.get(url, {\n        headers: {\n          Referer: REFERRER,\n          'User-Agent': userAgent,\n          accept: 'application/json',\n          'content-type': 'application/json',\n          'x-api-key': API_KEY,\n          'x-app-var': APP_VAR,\n        },\n      });\n\n      console.log(data);\n\n      return data.authentication.authorisationToken;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get init auth token for NWSL+');\n    }\n  };\n\n  private save = async (): Promise<void> => {\n    await db.providers.updateAsync({name: 'nwsl'}, {$set: {tokens: this}});\n  };\n\n  private load = async (): Promise<void> => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TNwslTokens>>({name: 'nwsl'});\n    const {refreshToken, refreshTokenExp, token, tokenExp} = tokens || {};\n\n    this.token = token;\n    this.tokenExp = tokenExp;\n    this.refreshToken = refreshToken;\n    this.refreshTokenExp = refreshTokenExp;\n  };\n}\n\nexport type TNwslTokens = ClassTypeWithoutMethods<NwslHandler>;\n\nexport const nwslHandler = new NwslHandler();\n"
  },
  {
    "path": "services/outside-handler.ts",
    "content": "import axios from 'axios';\nimport moment from 'moment';\n\nimport {okHttpUserAgent} from './user-agent';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {normalTimeRange} from './shared-helpers';\nimport {usesLinear} from './misc-db-service';\nimport {ITubiEvent, tubiHelper} from './tubi-helper';\nimport {debug} from './debug';\n\nconst APP_KEY = [\n  '2',\n  '9',\n  'b',\n  '0',\n  '4',\n  'f',\n  '7',\n  '4',\n  '3',\n  'f',\n  '2',\n  '1',\n  '5',\n  '9',\n  'b',\n  'a',\n  'b',\n  'a',\n  '9',\n  '4',\n  '8',\n  '0',\n  '0',\n  'b',\n  '8',\n  'b',\n  '2',\n  '0',\n  '5',\n  'f',\n  '9',\n  '4',\n].join('');\n\nconst APP_ID = '247';\nconst APP_PLATFORM = 'android_tv';\nconst APP_LANGUAGE = 'en';\n\nconst BASE_API_URL = 'https://api.maz.tv';\n\ninterface IOutsideEvent {\n  cid: string;\n  title: string;\n  summary: string;\n  access: {\n    startsAt: string;\n    endsAt: string;\n  };\n  previewImage: {\n    url: string;\n  };\n  cover: {\n    url: string;\n  };\n}\n\ninterface IOutsideSchedule {\n  sections: {\n    title: string;\n    slug_identifier: string;\n    contentArray: {\n      title: string;\n      slug_identifier: string;\n      contentArray: IOutsideEvent[];\n    }[];\n  }[];\n}\n\nconst parseAirings = async (events: IOutsideEvent[]) => {\n  const [now, endDate] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.cid) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `outside-${event.cid}`});\n\n    if (!entryExists) {\n      const start = moment(new Date(event.access.startsAt));\n      const end = moment(new Date(event.access.endsAt)).add(1, 'hour');\n      const originalEnd = moment(new Date(event.access.endsAt));\n\n      if (end.isBefore(now) || start.isAfter(endDate)) {\n        continue;\n      }\n\n      console.log('Adding event: ', event.title);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: ['Outside TV'],\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'outside',\n        id: `outside-${event.cid}`,\n        image: event.previewImage.url || event.cover.url,\n        name: event.title,\n        network: 'Outside TV',\n        originalEnd: originalEnd.valueOf(),\n        start: start.valueOf(),\n      });\n    }\n  }\n};\n\nconst parseLinearAirings = async (events: ITubiEvent[]) => {\n  const [now, endSchedule] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.id) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `outside-${event.id}`});\n\n    if (!entryExists) {\n      const start = moment(event.start_time);\n      const end = moment(event.end_time);\n\n      if (end.isBefore(now) || start.isAfter(endSchedule)) {\n        continue;\n      }\n\n      console.log('Adding event: ', event.title);\n\n      let image = event.images.thumbnail.find(a => a);\n\n      if (!image) {\n        image = event.images.poster.find(a => a);\n      }\n\n      await db.entries.insertAsync<IEntry>({\n        categories: ['Outside TV'],\n        channel: 'OTVSTR',\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'outside',\n        id: `outside-${event.id}`,\n        image,\n        linear: true,\n        name: event.title,\n        network: 'Outside',\n        originalEnd: end.valueOf(),\n        start: start.valueOf(),\n      });\n    }\n  }\n};\n\nclass OutsideHandler {\n  public token?: string;\n  public locale_id?: number;\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'outside'})) > 0 ? true : false;\n\n    // First time setup\n    if (!setup) {\n      const data: TOutsideTokens = {};\n      const useLinear = await usesLinear();\n\n      await db.providers.insertAsync<IProvider<TOutsideTokens>>({\n        enabled: false,\n        linear_channels: [\n          {\n            enabled: useLinear,\n            id: 'OTVSTR',\n            name: 'Outside',\n            tmsId: '114313',\n          },\n        ],\n        name: 'outside',\n        tokens: data,\n      });\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'outside'});\n\n    if (!enabled) {\n      return;\n    }\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled, linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'outside'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log('Looking for Outside TV events...');\n\n    const entries: IOutsideEvent[] = [];\n\n    const [now, endSchedule] = normalTimeRange();\n\n    const signature = await this.getSignature();\n\n    try {\n      const url = ['https://', 'cloud.maz.tv', '/247/445/en/feeds/v1/tv_app_feed', signature].join('');\n\n      const {data} = await axios.get<IOutsideSchedule>(url, {\n        headers: {\n          'Content-Type': 'application/json',\n          'User-Agent': okHttpUserAgent,\n        },\n      });\n\n      const events =\n        data?.sections\n          .find(a => a.slug_identifier === 'home')\n          ?.contentArray.find(a => a.slug_identifier === 'live-events')?.contentArray || [];\n\n      debug.saveRequestData(events, 'outside', 'epg');\n\n      events.forEach(e => {\n        if (\n          moment(new Date(e.access.startsAt)).isBefore(endSchedule) &&\n          moment(new Date(e.access.startsAt)).isAfter(now)\n        ) {\n          entries.push(e);\n        }\n      });\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse Outside TV events');\n    }\n\n    if (linear_channels?.[0]?.enabled) {\n      await this.getLinearSchedule();\n    }\n\n    await parseAirings(entries);\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    const event = await db.entries.findOneAsync<IEntry>({id: eventId});\n\n    try {\n      let cid = '1187629';\n\n      if (!event.linear) {\n        cid = eventId.split('-')[1];\n      }\n\n      const url = [BASE_API_URL, '/v1', '/streams'].join('');\n\n      const {data} = await axios.post(\n        url,\n        {\n          cid,\n          first_play: true,\n          language: APP_LANGUAGE,\n          locale_id: this.locale_id,\n          platform: APP_PLATFORM,\n          progress: 0,\n        },\n        {\n          headers: {\n            'Content-Type': 'application/json',\n            authorization: `Bearer ${this.token}`,\n            'user-agent': okHttpUserAgent,\n          },\n        },\n      );\n\n      return [data.url, {}];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start playback');\n    }\n  };\n\n  public getAuthCode = async (): Promise<[string, string, string]> => {\n    await this.getSignature();\n\n    try {\n      const url = [BASE_API_URL, '/device_codes'].join('');\n\n      const {data} = await axios.post(\n        url,\n        {\n          app_user: {\n            app_id: APP_ID,\n          },\n          key: APP_KEY,\n          language: APP_LANGUAGE,\n          locale_id: this.locale_id,\n          platform: APP_PLATFORM,\n        },\n        {\n          headers: {\n            'Content-Type': 'application/json',\n            'User-Agent': okHttpUserAgent,\n          },\n        },\n      );\n\n      return [data.code, encodeURIComponent(data.sign_in_url), encodeURIComponent(data.polling_url)];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not login to Outside TV');\n    }\n  };\n\n  public authenticateRegCode = async (checkUrl: string): Promise<boolean> => {\n    const url = [decodeURIComponent(checkUrl), '?key=', APP_KEY].join('');\n\n    console.log('Authenticating Outside TV...', url);\n    try {\n      const {data} = await axios.get(url, {\n        headers: {\n          'Content-Type': 'application/json',\n          'User-Agent': okHttpUserAgent,\n        },\n      });\n\n      if (!data || !data?.polling_success) {\n        return false;\n      }\n\n      this.token = data.token;\n\n      await this.save();\n\n      return true;\n    } catch (e) {\n      return false;\n    }\n  };\n\n  private getLinearSchedule = async (): Promise<void> => {\n    try {\n      const {programs} = await tubiHelper(400000005);\n\n      debug.saveRequestData(programs, 'outside', 'linear-epg');\n\n      await parseLinearAirings(programs);\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse Outside TV linear events');\n    }\n  };\n\n  private getSignature = async (): Promise<string> => {\n    try {\n      const url = [BASE_API_URL, '/policy'].join('');\n\n      const {data} = await axios.post(\n        url,\n        {\n          app_id: APP_ID,\n          key: APP_KEY,\n          language: APP_LANGUAGE,\n        },\n        {\n          headers: {\n            'Content-Type': 'application/json',\n            'User-Agent': okHttpUserAgent,\n          },\n        },\n      );\n\n      this.locale_id = data.locale_id;\n\n      await this.save();\n\n      return data.signature;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get Outside TV signature');\n    }\n  };\n\n  private save = async (): Promise<void> => {\n    await db.providers.updateAsync({name: 'outside'}, {$set: {tokens: this}});\n  };\n\n  private load = async (): Promise<void> => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TOutsideTokens>>({name: 'outside'});\n    const {locale_id, token} = tokens || {};\n\n    this.locale_id = locale_id;\n    this.token = token;\n  };\n}\n\nexport type TOutsideTokens = ClassTypeWithoutMethods<OutsideHandler>;\n\nexport const outsideHandler = new OutsideHandler();\n"
  },
  {
    "path": "services/paramount-handler.ts",
    "content": "import fs from 'fs';\nimport fsExtra from 'fs-extra';\nimport path from 'path';\nimport axios from 'axios';\nimport _ from 'lodash';\nimport moment from 'moment';\nimport crypto from 'crypto';\nimport jwt_decode from 'jwt-decode';\n\nimport {configPath} from './config';\nimport {useParamount} from './networks';\nimport {getRandomHex, normalTimeRange} from './shared-helpers';\nimport {db} from './database';\nimport {ClassTypeWithoutMethods, IEntry, IHeaders, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {debug} from './debug';\nimport {usesLinear} from './misc-db-service';\n\nconst BASE_THUMB_URL = 'https://wwwimage-us.pplusstatic.com/thumbnails/photos/w370-q80/';\nconst BASE_URL = 'https://www.paramountplus.com';\nconst TOKEN = [\n  'A',\n  'B',\n  'C',\n  'v',\n  'v',\n  'U',\n  '1',\n  'P',\n  'v',\n  '0',\n  'B',\n  'R',\n  'R',\n  '9',\n  'a',\n  'W',\n  'Y',\n  'F',\n  'L',\n  'A',\n  'm',\n  '+',\n  'm',\n  '8',\n  'b',\n  'c',\n  'I',\n  'J',\n  'X',\n  'm',\n  '7',\n  'a',\n  '9',\n  'G',\n  'Y',\n  'p',\n  'M',\n  'w',\n  'X',\n  'F',\n  't',\n  'D',\n  'u',\n  'q',\n  '1',\n  'P',\n  '5',\n  'A',\n  'R',\n  'A',\n  'g',\n  '6',\n  'o',\n  '6',\n  '0',\n  'y',\n  'i',\n  'l',\n  'K',\n  '8',\n  'o',\n  'Q',\n  '2',\n  'E',\n  'a',\n  'x',\n  'c',\n  '=',\n].join('');\n\nconst instance = axios.create({\n  baseURL: BASE_URL,\n});\n\ninterface IParamountUserProfile {\n  id: number;\n  isMasterProfile: boolean;\n}\n\ninterface IParamountUser {\n  activeProfile: IParamountUserProfile;\n  accountProfiles: IParamountUserProfile[];\n}\n\ninterface IParamountEvent {\n  videoContentId: string;\n  startTimestamp: number;\n  endTimestamp: number;\n  channelName: string;\n  title: string;\n  filePathThumb: string;\n  linear?: boolean;\n  linearChannel?: string;\n}\n\ninterface IDma {\n  dma: string;\n  tokenDetails: {\n    syncBackToken: string;\n    playback_url: string;\n  };\n}\n\ninterface IChannel {\n  id: number;\n  slug: string;\n  channelName: string;\n  local: boolean;\n}\n\nconst paramountConfigPath = path.join(configPath, 'paramount_tokens.json');\n\nconst ALLOWED_LOCAL_SPORTS = ['College Basketball', 'College Football', 'NFL Football', 'Super Bowl LVIII'];\n\nconst parseAirings = async (events: IParamountEvent[]) => {\n  const [now, inTwoDays] = normalTimeRange();\n\n  for (const event of events) {\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `${event.videoContentId}`});\n\n    if (!entryExists) {\n      const start = moment(event.startTimestamp);\n      const end = moment(event.endTimestamp);\n      const originalEnd = moment(end);\n\n      if (!event.linear) {\n        end.add(1, 'hour');\n      }\n\n      if (end.isBefore(now) || start.isAfter(inTwoDays)) {\n        continue;\n      }\n\n      const categories = ['CBS Sports', 'Paramount+', event.channelName];\n\n      console.log('Adding event: ', event.title);\n\n      await db.entries.insertAsync<IEntry>({\n        categories,\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'paramount+',\n        id: event.videoContentId,\n        image: `${BASE_THUMB_URL}${event.filePathThumb?.replace('files/', '')}`,\n        name: event.title,\n        network: 'Paramount+',\n        originalEnd: originalEnd.valueOf(),\n        start: start.valueOf(),\n        ...(event.linear\n          ? {\n              channel: event.linearChannel,\n              linear: true,\n            }\n          : {\n              sport: event.channelName,\n            }),\n      });\n    }\n  }\n};\n\nlet isParamountDisabled = false;\n\nclass ParamountHandler {\n  public device_id?: string;\n  public hashed_token?: string;\n  public cookies?: string[];\n  public expires?: number;\n  public profileId?: number;\n\n  private appConfig: any;\n  private ip: string;\n  private dma: IDma;\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'paramount'})) > 0 ? true : false;\n\n    if (!setup) {\n      const data: TParamountTokens = {};\n\n      if (useParamount.plus) {\n        this.loadJSON();\n\n        data.cookies = this.cookies;\n        data.device_id = this.device_id;\n        data.expires = this.expires;\n        data.hashed_token = this.hashed_token;\n        data.profileId = this.profileId;\n      }\n\n      await db.providers.insertAsync<IProvider<TParamountTokens>>({\n        enabled: useParamount.plus,\n        linear_channels: [\n          {\n            enabled: useParamount.cbsSportsHq,\n            id: 'cbssportshq',\n            name: 'CBS Sports HQ',\n            tmsId: '108919',\n          },\n          {\n            enabled: useParamount.golazo,\n            id: 'golazo',\n            name: 'GOLAZO Network',\n            tmsId: '133691',\n          },\n        ],\n        name: 'paramount',\n        tokens: data,\n      });\n\n      if (fs.existsSync(paramountConfigPath)) {\n        fs.rmSync(paramountConfigPath);\n      }\n    }\n\n    if (useParamount.plus) {\n      console.log('Using PARAMOUNTPLUS variable is no longer needed. Please use the UI going forward');\n    }\n    if (useParamount.golazo) {\n      console.log('Using GOLAZO variable is no longer needed. Please use the UI going forward');\n    }\n    if (useParamount.cbsSportsHq) {\n      console.log('Using CBSSPORTSHQ variable is no longer needed. Please use the UI going forward');\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'paramount'});\n\n    if (!enabled || isParamountDisabled) {\n      return;\n    }\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n\n    if (!this.appConfig) {\n      await this.getAppConfig();\n    }\n\n    if (!this.profileId) {\n      await this.getUserProfile();\n    }\n  };\n\n  public refreshTokens = async () => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'paramount'});\n\n    if (!enabled || isParamountDisabled) {\n      return;\n    }\n\n    if (moment().valueOf() > moment(this.expires).subtract(1, 'month').valueOf()) {\n      await this.getNewTokens();\n    }\n  };\n\n  public getSchedule = async () => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'paramount'});\n\n    if (!enabled || isParamountDisabled) {\n      return;\n    }\n\n    console.log('Looking for Paramount+ events...');\n\n    const events: IParamountEvent[] = [];\n\n    try {\n      const {data} = await instance.get<{listings: IParamountEvent[]}>(\n        `/apps-api/v3.0/androidtv/hub/multi-channel-collection/live-and-upcoming.json?${new URLSearchParams({\n          at: TOKEN,\n          locale: 'en-us',\n          platformType: 'androidtv',\n          rows: '300',\n          start: '0',\n        })}`,\n        {\n          headers: {\n            Cookie: this.cookies,\n          },\n        },\n      );\n\n      data.listings?.forEach(e => events.push(e));\n\n      const channels = await this.getLiveChannels();\n\n      debug.saveRequestData(data, 'paramount+local', 'epg');\n\n      for (const c of channels) {\n        try {\n          const {data} = await instance.get(\n            `/apps-api/v3.0/androidphone/live/channels/${c.slug}/listings.json?${new URLSearchParams({\n              _clientRegion: this.appConfig.countAsyncry,\n              at: TOKEN,\n              locale: 'en-us',\n              rows: '125',\n              showListing: 'true',\n              start: '0',\n            })}`,\n            {\n              headers: {\n                Cookie: this.cookies,\n              },\n            },\n          );\n\n          if (c.local) {\n            (data.listing || []).forEach(e => {\n              if (ALLOWED_LOCAL_SPORTS.includes(e.title)) {\n                const transformedEvent: IParamountEvent = {\n                  channelName: e.title,\n                  endTimestamp: e.endTimestamp,\n                  filePathThumb: e.filePathThumb,\n                  startTimestamp: e.startTimestamp,\n                  title: e.episodeTitle || e.title,\n                  videoContentId: e.videoContentId.startsWith('_')\n                    ? `${e.endTimestamp}----${e.videoContentId}`\n                    : e.videoContentId,\n                };\n\n                events.push(transformedEvent);\n              }\n            });\n          } else {\n            (data.listing || []).forEach(e => {\n              const transformedEvent: IParamountEvent = {\n                channelName: e.title,\n                endTimestamp: e.endTimestamp,\n                filePathThumb: e.filePathThumb,\n                linear: true,\n                linearChannel: c.slug,\n                startTimestamp: e.startTimestamp,\n                title: e.episodeTitle || e.title,\n                videoContentId: `${e.endTimestamp}::::${e.videoContentId}`,\n              };\n\n              events.push(transformedEvent);\n            });\n          }\n        } catch (e) {\n          console.error(e);\n          console.log('Could not get EPG for: ', c.channelName);\n        }\n      }\n    } catch (e) {\n      console.error(e);\n      console.log('Could not find events for Paramount+');\n    }\n\n    await parseAirings(events);\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    try {\n      const data = await this.getSteamData(eventId);\n\n      if (!data) {\n        throw new Error('Could not get stream data. Event might be upcoming, ended, or in blackout...');\n      }\n\n      return [data.streamingUrl, this.getPlaybackAuthToken];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get stream information!');\n    }\n  };\n\n  private getPlaybackAuthToken = async (eventId: string, headers?: IHeaders): Promise<IHeaders> => {\n    let newHeaders: IHeaders = {};\n\n    const updateLsSession = async (): Promise<void> => {\n      try {\n        const data = await this.getSteamData(eventId);\n\n        newHeaders = {\n          ...(data.ls_session && {\n            Authorization: `Bearer ${data.ls_session}`,\n          }),\n        };\n      } catch (e) {}\n    };\n\n    if (!headers) {\n      await updateLsSession();\n    } else {\n      newHeaders = _.cloneDeep(headers);\n\n      const lsSession = (headers['Authorization'] as string)?.split(' ')[1];\n\n      if (lsSession) {\n        const {exp}: {exp: number} = jwt_decode(lsSession);\n\n        if (moment(exp * 1000).isBefore(moment().add(30, 'minutes'))) {\n          await updateLsSession();\n        }\n      }\n    }\n\n    return newHeaders;\n  };\n\n  private getSteamData = async (id: string): Promise<{streamingUrl: string; ls_session?: string}> => {\n    try {\n      // Local channel stream\n      if (id.indexOf('----') > -1) {\n        await this.getDma();\n\n        return {\n          streamingUrl: this.dma.tokenDetails.playback_url,\n        };\n      } else {\n        let contentId = id;\n\n        if (id.indexOf('::::') > -1) {\n          contentId = id.split('::::')[1];\n        }\n\n        const {data} = await instance.get(\n          `/apps-api/v3.1/androidphone/irdeto-control/session-token.json?${new URLSearchParams({\n            at: TOKEN,\n            contentId,\n            locale: 'en-us',\n          })}`,\n          {\n            headers: {\n              Cookie: this.cookies,\n            },\n          },\n        );\n\n        if (!data || !data.streamingUrl || !data.ls_session) {\n          throw new Error('Could not get stream data');\n        }\n\n        return data;\n      }\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get stream data');\n    }\n  };\n\n  private getLiveChannels = async (): Promise<IChannel[]> => {\n    if (!this.dma) {\n      await this.getDma();\n    }\n\n    const useLinear = await usesLinear();\n\n    try {\n      const {data} = await instance.get<{carousel: IChannel[]}>(\n        `/apps-api/v3.0/androidphone/home/configurator/channels.json?${new URLSearchParams({\n          _clientRegion: this.appConfig.countAsyncry_code,\n          at: TOKEN,\n          dma: this.dma?.dma,\n          locale: 'en-us',\n          rows: '100',\n          showListing: 'true',\n          start: '0',\n        })}`,\n      );\n\n      debug.saveRequestData(data, 'paramount+channels', 'epg');\n\n      const channels: IChannel[] = [];\n\n      for (const c of data.carousel) {\n        if (c.local) {\n          channels.push(c);\n        }\n\n        if (useLinear) {\n          const {linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'paramount'});\n\n          const useCbsSportsHq = linear_channels.find(c => c.id === 'cbssportshq');\n          const useGolazo = linear_channels.find(c => c.id === 'golazo');\n\n          if (useCbsSportsHq && c.channelName === 'CBS Sports HQ') {\n            channels.push(c);\n          }\n\n          if (useGolazo && c.channelName === 'CBS Sports Golazo Network') {\n            channels.push(c);\n          }\n        }\n      }\n\n      return channels;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get channel list for Paramount+');\n    }\n  };\n\n  private getDma = async (): Promise<void> => {\n    if (!this.ip) {\n      await this.getIpAddress();\n    }\n\n    try {\n      const {data} = await instance.get(\n        `/apps-api/v3.0/androidphone/dma.json?${new URLSearchParams({\n          at: TOKEN,\n          did: this.device_id,\n          dtp: '8',\n          ipaddress: this.ip,\n          is60FPS: 'true',\n          locale: 'en-us',\n          mvpdId: 'AllAccess',\n          syncBackVersion: '3.0',\n        })}`,\n        {\n          headers: {\n            Cookie: this.cookies,\n          },\n        },\n      );\n\n      if (data && data.success && data.dmas && data.dmas[0]) {\n        this.dma = data.dmas[0];\n      }\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get DMA information');\n    }\n  };\n\n  private getIpAddress = async (): Promise<void> => {\n    try {\n      const {data} = await instance.get(\n        `/apps/user/ip.json?${new URLSearchParams({\n          at: TOKEN,\n          locale: 'en-us',\n        })}`,\n        {\n          headers: {\n            Cookie: this.cookies,\n          },\n        },\n      );\n\n      this.ip = data.ip;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get IP address');\n    }\n  };\n\n  private getAppConfig = async (): Promise<void> => {\n    try {\n      const {data} = await instance.get(\n        `/apps-api/v2.0/androidphone/app/status.json?${new URLSearchParams({\n          at: TOKEN,\n          locale: 'en-us',\n        })}`,\n        {\n          headers: {\n            Cookie: this.cookies,\n          },\n        },\n      );\n\n      if (!data || !data.appVersion || !data.appVersion.availableInRegion) {\n        console.log('Paramount+ account not available in region - disabling P+ integration...');\n        isParamountDisabled = true;\n        return;\n      }\n\n      if (!data.appConfig) {\n        isParamountDisabled = true;\n        throw new Error('Getting app config failed');\n      }\n\n      if (data.appConfig.livetv_disabled) {\n        isParamountDisabled = true;\n        console.log('Paramount+ account does not have access to live TV - disabling P+ integration...');\n        return;\n      }\n\n      this.appConfig = data.appConfig;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get Paramount+ app config');\n    }\n  };\n\n  private getNewTokens = async (): Promise<void> => {\n    try {\n      const {headers} = await instance.post(\n        `/apps-api/v2.0/androidtv/user/account/profile/switch/${this.profileId}.json?${new URLSearchParams({\n          at: TOKEN,\n          locale: 'en-us',\n        })}`,\n        {},\n        {\n          headers: {\n            Cookie: this.cookies,\n          },\n        },\n      );\n\n      this.saveCookies(headers['set-cookie']);\n    } catch (e) {\n      console.error(e);\n      console.log('Could not refresh tokens for Paramount+!');\n    }\n  };\n\n  private getUserProfile = async (): Promise<void> => {\n    try {\n      const user = await this.getUser();\n\n      if (!user || !user.activeProfile || !user.activeProfile.id) {\n        const masterProfile = _.find(user.accountProfiles, p => p.isMasterProfile);\n\n        if (!masterProfile) {\n          throw new Error('Could not parse out a master profile');\n        }\n\n        this.profileId = masterProfile.id;\n      } else {\n        this.profileId = user.activeProfile.id;\n      }\n\n      this.save();\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get user profile!');\n    }\n  };\n\n  private getUser = async (): Promise<IParamountUser> => {\n    try {\n      const {data} = await instance.get<IParamountUser>(\n        `/apps-api/v3.0/androidtv/login/status.json?${new URLSearchParams({\n          at: TOKEN,\n          locale: 'en-us',\n        })}`,\n        {\n          headers: {\n            Cookie: this.cookies,\n          },\n        },\n      );\n\n      return data;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get Paramount+ user!');\n    }\n  };\n\n  public getAuthCode = async (): Promise<[string, string]> => {\n    this.device_id = _.take(getRandomHex(), 16).join('');\n    this.hashed_token = crypto\n      .createHmac('sha1', 'eplustv')\n      .update(this.device_id)\n      .digest()\n      .toString('base64')\n      .substring(0, 16);\n\n    try {\n      const {data} = await instance.post(\n        `/apps-api/v2.0/androidtv/ott/auth/code.json?${new URLSearchParams({\n          at: TOKEN,\n          deviceId: this.hashed_token,\n        }).toString()}`,\n      );\n\n      return [data.activationCode, data.deviceToken];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start the authentication process for Paramount+!');\n    }\n  };\n\n  public authenticateRegCode = async (activationCode: string, deviceToken: string): Promise<boolean> => {\n    const regUrl = [\n      '/apps-api/v2.0/androidtv/ott/auth/status.json?',\n      new URLSearchParams({\n        activationCode,\n        at: TOKEN,\n        deviceId: this.hashed_token,\n        deviceToken,\n      }).toString(),\n    ].join('');\n\n    try {\n      const {data, headers} = await instance.post(regUrl);\n\n      if (!data.success) {\n        return false;\n      }\n\n      this.saveCookies(headers['set-cookie']);\n\n      if (!this.appConfig) {\n        await this.getAppConfig();\n      }\n\n      if (!this.profileId) {\n        await this.getUserProfile();\n      }\n\n      return true;\n    } catch (e) {\n      return false;\n    }\n  };\n\n  private saveCookies = (cookies: string[]) => {\n    this.cookies = cookies;\n    this.expires = moment().add(1, 'year').valueOf();\n    this.save();\n  };\n\n  private save = async () => {\n    await db.providers.updateAsync({name: 'paramount'}, {$set: {tokens: _.omit(this, 'appConfig', 'ip', 'dma')}});\n  };\n\n  private load = async (): Promise<void> => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TParamountTokens>>({name: 'paramount'});\n    const {device_id, hashed_token, cookies, expires} = tokens || {};\n\n    this.device_id = device_id;\n    this.hashed_token = hashed_token;\n    this.cookies = cookies;\n    this.expires = expires;\n  };\n\n  private loadJSON = () => {\n    if (fs.existsSync(paramountConfigPath)) {\n      const {device_id, hashed_token, cookies, expires, profileId} = fsExtra.readJSONSync(paramountConfigPath);\n\n      this.device_id = device_id;\n      this.hashed_token = hashed_token;\n      this.cookies = cookies;\n      this.expires = expires;\n      this.profileId = profileId;\n    }\n  };\n}\n\nexport type TParamountTokens = ClassTypeWithoutMethods<ParamountHandler>;\n\nexport const paramountHandler = new ParamountHandler();\n"
  },
  {
    "path": "services/playlist-handler.ts",
    "content": "import HLS from 'hls-parser';\nimport axios from 'axios';\nimport _ from 'lodash';\n\nimport {userAgent} from './user-agent';\nimport {IHeaders, THeaderInfo} from './shared-interfaces';\nimport {cacheLayer, promiseCache} from './caching';\nimport {proxySegments} from './misc-db-service';\n\nconst isRelativeUrl = (url?: string): boolean => (url?.startsWith('http') ? false : true);\nconst cleanUrl = (url: string): string => url.replace(/(\\[.*\\])/gm, '').replace(/(?<!:)\\/\\//gm, '/');\nconst createBaseUrl = (url: string): string => {\n  const cleaned = url.replace(/\\.m3u8.*$/, '');\n  return cleaned.substring(0, cleaned.lastIndexOf('/') + 1);\n};\nconst createBaseUrlChunklist = (url: string, network: string): string => {\n  const cleaned = url.replace(/\\.m3u8.*$/, '');\n  let filteredUrl: string[] | string = cleaned.split('/');\n\n  if (network === 'foxsports' && !url.includes('akamai')) {\n    filteredUrl = filteredUrl.filter(seg => !seg.match(/=/));\n  }\n\n  filteredUrl = filteredUrl.join('/');\n  return filteredUrl.substring(0, filteredUrl.lastIndexOf('/') + 1);\n};\nconst usesHostRoot = (url: string): boolean => url.startsWith('/');\nconst convertHostUrl = (url: string, fullUrl: string): string => {\n  const uri = new URL(fullUrl);\n\n  return `${uri.origin}${url}`;\n};\nconst isBase64Uri = (url: string) => url.indexOf('base64') > -1 || url.startsWith('data');\n\nconst reTarget = /#EXT-X-TARGETDURATION:([0-9]+)/;\nconst reAudioTrack = /#EXT-X-MEDIA:TYPE=AUDIO.*?,URI=\"([^\"]+?)\"/gm;\nconst reMap = /#EXT-X-MAP:URI=\"([^\"]+)\"/gm;\nconst reSubMap = /#EXT-X-MEDIA:TYPE=SUBTITLES.*?,URI=\"([^\"]+?)\"/gm;\nconst reSubMapVictory = /#EXT-X-MEDIA:.*TYPE=SUBTITLES.*URI=\"([^\"]+)\"/gm;\nconst reSubMapYT = /#EXT-X-MEDIA:URI=\"([^\"]+)\",TYPE=SUBTITLES.*/gm;\nconst reVersion = /#EXT-X-VERSION:(\\d+)/;\n\nconst updateVersion = (playlist: string): string =>\n  playlist.replace(reVersion, (match, currentVersion) => {\n    const numericValue = +currentVersion;\n    const newVersion = numericValue < 5 ? 5 : numericValue;\n    return `#EXT-X-VERSION:${newVersion}`;\n  });\n\nconst getTargetDuration = (chunklist: string, divide = true): number => {\n  let targetDuration = 2;\n\n  const tester = reTarget.exec(chunklist);\n\n  if (tester && tester[1]) {\n    targetDuration = divide ? Math.floor(parseInt(tester[1], 10) / 2) : parseInt(tester[1], 10);\n\n    if (!_.isNumber(targetDuration) || _.isNaN(targetDuration)) {\n      targetDuration = 2;\n    }\n  }\n\n  return targetDuration;\n};\n\nconst handleDateranges = (manifest: string, network: string): string => {\n  if (network !== 'foxone') {\n    return manifest;\n  }\n  return manifest.replace(/#EXT-X-DATERANGE.*\\n/gm, '');\n};\n\nconst parseReplacementUrl = (uri: string, manifestUrl: string): string => {\n  if (isRelativeUrl(uri)) {\n    if (usesHostRoot(uri)) {\n      return convertHostUrl(uri, manifestUrl);\n    }\n    const base = createBaseUrl(manifestUrl);\n    const cleanedUri = cleanUrl(`${base}${uri.split('?')[0]}`);\n    const query = uri.includes('?') ? `?${uri.split('?')[1]}` : '';\n    return `${cleanedUri}${query}`;\n  }\n  return uri.replace(/\\/(\\d+_\\w+\\/\\*\\~\\/)/, '/');\n};\n\nexport class PlaylistHandler {\n  public playlist: string;\n  private chunklistUrlMap: Map<string, string> = new Map();\n\n  private baseUrl: string;\n  private baseProxyUrl: string;\n  private headers: THeaderInfo;\n  private overlayCookies?: string[];\n  private currentHeaders?: IHeaders;\n  private channel: string;\n  private segmentDuration: number;\n  private network: string;\n  private eventId: string | number;\n\n  constructor(headers: THeaderInfo, appUrl: string, channel: string, network: string, eventId: string | number) {\n    this.headers = headers;\n    this.channel = channel;\n    this.baseUrl = `${appUrl}/channels/${channel}/`;\n    this.baseProxyUrl = `${appUrl}/chunklist/${channel}/`;\n    this.network = network;\n    this.eventId = eventId;\n  }\n\n  public async initialize(manifestUrl: string): Promise<void> {\n    const headers = await this.getHeaders();\n    await this.parseManifest(manifestUrl, headers);\n  }\n\n  public async getSegmentOrKey(segmentId: string): Promise<ArrayBuffer> {\n    try {\n      const headers = await this.getHeaders();\n      return cacheLayer.getDataFromSegment(segmentId, headers, this.network);\n    } catch (e) {\n      console.log('Could not get segment or key properly!');\n      console.error(e);\n      //throw e;\n    }\n  }\n\n  public async parseManifest(manifestUrl: string, headers: IHeaders): Promise<void> {\n    try {\n      if (!('Accept-Encoding' in headers)) {\n        headers['Accept-Encoding'] = 'identity';\n      }\n      if (!('User-Agent' in headers)) {\n        headers['User-Agent'] = userAgent;\n      }\n      \n      const {\n        data: manifest,\n        request,\n        headers: resHeaders,\n      } = await axios.get<string>(manifestUrl, {\n        headers: headers,\n      });\n\n      const processedManifest = this.network === 'foxone' ? handleDateranges(manifest, this.network) : manifest;\n\n      if (resHeaders['set-cookie']) {\n        this.overlayCookies = resHeaders['set-cookie'];\n      }\n\n      const realManifestUrl = request.res.responseUrl;\n\n      let urlParams = '';\n      if (this.network === 'foxsports' || this.network === 'foxone') {\n        try {\n          const parsedUrl = new URL(realManifestUrl);\n          urlParams = parsedUrl.search;\n        } catch (error) {\n          console.error('Invalid URL provided:', error);\n          urlParams = '';\n        }\n      }\n\n      const playlist = HLS.parse(processedManifest);\n\n      /** Sort playlist so highest resolution is first in list (Emby workaround) */\n      playlist.variants?.sort((v1, v2) => {\n        if (v1.bandwidth > v2.bandwidth) {\n          return -1;\n        }\n\n        if (v1.bandwidth < v2.bandwidth) {\n          return 1;\n        }\n\n        return 0;\n      });\n\n      const isUHDStream = playlist.variants?.some(variant => variant.resolution?.width >= 3840);\n\n      const clonedManifest = updateVersion(HLS.stringify(playlist));\n      let updatedManifest = clonedManifest;\n\n      const cleanForChunklist = (url: string): string => {\n        try {\n          const parsedUrl = new URL(url);\n          return parsedUrl.pathname.split('/').pop()?.replace(/\\.m3u8$/, '') || 'chunklist';\n        } catch {\n          return url.split('/').pop()?.replace(/\\.m3u8$/, '') || 'chunklist';\n        }\n      };\n\n      const mergeQueryParams = (baseUrl: string, params: string): string => {\n        try {\n          const base = new URL(baseUrl);\n          const existingParams = new URLSearchParams(base.search);\n          const newParams = new URLSearchParams(params);\n          newParams.forEach((value, key) => {\n            existingParams.set(key, value);\n          });\n          base.search = existingParams.toString();\n          return base.toString();\n        } catch {\n          return `${baseUrl}${params}`;\n        }\n      };\n\n      if (this.network === 'victory' || this.network === 'bally') {\n        const subTracks = [...manifest.matchAll(reSubMapVictory)];\n        subTracks.forEach(track => {\n          if (track && track[1]) {\n            const fullChunklistUrl = parseReplacementUrl(track[1], realManifestUrl);\n            const chunklistName = cacheLayer.getChunklistFromUrl(fullChunklistUrl);\n            updatedManifest = updatedManifest.replace(track[1], `${this.baseProxyUrl}${chunklistName}.m3u8`);\n          }\n        });\n      }\n\n      if (this.network === 'pwhl') {\n        const subTracks = [...manifest.matchAll(reSubMapYT)];\n        subTracks.forEach(track => {\n          if (track && track[1]) {\n            const fullChunklistUrl = parseReplacementUrl(track[1], realManifestUrl);\n            const chunklistName = cacheLayer.getChunklistFromUrl(fullChunklistUrl);\n            updatedManifest = updatedManifest.replace(track[1], `${this.baseProxyUrl}${chunklistName}.m3u8`);\n          }\n        });\n      }\n\n      if (this.network !== 'foxsports' && this.network != 'pwhl') {\n        const audioTracks = [...processedManifest.matchAll(reAudioTrack)];\n        audioTracks.forEach(track => {\n          if (track && track[1]) {\n            const fullChunklistUrl = parseReplacementUrl(track[1], realManifestUrl);\n            const chunklistUrlForName = this.network === 'foxone' && isUHDStream ? cleanForChunklist(fullChunklistUrl) : fullChunklistUrl;\n            const chunklistName = cacheLayer.getChunklistFromUrl(chunklistUrlForName);\n            if (this.network === 'foxone' && isUHDStream) {\n              const fullUrl = urlParams ? mergeQueryParams(fullChunklistUrl, urlParams) : fullChunklistUrl;\n              this.chunklistUrlMap.set(chunklistName, fullUrl);\n            }\n            updatedManifest = updatedManifest.replace(track[1], `${this.baseProxyUrl}${chunklistName}.m3u8`);\n          }\n        });\n\n        const subTracks = [...processedManifest.matchAll(reSubMap)];\n        subTracks.forEach(track => {\n          if (track && track[1]) {\n            const fullChunklistUrl = parseReplacementUrl(track[1], realManifestUrl);\n            const chunklistUrlForName = this.network === 'foxone' && isUHDStream ? cleanForChunklist(fullChunklistUrl) : fullChunklistUrl;\n            const chunklistName = cacheLayer.getChunklistFromUrl(chunklistUrlForName);\n            if (this.network === 'foxone' && isUHDStream) {\n              const fullUrl = urlParams ? mergeQueryParams(fullChunklistUrl, urlParams) : fullChunklistUrl;\n              this.chunklistUrlMap.set(chunklistName, fullUrl);\n            }\n            updatedManifest = updatedManifest.replace(track[1], `${this.baseProxyUrl}${chunklistName}.m3u8`);\n          }\n        });\n\n        if (this.network === 'foxone' && isUHDStream && audioTracks.length === 0 && subTracks.length === 0) {\n          if (playlist.media && playlist.media.length > 0) {\n            playlist.media.forEach(media => {\n              if (media.type === 'AUDIO' || media.type === 'SUBTITLES') {\n                const fullChunklistUrl = parseReplacementUrl(media.uri, realManifestUrl);\n                const chunklistUrlForName = cleanForChunklist(fullChunklistUrl);\n                const chunklistName = cacheLayer.getChunklistFromUrl(chunklistUrlForName);\n                const fullUrl = urlParams ? mergeQueryParams(fullChunklistUrl, urlParams) : fullChunklistUrl;\n                this.chunklistUrlMap.set(chunklistName, fullUrl);\n                updatedManifest = updatedManifest.replace(media.uri, `${this.baseProxyUrl}${chunklistName}.m3u8`);\n              }\n            });\n          } else {\n            const lines = processedManifest.split('\\n');\n            lines.forEach(line => {\n              if (line.includes('TYPE=AUDIO') || line.includes('TYPE=SUBTITLES')) {\n                const uriMatch = line.match(/URI=\"([^\"]+?)\"/);\n                if (uriMatch && uriMatch[1]) {\n                  const fullChunklistUrl = parseReplacementUrl(uriMatch[1], realManifestUrl);\n                  const chunklistUrlForName = cleanForChunklist(fullChunklistUrl);\n                  const chunklistName = cacheLayer.getChunklistFromUrl(chunklistUrlForName);\n                  const fullUrl = urlParams ? mergeQueryParams(fullChunklistUrl, urlParams) : fullChunklistUrl;\n                  this.chunklistUrlMap.set(chunklistName, fullUrl);\n                  updatedManifest = updatedManifest.replace(uriMatch[1], `${this.baseProxyUrl}${chunklistName}.m3u8`);\n                }\n              }\n            });\n          }\n        }\n      }\n\n      playlist.variants?.forEach(variant => {\n        const fullChunklistUrl = parseReplacementUrl(variant.uri, realManifestUrl);\n        const chunklistUrlForName = this.network === 'foxone' && isUHDStream ? cleanForChunklist(fullChunklistUrl) : fullChunklistUrl;\n        const chunklistName = cacheLayer.getChunklistFromUrl(chunklistUrlForName);\n        if (this.network === 'foxone' && isUHDStream) {\n          const fullUrl = urlParams ? mergeQueryParams(fullChunklistUrl, urlParams) : fullChunklistUrl;\n          this.chunklistUrlMap.set(chunklistName, fullUrl);\n        }\n        updatedManifest = updatedManifest.replace(variant.uri, `${this.baseProxyUrl}${chunklistName}.m3u8`);\n      });\n      \n      for (const key of playlist.sessionKeyList) {\n        const fullKeyUrl = isRelativeUrl(key.uri)\n          ? usesHostRoot(key.uri)\n            ? convertHostUrl(key.uri, realManifestUrl)\n            : this.network === 'foxone'\n              ? cleanUrl(`${createBaseUrl(realManifestUrl)}${key.uri}`)\n              : cleanUrl(`${realManifestUrl}${key.uri}`)\n          : key.uri;\n        \n        const response = await axios.get<string>(fullKeyUrl, {\n          headers: headers,\n          responseType: 'arraybuffer',\n        });\n        \n        const buffer = Buffer.from(response.data);\n        const base64String = buffer.toString('base64');\n\n        updatedManifest = updatedManifest.replace(key.uri, 'data:text/plain;base64,' + base64String);\n      }\n\n      this.playlist = updatedManifest;\n    } catch (e) {\n      console.log('Could not parse M3U8 properly!');\n      console.error(e);\n      //throw e;\n    }\n  }\n\n  public cacheChunklist(chunklistId: string): Promise<string> {\n    if (this.segmentDuration) {\n      return promiseCache.getPromise(chunklistId, this.proxyChunklist(chunklistId), this.segmentDuration * 1000);\n    }\n\n    return this.proxyChunklist(chunklistId);\n  }\n\n  private async proxyChunklist(chunkListId: string): Promise<string> {\n    const proxyAllSegments = await proxySegments();\n\n    try {\n      let url = this.chunklistUrlMap.get(chunkListId);\n      if (!url) {\n        url = cacheLayer.getChunklistFromId(chunkListId);\n      }\n\n      const headers = await this.getHeaders();\n\n      if (!('Accept-Encoding' in headers)) {\n        headers['Accept-Encoding'] = 'identity';\n      }\n      if (!('User-Agent' in headers)) {\n        headers['User-Agent'] = userAgent;\n      }\n      \n      const {data: chunkList, request} = await axios.get<string>(url, {\n        headers: headers,\n      });\n\n      const processedChunklist = this.network === 'foxone' ? handleDateranges(chunkList, this.network) : chunkList;\n\n      const realChunklistUrl = request.res.responseUrl;\n      const baseManifestUrl = cleanUrl(createBaseUrlChunklist(realChunklistUrl, this.network));\n      const keys = new Set<string>();\n\n      const clonedChunklist = updateVersion(processedChunklist);\n      let updatedChunkList = clonedChunklist;\n\n      const chunks = HLS.parse(clonedChunklist);\n\n      const shouldProxy =\n        proxyAllSegments || (this.network !== 'foxone' && baseManifestUrl.includes('akamai')) || this.network === 'mlbtv' || this.network === 'gotham';\n\n      chunks.segments.forEach(segment => {\n        const segmentUrl = segment.uri;\n        const segmentKey = segment.key?.uri;\n\n        const fullSegmentUrl = isRelativeUrl(segmentUrl)\n          ? usesHostRoot(segmentUrl)\n            ? convertHostUrl(segmentUrl, baseManifestUrl)\n            : cleanUrl(`${baseManifestUrl}${segmentUrl}`)\n          : segmentUrl;\n\n        if (\n          shouldProxy &&\n          // Proxy keyed segments\n          (segmentKey ||\n            // Proxy non-keyed segments that aren't on ESPN\n            (!segmentKey && this.network !== 'espn')) &&\n          // Just until I figure out a workaround\n          !segmentUrl.endsWith('mp4')\n        ) {\n          const segmentName = cacheLayer.getSegmentFromUrl(fullSegmentUrl, `${this.channel}-segment`);\n          updatedChunkList = updatedChunkList.replace(segmentUrl, `${this.baseUrl}${segmentName}.ts`);\n        } else {\n          updatedChunkList = updatedChunkList.replace(segmentUrl, fullSegmentUrl);\n        }\n\n        if (segmentKey && !isBase64Uri(segmentKey)) {\n          keys.add(segmentKey);\n        }\n      });\n\n      if (!this.segmentDuration) {\n        this.segmentDuration = getTargetDuration(chunkList);\n      }\n\n      keys.forEach(key => {\n        const fullKeyUrl = isRelativeUrl(key)\n          ? usesHostRoot(key)\n            ? convertHostUrl(key, baseManifestUrl)\n            : cleanUrl(`${baseManifestUrl}${key}`)\n          : key;\n\n        const keyName = cacheLayer.getSegmentFromUrl(fullKeyUrl, `${this.channel}-key`);\n\n        while (updatedChunkList.indexOf(key) > -1) {\n          updatedChunkList = updatedChunkList.replace(key, `${this.baseUrl}${keyName}.key`);\n        }\n      });\n\n      const xMaps = [...updatedChunkList.matchAll(reMap)];\n\n      xMaps.forEach(xmap => {\n        if (xmap && xmap[1]) {\n          const fullMapUrl = isRelativeUrl(xmap[1])\n            ? usesHostRoot(xmap[1])\n              ? convertHostUrl(xmap[1], baseManifestUrl)\n              : cleanUrl(`${baseManifestUrl}${xmap[1]}`)\n            : xmap[1];\n\n          if (shouldProxy) {\n            const m4iName = cacheLayer.getSegmentFromUrl(fullMapUrl, `${this.channel}-m4i`);\n            updatedChunkList = updatedChunkList.replace(xmap[1], `${this.baseUrl}${m4iName}.m4i`);\n          } else {\n            updatedChunkList = updatedChunkList.replace(xmap[1], fullMapUrl);\n          }\n        }\n      });\n\n      return updatedChunkList;\n    } catch (e) {\n      console.log('Could not parse chunklist properly!');\n      console.error(e);\n      //throw e;\n    }\n  }\n\n  private async getHeaders(): Promise<IHeaders> {\n    let headers: IHeaders = {};\n\n    if (_.isFunction(this.headers)) {\n      headers = await this.headers(this.eventId, this.currentHeaders);\n    } else {\n      headers = _.cloneDeep(this.headers);\n    }\n\n    this.currentHeaders = _.cloneDeep(headers);\n\n    if (this.overlayCookies) {\n      if (headers.Cookie) {\n        headers.Cookie = [\n          ...new Set([...(_.isArray(headers.Cookie) ? headers.Cookie : [`${headers.Cookie}`]), ...this.overlayCookies]),\n        ];\n      } else {\n        headers.Cookie = this.overlayCookies;\n      }\n    }\n\n    return headers;\n  }\n}\n"
  },
  {
    "path": "services/port.ts",
    "content": "import _ from 'lodash';\n\nlet serverPort = _.toNumber(process.env.PORT);\nif (_.isNaN(serverPort)) {\n  serverPort = 8000;\n}\n\nexport const SERVER_PORT = serverPort;\n"
  },
  {
    "path": "services/providers/b1g/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {B1GBody} from './views/CardBody';\n\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {b1gHandler, TB1GTokens} from '@/services/b1g-handler';\n\nexport const b1g = new Hono().basePath('/b1g');\n\nconst scheduleEvents = async () => {\n  await b1gHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('b1g+');\n};\n\nb1g.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['b1g-enabled'] === 'on';\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider<TB1GTokens>, any>({name: 'b1g'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  if ( await b1gHandler.ispAccess() ) {\n    const {affectedDocuments} = await db.providers.updateAsync<IProvider<TB1GTokens>, any>(\n      {name: 'b1g'},\n      {\n        $set: {\n          enabled: true,\n          meta: {\n            password: '',\n            username: '',\n          },\n        },\n      },\n      {returnUpdatedDocs: true},\n    );\n    const {tokens} = affectedDocuments as IProvider<TB1GTokens>;\n\n    // Kickoff event scheduler\n    scheduleEvents();\n\n    return c.html(<B1GBody enabled={true} tokens={tokens} open={true} />, 200, {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled B1G+\"}}`,\n    });\n  }\n\n  return c.html(<Login />);\n});\n\nb1g.post('/login', async c => {\n  const body = await c.req.parseBody();\n  const username = body.username as string;\n  const password = body.password as string;\n\n  const isAuthenticated = await b1gHandler.login(username, password);\n\n  if (!isAuthenticated) {\n    return c.html(<Login invalid={true} />);\n  }\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TB1GTokens>, any>(\n    {name: 'b1g'},\n    {\n      $set: {\n        enabled: true,\n        meta: {\n          password,\n          username,\n        },\n      },\n    },\n    {returnUpdatedDocs: true},\n  );\n  const {tokens} = affectedDocuments as IProvider<TB1GTokens>;\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<B1GBody enabled={true} tokens={tokens} open={true} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled B1G+\"}}`,\n  });\n});\n\nb1g.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n"
  },
  {
    "path": "services/providers/b1g/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TB1GTokens} from '@/services/b1g-handler';\n\ninterface IB1GBodyProps {\n  enabled: boolean;\n  tokens?: TB1GTokens;\n  open?: boolean;\n}\n\nexport const B1GBody: FC<IB1GBodyProps> = ({enabled, tokens, open}) => {\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/b1g/reauth\" hx-trigger=\"submit\">\n            <button id=\"b1g-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/b1g/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\ninterface ILoginProps {\n  invalid?: boolean;\n}\n\nexport const Login: FC<ILoginProps> = async ({invalid}) => {\n  return (\n    <div hx-target=\"this\" hx-swap=\"outerHTML\">\n      <form hx-post=\"/providers/b1g/login\" hx-trigger=\"submit\" id=\"b1g-login-form\">\n        <fieldset class=\"grid\">\n          <input\n            {...(invalid && {\n              'aria-describedby': 'invalid-helper',\n              'aria-invalid': 'true',\n            })}\n            name=\"username\"\n            id=\"b1g-username\"\n            placeholder=\"Username\"\n            aria-label=\"Username\"\n          />\n          <input\n            {...(invalid && {\n              'aria-describedby': 'invalid-helper',\n              'aria-invalid': 'true',\n            })}\n            id=\"b1g-password\"\n            type=\"password\"\n            name=\"password\"\n            placeholder=\"Password\"\n            aria-label=\"Password\"\n          />\n          <button type=\"submit\" id=\"b1g-login\">\n            Log in\n          </button>\n        </fieldset>\n        {invalid && <small id=\"invalid-helper\">Login failed. Please try again.</small>}\n      </form>\n      <script\n        dangerouslySetInnerHTML={{\n          __html: `\n            var form = document.getElementById('b1g-login-form');\n\n            if (form) {\n              form.addEventListener('htmx:beforeRequest', function() {\n                this.querySelector('#b1g-login').setAttribute('aria-busy', 'true');\n                this.querySelector('#b1g-login').setAttribute('aria-label', 'Loading…');\n                this.querySelector('#b1g-username').disabled = true;\n                this.querySelector('#b1g-password').disabled = true;\n              });\n            }\n          `,\n        }}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/b1g/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {TB1GTokens} from '@/services/b1g-handler';\n\nimport {B1GBody} from './CardBody';\n\nexport const B1G: FC = async () => {\n  const b1g = await db.providers.findOneAsync<IProvider<TB1GTokens>>({name: 'b1g'});\n  const enabled = b1g?.enabled;\n  const tokens = b1g?.tokens;\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>B1G+</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/b1g/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#b1g-body\"\n                name=\"b1g-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"b1g-body\" hx-swap=\"innerHTML\">\n          <B1GBody enabled={enabled} tokens={tokens} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/bally/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {ballyHandler} from '@/services/bally-handler';\n\nexport const bally = new Hono().basePath('/bally');\n\nconst scheduleEvents = async () => {\n  await ballyHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('bally');\n};\n\nbally.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['bally-enabled'] === 'on';\n\n  await db.providers.updateAsync<IProvider, any>({name: 'bally'}, {$set: {enabled}});\n\n  if (enabled) {\n    scheduleEvents();\n  } else {\n    removeEvents();\n  }\n\n  return c.html(<></>, 200, {\n    ...(enabled && {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled Bally Sports\"}}`,\n    }),\n  });\n});\n\nbally.put('/channels/toggle/:id', async c => {\n  const channelId = c.req.param('id');\n  const {linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'bally'});\n\n  const body = await c.req.parseBody();\n  const enabled = body['channel-enabled'] === 'on';\n\n  let updatedChannel = channelId;\n\n  const updatedChannels = linear_channels.map(channel => {\n    if (channel.id === channelId) {\n      updatedChannel = 'zzzzzzz';\n      return {...channel, enabled: !channel.enabled};\n    }\n    return channel;\n  });\n\n  if (updatedChannel !== channelId) {\n    await db.providers.updateAsync<IProvider, any>({name: 'bally'}, {$set: {linear_channels: updatedChannels}});\n\n    // Kickoff event scheduler\n    scheduleEvents();\n\n    return c.html(\n      <input\n        hx-target=\"this\"\n        hx-swap=\"outerHTML\"\n        type=\"checkbox\"\n        checked={enabled ? true : false}\n        data-enabled={enabled ? 'true' : 'false'}\n        hx-put={`/providers/bally/channels/toggle/${channelId}`}\n        hx-trigger=\"change\"\n        name=\"channel-enabled\"\n      />,\n      200,\n      {\n        ...(enabled && {\n          'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled ${updatedChannel}\"}}`,\n        }),\n      },\n    );\n  }\n});\n"
  },
  {
    "path": "services/providers/bally/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {usesLinear} from '@/services/misc-db-service';\nimport {IProviderChannel} from '@/services/shared-interfaces';\n\ninterface IBallyBodyProps {\n  enabled: boolean;\n  channels: IProviderChannel[];\n}\n\nexport const BallyBody: FC<IBallyBodyProps> = async ({enabled, channels}) => {\n  const useLinear = await usesLinear();\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <summary>\n        <span data-tooltip=\"These are only enabled with Dedicated Linear Channels enabled\" data-placement=\"right\">\n          Linear Channels\n        </span>\n      </summary>\n      <table class=\"striped\">\n        <thead>\n          <tr>\n            <th></th>\n            <th scope=\"col\">Name</th>\n          </tr>\n        </thead>\n        <tbody>\n          {channels.map(c => (\n            <tr key={c.id}>\n              <td>\n                <input\n                  hx-target=\"this\"\n                  hx-swap=\"outerHTML\"\n                  type=\"checkbox\"\n                  checked={c.enabled}\n                  data-enabled={c.enabled ? 'true' : 'false'}\n                  disabled={!useLinear}\n                  hx-put={`/providers/bally/channels/toggle/${c.id}`}\n                  hx-trigger=\"change\"\n                  name=\"channel-enabled\"\n                />\n              </td>\n              <td>\n                {!c.tmsId ? (\n                  <>\n                    {c.name}\n                    <span class=\"warning-red\" data-tooltip=\"Non-Gracenote channel\" data-placement=\"right\">\n                      *\n                    </span>\n                  </>\n                ) : (\n                  <>{c.name}</>\n                )}\n              </td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/bally/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\n\nimport {BallyBody} from './CardBody';\n\nexport const Bally: FC = async () => {\n  const bally = await db.providers.findOneAsync<IProvider>({name: 'bally'});\n  const {enabled, linear_channels} = bally;\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>\n            <span>\n              Bally Sports Live\n              <span class=\"warning-red\" data-tooltip=\"MiLB Games\" data-placement=\"right\">\n                **\n              </span>\n            </span>\n          </h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/bally/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#bally-body\"\n                name=\"bally-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"bally-body\" hx-swap=\"innerHTML\">\n          <BallyBody channels={linear_channels} enabled={enabled} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/cbs-sports/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {Login} from './views/Login';\nimport {CBSBody} from './views/CardBody';\n\nimport {db} from '@/services/database';\nimport {cbsHandler, TCBSTokens} from '@/services/cbs-handler';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\n\nexport const cbs = new Hono().basePath('/cbs');\n\nconst scheduleEvents = async () => {\n  await cbsHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('cbssports');\n};\n\ncbs.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['cbs-enabled'] === 'on';\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider, any>({name: 'cbs'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  return c.html(<Login />);\n});\n\ncbs.get('/tve-login/:code', async c => {\n  const code = c.req.param('code');\n\n  const isAuthenticated = await cbsHandler.authenticateRegCode(code);\n\n  if (!isAuthenticated) {\n    return c.html(<Login code={code} />);\n  }\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TCBSTokens>, any>(\n    {name: 'cbs'},\n    {$set: {enabled: true}},\n    {returnUpdatedDocs: true},\n  );\n  const {tokens} = affectedDocuments as IProvider<TCBSTokens>;\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<CBSBody enabled={true} tokens={tokens} open={true} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled CBS Sports\"}}`,\n  });\n});\n\ncbs.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n"
  },
  {
    "path": "services/providers/cbs-sports/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TCBSTokens} from '@/services/cbs-handler';\n\ninterface ICBSBodyProps {\n  enabled: boolean;\n  tokens?: TCBSTokens;\n  open?: boolean;\n}\n\nexport const CBSBody: FC<ICBSBodyProps> = ({enabled, tokens, open}) => {\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/cbs/reauth\" hx-trigger=\"submit\">\n            <button id=\"cbs-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/cbs-sports/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {cbsHandler} from '@/services/cbs-handler';\n\ninterface ILogin {\n  code?: string;\n}\n\nexport const Login: FC<ILogin> = async ({code}) => {\n  let shownCode = code;\n\n  if (!shownCode) {\n    shownCode = await cbsHandler.getAuthCode();\n  }\n\n  return (\n    <div hx-target=\"this\" hx-swap=\"outerHTML\" hx-trigger=\"every 5s\" hx-get={`/providers/cbs/tve-login/${shownCode}`}>\n      <div class=\"grid-container\">\n        <div>\n          <h5>TVE Login:</h5>\n          <span>\n            Open this link and follow instructions:\n            <br />\n            <a href={`https://www.cbssports.com/androidtv/${shownCode}`} target=\"_blank\">\n              {`https://www.cbssports.com/androidtv/${shownCode}`}\n            </a>\n          </span>\n        </div>\n        <div aria-busy=\"true\" style=\"align-content: center\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/cbs-sports/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {TCBSTokens} from '@/services/cbs-handler';\nimport {CBSBody} from './CardBody';\n\nexport const CBSSports: FC = async () => {\n  const cbs = await db.providers.findOneAsync<IProvider<TCBSTokens>>({name: 'cbs'});\n  const enabled = cbs?.enabled;\n  const tokens = cbs?.tokens || {};\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>CBS Sports</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/cbs/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#cbs-auth\"\n                name=\"cbs-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"cbs-auth\" hx-swap=\"innerHTML\">\n          <CBSBody enabled={enabled} tokens={tokens} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/espn/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {espnHandler, IEspnMeta, TESPNTokens} from '@/services/espn-handler';\nimport {ESPNBody} from './views/CardBody';\n\nexport const espn = new Hono().basePath('/espn');\n\nconst scheduleEvents = async () => {\n  await espnHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('espn');\n};\n\nespn.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['espn-enabled'] === 'on';\n  \n  const {meta} = await db.providers.findOneAsync<IProvider<any, IEspnMeta>>({name: 'espn'});\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider, any>({name: 'espn'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  if ( await espnHandler.ispAccess() ) {\n    await db.providers.updateAsync<IProvider, any>(\n      {name: 'espn'},\n      {\n        $set: {\n          enabled: true,\n          tokens: {},\n          meta: {\n            ...meta,\n            espn3: enabled,\n            espn3isp: true,\n            espn_free: true,\n          },\n        },\n      },\n    );\n\n    // Kickoff event scheduler\n    scheduleEvents();\n\n    return c.html(<Login />, 200, {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled ESPN3 and @ESPN (free)\"}}`,\n    });\n  } else {\n    await db.providers.updateAsync<IProvider, any>(\n      {name: 'espn'},\n      {\n        $set: {\n          enabled: true,\n          tokens: {},\n          meta: {\n            ...meta,\n            espn_free: true,\n          },\n        },\n      },\n    );\n\n    // Kickoff event scheduler\n    scheduleEvents();\n\n    return c.html(<Login />, 200, {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled @ESPN (free)\"}}`,\n    });\n  }\n\n  return c.html(<Login />);\n});\n\nespn.get('/tve-login/:code', async c => {\n  const code = c.req.param('code');\n\n  const isAuthenticated = await espnHandler.authenticateLinearRegCode(code);\n\n  if (!isAuthenticated) {\n    return c.html(<Login code={code} />);\n  }\n  \n  let updatedMeta = await db.providers.findOneAsync<IProvider<any, IEspnMeta>>({name: 'espn'});\n  if (updatedMeta['espn3isp']) {\n    console.log('Preferring ESPN3 via TVE rather than ISP');\n    updatedMeta['espn3isp'] = false;\n  }\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TESPNTokens, IEspnMeta>, any>(\n    {name: 'espn'},\n    {$set: {enabled: true, meta: updatedMeta}},\n    {returnUpdatedDocs: true},\n  );\n  const {tokens, linear_channels, meta} = affectedDocuments as IProvider<TESPNTokens, IEspnMeta>;\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<ESPNBody enabled={true} tokens={tokens} open={true} channels={linear_channels} meta={meta} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled ESPN\"}}`,\n  });\n});\n\nespn.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n\nespn.put('/channels/toggle/:id', async c => {\n  const channelId = c.req.param('id');\n  const {linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'espn'});\n\n  const body = await c.req.parseBody();\n  const enabled = body['channel-enabled'] === 'on';\n\n  let updatedChannel = channelId;\n\n  const updatedChannels = linear_channels.map(channel => {\n    if (channel.id === channelId) {\n      updatedChannel = channel.name;\n      return {...channel, enabled: !channel.enabled};\n    }\n    return channel;\n  });\n\n  if (updatedChannel !== channelId) {\n    await db.providers.updateAsync<IProvider, any>({name: 'espn'}, {$set: {linear_channels: updatedChannels}});\n\n    // Kickoff event scheduler\n    scheduleEvents();\n\n    return c.html(\n      <input\n        hx-target=\"this\"\n        hx-swap=\"outerHTML\"\n        type=\"checkbox\"\n        checked={enabled ? true : false}\n        data-enabled={enabled ? 'true' : 'false'}\n        hx-put={`/providers/espn/channels/toggle/${channelId}`}\n        hx-trigger=\"change\"\n        name=\"channel-enabled\"\n      />,\n      200,\n      {\n        ...(enabled && {\n          'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled ${updatedChannel}\"}}`,\n        }),\n      },\n    );\n  }\n});\n\nespn.put('/features/toggle/:id', async c => {\n  const featureId = c.req.param('id');\n  const {meta} = await db.providers.findOneAsync<IProvider<any, IEspnMeta>>({name: 'espn'});\n\n  const body = await c.req.parseBody();\n  const enabled = body['channel-enabled'] === 'on';\n\n  const featureMap = {\n    accnx: 'ACC Network Extra',\n    espn3: 'ESPN3',\n    sec_plus: 'SEC Network+',\n    espn_free: '@ESPN (free)',\n  };\n\n  let updatedMeta = {\n    ...meta,\n    [featureId]: enabled,\n  }\n  if (featureId === 'espn3') {\n    updatedMeta['espn3isp'] = false;\n    if (enabled) {\n      const {tokens} = await db.providers.findOneAsync<IProvider<TESPNTokens>>({name: 'espn'});\n      if (!tokens.adobe_auth) {\n        updatedMeta['espn3isp'] = await espnHandler.ispAccess();\n      }\n    }\n  }\n\n  await db.providers.updateAsync<IProvider, any>(\n    {name: 'espn'},\n    {\n      $set: {\n        meta: updatedMeta,\n      },\n    },\n  );\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(\n    <input\n      hx-target=\"this\"\n      hx-swap=\"outerHTML\"\n      type=\"checkbox\"\n      checked={enabled ? true : false}\n      data-enabled={enabled ? 'true' : 'false'}\n      hx-put={`/providers/espn/features/toggle/${featureId}`}\n      hx-trigger=\"change\"\n      name=\"channel-enabled\"\n    />,\n    200,\n    {\n      ...(enabled && {\n        'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled ${featureMap[featureId]}\"}}`,\n      }),\n    },\n  );\n});\n"
  },
  {
    "path": "services/providers/espn/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {IEspnMeta, TESPNTokens} from '@/services/espn-handler';\nimport {IProviderChannel} from '@/services/shared-interfaces';\n\ninterface IESPNBodyProps {\n  enabled: boolean;\n  tokens?: TESPNTokens;\n  open?: boolean;\n  channels: IProviderChannel[];\n  meta: IEspnMeta;\n}\n\nexport const ESPNBody: FC<IESPNBodyProps> = ({enabled, tokens, open, channels, meta}) => {\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <summary>\n        <span>Linear Channels</span>\n      </summary>\n      <table class=\"striped\">\n        <thead>\n          <tr>\n            <th></th>\n            <th scope=\"col\">Name</th>\n          </tr>\n        </thead>\n        <tbody>\n          {channels.map(c => (\n            <tr key={c.id}>\n              <td>\n                <input\n                  hx-target=\"this\"\n                  hx-swap=\"outerHTML\"\n                  type=\"checkbox\"\n                  checked={c.enabled}\n                  data-enabled={c.enabled ? 'true' : 'false'}\n                  hx-put={`/providers/espn/channels/toggle/${c.id}`}\n                  hx-trigger=\"change\"\n                  name=\"channel-enabled\"\n                />\n              </td>\n              <td>{c.name}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/espn/reauth\" hx-trigger=\"submit\">\n            <button id=\"espn-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/espn/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {espnHandler} from '@/services/espn-handler';\n\ninterface ILogin {\n  code?: string;\n}\n\nexport const Login: FC<ILogin> = async ({code}) => {\n  let shownCode = code;\n\n  if (!shownCode) {\n    shownCode = await espnHandler.getLinearAuthCode();\n  }\n\n  return (\n    <div hx-target=\"this\" hx-swap=\"outerHTML\" hx-trigger=\"every 5s\" hx-get={`/providers/espn/tve-login/${shownCode}`}>\n      <div class=\"grid-container\">\n        <div>\n          <h5>TVE Login:</h5>\n          <span>\n            Open this link and follow instructions:\n            <br />\n            <a href=\"https://www.espn.com/watch/activate\" target=\"_blank\">\n              https://www.espn.com/watch/activate\n            </a>\n          </span>\n          <h6>Code: {shownCode}</h6>\n        </div>\n        <div aria-busy=\"true\" style=\"align-content: center\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/espn/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {IEspnMeta, TESPNTokens} from '@/services/espn-handler';\n\nimport {ESPNBody} from './CardBody';\n\nexport const ESPN: FC = async () => {\n  const {\n    enabled,\n    tokens,\n    linear_channels: channels,\n    meta,\n  } = await db.providers.findOneAsync<IProvider<TESPNTokens, IEspnMeta>>({name: 'espn'});\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>ESPN with TV provider or ISP</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/espn/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#espn-body\"\n                name=\"espn-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"espn-body\" hx-swap=\"innerHTML\">\n          <ESPNBody enabled={enabled} tokens={tokens} channels={channels} meta={meta} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/espn-plus/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {espnHandler, IEspnPlusMeta, TESPNPlusTokens} from '@/services/espn-handler';\nimport {ESPNPlusBody} from './views/CardBody';\n\nexport const espnplus = new Hono().basePath('/espnplus');\n\nconst scheduleEvents = async () => {\n  await espnHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('espn');\n};\n\nespnplus.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['espnplus-enabled'] === 'on';\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider, any>({name: 'espnplus'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  await espnHandler.refreshInMarketTeams();\n\n  return c.html(<Login />);\n});\n\nespnplus.put('/toggle-ppv', async c => {\n  const body = await c.req.parseBody();\n  const use_ppv = body['espnplus-ppv-enabled'] === 'on';\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TESPNPlusTokens, IEspnPlusMeta>, any>(\n    {name: 'espnplus'},\n    {$set: {'meta.use_ppv': use_ppv}},\n    {returnUpdatedDocs: true},\n  );\n  const {enabled, tokens} = affectedDocuments as IProvider<TESPNPlusTokens, IEspnPlusMeta>;\n\n  scheduleEvents();\n\n  return c.html(<ESPNPlusBody enabled={enabled} tokens={tokens} />);\n});\n\nespnplus.put('/refresh-in-market-teams', async c => {\n  const {zip_code, in_market_teams} = await espnHandler.refreshInMarketTeams();\n\n  return c.html(\n    <div>\n      <pre>\n        {in_market_teams} ({zip_code})\n      </pre>\n      <button id=\"espnplus-refresh-in-market-teams-button\" disabled>\n        Refresh In-Market Teams\n      </button>\n    </div>,\n    200,\n    {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully refreshed in-market teams\"}}`,\n    },\n  );\n});\n\nespnplus.get('/login/check/:code', async c => {\n  const code = c.req.param('code');\n\n  const isAuthenticated = await espnHandler.authenticatePlusRegCode();\n\n  if (!isAuthenticated) {\n    return c.html(<Login code={code} />);\n  }\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TESPNPlusTokens>, any>(\n    {name: 'espnplus'},\n    {$set: {enabled: true}},\n    {returnUpdatedDocs: true},\n  );\n  const {tokens} = affectedDocuments as IProvider<TESPNPlusTokens, IEspnPlusMeta>;\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<ESPNPlusBody enabled={true} tokens={tokens} open={true} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled ESPN+\"}}`,\n  });\n});\n\nespnplus.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n"
  },
  {
    "path": "services/providers/espn-plus/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TESPNPlusTokens} from '@/services/espn-handler';\n\ninterface IESPNPlusBodyProps {\n  enabled: boolean;\n  tokens?: TESPNPlusTokens;\n  open?: boolean;\n}\n\nexport const ESPNPlusBody: FC<IESPNPlusBodyProps> = ({enabled, tokens, open}) => {\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/espnplus/reauth\" hx-trigger=\"submit\">\n            <button id=\"espnplus-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/espn-plus/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {espnHandler} from '@/services/espn-handler';\n\ninterface ILoginProps {\n  code?: string;\n}\n\nexport const Login: FC<ILoginProps> = async ({code}) => {\n  let shownCode = code;\n\n  if (!shownCode) {\n    shownCode = await espnHandler.getPlusAuthCode();\n  }\n\n  return (\n    <div\n      hx-target=\"this\"\n      hx-swap=\"outerHTML\"\n      hx-trigger=\"every 5s\"\n      hx-get={`/providers/espnplus/login/check/${shownCode}`}\n    >\n      <div class=\"grid-container\">\n        <div>\n          <h5>ESPN+ Login:</h5>\n          <span>\n            Open this link and follow instructions:\n            <br />\n            <a href=\"https://www.espn.com/watch/activate\" target=\"_blank\">\n              https://www.espn.com/watch/activate\n            </a>\n          </span>\n          <h6>Code: {shownCode}</h6>\n        </div>\n        <div aria-busy=\"true\" style=\"align-content: center\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/espn-plus/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {IEspnPlusMeta, TESPNPlusTokens} from '@/services/espn-handler';\n\nimport {ESPNPlusBody} from './CardBody';\n\nexport const ESPNPlus: FC = async () => {\n  const {enabled, tokens, meta} = await db.providers.findOneAsync<IProvider<TESPNPlusTokens, IEspnPlusMeta>>({\n    name: 'espnplus',\n  });\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>\n            <span>\n              ESPN Account\n              <span class=\"warning-red\" data-tooltip=\"Formerly ESPN+, no longer working\" data-placement=\"right\">\n                **\n              </span>\n            </span>\n          </h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/espnplus/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#espnplus-body\"\n                name=\"espnplus-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div class=\"grid-container\">\n          <div />\n          <fieldset>\n            <label>\n              PPV Events?&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/espnplus/toggle-ppv`}\n                hx-trigger=\"change\"\n                name=\"espnplus-ppv-enabled\"\n                hx-target=\"#espnplus-body\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={meta.use_ppv ? true : false}\n                data-enabled={meta.use_ppv ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div class=\"grid\">\n          <details>\n            <summary>\n              ESPN+ Options{' '}\n              <span\n                class=\"warning-red\"\n                data-tooltip=\"Rebuild EPG to reflect changes immediately\"\n                data-placement=\"right\"\n              >\n                **\n              </span>\n            </summary>\n            <span>In-Market Teams</span>\n            <fieldset role=\"group\">\n              <form\n                id=\"espnplus-refresh-in-market-teams\"\n                hx-put=\"/providers/espnplus/refresh-in-market-teams\"\n                hx-trigger=\"submit\"\n              >\n                <div>\n                  <pre>\n                    {meta.in_market_teams} ({meta.zip_code})\n                  </pre>\n                  <button id=\"espnplus-refresh-in-market-teams-button\">Refresh In-Market Teams</button>\n                </div>\n              </form>\n            </fieldset>\n          </details>\n        </div>\n        <div id=\"espnplus-body\" hx-swap=\"innerHTML\">\n          <ESPNPlusBody enabled={enabled} tokens={tokens} />\n        </div>\n        <script\n          dangerouslySetInnerHTML={{\n            __html: `\n            var espnPlusInMarketTeams = document.getElementById('espnplus-refresh-in-market-teams');\n\n            if (espnPlusInMarketTeams) {\n              espnPlusInMarketTeams.addEventListener('htmx:beforeRequest', function() {\n                this.querySelector('#espnplus-refresh-in-market-teams-button').setAttribute('aria-busy', 'true');\n                this.querySelector('#espnplus-refresh-in-market-teams-button').setAttribute('aria-label', 'Loading…');\n              });\n            }\n          `,\n          }}\n        />\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/flosports/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {FloSportsBody} from './views/CardBody';\n\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {floSportsHandler, TFloSportsTokens} from '@/services/flo-handler';\n\nexport const flosports = new Hono().basePath('/flosports');\n\nconst scheduleEvents = async () => {\n  await floSportsHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('flo');\n};\n\nflosports.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['flosports-enabled'] === 'on';\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider, any>({name: 'flosports'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  return c.html(<Login />);\n});\n\nflosports.get('/auth/:code', async c => {\n  const code = c.req.param('code');\n\n  const isAuthenticated = await floSportsHandler.authenticateRegCode(code);\n\n  if (!isAuthenticated) {\n    return c.html(<Login code={code} />);\n  }\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TFloSportsTokens>, any>(\n    {name: 'flosports'},\n    {$set: {enabled: true}},\n    {returnUpdatedDocs: true},\n  );\n  const {tokens} = affectedDocuments as IProvider<TFloSportsTokens>;\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<FloSportsBody enabled={true} tokens={tokens} open={true} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled FloSports\"}}`,\n  });\n});\n\nflosports.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n"
  },
  {
    "path": "services/providers/flosports/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TFloSportsTokens} from '@/services/flo-handler';\n\ninterface IFloSportsBodyProps {\n  enabled: boolean;\n  tokens?: TFloSportsTokens;\n  open?: boolean;\n}\n\nexport const FloSportsBody: FC<IFloSportsBodyProps> = ({enabled, tokens, open}) => {\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/flosports/reauth\" hx-trigger=\"submit\">\n            <button id=\"flosports-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/flosports/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {floSportsHandler} from '@/services/flo-handler';\n\ninterface ILogin {\n  code?: string;\n}\n\nexport const Login: FC<ILogin> = async ({code}) => {\n  let shownCode = code;\n\n  if (!shownCode) {\n    shownCode = await floSportsHandler.getAuthCode();\n  }\n\n  return (\n    <div hx-target=\"this\" hx-swap=\"outerHTML\" hx-trigger=\"every 5s\" hx-get={`/providers/flosports/auth/${shownCode}`}>\n      <div class=\"grid-container\">\n        <div>\n          <h5>FloSports Login:</h5>\n          <span>\n            Open this link and follow instructions:\n            <br />\n            <a href=\"https://www.flolive.tv/activate\" target=\"_blank\">\n              https://www.flolive.tv/activate\n            </a>\n          </span>\n          <h6>Code: {shownCode}</h6>\n        </div>\n        <div aria-busy=\"true\" style=\"align-content: center\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/flosports/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {TFloSportsTokens} from '@/services/flo-handler';\n\nimport {FloSportsBody} from './CardBody';\n\nexport const FloSports: FC = async () => {\n  const parmount = await db.providers.findOneAsync<IProvider<TFloSportsTokens>>({name: 'flosports'});\n  const enabled = parmount?.enabled;\n  const tokens = parmount?.tokens;\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>FloSports</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/flosports/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#flosports-body\"\n                name=\"flosports-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"flosports-body\" hx-swap=\"innerHTML\">\n          <FloSportsBody enabled={enabled} tokens={tokens} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/fox/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {foxHandler, TFoxTokens} from '@/services/fox-handler';\nimport {FoxBody} from './views/CardBody';\n\nexport const fox = new Hono().basePath('/fox');\n\nconst scheduleEvents = async () => {\n  await foxHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('foxsports');\n};\n\nconst removeAndSchedule = async () => {\n  await removeEvents();\n  await scheduleEvents();\n};\n\nfox.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['fox-enabled'] === 'on';\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider, any>({name: 'foxsports'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  return c.html(<Login />);\n});\n\nfox.put('/toggle-4k-only', async c => {\n  const body = await c.req.parseBody();\n  const only4k = body['fox-enabled-4k-only'] === 'on';\n\n  const {meta} = await db.providers.findOneAsync<IProvider>({name: 'foxsports'});\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TFoxTokens>, any>(\n    {name: 'foxsports'},\n    {\n      $set: {\n        meta: {\n          ...meta,\n          only4k,\n        },\n      },\n    },\n    {\n      returnUpdatedDocs: true,\n    },\n  );\n  const {enabled, tokens, linear_channels} = affectedDocuments as IProvider<TFoxTokens>;\n\n  removeAndSchedule();\n\n  return c.html(<FoxBody enabled={enabled} tokens={tokens} channels={linear_channels} />);\n});\n\nfox.put('/toggle-uhd', async c => {\n  const body = await c.req.parseBody();\n  const uhd = body['fox-enabled-uhd'] === 'on';\n\n  const {meta} = await db.providers.findOneAsync<IProvider>({name: 'foxsports'});\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TFoxTokens>, any>(\n    {name: 'foxsports'},\n    {\n      $set: {\n        meta: {\n          ...meta,\n          uhd,\n        },\n      },\n    },\n    {\n      returnUpdatedDocs: true,\n    },\n  );\n  const {enabled, tokens, linear_channels} = affectedDocuments as IProvider<TFoxTokens>;\n\n  return c.html(<FoxBody enabled={enabled} tokens={tokens} channels={linear_channels} />);\n});\n\nfox.get('/tve-login/:code', async c => {\n  const code = c.req.param('code');\n\n  const isAuthenticated = await foxHandler.authenticateRegCode(false);\n\n  if (!isAuthenticated) {\n    return c.html(<Login code={code} />);\n  }\n\n  // Trigger a refresh of tokens straight away\n  await foxHandler.authenticateRegCode(false);\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TFoxTokens>, any>(\n    {name: 'foxsports'},\n    {$set: {enabled: true}},\n    {returnUpdatedDocs: true},\n  );\n  const {tokens, linear_channels} = affectedDocuments as IProvider<TFoxTokens>;\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<FoxBody enabled={true} tokens={tokens} open={true} channels={linear_channels} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled Fox Sports\"}}`,\n  });\n});\n\nfox.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n\nfox.put('/channels/toggle/:id', async c => {\n  const channelId = c.req.param('id');\n  const {linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'foxsports'});\n\n  const body = await c.req.parseBody();\n  const enabled = body['channel-enabled'] === 'on';\n\n  let updatedChannel = '';\n\n  const updatedChannels = linear_channels.map(channel => {\n    if (channel.id === channelId) {\n      updatedChannel = channel.name;\n      return {...channel, enabled: !channel.enabled};\n    }\n    return channel;\n  });\n\n  if (updatedChannel !== '') {\n    await db.providers.updateAsync<IProvider<TFoxTokens>, any>({name: 'foxsports'}, {$set: {linear_channels: updatedChannels}});\n\n    // Kickoff event scheduler\n    scheduleEvents();\n\n    return c.html(\n      <input\n        hx-target=\"this\"\n        hx-swap=\"outerHTML\"\n        type=\"checkbox\"\n        checked={enabled ? true : false}\n        data-enabled={enabled ? 'true' : 'false'}\n        hx-put={`/providers/fox/channels/toggle/${channelId}`}\n        hx-trigger=\"change\"\n        name=\"channel-enabled\"\n      />,\n      200,\n      {\n        ...(enabled && {\n          'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled ${updatedChannel}\"}}`,\n        }),\n      },\n    );\n  }\n});\n"
  },
  {
    "path": "services/providers/fox/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TFoxTokens} from '@/services/fox-handler';\nimport {IProviderChannel} from '@/services/shared-interfaces';\n\ninterface IFoxBodyProps {\n  enabled: boolean;\n  tokens?: TFoxTokens;\n  open?: boolean;\n  channels: IProviderChannel[];\n}\n\nexport const FoxBody: FC<IFoxBodyProps> = ({enabled, tokens, open, channels}) => {\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <summary>\n        <span>Linear Channels</span>\n      </summary>\n      <table class=\"striped\">\n        <thead>\n          <tr>\n            <th scope=\"col\">Name</th>\n          </tr>\n        </thead>\n        <tbody>\n          {channels.map(c => (\n            <tr key={c.id}>\n              <td>\n                <input\n                  hx-target=\"this\"\n                  hx-swap=\"outerHTML\"\n                  type=\"checkbox\"\n                  checked={c.enabled}\n                  data-enabled={c.enabled ? 'true' : 'false'}\n                  hx-put={`/providers/fox/channels/toggle/${c.id}`}\n                  hx-trigger=\"change\"\n                  name=\"channel-enabled\"\n                />\n              </td>\n              <td>{c.name}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/fox/reauth\" hx-trigger=\"submit\">\n            <button id=\"fox-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/fox/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {foxHandler} from '@/services/fox-handler';\n\ninterface ILogin {\n  code?: string;\n  deviceToken?: string;\n}\n\nexport const Login: FC<ILogin> = async ({code}) => {\n  let shownCode = code;\n\n  if (!shownCode) {\n    shownCode = await foxHandler.getAuthCode();\n  }\n\n  return (\n    <div hx-target=\"this\" hx-swap=\"outerHTML\" hx-trigger=\"every 5s\" hx-get={`/providers/fox/tve-login/${shownCode}`}>\n      <div class=\"grid-container\">\n        <div>\n          <h5>FOX Sports Login:</h5>\n          <span>\n            Open this link and follow instructions:\n            <br />\n            <a href=\"https://go.foxsports.com\" target=\"_blank\">\n              https://go.foxsports.com\n            </a>\n          </span>\n          <h6>Code: {shownCode}</h6>\n        </div>\n        <div aria-busy=\"true\" style=\"align-content: center\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/fox/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {TFoxTokens} from '@/services/fox-handler';\n\nimport {FoxBody} from './CardBody';\n\nexport const FoxSports: FC = async () => {\n  const fox = await db.providers.findOneAsync<IProvider<TFoxTokens>>({name: 'foxsports'});\n  const enabled = fox?.enabled;\n  const tokens = fox?.tokens;\n  const channels = fox?.linear_channels || [];\n  const only4k = fox?.meta?.only4k;\n  const uhd = fox?.meta?.uhd;\n  const hide_studio = fox?.meta?.hide_studio;\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>Fox Sports</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/fox/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#fox-body\"\n                name=\"fox-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div class=\"grid\">\n          <fieldset>\n            <label>\n              <input\n                hx-put={`/providers/fox/toggle-uhd`}\n                hx-trigger=\"change\"\n                hx-target=\"#fox-body\"\n                name=\"fox-enabled-uhd\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={uhd ? true : false}\n                data-enabled={uhd ? 'true' : 'false'}\n              />\n              Enable UHD/HDR events?\n            </label>\n          </fieldset>\n          <fieldset>\n            <label>\n              <input\n                hx-put={`/providers/fox/toggle-4k-only`}\n                hx-trigger=\"change\"\n                hx-target=\"#fox-body\"\n                name=\"fox-enabled-4k-only\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={only4k ? true : false}\n                data-enabled={only4k ? 'true' : 'false'}\n              />\n              Only grab 4K events?\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"fox-body\" hx-swap=\"innerHTML\">\n          <FoxBody enabled={enabled} tokens={tokens} channels={channels} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/foxone/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {foxOneHandler, TFoxOneTokens} from '@/services/foxone-handler';\nimport {FoxOneBody} from './views/CardBody';\n\nexport const foxone = new Hono().basePath('/foxone');\n\nconst scheduleEvents = async () => {\n  await foxOneHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('foxone');\n};\n\nconst removeAndSchedule = async () => {\n  await removeEvents();\n  await scheduleEvents();\n};\n\nfoxone.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['foxone-enabled'] === 'on';\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider, any>({name: 'foxone'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  return c.html(<Login />);\n});\n\nfoxone.put('/toggle-4k-only', async c => {\n  const body = await c.req.parseBody();\n  const only4k = body['foxone-enabled-4k-only'] === 'on';\n\n  const {meta} = await db.providers.findOneAsync<IProvider>({name: 'foxone'});\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TFoxOneTokens>, any>(\n    {name: 'foxone'},\n    {\n      $set: {\n        meta: {\n          ...meta,\n          only4k,\n        },\n      },\n    },\n    {\n      returnUpdatedDocs: true,\n    },\n  );\n  const {enabled, tokens, linear_channels} = affectedDocuments as IProvider<TFoxOneTokens>;\n\n  removeAndSchedule();\n\n  return c.html(<FoxOneBody enabled={enabled} tokens={tokens} channels={linear_channels} />);\n});\n\nfoxone.put('/toggle-uhd', async c => {\n  const body = await c.req.parseBody();\n  const uhd = body['foxone-enabled-uhd'] === 'on';\n\n  const {meta} = await db.providers.findOneAsync<IProvider>({name: 'foxone'});\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TFoxOneTokens>, any>(\n    {name: 'foxone'},\n    {\n      $set: {\n        meta: {\n          ...meta,\n          uhd,\n        },\n      },\n    },\n    {\n      returnUpdatedDocs: true,\n    },\n  );\n  const {enabled, tokens, linear_channels} = affectedDocuments as IProvider<TFoxOneTokens>;\n\n  return c.html(<FoxOneBody enabled={enabled} tokens={tokens} channels={linear_channels} />);\n});\n\nfoxone.get('/tve-login/:code', async c => {\n  const code = c.req.param('code');\n\n  const isAuthenticated = await foxOneHandler.authenticateRegCode(false);\n\n  if (!isAuthenticated) {\n    return c.html(<Login code={code} />);\n  }\n\n  // Trigger a refresh of tokens straight away\n  await foxOneHandler.authenticateRegCode(false);\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TFoxOneTokens>, any>(\n    {name: 'foxone'},\n    {$set: {enabled: true}},\n    {returnUpdatedDocs: true},\n  );\n  const {tokens, linear_channels} = affectedDocuments as IProvider<TFoxOneTokens>;\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<FoxOneBody enabled={true} tokens={tokens} open={true} channels={linear_channels} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled Fox One\"}}`,\n  });\n});\n\nfoxone.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n\nfoxone.put('/channels/toggle/:id', async c => {\n  const channelId = c.req.param('id');\n  const {linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'foxone'});\n\n  const body = await c.req.parseBody();\n  const enabled = body['channel-enabled'] === 'on';\n\n  let updatedChannel = '';\n\n  const updatedChannels = linear_channels.map(channel => {\n    if (channel.id === channelId) {\n      updatedChannel = channel.name;\n      return {...channel, enabled: !channel.enabled};\n    }\n    return channel;\n  });\n\n  if (updatedChannel !== '') {\n    await db.providers.updateAsync<IProvider<TFoxOneTokens>, any>({name: 'foxone'}, {$set: {linear_channels: updatedChannels}});\n\n    // Kickoff event scheduler\n    scheduleEvents();\n\n    return c.html(\n      <input\n        hx-target=\"this\"\n        hx-swap=\"outerHTML\"\n        type=\"checkbox\"\n        checked={enabled ? true : false}\n        data-enabled={enabled ? 'true' : 'false'}\n        hx-put={`/providers/foxone/channels/toggle/${channelId}`}\n        hx-trigger=\"change\"\n        name=\"channel-enabled\"\n      />,\n      200,\n      {\n        ...(enabled && {\n          'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled ${updatedChannel}\"}}`,\n        }),\n      },\n    );\n  }\n});"
  },
  {
    "path": "services/providers/foxone/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TFoxOneTokens} from '@/services/foxone-handler';\nimport {IProviderChannel} from '@/services/shared-interfaces';\n\ninterface IFoxOneBodyProps {\n  enabled: boolean;\n  tokens?: TFoxOneTokens;\n  open?: boolean;\n  channels: IProviderChannel[];\n}\n\nexport const FoxOneBody: FC<IFoxOneBodyProps> = ({enabled, tokens, open, channels}) => {\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <summary>\n        <span>Linear Channels</span>\n      </summary>\n      <table class=\"striped\">\n        <thead>\n          <tr>\n            <th scope=\"col\">Name</th>\n          </tr>\n        </thead>\n        <tbody>\n          {channels.map(c => (\n            <tr key={c.id}>\n              <td>\n                <input\n                  hx-target=\"this\"\n                  hx-swap=\"outerHTML\"\n                  type=\"checkbox\"\n                  checked={c.enabled}\n                  data-enabled={c.enabled ? 'true' : 'false'}\n                  hx-put={`/providers/foxone/channels/toggle/${c.id}`}\n                  hx-trigger=\"change\"\n                  name=\"channel-enabled\"\n                />\n              </td>\n              <td>{c.name}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/foxone/reauth\" hx-trigger=\"submit\">\n            <button id=\"foxone-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/foxone/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {foxOneHandler} from '@/services/foxone-handler';\n\ninterface ILogin {\n  code?: string;\n  deviceToken?: string;\n}\n\nexport const Login: FC<ILogin> = async ({code}) => {\n  let shownCode = code;\n\n  if (!shownCode) {\n    shownCode = await foxOneHandler.getAuthCode();\n  }\n\n  return (\n    <div hx-target=\"this\" hx-swap=\"outerHTML\" hx-trigger=\"every 5s\" hx-get={`/providers/foxone/tve-login/${shownCode}`}>\n      <div class=\"grid-container\">\n        <div>\n          <h5>FOX One Login:</h5>\n          <span>\n            Open this link and follow instructions:\n            <br />\n            <a href=\"https://go.fox.com\" target=\"_blank\">\n              https://go.fox.com\n            </a>\n          </span>\n          <h6>Code: {shownCode}</h6>\n        </div>\n        <div aria-busy=\"true\" style=\"align-content: center\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/foxone/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {TFoxOneTokens} from '@/services/foxone-handler';\n\nimport {FoxOneBody} from './CardBody';\n\nexport const FoxOne: FC = async () => {\n  const {\n    enabled,\n    tokens,\n    linear_channels: channels,\n    meta,\n  } = await db.providers.findOneAsync<IProvider<TFoxOneTokens>>({name: 'foxone'});\n  \n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>Fox One</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/foxone/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#foxone-body\"\n                name=\"foxone-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div class=\"grid\">\n          <fieldset>\n            <label>\n              <input\n                hx-put={`/providers/foxone/toggle-uhd`}\n                hx-trigger=\"change\"\n                hx-target=\"#foxone-body\"\n                name=\"foxone-enabled-uhd\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={meta.uhd ? true : false}\n                data-enabled={meta.uhd ? 'true' : 'false'}\n              />\n              Enable UHD/HDR events?\n            </label>\n          </fieldset>\n          <fieldset>\n            <label>\n              <input\n                hx-put={`/providers/foxone/toggle-4k-only`}\n                hx-trigger=\"change\"\n                hx-target=\"#foxone-body\"\n                name=\"foxone-enabled-4k-only\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={meta.only4k ? true : false}\n                data-enabled={meta.only4k ? 'true' : 'false'}\n              />\n              Only grab 4K events?\n            </label>\n          </fieldset>               \n        </div>\n        <div id=\"foxone-body\" hx-swap=\"innerHTML\">\n          <FoxOneBody enabled={enabled} tokens={tokens} channels={channels} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/gotham/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {TVELogin} from './views/TveLogin';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {gothamHandler, TGothamTokens} from '@/services/gotham-handler';\nimport {GothamBody} from './views/CardBody';\n\nexport const gotham = new Hono().basePath('/gotham');\n\nconst scheduleEvents = async () => {\n  await gothamHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('gotham');\n};\n\ngotham.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['gotham-enabled'] === 'on';\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider, any>({name: 'gotham'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  return c.html(<Login />);\n});\n\ngotham.post('/login', async c => {\n  const body = await c.req.parseBody();\n  const username = body.username as string;\n  const password = body.password as string;\n\n  const isAuthenticated = await gothamHandler.login(username, password);\n\n  if (!isAuthenticated) {\n    return c.html(<Login invalid={true} />);\n  }\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TGothamTokens>, any>(\n    {name: 'gotham'},\n    {\n      $set: {\n        enabled: true,\n        meta: {\n          password,\n          username,\n        },\n      },\n    },\n    {returnUpdatedDocs: true},\n  );\n  const {tokens} = affectedDocuments as IProvider<TGothamTokens>;\n\n  const channels = await gothamHandler.getLinearChannels();\n\n  const linear_channels = [];\n\n  for (const channel of Object.values(channels)) {\n    linear_channels.push({\n      id: channel.id,\n      name: channel.name,\n    });\n  }\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<GothamBody enabled={true} tokens={tokens} open={true} channels={linear_channels} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled Gotham Sports\"}}`,\n  });\n});\n\ngotham.put('/auth/tve', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body[`gotham-tve-enabled`] === 'on';\n\n  const {tokens} = await db.providers.findOneAsync<IProvider<TGothamTokens>>({name: 'gotham'});\n\n  const updatedTokens = {...tokens};\n\n  if (!enabled) {\n    delete updatedTokens.adobe_token;\n    delete updatedTokens.adobe_token_expires;\n\n    const {affectedDocuments} = await db.providers.updateAsync<IProvider<TGothamTokens>, any>(\n      {name: 'gotham'},\n      {$set: {tokens: updatedTokens}},\n      {returnUpdatedDocs: true},\n    );\n    const {tokens} = affectedDocuments as IProvider<TGothamTokens>;\n\n    const channels = await gothamHandler.getLinearChannels();\n\n    const linear_channels = [];\n\n    for (const channel of Object.values(channels)) {\n      linear_channels.push({\n        id: channel.id,\n        name: channel.name,\n      });\n    }\n\n    return c.html(<GothamBody channels={linear_channels} enabled={true} open={false} tokens={tokens} />);\n  }\n\n  return c.html(<TVELogin />);\n});\n\ngotham.get('/tve-login/:link', async c => {\n  const link = c.req.param('link');\n\n  const isAuthenticated = await gothamHandler.authenticateRegCode();\n\n  if (!isAuthenticated) {\n    return c.html(<TVELogin link={link} />);\n  }\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TGothamTokens>, any>(\n    {name: 'gotham'},\n    {$set: {enabled: true}},\n    {returnUpdatedDocs: true},\n  );\n  const {tokens} = affectedDocuments as IProvider<TGothamTokens>;\n\n  const channels = await gothamHandler.getLinearChannels();\n\n  const linear_channels = [];\n\n  for (const channel of Object.values(channels)) {\n    linear_channels.push({\n      id: channel.id,\n      name: channel.name,\n    });\n  }\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<GothamBody enabled={true} tokens={tokens} open={true} channels={linear_channels} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully authenticated TVE Provider for Gotham\"}}`,\n  });\n});\n\ngotham.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n"
  },
  {
    "path": "services/providers/gotham/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TGothamTokens} from '@/services/gotham-handler';\nimport {IProviderChannel} from '@/services/shared-interfaces';\n\ninterface IGothamBodyProps {\n  enabled: boolean;\n  tokens?: TGothamTokens;\n  open?: boolean;\n  channels: IProviderChannel[];\n}\n\nexport const GothamBody: FC<IGothamBodyProps> = ({enabled, tokens, open, channels}) => {\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"innerHTML\" hx-target=\"this\">\n      <summary>\n        <span>Linear Channels</span>\n      </summary>\n      <table class=\"striped\">\n        <thead>\n          <tr>\n            <th scope=\"col\">Name</th>\n          </tr>\n        </thead>\n        <tbody>\n          {channels.map(c => (\n            <tr key={c.id}>\n              <td>{c.name}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n      <div class=\"grid-container\">\n        <h6>TV Provider:</h6>\n        <fieldset>\n          <label>\n            Enabled&nbsp;&nbsp;\n            <input\n              hx-put=\"/providers/gotham/auth/tve\"\n              hx-trigger=\"change\"\n              hx-target=\"#gotham-body\"\n              name=\"gotham-tve-enabled\"\n              type=\"checkbox\"\n              role=\"switch\"\n              checked={tokens.adobe_token ? true : false}\n              data-enabled={tokens.adobe_token ? 'true' : 'false'}\n            />\n          </label>\n        </fieldset>\n      </div>\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/gotham/reauth\" hx-trigger=\"submit\">\n            <button id=\"gotham-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/gotham/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\ninterface ILoginProps {\n  invalid?: boolean;\n}\n\nexport const Login: FC<ILoginProps> = async ({invalid}) => {\n  return (\n    <div hx-target=\"this\" hx-swap=\"outerHTML\">\n      <form hx-post=\"/providers/gotham/login\" hx-trigger=\"submit\" id=\"gotham-login-form\">\n        <fieldset class=\"grid\">\n          <input\n            {...(invalid && {\n              'aria-describedby': 'invalid-helper',\n              'aria-invalid': 'true',\n            })}\n            name=\"username\"\n            id=\"gotham-username\"\n            placeholder=\"Username\"\n            aria-label=\"Username\"\n          />\n          <input\n            {...(invalid && {\n              'aria-describedby': 'invalid-helper',\n              'aria-invalid': 'true',\n            })}\n            id=\"gotham-password\"\n            type=\"password\"\n            name=\"password\"\n            placeholder=\"Password\"\n            aria-label=\"Password\"\n          />\n          <button type=\"submit\" id=\"gotham-login\">\n            Log in\n          </button>\n        </fieldset>\n        {invalid && <small id=\"invalid-helper\">Login failed. Please try again.</small>}\n      </form>\n      <script\n        dangerouslySetInnerHTML={{\n          __html: `\n            var form = document.getElementById('gotham-login-form');\n\n            if (form) {\n              form.addEventListener('htmx:beforeRequest', function() {\n                this.querySelector('#gotham-login').setAttribute('aria-busy', 'true');\n                this.querySelector('#gotham-login').setAttribute('aria-label', 'Loading…');\n                this.querySelector('#gotham-username').disabled = true;\n                this.querySelector('#gotham-password').disabled = true;\n              });\n            }\n          `,\n        }}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/gotham/views/TveLogin.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {gothamHandler} from '@/services/gotham-handler';\n\ninterface ILogin {\n  link?: string;\n}\n\nexport const TVELogin: FC<ILogin> = async ({link}) => {\n  let hashedLink = '';\n  let shownLink = '';\n\n  if (!link) {\n    shownLink = await gothamHandler.getAuthCode();\n    hashedLink = Buffer.from(shownLink).toString('base64');\n  } else {\n    hashedLink = link;\n    shownLink = Buffer.from(hashedLink, 'base64').toString();\n  }\n\n  return (\n    <div\n      hx-target=\"this\"\n      hx-swap=\"outerHTML\"\n      hx-trigger=\"every 5s\"\n      hx-get={`/providers/gotham/tve-login/${hashedLink}`}\n    >\n      <div class=\"grid-container\">\n        <div>\n          <h5>{`Gotham Sports TV Login`}:</h5>\n          {shownLink !== 'Loading...' ? (\n            <span>\n              Open this link and follow instructions:\n              <br />\n              <a href={shownLink} target=\"_blank\">\n                {shownLink}\n              </a>\n            </span>\n          ) : (\n            <span>Trying to refresh Adobe auth...</span>\n          )}\n        </div>\n        <div aria-busy=\"true\" style=\"align-content: center\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/gotham/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {gothamHandler, TGothamTokens} from '@/services/gotham-handler';\n\nimport {GothamBody} from './CardBody';\n\nexport const Gotham: FC = async () => {\n  const gotham = await db.providers.findOneAsync<IProvider<TGothamTokens>>({name: 'gotham'});\n  const enabled = gotham?.enabled;\n  const tokens = gotham?.tokens;\n\n  const channels = await gothamHandler.getLinearChannels();\n\n  const linear_channels = [];\n\n  for (const channel of Object.values(channels)) {\n    linear_channels.push({\n      id: channel.id,\n      name: channel.name,\n    });\n  }\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>Gotham Sports</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/gotham/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#gotham-body\"\n                name=\"gotham-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"gotham-body\" hx-swap=\"innerHTML\">\n          <GothamBody enabled={enabled} tokens={tokens} channels={linear_channels} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/hudl/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {hudlHandler, IHudlMeta} from '@/services/hudl-handler';\nimport {HudlBody} from './views/CardBody';\n\nexport const hudl = new Hono().basePath('/hudl');\n\nconst scheduleEvents = async () => {\n  await hudlHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('hudl');\n};\n\nhudl.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['hudl-enabled'] === 'on';\n\n  //await db.providers.updateAsync<IProvider, any>({name: 'hudl'}, {$set: {enabled}});\n  \n  const {affectedDocuments} = await db.providers.updateAsync<IProvider, any>(\n    {name: 'hudl'},\n    {$set: {enabled}},\n    {returnUpdatedDocs: true},\n  );\n  \n  const {meta} = affectedDocuments as IProvider<IHudlMeta>;\n\n  if (enabled) {\n    //scheduleEvents();\n  } else {\n    removeEvents();\n  }\n  \n  return c.html(<HudlBody enabled={enabled} open={enabled} meta={meta} />, 200, {\n    ...(enabled && {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled Hudl\"}}`,\n    }),\n  });\n});\n\nhudl.put('/conferences/toggle/:short_name', async c => {\n  const short_name = c.req.param('short_name');\n  const {meta} = await db.providers.findOneAsync<IProvider>({name: 'hudl'});\n\n  const body = await c.req.parseBody();\n  const enabled = body['conference-enabled'] === 'on';\n  \n  // find requested conference\n  const matchingConference = meta.conferences.filter(obj => obj.short_name === short_name);\n  // update requested conference\n  matchingConference[0].enabled = enabled;\n  // add the conference back\n  await hudlHandler.updateConference(matchingConference[0]);\n  // update sites\n  await hudlHandler.updateConferenceSites(matchingConference[0]);\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(\n    <input\n      hx-target=\"this\"\n      hx-swap=\"outerHTML\"\n      type=\"checkbox\"\n      checked={enabled ? true : false}\n      data-enabled={enabled ? 'true' : 'false'}\n      hx-put={`/providers/hudl/conferences/toggle/${short_name}`}\n      hx-trigger=\"change\"\n      name=\"conference-enabled\"\n    />,\n    200,\n    {\n      ...(enabled && {\n        'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled ${short_name}\"}}`,\n      }),\n    },\n  );\n});\n"
  },
  {
    "path": "services/providers/hudl/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {IHudlMeta} from '@/services/hudl-handler';\n\ninterface IHudlBodyProps {\n  enabled: boolean;\n  open?: boolean;\n  meta: IHudlMeta;\n}\n\nexport const HudlBody: FC<IHudlBodyProps> = ({enabled, open, meta}) => {\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <summary>\n        <span>Conferences</span>\n      </summary>\n      <table class=\"striped\">\n        <thead>\n          <tr>\n            <th></th>\n            <th scope=\"col\">Name</th>\n          </tr>\n        </thead>\n        <tbody>\n          {meta.conferences.map(c => (\n            <tr key={c.short_name}>\n              <td>\n                <input\n                  hx-target=\"this\"\n                  hx-swap=\"outerHTML\"\n                  type=\"checkbox\"\n                  checked={c.enabled}\n                  data-enabled={c.enabled ? 'true' : 'false'}\n                  hx-put={`/providers/hudl/conferences/toggle/${c.short_name}`}\n                  hx-trigger=\"change\"\n                  name=\"conference-enabled\"\n                />\n              </td>\n              <td>{c.short_name} {(c.full_name == c.short_name) ? '' : (' (' + c.full_name + ')')}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/hudl/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\n\nimport {HudlBody} from './CardBody';\n\nexport const Hudl: FC = async () => {\n  const {enabled, meta} = await db.providers.findOneAsync<IProvider>({name: 'hudl'});\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>Hudl</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/hudl/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#hudl-body\"\n                name=\"hudl-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"hudl-body\" hx-swap=\"innerHTML\">\n          <HudlBody enabled={enabled} meta={meta} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/index.ts",
    "content": "import {Hono} from 'hono';\n\nimport {cbs} from './cbs-sports';\nimport {mw} from './mw';\nimport {wsn} from './wsn';\nimport {hudl} from './hudl';\nimport {paramount} from './paramount';\nimport {flosports} from './flosports';\nimport {mlbtv} from './mlb';\nimport {fox} from './fox';\nimport {foxone} from './foxone';\nimport {b1g} from './b1g';\nimport {nfl} from './nfl';\nimport {espn} from './espn';\nimport {espnplus} from './espn-plus';\nimport {gotham} from './gotham';\nimport {pwhl} from './pwhl';\nimport {bally} from './bally';\nimport {nwsl} from './nwsl';\nimport {midco} from './midco';\nimport {nhl} from './nhl-tv';\nimport {victory} from './victory';\nimport {kbo} from './kbo';\nimport {ksl} from './ksl';\nimport {zeam} from './zeam';\nimport {outside} from './outside';\nimport {wnba} from './wnba';\n\nexport const providers = new Hono().basePath('/providers');\n\nproviders.route('/', cbs);\nproviders.route('/', nhl);\nproviders.route('/', mw);\nproviders.route('/', wsn);\nproviders.route('/', pwhl);\nproviders.route('/', bally);\nproviders.route('/', nwsl);\nproviders.route('/', midco);\nproviders.route('/', hudl);\nproviders.route('/', paramount);\nproviders.route('/', flosports);\nproviders.route('/', mlbtv);\nproviders.route('/', victory);\nproviders.route('/', fox);\nproviders.route('/', foxone);\nproviders.route('/', b1g);\nproviders.route('/', nfl);\nproviders.route('/', espn);\nproviders.route('/', espnplus);\nproviders.route('/', gotham);\nproviders.route('/', kbo);\nproviders.route('/', ksl);\nproviders.route('/', zeam);\nproviders.route('/', outside);\nproviders.route('/', wnba);\n"
  },
  {
    "path": "services/providers/kbo/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {kboHandler} from '@/services/kbo-handler';\n\nexport const kbo = new Hono().basePath('/kbo');\n\nconst scheduleEvents = async () => {\n  await kboHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('kbo');\n};\n\nkbo.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['kbo-enabled'] === 'on';\n\n  await db.providers.updateAsync<IProvider, any>({name: 'kbo'}, {$set: {enabled}});\n\n  if (enabled) {\n    scheduleEvents();\n  } else {\n    await db.providers.updateAsync({name: 'kbo'}, {$set: {'meta.client_id': ''}});\n    removeEvents();\n  }\n\n  return c.html(<></>, 200, {\n    ...(enabled && {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled KBO\"}}`,\n    }),\n  });\n});\n"
  },
  {
    "path": "services/providers/kbo/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\n\nexport const KBO: FC = async () => {\n  const kbo = await db.providers.findOneAsync<IProvider>({name: 'kbo'});\n  const enabled = kbo?.enabled;\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>KBO</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/kbo/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#kbo-body\"\n                name=\"kbo-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"kbo-body\" hx-swap=\"outerHTML\" />\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/ksl/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {kslHandler} from '@/services/ksl-handler';\n\nexport const ksl = new Hono().basePath('/ksl');\n\nconst scheduleEvents = async () => {\n  await kslHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('ksl');\n};\n\nksl.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['ksl-enabled'] === 'on';\n\n  await db.providers.updateAsync<IProvider, any>({name: 'ksl'}, {$set: {enabled}});\n\n  if (enabled) {\n    scheduleEvents();\n  } else {\n    removeEvents();\n  }\n\n  return c.html(<></>, 200, {\n    ...(enabled && {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled KSL Sports\"}}`,\n    }),\n  });\n});\n"
  },
  {
    "path": "services/providers/ksl/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\n\nexport const KSL: FC = async () => {\n  const ksl = await db.providers.findOneAsync<IProvider>({name: 'ksl'});\n  const enabled = ksl?.enabled;\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>KSL Sports</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/ksl/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#ksl-body\"\n                name=\"ksl-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"ksl-body\" hx-swap=\"outerHTML\" />\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/midco/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {MidcoBody} from './views/CardBody';\n\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {midcoHandler, TMidcoTokens} from '@/services/midco-handler';\n\nexport const midco = new Hono().basePath('/midco');\n\nconst scheduleEvents = async () => {\n  await midcoHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('midco');\n};\n\nmidco.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['midco-enabled'] === 'on';\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider<TMidcoTokens>, any>({name: 'midco'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  return c.html(<Login />);\n});\n\nmidco.post('/login', async c => {\n  const body = await c.req.parseBody();\n  const email = body.email as string;\n  const password = body.password as string;\n\n  const isAuthenticated = await midcoHandler.login(email, password);\n\n  if (!isAuthenticated) {\n    return c.html(<Login invalid={true} />);\n  }\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TMidcoTokens>, any>(\n    {name: 'midco'},\n    {\n      $set: {\n        enabled: true,\n        meta: {\n          password,\n          email,\n        },\n      },\n    },\n    {returnUpdatedDocs: true},\n  );\n  const {tokens} = affectedDocuments as IProvider<TMidcoTokens>;\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<MidcoBody enabled={true} tokens={tokens} open={true} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled Midco Sports Plus\"}}`,\n  });\n});\n\nmidco.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n"
  },
  {
    "path": "services/providers/midco/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TMidcoTokens} from '@/services/midco-handler';\n\ninterface IMidcoBodyProps {\n  enabled: boolean;\n  tokens?: TMidcoTokens;\n  open?: boolean;\n}\n\nexport const MidcoBody: FC<IMidcoBodyProps> = async ({enabled, tokens, open}) => {\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/midco/reauth\" hx-trigger=\"submit\">\n            <button id=\"midco-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/midco/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\ninterface ILoginProps {\n  invalid?: boolean;\n}\n\nexport const Login: FC<ILoginProps> = async ({invalid}) => {\n  return (\n    <div hx-target=\"this\" hx-swap=\"outerHTML\">\n      <form hx-post=\"/providers/midco/login\" hx-trigger=\"submit\" id=\"midco-login-form\">\n        <fieldset class=\"grid\">\n          <input\n            {...(invalid && {\n              'aria-describedby': 'invalid-helper',\n              'aria-invalid': 'true',\n            })}\n            name=\"email\"\n            id=\"midco-email\"\n            placeholder=\"Email\"\n            aria-label=\"Email\"\n          />\n          <input\n            {...(invalid && {\n              'aria-describedby': 'invalid-helper',\n              'aria-invalid': 'true',\n            })}\n            id=\"midco-password\"\n            type=\"password\"\n            name=\"password\"\n            placeholder=\"Password\"\n            aria-label=\"Password\"\n          />\n          <button type=\"submit\" id=\"midco-login\">\n            Log in\n          </button>\n        </fieldset>\n        {invalid && <small id=\"invalid-helper\">Login failed. Please try again.</small>}\n      </form>\n      <script\n        dangerouslySetInnerHTML={{\n          __html: `\n            var form = document.getElementById('midco-login-form');\n\n            if (form) {\n              form.addEventListener('htmx:beforeRequest', function() {\n                this.querySelector('#midco-login').setAttribute('aria-busy', 'true');\n                this.querySelector('#midco-login').setAttribute('aria-label', 'Loading…');\n                this.querySelector('#midco-email').disabled = true;\n                this.querySelector('#midco-password').disabled = true;\n              });\n            }\n          `,\n        }}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/midco/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {TMidcoTokens} from '@/services/midco-handler';\n\nimport {MidcoBody} from './CardBody';\n\nexport const Midco: FC = async () => {\n  const midco = await db.providers.findOneAsync<IProvider<TMidcoTokens>>({name: 'midco'});\n  const {enabled, tokens} = midco;\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>Midco Sports</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/midco/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#midco-body\"\n                name=\"midco-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"midco-body\" hx-swap=\"innerHTML\">\n          <MidcoBody enabled={enabled} tokens={tokens} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/mlb/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {MlbBody} from './views/CardBody';\n\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {mlbHandler, TMLBTokens} from '@/services/mlb-handler';\n\nexport const mlbtv = new Hono().basePath('/mlbtv');\n\nconst scheduleEvents = async () => {\n  await mlbHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('mlbtv');\n};\n\nmlbtv.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['mlbtv-enabled'] === 'on';\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider, any>({name: 'mlbtv'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  return c.html(<Login />);\n});\n\nmlbtv.put('/toggle-free', async c => {\n  const body = await c.req.parseBody();\n  const onlyFree = body['mlbtv-onlyfree-enabled'] === 'on';\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TMLBTokens>, any>(\n    {name: 'mlbtv'},\n    {$set: {meta: {onlyFree}}},\n    {returnUpdatedDocs: true},\n  );\n  const {enabled, tokens, linear_channels} = affectedDocuments as IProvider<TMLBTokens>;\n  scheduleEvents();\n\n  return c.html(<MlbBody enabled={enabled} tokens={tokens} channels={linear_channels} />);\n});\n\nmlbtv.get('/auth/:code', async c => {\n  const code = c.req.param('code');\n\n  const isAuthenticated = await mlbHandler.authenticateRegCode();\n\n  if (!isAuthenticated) {\n    return c.html(<Login code={code} />);\n  }\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TMLBTokens>, any>(\n    {name: 'mlbtv'},\n    {$set: {enabled: true}},\n    {returnUpdatedDocs: true},\n  );\n  const {tokens, linear_channels} = affectedDocuments as IProvider<TMLBTokens>;\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<MlbBody enabled={true} tokens={tokens} open={true} channels={linear_channels} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled MLB.tv\"}}`,\n  });\n});\n\nmlbtv.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n\nmlbtv.put('/mlbn-access', async c => {\n  const {linear_channels: originalChannels} = await db.providers.findOneAsync<IProvider>({name: 'mlbtv'});\n  const updatedValue = await mlbHandler.checkMlbNetworkAccess(true);\n\n  if (updatedValue && !originalChannels[1].enabled) {\n    await mlbHandler.getSchedule();\n    await scheduleEntries();\n  }\n\n  const {enabled, tokens, linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'mlbtv'});\n\n  return c.html(<MlbBody enabled={enabled} tokens={tokens} channels={linear_channels} />);\n});\n\nmlbtv.put('/sny-access', async c => {\n  const {linear_channels: originalChannels} = await db.providers.findOneAsync<IProvider>({name: 'mlbtv'});\n  const updatedValue = await mlbHandler.checkSnyAccess(true);\n\n  if (updatedValue && !originalChannels[2].enabled) {\n    await mlbHandler.getSchedule();\n    await scheduleEntries();\n  }\n\n  const {enabled, tokens, linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'mlbtv'});\n\n  return c.html(<MlbBody enabled={enabled} tokens={tokens} channels={linear_channels} />);\n});\n\nmlbtv.put('/snla-access', async c => {\n  const {linear_channels: originalChannels} = await db.providers.findOneAsync<IProvider>({name: 'mlbtv'});\n  const updatedValue = await mlbHandler.checkSnyAccess(true);\n\n  if (updatedValue && !originalChannels[3].enabled) {\n    await mlbHandler.getSchedule();\n    await scheduleEntries();\n  }\n\n  const {enabled, tokens, linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'mlbtv'});\n\n  return c.html(<MlbBody enabled={enabled} tokens={tokens} channels={linear_channels} />);\n});\n"
  },
  {
    "path": "services/providers/mlb/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TMLBTokens} from '@/services/mlb-handler';\nimport {IProviderChannel} from '@/services/shared-interfaces';\n\ninterface IMLBBodyProps {\n  enabled: boolean;\n  tokens?: TMLBTokens;\n  open?: boolean;\n  channels: IProviderChannel[];\n}\n\nexport const MlbBody: FC<IMLBBodyProps> = ({enabled, tokens, open, channels}) => {\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  const allEnabled = channels.filter(a => a.enabled).length === channels.length;\n\n  const linkMap = [\n    {},\n    {\n      btnText: 'Check TVE Access',\n      hintText: 'Enable with TVE Provider',\n      link: 'https://www.mlb.com/login?campaignCode=mlbn2&redirectUri=/app/atbat/network/live&affiliateId=mlbapp-android_webview',\n      network: 'mlbn',\n      toolTipText: 'Only if your TVE Provider has MLB Network',\n    },\n    {\n      btnText: 'Check SNY Access',\n      hintText: 'Enable with MLB.tv',\n      link: 'https://www.mlb.com/commerce/mvpd/sny/link',\n      network: 'sny',\n    },\n    {\n      btnText: 'Check SNLA Access',\n      hintText: 'Enable with MLB.tv',\n      link: 'https://www.mlb.com/commerce/mvpd/getdodgers',\n      network: 'snla',\n    },\n  ];\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <summary>\n        <span>Linear Channels</span>\n      </summary>\n      <table class=\"striped\">\n        <thead>\n          <tr>\n            <th></th>\n            <th scope=\"col\">Name</th>\n            {!allEnabled && (\n              <>\n                <th scope=\"col\">Notes</th>\n                <th scope=\"col\">Action</th>\n              </>\n            )}\n          </tr>\n        </thead>\n        <tbody>\n          {channels.map((c, i) => (\n            <tr key={c.id}>\n              <td>\n                <input\n                  type=\"checkbox\"\n                  checked={c.enabled}\n                  data-enabled={c.enabled ? 'true' : 'false'}\n                  disabled={true}\n                />\n              </td>\n              <td>{c.name}</td>\n              {!c.enabled ? (\n                <>\n                  {linkMap[i].link ? (\n                    <>\n                      <td>\n                        <a href={linkMap[i].link} target=\"_blank\">\n                          {linkMap[i].hintText}\n                        </a>\n                        {linkMap[i].toolTipText && (\n                          <span class=\"warning-red\" data-tooltip={linkMap[i].toolTipText}>\n                            **\n                          </span>\n                        )}\n                      </td>\n                      <td>\n                        <form\n                          hx-trigger=\"submit\"\n                          hx-put={`/providers/mlbtv/${linkMap[i].network}-access`}\n                          id={`mlbtv-${linkMap[i].network}`}\n                        >\n                          <button id={`mlbtv-check-${linkMap[i].network}`}>{linkMap[i].btnText}</button>\n                        </form>\n                        <script\n                          dangerouslySetInnerHTML={{\n                            __html: `\n                              var recheck${linkMap[i].network}Access = document.getElementById('mlbtv-${linkMap[i].network}');\n\n                              if (recheck${linkMap[i].network}Access) {\n                                recheck${linkMap[i].network}Access.addEventListener('htmx:beforeRequest', function() {\n                                  this.querySelector('#mlbtv-check-${linkMap[i].network}').setAttribute('aria-busy', 'true');\n                                  this.querySelector('#mlbtv-check-${linkMap[i].network}').setAttribute('aria-label', 'Loading…');\n                                });\n                              }\n                            `,\n                          }}\n                        />\n                      </td>\n                    </>\n                  ) : (\n                    <>\n                      <td>Unlock with a full subscription on MLB.tv</td>\n                      <td></td>\n                    </>\n                  )}\n                </>\n              ) : (\n                <>\n                  <td></td>\n                  <td></td>\n                </>\n              )}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/mlbtv/reauth\" hx-trigger=\"submit\">\n            <button id=\"mlbtv-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/mlb/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {mlbHandler} from '@/services/mlb-handler';\n\ninterface ILogin {\n  code?: string;\n}\n\nexport const Login: FC<ILogin> = async ({code}) => {\n  let shownCode = code;\n\n  if (!shownCode) {\n    shownCode = await mlbHandler.getAuthCode();\n  }\n\n  return (\n    <div hx-target=\"this\" hx-swap=\"outerHTML\" hx-trigger=\"every 5s\" hx-get={`/providers/mlbtv/auth/${shownCode}`}>\n      <div class=\"grid-container\">\n        <div>\n          <h5>MLB.tv Login:</h5>\n          <span>\n            Open this link and follow instructions:\n            <br />\n            <a href=\"https://ids.mlb.com/activate\" target=\"_blank\">\n              https://ids.mlb.com/activate\n            </a>\n          </span>\n          <h6>Code: {shownCode}</h6>\n        </div>\n        <div aria-busy=\"true\" style=\"align-content: center\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/mlb/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {TMLBTokens} from '@/services/mlb-handler';\n\nimport {MlbBody} from './CardBody';\n\nexport const MlbTv: FC = async () => {\n  const mlbtv = await db.providers.findOneAsync<IProvider<TMLBTokens>>({name: 'mlbtv'});\n  const enabled = mlbtv?.enabled;\n  const tokens = mlbtv?.tokens;\n  const channels = mlbtv?.linear_channels || [];\n  const onlyFree = mlbtv.meta?.onlyFree;\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>MLB.tv</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/mlbtv/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#mlbtv-body\"\n                name=\"mlbtv-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div class=\"grid-container\">\n          <div />\n          <fieldset>\n            <label>\n              Only Free Games?&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/mlbtv/toggle-free`}\n                hx-trigger=\"change\"\n                name=\"mlbtv-onlyfree-enabled\"\n                hx-target=\"#mlbtv-body\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={onlyFree ? true : false}\n                data-enabled={onlyFree ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"mlbtv-body\" hx-swap=\"innerHTML\">\n          <MlbBody enabled={enabled} tokens={tokens} channels={channels} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/mw/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {mwHandler, TMWTokens} from '@/services/mw-handler';\nimport {MWBody} from './views/CardBody';\n\nexport const mw = new Hono().basePath('/mw');\n\nconst scheduleEvents = async () => {\n  await mwHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('mountain-west');\n};\n\nconst registerUser = async () => {\n  const isRegistered = await mwHandler.registerUser();\n  if (isRegistered) {\n    const {affectedDocuments} = await db.providers.updateAsync<IProvider, any>(\n      {name: 'mw'}, \n      {$set: {enabled: true}},\n      {returnUpdatedDocs: true},\n    );\n    const {tokens} = affectedDocuments as IProvider<TMWTokens>;\n    scheduleEvents();\n    return {enabled: true, tokens};\n  } else {\n    console.log('Failed to register Mountain West user');\n    await db.providers.updateAsync<IProvider, any>({name: 'mw'}, {$set: {enabled: false}});\n  }\n}\n\nmw.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['mw-enabled'] === 'on';\n\n  if (enabled) {\n    const {enabled, tokens} = await registerUser();\n\n    return c.html(<MWBody enabled={true} tokens={tokens} open={true} />, 200, {\n      ...(enabled && {\n        'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled Mountain West\"}}`,\n      }),\n    });\n  } else {\n    await db.providers.updateAsync<IProvider, any>({name: 'mw'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<MWBody enabled={false} open={false} />, 200, {\n      ...(enabled && {\n        'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully disabled Mountain West\"}}`,\n      }),\n    });\n  }\n});\n\nmw.put('/register', async c => {\n  const {enabled, tokens} = await registerUser();\n\n  return c.html(<MWBody enabled={true} tokens={tokens} open={true} />, 200, {\n    ...(enabled && {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully registered Mountain West\"}}`,\n    }),\n  });\n});"
  },
  {
    "path": "services/providers/mw/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TMWTokens} from '@/services/mw-handler';\n\ninterface IMWBodyProps {\n  enabled: boolean;\n  tokens?: TMWTokens;\n  open?: boolean;\n}\n\nexport const MWBody: FC<IMWBodyProps> = async ({enabled, tokens, open}) => {\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/mw/register\" hx-trigger=\"submit\">\n            <button id=\"mw-register\">Re-Register</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/mw/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {TMWTokens} from '@/services/mw-handler';\n\nimport {MWBody} from './CardBody';\n\nexport const MntWest: FC = async () => {\n  const mw = await db.providers.findOneAsync<IProvider<TMWTokens>>({name: 'mw'});\n  const enabled = mw?.enabled;\n  const tokens = mw?.tokens;\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>Mountain West</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/mw/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#mw-body\"\n                name=\"mw-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"mw-body\" hx-swap=\"outerHTML\">\n          <MWBody enabled={enabled} tokens={tokens} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/nfl/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {nflHandler, TNFLTokens, TOtherAuth} from '@/services/nfl-handler';\n\nimport {Login} from './views/Login';\nimport {NFLBody} from './views/CardBody';\n\nexport const nfl = new Hono().basePath('/nfl');\n\nconst scheduleEvents = async () => {\n  await nflHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('nfl+');\n};\n\nnfl.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['nfl-enabled'] === 'on';\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider<TNFLTokens>, any>({name: 'nfl'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  return c.html(<Login />);\n});\n\nnfl.put('/auth/:provider', async c => {\n  const provider = c.req.param('provider') as TOtherAuth;\n  const body = await c.req.parseBody();\n  const enabled = body[`nfl-${provider}-enabled`] === 'on';\n\n  const {tokens} = await db.providers.findOneAsync<IProvider<TNFLTokens>>({name: 'nfl'});\n\n  const updatedTokens = {...tokens};\n\n  if (!enabled) {\n    switch (provider) {\n      case 'peacock':\n        delete updatedTokens.peacockUUID;\n        delete updatedTokens.peacockUserId;\n        break;\n      case 'prime':\n        delete updatedTokens.amazonPrimeUUID;\n        delete updatedTokens.amazonPrimeUserId;\n        break;\n      case 'tve':\n        delete updatedTokens.mvpdIdp;\n        delete updatedTokens.mvpdUUID;\n        delete updatedTokens.mvpdUserId;\n        break;\n      case 'sunday_ticket':\n        delete updatedTokens.youTubeUUID;\n        delete updatedTokens.youTubeUserId;\n        break;\n    }\n  }\n\n  if (!enabled) {\n    const {affectedDocuments} = await db.providers.updateAsync<IProvider<TNFLTokens>, any>(\n      {name: 'nfl'},\n      {$set: {tokens: updatedTokens}},\n      {returnUpdatedDocs: true},\n    );\n    const {linear_channels, tokens} = affectedDocuments as IProvider<TNFLTokens>;\n\n    return c.html(<NFLBody channels={linear_channels} enabled={true} open={false} tokens={tokens} />);\n  }\n\n  return c.html(<Login otherAuth={provider} />);\n});\n\nnfl.get('/login/:code/:other', async c => {\n  const code = c.req.param('code');\n  const otherAuth = c.req.param('other');\n\n  const provider = otherAuth === 'undefined' ? undefined : (otherAuth as TOtherAuth);\n\n  const isAuthenticated = await nflHandler.authenticateRegCode(code, provider);\n\n  if (!isAuthenticated) {\n    return c.html(<Login code={code} otherAuth={provider} />);\n  }\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TNFLTokens>, any>(\n    {name: 'nfl'},\n    {$set: {enabled: true}},\n    {returnUpdatedDocs: true},\n  );\n  const {linear_channels, tokens} = affectedDocuments as IProvider<TNFLTokens>;\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  const otherAuthName =\n    otherAuth === 'tve'\n      ? ' (TV Provider)'\n      : otherAuth === 'prime'\n      ? ' (Amazon Prime)'\n      : otherAuth === 'peacock'\n      ? ' (Peacock)'\n      : otherAuth === 'sunday_ticket'\n      ? ' (Youtube)'\n      : '';\n\n  const message = `NFL${otherAuthName}`;\n\n  return c.html(<NFLBody enabled={true} tokens={tokens} open={true} channels={linear_channels} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled ${message}\"}}`,\n  });\n});\n\nnfl.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n\nnfl.put('/channels/toggle/:id', async c => {\n  const channelId = c.req.param('id');\n  const {linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'nfl'});\n\n  const body = await c.req.parseBody();\n  const enabled = body['channel-enabled'] === 'on';\n\n  let updatedChannel = channelId;\n\n  const updatedChannels = linear_channels.map(channel => {\n    if (channel.id === channelId) {\n      updatedChannel = channel.name;\n      return {...channel, enabled: !channel.enabled};\n    }\n    return channel;\n  });\n\n  if (updatedChannel !== channelId) {\n    await db.providers.updateAsync<IProvider, any>({name: 'nfl'}, {$set: {linear_channels: updatedChannels}});\n\n    // Kickoff event scheduler\n    scheduleEvents();\n\n    return c.html(\n      <input\n        hx-target=\"this\"\n        hx-swap=\"outerHTML\"\n        type=\"checkbox\"\n        checked={enabled ? true : false}\n        data-enabled={enabled ? 'true' : 'false'}\n        hx-put={`/providers/nfl/channels/toggle/${channelId}`}\n        hx-trigger=\"change\"\n        name=\"channel-enabled\"\n      />,\n      200,\n      {\n        ...(enabled && {\n          'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled ${updatedChannel}\"}}`,\n        }),\n      },\n    );\n  }\n});\n"
  },
  {
    "path": "services/providers/nfl/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TNFLTokens} from '@/services/nfl-handler';\nimport {IProviderChannel} from '@/services/shared-interfaces';\nimport {usesLinear} from '@/services/misc-db-service';\n\ninterface INFLBodyProps {\n  enabled: boolean;\n  tokens?: TNFLTokens;\n  open?: boolean;\n  channels: IProviderChannel[];\n}\n\nexport const NFLBody: FC<INFLBodyProps> = async ({enabled, tokens, open, channels}) => {\n  const useLinear = await usesLinear();\n\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"innerHTML\" hx-target=\"this\">\n      <summary>\n        <span data-tooltip=\"These are only enabled with Dedicated Linear Channels enabled\" data-placement=\"right\">\n          Linear Channels\n        </span>\n      </summary>\n      <table class=\"striped\">\n        <thead>\n          <tr>\n            <th></th>\n            <th scope=\"col\">Name</th>\n          </tr>\n        </thead>\n        <tbody>\n          {channels.map(c => (\n            <tr key={c.id}>\n              <td>\n                <input\n                  hx-target=\"this\"\n                  hx-swap=\"outerHTML\"\n                  type=\"checkbox\"\n                  checked={c.enabled}\n                  data-enabled={c.enabled ? 'true' : 'false'}\n                  disabled={!useLinear || c.id === 'NFLNRZ'}\n                  hx-put={`/providers/nfl/channels/toggle/${c.id}`}\n                  hx-trigger=\"change\"\n                  name=\"channel-enabled\"\n                />\n              </td>\n              <td>{c.name}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n      <div class=\"grid-container\">\n        <h6>TV Provider:</h6>\n        <fieldset>\n          <label>\n            Enabled&nbsp;&nbsp;\n            <input\n              hx-put=\"/providers/nfl/auth/tve\"\n              hx-trigger=\"change\"\n              hx-target=\"#nfl-body\"\n              name=\"nfl-tve-enabled\"\n              type=\"checkbox\"\n              role=\"switch\"\n              checked={tokens.mvpdIdp ? true : false}\n              data-enabled={tokens.mvpdIdp ? 'true' : 'false'}\n            />\n          </label>\n        </fieldset>\n      </div>\n      <div class=\"grid-container\">\n        <h6>Peacock:</h6>\n        <fieldset>\n          <label>\n            Enabled&nbsp;&nbsp;\n            <input\n              hx-put=\"/providers/nfl/auth/peacock\"\n              hx-trigger=\"change\"\n              hx-target=\"#nfl-body\"\n              name=\"nfl-peacock-enabled\"\n              type=\"checkbox\"\n              role=\"switch\"\n              checked={tokens.peacockUserId ? true : false}\n              data-enabled={tokens.peacockUserId ? 'true' : 'false'}\n            />\n          </label>\n        </fieldset>\n      </div>\n      <div class=\"grid-container\">\n        <h6>Amazon Prime:</h6>\n        <fieldset>\n          <label>\n            Enabled&nbsp;&nbsp;\n            <input\n              hx-put=\"/providers/nfl/auth/prime\"\n              hx-trigger=\"change\"\n              hx-target=\"#nfl-body\"\n              name=\"nfl-prime-enabled\"\n              type=\"checkbox\"\n              role=\"switch\"\n              checked={tokens.amazonPrimeUserId ? true : false}\n              data-enabled={tokens.amazonPrimeUserId ? 'true' : 'false'}\n            />\n          </label>\n        </fieldset>\n      </div>\n      <div class=\"grid-container\">\n        <h6>Sunday Ticket:</h6>\n        <fieldset>\n          <label>\n            Enabled&nbsp;&nbsp;\n            <input\n              hx-put=\"/providers/nfl/auth/sunday_ticket\"\n              hx-trigger=\"change\"\n              hx-target=\"#nfl-body\"\n              name=\"nfl-sunday_ticket-enabled\"\n              type=\"checkbox\"\n              role=\"switch\"\n              checked={tokens.youTubeUserId ? true : false}\n              data-enabled={tokens.youTubeUserId ? 'true' : 'false'}\n            />\n          </label>\n        </fieldset>\n      </div>\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/nfl/reauth\" hx-trigger=\"submit\">\n            <button id=\"nfl-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/nfl/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {nflHandler, TOtherAuth} from '@/services/nfl-handler';\n\ninterface ILogin {\n  code?: string;\n  otherAuth?: TOtherAuth;\n}\n\nexport const Login: FC<ILogin> = async ({code, otherAuth}) => {\n  let shownCode = code;\n\n  if (!shownCode) {\n    [shownCode] = await nflHandler.getAuthCode(otherAuth);\n  }\n\n  const otherAuthName =\n    otherAuth === 'tve'\n      ? ' (TV Provider)'\n      : otherAuth === 'prime'\n      ? ' (Amazon Prime)'\n      : otherAuth === 'peacock'\n      ? ' (Peacock)'\n      : otherAuth === 'sunday_ticket'\n      ? ' (Youtube)'\n      : '';\n\n  return (\n    <div\n      hx-target=\"this\"\n      hx-swap=\"outerHTML\"\n      hx-trigger=\"every 5s\"\n      hx-get={`/providers/nfl/login/${shownCode}/${otherAuth}`}\n    >\n      <div class=\"grid-container\">\n        <div>\n          <h5>{`NFL Login${otherAuthName}`}:</h5>\n          <span>\n            Open this link and follow instructions:\n            <br />\n            <a href={`https://id.nfl.com/account/activate?regCode=${shownCode}`} target=\"_blank\">\n              {`https://id.nfl.com/account/activate?regCode=${shownCode}`}\n            </a>\n          </span>\n        </div>\n        <div aria-busy=\"true\" style=\"align-content: center\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/nfl/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {TNFLTokens} from '@/services/nfl-handler';\n\nimport {NFLBody} from './CardBody';\n\nexport const NFL: FC = async () => {\n  const nfl = await db.providers.findOneAsync<IProvider<TNFLTokens>>({name: 'nfl'});\n  const enabled = nfl?.enabled;\n  const tokens = nfl?.tokens;\n  const channels = nfl?.linear_channels || [];\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>NFL</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/nfl/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#nfl-body\"\n                name=\"nfl-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"nfl-body\" hx-swap=\"innerHTML\">\n          <NFLBody enabled={enabled} tokens={tokens} channels={channels} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/nhl-tv/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {Login} from './views/Login';\nimport {NHLBody} from './views/CardBody';\n\nimport {db} from '@/services/database';\nimport {nhlHandler, TNHLTokens} from '@/services/nhltv-handler';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\n\nexport const nhl = new Hono().basePath('/nhl');\n\nconst scheduleEvents = async () => {\n  await nhlHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('nhl');\n};\n\nnhl.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['nhl-enabled'] === 'on';\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider, any>({name: 'nhl'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  return c.html(<Login />);\n});\n\nnhl.get('/login/:code', async c => {\n  const code = c.req.param('code');\n\n  const isAuthenticated = await nhlHandler.authenticateRegCode(code);\n\n  if (!isAuthenticated) {\n    return c.html(<Login code={code} />);\n  }\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TNHLTokens>, any>(\n    {name: 'nhl'},\n    {$set: {enabled: true}},\n    {returnUpdatedDocs: true},\n  );\n  const {tokens} = affectedDocuments as IProvider<TNHLTokens>;\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<NHLBody enabled={true} tokens={tokens} open={true} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled NHL Sports\"}}`,\n  });\n});\n\nnhl.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n"
  },
  {
    "path": "services/providers/nhl-tv/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TNHLTokens} from '@/services/nhltv-handler';\n\ninterface INHLBodyProps {\n  enabled: boolean;\n  tokens?: TNHLTokens;\n  open?: boolean;\n}\n\nexport const NHLBody: FC<INHLBodyProps> = ({enabled, tokens, open}) => {\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/nhl/reauth\" hx-trigger=\"submit\">\n            <button id=\"nhl-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/nhl-tv/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {nhlHandler} from '@/services/nhltv-handler';\n\ninterface ILogin {\n  code?: string;\n}\n\nexport const Login: FC<ILogin> = async ({code}) => {\n  let shownCode = code;\n\n  if (!shownCode) {\n    shownCode = await nhlHandler.getAuthCode();\n  }\n\n  return (\n    <div hx-target=\"this\" hx-swap=\"outerHTML\" hx-trigger=\"every 5s\" hx-get={`/providers/nhl/login/${shownCode}`}>\n      <div class=\"grid-container\">\n        <div>\n          <h5>Login:</h5>\n          <span>\n            Open this link and follow instructions:\n            <br />\n            <a href=\"http://nhltv.nhl.com/code\" target=\"_blank\">\n              http://nhltv.nhl.com/code\n            </a>\n          </span>\n          <h6>Code: {shownCode}</h6>\n        </div>\n        <div aria-busy=\"true\" style=\"align-content: center\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/nhl-tv/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {TNHLTokens} from '@/services/nhltv-handler';\nimport {NHLBody} from './CardBody';\n\nexport const NHL: FC = async () => {\n  const nhl = await db.providers.findOneAsync<IProvider<TNHLTokens>>({name: 'nhl'});\n  const enabled = nhl?.enabled;\n  const tokens = nhl?.tokens || {};\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>\n            <span>\n              NHL.tv\n              <span class=\"warning-red\" data-tooltip=\"Europe only\" data-placement=\"right\">\n                **\n              </span>\n            </span>\n          </h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/nhl/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#nhl-auth\"\n                name=\"nhl-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"nhl-auth\" hx-swap=\"innerHTML\">\n          <NHLBody enabled={enabled} tokens={tokens} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/nwsl/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {NwslBody} from './views/CardBody';\n\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {nwslHandler, TNwslTokens} from '@/services/nwsl-handler';\n\nexport const nwsl = new Hono().basePath('/nwsl');\n\nconst scheduleEvents = async () => {\n  await nwslHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('nwsl+');\n};\n\nnwsl.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['nwsl-enabled'] === 'on';\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider<TNwslTokens>, any>({name: 'nwsl'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  return c.html(<Login />);\n});\n\nnwsl.post('/login', async c => {\n  const body = await c.req.parseBody();\n  const username = body.username as string;\n  const password = body.password as string;\n\n  const isAuthenticated = await nwslHandler.login(username, password);\n\n  if (!isAuthenticated) {\n    return c.html(<Login invalid={true} />);\n  }\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TNwslTokens>, any>(\n    {name: 'nwsl'},\n    {\n      $set: {\n        enabled: true,\n        meta: {\n          password,\n          username,\n        },\n      },\n    },\n    {returnUpdatedDocs: true},\n  );\n  const {tokens, linear_channels} = affectedDocuments as IProvider<TNwslTokens>;\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<NwslBody enabled={true} tokens={tokens} open={true} channels={linear_channels} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled NWSL+\"}}`,\n  });\n});\n\nnwsl.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n\nnwsl.put('/channels/toggle/:id', async c => {\n  const channelId = c.req.param('id');\n  const {linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'nwsl'});\n\n  const body = await c.req.parseBody();\n  const enabled = body['channel-enabled'] === 'on';\n\n  let updatedChannel = channelId;\n\n  const updatedChannels = linear_channels.map(channel => {\n    if (channel.id === channelId) {\n      updatedChannel = channel.name;\n      return {...channel, enabled: !channel.enabled};\n    }\n    return channel;\n  });\n\n  if (updatedChannel !== channelId) {\n    await db.providers.updateAsync<IProvider, any>({name: 'nwsl'}, {$set: {linear_channels: updatedChannels}});\n\n    // Kickoff event scheduler\n    scheduleEvents();\n\n    return c.html(\n      <input\n        hx-target=\"this\"\n        hx-swap=\"outerHTML\"\n        type=\"checkbox\"\n        checked={enabled ? true : false}\n        data-enabled={enabled ? 'true' : 'false'}\n        hx-put={`/providers/nwsl/channels/toggle/${channelId}`}\n        hx-trigger=\"change\"\n        name=\"channel-enabled\"\n      />,\n      200,\n      {\n        ...(enabled && {\n          'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled ${updatedChannel}\"}}`,\n        }),\n      },\n    );\n  }\n});\n"
  },
  {
    "path": "services/providers/nwsl/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TNwslTokens} from '@/services/nwsl-handler';\nimport {usesLinear} from '@/services/misc-db-service';\nimport {IProviderChannel} from '@/services/shared-interfaces';\n\ninterface INwslBodyProps {\n  enabled: boolean;\n  tokens?: TNwslTokens;\n  open?: boolean;\n  channels: IProviderChannel[];\n}\n\nexport const NwslBody: FC<INwslBodyProps> = async ({enabled, tokens, open, channels}) => {\n  const useLinear = await usesLinear();\n\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <summary>\n        <span data-tooltip=\"These are only enabled with Dedicated Linear Channels enabled\" data-placement=\"right\">\n          Linear Channels\n        </span>\n      </summary>\n      <table class=\"striped\">\n        <thead>\n          <tr>\n            <th></th>\n            <th scope=\"col\">Name</th>\n          </tr>\n        </thead>\n        <tbody>\n          {channels.map(c => (\n            <tr key={c.id}>\n              <td>\n                <input\n                  hx-target=\"this\"\n                  hx-swap=\"outerHTML\"\n                  type=\"checkbox\"\n                  checked={c.enabled}\n                  data-enabled={c.enabled ? 'true' : 'false'}\n                  disabled={!useLinear}\n                  hx-put={`/providers/nwsl/channels/toggle/${c.id}`}\n                  hx-trigger=\"change\"\n                  name=\"channel-enabled\"\n                />\n              </td>\n              <td>\n                {!c.tmsId ? (\n                  <>\n                    {c.name}\n                    <span class=\"warning-red\" data-tooltip=\"Non-Gracenote channel\" data-placement=\"right\">\n                      *\n                    </span>\n                  </>\n                ) : (\n                  <>{c.name}</>\n                )}\n              </td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/nwsl/reauth\" hx-trigger=\"submit\">\n            <button id=\"nwsl-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/nwsl/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\ninterface ILoginProps {\n  invalid?: boolean;\n}\n\nexport const Login: FC<ILoginProps> = async ({invalid}) => {\n  return (\n    <div hx-target=\"this\" hx-swap=\"outerHTML\">\n      <form hx-post=\"/providers/nwsl/login\" hx-trigger=\"submit\" id=\"nwsl-login-form\">\n        <fieldset class=\"grid\">\n          <input\n            {...(invalid && {\n              'aria-describedby': 'invalid-helper',\n              'aria-invalid': 'true',\n            })}\n            name=\"username\"\n            id=\"nwsl-username\"\n            placeholder=\"Username\"\n            aria-label=\"Username\"\n          />\n          <input\n            {...(invalid && {\n              'aria-describedby': 'invalid-helper',\n              'aria-invalid': 'true',\n            })}\n            id=\"nwsl-password\"\n            type=\"password\"\n            name=\"password\"\n            placeholder=\"Password\"\n            aria-label=\"Password\"\n          />\n          <button type=\"submit\" id=\"nwsl-login\">\n            Log in\n          </button>\n        </fieldset>\n        {invalid && <small id=\"invalid-helper\">Login failed. Please try again.</small>}\n      </form>\n      <script\n        dangerouslySetInnerHTML={{\n          __html: `\n            var form = document.getElementById('nwsl-login-form');\n\n            if (form) {\n              form.addEventListener('htmx:beforeRequest', function() {\n                this.querySelector('#nwsl-login').setAttribute('aria-busy', 'true');\n                this.querySelector('#nwsl-login').setAttribute('aria-label', 'Loading…');\n                this.querySelector('#nwsl-username').disabled = true;\n                this.querySelector('#nwsl-password').disabled = true;\n              });\n            }\n          `,\n        }}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/nwsl/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {TNwslTokens} from '@/services/nwsl-handler';\n\nimport {NwslBody} from './CardBody';\n\nexport const Nwsl: FC = async () => {\n  const nwsl = await db.providers.findOneAsync<IProvider<TNwslTokens>>({name: 'nwsl'});\n  const {enabled, tokens, linear_channels} = nwsl;\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>NWSL+</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/nwsl/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#nwsl-body\"\n                name=\"nwsl-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"nwsl-body\" hx-swap=\"innerHTML\">\n          <NwslBody enabled={enabled} tokens={tokens} channels={linear_channels} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/outside/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {outsideHandler, TOutsideTokens} from '@/services/outside-handler';\nimport {OutsideBody} from './views/CardBody';\n\nexport const outside = new Hono().basePath('/outside');\n\nconst scheduleEvents = async () => {\n  await outsideHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('outside');\n};\n\noutside.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['outside-enabled'] === 'on';\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider, any>({name: 'outside'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  return c.html(<Login />);\n});\n\noutside.get('/tve-login/:code/:loginLink/:checkLink', async c => {\n  const code = c.req.param('code');\n  const loginLink = c.req.param('loginLink');\n  const checkLink = c.req.param('checkLink');\n\n  const isAuthenticated = await outsideHandler.authenticateRegCode(checkLink);\n\n  if (!isAuthenticated) {\n    return c.html(<Login code={code} loginLink={loginLink} checkLink={checkLink} />);\n  }\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TOutsideTokens>, any>(\n    {name: 'outside'},\n    {$set: {enabled: true}},\n    {returnUpdatedDocs: true},\n  );\n  const {tokens, linear_channels} = affectedDocuments as IProvider<TOutsideTokens>;\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<OutsideBody enabled={true} tokens={tokens} open={true} channels={linear_channels} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled Outside TV\"}}`,\n  });\n});\n\noutside.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n\noutside.put('/channels/toggle/:id', async c => {\n  const channelId = c.req.param('id');\n  const {linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'outside'});\n\n  const body = await c.req.parseBody();\n  const enabled = body['channel-enabled'] === 'on';\n\n  let updatedChannel = channelId;\n\n  const updatedChannels = linear_channels.map(channel => {\n    if (channel.id === channelId) {\n      updatedChannel = channel.name;\n      return {...channel, enabled: !channel.enabled};\n    }\n    return channel;\n  });\n\n  if (updatedChannel !== channelId) {\n    await db.providers.updateAsync<IProvider, any>({name: 'outside'}, {$set: {linear_channels: updatedChannels}});\n\n    // Kickoff event scheduler\n    scheduleEvents();\n\n    return c.html(\n      <input\n        hx-target=\"this\"\n        hx-swap=\"outerHTML\"\n        type=\"checkbox\"\n        checked={enabled ? true : false}\n        data-enabled={enabled ? 'true' : 'false'}\n        hx-put={`/providers/outside/channels/toggle/${channelId}`}\n        hx-trigger=\"change\"\n        name=\"channel-enabled\"\n      />,\n      200,\n      {\n        ...(enabled && {\n          'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled ${updatedChannel}\"}}`,\n        }),\n      },\n    );\n  }\n});\n"
  },
  {
    "path": "services/providers/outside/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TOutsideTokens} from '@/services/outside-handler';\nimport {IProviderChannel} from '@/services/shared-interfaces';\nimport {usesLinear} from '@/services/misc-db-service';\n\ninterface IOutsideBodyProps {\n  enabled: boolean;\n  tokens?: TOutsideTokens;\n  open?: boolean;\n  channels: IProviderChannel[];\n}\n\nexport const OutsideBody: FC<IOutsideBodyProps> = async ({enabled, tokens, open, channels}) => {\n  const useLinear = await usesLinear();\n\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <summary>\n        <span data-tooltip=\"These are only enabled with Dedicated Linear Channels enabled\" data-placement=\"right\">\n          Linear Channels\n        </span>\n      </summary>\n      <table class=\"striped\">\n        <thead>\n          <tr>\n            <th></th>\n            <th scope=\"col\">Name</th>\n          </tr>\n        </thead>\n        <tbody>\n          {channels.map(c => (\n            <tr key={c.id}>\n              <td>\n                <input\n                  hx-target=\"this\"\n                  hx-swap=\"outerHTML\"\n                  type=\"checkbox\"\n                  checked={c.enabled}\n                  data-enabled={c.enabled ? 'true' : 'false'}\n                  disabled={!useLinear}\n                  hx-put={`/providers/outside/channels/toggle/${c.id}`}\n                  hx-trigger=\"change\"\n                  name=\"channel-enabled\"\n                />\n              </td>\n              <td>{c.name}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/outside/reauth\" hx-trigger=\"submit\">\n            <button id=\"outside-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/outside/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {outsideHandler} from '@/services/outside-handler';\n\ninterface ILogin {\n  code?: string;\n  loginLink?: string;\n  checkLink?: string;\n}\n\nexport const Login: FC<ILogin> = async ({code, loginLink, checkLink}) => {\n  let shownCode = code;\n  let loginUrl = loginLink;\n  let checkUrl = checkLink;\n\n  if (!shownCode || !loginLink) {\n    [shownCode, loginUrl, checkUrl] = await outsideHandler.getAuthCode();\n  }\n\n  const shownLoginUrl = decodeURIComponent(loginUrl);\n\n  return (\n    <div\n      hx-target=\"this\"\n      hx-swap=\"outerHTML\"\n      hx-trigger=\"every 5s\"\n      hx-get={`/providers/outside/tve-login/${shownCode}/${loginUrl}/${checkUrl}`}\n    >\n      <div class=\"grid-container\">\n        <div>\n          <h5>Outside TV Login:</h5>\n          <span>\n            Open this link and follow instructions:\n            <br />\n            <a href={shownLoginUrl} target=\"_blank\">\n              {shownLoginUrl}\n            </a>\n          </span>\n          <h6>Code: {shownCode}</h6>\n        </div>\n        <div aria-busy=\"true\" style=\"align-content: center\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/outside/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {TOutsideTokens} from '@/services/outside-handler';\n\nimport {OutsideBody} from './CardBody';\n\nexport const Outside: FC = async () => {\n  const outside = await db.providers.findOneAsync<IProvider<TOutsideTokens>>({name: 'outside'});\n  const enabled = outside?.enabled;\n  const tokens = outside?.tokens;\n  const channels = outside?.linear_channels || [];\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>Outside TV</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/outside/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#outside-body\"\n                name=\"outside-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"outside-body\" hx-swap=\"innerHTML\">\n          <OutsideBody enabled={enabled} tokens={tokens} channels={channels} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/paramount/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {paramountHandler, TParamountTokens} from '@/services/paramount-handler';\nimport {ParamountBody} from './views/CardBody';\n\nexport const paramount = new Hono().basePath('/paramount');\n\nconst scheduleEvents = async () => {\n  await paramountHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('paramount+');\n};\n\nparamount.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['paramount-enabled'] === 'on';\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider, any>({name: 'paramount'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  return c.html(<Login />);\n});\n\nparamount.get('/tve-login/:code/:token', async c => {\n  const code = c.req.param('code');\n  const token = c.req.param('token');\n\n  const isAuthenticated = await paramountHandler.authenticateRegCode(code, token);\n\n  if (!isAuthenticated) {\n    return c.html(<Login code={code} deviceToken={token} />);\n  }\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TParamountTokens>, any>(\n    {name: 'paramount'},\n    {$set: {enabled: true}},\n    {returnUpdatedDocs: true},\n  );\n  const {tokens, linear_channels} = affectedDocuments as IProvider<TParamountTokens>;\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<ParamountBody enabled={true} tokens={tokens} open={true} channels={linear_channels} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled Paramount+\"}}`,\n  });\n});\n\nparamount.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n\nparamount.put('/channels/toggle/:id', async c => {\n  const channelId = c.req.param('id');\n  const {linear_channels} = await db.providers.findOneAsync<IProvider>({name: 'paramount'});\n\n  const body = await c.req.parseBody();\n  const enabled = body['channel-enabled'] === 'on';\n\n  let updatedChannel = channelId;\n\n  const updatedChannels = linear_channels.map(channel => {\n    if (channel.id === channelId) {\n      updatedChannel = channel.name;\n      return {...channel, enabled: !channel.enabled};\n    }\n    return channel;\n  });\n\n  if (updatedChannel !== channelId) {\n    await db.providers.updateAsync<IProvider, any>({name: 'paramount'}, {$set: {linear_channels: updatedChannels}});\n\n    // Kickoff event scheduler\n    scheduleEvents();\n\n    return c.html(\n      <input\n        hx-target=\"this\"\n        hx-swap=\"outerHTML\"\n        type=\"checkbox\"\n        checked={enabled ? true : false}\n        data-enabled={enabled ? 'true' : 'false'}\n        hx-put={`/providers/paramount/channels/toggle/${channelId}`}\n        hx-trigger=\"change\"\n        name=\"channel-enabled\"\n      />,\n      200,\n      {\n        ...(enabled && {\n          'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled ${updatedChannel}\"}}`,\n        }),\n      },\n    );\n  }\n});\n"
  },
  {
    "path": "services/providers/paramount/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TParamountTokens} from '@/services/paramount-handler';\nimport {IProviderChannel} from '@/services/shared-interfaces';\nimport {usesLinear} from '@/services/misc-db-service';\n\ninterface IParamountBodyProps {\n  enabled: boolean;\n  tokens?: TParamountTokens;\n  open?: boolean;\n  channels: IProviderChannel[];\n}\n\nexport const ParamountBody: FC<IParamountBodyProps> = async ({enabled, tokens, open, channels}) => {\n  const useLinear = await usesLinear();\n\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <summary>\n        <span data-tooltip=\"These are only enabled with Dedicated Linear Channels enabled\" data-placement=\"right\">\n          Linear Channels\n        </span>\n      </summary>\n      <table class=\"striped\">\n        <thead>\n          <tr>\n            <th></th>\n            <th scope=\"col\">Name</th>\n          </tr>\n        </thead>\n        <tbody>\n          {channels.map(c => (\n            <tr key={c.id}>\n              <td>\n                <input\n                  hx-target=\"this\"\n                  hx-swap=\"outerHTML\"\n                  type=\"checkbox\"\n                  checked={c.enabled}\n                  data-enabled={c.enabled ? 'true' : 'false'}\n                  disabled={!useLinear}\n                  hx-put={`/providers/paramount/channels/toggle/${c.id}`}\n                  hx-trigger=\"change\"\n                  name=\"channel-enabled\"\n                />\n              </td>\n              <td>{c.name}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/paramount/reauth\" hx-trigger=\"submit\">\n            <button id=\"paramount-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/paramount/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {paramountHandler} from '@/services/paramount-handler';\n\ninterface ILogin {\n  code?: string;\n  deviceToken?: string;\n}\n\nexport const Login: FC<ILogin> = async ({code, deviceToken}) => {\n  let shownCode = code;\n  let token = deviceToken;\n\n  if (!shownCode || !token) {\n    [shownCode, token] = await paramountHandler.getAuthCode();\n  }\n\n  return (\n    <div\n      hx-target=\"this\"\n      hx-swap=\"outerHTML\"\n      hx-trigger=\"every 5s\"\n      hx-get={`/providers/paramount/tve-login/${shownCode}/${token}`}\n    >\n      <div class=\"grid-container\">\n        <div>\n          <h5>Paramount+ Login:</h5>\n          <span>\n            Open this link and follow instructions:\n            <br />\n            <a href=\"https://www.paramountplus.com/activate/androidtv\" target=\"_blank\">\n              https://www.paramountplus.com/activate/androidtv\n            </a>\n          </span>\n          <h6>Code: {shownCode}</h6>\n        </div>\n        <div aria-busy=\"true\" style=\"align-content: center\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/paramount/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {TParamountTokens} from '@/services/paramount-handler';\n\nimport {ParamountBody} from './CardBody';\n\nexport const Paramount: FC = async () => {\n  const paramount = await db.providers.findOneAsync<IProvider<TParamountTokens>>({name: 'paramount'});\n  const enabled = paramount?.enabled;\n  const tokens = paramount?.tokens;\n  const channels = paramount?.linear_channels || [];\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>Paramount+</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/paramount/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#paramount-body\"\n                name=\"paramount-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"paramount-body\" hx-swap=\"innerHTML\">\n          <ParamountBody enabled={enabled} tokens={tokens} channels={channels} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/pwhl/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {pwhlHandler} from '@/services/pwhl-handler';\n\nexport const pwhl = new Hono().basePath('/pwhl');\n\nconst scheduleEvents = async () => {\n  await pwhlHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('pwhl');\n};\n\npwhl.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['pwhl-enabled'] === 'on';\n\n  await db.providers.updateAsync<IProvider, any>({name: 'pwhl'}, {$set: {enabled}});\n\n  if (enabled) {\n    scheduleEvents();\n  } else {\n    removeEvents();\n  }\n\n  return c.html(<></>, 200, {\n    ...(enabled && {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled PWHL\"}}`,\n    }),\n  });\n});\n"
  },
  {
    "path": "services/providers/pwhl/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\n\nexport const PWHL: FC = async () => {\n  const pwhl = await db.providers.findOneAsync<IProvider>({name: 'pwhl'});\n  const enabled = pwhl?.enabled;\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>PWHL</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/pwhl/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#pwhl-body\"\n                name=\"pwhl-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"pwhl-body\" hx-swap=\"outerHTML\" />\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/victory/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {VictoryBody} from './views/CardBody';\n\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {victoryHandler, TVictoryTokens} from '@/services/victory-handler';\n\nexport const victory = new Hono().basePath('/victory');\n\nconst scheduleEvents = async () => {\n  await victoryHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('victory');\n};\n\nvictory.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['victory-enabled'] === 'on';\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider, any>({name: 'victory'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  return c.html(<Login />);\n});\n\nvictory.put('/toggle-stars', async c => {\n  const body = await c.req.parseBody();\n  const stars = body['victory-stars-enabled'] === 'on';\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TVictoryTokens>, any>(\n    {name: 'victory'},\n    {$set: {'meta.stars': stars}},\n    {returnUpdatedDocs: true},\n  );\n  const {enabled, tokens} = affectedDocuments as IProvider<TVictoryTokens>;\n  scheduleEvents();\n\n  return c.html(<VictoryBody enabled={enabled} tokens={tokens} />);\n});\n\nvictory.put('/toggle-rangers', async c => {\n  const body = await c.req.parseBody();\n  const rangers = body['victory-rangers-enabled'] === 'on';\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TVictoryTokens>, any>(\n    {name: 'victory'},\n    {$set: {'meta.rangers': rangers}},\n    {returnUpdatedDocs: true},\n  );\n  const {enabled, tokens} = affectedDocuments as IProvider<TVictoryTokens>;\n  scheduleEvents();\n\n  return c.html(<VictoryBody enabled={enabled} tokens={tokens} />);\n});\n\nvictory.put('/toggle-ducks', async c => {\n  const body = await c.req.parseBody();\n  const ducks = body['victory-ducks-enabled'] === 'on';\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TVictoryTokens>, any>(\n    {name: 'victory'},\n    {$set: {'meta.ducks': ducks}},\n    {returnUpdatedDocs: true},\n  );\n  const {enabled, tokens} = affectedDocuments as IProvider<TVictoryTokens>;\n  scheduleEvents();\n\n  return c.html(<VictoryBody enabled={enabled} tokens={tokens} />);\n});\n\nvictory.put('/toggle-blues', async c => {\n  const body = await c.req.parseBody();\n  const blues = body['victory-blues-enabled'] === 'on';\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TVictoryTokens>, any>(\n    {name: 'victory'},\n    {$set: {'meta.blues': blues}},\n    {returnUpdatedDocs: true},\n  );\n  const {enabled, tokens} = affectedDocuments as IProvider<TVictoryTokens>;\n  scheduleEvents();\n\n  return c.html(<VictoryBody enabled={enabled} tokens={tokens} />);\n});\n\nvictory.get('/auth/:code', async c => {\n  const code = c.req.param('code');\n\n  const isAuthenticated = await victoryHandler.authenticateRegCode(code);\n\n  if (!isAuthenticated) {\n    return c.html(<Login code={code} />);\n  }\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TVictoryTokens>, any>(\n    {name: 'victory'},\n    {$set: {enabled: true}},\n    {returnUpdatedDocs: true},\n  );\n  const {tokens} = affectedDocuments as IProvider<TVictoryTokens>;\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<VictoryBody enabled={true} tokens={tokens} open={true} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled Victory+\"}}`,\n  });\n});\n\nvictory.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n"
  },
  {
    "path": "services/providers/victory/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TVictoryTokens} from '@/services/victory-handler';\n\ninterface IVictoryBodyProps {\n  enabled: boolean;\n  tokens?: TVictoryTokens;\n  open?: boolean;\n}\n\nexport const VictoryBody: FC<IVictoryBodyProps> = ({enabled, tokens, open}) => {\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/victory/reauth\" hx-trigger=\"submit\">\n            <button id=\"victory-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/victory/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {victoryHandler} from '@/services/victory-handler';\n\ninterface ILogin {\n  code?: string;\n}\n\nexport const Login: FC<ILogin> = async ({code}) => {\n  let shownCode = code;\n\n  if (!shownCode) {\n    shownCode = await victoryHandler.getAuthCode();\n  }\n\n  return (\n    <div hx-target=\"this\" hx-swap=\"outerHTML\" hx-trigger=\"every 5s\" hx-get={`/providers/victory/auth/${shownCode}`}>\n      <div class=\"grid-container\">\n        <div>\n          <h5>Victory+ Login:</h5>\n          <span>\n            Open this link and follow instructions:\n            <br />\n            <a href=\"https://victoryplus.com/pair\" target=\"_blank\">\n              https://victoryplus.com/pair\n            </a>\n          </span>\n          <h6>Code: {shownCode}</h6>\n        </div>\n        <div aria-busy=\"true\" style=\"align-content: center\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/victory/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {TVictoryTokens} from '@/services/victory-handler';\n\nimport {VictoryBody} from './CardBody';\n\nexport const Victory: FC = async () => {\n  const victory = await db.providers.findOneAsync<IProvider<TVictoryTokens>>({name: 'victory'});\n  const enabled = victory?.enabled;\n  const tokens = victory?.tokens;\n  const {stars, ducks, rangers, blues} = victory.meta;\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>Victory+</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/victory/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#victory-body\"\n                name=\"victory-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div class=\"grid-container\">\n          <div>\n            <fieldset>\n              <label>\n                Dallas Stars?&nbsp;&nbsp;\n                <input\n                  hx-put={`/providers/victory/toggle-stars`}\n                  hx-trigger=\"change\"\n                  name=\"victory-stars-enabled\"\n                  hx-target=\"#victory-body\"\n                  type=\"checkbox\"\n                  role=\"switch\"\n                  checked={stars ? true : false}\n                  data-enabled={stars ? 'true' : 'false'}\n                />\n              </label>\n            </fieldset>\n            <fieldset>\n              <label>\n                Texas Rangers?&nbsp;&nbsp;\n                <input\n                  hx-put={`/providers/victory/toggle-rangers`}\n                  hx-trigger=\"change\"\n                  name=\"victory-rangers-enabled\"\n                  hx-target=\"#victory-body\"\n                  type=\"checkbox\"\n                  role=\"switch\"\n                  checked={rangers ? true : false}\n                  data-enabled={rangers ? 'true' : 'false'}\n                />\n              </label>\n            </fieldset>\n            <fieldset>\n              <label>\n                Anaheim Ducks?&nbsp;&nbsp;\n                <input\n                  hx-put={`/providers/victory/toggle-ducks`}\n                  hx-trigger=\"change\"\n                  name=\"victory-ducks-enabled\"\n                  hx-target=\"#victory-body\"\n                  type=\"checkbox\"\n                  role=\"switch\"\n                  checked={ducks ? true : false}\n                  data-enabled={ducks ? 'true' : 'false'}\n                />\n              </label>\n            </fieldset>\n            <fieldset>\n              <label>\n                St. Louis Blues?&nbsp;&nbsp;\n                <input\n                  hx-put={`/providers/victory/toggle-blues`}\n                  hx-trigger=\"change\"\n                  name=\"victory-blues-enabled\"\n                  hx-target=\"#victory-body\"\n                  type=\"checkbox\"\n                  role=\"switch\"\n                  checked={blues ? true : false}\n                  data-enabled={blues ? 'true' : 'false'}\n                />\n              </label>\n            </fieldset>\n          </div>\n        </div>\n        <div id=\"victory-body\" hx-swap=\"innerHTML\">\n          <VictoryBody enabled={enabled} tokens={tokens} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/wnba/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {Login} from './views/Login';\nimport {WNBABody} from './views/CardBody';\n\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {wnbaHandler, TWNBATokens} from '@/services/wnba-handler';\n\nexport const wnba = new Hono().basePath('/wnba');\n\nconst scheduleEvents = async () => {\n  await wnbaHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('wnba+');\n};\n\nwnba.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['wnba-enabled'] === 'on';\n\n  if (!enabled) {\n    await db.providers.updateAsync<IProvider<TWNBATokens>, any>({name: 'wnba'}, {$set: {enabled, tokens: {}}});\n    removeEvents();\n\n    return c.html(<></>);\n  }\n\n  return c.html(<Login />);\n});\n\nwnba.post('/login', async c => {\n  const body = await c.req.parseBody();\n  const username = body.username as string;\n  const password = body.password as string;\n\n  const isAuthenticated = await wnbaHandler.login(username, password);\n\n  if (!isAuthenticated) {\n    return c.html(<Login invalid={true} />);\n  }\n\n  const {affectedDocuments} = await db.providers.updateAsync<IProvider<TWNBATokens>, any>(\n    {name: 'wnba'},\n    {\n      $set: {\n        enabled: true,\n        meta: {\n          password,\n          username,\n        },\n      },\n    },\n    {returnUpdatedDocs: true},\n  );\n\n  const {tokens} = affectedDocuments as IProvider<TWNBATokens>;\n\n  // Kickoff event scheduler\n  scheduleEvents();\n\n  return c.html(<WNBABody enabled={true} tokens={tokens} open={true} />, 200, {\n    'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled WNBA League Pass\"}}`,\n  });\n});\n\nwnba.put('/reauth', async c => {\n  return c.html(<Login />);\n});\n"
  },
  {
    "path": "services/providers/wnba/views/CardBody.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {TWNBATokens} from '@/services/wnba-handler';\n\ninterface IWNBABodyProps {\n  enabled: boolean;\n  tokens?: TWNBATokens;\n  open?: boolean;\n}\n\nexport const WNBABody: FC<IWNBABodyProps> = ({enabled, tokens, open}) => {\n  const parsedTokens = JSON.stringify(tokens, undefined, 2);\n\n  if (!enabled) {\n    return <></>;\n  }\n\n  return (\n    <div hx-swap=\"outerHTML\" hx-target=\"this\">\n      <details open={open}>\n        <summary>Tokens</summary>\n        <div>\n          <pre>{parsedTokens}</pre>\n          <form hx-put=\"/providers/wnba/reauth\" hx-trigger=\"submit\">\n            <button id=\"wnba-reauth\">Re-Authenticate</button>\n          </form>\n        </div>\n      </details>\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/wnba/views/Login.tsx",
    "content": "import {FC} from 'hono/jsx';\n\ninterface ILoginProps {\n  invalid?: boolean;\n}\n\nexport const Login: FC<ILoginProps> = async ({invalid}) => {\n  return (\n    <div hx-target=\"this\" hx-swap=\"outerHTML\">\n      <form hx-post=\"/providers/wnba/login\" hx-trigger=\"submit\" id=\"wnba-login-form\">\n        <fieldset class=\"grid\">\n          <input\n            {...(invalid && {\n              'aria-describedby': 'invalid-helper',\n              'aria-invalid': 'true',\n            })}\n            name=\"username\"\n            id=\"wnba-username\"\n            placeholder=\"Username\"\n            aria-label=\"Username\"\n          />\n          <input\n            {...(invalid && {\n              'aria-describedby': 'invalid-helper',\n              'aria-invalid': 'true',\n            })}\n            id=\"wnba-password\"\n            type=\"password\"\n            name=\"password\"\n            placeholder=\"Password\"\n            aria-label=\"Password\"\n          />\n          <button type=\"submit\" id=\"wnba-login\">\n            Log in\n          </button>\n        </fieldset>\n        {invalid && <small id=\"invalid-helper\">Login failed. Please try again.</small>}\n      </form>\n      <script\n        dangerouslySetInnerHTML={{\n          __html: `\n            var form = document.getElementById('wnba-login-form');\n\n            if (form) {\n              form.addEventListener('htmx:beforeRequest', function() {\n                this.querySelector('#wnba-login').setAttribute('aria-busy', 'true');\n                this.querySelector('#wnba-login').setAttribute('aria-label', 'Loading…');\n                this.querySelector('#wnba-username').disabled = true;\n                this.querySelector('#wnba-password').disabled = true;\n              });\n            }\n          `,\n        }}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/wnba/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\nimport {TWNBATokens} from '@/services/wnba-handler';\n\nimport {WNBABody} from './CardBody';\n\nexport const WNBA: FC = async () => {\n  const wnba = await db.providers.findOneAsync<IProvider<TWNBATokens>>({name: 'wnba'});\n  const enabled = wnba?.enabled;\n  const tokens = wnba?.tokens;\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>WNBA League Pass</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/wnba/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#wnba-body\"\n                name=\"wnba-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"wnba-body\" hx-swap=\"innerHTML\">\n          <WNBABody enabled={enabled} tokens={tokens} />\n        </div>\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/wsn/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {wsnHandler} from '@/services/wsn-handler';\n\nexport const wsn = new Hono().basePath('/wsn');\n\nconst scheduleEvents = async () => {\n  await wsnHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('wsn');\n};\n\nwsn.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['wsn-enabled'] === 'on';\n\n  await db.providers.updateAsync<IProvider, any>({name: 'wsn'}, {$set: {enabled}});\n\n  if (enabled) {\n    scheduleEvents();\n  } else {\n    removeEvents();\n  }\n\n  return c.html(<></>, 200, {\n    ...(enabled && {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled Women's Sports Network\"}}`,\n    }),\n  });\n});\n"
  },
  {
    "path": "services/providers/wsn/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\n\nexport const WSN: FC = async () => {\n  const wsn = await db.providers.findOneAsync<IProvider>({name: 'wsn'});\n  const enabled = wsn?.enabled;\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>\n            <span>\n              Women's Sports Network{' '}\n              <span class=\"warning-red\" data-tooltip=\"Linear only\" data-placement=\"right\">\n                **\n              </span>\n            </span>\n          </h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/wsn/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#wsn-body\"\n                name=\"wsn-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"wsn-body\" hx-swap=\"outerHTML\" />\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/providers/zeam/index.tsx",
    "content": "import {Hono} from 'hono';\n\nimport {db} from '@/services/database';\n\nimport {IProvider} from '@/services/shared-interfaces';\nimport {removeEntriesProvider, scheduleEntries} from '@/services/build-schedule';\nimport {zeamHandler} from '@/services/zeam-handler';\n\nexport const zeam = new Hono().basePath('/zeam');\n\nconst scheduleEvents = async () => {\n  await zeamHandler.getSchedule();\n  await scheduleEntries();\n};\n\nconst removeEvents = async () => {\n  await removeEntriesProvider('zeam');\n};\n\nzeam.put('/toggle', async c => {\n  const body = await c.req.parseBody();\n  const enabled = body['zeam-enabled'] === 'on';\n\n  await db.providers.updateAsync<IProvider, any>({name: 'zeam'}, {$set: {enabled}});\n\n  if (enabled) {\n    scheduleEvents();\n  } else {\n    removeEvents();\n  }\n\n  return c.html(<></>, 200, {\n    ...(enabled && {\n      'HX-Trigger': `{\"HXToast\":{\"type\":\"success\",\"body\":\"Successfully enabled Zeam\"}}`,\n    }),\n  });\n});\n"
  },
  {
    "path": "services/providers/zeam/views/index.tsx",
    "content": "import {FC} from 'hono/jsx';\n\nimport {db} from '@/services/database';\nimport {IProvider} from '@/services/shared-interfaces';\n\nexport const Zeam: FC = async () => {\n  const zeam = await db.providers.findOneAsync<IProvider>({name: 'zeam'});\n  const enabled = zeam?.enabled;\n\n  return (\n    <div>\n      <section class=\"overflow-auto provider-section\">\n        <div class=\"grid-container\">\n          <h4>Zeam Live Events</h4>\n          <fieldset>\n            <label>\n              Enabled&nbsp;&nbsp;\n              <input\n                hx-put={`/providers/zeam/toggle`}\n                hx-trigger=\"change\"\n                hx-target=\"#zeam-body\"\n                name=\"zeam-enabled\"\n                type=\"checkbox\"\n                role=\"switch\"\n                checked={enabled ? true : false}\n                data-enabled={enabled ? 'true' : 'false'}\n              />\n            </label>\n          </fieldset>\n        </div>\n        <div id=\"zeam-body\" hx-swap=\"outerHTML\" />\n      </section>\n      <hr />\n    </div>\n  );\n};\n"
  },
  {
    "path": "services/pwhl-handler.ts",
    "content": "import axios from 'axios';\nimport moment from 'moment-timezone';\n\nimport {userAgent} from './user-agent';\nimport {IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {debug} from './debug';\nimport {combineImages, normalTimeRange} from './shared-helpers';\nimport {getEventStream, getLiveEventsFromChannel, matchEvent} from './yt-dlp-helper';\n\nconst YT_CHANNEL = 'UCNKUkQV2R0JKakyE1vuC1lQ';\n\ninterface IPWHLEvent {\n  awayLogo: string;\n  homeLogo: string;\n  title: string;\n  start: Date;\n  id: string;\n}\n\nconst getLogo = (competitorId: string): string => {\n  return ['https://', 'assets', '.leaguestat', '.com', '/pwhl', '/logos', '/50x50', '/', competitorId, '.png'].join('');\n};\n\nconst parseAirings = async (events: IPWHLEvent[]) => {\n  const [now, endSchedule] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.id) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: event.id});\n\n    if (!entryExists) {\n      const start = moment(event.start);\n      const end = moment(event.start).add(3.5, 'hours');\n      const originalEnd = moment(event.start).add(2.5, 'hours');\n\n      if (end.isBefore(now) || start.isAfter(endSchedule)) {\n        continue;\n      }\n\n      console.log('Adding event: ', event.title);\n\n      const image = await combineImages(event.homeLogo, event.awayLogo);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: [...new Set(['PWHL', 'Ice Hockey', \"Women's Sports\"])],\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'pwhl',\n        id: event.id,\n        image,\n        name: event.title,\n        network: 'Youtube',\n        originalEnd: originalEnd.valueOf(),\n        sport: 'PWHL',\n        start: start.valueOf(),\n      });\n    }\n  }\n};\n\nclass PWHLHandler {\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'pwhl'})) > 0 ? true : false;\n\n    // First time setup\n    if (!setup) {\n      await db.providers.insertAsync<IProvider>({\n        enabled: false,\n        name: 'pwhl',\n      });\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'pwhl'});\n\n    if (!enabled) {\n      return;\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'pwhl'});\n\n    if (!enabled) {\n      return;\n    }\n\n    const allItems: IPWHLEvent[] = [];\n\n    console.log('Looking for PWHL events...');\n\n    try {\n      const schedule_url = [\n        'https://',\n        'next-gen',\n        '.sports',\n        '.bellmedia',\n        '.ca',\n        '/v2',\n        '/schedule',\n        '/sports',\n        '/hockey',\n        '/leagues',\n        '/pwhl',\n        '?brand=tsn',\n        '&lang=en',\n        '&grouping=',\n        encodeURIComponent(moment().format('YYYY-MM-DD')),\n      ].join('');\n\n      const {data: schedule_data} = await axios.get(schedule_url, {\n        headers: {\n          origin: 'https://www.tsn.ca',\n          referer: 'https://www.tsn.ca/',\n          'user-agent': userAgent,\n        },\n      });\n\n      for (const game_date in schedule_data) {\n        for (const game_data of schedule_data[game_date]) {\n          const awayTeam = [game_data['event']['bottom']['location'], game_data['event']['bottom']['name']].join(' ');\n          const homeTeam = [game_data['event']['top']['location'], game_data['event']['top']['name']].join(' ');\n          const start = moment.tz(game_data['event']['dateGMT'], 'GMT');\n\n          allItems.push({\n            awayLogo: getLogo(game_data['event']['bottom']['competitorId']),\n            homeLogo: getLogo(game_data['event']['top']['competitorId']),\n            id: [\n              'pwhl',\n              start.valueOf(),\n              game_data['event']['bottom']['shortName'],\n              game_data['event']['top']['shortName'],\n            ].join('-'),\n            start: start.toDate(),\n            title: `${homeTeam} vs ${awayTeam}`,\n          });\n        }\n      }\n\n      debug.saveRequestData(allItems, 'pwhl', 'epg');\n\n      await parseAirings(allItems);\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse PWHL events');\n    }\n  };\n\n  public getEventData = async (id: string): Promise<TChannelPlaybackInfo> => {\n    try {\n      const event = await db.entries.findOneAsync<IEntry>({id});\n\n      const channelStreams = await getLiveEventsFromChannel(YT_CHANNEL);\n      const matchedEvent = matchEvent(channelStreams, event.name);\n\n      if (!matchedEvent) {\n        throw new Error('Could not get event data');\n      }\n\n      const streamUrl = await getEventStream(matchedEvent.id);\n\n      if (streamUrl) {\n        return [streamUrl, {}];\n      }\n\n      throw new Error('Could not get event data');\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get event data');\n    }\n  };\n}\n\nexport const pwhlHandler = new PWHLHandler();\n"
  },
  {
    "path": "services/shared-helpers.ts",
    "content": "import crypto from 'crypto';\nimport axios from 'axios';\nimport moment, {Moment} from 'moment';\nimport sharp from 'sharp';\n\nimport {appStatus} from './app-status';\nimport {db} from './database';\nimport {IEntry, IStringObj, IReleaseData} from './shared-interfaces';\nimport {getLatestVersion, setLatestVersion, getLastModified, setLastModified} from './misc-db-service';\n\nconst chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMnumCharsOPQRSTUVWXYZ0123456789';\n\nexport const flipObject = (obj: IStringObj): IStringObj => {\n  const ret: IStringObj = {};\n\n  Object.keys(obj).forEach(key => {\n    ret[obj[key]] = key;\n  });\n\n  return ret;\n};\n\nexport const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));\n\nexport const generateRandom = (numChars = 8, namespace?: string): string => {\n  let nameSpaceFull = '';\n\n  const randomId = Array(numChars)\n    .join()\n    .split(',')\n    .map(() => chars.charAt(Math.floor(Math.random() * chars.length)))\n    .join('');\n\n  if (namespace && namespace.length) {\n    nameSpaceFull = `${namespace}-`;\n  }\n\n  return `${nameSpaceFull}${randomId}`;\n};\n\nexport const getRandomHex = (): string => crypto.randomUUID().replace(/-/g, '');\nexport const getRandomUUID = (): string => crypto.randomUUID();\n\nexport const resetSchedule = async (): Promise<void> => {\n  await db.schedule.removeAsync({}, {multi: true});\n  await db.entries.updateAsync<IEntry, any>({linear: {$exists: false}}, {$unset: {channel: true}}, {multi: true});\n};\n\nexport const cleanEntries = async (): Promise<void> => {\n  const now = new Date().valueOf();\n  await db.entries.removeAsync({end: {$lt: now}}, {multi: true});\n};\n\nexport const removeAllEntries = async (): Promise<void> => {\n  await db.schedule.removeAsync({}, {multi: true});\n  await db.entries.removeAsync({}, {multi: true});\n};\n\nexport const removeChannelStatus = (channelId: string | number): void => {\n  try {\n    if (appStatus.channels?.[channelId]?.heartbeatTimer) {\n      clearTimeout(appStatus.channels[channelId].heartbeatTimer);\n    }\n\n    delete appStatus.channels[channelId];\n  } catch (e) {\n    console.error(e);\n    console.log(`Failed to delete info for channel #${channelId}`);\n  }\n};\n\nexport const clearChannels = (): void => {\n  Object.keys(appStatus.channels).forEach(key => {\n    removeChannelStatus(key);\n  });\n\n  appStatus.channels = {};\n};\n\nexport const normalTimeRange = (): [Moment, Moment] => [\n  moment().subtract(2, 'hours'),\n  moment().add(2, 'days').endOf('day'),\n];\n\nexport const downloadImage = async (url: string): Promise<Buffer> => {\n  const response = await axios({\n    responseType: 'arraybuffer',\n    url,\n  });\n\n  return Buffer.from(response.data, 'binary');\n};\n\nexport const combineImages = async (url1: string, url2: string): Promise<string> => {\n  const [image1, image2] = await Promise.all([downloadImage(url1), downloadImage(url2)]);\n\n  const img1 = sharp(image1);\n  const img2 = sharp(image2);\n\n  const [metadata1, metadata2] = await Promise.all([img1.metadata(), img2.metadata()]);\n  const [buffer1, buffer2] = await Promise.all([img1.toBuffer(), img2.toBuffer()]);\n\n  const combinedWidth = metadata1.width + metadata2.width + 48;\n  const maxHeight = Math.max(metadata1.height, metadata2.height) + 24;\n\n  const combinedImage = sharp({\n    create: {\n      background: {\n        alpha: 0,\n        b: 0,\n        g: 0,\n        r: 0,\n      },\n      channels: 4,\n      height: maxHeight,\n      width: combinedWidth,\n    },\n  })\n    .composite([\n      {\n        input: buffer1,\n        left: 12,\n        top: 12,\n      },\n      {\n        input: buffer2,\n        left: 36 + metadata1.width,\n        top: 12,\n      },\n    ])\n    .png();\n\n  const combinedBuffer = await combinedImage.toBuffer();\n\n  return `data:image/png;base64,${combinedBuffer.toString('base64')}`;\n};\n\nexport const isBase64 = (str?: string): boolean => {\n  if (!str || str.length === 0) {\n    return false;\n  }\n\n  try {\n    return Buffer.from(str, 'base64').toString('base64') === str;\n  } catch (e) {\n    return false;\n  }\n};\n\nexport const latestRelease = async (): Promise<string> => {\n  let latest_version = await getLatestVersion();\n  try {\n    const url = [\n      'https://',\n      'api.',\n      'github.',\n      'com',\n      '/repos',\n      '/tonywagner',\n      '/EPlusTV',\n      '/releases',\n      '/latest',\n    ].join('');\n    \n    let headers = {}\n    const last_modified = await getLastModified();\n    if ( last_modified != '' ) {\n      headers['If-Modified-Since'] = last_modified;\n    }\n    \n    const response = await axios.get(url, {headers});\n    \n    if ( response ) {\n      if ( response.data && response.data.tag_name ) {\n        latest_version = response.data.tag_name;\n        await setLatestVersion(latest_version);\n      }\n      if ( response.headers && response.headers['last-modified'] ) {\n        await setLastModified(response.headers['last-modified']);\n      }\n    }\n  } catch (e) {\n    //console.error(e);\n    //console.log('Failed to retrieve latest version number');\n  }\n  return latest_version;\n};\n"
  },
  {
    "path": "services/shared-interfaces.ts",
    "content": "interface IChannelStatus {\n  current?: string;\n  player?: IManifestPlayer;\n  heartbeatTimer?: NodeJS.Timer;\n  heartbeat?: Date;\n}\n\ninterface IManifestPlayer {\n  playlist?: string;\n\n  initialize(url: string): Promise<void>;\n  getSegmentOrKey(segmentId: string): Promise<ArrayBuffer>;\n  cacheChunklist(chunkListId: string): Promise<string>;\n}\n\nexport interface IHeaders {\n  [key: string]: string | number | string[];\n}\n\nexport interface IStringObj {\n  [key: string]: string;\n}\n\nexport interface IAppStatus {\n  channels: {\n    [string: number | string]: IChannelStatus;\n  };\n}\n\nexport interface IJWToken {\n  exp: number;\n  iat: number;\n  [key: string]: string | number;\n}\n\nexport interface IEntry {\n  categories: string[];\n  description?: string;\n  duration: number;\n  end: number;\n  from: string;\n  id: string;\n  image: string;\n  feed?: string;\n  name: string;\n  network: string;\n  start: number;\n  url?: string;\n  channel?: string | number;\n  sport?: string;\n  linear?: boolean;\n  replay?: boolean;\n  originalEnd?: number;\n}\n\nexport interface IChannel {\n  channel: string | number;\n  endsAt: number;\n}\n\nexport interface IProviderChannel {\n  enabled: boolean;\n  name: string;\n  tmsId?: string;\n  id: string;\n  callSign?: string;\n}\n\nexport interface IProvider<T = any, M = any> {\n  enabled: boolean;\n  tokens?: T;\n  linear_channels?: IProviderChannel[];\n  name: string;\n  meta?: M;\n}\n\nexport interface IMiscDbEntry<T = string | number | boolean, M = any> {\n  name: string;\n  value: T;\n  meta?: M;\n}\n\nexport type THeaderInfo = IHeaders | ((eventId: string | number, currentHeaders?: IHeaders) => Promise<IHeaders>);\n\nexport type TChannelPlaybackInfo = [string, THeaderInfo];\n\nexport type ClassTypeWithoutMethods<T> = Omit<\n  T,\n  {\n    [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never;\n  }[keyof T]\n>;\n\nexport interface IReleaseData {\n  tag_name: string;\n}\n"
  },
  {
    "path": "services/template-handler.ts",
    "content": "import axios from 'axios';\nimport moment from 'moment';\n\nimport {userAgent} from './user-agent';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {getRandomUUID, normalTimeRange} from './shared-helpers';\nimport {debug} from './debug';\n\ninterface ITemplateEvent {\n  id: number;\n  start: string;\n  end: string;\n  name: string;\n  sport: string;\n  image: string;\n  categories: string[];\n}\n\nconst parseAirings = async (events: ITemplateEvent[]) => {\n  const [now, endDate] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.id) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `${event.id}`});\n\n    if (!entryExists) {\n      const start = moment(event.start);\n      const end = moment(event.end).add(1, 'hour');\n      const originalEnd = moment(event.end);\n\n      if (end.isBefore(now) || start.isAfter(endDate)) {\n        continue;\n      }\n\n      console.log('Adding event: ', event.name);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: event.categories,\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'template',\n        id: `${event.id}`,\n        image: event.image,\n        name: event.name,\n        network: 'Template Sports',\n        originalEnd: originalEnd.valueOf(),\n        sport: event.sport,\n        start: start.valueOf(),\n      });\n    }\n  }\n};\n\nclass TemplateHandler {\n  public device_id?: string;\n  public user_id?: string;\n  public mvpd_id?: string;\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'template'})) > 0 ? true : false;\n\n    // First time setup\n    if (!setup) {\n      const data: TTemplateTokens = {};\n\n      await db.providers.insertAsync<IProvider<TTemplateTokens>>({\n        enabled: false,\n        name: 'template',\n        tokens: data,\n      });\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'template'});\n\n    if (!enabled) {\n      return;\n    }\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n  };\n\n  public refreshTokens = async () => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'template'});\n\n    if (!enabled) {\n      return;\n    }\n\n    // Refresh logic\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'template'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log('Looking for Template events...');\n\n    const entries: ITemplateEvent[] = [];\n\n    const [now, endSchedule] = normalTimeRange();\n\n    try {\n      const url = [].join('');\n\n      const {data} = await axios.get<ITemplateEvent[]>(url, {\n        headers: {\n          'Content-Type': 'application/json',\n          'User-Agent': userAgent,\n        },\n      });\n\n      debug.saveRequestData(data, 'template', 'epg');\n\n      data.forEach(e => {\n        if (moment(e.start).isBefore(endSchedule) && moment(e.end).isAfter(now)) {\n          entries.push(e);\n        }\n      });\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse Template Sports events');\n    }\n\n    await parseAirings(entries);\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    const event = await db.entries.findOneAsync<IEntry>({id: eventId});\n\n    try {\n      let streamUrl: string;\n\n      if (event.url) {\n        streamUrl = event.url;\n      }\n\n      return [streamUrl, {}];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start playback');\n    }\n  };\n\n  public getAuthCode = async (): Promise<string> => {\n    this.device_id = getRandomUUID();\n\n    try {\n      const url = [].join('');\n\n      const {data} = await axios.get(url, {\n        headers: {\n          'Content-Type': 'application/json',\n          'User-Agent': userAgent,\n        },\n      });\n\n      return data.code;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not login to Template Sports');\n    }\n  };\n\n  public authenticateRegCode = async (code: string): Promise<boolean> => {\n    try {\n      const url = [code].join('');\n\n      const {data} = await axios.get(url, {\n        headers: {\n          'Content-Type': 'application/json',\n          'User-Agent': userAgent,\n        },\n      });\n\n      if (!data || data?.subscriptions.adobe !== 'y') {\n        return false;\n      }\n\n      return true;\n    } catch (e) {\n      return false;\n    }\n  };\n\n  private save = async (): Promise<void> => {\n    await db.providers.updateAsync({name: 'template'}, {$set: {tokens: this}});\n  };\n\n  private load = async (): Promise<void> => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TTemplateTokens>>({name: 'template'});\n    const {device_id, user_id, mvpd_id} = tokens || {};\n\n    this.device_id = device_id;\n    this.user_id = user_id;\n    this.mvpd_id = mvpd_id;\n  };\n}\n\nexport type TTemplateTokens = ClassTypeWithoutMethods<TemplateHandler>;\n\nexport const templateHandler = new TemplateHandler();\n"
  },
  {
    "path": "services/tubi-helper.ts",
    "content": "import axios from 'axios';\n\nimport {userAgent} from './user-agent';\n\nexport interface ITubiRes {\n  video_resources: {\n    type: string;\n    manifest: {\n      url: string;\n    };\n  }[];\n  programs: ITubiEvent[];\n}\n\nexport interface ITubiEvent {\n  images: {\n    thumbnail: string[];\n    poster: string[];\n  };\n  title: string;\n  start_time: string;\n  end_time: string;\n  description: string;\n  id: string;\n}\n\nexport const tubiHelper = async (channelId: string | number): Promise<ITubiRes> => {\n  try {\n    const url = [\n      'https://',\n      'epg-cdn.production-public.tubi.io',\n      '/content/epg/programming',\n      '?content_id=',\n      channelId,\n      '&platform=web',\n    ].join('');\n\n    const {data} = await axios.get<{rows: ITubiRes[]}>(url, {\n      headers: {\n        'user-agent': userAgent,\n      },\n    });\n\n    return data.rows[0];\n  } catch (e) {\n    console.error(e);\n    console.log('Could not get Tubi channel data');\n  }\n};\n"
  },
  {
    "path": "services/user-agent.ts",
    "content": "const userAgents = [\n  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',\n  'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.7; rv:133.0) Gecko/20100101 Firefox/133.0',\n  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3',\n  'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0',\n  'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3',\n  'Mozilla/5.0 (X11; Linux i686; rv:133.0) Gecko/20100101 Firefox/133.0',\n];\n\nexport const cbsSportsUserAgent = 'CBSSports/7.4.0-1739899507 (androidtv)';\n\nexport const androidFoxUserAgent =\n  'foxsports-androidtv/3.42.1 (Linux;Android 9.0.0;SHIELD Android TV) ExoPlayerLib/2.12.1';\n\nexport const androidFoxOneUserAgent =\n  'foxone-androidtv/1.3.0 (Linux; Android 12; en-us; onn. 4K Streaming Box Build/SGZ1.221127.063.A1.9885170)';\n\nexport const okHttpUserAgent = 'okhttp/4.11.0';\n\nexport const floSportsUserAgent =\n  'Dalvik/2.1.0 (Linux; U; Android 9; sdk_google_atv_x86 Build/PSR1.180720.121) FloSports/2.11.0-2220530';\n\nexport const oktaUserAgent = 'okta-auth-js/7.0.2 okta-signin-widget-7.14.0';\n\nexport const b1gUserAgent = 'Ktor client';\n\nexport const nhlTvUserAgent =\n  'Mozilla/5.0 (Linux; Android 11; AOSP TV on x86 Build/RTU1.221129.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/90.0.4430.91 Mobile Safari/537.36';\n\nexport const androidMlbUserAgent =\n  'com.bamnetworks.mobile.android.gameday.atbat/7.36.0.23 (Android 9;en_US;sdk_google_atv_x86;Build/PSR1.180720.121)';\n\n// Will generate one random User Agent for the session\nexport const userAgent = (() => userAgents[Math.floor(Math.random() * userAgents.length)])();\n"
  },
  {
    "path": "services/victory-handler.ts",
    "content": "import axios from 'axios';\nimport moment from 'moment';\n\nimport {userAgent} from './user-agent';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {getRandomUUID, normalTimeRange} from './shared-helpers';\nimport {debug} from './debug';\n\ninterface IVictoryEvent {\n  id: number;\n  broadcast_start: number;\n  broadcast_end: number;\n  title: string;\n  imageUrl: string;\n  videoUrl: string;\n  seriesId: string;\n  seriesName: string;\n  seriesSlug: string;\n  episodeType: 'live' | string;\n}\n\nconst BASE_URL = 'https://api.sports.aparentmedia.com/api/2.0';\n\nconst DEVICE_INFO = {\n  device: 'AOSP TV on x86',\n  kdApiVersion: '1',\n  kdAppVersion: '10.3',\n  language: 'en',\n  osVersion: '31',\n  platform: 'AndroidTV',\n  requestCountry: true,\n  screenDensityDPI: 640,\n  screenResH: 2160,\n  screenResW: 3840,\n};\n\nconst fillEvent = (event: IVictoryEvent): [string, string[]] => {\n  let sport = '';\n  const categories = ['Victory+'];\n  const seriesId = event.seriesId;\n  const seriesName = event.seriesName;\n  const seriesSlug = event.seriesSlug;\n\n  if (seriesId === '66' || seriesId === '67' || seriesId === '68' || seriesId === '150') {\n    // Stars or Ducks or Blues game\n    sport = 'Hockey';\n    categories.push('Hockey', 'NHL');\n  } else if (seriesId === '128' || seriesId === '139') {\n    // Rangers games?\n    sport = 'Baseball';\n    categories.push('Baseball', 'MLB');\n  } else if (seriesName.includes('WHL') || seriesSlug.includes('WHL')) {\n    // WHL\n    sport = 'Hockey';\n    categories.push('Hockey', 'WHL');\n  } else if (seriesName.includes('THSCA') || seriesSlug.includes('THSCA') || seriesName.includes('UIL') || seriesSlug.includes('UIL')) {\n    // Texas high school football\n    sport = 'Football';\n    categories.push('Football', 'High School Football', 'Texas High School Football');\n  } else if (seriesName.includes('Major Arena Soccer League') || seriesSlug.includes('MajorArenaSoccerLeague')) {\n    // Major Arena Soccer League\n    sport = 'Soccer';\n    categories.push('Soccer', 'MASL');\n  } else if (seriesName.includes('IFL') || seriesSlug.includes('IFL')) {\n    // Indoor Football League\n    sport = 'Football';\n    categories.push('Football', 'Indoor Football', 'IFL');\n  } else if (seriesName.includes('WNFC') || seriesSlug.includes('WNFC')) {\n    // Women's National Football Conference\n    sport = 'Football';\n    categories.push('Football', 'Women\\'s Football', 'WNFC');\n  } else if (seriesName.includes('League One Volleyball') || seriesSlug.includes('League_One_Volleyball')) {\n    // League One Volleyball\n    sport = 'Volleyball';\n    categories.push('Volleyball', 'Women\\'s Volleyball', 'LOVB');\n  }\n\n  return [sport, categories];\n};\n\nconst parseAirings = async (events: IVictoryEvent[]) => {\n  const [now, endDate] = normalTimeRange();\n  const {meta} = await db.providers.findOneAsync<IProvider<TVictoryTokens>>({name: 'victory'});\n\n  for (const event of events) {\n    const [sport, categories] = fillEvent(event);\n\n    if (\n      !event ||\n      !event.id ||\n      (event.seriesId === '66' && !meta.stars) ||\n      (event.seriesId === '150' && !meta.stars) ||\n      (event.seriesId === '67' && !meta.ducks) ||\n      (event.seriesId === '68' && !meta.blues) ||\n      (event.seriesId === '128' && !meta.rangers)\n    ) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `victory-${event.id}`});\n\n    if (!entryExists) {\n      const start = moment(event.broadcast_start * 1000);\n      const end = moment(event.broadcast_end * 1000).add(1, 'hour');\n      const originalEnd = moment(event.broadcast_end * 1000);\n\n      if (end.isBefore(now) || start.isAfter(endDate)) {\n        continue;\n      }\n\n      console.log('Adding event: ', event.title);\n\n      await db.entries.insertAsync<IEntry>({\n        categories,\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'victory',\n        id: `victory-${event.id}`,\n        image: event.imageUrl,\n        name: event.title,\n        network: 'Victory+',\n        originalEnd: originalEnd.valueOf(),\n        sport,\n        start: start.valueOf(),\n        url: event.videoUrl,\n      });\n    }\n  }\n};\n\nclass VictoryHandler {\n  public device_id?: string;\n  public user_id?: string;\n  public session_key?: string;\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'victory'})) > 0 ? true : false;\n\n    // First time setup\n    if (!setup) {\n      const data: TVictoryTokens = {};\n\n      await db.providers.insertAsync<IProvider<TVictoryTokens>>({\n        enabled: false,\n        meta: {\n          blues: false,\n          ducks: false,\n          rangers: false,\n          stars: false,\n        },\n        name: 'victory',\n        tokens: data,\n      });\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'victory'});\n\n    if (!enabled) {\n      return;\n    }\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n  };\n\n  public refreshTokens = async () => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'victory'});\n\n    if (!enabled) {\n      return;\n    }\n\n    // Refresh logic\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'victory'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log('Looking for Victory+ events...');\n\n    const entries: IVictoryEvent[] = [];\n\n    const [now, endSchedule] = normalTimeRange();\n\n    try {\n      const categoryIds = ['57']; // add 285 for WHL away feeds, but I think they are just duplicates of home feeds\n\n      for (let i = 0; i < categoryIds.length; i++) {\n        const url = [BASE_URL, '/content/categories/', categoryIds[i]].join('');\n\n        const {data} = await axios.get<{contents: IVictoryEvent[]}>(url, {\n          headers: {\n            'Content-Type': 'application/json',\n            'User-Agent': userAgent,\n            'x-api-session': this.session_key,\n          },\n        });\n\n        debug.saveRequestData(data.contents, 'victory', 'epg');\n\n        data.contents.forEach(e => {\n          if (moment(e.broadcast_start * 1000).isBefore(endSchedule) && moment(e.broadcast_end * 1000).isAfter(now)) {\n            entries.push(e);\n          }\n        });\n      }\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse Victory+ events');\n    }\n\n    await parseAirings(entries);\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    const event = await db.entries.findOneAsync<IEntry>({id: eventId});\n    const realEventId = event.id.split('victory-')[1];\n\n    try {\n      const url = [BASE_URL, '/live/default/', realEventId, '/manifest.json'].join('');\n\n      const {data} = await axios.get(url, {\n        headers: {\n          'Content-Type': 'application/json',\n          'User-Agent': userAgent,\n          'x-api-session': this.session_key,\n        },\n      });\n\n      return [data.manifestUrl, {}];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start playback');\n    }\n  };\n\n  public getAuthCode = async (): Promise<string> => {\n    this.device_id = getRandomUUID();\n\n    const [guestUserId, guestSessionKey] = await this.registerGuestUser();\n\n    this.user_id = guestUserId;\n    this.session_key = guestSessionKey;\n\n    try {\n      const url = [...BASE_URL, '/users/rendezvous', '?userId=', guestUserId].join('');\n\n      const {data} = await axios.get(url, {\n        headers: {\n          'User-Agent': userAgent,\n          'x-kidoodle-session': this.session_key,\n        },\n      });\n\n      return data.linkCode;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get Victory+ login code');\n    }\n  };\n\n  public authenticateRegCode = async (code: string): Promise<boolean> => {\n    try {\n      const url = [...BASE_URL, '/users/rendezvous', '/currentUser'].join('');\n\n      const {data} = await axios.post(\n        url,\n        {\n          ...DEVICE_INFO,\n          deviceId: this.device_id,\n          linkCode: code,\n        },\n        {\n          headers: {\n            'Content-Type': 'application/json',\n            'User-Agent': userAgent,\n            'x-kidoodle-session': this.session_key,\n          },\n        },\n      );\n\n      if (!data || !data?.session_key || !data.id) {\n        return false;\n      }\n\n      this.session_key = data.session_key;\n      this.user_id = data.id;\n\n      await this.save();\n\n      return true;\n    } catch (e) {\n      return false;\n    }\n  };\n\n  private registerGuestUser = async (): Promise<[string, string]> => {\n    const url = [...BASE_URL, '/users/register'].join('');\n\n    try {\n      const {data} = await axios.post(\n        url,\n        {\n          createDefaultProfile: true,\n          guestUser: true,\n        },\n        {\n          headers: {\n            'Content-Type': 'application/json',\n            'User-Agent': userAgent,\n          },\n        },\n      );\n\n      return [data.id, data.session_key];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not create guest user for Victory+');\n    }\n  };\n\n  private save = async (): Promise<void> => {\n    await db.providers.updateAsync({name: 'victory'}, {$set: {tokens: this}});\n  };\n\n  private load = async (): Promise<void> => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TVictoryTokens>>({name: 'victory'});\n    const {device_id, user_id, session_key} = tokens || {};\n\n    this.device_id = device_id;\n    this.user_id = user_id;\n    this.session_key = session_key;\n  };\n}\n\nexport type TVictoryTokens = ClassTypeWithoutMethods<VictoryHandler>;\n\nexport const victoryHandler = new VictoryHandler();\n"
  },
  {
    "path": "services/wnba-handler.ts",
    "content": "import axios from 'axios';\nimport moment from 'moment';\n\nimport {okHttpUserAgent, userAgent} from './user-agent';\nimport {ClassTypeWithoutMethods, IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {normalTimeRange} from './shared-helpers';\nimport {debug} from './debug';\n\ninterface IWNBABroadcast {\n  broadcasterDisplay: string;\n  broadcasterVideoLink?: string;\n}\n\ninterface IWNBATeam {\n  id: string;\n  teamName: string;\n  teamCity: string;\n}\n\ninterface IWNBAEvent {\n  gameDateTimeUTC: string;\n  gameId: string;\n  broadcasters: {\n    nationalTvBroadcasters: IWNBABroadcast[];\n    nationalOttBroadcasters: IWNBABroadcast[];\n  };\n  homeTeam: IWNBATeam;\n  awayTeam: IWNBATeam;\n  ifNecessary: boolean;\n}\n\ninterface IWNBASchedule {\n  leagueSchedule: {\n    gameDates: {\n      games: IWNBAEvent[];\n    }[];\n  };\n}\n\ninterface IWNBAMeta {\n  username: string;\n  password: string;\n}\n\ninterface IWNBAEventDetail {\n  accessLevel: string;\n  playerUrlCallback: string;\n  id: string;\n  title: string;\n  thumbnailUrl: string;\n  startDate: string;\n  endDate: string;\n}\n\ninterface IWNBAStreamCallback {\n  hls: {\n    url: string;\n  }[];\n}\n\nconst API_KEY = [\n  '9',\n  '6',\n  '5',\n  '0',\n  'f',\n  'b',\n  'b',\n  '7',\n  '-',\n  '1',\n  '1',\n  '6',\n  '7',\n  '-',\n  '4',\n  '8',\n  '9',\n  'f',\n  '-',\n  '8',\n  '4',\n  '6',\n  '1',\n  '-',\n  '4',\n  'b',\n  '2',\n  '6',\n  '5',\n  'a',\n  '6',\n  '6',\n  'b',\n  'b',\n  'e',\n  'd',\n].join('');\n\nconst APP_VAR = '18.1.0';\n\nconst BASE_API_URL = 'https://dce-frontoffice.imggaming.com/api';\n\nconst getEventId = (event: IWNBAEvent): string | undefined => {\n  const wnbaLeaguePass = event?.broadcasters?.nationalOttBroadcasters?.find(\n    b => b.broadcasterDisplay === 'WNBA League Pass',\n  );\n\n  const eventLink = wnbaLeaguePass?.broadcasterVideoLink;\n\n  if (!eventLink) return;\n\n  return eventLink.split('/').pop();\n};\n\nconst getEventData = async (eventId: string): Promise<IWNBAEventDetail> => {\n  try {\n    const url = [BASE_API_URL, '/v4/event/', eventId, '?includePlaybackDetails=URL&displayGeoblocked=SHOW'].join('');\n\n    const {data} = await axios.get<IWNBAEventDetail>(url, {\n      headers: {\n        'Content-Type': 'application/json',\n        'User-Agent': okHttpUserAgent,\n        authorization: `Bearer ${wnbaHandler.token}`,\n        realm: 'dce.wnba',\n        'x-api-key': API_KEY,\n        'x-app-var': APP_VAR,\n      },\n    });\n\n    return data;\n  } catch (e) {\n    console.error(e);\n    console.log('Could not get event data');\n  }\n};\n\nconst parseAirings = async (events: IWNBAEvent[]) => {\n  const [now, endDate] = normalTimeRange();\n\n  for (const event of events) {\n    const eventId = getEventId(event);\n\n    if (!event || !eventId) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `wnba-${eventId}`});\n\n    if (!entryExists) {\n      const eventData = await getEventData(eventId);\n\n      if (!eventData || eventData.accessLevel !== 'GRANTED') {\n        continue;\n      }\n\n      const start = moment(eventData.startDate);\n      const end = moment(eventData.endDate).add(1, 'hour');\n      const originalEnd = moment(eventData.endDate);\n\n      if (end.isBefore(now) || start.isAfter(endDate)) {\n        continue;\n      }\n\n      console.log('Adding event: ', eventData.title);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: ['WNBA', 'Basketball', \"Women's Sports\", \"Women's Basketball\"],\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'wnba',\n        id: `wnba-${eventId}`,\n        image: eventData.thumbnailUrl,\n        name: eventData.title,\n        network: 'WNBA League Pass',\n        originalEnd: originalEnd.valueOf(),\n        sport: 'WNBA',\n        start: start.valueOf(),\n      });\n    }\n  }\n};\n\nclass WNBAHandler {\n  public token?: string;\n  public refresh_token?: string;\n\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'wnba'})) > 0 ? true : false;\n\n    // First time setup\n    if (!setup) {\n      const data: TWNBATokens = {};\n\n      await db.providers.insertAsync<IProvider<TWNBATokens>>({\n        enabled: false,\n        name: 'wnba',\n        tokens: data,\n      });\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'wnba'});\n\n    if (!enabled) {\n      return;\n    }\n\n    // Load tokens from local file and make sure they are valid\n    await this.load();\n  };\n\n  public refreshTokens = async () => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'wnba'});\n\n    if (!enabled) {\n      return;\n    }\n\n    try {\n      const url = [BASE_API_URL, '/v2/token/refresh'].join('');\n\n      const {data} = await axios.post(\n        url,\n        {refreshToken: this.refresh_token},\n        {\n          headers: {\n            'Content-Type': 'application/json',\n            'User-Agent': okHttpUserAgent,\n            authorization: `Bearer ${this.token}`,\n            realm: 'dce.wnba',\n            'x-api-key': API_KEY,\n            'x-app-var': APP_VAR,\n          },\n        },\n      );\n\n      if (data && data.authorisationToken) {\n        this.token = data.authorisationToken;\n\n        await this.save();\n      }\n    } catch (e) {}\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'wnba'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log('Looking for WNBA events...');\n\n    await this.refreshTokens();\n\n    const entries: IWNBAEvent[] = [];\n\n    const [now, endSchedule] = normalTimeRange();\n\n    try {\n      const url = ['https://', 'cdn.wnba.com', '/static/json/staticData/scheduleLeagueV2_1.json'].join('');\n\n      const {data} = await axios.get<IWNBASchedule>(url, {\n        headers: {\n          'Content-Type': 'application/json',\n          'User-Agent': userAgent,\n        },\n      });\n\n      debug.saveRequestData(data, 'wnba', 'epg');\n\n      data.leagueSchedule.gameDates.forEach(e =>\n        e.games.forEach(g => {\n          const eventStart = moment.utc(g.gameDateTimeUTC);\n\n          if (eventStart.isBefore(endSchedule) && moment(eventStart).add(3, 'hours').isAfter(now)) {\n            entries.push(g);\n          }\n        }),\n      );\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse WNBA events');\n    }\n\n    await parseAirings(entries);\n  };\n\n  public getEventData = async (eventId: string): Promise<TChannelPlaybackInfo> => {\n    const parsedEventId = eventId.split('-')[1];\n\n    await this.refreshTokens();\n\n    try {\n      const eventData = await getEventData(parsedEventId);\n\n      const {data: streamData} = await axios.get<IWNBAStreamCallback>(eventData.playerUrlCallback);\n\n      const hlsStream = streamData?.hls?.find(a => a.url);\n\n      return [hlsStream.url, {}];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not start playback');\n    }\n  };\n\n  public login = async (username?: string, password?: string): Promise<boolean> => {\n    try {\n      const url = [BASE_API_URL, '/v2/login'].join('');\n\n      const {meta} = await db.providers.findOneAsync<IProvider<TWNBATokens, IWNBAMeta>>({name: 'wnba'});\n\n      const {data} = await axios.post(\n        url,\n        {\n          id: username || meta.username,\n          secret: password || meta.password,\n        },\n        {\n          headers: {\n            'content-type': 'application/json; charset=utf-8',\n            realm: 'dce.wnba',\n            'user-agent': okHttpUserAgent,\n            'x-api-key': API_KEY,\n            'x-app-var': APP_VAR,\n          },\n        },\n      );\n\n      this.token = data.authorisationToken;\n      this.refresh_token = data.refreshToken;\n\n      await this.save();\n\n      return true;\n    } catch (e) {\n      console.error(e);\n      console.log('Could not login to WNBA League Pass');\n\n      return false;\n    }\n  };\n\n  private save = async (): Promise<void> => {\n    await db.providers.updateAsync({name: 'wnba'}, {$set: {tokens: this}});\n  };\n\n  private load = async (): Promise<void> => {\n    const {tokens} = await db.providers.findOneAsync<IProvider<TWNBATokens>>({name: 'wnba'});\n    const {refresh_token, token} = tokens || {};\n\n    this.token = token;\n    this.refresh_token = refresh_token;\n  };\n}\n\nexport type TWNBATokens = ClassTypeWithoutMethods<WNBAHandler>;\n\nexport const wnbaHandler = new WNBAHandler();\n"
  },
  {
    "path": "services/wsn-handler.ts",
    "content": "import moment from 'moment';\n\nimport {IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {debug} from './debug';\nimport {normalTimeRange} from './shared-helpers';\nimport {ITubiEvent, tubiHelper} from './tubi-helper';\n\nconst parseAirings = async (events: ITubiEvent[]) => {\n  const [now, endSchedule] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.id) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: `wsn-${event.id}`});\n\n    if (!entryExists) {\n      const start = moment(event.start_time);\n      const end = moment(event.end_time);\n\n      if (end.isBefore(now) || start.isAfter(endSchedule)) {\n        continue;\n      }\n\n      console.log('Adding event: ', event.title);\n\n      let image = event.images.thumbnail.find(a => a);\n\n      if (!image) {\n        image = event.images.poster.find(a => a);\n      }\n\n      await db.entries.insertAsync<IEntry>({\n        categories: [...new Set(['WSN', \"Women's Sports Network\", \"Women's Sports\"])],\n        channel: 'WSN',\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'wsn',\n        id: `wsn-${event.id}`,\n        image,\n        linear: true,\n        name: event.title,\n        network: 'WSN',\n        originalEnd: end.valueOf(),\n        start: start.valueOf(),\n      });\n    }\n  }\n};\n\nclass WomensSportsNetworkHandler {\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'wsn'})) > 0 ? true : false;\n\n    // First time setup\n    if (!setup) {\n      await db.providers.insertAsync<IProvider>({\n        enabled: false,\n        name: 'wsn',\n      });\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'wsn'});\n\n    if (!enabled) {\n      return;\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'wsn'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log(\"Looking for Women's Sports Network events...\");\n\n    try {\n      const {programs} = await tubiHelper(692073);\n\n      debug.saveRequestData(programs, 'wsn', 'epg');\n\n      await parseAirings(programs);\n    } catch (e) {\n      console.error(e);\n      console.log(\"Could not parse Women's Sports Network events\");\n    }\n  };\n\n  public getEventData = async (): Promise<TChannelPlaybackInfo> => {\n    try {\n      const {video_resources} = await tubiHelper(692073);\n      const eventData = video_resources.find(a => a.type === 'hlsv3');\n\n      if (eventData) {\n        return [eventData.manifest.url, {}];\n      }\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get event data');\n    }\n  };\n}\n\nexport const wsnHandler = new WomensSportsNetworkHandler();\n"
  },
  {
    "path": "services/yt-dlp-helper.ts",
    "content": "import ytdlpWrap from 'yt-dlp-wrap';\n\nconst ytdlp = new ytdlpWrap();\n\ninterface ILiveStream {\n  id: string;\n  title: string;\n  description: string;\n}\n\nexport const getLiveEventsFromChannel = async (channelId: string): Promise<ILiveStream[]> => {\n  const options = [\n    '--flat-playlist',\n    '--match-filter',\n    'live_status=is_live',\n    '--playlist-items',\n    '1-10',\n    '--dump-json',\n  ];\n\n  try {\n    const rawStreams = await ytdlp.execPromise([`https://www.youtube.com/channel/${channelId}/streams`, ...options]);\n\n    if (rawStreams && rawStreams.length > 0) {\n      return rawStreams\n        .split('\\n')\n        .filter(a => a)\n        .map(e => JSON.parse(e) as ILiveStream);\n    }\n\n    return [];\n  } catch (e) {\n    console.error(e);\n    console.log('Could not get live streams for channel: ', channelId);\n  }\n};\n\nexport const matchEvent = (streams: ILiveStream[], title: string): ILiveStream | undefined => {\n  const [homeTeam, awayTeam] = title.split(' vs ');\n\n  return streams.find(a => a.title.indexOf(homeTeam.split(' ')[0]) > -1 || a.title.indexOf(awayTeam.split(' ')[0]) > -1);\n};\n\nexport const getEventStream = async (videoId: string): Promise<string | undefined> => {\n  const options = ['--print', '%(manifest_url)s'];\n\n  try {\n    const streamRaw = await ytdlp.execPromise([`https://www.youtube.com/watch?v=${videoId}`, ...options]);\n\n    if (streamRaw && streamRaw.length > 0) {\n      return streamRaw.trim();\n    } else {\n      throw new Error(`Could not get m3u8 for video: ${videoId}`);\n    }\n  } catch (e) {\n    console.error(e);\n    console.log('Could not get m3u8 for video: ', videoId);\n  }\n};\n"
  },
  {
    "path": "services/zeam-handler.ts",
    "content": "import moment from 'moment';\n\nimport {userAgent} from './user-agent';\nimport {IEntry, IProvider, TChannelPlaybackInfo} from './shared-interfaces';\nimport {db} from './database';\nimport {debug} from './debug';\nimport {normalTimeRange} from './shared-helpers';\nimport axios from 'axios';\n\nconst origin = [\n  'https://', \n  'zeam', \n  '.com'\n].join('');\nconst referer = [origin, '/'].join('');\n\nconst slugify = (n: string): string => {\n  return n?n.toLowerCase().replace(/ /g,\"-\").replace(/[^\\w-]+/g,\"\"):n;\n}\n\nconst getImagePath = (n: string): string => {\n  return [\n    'https://',\n    'd',\n    '1',\n    '0',\n    'b',\n    't',\n    '0',\n    '8',\n    '1',\n    '2',\n    'q',\n    'i',\n    'c',\n    'o',\n    't',\n    '.',\n    'c',\n    'l',\n    'o',\n    'u',\n    'd',\n    'f',\n    'r',\n    'o',\n    'n',\n    't',\n    '.',\n    'n',\n    'e',\n    't',\n    '/img/',\n    n?(n=n.replace(/\\-/g,\"\").toLowerCase(),n.slice(0,2)+\"/\"+n.slice(2)):\"\",\n    '/',\n    '400',\n    'x',\n    '224',\n    '.jpg',\n  ].join('');\n}\n\ninterface IZeamEvent {\n  eventId: string;\n  title: string;\n  start: number;\n  end: number;\n  imageGuid: string;\n}\n\nconst parseAirings = async (events: IZeamEvent[]) => {\n  const [now, endSchedule] = normalTimeRange();\n\n  for (const event of events) {\n    if (!event || !event.eventId) {\n      continue;\n    }\n\n    const entryExists = await db.entries.findOneAsync<IEntry>({id: event.eventId});\n\n    if (!entryExists) {\n      const start = moment.unix(event.start);\n      const end = moment.unix(event.end).add(1, 'hours');\n      const originalEnd = moment.unix(event.end);\n\n      if (end.isBefore(now) || start.isAfter(endSchedule)) {\n        continue;\n      }\n\n      console.log('Adding event: ', event.title);\n\n      await db.entries.insertAsync<IEntry>({\n        categories: [...new Set(['Zeam'])],\n        duration: end.diff(start, 'seconds'),\n        end: end.valueOf(),\n        from: 'zeam',\n        id: event.eventId,\n        image: getImagePath(event.imageGuid),\n        name: event.title,\n        network: 'Zeam',\n        originalEnd: originalEnd.valueOf(),\n        start: start.valueOf(),\n      });\n    }\n  }\n};\n\nclass ZeamHandler {\n  public initialize = async () => {\n    const setup = (await db.providers.countAsync({name: 'zeam'})) > 0 ? true : false;\n\n    // First time setup\n    if (!setup) {\n      await db.providers.insertAsync<IProvider>({\n        enabled: false,\n        name: 'zeam',\n      });\n    }\n\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'zeam'});\n\n    if (!enabled) {\n      return;\n    }\n  };\n\n  public getSchedule = async (): Promise<void> => {\n    const {enabled} = await db.providers.findOneAsync<IProvider>({name: 'zeam'});\n\n    if (!enabled) {\n      return;\n    }\n\n    console.log('Looking for Zeam events...');\n\n    try {\n      const [now, endSchedule] = normalTimeRange();\n      \n      const url = [\n        'https://',\n        'zeam',\n        '.com',\n        '/events/',\n      ].join('');\n\n      const {data} = await axios.get(url, {\n        headers: {\n          'user-agent': userAgent,\n          'referer': referer,\n        },\n      });\n      \n      const match = data.match(/var\\s*json\\s*=\\s*({.*?});[\\r\\n]/s);\n      \n      const json = JSON.parse(match[1]);\n\n      debug.saveRequestData(json, 'zeam', 'epg');\n\n      await parseAirings(json.groups[0].liveEvents);\n    } catch (e) {\n      console.error(e);\n      console.log('Could not parse Zeam events');\n    }\n  };\n\n  public getEventData = async (id: string): Promise<TChannelPlaybackInfo> => {\n    try {\n      const event = await db.entries.findOneAsync<IEntry>({id});\n\n      const {data} = await axios.get(\n        [\n          'https://',\n          'zeam',\n          '.com',\n          '/api/services/',\n          'watchevent',\n          '?id=', \n          id,\n        ].join(''), {\n        headers: {\n          'user-agent': userAgent,\n          'referer': [referer, 'events/', id, '/', slugify(event.name)].join(''),\n        },\n      });\n\n      const streamUrl = data.playlistUrl;\n        \n      return [streamUrl, {'user-agent': userAgent, 'origin': origin, 'referer': referer}];\n    } catch (e) {\n      console.error(e);\n      console.log('Could not get event data');\n    }\n  };\n}\n\nexport const zeamHandler = new ZeamHandler();\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es6\",\n    \"jsx\": \"react-jsx\",\n    \"jsxImportSource\": \"hono/jsx\",\n    \"module\": \"commonjs\",\n    \"rootDir\": \"./\",\n    \"outDir\": \"./dist\",\n    \"moduleResolution\": \"node\",\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": false,\n    \"resolveJsonModule\": true,\n    \"baseUrl\": \"./\",\n    \"paths\": {\n      \"@/*\": [\n        \"*\"\n      ]\n    }\n  },\n  \"exclude\": [\n    \"tmp\",\n    \"slate\"\n  ]\n}\n"
  },
  {
    "path": "views/Header.tsx",
    "content": "import type {FC} from 'hono/jsx';\n\nimport {version} from '../package.json';\n\nimport {latestRelease} from '@/services/shared-helpers';\n\nexport const Header: FC = async () => {\n  const latest_release = await latestRelease();\n  let latestLabel = 'latest';\n  if ( latest_release && (latest_release != '') && (version != latest_release.slice(1)) ) {\n    latestLabel = [latestLabel, latest_release].join(' ');\n  }\n  \n  return (\n    <header class=\"container\">\n      <div class=\"grid-container\">\n        <h1>\n          <span class=\"title\">\n            <span>E+</span>\n            <span class=\"title-bold\">TV</span>\n          </span>\n        </h1>\n        <div class=\"align-center\">\n          <p>v{version} ({latestLabel})</p>\n        </div>\n      </div>\n    </header>\n  );\n};\n"
  },
  {
    "path": "views/Layout.tsx",
    "content": "import type {FC, ReactNode} from 'hono/jsx';\n\nexport interface ILayoutProps {\n  children: ReactNode;\n}\n\nexport const Layout: FC = ({children}: ILayoutProps) => (\n  <html lang=\"en\">\n    <head>\n      <meta charset=\"utf-8\" />\n      <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n      <meta name=\"color-scheme\" content=\"light dark\" />\n      <link rel=\"icon\" type=\"image/x-icon\" href=\"/favicon.ico\"></link>\n      <script src=\"/node_modules/htmx.org/dist/htmx.min.js\"></script>\n      <script src=\"/node_modules/htmx-toaster/dist/htmx-toaster.min.js\"></script>\n      <link rel=\"stylesheet\" href=\"/node_modules/@picocss/pico/css/pico.min.css\" />\n      <title>E+TV</title>\n    </head>\n    <body>{children}</body>\n  </html>\n);\n"
  },
  {
    "path": "views/Links.tsx",
    "content": "import type {FC} from 'hono/jsx';\n\nimport {usesLinear} from '@/services/misc-db-service';\n\nexport interface ILinksProps {\n  baseUrl: string;\n}\n\nexport const Links: FC<ILinksProps> = async ({baseUrl}) => {\n  const useLinear = await usesLinear();\n\n  const xmltvUrl = `${baseUrl}/xmltv.xml`;\n  const linearXmltvUrl = `${baseUrl}/linear-xmltv.xml`;\n  const channelsUrl = `${baseUrl}/channels.m3u`;\n  const eventChannelsUrl = `${baseUrl}/event-channels.m3u`;\n  const linearChannelsUrl = `${baseUrl}/linear-channels.m3u`;\n  const linearChannelsGc = `${baseUrl}/linear-channels.m3u?gracenote=include`;\n  const linearChannelsNoGc = `${baseUrl}/linear-channels.m3u?gracenote=exclude`;\n\n  return (\n    <section>\n      <h3>\n        <span data-tooltip=\"Import these into your DVR client\" data-placement=\"right\">\n          Links\n        </span>\n      </h3>\n      <table class=\"striped\">\n        <thead>\n          <tr>\n            <th scope=\"col\">Type</th>\n            <th scope=\"col\">Description</th>\n            <th scope=\"col\">Link</th>\n          </tr>\n        </thead>\n        <tbody>\n          <tr>\n            <td>Normal</td>\n            <td>Insert into XMLTV Guide Data section</td>\n            <td>\n              <a href={xmltvUrl} class=\"secondary\" target=\"_blank\">\n                {xmltvUrl}\n              </a>\n            </td>\n          </tr>\n          <tr>\n            <td>Normal</td>\n            <td>Insert into Source section</td>\n            <td>\n              <a href={channelsUrl} class=\"secondary\" target=\"_blank\">\n                {channelsUrl}\n              </a>\n            </td>\n          </tr>\n          <tr>\n            <td>Event Channel</td>\n            <td>One channel per scheduled event with details in the name, no accompanying EPG data</td>\n            <td>\n              <a href={eventChannelsUrl} class=\"secondary\" target=\"_blank\">\n                {eventChannelsUrl}\n              </a>\n            </td>\n          </tr>\n          {useLinear && (\n            <>\n              <tr>\n                <td>Linear</td>\n                <td>\n                  Insert into XMLTV Guide Data section\n                  <p class=\"help-text\">\n                    <small\n                      data-tooltip=\"Gracenote data is automatically added to M3U so Channels is able to map EPG data automatically.\"\n                      data-placement=\"bottom\"\n                    >\n                      Not needed for Channels DVR (unless using non-gracenote channels)\n                    </small>\n                  </p>\n                </td>\n                <td>\n                  <a href={linearXmltvUrl} class=\"secondary\" target=\"_blank\">\n                    {linearXmltvUrl}\n                  </a>\n                </td>\n              </tr>\n              <tr>\n                <td>Linear</td>\n                <td>Insert into Source section</td>\n                <td>\n                  <a href={linearChannelsUrl} class=\"secondary\" target=\"_blank\">\n                    {linearChannelsUrl}\n                  </a>\n                </td>\n              </tr>\n              <tr>\n                <td>Linear</td>\n                <td>Insert into Source section (same as above for legacy support)</td>\n                <td>\n                  <a href={linearChannelsGc} class=\"secondary\" target=\"_blank\">\n                    {linearChannelsGc}\n                  </a>\n                </td>\n              </tr>\n              <tr>\n                <td>Linear</td>\n                <td>Insert into Source section (for channels that don't include Gracenote data)</td>\n                <td>\n                  <a href={linearChannelsNoGc} class=\"secondary\" target=\"_blank\">\n                    {linearChannelsNoGc}\n                  </a>\n                </td>\n              </tr>\n            </>\n          )}\n        </tbody>\n      </table>\n    </section>\n  );\n};\n"
  },
  {
    "path": "views/Main.tsx",
    "content": "import type {FC, ReactNode} from 'hono/jsx';\n\nexport interface IMainProps {\n  children: ReactNode;\n}\nexport const Main: FC = ({children}: IMainProps) => <main class=\"container\">{children}</main>;\n"
  },
  {
    "path": "views/Options.tsx",
    "content": "import type {FC} from 'hono/jsx';\n\nimport {\n  getNumberOfChannels,\n  getStartChannel,\n  proxySegments,\n  usesLinear,\n  xmltvPadding,\n  hideStudio,\n  getCategoryFilter,\n  getTitleFilter,\n} from '@/services/misc-db-service';\n\nexport const Options: FC = async () => {\n  const startChannel = await getStartChannel();\n  const numOfChannels = await getNumberOfChannels();\n  const linearChannels = await usesLinear();\n  const proxiedSegments = await proxySegments();\n  const xmltvPadded = await xmltvPadding();\n  const hide_studio = await hideStudio();\n  const categoryFilter = await getCategoryFilter();\n  const titleFilter = await getTitleFilter();\n\n  return (\n    <section hx-swap=\"outerHTML\" hx-target=\"this\">\n      <h3>Options</h3>\n      <div class=\"grid\">\n        <form id=\"start-channel\" hx-post=\"/start-channel\" hx-trigger=\"submit\">\n          <label>\n            <span>\n              Starting Channel #{' '}\n              <span\n                class=\"warning-red\"\n                data-tooltip=\"Making changes will break/invalidate existing scheduled recordings\"\n                data-placement=\"right\"\n              >\n                **\n              </span>\n            </span>\n            <fieldset role=\"group\">\n              <input\n                type=\"number\"\n                placeholder=\"Starting Channel #\"\n                value={startChannel}\n                data-value={startChannel}\n                name=\"start-channel\"\n                min={1}\n                required\n              />\n              <button type=\"submit\" id=\"start-channel-button\">\n                Save\n              </button>\n            </fieldset>\n          </label>\n        </form>\n        <form id=\"num-of-channels\" hx-post=\"/num-of-channels\" hx-trigger=\"submit\">\n          <label>\n            <span>\n              # of Channels{' '}\n              <span\n                class=\"warning-red\"\n                data-tooltip=\"Making changes will break/invalidate existing scheduled recordings\"\n                data-placement=\"right\"\n              >\n                **\n              </span>\n            </span>\n            <fieldset role=\"group\">\n              <input\n                type=\"number\"\n                placeholder=\"# of Channels\"\n                value={numOfChannels}\n                data-value={numOfChannels}\n                name=\"num-of-channels\"\n                min={0}\n                max={5000}\n                required\n              />\n              <button type=\"submit\" id=\"num-of-channels-button\">\n                Save\n              </button>\n            </fieldset>\n            {/* <small id=\"email-helper\">We'll never share your email with anyone else.</small> */}\n          </label>\n        </form>\n      </div>\n      <div class=\"grid\">\n        <fieldset>\n          <label>\n            <input\n              hx-target=\"this\"\n              hx-swap=\"outerHTML\"\n              hx-trigger=\"change\"\n              hx-post=\"/linear-channels\"\n              name=\"linear-channels\"\n              type=\"checkbox\"\n              role=\"switch\"\n              checked={linearChannels}\n              data-enabled={linearChannels ? 'true' : 'false'}\n            />\n            <span>\n              Dedicated Linear Channels?{' '}\n              <span\n                class=\"warning-red\"\n                data-tooltip=\"Making changes will break/invalidate existing scheduled recordings\"\n                data-placement=\"right\"\n              >\n                **\n              </span>\n            </span>\n          </label>\n        </fieldset>\n        <fieldset>\n          <label>\n            <input\n              hx-post=\"/proxy-segments\"\n              hx-target=\"this\"\n              hx-swap=\"outerHTML\"\n              hx-trigger=\"change\"\n              name=\"proxy-segments\"\n              type=\"checkbox\"\n              role=\"switch\"\n              checked={proxiedSegments}\n              data-enabled={proxiedSegments ? 'true' : 'false'}\n            />\n            Proxy segment files?\n          </label>\n        </fieldset>\n        <fieldset>\n          <label>\n            <input\n              hx-post=\"/xmltv-padding\"\n              hx-target=\"this\"\n              hx-swap=\"outerHTML\"\n              hx-trigger=\"change\"\n              name=\"xmltv-padding\"\n              type=\"checkbox\"\n              role=\"switch\"\n              checked={xmltvPadded}\n              data-enabled={xmltvPadded ? 'true' : 'false'}\n            />\n            Pad XMLTV event end times?\n          </label>\n        </fieldset>\n        <fieldset>\n          <label>\n            <input\n              hx-post=\"/hide-studio\"\n              hx-target=\"this\"\n              hx-swap=\"outerHTML\"\n              hx-trigger=\"change\"\n              name=\"hide-studio\"\n              type=\"checkbox\"\n              role=\"switch\"\n              checked={hide_studio}\n              data-enabled={hide_studio ? 'true' : 'false'}\n            />\n            Hide studio shows?\n          </label>\n        </fieldset>\n      </div>\n      <div class=\"grid\">\n        <form\n          id=\"event-filters\"\n          hx-put=\"/event-filters\"\n          hx-trigger=\"submit\"\n          hx-swap=\"outerHTML\"\n          hx-target=\"#event-filters-button\"\n        >\n          <div>\n            <span>Category Filter{' '}\n              <span\n                class=\"warning-red\"\n                data-tooltip=\"Making changes will break/invalidate existing scheduled recordings\"\n                data-placement=\"right\"\n              >\n                **\n              </span>\n            </span>\n            <fieldset role=\"group\">\n              <input\n                type=\"text\"\n                placeholder=\"comma-separated list of categories to include, leave blank for all\"\n                value={categoryFilter}\n                data-value={categoryFilter}\n                name=\"category-filter\"\n              />\n            </fieldset>\n            <span>Title Filter{' '}\n              <span\n                class=\"warning-red\"\n                data-tooltip=\"Making changes will break/invalidate existing scheduled recordings\"\n                data-placement=\"right\"\n              >\n                **\n              </span>\n            </span>\n            <fieldset role=\"group\">\n              <input\n                type=\"text\"\n                placeholder=\"if specified, only include events with matching titles; supports regular expressions, use regex101.com for testing\"\n                value={titleFilter}\n                data-value={titleFilter}\n                name=\"title-filter\"\n              />\n            </fieldset>\n            <button type=\"submit\" id=\"event-filters-button\">\n              Save and Apply Event Filters\n            </button>\n          </div>\n        </form>\n      </div>\n      <hr />\n      <script\n        dangerouslySetInnerHTML={{\n          __html: `\n          var rebuildEpgForm = document.getElementById('start-channel');\n\n          if (rebuildEpgForm) {\n            rebuildEpgForm.addEventListener('htmx:beforeRequest', function() {\n              this.querySelector('#start-channel-button').setAttribute('aria-busy', 'true');\n              this.querySelector('#start-channel-button').setAttribute('aria-label', 'Loading…');\n            });\n          }\n\n          var resetChannelsForm = document.getElementById('num-of-channels');\n\n          if (resetChannelsForm) {\n            resetChannelsForm.addEventListener('htmx:beforeRequest', function() {\n              this.querySelector('#num-of-channels-button').setAttribute('aria-busy', 'true');\n              this.querySelector('#num-of-channels-button').setAttribute('aria-label', 'Loading…');\n            });\n          }\n\n          var eventFiltersForm = document.getElementById('event-filters');\n\n          if (eventFiltersForm) {\n            eventFiltersForm.addEventListener('htmx:beforeRequest', function() {\n              this.querySelector('#event-filters-button').setAttribute('aria-busy', 'true');\n              this.querySelector('#event-filters-button').setAttribute('aria-label', 'Loading…');\n            });\n          }\n        `,\n        }}\n      />\n    </section>\n  );\n};\n"
  },
  {
    "path": "views/Providers.tsx",
    "content": "import type {FC, ReactNode} from 'hono/jsx';\n\nexport interface IProvidersProps {\n  children: ReactNode;\n}\n\nexport const Providers: FC = ({children}: IProvidersProps) => (\n  <section>\n    <h3>Providers</h3>\n    {children}\n  </section>\n);\n"
  },
  {
    "path": "views/Script.tsx",
    "content": "import type {FC} from 'hono/jsx';\n\nexport const Script: FC = () => (\n  <script\n    dangerouslySetInnerHTML={{\n      __html: `\n          function updateCheckboxes() {\n            document.querySelectorAll('input[type=\"checkbox\"]').forEach(checkbox => {\n              const isEnabled = checkbox.getAttribute('data-enabled') === 'true';\n              checkbox.checked = isEnabled;\n            });\n          }\n\n          function updateTextInputs() {\n            document.querySelectorAll('input[type=\"text\"], input[type=\"number\"]').forEach(checkbox => {\n              checkbox.value = checkbox.getAttribute('data-value');\n            });\n          }\n\n          function refreshPage() {\n            setTimeout(function() {\n              location.reload();\n            }, 5000);\n          }\n\n          document.addEventListener('DOMContentLoaded', updateCheckboxes);\n          document.addEventListener('DOMContentLoaded', updateTextInputs);\n          document.body.addEventListener(\"HXRefresh\", refreshPage);\n        `,\n    }}\n  />\n);\n"
  },
  {
    "path": "views/Style.tsx",
    "content": "import type {FC} from 'hono/jsx';\n\nexport const Style: FC = () => (\n  <style>{`\n    pre {\n      padding: 5px;\n    }\n    .align-center {\n      align-content: center;\n    }\n    .title {\n      font-family: 'sans-serif';\n      font-weight: normal;\n    }\n    .title-bold {\n      font-weight: 900;\n      color: red;\n    }\n    .help-text {\n      margin-bottom: 0px;\n      color: red;\n    }\n    .provider-section {\n      // max-height: 30rem;\n    }\n    .grid-container {\n      display: grid;\n      grid-template-columns: 1fr auto;\n      justify-items: stretch;\n      align-items: stretch;\n      padding-right: 1rem;\n    }\n    .grid-container-3 {\n      display: grid;\n      grid-template-columns: 1fr auto auto;\n      grid-column-gap: 10px;\n      justify-items: stretch;\n      align-items: stretch;\n      padding-right: 1rem;\n    }\n    .warning-red {\n      color: red;\n    }\n  `}</style>\n);\n"
  },
  {
    "path": "views/Tools.tsx",
    "content": "import type {FC} from 'hono/jsx';\n\nexport const Tools: FC = () => (\n  <section hx-swap=\"outerHTML\" hx-target=\"this\">\n    <h3>Tools</h3>\n    <div class=\"grid\">\n      <form id=\"rebuild-epg\" hx-post=\"/rebuild-epg\" hx-trigger=\"submit\">\n        <button class=\"outline\" id=\"rebuild-epg-button\">\n          Rebuild EPG\n        </button>\n      </form>\n      <form id=\"reset-channels\" hx-post=\"/reset-channels\" hx-trigger=\"submit\">\n        <button class=\"outline\" id=\"reset-channels-button\">\n          Reset Active Playing Channels\n        </button>\n      </form>\n    </div>\n    <hr />\n    <script\n      dangerouslySetInnerHTML={{\n        __html: `\n            var rebuildEpgForm = document.getElementById('rebuild-epg');\n\n            if (rebuildEpgForm) {\n              rebuildEpgForm.addEventListener('htmx:beforeRequest', function() {\n                this.querySelector('#rebuild-epg-button').setAttribute('aria-busy', 'true');\n                this.querySelector('#rebuild-epg-button').setAttribute('aria-label', 'Loading…');\n              });\n            }\n\n            var resetChannelsForm = document.getElementById('reset-channels');\n\n            if (resetChannelsForm) {\n              resetChannelsForm.addEventListener('htmx:beforeRequest', function() {\n                this.querySelector('#reset-channels-button').setAttribute('aria-busy', 'true');\n                this.querySelector('#reset-channels-button').setAttribute('aria-label', 'Loading…');\n              });\n            }\n          `,\n      }}\n    />\n  </section>\n);\n"
  }
]