├── .dockerignore ├── .env.bootstrap.default ├── .env.default ├── .env.dev.default ├── .github └── workflows │ └── codeql.yml ├── .gitignore ├── .prettierrc.json ├── .releaserc.json ├── CHANGELOG.md ├── Dockerfile ├── LEGACY.md ├── LICENSE ├── README.md ├── ROADMAP.md ├── assets ├── Football.png ├── Formule 1.png ├── Moto.png ├── Rugby.png ├── TVChannels.png └── Tennis.png ├── doc └── COMMANDS.md ├── docker-compose.bootstrap.dev.yml ├── docker-compose.bootstrap.yml ├── docker-compose.dev.yml ├── docker-compose.yml ├── eslint.config.mjs ├── package.json ├── screenshots ├── BroadcastLinkJellyfin.png ├── CategoryPublishDiscord.png ├── CategoryPublishMatrix.png └── CollectionViewJellyfin.png ├── scripts ├── entrypoint.sh └── gateway.sh ├── src ├── api │ ├── discord.ts │ ├── gotify.ts │ ├── jellyfin.ts │ └── theSportsDB.ts ├── bootstrap.ts ├── bootstrappers │ ├── bootstrapper.ts │ ├── config.ts │ ├── emoji.ts │ ├── groups.ts │ ├── index.ts │ ├── indexers.ts │ ├── publishers.ts │ ├── releasers.ts │ └── roles.ts ├── bot │ ├── commands.ts │ ├── commands │ │ ├── addCategory.ts │ │ ├── addRole.ts │ │ ├── indexCategory.ts │ │ ├── removeCategory.ts │ │ ├── setCategoryEmoji.ts │ │ ├── setConfig.ts │ │ ├── setGroupEmoji.ts │ │ ├── togglegroup.ts │ │ ├── toggleindexer.ts │ │ ├── togglepublisher.ts │ │ └── togglereleaser.ts │ ├── components │ │ ├── confirm.ts │ │ └── selectGroup.ts │ ├── discord.ts │ └── type.ts ├── config │ └── env.ts ├── modules │ ├── agenda │ │ ├── agenda.ts │ │ ├── definer.ts │ │ ├── handlers │ │ │ ├── deleteBroadcast.ts │ │ │ ├── grabBroadcastStream.ts │ │ │ ├── index.ts │ │ │ ├── indexCategory.ts │ │ │ ├── publishCategory.ts │ │ │ ├── publishGroup.ts │ │ │ ├── releaseBroadcast.ts │ │ │ ├── tasks.ts │ │ │ └── updateCategoryChannelName.ts │ │ ├── index.ts │ │ ├── options │ │ │ ├── deleteBroadcast.ts │ │ │ ├── grabBroadcastStream.ts │ │ │ ├── index.ts │ │ │ ├── indexCategory.ts │ │ │ ├── publishCategory.ts │ │ │ ├── publishGroup.ts │ │ │ ├── releaseBroadcast.ts │ │ │ └── updateCategoryChannelName.ts │ │ ├── tasks.ts │ │ └── triggers │ │ │ ├── deleteBroadcast.ts │ │ │ ├── grabBroadcastStream.ts │ │ │ ├── index.ts │ │ │ ├── indexCategory.ts │ │ │ ├── publishCategory.ts │ │ │ ├── publishGroup.ts │ │ │ ├── releaseBroadcast.ts │ │ │ └── updateCategoryChannelName.ts │ ├── auth │ │ ├── controller.ts │ │ ├── index.ts │ │ ├── model.ts │ │ └── service.ts │ ├── broadcast │ │ ├── controller.ts │ │ ├── index.ts │ │ ├── model.ts │ │ └── service.ts │ ├── category │ │ ├── controller.ts │ │ ├── index.ts │ │ ├── model.ts │ │ └── service.ts │ ├── config │ │ ├── controller.ts │ │ ├── index.ts │ │ ├── model.ts │ │ └── service.ts │ ├── group │ │ ├── controller.ts │ │ ├── index.ts │ │ ├── model.ts │ │ └── service.ts │ ├── indexer │ │ ├── commands.ts │ │ ├── controller.ts │ │ ├── index.ts │ │ ├── model.ts │ │ └── service.ts │ ├── indexers │ │ ├── broadcastInterceptor.ts │ │ ├── broadcastsIndexer.ts │ │ ├── converters.ts │ │ ├── dynamicBroadcastInterceptor.ts │ │ ├── dynamicBroadcastsIndexer.ts │ │ ├── genericBroadcastInterceptor.ts │ │ ├── genericBroadcastsIndexer.ts │ │ ├── index.ts │ │ └── scrapper.ts │ ├── nodeProperties │ │ ├── controller.ts │ │ ├── index.ts │ │ ├── model.ts │ │ └── service.ts │ ├── publishers │ │ ├── controller.ts │ │ ├── implementations │ │ │ ├── discord.ts │ │ │ └── gotify.ts │ │ ├── index.ts │ │ ├── markdown.ts │ │ ├── model.ts │ │ ├── service.ts │ │ └── types.ts │ ├── releasers │ │ ├── controller.ts │ │ ├── implementations │ │ │ └── jellyfin.ts │ │ ├── index.ts │ │ ├── model.ts │ │ ├── service.ts │ │ └── types.ts │ ├── role │ │ ├── controller.ts │ │ ├── index.ts │ │ ├── model.ts │ │ └── service.ts │ ├── scrapper │ │ ├── Orchestrator.ts │ │ ├── commands │ │ │ ├── broadcast │ │ │ │ └── AssertGroup.ts │ │ │ ├── command.ts │ │ │ ├── index.ts │ │ │ ├── navigation │ │ │ │ ├── Click.ts │ │ │ │ ├── Counter.ts │ │ │ │ ├── FillInput.ts │ │ │ │ ├── GetValues.ts │ │ │ │ ├── GoToPage.ts │ │ │ │ ├── InterceptResponse.ts │ │ │ │ └── Sleep.ts │ │ │ └── scenario │ │ │ │ ├── Print.ts │ │ │ │ ├── RunScenario.ts │ │ │ │ └── SetValues.ts │ │ └── scrapper.ts │ ├── templater │ │ ├── index.ts │ │ └── templater.ts │ └── uuid │ │ ├── controller.ts │ │ ├── index.ts │ │ ├── model.ts │ │ └── service.ts ├── routes │ ├── api │ │ ├── broadcast.ts │ │ ├── category.ts │ │ ├── group.ts │ │ ├── index.ts │ │ ├── indexer.ts │ │ └── monitor.ts │ ├── index.ts │ ├── stream.ts │ └── types.ts ├── server.ts ├── utils │ ├── file.ts │ ├── formatter.ts │ ├── getEmoji.ts │ ├── logger.ts │ ├── onExit.ts │ ├── silentError.ts │ ├── sleep.ts │ ├── teamsFlags.ts │ ├── types.ts │ └── urlJoin.ts └── worker.ts ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | 2 | .git 3 | *Dockerfile* 4 | *docker-compose* 5 | node_modules 6 | wg0.conf -------------------------------------------------------------------------------- /.env.bootstrap.default: -------------------------------------------------------------------------------- 1 | # Delays in seconds 2 | DELAY_REGULAR_INDEX_CATEGORY= 3 | DELAY_RETRY_INDEX_CATEGORY= 4 | DELAY_RETRY_GRAB_BROADCAST_STREAM= 5 | DELAY_RETRY_UPDATE_CATEGORY_CHANNEL_NAME= 6 | DELAY_GRAB_STREAM= 7 | DELAY_JELLYFIN_LIVETV_REFRESH= 8 | DELAY_PUBLISH_GROUP= 9 | DELAY_UPDATE_CATEGORY_CHANNEL_NAME= 10 | DELAY_RENEW_STREAM= 11 | DELAY_SIMPLE_INDEX_CATEGORY= 12 | 13 | FUTURE_LIMIT= 14 | PAST_LIMIT= 15 | 16 | CATEGORIES_EMOJIS= 17 | 18 | GROUPS= 19 | 20 | DISCORD_WEBHOOKS= 21 | 22 | CREATE_PUBLISHER_DISCORD= 23 | CREATE_PUBLISHER_MATRIX= 24 | CREATE_PUBLISHER_GOTIFY= 25 | 26 | CREATE_RELEASER_JELLYFIN= 27 | -------------------------------------------------------------------------------- /.env.default: -------------------------------------------------------------------------------- 1 | # The user ID of the container owner 2 | UID= 3 | # silly, trace, debug, info, warn, error, fatal 4 | LOG_LEVEL= 5 | # Print the data in the logs 6 | LOG_DATA= 7 | # The port on which the server will listen 8 | PORT= 9 | # The folder where the M3U8 files are stored, must be accessible by the Jellyfin container too at the same path 10 | M3U8_FOLDER= 11 | # Images folder 12 | IMAGES_FOLDER= 13 | 14 | # The timezone of the server 15 | TZ= 16 | 17 | # The user agent to use for the scrapper 18 | USER_AGENT= 19 | # The URL of which you'll access the API 20 | BROADCASTARR_REMOTE_URL= 21 | 22 | # The URL of the Jellyfin server 23 | JELLYFIN_URL= 24 | # The token to use to access the Jellyfin server 25 | JELLYFIN_TOKEN= 26 | 27 | # The token of the user to use to send messages 28 | DISCORD_USER_TOKEN= 29 | # The URL of the avatar to use for the webhook, can be empty 30 | DISCORD_WEBHOOK_AVATAR= 31 | # The username to use for the webhook, can be empty 32 | DISCORD_WEBHOOK_USERNAME= 33 | 34 | # Whether the Discord bot is active or not 35 | DISCORD_BOT_ACTIVE= 36 | # The token of the Discord bot 37 | DISCORD_BOT_TOKEN= 38 | # The client ID of the Discord bot 39 | DISCORD_BOT_CLIENT_ID= 40 | 41 | # The URL of the Matrix server 42 | MATRIX_URL= 43 | # The name of the Matrix server 44 | MATRIX_SERVER_NAME= 45 | # The user to use to send messages 46 | MATRIX_USER= 47 | # The access token of the user 48 | MATRIX_ACCESS_TOKEN= 49 | # Additional admins to add to the room 50 | MATRIX_ADDITIONAL_ADMINS= 51 | 52 | # The URL of the Gotify server 53 | GOTIFY_URL= 54 | # The token of the Gotify server 55 | GOTIFY_TOKEN= -------------------------------------------------------------------------------- /.env.dev.default: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | DEV_INDEXER= 3 | DEV_CATEGORY= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | wg0.conf 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Compiled binary addons (https://nodejs.org/api/addons.html) 31 | build/Release 32 | build/ 33 | 34 | # Dependency directories 35 | node_modules/ 36 | jspm_packages/ 37 | 38 | # TypeScript cache 39 | *.tsbuildinfo 40 | 41 | # Optional npm cache directory 42 | .npm 43 | 44 | # Optional eslint cache 45 | .eslintcache 46 | 47 | # Optional REPL history 48 | .node_repl_history 49 | 50 | 51 | # Yarn Integrity file 52 | .yarn-integrity 53 | 54 | # environment variables file 55 | .env 56 | .env.bootstrap 57 | .env.test 58 | .env.dev 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | *.m3u 64 | *.m3u8 65 | 66 | # Ignore the data files 67 | data/*.json 68 | data/*.json5 -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": false, 4 | "printWidth": 140, 5 | "trailingComma": "all", 6 | "plugins": [ 7 | "prettier-plugin-packagejson", 8 | "@ianvs/prettier-plugin-sort-imports", 9 | "prettier-plugin-organize-imports", 10 | "prettier-plugin-multiline-arrays" 11 | ], 12 | "importOrder": [ 13 | "", 14 | "", 15 | "", 16 | "", 17 | "^[.]" 18 | ], 19 | "multilineArraysWrapThreshold": 2 20 | } 21 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "release": { 3 | "branches": ["main"] 4 | }, 5 | "plugins": [ 6 | "@semantic-release/commit-analyzer", 7 | [ 8 | "@semantic-release/exec", 9 | { 10 | "prepareCmd": "sed -i 's/docker.io\\/billos\\/broadcastarr:.*/docker.io\\/billos\\/broadcastarr:${nextRelease.version}/g' docker-compose*.yml" 11 | } 12 | ], 13 | "@semantic-release/release-notes-generator", 14 | "@semantic-release/changelog", 15 | "@semantic-release/npm", 16 | [ 17 | "@semantic-release/git", 18 | { 19 | "assets": [ 20 | "package.json", 21 | "CHANGELOG.md", 22 | "docker-compose.yml", 23 | "docker-compose.bootstrap.yml", 24 | "docker-compose.bootstrap.dev.yml", 25 | "docker-compose.dev.yml" 26 | ], 27 | "message": "chore(release): Bump version to ${nextRelease.version} [skip ci]" 28 | } 29 | ], 30 | "@semantic-release/github", 31 | [ 32 | "@codedependant/semantic-release-docker", 33 | { 34 | "dockerProject": "billos", 35 | "dockerTags": ["{{version}}", "latest"] 36 | } 37 | ] 38 | 39 | ], 40 | "tagFormat": "${version}" 41 | } 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Node.js runtime as a parent image 2 | FROM node:22.14.0-alpine AS builder 3 | WORKDIR /app 4 | 5 | COPY . . 6 | RUN yarn 7 | RUN yarn build 8 | 9 | FROM node:22.14.0-alpine AS runtime 10 | WORKDIR /app 11 | ARG UID=1000 12 | RUN chown -R $UID /app 13 | 14 | # Install Chromium 15 | RUN apk add chromium 16 | 17 | # Set environment variables 18 | ENV BROWSER=chrome 19 | ENV BROWSER_EXECUTABLE_PATH=/usr/bin/chromium-browser 20 | 21 | USER $UID 22 | 23 | COPY ./package.json ./package.json 24 | RUN npm install --omit=dev 25 | COPY --from=builder /app/build ./build 26 | 27 | EXPOSE 3000 28 | 29 | ENTRYPOINT [ "npm", "run" ] -------------------------------------------------------------------------------- /LEGACY.md: -------------------------------------------------------------------------------- 1 | # Legacy indexer and interceptor 2 | 3 | ```typescript 4 | type Replacement = { 5 | regex: RegExp 6 | replace: string 7 | } 8 | 9 | type DateReplacement = { 10 | regex: RegExp 11 | format: string 12 | } 13 | 14 | type Selector = { 15 | path: string 16 | } 17 | 18 | type TextContentSelector = Selector & { 19 | attribute?: string 20 | replacement?: Replacement 21 | } 22 | 23 | type DateSelector = TextContentSelector & { 24 | format?: string 25 | dateReplacement?: DateReplacement 26 | } 27 | 28 | type RegexSelector> = TextContentSelector & { 29 | regex: RegExp 30 | default?: T 31 | } 32 | ``` 33 | 34 | ```jsonc 35 | { 36 | "name": string, 37 | "url": string, 38 | "active": boolean, 39 | // The data to configure the indexation 40 | "data": { 41 | "category": { 42 | // The links to retrieve the categories 43 | "links": Selector[], 44 | // When set, the lookup is a map category => strings to look for in the category name in the links textContent retrieved by the links selectors 45 | "lookups": Map, 46 | }, 47 | // Element to wait before looking for links 48 | "loadPageElement": string, 49 | // Broadcasts can be grouped by sets, in this case we start by looking for the day 50 | "broadcastSets": { 51 | // The selectors to retrieve the sets of broadcasts 52 | "selector": Selector[], 53 | // The selectors to retrieve the day 54 | "day": DateSelector[], 55 | // Some sites have a "today" string instead of the current date, in this case we need to replace it 56 | "today": { 57 | "regex": string, 58 | "format": string, 59 | }, 60 | }, 61 | // The selectors to retrieve the broadcasts, is run in the context of the set, or the page if not set 62 | "broadcast": { 63 | // The selectors to retrieve the broadcasts 64 | "selector": Selector[], 65 | // The selectors to retrieve the broadcast start time, is run in the context of the broadcast 66 | "startTime": DateSelector[], 67 | // The selectors to retrieve the broadcast link, is run in the context of the broadcast 68 | "link": TextContentSelector[], 69 | // The selectors to retrieve the broadcast title, is run in the context of the broadcast 70 | "name": TextContentSelector[], 71 | // The selectors to retrieve the broadcast group, is run in the context of the broadcast 72 | "group": RegexSelector[], 73 | }, 74 | // The selectors to retrieve the next page link, as long as there is a next page, and the broadcastSets start before the future limit, we go to the next page and continue the indexation 75 | "nextPage": Selector[], 76 | }, 77 | // The data to configure the grabbing 78 | "interceptorData": { 79 | // Element to wait before looking for stream items 80 | "loadPageElement": string, 81 | // The selectors to retrieve the stream items 82 | "streamItems": Selector[], 83 | // The selectors to retrieve the score of the broadcast, is run in the context of the stream item 84 | "positiveScores": Selector[], 85 | // The selectors to retrieve the link of the broadcast, is run in the context of the stream item 86 | "link": TextContentSelector[], 87 | // If set, we go to the link(s) previously found with a referer header 88 | "referer": string, 89 | // If set, we click on the items found (play button for instance) 90 | "clickButton": Selector[], 91 | } 92 | } 93 | ``` 94 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2024, Brendan Goubin 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 22 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 23 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | - [x] Configuration to activate / deactivate the Discord Bot 4 | - [x] Configuration to activate / deactivate publishers 5 | - [x] Discord bot commands to activate / deactivate publishers 6 | - [x] Configuration to activate / deactivate releasers 7 | - [x] Discord bot commands to activate / deactivate releasers 8 | - [x] Improve doc with screenshots 9 | - [ ] Better doc for the configuration 10 | - [x] Use Prettier for code formatting 11 | - [x] Script for versioning & automated changelog generation with the releases 12 | - [x] Create docker image that does not mount the src folder 13 | - [x] Implement a Gotify publisher 14 | - [ ] Unit tests 15 | - [x] Tester that uses 0s delays 16 | - [x] Implement an optional authentication for Indexers and Interceptors 17 | - [ ] Implement a generic engine for Puppeteer, and review the indexers and interceptors to use it 18 | - [ ] Option to reload a stream when proxying has been requested 19 | - [ ] Working API for TheSportsDB 20 | - [ ] Refactor the publishers configuration to store everything in the database 21 | - [ ] Refactor the releasers configuration to store everything in the database 22 | - [ ] Use node in start script 23 | - [ ] Implement a Plex releaser 24 | - [ ] Implement a Emby releaser 25 | - [ ] Ensure Jellyfin collection does not need to be created manually 26 | -------------------------------------------------------------------------------- /assets/Football.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Billos/Broadcastarr/31c27dd5ad174d2715e4942469458b34b2749abd/assets/Football.png -------------------------------------------------------------------------------- /assets/Formule 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Billos/Broadcastarr/31c27dd5ad174d2715e4942469458b34b2749abd/assets/Formule 1.png -------------------------------------------------------------------------------- /assets/Moto.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Billos/Broadcastarr/31c27dd5ad174d2715e4942469458b34b2749abd/assets/Moto.png -------------------------------------------------------------------------------- /assets/Rugby.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Billos/Broadcastarr/31c27dd5ad174d2715e4942469458b34b2749abd/assets/Rugby.png -------------------------------------------------------------------------------- /assets/TVChannels.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Billos/Broadcastarr/31c27dd5ad174d2715e4942469458b34b2749abd/assets/TVChannels.png -------------------------------------------------------------------------------- /assets/Tennis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Billos/Broadcastarr/31c27dd5ad174d2715e4942469458b34b2749abd/assets/Tennis.png -------------------------------------------------------------------------------- /docker-compose.bootstrap.dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | bootstrap: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | command: "dev:bootstrap" 7 | env_file: 8 | - .env 9 | - .env.bootstrap 10 | environment: 11 | NODE_ENV: development 12 | LOG_LEVEL: debug 13 | LOG_DATA: true 14 | MONGO_URL: mongodb://mongo 15 | MONGO_AGENDA_DB: agenda 16 | MONGO_DB: broadcastarr 17 | DATA_FOLDER: /app/data 18 | deploy: 19 | resources: 20 | limits: 21 | memory: 4g 22 | restart: unless-stopped 23 | volumes: 24 | - ./:/app 25 | depends_on: 26 | mongo: 27 | condition: service_healthy 28 | 29 | mongo: 30 | image: docker.io/library/mongo:7.0 31 | container_name: mongo 32 | restart: unless-stopped 33 | volumes: 34 | - broadcastarr_data:/data/db 35 | healthcheck: 36 | test: echo 'db.runCommand("ping").ok' | mongosh mongo/test --quiet 37 | interval: 5s 38 | timeout: 5s 39 | retries: 5 40 | 41 | volumes: 42 | broadcastarr_data: 43 | external: true 44 | -------------------------------------------------------------------------------- /docker-compose.bootstrap.yml: -------------------------------------------------------------------------------- 1 | services: 2 | bootstrap: 3 | image: docker.io/billos/broadcastarr:1.7.0 4 | command: "start:bootstrap" 5 | env_file: 6 | - .env 7 | - .env.bootstrap 8 | environment: 9 | NODE_ENV: development 10 | LOG_LEVEL: debug 11 | LOG_DATA: true 12 | MONGO_URL: mongodb://mongo 13 | MONGO_AGENDA_DB: agenda 14 | MONGO_DB: broadcastarr 15 | DATA_FOLDER: /data 16 | deploy: 17 | resources: 18 | limits: 19 | memory: 4g 20 | restart: unless-stopped 21 | volumes: 22 | - ./data:/data 23 | depends_on: 24 | mongo: 25 | condition: service_healthy 26 | 27 | mongo: 28 | image: docker.io/library/mongo:7.0 29 | container_name: mongo 30 | restart: unless-stopped 31 | volumes: 32 | - broadcastarr_data:/data/db 33 | healthcheck: 34 | test: echo 'db.runCommand("ping").ok' | mongosh mongo/test --quiet 35 | interval: 5s 36 | timeout: 5s 37 | retries: 5 38 | 39 | volumes: 40 | broadcastarr_data: 41 | external: true 42 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | server: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | container_name: server 7 | command: "dev:server" 8 | volumes: 9 | - .:/app 10 | - ${M3U8_FOLDER}:${M3U8_FOLDER}:rw 11 | - ${IMAGES_FOLDER}:${IMAGES_FOLDER}:rw 12 | env_file: 13 | - .env 14 | - .env.dev 15 | environment: 16 | NODE_ENV: development 17 | LOG_LEVEL: debug 18 | LOG_DATA: true 19 | MONGO_URL: mongodb://mongo 20 | MONGO_AGENDA_DB: agenda 21 | MONGO_DB: broadcastarr 22 | DEV_INDEXER: Streamed 23 | DEV_CATEGORY: Football 24 | deploy: 25 | resources: 26 | limits: 27 | memory: 4g 28 | restart: unless-stopped 29 | depends_on: 30 | mongo: 31 | condition: service_healthy 32 | 33 | worker: 34 | extends: 35 | service: server 36 | container_name: worker 37 | command: "dev:worker" 38 | 39 | mongo: 40 | image: docker.io/library/mongo:7.0 41 | container_name: mongo 42 | restart: unless-stopped 43 | volumes: 44 | - broadcastarr_data:/data/db 45 | healthcheck: 46 | test: echo 'db.runCommand("ping").ok' | mongosh mongo/test --quiet 47 | interval: 5s 48 | timeout: 5s 49 | retries: 5 50 | 51 | volumes: 52 | broadcastarr_data: 53 | external: true 54 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | server: 3 | image: docker.io/billos/broadcastarr:1.7.0 4 | command: "start:server" 5 | volumes: 6 | - ${M3U8_FOLDER}:${M3U8_FOLDER}:rw 7 | - ${IMAGES_FOLDER}:${IMAGES_FOLDER}:rw 8 | env_file: 9 | - .env 10 | environment: 11 | NODE_ENV: development 12 | LOG_LEVEL: debug 13 | LOG_DATA: true 14 | MONGO_URL: mongodb://mongo 15 | MONGO_AGENDA_DB: agenda 16 | MONGO_DB: broadcastarr 17 | deploy: 18 | resources: 19 | limits: 20 | memory: 4g 21 | restart: unless-stopped 22 | depends_on: 23 | mongo: 24 | condition: service_healthy 25 | 26 | worker: 27 | extends: 28 | service: server 29 | container_name: worker 30 | command: "start:worker" 31 | 32 | mongo: 33 | image: docker.io/library/mongo:7.0 34 | container_name: mongo 35 | restart: unless-stopped 36 | volumes: 37 | - broadcastarr_data:/data/db 38 | healthcheck: 39 | test: echo 'db.runCommand("ping").ok' | mongosh mongo/test --quiet 40 | interval: 5s 41 | timeout: 5s 42 | retries: 5 43 | 44 | volumes: 45 | broadcastarr_data: 46 | external: true 47 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import stylisticJs from "@stylistic/eslint-plugin-js" 2 | import stylisticTs from "@stylistic/eslint-plugin-ts" 3 | import typescriptPlugin from "@typescript-eslint/eslint-plugin" 4 | import parser from "@typescript-eslint/parser" 5 | 6 | export default [ 7 | { 8 | languageOptions: { 9 | parser, 10 | parserOptions: { 11 | ecmaVersion: 2022, 12 | project: "./tsconfig.json", 13 | }, 14 | }, 15 | files: ["**/*.ts"], 16 | plugins: { 17 | "@typescript-eslint": typescriptPlugin, 18 | "@stylistic/js": stylisticJs, 19 | "@stylistic/ts": stylisticTs, 20 | }, 21 | settings: { 22 | "import/resolver": { 23 | node: { 24 | extensions: [ 25 | ".js", 26 | ".jsx", 27 | ".ts", 28 | ".tsx", 29 | ], 30 | }, 31 | }, 32 | }, 33 | rules: { 34 | "arrow-body-style": ["error", "as-needed"], 35 | "prefer-destructuring": [ 36 | "error", 37 | { 38 | array: true, 39 | object: true, 40 | }, 41 | {}, 42 | ], 43 | "prefer-template": "error", 44 | "no-useless-concat": "error", 45 | "@stylistic/js/quotes": ["error", "double"], 46 | "consistent-return": "off", 47 | "no-param-reassign": [ 48 | "error", 49 | { 50 | props: true, 51 | ignorePropertyModificationsForRegex: [".*"], 52 | }, 53 | ], 54 | "@stylistic/js/lines-between-class-members": ["error", "always"], 55 | "@stylistic/js/padding-line-between-statements": [ 56 | "error", 57 | { 58 | blankLine: "always", 59 | prev: [ 60 | "export", 61 | "class", 62 | "default", 63 | "function", 64 | ], 65 | next: [ 66 | "export", 67 | "class", 68 | "default", 69 | "function", 70 | ], 71 | }, 72 | ], 73 | // Linebreaks 74 | "no-underscore-dangle": [ 75 | "error", 76 | { 77 | allowAfterThis: true, 78 | allow: ["_id"], 79 | }, 80 | ], 81 | }, 82 | }, 83 | ] 84 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "broadcastarr", 3 | "version": "1.7.0", 4 | "private": true, 5 | "description": "", 6 | "homepage": "https://github.com/Billos/Broadcastarr#readme", 7 | "bugs": { 8 | "url": "https://github.com/Billos/Broadcastarr/issues" 9 | }, 10 | "repository": "git@github.com:Billos/Broadcastarr.git", 11 | "license": "BSD-2-Clause", 12 | "author": "Billos", 13 | "main": "./src/server.ts", 14 | "scripts": { 15 | "bootstrap": "yarn start src/bootstrap.js", 16 | "build": "tsc -p tsconfig.json", 17 | "dev": "tsx watch --clear-screen", 18 | "dev:bootstrap": "yarn dev src/bootstrap.ts", 19 | "dev:server": "yarn dev src/server.ts", 20 | "dev:worker": "sleep 1; yarn dev src/worker.ts", 21 | "docker:build": "docker build . -t billos/broadcastarr:latest", 22 | "format": "prettier . --write", 23 | "format-check": "prettier . --check", 24 | "lint": "eslint src", 25 | "release": "semantic-release", 26 | "start:server": "node build/server.js", 27 | "start:worker": "sleep 1; node build/worker.js", 28 | "start:bootstrap": "node build/bootstrap.js", 29 | "type-check": "tsc --noEmit" 30 | }, 31 | "dependencies": { 32 | "@hokify/agenda": "^6.3.0", 33 | "axios": "^1.7.8", 34 | "discord.js": "^14.16.3", 35 | "express": "^4.18.2", 36 | "express-http-proxy": "^2.1.1", 37 | "json5": "^2.2.3", 38 | "lodash": "^4.17.21", 39 | "luxon": "^3.3.0", 40 | "mongoose": "^8.8.3", 41 | "node-cache": "^5.1.2", 42 | "nunjucks": "^3.2.4", 43 | "puppeteer-core": "^24.2.1", 44 | "showdown": "^2.1.0", 45 | "uuid": "^11.0.3", 46 | "winston": "^3.17.0" 47 | }, 48 | "devDependencies": { 49 | "@codedependant/semantic-release-docker": "^5.0.3", 50 | "@ianvs/prettier-plugin-sort-imports": "^4.4.0", 51 | "@semantic-release/changelog": "^6.0.3", 52 | "@semantic-release/exec": "^6.0.3", 53 | "@semantic-release/git": "^10.0.1", 54 | "@stylistic/eslint-plugin-js": "^2.11.0", 55 | "@stylistic/eslint-plugin-ts": "^2.11.0", 56 | "@types/express": "^4.17.17", 57 | "@types/express-http-proxy": "^1.6.3", 58 | "@types/lodash": "^4.17.13", 59 | "@types/luxon": "^3.3.0", 60 | "@types/nunjucks": "^3.2.6", 61 | "@types/showdown": "^2.0.6", 62 | "@types/uuid": "^10.0.0", 63 | "@typescript-eslint/eslint-plugin": "^8.16.0", 64 | "@typescript-eslint/parser": "^8.16.0", 65 | "eslint": "^9.15.0", 66 | "prettier": "^3.4.1", 67 | "prettier-plugin-multiline-arrays": "^3.0.6", 68 | "prettier-plugin-organize-imports": "^4.1.0", 69 | "prettier-plugin-packagejson": "^2.5.6", 70 | "semantic-release": "^24.2.0", 71 | "tsx": "^4.19.2", 72 | "typescript": "^5.7.2" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /screenshots/BroadcastLinkJellyfin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Billos/Broadcastarr/31c27dd5ad174d2715e4942469458b34b2749abd/screenshots/BroadcastLinkJellyfin.png -------------------------------------------------------------------------------- /screenshots/CategoryPublishDiscord.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Billos/Broadcastarr/31c27dd5ad174d2715e4942469458b34b2749abd/screenshots/CategoryPublishDiscord.png -------------------------------------------------------------------------------- /screenshots/CategoryPublishMatrix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Billos/Broadcastarr/31c27dd5ad174d2715e4942469458b34b2749abd/screenshots/CategoryPublishMatrix.png -------------------------------------------------------------------------------- /screenshots/CollectionViewJellyfin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Billos/Broadcastarr/31c27dd5ad174d2715e4942469458b34b2749abd/screenshots/CollectionViewJellyfin.png -------------------------------------------------------------------------------- /scripts/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | service cron start 3 | 4 | # The command set in docker compose 5 | yarn run $1 -------------------------------------------------------------------------------- /scripts/gateway.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Function to get the container IP address using ping 4 | get_container_ip() { 5 | container_name=$1 6 | ip=$(dig +short $container_name) 7 | echo $ip 8 | } 9 | 10 | # Function to delete the default gateway and set a new one 11 | set_default_gateway() { 12 | new_gateway=$1 13 | 14 | # Check if the new gateway is valid 15 | if [ -z "$new_gateway" ]; then 16 | echo "No IP address found for container. Exiting..." 17 | exit 1 18 | fi 19 | 20 | # Delete the current default gateway 21 | echo "Deleting current default gateway..." 22 | ip route del default 23 | 24 | # Set the new default gateway 25 | echo "Setting new default gateway to $new_gateway..." 26 | ip route add default via $new_gateway 27 | 28 | echo "Default gateway updated to $new_gateway" 29 | } 30 | 31 | # Main script execution 32 | 33 | container_name="wireguard" # Replace with your container name 34 | container_ip=$(get_container_ip $container_name) 35 | 36 | if [ -n "$container_ip" ]; then 37 | echo "Found IP address for $container_name: $container_ip" 38 | set_default_gateway $container_ip 39 | else 40 | echo "Failed to get IP address for container $container_name." 41 | fi -------------------------------------------------------------------------------- /src/api/gotify.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | 3 | import env from "../config/env" 4 | import mainLogger from "../utils/logger" 5 | 6 | export type Application = { 7 | id: number 8 | token: string 9 | name: string 10 | description: string 11 | internal: boolean 12 | image: string 13 | defaultPriority?: number 14 | lastUsed?: string 15 | } 16 | 17 | type ApplicationParams = { 18 | name: string 19 | description?: string 20 | defaultPriority?: number 21 | } 22 | 23 | type Message = { 24 | id: number 25 | appid: number 26 | message: string 27 | date: string 28 | extras?: Record 29 | priority?: number 30 | title?: string 31 | } 32 | 33 | type MessageParams = { 34 | message: string 35 | priority?: number 36 | title?: string 37 | extras?: Record 38 | } 39 | 40 | const instance = axios.create({ 41 | baseURL: env.publishers.gotify.url, 42 | headers: { "X-Gotify-Key": env.publishers.gotify.token }, 43 | }) 44 | 45 | async function getApplications(): Promise { 46 | const logger = mainLogger.child({ name: "Gotify", func: "getApplications" }) 47 | logger.info("Getting applications") 48 | const { data } = await instance.get("/application") 49 | return data 50 | } 51 | 52 | async function getApplication(name: string): Promise { 53 | const logger = mainLogger.child({ name: "Gotify", func: "getApplication" }) 54 | logger.info(`Getting application ${name}`) 55 | const applications = await getApplications() 56 | return applications.find((app) => app.name === name) 57 | } 58 | 59 | async function createApplication(params: ApplicationParams): Promise { 60 | const logger = mainLogger.child({ name: "Gotify", func: "createApplication" }) 61 | logger.info(`Creating application ${params.name}`) 62 | const { data } = await instance.post("/application", params) 63 | return data 64 | } 65 | 66 | async function deleteApplication(appId: number): Promise { 67 | const logger = mainLogger.child({ name: "Gotify", func: "deleteApplication" }) 68 | logger.info(`Deleting application ${appId}`) 69 | await instance.delete(`/application/${appId}`) 70 | } 71 | 72 | async function createMessage(application: Application, params: MessageParams): Promise { 73 | const logger = mainLogger.child({ name: "Gotify", func: "createMessage" }) 74 | logger.info(`Creating message for application ${application.name}`) 75 | const { data } = await instance.post(`/message?token=${application.token}`, params) 76 | return data 77 | } 78 | 79 | async function deleteMessage(messageId: string): Promise { 80 | const logger = mainLogger.child({ name: "Gotify", func: "deleteMessage" }) 81 | logger.info(`Deleting message ${messageId}`) 82 | try { 83 | await instance.delete(`/message/${messageId}`) 84 | } catch (error) { 85 | logger.warn(`Failed to delete message ${messageId}. Error: ${error}`) 86 | } 87 | } 88 | 89 | async function getMessagesOfApplication(appId: number): Promise { 90 | const logger = mainLogger.child({ name: "Gotify", func: "getMessagesOfApplication" }) 91 | logger.info(`Getting messages of application ${appId}`) 92 | const res = await instance.get<{ messages: Message[] }>(`/application/${appId}/message`) 93 | return res.data.messages 94 | } 95 | 96 | const GotifyAPI = { 97 | getApplications, 98 | getApplication, 99 | createApplication, 100 | deleteApplication, 101 | createMessage, 102 | deleteMessage, 103 | getMessagesOfApplication, 104 | } 105 | 106 | export { GotifyAPI } 107 | -------------------------------------------------------------------------------- /src/api/theSportsDB.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path" 2 | 3 | import axios from "axios" 4 | 5 | import env from "../config/env" 6 | 7 | const instance = axios.create({ 8 | baseURL: join(env.theSportsDb.url, env.theSportsDb.apiKey), 9 | }) 10 | 11 | type Event = { 12 | strEvent: string 13 | strHomeTeam: string 14 | strAwayTeam: string 15 | strLeague: string 16 | } 17 | 18 | type EventQuery = { 19 | event: Event[] 20 | } 21 | 22 | export async function searchGame(teamA: string, teamB: string): Promise { 23 | // First we will retrieve the events for the first team 24 | const { 25 | data: { event: events }, 26 | status, 27 | } = await instance.get(`/searchevents.php?e=${encodeURIComponent(teamA)}`) 28 | if (status === 200 && events !== null && events?.length >= 0) { 29 | // If we find an event with the second team, we return it 30 | // The teamB may include FC, SC, AJ, RC, OGC... We want a regex that will check if strAwayTeam includes teamB 31 | const event = events.find((game) => game.strAwayTeam === teamB) 32 | if (event) { 33 | return event 34 | } 35 | } 36 | 37 | // If we don't find it, we will retrieve the events for the second team 38 | const { 39 | data: { event: events2 }, 40 | status: status2, 41 | } = await instance.get(`/searchevents.php?e=${encodeURIComponent(teamB)}`) 42 | if (status2 === 200 && events !== null && events2?.length >= 0) { 43 | // If we find an event with the first team, we return it 44 | const event = events2.find((game) => game.strAwayTeam === teamA) 45 | if (event) { 46 | return event 47 | } 48 | } 49 | 50 | // If we don't find it, we return null 51 | return null 52 | } 53 | -------------------------------------------------------------------------------- /src/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose" 2 | 3 | import { bootstrappers } from "./bootstrappers" 4 | import env from "./config/env" 5 | import mainLogger from "./utils/logger" 6 | 7 | // Print the node version 8 | mainLogger.info(`Node version: ${process.version}`) 9 | 10 | // Worker 11 | async function bootstrap() { 12 | const logger = mainLogger.child({ name: "Server", func: "bootstrap" }) 13 | const mongo = await mongoose.connect(`${env.mongo.url}/${env.mongo.db}`, {}) 14 | logger.info(`Mongo is up on ${mongo.connection.host}:${mongo.connection.port}`) 15 | logger.info("Running bootstrappers") 16 | for (const bootstrapper of bootstrappers) { 17 | await bootstrapper.bootstrap() 18 | } 19 | 20 | logger.info("Bootstrapping done") 21 | } 22 | 23 | bootstrap() 24 | -------------------------------------------------------------------------------- /src/bootstrappers/bootstrapper.ts: -------------------------------------------------------------------------------- 1 | export abstract class Bootstrapper { 2 | abstract bootstrap(): Promise 3 | } 4 | -------------------------------------------------------------------------------- /src/bootstrappers/config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigController } from "../modules/config" 2 | import mainLogger from "../utils/logger" 3 | import { Bootstrapper } from "./bootstrapper" 4 | 5 | export class ConfigBootstrapper extends Bootstrapper { 6 | public async bootstrap(): Promise { 7 | const logger = mainLogger.child({ name: "ConfigBootstrapper", func: "bootstrap" }) 8 | 9 | logger.info("Bootstrapping config for Discord webhooks") 10 | // discordWebhooks will be set as "CategoryA:id:token,CategoryB:id:token" 11 | const raw = process.env.DISCORD_WEBHOOKS 12 | const discordWebhooks = (raw ? raw.split(",") : []).filter((webhook) => webhook.trim() !== "") 13 | 14 | for (const webhook of discordWebhooks) { 15 | const [ 16 | key, 17 | id, 18 | token, 19 | ] = webhook.trim().split(":") 20 | try { 21 | logger.info(`Setting config for Category ${key}`) 22 | await ConfigController.setConfig(`discord-webhook-${key}-id`, id) 23 | await ConfigController.setConfig(`discord-webhook-${key}-token`, token) 24 | } catch (error) { 25 | logger.warn(`Error while setting config ${key}`) 26 | } 27 | } 28 | 29 | // Reading all the delays 30 | logger.info("Bootstrapping delays") 31 | const allDelays: Record> = { 32 | regular: { 33 | IndexCategory: process.env.DELAY_REGULAR_INDEX_CATEGORY, 34 | }, 35 | retry: { 36 | IndexCategory: process.env.DELAY_RETRY_INDEX_CATEGORY, 37 | GrabBroadcastStream: process.env.DELAY_RETRY_GRAB_BROADCAST_STREAM, 38 | UpdateCategoryChannelName: process.env.DELAY_RETRY_UPDATE_CATEGORY_CHANNEL_NAME, 39 | }, 40 | simple: { 41 | GrabStream: process.env.DELAY_GRAB_STREAM, 42 | JellyfinTvRefresh: process.env.DELAY_JELLYFIN_LIVETV_REFRESH, 43 | PublishGroup: process.env.DELAY_PUBLISH_GROUP, 44 | UpdateCategoryChannelName: process.env.DELAY_UPDATE_CATEGORY_CHANNEL_NAME, 45 | RenewStream: process.env.DELAY_RENEW_STREAM, 46 | IndexCategory: process.env.DELAY_SIMPLE_INDEX_CATEGORY, 47 | }, 48 | } 49 | 50 | for (const [delayType, delays] of Object.entries(allDelays)) { 51 | logger.info(`Setting config for delay ${delayType}`) 52 | for (const [key, value] of Object.entries(delays)) { 53 | try { 54 | logger.info(`Setting config for ${delayType}-${key} with value ${value}`) 55 | await ConfigController.setConfig(`delay-${delayType}-${key}`, value) 56 | 57 | const valueSet = await ConfigController.getConfig(`delay-${delayType}-${key}`) 58 | logger.info(`Value set for ${delayType}-${key}: ${valueSet.value}`) 59 | } catch (error) { 60 | logger.warn(`Error while setting config ${delayType}-${key}`) 61 | } 62 | } 63 | } 64 | 65 | // Reading all the limits 66 | logger.info("Bootstrapping limits") 67 | await ConfigController.setConfig("filter-limit-future", process.env.FUTURE_LIMIT) 68 | await ConfigController.setConfig("filter-limit-past", process.env.PAST_LIMIT) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/bootstrappers/emoji.ts: -------------------------------------------------------------------------------- 1 | import { CategoryController } from "../modules/category" 2 | import { GroupController } from "../modules/group" 3 | import getGroupEmoji from "../utils/getEmoji" 4 | import mainLogger from "../utils/logger" 5 | import { Bootstrapper } from "./bootstrapper" 6 | 7 | export class EmojiBootstrapper extends Bootstrapper { 8 | public async bootstrap(): Promise { 9 | const logger = mainLogger.child({ name: "EmojiBootstrapper", func: "bootstrap" }) 10 | logger.info("Bootstrapping emojis for categories") 11 | // Emojis are stored in process.env.CATEGORIES_EMOJIS as CategoryA:emoji,CategoryB:emoji... 12 | const emojis = process.env.CATEGORIES_EMOJIS.split(",") 13 | for (const item of emojis) { 14 | const [category, emoji] = item.split(":") 15 | try { 16 | logger.info(`Setting emoji for category ${category}`) 17 | await CategoryController.setEmoji(category, emoji) 18 | } catch (error) { 19 | logger.warn(`Error while setting emoji for category ${category}`) 20 | } 21 | } 22 | 23 | logger.info("Bootstrapping emojis for groups") 24 | const groups = await GroupController.getAllGroups() 25 | logger.info(`Found ${groups.length} groups`) 26 | for (const group of groups) { 27 | const emoji = getGroupEmoji(group.name) 28 | // Update the emoji if it's different 29 | if (emoji) { 30 | logger.info(`Setting emoji for ${group.name}`) 31 | await GroupController.setEmoji(group, emoji) 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/bootstrappers/groups.ts: -------------------------------------------------------------------------------- 1 | import { CategoryController } from "../modules/category" 2 | import { GroupController } from "../modules/group" 3 | import mainLogger from "../utils/logger" 4 | import { Bootstrapper } from "./bootstrapper" 5 | 6 | type Group = { 7 | country?: string 8 | name: string 9 | } 10 | 11 | export class GroupsBootstrapper extends Bootstrapper { 12 | public async bootstrap(): Promise { 13 | const logger = mainLogger.child({ name: "GroupsBootstrapper", func: "bootstrap" }) 14 | logger.info("Bootstrapping groups") 15 | // group will be set as "CategoryA:groupA,countryB*groupB,groupC|CategoryB:countryD*groupD,groupE|CategoryA:groupF" 16 | const envGroups: Record> = {} 17 | // const envGroups: Record> = {} 18 | if (!process.env.GROUPS) { 19 | logger.info("No groups to bootstrap") 20 | return 21 | } 22 | for (const categoryStr of process.env.GROUPS.split("|")) { 23 | const [category, groupsStr] = categoryStr.split(":") 24 | 25 | const elements = groupsStr 26 | .split(",") 27 | .map((element) => element.trim()) 28 | .filter((element) => element.length > 0) 29 | if (!envGroups[category]) { 30 | envGroups[category] = new Set() 31 | } 32 | 33 | for (const element of elements) { 34 | if (element.includes("*")) { 35 | const [country, name] = element.split("*") 36 | envGroups[category].add({ country, name }) 37 | } else { 38 | envGroups[category].add({ name: element }) 39 | } 40 | } 41 | } 42 | 43 | logger.info("Creating categories and groups") 44 | // Once the groups are parsed, we create them in the db if they don't already exist 45 | for (const [category, groups] of Object.entries(envGroups) as [string, Set][]) { 46 | const groupNames = Array.from(groups).map((group) => group.name) 47 | logger.info(`Creating category ${category} with groups ${groupNames.join(", ")}`) 48 | // Assert that category exists 49 | try { 50 | await CategoryController.getCategory(category) 51 | logger.info(`Category ${category} already exists`) 52 | } catch (error) { 53 | logger.info(`Creating category ${category}`) 54 | await CategoryController.createCategory(category) 55 | } 56 | for (const { name, country } of groups) { 57 | try { 58 | await GroupController.createGroup({ name, category, country }, true) 59 | logger.info(`Group ${name} in category ${category} created`) 60 | } catch (error) { 61 | // Nothing to do, the group already exists 62 | logger.info(`Group ${name} in category ${category} already exists, setting it as active if it was not`) 63 | await GroupController.updateActive({ name, category, country }, true) 64 | } 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/bootstrappers/index.ts: -------------------------------------------------------------------------------- 1 | import { Bootstrapper } from "./bootstrapper" 2 | import { ConfigBootstrapper } from "./config" 3 | import { EmojiBootstrapper } from "./emoji" 4 | import { GroupsBootstrapper } from "./groups" 5 | import { IndexersBootstrapper } from "./indexers" 6 | import { PublishersBootstrapper } from "./publishers" 7 | import { ReleasersBootstrapper } from "./releasers" 8 | import { RolesBootstrapper } from "./roles" 9 | 10 | const bootstrappers = [ 11 | new IndexersBootstrapper(), 12 | new ConfigBootstrapper(), 13 | new GroupsBootstrapper(), 14 | new EmojiBootstrapper(), 15 | new ReleasersBootstrapper(), 16 | new PublishersBootstrapper(), 17 | new RolesBootstrapper(), 18 | ] as Bootstrapper[] 19 | 20 | export { bootstrappers } 21 | -------------------------------------------------------------------------------- /src/bootstrappers/indexers.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync, readFileSync } from "fs" 2 | import { join } from "path" 3 | 4 | import JSON5 from "json5" 5 | 6 | import { IndexerController, IndexerDocument } from "../modules/indexer" 7 | import mainLogger from "../utils/logger" 8 | import { Bootstrapper } from "./bootstrapper" 9 | 10 | export class IndexersBootstrapper extends Bootstrapper { 11 | public async bootstrap(): Promise { 12 | const logger = mainLogger.child({ name: "IndexersBootstrapper", func: "bootstrap" }) 13 | logger.info("Bootstrapping indexers") 14 | 15 | const folder = process.env.DATA_FOLDER 16 | const files = await readdirSync(folder) 17 | for (const file of files) { 18 | try { 19 | const filePath = join(folder, file) 20 | const raw = await readFileSync(filePath, "utf-8") 21 | const data = await this.generateData(raw) 22 | try { 23 | await IndexerController.getIndexer(data.name) 24 | logger.info(`Indexer ${data.name} already exists`) 25 | } catch (error) { 26 | // If the indexer does not exist, we create it 27 | logger.info(`Creating indexer ${data.name}`) 28 | await IndexerController.createIndexer(data.name, data.url) 29 | } 30 | await IndexerController.getIndexer(data.name) 31 | logger.info(`Updating indexer ${data.name}`) 32 | await IndexerController.updateLogin(data.name, data.login) 33 | await IndexerController.updateActive(data.name, data.active) 34 | await IndexerController.updateIndexerData(data.name, data.data) 35 | await IndexerController.updateIndexerInterceptorData(data.name, data.interceptorData) 36 | logger.info(`Validating indexer ${data.name}`) 37 | await IndexerController.validateIndexer(data.name) 38 | 39 | if (data.scenarios) { 40 | await IndexerController.updateScenarios(data.name, data.scenarios) 41 | } 42 | } catch (error) { 43 | logger.warn(`Error while processing file ${file} - ${error.message}`) 44 | } 45 | } 46 | } 47 | 48 | private async generateData(raw: string): Promise { 49 | const logger = mainLogger.child({ name: "IndexersBootstrapper", func: "generateData" }) 50 | logger.info("Generating data for indexer") 51 | const data = JSON5.parse(raw) as IndexerDocument 52 | return { 53 | ...data, 54 | active: data.active || false, 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/bootstrappers/publishers.ts: -------------------------------------------------------------------------------- 1 | import { PublishersController } from "../modules/publishers" 2 | import mainLogger from "../utils/logger" 3 | import { Bootstrapper } from "./bootstrapper" 4 | 5 | export class PublishersBootstrapper extends Bootstrapper { 6 | public async bootstrap(): Promise { 7 | const logger = mainLogger.child({ name: "PublishersBootstrapper", func: "bootstrap" }) 8 | 9 | logger.info("Creating Publishers") 10 | // check CREATE_PUBLISHER_DISCORD and CREATE_PUBLISHER_MATRIX 11 | await PublishersController.deletePublisher("Discord") 12 | await PublishersController.createPublisher("Discord", process.env.CREATE_PUBLISHER_DISCORD === "true") 13 | // await PublishersController.deletePublisher("Matrix") 14 | // await PublishersController.createPublisher("Matrix", process.env.CREATE_PUBLISHER_MATRIX === "true") 15 | await PublishersController.deletePublisher("Gotify") 16 | await PublishersController.createPublisher("Gotify", process.env.CREATE_PUBLISHER_GOTIFY === "true") 17 | 18 | logger.info("Bootstrapping Publishers") 19 | await PublishersController.bootstrapPublishers() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/bootstrappers/releasers.ts: -------------------------------------------------------------------------------- 1 | import { ReleasersController } from "../modules/releasers" 2 | import mainLogger from "../utils/logger" 3 | import { Bootstrapper } from "./bootstrapper" 4 | 5 | export class ReleasersBootstrapper extends Bootstrapper { 6 | public async bootstrap(): Promise { 7 | const logger = mainLogger.child({ name: "ReleasersBootstrapper", func: "bootstrap" }) 8 | logger.info("Bootstrapping Releasers") 9 | 10 | await ReleasersController.deleteReleaser("Jellyfin") 11 | await ReleasersController.createReleaser("Jellyfin", process.env.CREATE_RELEASER_JELLYFIN === "true") 12 | 13 | await ReleasersController.bootstrapReleasers() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/bootstrappers/roles.ts: -------------------------------------------------------------------------------- 1 | import { commandGenerators } from "../bot/commands" 2 | import { RoleController } from "../modules/role" 3 | import mainLogger from "../utils/logger" 4 | import { Bootstrapper } from "./bootstrapper" 5 | 6 | export class RolesBootstrapper extends Bootstrapper { 7 | public async bootstrap(): Promise { 8 | const logger = mainLogger.child({ name: "RolesBootstrapper", func: "bootstrap" }) 9 | 10 | // Mandatory roles 11 | const roles = [ 12 | "admin", 13 | "moderator", 14 | "user", 15 | ] 16 | logger.info("Bootstrapping Roles") 17 | 18 | for (const role of roles) { 19 | try { 20 | logger.info(`Asserting that the role ${role} exists`) 21 | await RoleController.getRole(role) 22 | } catch (error) { 23 | logger.warn(`Role ${role} does not exist, creating it`) 24 | await RoleController.createRole(role, []) 25 | } 26 | for (const generator of commandGenerators) { 27 | const cmd = await generator.generate() 28 | if (cmd.roles.includes(role)) { 29 | logger.info(`Adding command ${cmd.data.name} to role ${role}`) 30 | await RoleController.addAbilities(role, [cmd.data.name]) 31 | } 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/bot/commands.ts: -------------------------------------------------------------------------------- 1 | import mainLogger from "../utils/logger" 2 | import addCategory from "./commands/addCategory" 3 | import addRole from "./commands/addRole" 4 | import indexCategory from "./commands/indexCategory" 5 | import removeCategory from "./commands/removeCategory" 6 | import setCategoryEmoji from "./commands/setCategoryEmoji" 7 | import setConfig from "./commands/setConfig" 8 | import setGroupEmoji from "./commands/setGroupEmoji" 9 | import togglegroup from "./commands/togglegroup" 10 | import toggleindexer from "./commands/toggleindexer" 11 | import togglepublisher from "./commands/togglepublisher" 12 | import togglereleaser from "./commands/togglereleaser" 13 | import { Command, CommandGenerator, isCommand, isCommandGenerator } from "./type" 14 | 15 | const logger = mainLogger.child({ name: "Commands", func: "index" }) 16 | 17 | const cmds = [ 18 | addCategory, 19 | addRole, 20 | indexCategory, 21 | removeCategory, 22 | setCategoryEmoji, 23 | setConfig, 24 | setGroupEmoji, 25 | togglegroup, 26 | toggleindexer, 27 | togglepublisher, 28 | togglereleaser, 29 | ] 30 | 31 | const commands: Command[] = [] 32 | const commandGenerators: CommandGenerator[] = [] 33 | 34 | // Importing the classes 35 | for (const command of cmds) { 36 | try { 37 | if (isCommandGenerator(command)) { 38 | commandGenerators.push(command) 39 | // Check if the instance is a valid 40 | } else if (isCommand(command)) { 41 | // Check if the instance is a valid 42 | isCommand(command) 43 | commands.push(command) 44 | } 45 | } catch (error) { 46 | logger.error("Error importing Command", error) 47 | } 48 | } 49 | 50 | export { commands, commandGenerators } 51 | -------------------------------------------------------------------------------- /src/bot/commands/addCategory.ts: -------------------------------------------------------------------------------- 1 | import { CommandInteraction, InteractionResponse, SlashCommandBuilder } from "discord.js" 2 | 3 | import { Triggers } from "../../modules/agenda/triggers" 4 | import { CategoryController } from "../../modules/category" 5 | import { ConfigController } from "../../modules/config" 6 | import { Command, CommandGenerator } from "../type" 7 | 8 | async function execute(interaction: CommandInteraction): Promise { 9 | const name = interaction.options.get("name", true).value as string 10 | const id = interaction.options.get("id", true).value as string 11 | const token = interaction.options.get("token", true).value as string 12 | const emoji = interaction.options.get("emoji")?.value as string 13 | 14 | await CategoryController.createCategory(name) 15 | if (emoji) { 16 | await CategoryController.setEmoji(name, emoji) 17 | } 18 | 19 | await ConfigController.setConfig(`discord-webhook-${name}-id`, id) 20 | await ConfigController.setConfig(`discord-webhook-${name}-token`, token) 21 | 22 | await Triggers.publishCategory(name) 23 | 24 | return interaction.reply(`Category ${name} added`) 25 | } 26 | 27 | const commandGenerator: CommandGenerator = { 28 | generate: async (): Promise => { 29 | const data = new SlashCommandBuilder() 30 | .setName("addcategory") 31 | .addStringOption((option) => option.setName("name").setDescription("The category name").setRequired(true)) 32 | .addStringOption((option) => option.setName("id").setDescription("The discord channel id").setRequired(true)) 33 | .addStringOption((option) => 34 | option.setName("token").setDescription("The discord channel token").setRequired(true), 35 | ) 36 | .addStringOption((option) => option.setName("emoji").setDescription("The category emoji").setRequired(false)) 37 | .setDescription("Add a category") 38 | 39 | return { 40 | data, 41 | execute, 42 | roles: ["admin"], 43 | } 44 | }, 45 | } 46 | 47 | export default commandGenerator 48 | -------------------------------------------------------------------------------- /src/bot/commands/addRole.ts: -------------------------------------------------------------------------------- 1 | import { CommandInteraction, InteractionResponse, SlashCommandBuilder } from "discord.js" 2 | 3 | import { AuthController } from "../../modules/auth" 4 | import { RoleController } from "../../modules/role" 5 | import { Command, CommandGenerator } from "../type" 6 | 7 | async function execute(interaction: CommandInteraction): Promise { 8 | const user = interaction.options.get("user", false) 9 | const channel = interaction.options.get("channel", false) 10 | const role = interaction.options.get("role", true).value as string 11 | 12 | if (!user && !channel) { 13 | return interaction.reply("No user or channel provided") 14 | } 15 | 16 | if (user && channel) { 17 | return interaction.reply("Cannot add both user and channel") 18 | } 19 | 20 | let reply = "Adding entity" 21 | if (user) { 22 | try { 23 | await AuthController.getAuth(user.user.id) 24 | await AuthController.addRolesToAuth("user", user.user.id, [role]) 25 | reply += `\n - User \`${user.user.username}\` already exists` 26 | } catch (error) { 27 | await AuthController.createAuth("user", user.user.id, [role]) 28 | reply += `\n - User \`${user.user.username}\` added` 29 | } 30 | 31 | reply += `\n - Role ${role} added to user` 32 | } 33 | if (channel) { 34 | try { 35 | await AuthController.getAuth(channel.channel.id) 36 | await AuthController.addRolesToAuth("channel", channel.channel.id, [role]) 37 | reply += `\n - Channel \`${channel.channel.name}\` already exists` 38 | } catch (error) { 39 | await AuthController.createAuth("channel", channel.channel.id, [role]) 40 | reply += `\n - Channel \`${channel.channel.name}\` added` 41 | } 42 | 43 | reply += `\n - Role ${role} added to channel` 44 | } 45 | 46 | return interaction.reply(reply) 47 | } 48 | 49 | const commandGenerator: CommandGenerator = { 50 | generate: async (): Promise => { 51 | const roles = await RoleController.getRoles() 52 | const roleNames = roles.map(({ name }) => ({ name, value: name })) 53 | 54 | const data = new SlashCommandBuilder() 55 | .setName("addrole") 56 | .addStringOption((option) => 57 | option.setName("role").setRequired(true).addChoices(roleNames).setDescription("The role to add"), 58 | ) 59 | .addUserOption((option) => option.setName("user").setRequired(false).setDescription("The chosen user")) 60 | .addChannelOption((option) => option.setName("channel").setRequired(false).setDescription("The chosen channel")) 61 | .setDescription("Add a role to an entity") 62 | 63 | return { 64 | data, 65 | execute, 66 | roles: ["admin"], 67 | } 68 | }, 69 | } 70 | 71 | export default commandGenerator 72 | -------------------------------------------------------------------------------- /src/bot/commands/indexCategory.ts: -------------------------------------------------------------------------------- 1 | import { CommandInteraction, SlashCommandBuilder } from "discord.js" 2 | 3 | import { Triggers } from "../../modules/agenda/triggers" 4 | import { CategoryController } from "../../modules/category" 5 | import { IndexerController } from "../../modules/indexer" 6 | import mainLogger from "../../utils/logger" 7 | import { Command, CommandGenerator } from "../type" 8 | 9 | async function execute(interaction: CommandInteraction) { 10 | const logger = mainLogger.child({ name: "ListBroadcasts", func: "execute" }) 11 | logger.info("Executing list broadcasts command") 12 | const category = interaction.options.get("category", true).value as string 13 | 14 | const indexers = await IndexerController.getIndexers(true) 15 | for (const indexer of indexers) { 16 | await Triggers.indexCategory(category, indexer.name) 17 | } 18 | return interaction.reply(`Listing broadcasts for ${category} scheduled`) 19 | } 20 | 21 | const commandGenerator: CommandGenerator = { 22 | generate: async (): Promise => { 23 | const categories = await CategoryController.getCategories() 24 | const categoryChoices = categories.map(({ name }) => ({ name, value: name })) 25 | 26 | const data = new SlashCommandBuilder() 27 | .setName("indexcategory") 28 | .addStringOption((option) => 29 | option 30 | .setName("category") 31 | .setDescription("The category to index") 32 | .setRequired(true) 33 | .setChoices(categoryChoices), 34 | ) 35 | .setDescription("List all broadcasts of a category") 36 | 37 | return { 38 | data, 39 | execute, 40 | roles: [ 41 | "admin", 42 | "moderator", 43 | "user", 44 | ], 45 | } 46 | }, 47 | } 48 | 49 | export default commandGenerator 50 | -------------------------------------------------------------------------------- /src/bot/commands/removeCategory.ts: -------------------------------------------------------------------------------- 1 | import { CommandInteraction, ComponentType, SlashCommandBuilder } from "discord.js" 2 | 3 | import { CategoryController } from "../../modules/category" 4 | import mainLogger from "../../utils/logger" 5 | import confirmRow from "../components/confirm" 6 | import { Command, CommandGenerator } from "../type" 7 | 8 | async function execute(interaction: CommandInteraction) { 9 | const logger = mainLogger.child({ name: "RemoveGroup", func: "execute" }) 10 | logger.info("Executing remove group command") 11 | const category = interaction.options.get("category", true).value as string 12 | 13 | // Ask for a group to remove 14 | const confirmationInteraction = await interaction.reply({ 15 | content: `Are you sure you want to remove the category ${category}?`, 16 | components: [confirmRow], 17 | ephemeral: true, 18 | }) 19 | // Ask for confirmation 20 | const confirmationResponse = await confirmationInteraction.awaitMessageComponent({ 21 | componentType: ComponentType.Button, 22 | }) 23 | const confirmed = confirmationResponse.customId === "confirm_yes" 24 | 25 | // Remove the group 26 | if (confirmed) { 27 | await CategoryController.deleteCategory(category) 28 | return confirmationResponse.update({ content: `Category ${category} removed`, components: [] }) 29 | } 30 | return confirmationResponse.update({ content: "Category removal cancelled", components: [] }) 31 | } 32 | 33 | const commandGenerator: CommandGenerator = { 34 | generate: async (): Promise => { 35 | const categories = await CategoryController.getCategories() 36 | const categoryChoices = categories.map(({ name }) => ({ name, value: name })) 37 | 38 | const data = new SlashCommandBuilder() 39 | .setName("removecategory") 40 | .addStringOption((option) => 41 | option 42 | .setName("category") 43 | .setDescription("The category to remove") 44 | .setRequired(true) 45 | .setChoices(categoryChoices), 46 | ) 47 | .setDescription("Remove a category") 48 | 49 | return { 50 | data, 51 | execute, 52 | roles: ["admin"], 53 | } 54 | }, 55 | } 56 | 57 | export default commandGenerator 58 | -------------------------------------------------------------------------------- /src/bot/commands/setCategoryEmoji.ts: -------------------------------------------------------------------------------- 1 | import { CommandInteraction, SlashCommandBuilder } from "discord.js" 2 | 3 | import { Triggers } from "../../modules/agenda/triggers" 4 | import { CategoryController } from "../../modules/category" 5 | import mainLogger from "../../utils/logger" 6 | import { Command, CommandGenerator } from "../type" 7 | 8 | async function execute(interaction: CommandInteraction) { 9 | const logger = mainLogger.child({ name: "RemoveGroup", func: "execute" }) 10 | logger.info("Executing set emoji command") 11 | const category = interaction.options.get("category", true).value as string 12 | 13 | await interaction.reply({ 14 | content: `Which emoji do you want to set to the category ${category}?`, 15 | components: [], 16 | }) 17 | 18 | const collected = await interaction.channel.awaitMessages({ 19 | filter: (msg) => msg.author.id === interaction.user.id && msg.content.length > 0, 20 | max: 1, 21 | time: 30 * 1000, // Timeout in 30 seconds 22 | errors: ["time"], 23 | }) 24 | 25 | if (!collected || collected.size === 0) { 26 | return interaction.followUp({ content: "You did not provide an emoji in time!", ephemeral: true }) 27 | } 28 | 29 | const emoji = collected.first().content 30 | logger.info(`Emoji or text selected: ${emoji}`) 31 | 32 | await CategoryController.setEmoji(category, emoji) 33 | await Triggers.publishCategory(category) 34 | return interaction.followUp({ content: `Category ${category} emoji set to ${emoji}`, ephemeral: true }) 35 | } 36 | 37 | const commandGenerator: CommandGenerator = { 38 | generate: async (): Promise => { 39 | const categories = await CategoryController.getCategories() 40 | const categoryChoices = categories.map(({ name }) => ({ name, value: name })) 41 | 42 | const data = new SlashCommandBuilder() 43 | .setName("setcategoryemoji") 44 | .addStringOption((option) => 45 | option 46 | .setName("category") 47 | .setDescription("The category of the group") 48 | .setRequired(true) 49 | .setChoices(categoryChoices), 50 | ) 51 | .setDescription("Change the emoji of a group") 52 | 53 | return { 54 | data, 55 | execute, 56 | roles: ["admin", "moderator"], 57 | } 58 | }, 59 | } 60 | 61 | export default commandGenerator 62 | -------------------------------------------------------------------------------- /src/bot/commands/setConfig.ts: -------------------------------------------------------------------------------- 1 | import { CommandInteraction, ComponentType, SlashCommandBuilder } from "discord.js" 2 | 3 | import { ConfigController } from "../../modules/config" 4 | import confirmRow from "../components/confirm" 5 | import { Command, CommandGenerator } from "../type" 6 | 7 | async function execute(interaction: CommandInteraction) { 8 | const config = interaction.options.get("config", true).value as string 9 | const value = interaction.options.get("value", true).value as string 10 | const configDocument = await ConfigController.getConfig(config) 11 | 12 | const confirmationInteraction = await interaction.reply({ 13 | content: `Are you sure you want to update the config ${config} to ${value} (current value: ${configDocument.value})?`, 14 | components: [confirmRow], 15 | ephemeral: true, 16 | }) 17 | const confirmationResponse = await confirmationInteraction.awaitMessageComponent({ 18 | componentType: ComponentType.Button, 19 | }) 20 | const confirmed = confirmationResponse.customId === "confirm_yes" 21 | if (confirmed) { 22 | await ConfigController.setConfig(config, value) 23 | return confirmationResponse.update({ content: `Config ${config} updated to ${value}`, components: [] }) 24 | } 25 | return confirmationResponse.update({ content: "Config update cancelled", components: [] }) 26 | } 27 | 28 | const commandGenerator: CommandGenerator = { 29 | generate: async (): Promise => { 30 | const configs = await ConfigController.getConfigs() 31 | const choices = configs.map(({ key }) => ({ name: key, value: key })) 32 | 33 | const data = new SlashCommandBuilder() 34 | .setName("setconfig") 35 | .addStringOption((option) => 36 | option.setName("config").setDescription("The config to update").setRequired(true).setChoices(choices), 37 | ) 38 | .addStringOption((option) => option.setName("value").setDescription("The value to set").setRequired(true)) 39 | .setDescription("Update a config value") 40 | 41 | return { 42 | data, 43 | execute, 44 | roles: ["admin"], 45 | } 46 | }, 47 | } 48 | 49 | export default commandGenerator 50 | -------------------------------------------------------------------------------- /src/bot/commands/setGroupEmoji.ts: -------------------------------------------------------------------------------- 1 | import { CommandInteraction, ComponentType, SlashCommandBuilder } from "discord.js" 2 | 3 | import { Triggers } from "../../modules/agenda/triggers" 4 | import { CategoryController } from "../../modules/category" 5 | import { GroupController } from "../../modules/group" 6 | import mainLogger from "../../utils/logger" 7 | import selectGroup from "../components/selectGroup" 8 | import { Command, CommandGenerator } from "../type" 9 | 10 | async function execute(interaction: CommandInteraction) { 11 | const logger = mainLogger.child({ name: "RemoveGroup", func: "execute" }) 12 | logger.info("Executing set emoji command") 13 | const category = interaction.options.get("category", true).value as string 14 | 15 | // Ask for a group to remove 16 | const groups = await GroupController.getActiveGroups(category) 17 | const groupInteraction = await interaction.reply({ 18 | content: "Choose a group", 19 | components: [selectGroup(groups)], 20 | }) 21 | const groupInteractionResponse = await groupInteraction.awaitMessageComponent({ 22 | componentType: ComponentType.StringSelect, 23 | }) 24 | const [selectedValue] = groupInteractionResponse.values 25 | const [countryFound, selectedGroup] = selectedValue.split(":") 26 | // If country === "undefined" then it equals null 27 | const country = countryFound || null 28 | 29 | // Ask for confirmation 30 | await interaction.followUp({ 31 | content: `Which emoji do you want to set to the group ${selectedGroup} in ${country} ?`, 32 | components: [], 33 | }) 34 | 35 | const collected = await interaction.channel.awaitMessages({ 36 | filter: (msg) => msg.author.id === interaction.user.id && msg.content.length > 0, 37 | max: 1, 38 | time: 30 * 1000, // Timeout in 30 seconds 39 | errors: ["time"], 40 | }) 41 | 42 | if (!collected || collected.size === 0) { 43 | return interaction.followUp({ content: "You did not provide an emoji in time!", ephemeral: true }) 44 | } 45 | 46 | const emoji = collected.first().content 47 | logger.info(`Emoji or text selected: ${emoji}`) 48 | 49 | await GroupController.setEmoji({ name: selectedGroup, category, country }, emoji) 50 | await Triggers.publishGroup(selectedGroup, category, country) 51 | return interaction.followUp({ 52 | content: `Group ${selectedGroup} of country ${country} emoji set to ${emoji}`, 53 | ephemeral: true, 54 | }) 55 | } 56 | 57 | const commandGenerator: CommandGenerator = { 58 | generate: async (): Promise => { 59 | const categories = await CategoryController.getCategories() 60 | const categoryChoices = categories.map(({ name }) => ({ name, value: name })) 61 | 62 | const data = new SlashCommandBuilder() 63 | .setName("setgroupemoji") 64 | .addStringOption((option) => 65 | option 66 | .setName("category") 67 | .setDescription("The category of the group") 68 | .setRequired(true) 69 | .setChoices(categoryChoices), 70 | ) 71 | .setDescription("Change the emoji of a group") 72 | 73 | return { 74 | data, 75 | execute, 76 | roles: ["admin", "moderator"], 77 | } 78 | }, 79 | } 80 | 81 | export default commandGenerator 82 | -------------------------------------------------------------------------------- /src/bot/commands/toggleindexer.ts: -------------------------------------------------------------------------------- 1 | import { CommandInteraction, InteractionResponse, SlashCommandBuilder } from "discord.js" 2 | 3 | import { Triggers } from "../../modules/agenda/triggers" 4 | import { CategoryController } from "../../modules/category" 5 | import { IndexerController } from "../../modules/indexer" 6 | import { Command, CommandGenerator } from "../type" 7 | 8 | async function execute(interaction: CommandInteraction): Promise { 9 | const indexer = interaction.options.get("indexer", true).value as string 10 | const active = (interaction.options.get("action", true).value as string) === "activate" 11 | 12 | await IndexerController.updateActive(indexer, active) 13 | 14 | if (active) { 15 | const categories = await CategoryController.getCategories() 16 | for (const { name } of categories) { 17 | await Triggers.publishCategory(name) 18 | } 19 | return interaction.reply(`Indexer ${indexer} activated`) 20 | } 21 | 22 | for (const { name } of await CategoryController.getCategories()) { 23 | await Triggers.cancelIndexCategory(name, indexer) 24 | await Triggers.publishCategory(name) 25 | } 26 | return interaction.reply(`Indexer ${indexer} deactivated`) 27 | } 28 | 29 | const commandGenerator: CommandGenerator = { 30 | generate: async (): Promise => { 31 | const indexers = await IndexerController.getIndexers() 32 | const choices = indexers.map(({ name }) => ({ name, value: name })) 33 | const actions = [ 34 | { name: "Activate", value: "activate" }, 35 | { name: "Deactivate", value: "deactivate" }, 36 | ] 37 | 38 | const data = new SlashCommandBuilder() 39 | .setName("toggleindexer") 40 | .addStringOption((option) => 41 | option.setName("indexer").setDescription("The category of the group").setRequired(true).setChoices(choices), 42 | ) 43 | .addStringOption((option) => 44 | option 45 | .setName("action") 46 | .setDescription("Action to perform on the indexer") 47 | .setRequired(true) 48 | .setChoices(actions), 49 | ) 50 | .setDescription("Activate or deactivate an indexer") 51 | 52 | return { 53 | data, 54 | execute, 55 | roles: ["admin"], 56 | } 57 | }, 58 | } 59 | 60 | export default commandGenerator 61 | -------------------------------------------------------------------------------- /src/bot/commands/togglepublisher.ts: -------------------------------------------------------------------------------- 1 | import { CommandInteraction, InteractionResponse, SlashCommandBuilder } from "discord.js" 2 | 3 | import { Triggers } from "../../modules/agenda/triggers" 4 | import { CategoryController } from "../../modules/category" 5 | import { IndexerController } from "../../modules/indexer" 6 | import { PublishersController } from "../../modules/publishers" 7 | import { Command, CommandGenerator } from "../type" 8 | 9 | async function execute(interaction: CommandInteraction): Promise { 10 | const publisher = interaction.options.get("publisher", true).value as string 11 | const active = (interaction.options.get("action", true).value as string) === "activate" 12 | 13 | await PublishersController.updateActive(publisher, active) 14 | 15 | const indexers = await IndexerController.getIndexers(true) 16 | for (const { name } of await CategoryController.getCategories()) { 17 | for (const indexer of indexers) { 18 | await Triggers.cancelIndexCategory(name, indexer.name) 19 | } 20 | await Triggers.publishCategory(name) 21 | } 22 | 23 | if (active) { 24 | return interaction.reply(`Publisher ${publisher} activated`) 25 | } 26 | return interaction.reply(`Publisher ${publisher} deactivated`) 27 | } 28 | 29 | const commandGenerator: CommandGenerator = { 30 | generate: async (): Promise => { 31 | const publishers = await PublishersController.getAllPublishers() 32 | const choices = publishers.map(({ name }) => ({ name, value: name })) 33 | const actions = [ 34 | { name: "Activate", value: "activate" }, 35 | { name: "Deactivate", value: "deactivate" }, 36 | ] 37 | 38 | const data = new SlashCommandBuilder() 39 | .setName("togglepublisher") 40 | .addStringOption((option) => 41 | option.setName("publisher").setDescription("The publisher to toggle").setRequired(true).setChoices(choices), 42 | ) 43 | .addStringOption((option) => 44 | option 45 | .setName("action") 46 | .setDescription("Action to perform on the publisher") 47 | .setRequired(true) 48 | .setChoices(actions), 49 | ) 50 | .setDescription("Activate or deactivate a publisher") 51 | 52 | return { 53 | data, 54 | execute, 55 | roles: ["admin"], 56 | } 57 | }, 58 | } 59 | 60 | export default commandGenerator 61 | -------------------------------------------------------------------------------- /src/bot/commands/togglereleaser.ts: -------------------------------------------------------------------------------- 1 | import { CommandInteraction, InteractionResponse, SlashCommandBuilder } from "discord.js" 2 | 3 | import { Triggers } from "../../modules/agenda/triggers" 4 | import { CategoryController } from "../../modules/category" 5 | import { IndexerController } from "../../modules/indexer" 6 | import { ReleasersController } from "../../modules/releasers" 7 | import { Command, CommandGenerator } from "../type" 8 | 9 | async function execute(interaction: CommandInteraction): Promise { 10 | const releaser = interaction.options.get("releaser", true).value as string 11 | const active = (interaction.options.get("action", true).value as string) === "activate" 12 | 13 | await ReleasersController.updateActive(releaser, active) 14 | 15 | const indexers = await IndexerController.getIndexers(true) 16 | for (const { name } of await CategoryController.getCategories()) { 17 | for (const indexer of indexers) { 18 | await Triggers.cancelIndexCategory(name, indexer.name) 19 | } 20 | await Triggers.publishCategory(name) 21 | } 22 | 23 | if (active) { 24 | return interaction.reply(`Releaser ${releaser} activated`) 25 | } 26 | return interaction.reply(`Releaser ${releaser} deactivated`) 27 | } 28 | 29 | const commandGenerator: CommandGenerator = { 30 | generate: async (): Promise => { 31 | const releasers = await ReleasersController.getAllReleasers() 32 | const choices = releasers.map(({ name }) => ({ name, value: name })) 33 | const actions = [ 34 | { name: "Activate", value: "activate" }, 35 | { name: "Deactivate", value: "deactivate" }, 36 | ] 37 | 38 | const data = new SlashCommandBuilder() 39 | .setName("togglereleaser") 40 | .addStringOption((option) => 41 | option.setName("releaser").setDescription("The releaser to toggle").setRequired(true).setChoices(choices), 42 | ) 43 | .addStringOption((option) => 44 | option 45 | .setName("action") 46 | .setDescription("Action to perform on the releaser") 47 | .setRequired(true) 48 | .setChoices(actions), 49 | ) 50 | .setDescription("Activate or deactivate a releaser") 51 | 52 | return { 53 | data, 54 | execute, 55 | roles: ["admin"], 56 | } 57 | }, 58 | } 59 | 60 | export default commandGenerator 61 | -------------------------------------------------------------------------------- /src/bot/components/confirm.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js" 2 | 3 | const confirmRow = new ActionRowBuilder().addComponents( 4 | new ButtonBuilder().setCustomId("confirm_yes").setLabel("Yes").setStyle(ButtonStyle.Danger), // Red color for confirmation 5 | new ButtonBuilder().setCustomId("confirm_no").setLabel("No").setStyle(ButtonStyle.Secondary), // Grey color for cancel 6 | ) 7 | 8 | export default confirmRow 9 | -------------------------------------------------------------------------------- /src/bot/components/selectGroup.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from "discord.js" 2 | 3 | import { GroupDocument } from "../../modules/group" 4 | import mainLogger from "../../utils/logger" 5 | 6 | export default function selectGroup(groups: GroupDocument[]): ActionRowBuilder { 7 | const logger = mainLogger.child({ name: "SelectGroup", func: "selectGroup" }) 8 | const select = new StringSelectMenuBuilder() 9 | select.setCustomId("selectGroup") 10 | select.setPlaceholder("Select a group") 11 | 12 | for (const { name, country } of groups) { 13 | const label = `${country}:${name}` 14 | const option = new StringSelectMenuOptionBuilder().setLabel(label).setValue(label) 15 | try { 16 | select.addOptions(option) 17 | } catch (error) { 18 | logger.error(`Error while adding options: ${name}`) 19 | break 20 | } 21 | } 22 | return new ActionRowBuilder().addComponents(select) 23 | } 24 | -------------------------------------------------------------------------------- /src/bot/type.ts: -------------------------------------------------------------------------------- 1 | import { CommandInteraction, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder } from "discord.js" 2 | 3 | export function isCommand(instance: any): instance is Command { 4 | if (!instance.data) { 5 | throw new Error("Command data is required") 6 | } 7 | if (!instance.data.name) { 8 | throw new Error("Command name is required") 9 | } 10 | if (!instance.data.description) { 11 | throw new Error("Command description is required") 12 | } 13 | if (!instance.execute) { 14 | throw new Error("Command execute is required") 15 | } 16 | return true 17 | } 18 | 19 | export type Command = { 20 | data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder 21 | execute: (interaction: CommandInteraction) => Promise 22 | roles: string[] 23 | } 24 | 25 | export function isCommandGenerator(instance: any): instance is CommandGenerator { 26 | if (typeof instance.generate !== "function") { 27 | return false 28 | } 29 | return true 30 | } 31 | 32 | export type CommandGenerator = { 33 | generate: () => Promise 34 | } 35 | -------------------------------------------------------------------------------- /src/config/env.ts: -------------------------------------------------------------------------------- 1 | import { SupportedBrowser } from "puppeteer-core" 2 | import { v4 as uuidv4 } from "uuid" 3 | 4 | // Read .env file 5 | export default { 6 | logLevel: process.env.LOG_LEVEL, 7 | logData: process.env.LOG_DATA === "true", 8 | nodeUuid: uuidv4(), 9 | dev: process.env.NODE_ENV === "development", 10 | devIndexer: process.env.DEV_INDEXER, 11 | devCategory: process.env.DEV_CATEGORY, 12 | port: parseInt(process.env.PORT, 10), 13 | mongo: { 14 | url: process.env.MONGO_URL, 15 | db: process.env.MONGO_DB, 16 | agendaDb: process.env.MONGO_AGENDA_DB, 17 | }, 18 | remoteUrl: process.env.BROADCASTARR_REMOTE_URL, 19 | m3u8Destination: process.env.M3U8_FOLDER, 20 | imagesFolder: process.env.IMAGES_FOLDER, 21 | browser: { 22 | userAgent: process.env.USER_AGENT, 23 | browser: process.env.BROWSER as SupportedBrowser, 24 | executablePath: process.env.BROWSER_EXECUTABLE_PATH, 25 | }, 26 | publishers: { 27 | discord: { 28 | token: process.env.DISCORD_USER_TOKEN, 29 | botAvatar: process.env.DISCORD_WEBHOOK_AVATAR || null, 30 | botName: process.env.DISCORD_WEBHOOK_USERNAME || null, 31 | }, 32 | matrix: { 33 | url: process.env.MATRIX_URL, 34 | user: process.env.MATRIX_USER, 35 | serverName: process.env.MATRIX_SERVER_NAME, 36 | accessToken: process.env.MATRIX_ACCESS_TOKEN, 37 | additionalAdmins: process.env.MATRIX_ADDITIONAL_ADMINS.split(","), 38 | }, 39 | gotify: { 40 | url: process.env.GOTIFY_URL, 41 | token: process.env.GOTIFY_TOKEN, 42 | }, 43 | }, 44 | filters: { 45 | channels: (process.env.CHANNELS || "").split(","), 46 | }, 47 | jellyfin: { 48 | url: process.env.JELLYFIN_URL, 49 | token: process.env.JELLYFIN_TOKEN, 50 | collectionUrl: process.env.JELLYFIN_COLLECTION_URL, 51 | }, 52 | theSportsDb: { 53 | url: "https://www.thesportsdb.com/api/v1/json/", 54 | apiKey: "3", 55 | }, 56 | discordBot: { 57 | active: process.env.DISCORD_BOT_ACTIVE === "true", 58 | clientId: process.env.DISCORD_BOT_CLIENT_ID, 59 | token: process.env.DISCORD_BOT_TOKEN, 60 | }, 61 | } 62 | -------------------------------------------------------------------------------- /src/modules/agenda/agenda.ts: -------------------------------------------------------------------------------- 1 | import { Agenda, IJobParameters, Job } from "@hokify/agenda" 2 | 3 | import env from "../../config/env" 4 | import onExit from "../../utils/onExit" 5 | import { TaskOptions } from "./options" 6 | import { Tasks } from "./tasks" 7 | 8 | const agenda = new Agenda({ db: { address: `${env.mongo.url}/${env.mongo.agendaDb}` } }) 9 | 10 | export default agenda 11 | 12 | onExit(async () => { 13 | await agenda.stop() 14 | }) 15 | 16 | export async function schedule( 17 | when: Date | string, 18 | name: T, 19 | data: TaskOptions, 20 | ): Promise>> { 21 | return agenda.schedule>(when, name, data) 22 | } 23 | 24 | export async function now(name: T, data: TaskOptions): Promise>> { 25 | return agenda.now>(name, data) 26 | } 27 | 28 | export async function every( 29 | interval: string, 30 | name: T, 31 | data: TaskOptions, 32 | ): Promise>> { 33 | return agenda.every(interval, name, data) 34 | } 35 | 36 | export async function cancel( 37 | name: T, 38 | params: Partial>>>, 39 | ): Promise { 40 | const flatten = Object.entries(params.data).reduce( 41 | (acc, [key, value]) => ({ ...acc, [`data.${key}`]: value }), 42 | {} as Record, 43 | ) 44 | return agenda.cancel({ name, ...params, ...flatten }) 45 | } 46 | 47 | export async function jobs( 48 | name: T, 49 | params: Partial>>>, 50 | ): Promise>[]> { 51 | // params.data.categoryId must become params.["data.categoryId"] and so on for all keys of data 52 | const flatten = Object.entries(params.data).reduce( 53 | (acc, [key, value]) => ({ ...acc, [`data.${key}`]: value }), 54 | {} as Record, 55 | ) 56 | const query = { name, ...params, ...flatten } 57 | delete query.data 58 | return agenda.jobs(query) as unknown as Job>[] 59 | } 60 | -------------------------------------------------------------------------------- /src/modules/agenda/definer.ts: -------------------------------------------------------------------------------- 1 | import mainLogger from "../../utils/logger" 2 | import { ConfigController } from "../config" 3 | import agenda from "./agenda" 4 | import { ErrorHandlers, Handlers } from "./handlers" 5 | import { TaskOptions } from "./options" 6 | import { Tasks } from "./tasks" 7 | 8 | // Auto reschedule failed jobs 9 | 10 | export async function defineAgendaTasks() { 11 | agenda.on("fail", async (error, job) => { 12 | const logger = mainLogger.child({ name: "AgendaDefiner", func: "onFail" }) 13 | const { name, data } = job.attrs as { name: Tasks; data: TaskOptions } 14 | logger.error(`Job "${name}" failed with error: ${error.message} - data: ${JSON.stringify(data)}`) 15 | 16 | // Find the task key from the job name 17 | const errorHandler = ErrorHandlers[name] 18 | if (errorHandler) { 19 | logger.info(`Handling error for job "${name}"`) 20 | const removeJob = await errorHandler(error, job) 21 | if (removeJob) { 22 | logger.info(`Removing job "${name}" from the database`) 23 | return job.remove() 24 | } 25 | } 26 | 27 | try { 28 | const retryDelay = await ConfigController.getNumberConfig(`delay-retry-${name.replaceAll(" ", "")}`) 29 | const retryTime = new Date(Date.now() + retryDelay * 1000) 30 | logger.info(`Rescheduling ${name} in ${retryDelay} seconds - ${retryTime}`) 31 | await job.schedule(retryTime) 32 | return job.save() 33 | } catch (err) { 34 | logger.warn(`Error while rescheduling job ${name} - ${err.message}`) 35 | } 36 | // If the job can't be rescheduled, it will be removed from the database 37 | return job.remove() 38 | }) 39 | 40 | agenda.on("success", async (job) => { 41 | const logger = mainLogger.child({ name: "AgendaDefiner", func: "onSuccess" }) 42 | const { name } = job.attrs as { name: Tasks; data: TaskOptions } 43 | logger.debug(`Job "${name}" succeeded, removing it from the database`) 44 | await job.remove() 45 | }) 46 | 47 | agenda.define(Tasks.PublishCategory, Handlers.PublishCategory, { concurrency: 20 }) 48 | agenda.define(Tasks.IndexCategory, Handlers.IndexCategory, { concurrency: 2 }) 49 | agenda.define(Tasks.GrabBroadcastStream, Handlers.GrabBroadcastStream, { concurrency: 5 }) 50 | agenda.define(Tasks.ReleaseBroadcast, Handlers.ReleaseBroadcast, { concurrency: 1 }) 51 | agenda.define(Tasks.PublishGroup, Handlers.PublishGroup, { concurrency: 1 }) 52 | agenda.define(Tasks.UpdateCategoryChannelName, Handlers.UpdateCategoryChannelName, { concurrency: 1 }) 53 | agenda.define(Tasks.DeleteBroadcast, Handlers.DeleteBroadcast, { concurrency: 1 }) 54 | 55 | agenda.on("error", (error) => { 56 | const logger = mainLogger.child({ name: "AgendaDefiner", func: "onError" }) 57 | // Print the task name and error message 58 | logger.error(`Task error: ${error.stack}`) 59 | logger.error(`Agenda error: ${error.message}`) 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /src/modules/agenda/handlers/deleteBroadcast.ts: -------------------------------------------------------------------------------- 1 | import { Job } from "@hokify/agenda" 2 | 3 | import { removeFile } from "../../../utils/file" 4 | import mainLogger from "../../../utils/logger" 5 | import { BroadcastController } from "../../broadcast" 6 | import { ReleasersController } from "../../releasers" 7 | import { DeleteBroadcastOptions } from "../options" 8 | import { Triggers } from "../triggers" 9 | 10 | export async function handler(job: Job): Promise { 11 | const { broadcastId } = job.attrs.data 12 | const broadcast = await BroadcastController.getBroadcast(broadcastId) 13 | const logger = mainLogger.child({ 14 | name: "DeleteBroadcastHandler", 15 | func: "handler", 16 | data: { 17 | broadcastId: broadcast.id, 18 | broadcastName: broadcast.name, 19 | }, 20 | }) 21 | const { category, group, country } = broadcast 22 | 23 | // Delete M3U8 24 | const path = await BroadcastController.getM3U8Path(broadcast.id) 25 | logger.debug(`Deleting M3U8 file at ${path}`) 26 | try { 27 | await removeFile(path) 28 | } catch (error) { 29 | logger.warn(`Error while deleting M3U8 file at ${path}`) 30 | } 31 | 32 | // Cancel GrabBroadcastStream task 33 | logger.debug("Canceling task that may concern the broadcast") 34 | await Triggers.cancelGrabBroadcastStream(broadcast.id) 35 | await Triggers.cancelReleaseBroadcast(broadcast.id) 36 | 37 | // Unrelease the broadcast 38 | await ReleasersController.unreleaseBroadcast(broadcast) 39 | 40 | // Delete broadcast 41 | logger.debug("Deleting broadcast from the database") 42 | await BroadcastController.removeBroadcast(broadcastId) 43 | 44 | // Check the remaining broadcasts 45 | logger.debug("Updating the group message") 46 | await Triggers.publishGroup(group, category, country) 47 | } 48 | -------------------------------------------------------------------------------- /src/modules/agenda/handlers/grabBroadcastStream.ts: -------------------------------------------------------------------------------- 1 | import { Job } from "@hokify/agenda" 2 | 3 | import mainLogger from "../../../utils/logger" 4 | import { BroadcastController } from "../../broadcast" 5 | import { IndexerController } from "../../indexer" 6 | import { DynamicBroadcastInterceptor } from "../../indexers" 7 | import { StreamData } from "../../indexers/broadcastInterceptor" 8 | import { Orchestrator } from "../../scrapper/Orchestrator" 9 | import { GrabBroadcastStreamOptions } from "../options" 10 | import { Triggers } from "../triggers" 11 | 12 | export async function handler(job: Job): Promise { 13 | const { broadcastId } = job.attrs.data 14 | const broadcast = await BroadcastController.getBroadcast(broadcastId) 15 | const logger = mainLogger.child({ 16 | name: "GrabBroadcastStreamHandler", 17 | func: "handler", 18 | data: { 19 | broadcastId: broadcast.id, 20 | broadcastName: broadcast.name, 21 | }, 22 | }) 23 | 24 | let stream: StreamData 25 | let streamIndex: number 26 | 27 | const indexerDocument = await IndexerController.getIndexer(broadcast.indexer) 28 | 29 | if (indexerDocument.scenarios) { 30 | logger.info("Grabbing the broadcast stream") 31 | const { scenarios } = indexerDocument 32 | const orchestrator = new Orchestrator(scenarios, broadcast) 33 | const result = await orchestrator.run("intercept") 34 | // eslint-disable-next-line prefer-destructuring 35 | stream = result.stream 36 | // eslint-disable-next-line prefer-destructuring 37 | streamIndex = result.streamIndex 38 | } else { 39 | logger.info("LEGACY - Grabbing the broadcast stream") 40 | const interceptor = new DynamicBroadcastInterceptor(indexerDocument, broadcast) 41 | stream = await interceptor.getStream() 42 | // Saving the current stream index 43 | streamIndex = interceptor.getStreamIndex() 44 | } 45 | await BroadcastController.setStreamIndex(broadcast.id, streamIndex) 46 | 47 | if (!stream) { 48 | logger.error("No stream found") 49 | // Still need to publish the group 50 | throw new Error(`No stream found for broadcast ${broadcast.name} - ${broadcastId}`) 51 | } 52 | 53 | // Will need to be updated 10 minutes before the stream expires 54 | logger.debug("Renewing the GrabBroadcastStream task") 55 | await Triggers.renewGrabBroadcastStream(broadcastId, streamIndex) 56 | 57 | // Set the stream in the database 58 | logger.debug("Setting the stream in the database") 59 | await BroadcastController.setStream(broadcast.id, stream) 60 | 61 | // If the file already exists, we don't need to do anything 62 | const m3u8FileExists = await BroadcastController.m3u8FileExists(broadcastId) 63 | logger.debug(`M3U8 file exists: ${m3u8FileExists}`) 64 | if (!m3u8FileExists) { 65 | // Writing the M3U8 file 66 | logger.debug("Writing the M3U8 file") 67 | await BroadcastController.generateM3U8File(broadcastId) 68 | } 69 | await Triggers.releaseBroadcast(broadcastId) 70 | } 71 | 72 | export async function onError(error: Error, job: Job): Promise { 73 | const { broadcastId } = job.attrs.data 74 | const logger = mainLogger.child({ 75 | name: "GrabBroadcastStreamHandler", 76 | func: "onError", 77 | data: { broadcastId }, 78 | }) 79 | logger.error(`An error occurred while grabbing the broadcast stream: ${error.message}`) 80 | try { 81 | const broadcast = await BroadcastController.getBroadcast(broadcastId) 82 | await Triggers.publishGroup(broadcast.group, broadcast.category, broadcast.country) 83 | return false 84 | } catch (err) { 85 | logger.warn("Broadcast not found, cannot publish the group, and deleting the job") 86 | return true 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/modules/agenda/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { Job } from "@hokify/agenda" 2 | 3 | import { Tasks } from "../tasks" 4 | import { handler as DeleteBroadcastHandler } from "./deleteBroadcast" 5 | import { onError as GrabBroadcastStreamError, handler as GrabBroadcastStreamHandler } from "./grabBroadcastStream" 6 | import { handler as IndexCategoryHandler } from "./indexCategory" 7 | import { handler as PublishCategoryHandler } from "./publishCategory" 8 | import { handler as PublishGroupHandler } from "./publishGroup" 9 | import { handler as ReleaseBroadcastHandler } from "./releaseBroadcast" 10 | import { handler as UpdateCategoryChannelNameHandler } from "./updateCategoryChannelName" 11 | 12 | type Handler = (job: Job) => Promise 13 | 14 | const Handlers: Record = { 15 | DeleteBroadcast: DeleteBroadcastHandler, 16 | GrabBroadcastStream: GrabBroadcastStreamHandler, 17 | IndexCategory: IndexCategoryHandler, 18 | PublishCategory: PublishCategoryHandler, 19 | PublishGroup: PublishGroupHandler, 20 | ReleaseBroadcast: ReleaseBroadcastHandler, 21 | UpdateCategoryChannelName: UpdateCategoryChannelNameHandler, 22 | } 23 | 24 | type ErrorHandler = (error: Error, job: Job) => Promise 25 | 26 | const ErrorHandlers: Partial> = { 27 | GrabBroadcastStream: GrabBroadcastStreamError, 28 | } 29 | 30 | export { Handlers, ErrorHandlers } 31 | -------------------------------------------------------------------------------- /src/modules/agenda/handlers/publishCategory.ts: -------------------------------------------------------------------------------- 1 | import { Job } from "@hokify/agenda" 2 | 3 | import mainLogger from "../../../utils/logger" 4 | import { CategoryController } from "../../category" 5 | import { GroupController } from "../../group" 6 | import { IndexerController } from "../../indexer" 7 | import { PublishersController } from "../../publishers" 8 | import { PublishCategoryOptions } from "../options" 9 | import { Triggers } from "../triggers" 10 | 11 | export async function handler(job: Job): Promise { 12 | const { category } = job.attrs.data 13 | 14 | const logger = mainLogger.child({ 15 | name: "PublishCategoryHandler", 16 | func: "handler", 17 | data: { category }, 18 | }) 19 | 20 | const categoryDocument = await CategoryController.getCategory(category) 21 | 22 | // Delete the old message if we just created the category 23 | const ids = await PublishersController.publishCategory(categoryDocument) 24 | for (const publisher of Object.keys(ids)) { 25 | await CategoryController.setPublications(category, publisher, ids[publisher]) 26 | } 27 | 28 | // If there was messages already published, we need to republish the groups 29 | logger.info("Checking if we need to republish the groups") 30 | const groups = await GroupController.getGroupsOfCategory(category) 31 | for (const group of groups) { 32 | const keys = Array.from(group.publications.keys()) 33 | const hasPublications = keys.some((key) => group.publications.get(key)?.length > 0) 34 | if (hasPublications) { 35 | logger.info(`Republishing group ${group.name}`) 36 | await Triggers.publishGroup(group.name, category, group.country) 37 | } 38 | } 39 | 40 | // schedule now the UpdateDiscordChannelName task 41 | logger.info("Scheduling the UpdateDiscordChannelName task for now") 42 | await Triggers.updateCategoryChannelName(category) 43 | 44 | // Cancel the previous IndexCategory scheduled task and schedule a new one 45 | logger.info("Scheduling the IndexCategory task") 46 | // For each BroadcastsIndexers, we start a IndexCategory task 47 | const indexers = await IndexerController.getIndexers(true) 48 | for (const indexer of indexers) { 49 | await Triggers.indexCategory(category, indexer.name) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/modules/agenda/handlers/publishGroup.ts: -------------------------------------------------------------------------------- 1 | import { Job } from "@hokify/agenda" 2 | 3 | import mainLogger from "../../../utils/logger" 4 | import { BroadcastController } from "../../broadcast" 5 | import { GroupController } from "../../group" 6 | import { PublishersController } from "../../publishers" 7 | import { PublishGroupOptions } from "../options" 8 | import { Triggers } from "../triggers" 9 | 10 | export async function handler(job: Job): Promise { 11 | const { group, category, country } = job.attrs.data 12 | const logger = mainLogger.child({ 13 | name: "PublishGroupHandler", 14 | func: "handler", 15 | data: { 16 | group, 17 | category, 18 | country, 19 | }, 20 | }) 21 | logger.info("Publishing group") 22 | 23 | // Get the group and the broadcasts 24 | const groupDocument = await GroupController.getGroup({ name: group, category, country }) 25 | const broadcasts = await BroadcastController.getBroadcastsOfGroup(country, groupDocument.name, category) 26 | 27 | // For each, if they have a stream but no jellyfinId, we release them 28 | for (const broadcast of broadcasts) { 29 | if (broadcast.streams?.length > 0 && !broadcast.jellyfinId) { 30 | logger.warn(`Broadcast ${broadcast.name} has streams but no jellyfinId, releasing it`) 31 | await Triggers.releaseBroadcast(broadcast.id) 32 | } 33 | } 34 | // Publish the group 35 | logger.debug("Unpublishing the group") 36 | const res = await PublishersController.unpublishGroup(groupDocument) 37 | logger.debug("Unsetting the publications") 38 | for (const publisher of Object.keys(res)) { 39 | await GroupController.setPublications(groupDocument, publisher, []) 40 | } 41 | 42 | if (groupDocument.active) { 43 | logger.debug("Publishing the group") 44 | const result = await PublishersController.publishGroup(groupDocument, broadcasts) 45 | logger.debug("Setting the publications") 46 | for (const publisher of Object.keys(result)) { 47 | await GroupController.setPublications(groupDocument, publisher, result[publisher]) 48 | } 49 | } 50 | 51 | // schedule now the UpdateDiscordChannelName task 52 | // Check if the task is already scheduled 53 | logger.debug("Scheduling the UpdateCategoryChannelName task") 54 | await Triggers.updateCategoryChannelName(category) 55 | } 56 | -------------------------------------------------------------------------------- /src/modules/agenda/handlers/releaseBroadcast.ts: -------------------------------------------------------------------------------- 1 | import { Job } from "@hokify/agenda" 2 | 3 | import mainLogger from "../../../utils/logger" 4 | import { BroadcastController } from "../../broadcast" 5 | import { ReleasersController } from "../../releasers" 6 | import { ReleaseBroadcastOptions } from "../options" 7 | import { Triggers } from "../triggers" 8 | 9 | export async function handler(job: Job): Promise { 10 | const { broadcastId } = job.attrs.data 11 | const broadcast = await BroadcastController.getBroadcast(broadcastId) 12 | const logger = mainLogger.child({ 13 | name: "ReleaseBroadcastHandler", 14 | func: "handler", 15 | data: { 16 | broadcastId: broadcast.id, 17 | broadcastName: broadcast.name, 18 | }, 19 | }) 20 | 21 | logger.debug("Releasing the broadcast") 22 | await ReleasersController.releaseBroadcast(broadcast) 23 | 24 | // We update the Discord message of the broadcast 25 | logger.debug("Republishing the broadcast") 26 | await Triggers.publishGroup(broadcast.group, broadcast.category, broadcast.country) 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/agenda/handlers/tasks.ts: -------------------------------------------------------------------------------- 1 | import { handler as DeleteBroadcast } from "./deleteBroadcast" 2 | import { handler as GrabBroadcastStream } from "./grabBroadcastStream" 3 | import { handler as IndexCategory } from "./indexCategory" 4 | import { handler as PublishCategory } from "./publishCategory" 5 | import { handler as PublishGroup } from "./publishGroup" 6 | import { handler as ReleaseBroadcast } from "./releaseBroadcast" 7 | import { handler as UpdateCategoryChannelName } from "./updateCategoryChannelName" 8 | 9 | const Handlers = { 10 | DeleteBroadcast, 11 | GrabBroadcastStream, 12 | IndexCategory, 13 | PublishCategory, 14 | PublishGroup, 15 | ReleaseBroadcast, 16 | UpdateCategoryChannelName, 17 | } 18 | 19 | export { Handlers } 20 | -------------------------------------------------------------------------------- /src/modules/agenda/handlers/updateCategoryChannelName.ts: -------------------------------------------------------------------------------- 1 | import { Job } from "@hokify/agenda" 2 | 3 | import mainLogger from "../../../utils/logger" 4 | import { CategoryController } from "../../category" 5 | import { PublishersController } from "../../publishers" 6 | import { UpdateCategoryChannelNameOptions } from "../options" 7 | 8 | export async function handler(job: Job): Promise { 9 | const { category } = job.attrs.data 10 | const logger = mainLogger.child({ 11 | name: "UpdateCategoryChannelNameHandler", 12 | func: "handler", 13 | data: { category }, 14 | }) 15 | 16 | logger.debug("Updating the category channel name") 17 | const categoryDocument = await CategoryController.getCategory(category) 18 | await PublishersController.updateChannelName(categoryDocument) 19 | 20 | logger.debug("Clearing the unlisted messages") 21 | await PublishersController.clearUnlistedMessages(categoryDocument) 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/agenda/index.ts: -------------------------------------------------------------------------------- 1 | import agenda from "./agenda" 2 | import { GrabBroadcastStreamOptions } from "./options" 3 | 4 | export { Tasks } from "./tasks" 5 | 6 | export { defineAgendaTasks } from "./definer" 7 | 8 | export { GrabBroadcastStreamOptions } 9 | 10 | export { agenda } 11 | -------------------------------------------------------------------------------- /src/modules/agenda/options/deleteBroadcast.ts: -------------------------------------------------------------------------------- 1 | export type DeleteBroadcastOptions = { 2 | broadcastId: string 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/agenda/options/grabBroadcastStream.ts: -------------------------------------------------------------------------------- 1 | export type GrabBroadcastStreamOptions = { 2 | broadcastId: string 3 | streamIndex: number 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/agenda/options/index.ts: -------------------------------------------------------------------------------- 1 | import { Tasks } from "../tasks" 2 | import { DeleteBroadcastOptions } from "./deleteBroadcast" 3 | import { GrabBroadcastStreamOptions } from "./grabBroadcastStream" 4 | import { IndexCategoryOptions } from "./indexCategory" 5 | import { PublishCategoryOptions } from "./publishCategory" 6 | import { PublishGroupOptions } from "./publishGroup" 7 | import { ReleaseBroadcastOptions } from "./releaseBroadcast" 8 | import { UpdateCategoryChannelNameOptions } from "./updateCategoryChannelName" 9 | 10 | export { 11 | DeleteBroadcastOptions, 12 | GrabBroadcastStreamOptions, 13 | IndexCategoryOptions, 14 | PublishCategoryOptions, 15 | PublishGroupOptions, 16 | ReleaseBroadcastOptions, 17 | UpdateCategoryChannelNameOptions, 18 | } 19 | 20 | // Define Tasks => Options mapping 21 | type TaskOptionsMapping = { 22 | [Tasks.PublishCategory]: PublishCategoryOptions 23 | [Tasks.IndexCategory]: IndexCategoryOptions 24 | [Tasks.GrabBroadcastStream]: GrabBroadcastStreamOptions 25 | [Tasks.ReleaseBroadcast]: ReleaseBroadcastOptions 26 | [Tasks.PublishGroup]: PublishGroupOptions 27 | [Tasks.UpdateCategoryChannelName]: UpdateCategoryChannelNameOptions 28 | [Tasks.DeleteBroadcast]: DeleteBroadcastOptions 29 | } 30 | 31 | export type TaskOptions = TaskOptionsMapping[T] 32 | -------------------------------------------------------------------------------- /src/modules/agenda/options/indexCategory.ts: -------------------------------------------------------------------------------- 1 | export type IndexCategoryOptions = { 2 | category: string 3 | indexerName: string 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/agenda/options/publishCategory.ts: -------------------------------------------------------------------------------- 1 | export type PublishCategoryOptions = { 2 | category: string 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/agenda/options/publishGroup.ts: -------------------------------------------------------------------------------- 1 | export type PublishGroupOptions = { 2 | category: string 3 | group: string 4 | country: string 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/agenda/options/releaseBroadcast.ts: -------------------------------------------------------------------------------- 1 | export type ReleaseBroadcastOptions = { 2 | broadcastId: string 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/agenda/options/updateCategoryChannelName.ts: -------------------------------------------------------------------------------- 1 | export type UpdateCategoryChannelNameOptions = { 2 | category: string 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/agenda/tasks.ts: -------------------------------------------------------------------------------- 1 | enum Tasks { 2 | PublishCategory = "PublishCategory", 3 | IndexCategory = "IndexCategory", 4 | GrabBroadcastStream = "GrabBroadcastStream", 5 | ReleaseBroadcast = "ReleaseBroadcast", 6 | PublishGroup = "PublishGroup", 7 | UpdateCategoryChannelName = "UpdateCategoryChannelName", 8 | DeleteBroadcast = "DeleteBroadcast", 9 | } 10 | export { Tasks } 11 | -------------------------------------------------------------------------------- /src/modules/agenda/triggers/deleteBroadcast.ts: -------------------------------------------------------------------------------- 1 | import mainLogger from "../../../utils/logger" 2 | import { now } from "../agenda" 3 | import { Tasks } from "../tasks" 4 | 5 | export async function deleteBroadcast(broadcastId: string): Promise { 6 | const logger = mainLogger.child({ 7 | name: "DeleteBroadcastTrigger", 8 | func: "deleteBroadcast", 9 | data: { 10 | broadcastId, 11 | }, 12 | }) 13 | logger.debug("Schedule DeleteBroadcast task for now") 14 | await now(Tasks.DeleteBroadcast, { broadcastId }) 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/agenda/triggers/grabBroadcastStream.ts: -------------------------------------------------------------------------------- 1 | import mainLogger from "../../../utils/logger" 2 | import { BroadcastController } from "../../broadcast" 3 | import { ConfigController } from "../../config" 4 | import { cancel, jobs, schedule } from "../agenda" 5 | import { Tasks } from "../tasks" 6 | 7 | export async function grabBroadcastStream(broadcastId: string, streamIndex: number): Promise { 8 | const broadcast = await BroadcastController.getBroadcast(broadcastId) 9 | const [existingJob] = await jobs(Tasks.GrabBroadcastStream, { data: { broadcastId } }) 10 | const logger = mainLogger.child({ 11 | name: "GrabBroadcastStreamTrigger", 12 | func: "grabBroadcastStream", 13 | data: { 14 | broadcastId, 15 | broadcastName: broadcast.name, 16 | }, 17 | }) 18 | if (existingJob) { 19 | logger.info("Task already scheduled") 20 | return 21 | } 22 | const delay = await ConfigController.getNumberConfig("delay-simple-GrabStream") 23 | const grabTime = new Date(broadcast.startTime.getTime() - delay * 1000) 24 | const job = await schedule(grabTime, Tasks.GrabBroadcastStream, { broadcastId: broadcast.id, streamIndex }) 25 | logger.info(`Scheduled for ${job.attrs.nextRunAt}`) 26 | } 27 | 28 | export async function renewGrabBroadcastStream(broadcastId: string, streamIndex: number): Promise { 29 | const broadcast = await BroadcastController.getBroadcast(broadcastId) 30 | const logger = mainLogger.child({ 31 | name: "GrabBroadcastStreamTrigger", 32 | func: "renewGrabBroadcastStream", 33 | data: { 34 | broadcastId, 35 | broadcastName: broadcast.name, 36 | }, 37 | }) 38 | const delay = await ConfigController.getNumberConfig("delay-simple-RenewStream") 39 | logger.info(`Renewing the task in ${delay} seconds`) 40 | await schedule(`in ${delay} seconds`, Tasks.GrabBroadcastStream, { broadcastId: broadcast.id, streamIndex }) 41 | } 42 | 43 | export async function cancelGrabBroadcastStream(broadcastId: string): Promise { 44 | await cancel(Tasks.GrabBroadcastStream, { data: { broadcastId } }) 45 | } 46 | -------------------------------------------------------------------------------- /src/modules/agenda/triggers/index.ts: -------------------------------------------------------------------------------- 1 | import { deleteBroadcast } from "./deleteBroadcast" 2 | import { cancelGrabBroadcastStream, grabBroadcastStream, renewGrabBroadcastStream } from "./grabBroadcastStream" 3 | import { cancelIndexCategory, indexCategory, renewIndexCategory } from "./indexCategory" 4 | import { publishCategory } from "./publishCategory" 5 | import { publishGroup } from "./publishGroup" 6 | import { cancelReleaseBroadcast, releaseBroadcast } from "./releaseBroadcast" 7 | import { updateCategoryChannelName } from "./updateCategoryChannelName" 8 | 9 | const Triggers = { 10 | deleteBroadcast, 11 | grabBroadcastStream, 12 | cancelGrabBroadcastStream, 13 | renewGrabBroadcastStream, 14 | indexCategory, 15 | cancelIndexCategory, 16 | renewIndexCategory, 17 | releaseBroadcast, 18 | cancelReleaseBroadcast, 19 | publishCategory, 20 | publishGroup, 21 | updateCategoryChannelName, 22 | } 23 | 24 | export { Triggers } 25 | -------------------------------------------------------------------------------- /src/modules/agenda/triggers/indexCategory.ts: -------------------------------------------------------------------------------- 1 | import mainLogger from "../../../utils/logger" 2 | import { ConfigController } from "../../config" 3 | import { cancel, schedule } from "../agenda" 4 | import { Tasks } from "../tasks" 5 | 6 | export async function cancelIndexCategory(category: string, indexerName: string): Promise { 7 | await cancel(Tasks.IndexCategory, { data: { category, indexerName } }) 8 | } 9 | 10 | export async function indexCategory(category: string, indexerName: string): Promise { 11 | const logger = mainLogger.child({ 12 | name: "IndexCategoryTrigger", 13 | func: "indexCategory", 14 | data: { 15 | category, 16 | indexerName, 17 | }, 18 | }) 19 | const delay = await ConfigController.getNumberConfig("delay-simple-IndexCategory") 20 | // Getting the existing job that does not have a repeatInterval value 21 | 22 | logger.info("Cancelling existing jobs") 23 | await cancelIndexCategory(category, indexerName) 24 | 25 | // We reschedule the job to be run now 26 | logger.info(`Scheduling a IndexCategory for category ${category} and indexer ${indexerName} in ${delay} seconds`) 27 | await schedule(`in ${delay} seconds`, Tasks.IndexCategory, { category, indexerName }) 28 | } 29 | 30 | export async function renewIndexCategory(category: string, indexerName: string): Promise { 31 | const logger = mainLogger.child({ 32 | name: "IndexCategoryTrigger", 33 | func: "renewIndexCategory", 34 | data: { 35 | category, 36 | indexerName, 37 | }, 38 | }) 39 | const delay = await ConfigController.getNumberConfig("delay-regular-IndexCategory") 40 | logger.info(`Renewing the task in ${delay / 60} minutes`) 41 | const job = await schedule(`in ${delay / 60} minutes`, Tasks.IndexCategory, { category, indexerName }) 42 | logger.info(`Task renewed, nextRunAt: ${job.attrs.nextRunAt}`) 43 | } 44 | -------------------------------------------------------------------------------- /src/modules/agenda/triggers/publishCategory.ts: -------------------------------------------------------------------------------- 1 | import mainLogger from "../../../utils/logger" 2 | import { jobs, now } from "../agenda" 3 | import { Tasks } from "../tasks" 4 | 5 | export async function publishCategory(category: string): Promise { 6 | const logger = mainLogger.child({ 7 | name: "PublishCategoryTrigger", 8 | func: "publishCategory", 9 | data: { 10 | category, 11 | }, 12 | }) 13 | // We create a job to list broadcasts 14 | logger.debug("Scheduling PublishCategory task") 15 | 16 | // Cancel previous task 17 | const [existingJob] = await jobs(Tasks.PublishCategory, { data: { category } }) 18 | if (existingJob) { 19 | logger.debug("Task already scheduled, removing it") 20 | await existingJob.remove() 21 | } 22 | 23 | await now(Tasks.PublishCategory, { category }) 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/agenda/triggers/publishGroup.ts: -------------------------------------------------------------------------------- 1 | import mainLogger from "../../../utils/logger" 2 | import { ConfigController } from "../../config" 3 | import { jobs, schedule } from "../agenda" 4 | import { Tasks } from "../tasks" 5 | 6 | export async function publishGroup(group: string, category: string, country: string): Promise { 7 | const logger = mainLogger.child({ 8 | name: "PublishGroupTrigger", 9 | func: "publishGroup", 10 | data: { 11 | group, 12 | category, 13 | country, 14 | }, 15 | }) 16 | const [existingJob] = await jobs(Tasks.PublishGroup, { data: { category, group, country } }) 17 | const delay = await ConfigController.getNumberConfig("delay-simple-PublishGroup") 18 | if (!existingJob) { 19 | logger.info(`No existing job found, scheduling a PublishGroup for category ${category} and group ${group}`) 20 | await schedule(`in ${delay} seconds`, Tasks.PublishGroup, { group, category, country }) 21 | return 22 | } 23 | logger.info(`Rescheduling the one time PublishGroup for category ${category} and group ${group} in ${delay} seconds`) 24 | await existingJob.schedule(`In ${delay} seconds`) 25 | await existingJob.save() 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/agenda/triggers/releaseBroadcast.ts: -------------------------------------------------------------------------------- 1 | import mainLogger from "../../../utils/logger" 2 | import { cancel, now } from "../agenda" 3 | import { Tasks } from "../tasks" 4 | 5 | export async function releaseBroadcast(broadcastId: string): Promise { 6 | const logger = mainLogger.child({ 7 | name: "ReleaseBroadcastTrigger", 8 | func: "releaseBroadcast", 9 | data: { broadcastId }, 10 | }) 11 | logger.debug("Schedule ReleaseBroadcast task for now") 12 | await now(Tasks.ReleaseBroadcast, { broadcastId }) 13 | } 14 | 15 | export async function cancelReleaseBroadcast(broadcastId: string): Promise { 16 | const logger = mainLogger.child({ 17 | name: "ReleaseBroadcastTrigger", 18 | func: "cancelReleaseBroadcast", 19 | data: { broadcastId }, 20 | }) 21 | logger.debug("Cancel ReleaseBroadcast task") 22 | await cancel(Tasks.ReleaseBroadcast, { data: { broadcastId } }) 23 | } 24 | -------------------------------------------------------------------------------- /src/modules/agenda/triggers/updateCategoryChannelName.ts: -------------------------------------------------------------------------------- 1 | import mainLogger from "../../../utils/logger" 2 | import { ConfigController } from "../../config" 3 | import { jobs, schedule } from "../agenda" 4 | import { Tasks } from "../tasks" 5 | 6 | export async function updateCategoryChannelName(category: string): Promise { 7 | const logger = mainLogger.child({ 8 | name: "UpdateCategoryChannelNameTrigger", 9 | func: "updateCategoryChannelName", 10 | data: { 11 | category, 12 | }, 13 | }) 14 | const [existingJob] = await jobs(Tasks.UpdateCategoryChannelName, { data: { category } }) 15 | const delay = await ConfigController.getNumberConfig("delay-simple-UpdateCategoryChannelName") 16 | if (existingJob) { 17 | logger.debug(`Task already scheduled, updating nextRunAt in ${delay} seconds`) 18 | existingJob.attrs.nextRunAt = new Date(Date.now() + delay * 1000) 19 | await existingJob.save() 20 | return 21 | } 22 | 23 | logger.debug(`Scheduling task in ${delay} seconds`) 24 | await schedule(`in ${delay} seconds`, Tasks.UpdateCategoryChannelName, { category }) 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/auth/controller.ts: -------------------------------------------------------------------------------- 1 | import { AuthDocument } from "./model" 2 | import * as AuthService from "./service" 3 | 4 | export async function getAuths(): Promise { 5 | return AuthService.getAuths() 6 | } 7 | 8 | export async function getAuth(value: string): Promise { 9 | return AuthService.getAuth(value) 10 | } 11 | 12 | export async function createAuth(type: string, value: string, roles: string[]): Promise { 13 | return AuthService.createAuth(type, value, roles) 14 | } 15 | 16 | export async function deleteAuth(type: string, value: string): Promise { 17 | return AuthService.deleteAuth(type, value) 18 | } 19 | 20 | export async function addRolesToAuth(type: string, value: string, roles: string[]): Promise { 21 | return AuthService.addRolesToAuth(type, value, roles) 22 | } 23 | 24 | export async function deleteRolesFromAuth(type: string, value: string, roles: string[]): Promise { 25 | return AuthService.deleteRolesFromAuth(type, value, roles) 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * as AuthController from "./controller" 2 | 3 | export { AuthDocument } from "./model" 4 | -------------------------------------------------------------------------------- /src/modules/auth/model.ts: -------------------------------------------------------------------------------- 1 | // Create a simple model of a unique document in a MongoDB collection. 2 | 3 | import mongoose, { InferSchemaType } from "mongoose" 4 | 5 | const authSchema = new mongoose.Schema({ 6 | type: { 7 | type: String, 8 | enum: ["user", "channel"], 9 | required: true, 10 | }, 11 | value: { 12 | type: String, 13 | required: true, 14 | unique: true, 15 | }, 16 | roles: { 17 | type: [String], 18 | default: [], 19 | }, 20 | }) 21 | 22 | export const AuthModel = mongoose.model("Auth", authSchema) 23 | 24 | export type AuthDocument = InferSchemaType 25 | -------------------------------------------------------------------------------- /src/modules/auth/service.ts: -------------------------------------------------------------------------------- 1 | import { AuthDocument, AuthModel } from "./model" 2 | 3 | // Simple CRUD operations for the Config model. 4 | 5 | export async function getAuth(value: string): Promise { 6 | return AuthModel.findOne({ value }).orFail() 7 | } 8 | 9 | export async function createAuth(type: string, value: string, roles: string[]): Promise { 10 | return AuthModel.create({ type, value, roles }) 11 | } 12 | 13 | export async function getAuths(): Promise { 14 | return AuthModel.find() 15 | } 16 | 17 | export async function deleteAuth(type: string, value: string): Promise { 18 | await AuthModel.findOneAndDelete({ type, value }).orFail() 19 | } 20 | 21 | export async function addRolesToAuth(type: string, value: string, roles: string[]): Promise { 22 | return AuthModel.findOneAndUpdate( 23 | { type, value }, 24 | { $addToSet: { roles } }, 25 | { new: true, runValidators: true }, 26 | ).orFail() 27 | } 28 | 29 | export async function deleteRolesFromAuth(type: string, value: string, roles: string[]): Promise { 30 | return AuthModel.findOneAndUpdate({ type, value }, { $pull: { roles } }, { new: true, runValidators: true }).orFail() 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/broadcast/index.ts: -------------------------------------------------------------------------------- 1 | export * as BroadcastController from "./controller" 2 | 3 | export { BroadcastDocument, BroadcastStream } from "./model" 4 | -------------------------------------------------------------------------------- /src/modules/broadcast/model.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { InferSchemaType } from "mongoose" 2 | 3 | const streamsSchema = new mongoose.Schema({ 4 | url: { 5 | type: String, 6 | required: true, 7 | }, 8 | referer: { 9 | type: String, 10 | }, 11 | expiresAt: { 12 | type: Date, 13 | }, 14 | }) 15 | 16 | const broadcastSchema = new mongoose.Schema({ 17 | indexer: { 18 | type: String, 19 | required: true, 20 | }, 21 | category: { 22 | type: String, 23 | required: true, 24 | }, 25 | group: { 26 | type: String, 27 | required: true, 28 | }, 29 | country: { 30 | type: String, 31 | required: true, 32 | }, 33 | name: { 34 | type: String, 35 | unique: true, 36 | required: true, 37 | }, 38 | startTime: { 39 | type: Date, 40 | required: true, 41 | }, 42 | link: { 43 | type: String, 44 | unique: true, 45 | required: true, 46 | }, 47 | streams: [streamsSchema], 48 | logo: { 49 | type: String, 50 | }, 51 | jellyfinId: { 52 | type: String, 53 | }, 54 | tunerHostId: { 55 | type: String, 56 | }, 57 | streamIndex: { 58 | type: Number, 59 | default: 0, 60 | }, 61 | }) 62 | 63 | // Export mongoose model for Broadcast 64 | export type BroadcastDocument = InferSchemaType & { id?: string } 65 | 66 | export type BroadcastStream = InferSchemaType 67 | 68 | export const BroadcastModel = mongoose.model("Broadcast", broadcastSchema) 69 | -------------------------------------------------------------------------------- /src/modules/broadcast/service.ts: -------------------------------------------------------------------------------- 1 | import { BroadcastDocument, BroadcastModel, BroadcastStream } from "./model" 2 | 3 | type BroadcastsFilter = { 4 | country?: string 5 | group?: string 6 | category?: string 7 | indexer?: string 8 | } 9 | 10 | export async function createBroadcast(broadcast: BroadcastDocument): Promise { 11 | const existing = await BroadcastModel.findOne({ 12 | name: broadcast.name, 13 | group: broadcast.group, 14 | country: broadcast.country, 15 | category: broadcast.category, 16 | }) 17 | if (existing) { 18 | return existing 19 | } 20 | return BroadcastModel.create(broadcast) 21 | } 22 | 23 | export async function getBroadcast(id: string): Promise { 24 | return BroadcastModel.findById(id).orFail() 25 | } 26 | 27 | export async function getBroadcastByName(name: string): Promise { 28 | return BroadcastModel.findOne({ name }).orFail() 29 | } 30 | 31 | export async function getBroadcasts(filters: BroadcastsFilter): Promise { 32 | return BroadcastModel.find(filters, null, { sort: { startTime: 1 } }) 33 | } 34 | 35 | export async function setTunerHostId(id: string, tunerHostId: string): Promise { 36 | return BroadcastModel.findByIdAndUpdate(id, { tunerHostId }, { runValidators: true }) 37 | } 38 | 39 | export async function setStream(id: string, stream: BroadcastStream): Promise { 40 | return BroadcastModel.findByIdAndUpdate(id, { $set: { streams: [stream] } }, { runValidators: true }) 41 | } 42 | 43 | export async function setJellyfinId(id: string, jellyfinId: string): Promise { 44 | return BroadcastModel.findByIdAndUpdate(id, { jellyfinId }, { runValidators: true }) 45 | } 46 | 47 | export async function setStreamIndex(id: string, streamIndex: number): Promise { 48 | return BroadcastModel.findByIdAndUpdate(id, { streamIndex }, { runValidators: true }) 49 | } 50 | 51 | export async function removeBroadcast(id: string): Promise { 52 | return BroadcastModel.findByIdAndDelete(id) 53 | } 54 | 55 | export async function deleteBroadcasts(group: string, country: string, category: string): Promise { 56 | const filters: Partial = {} 57 | if (group) { 58 | filters.group = group 59 | } 60 | if (country) { 61 | filters.country = country 62 | } 63 | if (category) { 64 | filters.category = category 65 | } 66 | await BroadcastModel.deleteMany(filters) 67 | } 68 | -------------------------------------------------------------------------------- /src/modules/category/controller.ts: -------------------------------------------------------------------------------- 1 | import { Triggers } from "../agenda/triggers" 2 | import { BroadcastController } from "../broadcast" 3 | import { GroupController } from "../group" 4 | import { CategoryDocument } from "./model" 5 | import * as CategoryService from "./service" 6 | 7 | export async function reloadCategoryGroups(category: string): Promise { 8 | const groups = await GroupController.getActiveGroups(category) 9 | for (const group of groups) { 10 | // If the group has broadcasts, we publish it 11 | const broadcasts = await BroadcastController.getBroadcastsOfGroup(group.country, group.name, category) 12 | if (broadcasts.length > 0) { 13 | await Triggers.publishGroup(group.name, category, group.country) 14 | } 15 | } 16 | } 17 | 18 | export async function getCategory(name: string): Promise { 19 | return CategoryService.getCategory(name) 20 | } 21 | 22 | export async function createCategory(name: string): Promise { 23 | return CategoryService.createCategory(name) 24 | } 25 | 26 | export async function getCategories(): Promise { 27 | return CategoryService.getCategories() 28 | } 29 | 30 | export async function setEmoji(name: string, emoji: string): Promise { 31 | return CategoryService.setEmoji(name, emoji) 32 | } 33 | 34 | export async function setPublications(name: string, publisher: string, publicationIds: string[]): Promise { 35 | return CategoryService.setPublications(name, publisher, publicationIds) 36 | } 37 | 38 | export async function deleteCategory(name: string): Promise { 39 | await CategoryService.deleteCategory(name) 40 | await GroupController.deleteGroups(name) 41 | await BroadcastController.removeBroadcastsOfCategory(name) 42 | } 43 | -------------------------------------------------------------------------------- /src/modules/category/index.ts: -------------------------------------------------------------------------------- 1 | export * as CategoryController from "./controller" 2 | 3 | export { CategoryDocument } from "./model" 4 | -------------------------------------------------------------------------------- /src/modules/category/model.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { InferSchemaType } from "mongoose" 2 | 3 | const categorySchema = new mongoose.Schema({ 4 | name: { 5 | type: String, 6 | required: true, 7 | }, 8 | publications: { 9 | type: Map, 10 | of: [String], 11 | required: false, 12 | default: {}, 13 | }, 14 | emoji: { 15 | type: String, 16 | required: false, 17 | default: "", 18 | }, 19 | }) 20 | 21 | export const CategoryModel = mongoose.model("Category", categorySchema) 22 | 23 | export type CategoryDocument = InferSchemaType 24 | -------------------------------------------------------------------------------- /src/modules/category/service.ts: -------------------------------------------------------------------------------- 1 | import { CategoryDocument, CategoryModel } from "./model" 2 | 3 | export async function getCategory(name: string): Promise { 4 | return CategoryModel.findOne({ name }).orFail() 5 | } 6 | 7 | export async function createCategory(name: string): Promise { 8 | return CategoryModel.create({ name, publications: {} }) 9 | } 10 | 11 | export async function getCategories(): Promise { 12 | return CategoryModel.find() as Promise 13 | } 14 | 15 | export async function deleteCategory(name: string): Promise { 16 | await CategoryModel.findOneAndDelete({ name }).orFail() 17 | } 18 | 19 | export async function setDiscordMessageId(name: string, discordMessageId: string): Promise { 20 | await CategoryModel.updateOne({ name }, { discordMessageId }, { runValidators: true }) 21 | } 22 | 23 | export async function setEmoji(name: string, emoji: string): Promise { 24 | await CategoryModel.updateOne({ name }, { emoji }, { runValidators: true }) 25 | } 26 | 27 | export async function setPublications(name: string, publisher: string, publicationIds: string[]): Promise { 28 | await CategoryModel.updateOne( 29 | { name }, 30 | { $set: { [`publications.${publisher}`]: publicationIds } }, 31 | { upsert: true, runValidators: true }, 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/modules/config/controller.ts: -------------------------------------------------------------------------------- 1 | import { ConfigDocument } from "./model" 2 | import * as ConfigService from "./service" 3 | 4 | export async function getConfig(key: string): Promise { 5 | return ConfigService.getConfig(key) 6 | } 7 | 8 | export async function getConfigs(): Promise { 9 | return ConfigService.getConfigs() 10 | } 11 | 12 | export async function getNumberConfig(key: string): Promise { 13 | const config = await ConfigService.getConfig(key) 14 | const value = parseInt(config.value, 10) 15 | if (Number.isNaN(value)) { 16 | throw new Error("Invalid number value") 17 | } 18 | return value 19 | } 20 | 21 | export async function setConfig(key: string, value: string): Promise { 22 | return ConfigService.setConfig(key, value) 23 | } 24 | 25 | export async function unsetConfig(key: string): Promise { 26 | return ConfigService.unsetConfig(key) 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/config/index.ts: -------------------------------------------------------------------------------- 1 | export * as ConfigController from "./controller" 2 | 3 | export { ConfigDocument } from "./model" 4 | -------------------------------------------------------------------------------- /src/modules/config/model.ts: -------------------------------------------------------------------------------- 1 | // Create a simple model of a unique document in a MongoDB collection. 2 | 3 | import mongoose, { InferSchemaType } from "mongoose" 4 | 5 | const configSchema = new mongoose.Schema({ 6 | key: { 7 | type: String, 8 | required: true, 9 | unique: true, 10 | }, 11 | value: { 12 | type: String, 13 | required: true, 14 | }, 15 | }) 16 | 17 | export const ConfigModel = mongoose.model("Config", configSchema) 18 | 19 | export type ConfigDocument = InferSchemaType 20 | -------------------------------------------------------------------------------- /src/modules/config/service.ts: -------------------------------------------------------------------------------- 1 | import { ConfigDocument, ConfigModel } from "./model" 2 | 3 | export async function getConfig(key: string): Promise { 4 | return ConfigModel.findOne({ key }).orFail() 5 | } 6 | 7 | export async function getConfigs(): Promise { 8 | return ConfigModel.find() 9 | } 10 | 11 | export async function setConfig(key: string, value: string): Promise { 12 | return ConfigModel.findOneAndUpdate({ key }, { $set: { value } }, { upsert: true, new: true, runValidators: true }) 13 | } 14 | 15 | export async function unsetConfig(key: string): Promise { 16 | await ConfigModel.deleteOne({ key }) 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/group/controller.ts: -------------------------------------------------------------------------------- 1 | import mainLogger from "../../utils/logger" 2 | import { Triggers } from "../agenda/triggers" 3 | import { BroadcastController } from "../broadcast" 4 | import { IndexerController } from "../indexer" 5 | import { GroupDocument, GroupIndex } from "./model" 6 | import * as GroupService from "./service" 7 | 8 | export async function getAllGroups(): Promise { 9 | return GroupService.getGroups({}) 10 | } 11 | 12 | export async function getActiveGroups(category: string): Promise { 13 | return GroupService.getGroups({ category, active: true }) 14 | } 15 | 16 | export async function getInactiveGroups(category: string): Promise { 17 | return GroupService.getGroups({ category, active: false }) 18 | } 19 | 20 | export async function getGroupsOfCategory(category: string): Promise { 21 | return GroupService.getGroups({ category }) 22 | } 23 | 24 | export async function getGroup(group: GroupIndex): Promise { 25 | return GroupService.getGroup(group) 26 | } 27 | 28 | export async function setPublications(group: GroupIndex, publisher: string, publications: string[]): Promise { 29 | return GroupService.setPublications(group, publisher, publications) 30 | } 31 | 32 | export async function deleteGroups(category: string): Promise { 33 | return GroupService.deleteGroups(category) 34 | } 35 | 36 | export async function setEmoji(group: GroupIndex, emoji: string): Promise { 37 | return GroupService.setEmoji(group, emoji) 38 | } 39 | 40 | export async function createGroup(group: GroupIndex, active: boolean): Promise { 41 | await GroupService.createGroup(group, active) 42 | if (active) { 43 | const indexers = await IndexerController.getIndexers(true) 44 | for (const indexer of indexers) { 45 | await Triggers.indexCategory(group.category, indexer.name) 46 | } 47 | } 48 | } 49 | 50 | export async function updateActive(group: GroupIndex, active: boolean): Promise { 51 | await GroupService.updateActive(group, active) 52 | 53 | if (active) { 54 | const indexers = await IndexerController.getIndexers(true) 55 | for (const indexer of indexers) { 56 | await Triggers.indexCategory(group.category, indexer.name) 57 | } 58 | } else { 59 | await Triggers.publishGroup(group.name, group.category, group.country) 60 | } 61 | } 62 | 63 | export async function removeGroup(group: GroupIndex): Promise { 64 | await BroadcastController.removeBroadcastsOfGroup(group.name, group.category, group.country) 65 | await GroupService.removeGroup(group) 66 | } 67 | 68 | export async function reload(group: GroupIndex): Promise { 69 | const logger = mainLogger.child({ 70 | name: "GroupController", 71 | func: "reload", 72 | data: group, 73 | }) 74 | logger.info("Reloading group") 75 | return Triggers.publishGroup(group.name, group.category, group.country) 76 | } 77 | -------------------------------------------------------------------------------- /src/modules/group/index.ts: -------------------------------------------------------------------------------- 1 | export * as GroupController from "./controller" 2 | 3 | export { GroupDocument, GroupIndex } from "./model" 4 | -------------------------------------------------------------------------------- /src/modules/group/model.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { InferSchemaType } from "mongoose" 2 | 3 | const groupSchema = new mongoose.Schema({ 4 | name: { 5 | type: String, 6 | required: true, 7 | }, 8 | country: { 9 | type: String, 10 | required: true, 11 | }, 12 | category: { 13 | type: String, 14 | required: true, 15 | }, 16 | emoji: { 17 | type: String, 18 | required: false, 19 | }, 20 | publications: { 21 | type: Map, 22 | of: [String], 23 | required: false, 24 | default: {}, 25 | }, 26 | active: { 27 | type: Boolean, 28 | required: true, 29 | default: false, 30 | }, 31 | }) 32 | 33 | groupSchema.index({ name: 1, country: 1, category: 1 }, { unique: true }) 34 | 35 | export type GroupDocument = InferSchemaType 36 | 37 | export type GroupIndex = Pick 38 | 39 | export const GroupModel = mongoose.model("Group", groupSchema) 40 | -------------------------------------------------------------------------------- /src/modules/group/service.ts: -------------------------------------------------------------------------------- 1 | import { GroupDocument, GroupIndex, GroupModel } from "./model" 2 | 3 | export async function createGroup({ category, name, country }: GroupIndex, active: boolean): Promise { 4 | return GroupModel.create({ name, category, active, country }) 5 | } 6 | 7 | export async function getGroup({ category, name, country }: GroupIndex): Promise { 8 | return GroupModel.findOne({ name, category, country }).orFail() 9 | } 10 | 11 | export async function getGroups(query: Partial): Promise { 12 | return GroupModel.find(query) 13 | } 14 | 15 | export async function setPublications( 16 | { category, name, country }: GroupIndex, 17 | publisher: string, 18 | publicationIds: string[], 19 | ): Promise { 20 | await GroupModel.findOneAndUpdate( 21 | { name, country, category }, 22 | { $set: { [`publications.${publisher}`]: publicationIds } }, 23 | { upsert: true, runValidators: true }, 24 | ) 25 | } 26 | 27 | export async function setEmoji({ category, name, country }: GroupIndex, emoji: string): Promise { 28 | await GroupModel.findOneAndUpdate({ name, category, country }, { $set: { emoji } }, { runValidators: true }).orFail() 29 | } 30 | 31 | export async function updateActive({ category, name, country }: GroupIndex, active: boolean): Promise { 32 | await GroupModel.findOneAndUpdate({ name, category, country }, { $set: { active } }, { runValidators: true }).orFail() 33 | } 34 | 35 | export async function removeGroup({ category, name, country }: GroupIndex): Promise { 36 | await GroupModel.findOneAndDelete({ name, category, country }).orFail() 37 | } 38 | 39 | export async function deleteGroups(category: string): Promise { 40 | await GroupModel.deleteMany({ category }) 41 | } 42 | -------------------------------------------------------------------------------- /src/modules/indexer/controller.ts: -------------------------------------------------------------------------------- 1 | import { IndexerData, IndexerDocument, IndexerInterceptorData, LoginData } from "./model" 2 | import * as IndexerService from "./service" 3 | 4 | export async function getIndexers(active?: boolean): Promise { 5 | return IndexerService.getIndexers(active) 6 | } 7 | 8 | export async function getIndexer(name: string): Promise { 9 | return IndexerService.getIndexer(name) 10 | } 11 | 12 | export async function getActiveIndexer(name: string): Promise { 13 | return IndexerService.getActiveIndexer(name) 14 | } 15 | 16 | export async function createIndexer(name: string, url: string): Promise { 17 | return IndexerService.createIndexer(name, url) 18 | } 19 | 20 | export async function updateLogin(name: string, data: Partial): Promise { 21 | return IndexerService.updateLogin(name, data) 22 | } 23 | 24 | export async function updateActive(name: string, active: boolean): Promise { 25 | return IndexerService.updateActive(name, active) 26 | } 27 | 28 | export async function updateIndexer(name: string, url: string): Promise { 29 | return IndexerService.updateIndexer(name, url) 30 | } 31 | 32 | export async function updateIndexerData(name: string, data: Partial): Promise { 33 | return IndexerService.updateIndexerData(name, data) 34 | } 35 | 36 | export async function updateIndexerInterceptorData( 37 | name: string, 38 | interceptorData: Partial, 39 | ): Promise { 40 | return IndexerService.updateIndexerInterceptorData(name, interceptorData) 41 | } 42 | 43 | export async function updateScenarios(name: string, scenarios: any): Promise { 44 | return IndexerService.updateScenarios(name, scenarios) 45 | } 46 | 47 | export async function validateIndexer(name: string): Promise { 48 | return IndexerService.validateIndexer(name) 49 | } 50 | 51 | export async function deleteIndexer(name: string): Promise { 52 | return IndexerService.deleteIndexer(name) 53 | } 54 | -------------------------------------------------------------------------------- /src/modules/indexer/index.ts: -------------------------------------------------------------------------------- 1 | export * as IndexerController from "./controller" 2 | 3 | export { 4 | DateSelector as DBDateSelector, 5 | RegexSelector as DBRegexSelector, 6 | Selector as DBSelector, 7 | TextContentSelector as DBTextContentSelector, 8 | IndexerDocument, 9 | } from "./model" 10 | 11 | export * from "./commands" 12 | -------------------------------------------------------------------------------- /src/modules/indexer/service.ts: -------------------------------------------------------------------------------- 1 | import { IndexerData, IndexerDocument, IndexerInterceptorData, IndexerModel, LoginData } from "./model" 2 | 3 | export async function getIndexers(active?: boolean): Promise { 4 | if (active !== undefined) { 5 | return IndexerModel.find({ active }) 6 | } 7 | return IndexerModel.find() 8 | } 9 | 10 | export async function getIndexer(name: string): Promise { 11 | return IndexerModel.findOne({ name }).orFail() 12 | } 13 | 14 | export async function getActiveIndexer(name: string): Promise { 15 | return IndexerModel.findOne({ name, active: true }).orFail() 16 | } 17 | 18 | export async function createIndexer(name: string, url: string): Promise { 19 | return IndexerModel.create({ name, url }) 20 | } 21 | 22 | export async function updateLogin(name: string, data: Partial): Promise { 23 | return IndexerModel.findOneAndUpdate({ name }, { $set: { login: data } }, { new: true, runValidators: true }) 24 | } 25 | 26 | export async function updateActive(name: string, active: boolean): Promise { 27 | return IndexerModel.findOneAndUpdate({ name }, { active }, { new: true, runValidators: true }) 28 | } 29 | 30 | export async function updateIndexer(name: string, url: string): Promise { 31 | return IndexerModel.findOneAndUpdate({ name }, { url }, { new: true, runValidators: true }) 32 | } 33 | 34 | export async function updateIndexerData(name: string, data: Partial): Promise { 35 | return IndexerModel.findOneAndUpdate({ name }, { $set: { data } }, { new: true, runValidators: true }) 36 | } 37 | 38 | export async function updateIndexerInterceptorData( 39 | name: string, 40 | interceptorData: Partial, 41 | ): Promise { 42 | return IndexerModel.findOneAndUpdate({ name }, { $set: { interceptorData } }, { new: true, runValidators: true }) 43 | } 44 | 45 | export async function validateIndexer(name: string): Promise { 46 | const doc = await IndexerModel.findOne({ name }).orFail() 47 | await doc.validate() 48 | } 49 | 50 | export async function updateScenarios(name: string, scenarios: any): Promise { 51 | return IndexerModel.findOneAndUpdate({ name }, { $set: { scenarios } }, { new: true, runValidators: true }) 52 | } 53 | 54 | export async function deleteIndexer(name: string): Promise { 55 | await IndexerModel.deleteOne({ name }) 56 | } 57 | -------------------------------------------------------------------------------- /src/modules/indexers/converters.ts: -------------------------------------------------------------------------------- 1 | import { DBDateSelector, DBRegexSelector, DBSelector, DBTextContentSelector } from "../indexer" 2 | import { DateSelector, RegexSelector, Selector, TextContentSelector } from "./scrapper" 3 | 4 | const convertSelector = (item: DBSelector): Selector => ({ 5 | path: item.path, 6 | }) 7 | 8 | const convertTextSelector = (item: DBTextContentSelector): TextContentSelector => ({ 9 | ...convertSelector(item), 10 | attribute: item.attribute, 11 | replacement: item.replacement 12 | ? { 13 | regex: new RegExp(item.replacement.regex), 14 | replace: item.replacement.replace, 15 | } 16 | : null, 17 | }) 18 | 19 | const convertDateSelector = (item: DBDateSelector): DateSelector => ({ 20 | ...convertTextSelector(item), 21 | format: item.format, 22 | dateReplacement: item.dateReplacement 23 | ? { 24 | regex: new RegExp(item.dateReplacement.regex), 25 | format: item.dateReplacement.format, 26 | } 27 | : null, 28 | }) 29 | 30 | const convertRegexSelector = (item: DBRegexSelector): RegexSelector => ({ 31 | ...convertTextSelector(item), 32 | regex: new RegExp(item.regex), 33 | default: Object.fromEntries(Array.from(item.default.entries())), 34 | }) 35 | 36 | export { convertSelector, convertTextSelector, convertDateSelector, convertRegexSelector } 37 | -------------------------------------------------------------------------------- /src/modules/indexers/dynamicBroadcastInterceptor.ts: -------------------------------------------------------------------------------- 1 | import { BroadcastDocument } from "../broadcast" 2 | import { IndexerDocument } from "../indexer" 3 | import { convertTextSelector } from "./converters" 4 | import GenericBroadcastInterceptor from "./genericBroadcastInterceptor" 5 | import { Selector, TextContentSelector } from "./scrapper" 6 | 7 | export default class DynamicBroadcastInterceptor extends GenericBroadcastInterceptor { 8 | protected override loadPageElement: string 9 | 10 | protected override streamItems: Selector[] 11 | 12 | protected override positiveScores: Selector[] 13 | 14 | protected override link: TextContentSelector[] 15 | 16 | protected override clickButton: Selector[] 17 | 18 | protected override referer: string 19 | 20 | constructor(indexer: IndexerDocument, broadcast: BroadcastDocument) { 21 | super(indexer.name, broadcast.name, broadcast.link, broadcast.streamIndex) 22 | this.loadPageElement = indexer.interceptorData.loadPageElement 23 | this.streamItems = indexer.interceptorData.streamItems 24 | this.positiveScores = indexer.interceptorData.positiveScores 25 | this.link = indexer.interceptorData.link.map((item) => convertTextSelector(item)) 26 | this.clickButton = indexer.interceptorData.clickButton 27 | this.referer = indexer.interceptorData.referer 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/modules/indexers/dynamicBroadcastsIndexer.ts: -------------------------------------------------------------------------------- 1 | import { IndexerDocument } from "../indexer" 2 | import { CategoryDetails } from "./broadcastsIndexer" 3 | import { convertDateSelector, convertRegexSelector, convertTextSelector } from "./converters" 4 | import GenericBroadcastsIndexer, { BroadcastDetails, BroadcastSetDetails } from "./genericBroadcastsIndexer" 5 | import { Selector } from "./scrapper" 6 | 7 | export default class DynamicBroadcastsIndexer extends GenericBroadcastsIndexer { 8 | protected override loadPageElement: string 9 | 10 | protected override categoryDetails: CategoryDetails 11 | 12 | protected override broadcastSets: BroadcastSetDetails 13 | 14 | protected override broadcast: BroadcastDetails 15 | 16 | protected override nextPage: Selector[] 17 | 18 | protected override teamSplitterRegex: RegExp 19 | 20 | constructor( 21 | private indexer: IndexerDocument, 22 | category: string, 23 | ) { 24 | super(indexer.url, indexer.name, category) 25 | this.loadPageElement = this.indexer.data.loadPageElement 26 | 27 | if (this.indexer.data.broadcastSets) { 28 | this.broadcastSets = { 29 | selector: this.indexer.data.broadcastSets.selector, 30 | day: this.indexer.data.broadcastSets.day.map((item) => convertDateSelector(item)), 31 | } 32 | } else { 33 | this.broadcastSets = null 34 | } 35 | 36 | this.broadcast = { 37 | selector: this.indexer.data.broadcast.selector, 38 | startTime: this.indexer.data.broadcast.startTime.map((item) => convertDateSelector(item)), 39 | link: this.indexer.data.broadcast.link.map((item) => convertTextSelector(item)), 40 | name: this.indexer.data.broadcast.name.map((item) => convertTextSelector(item)), 41 | group: this.indexer.data.broadcast.group.map((item) => convertRegexSelector(item)), 42 | } 43 | this.nextPage = this.indexer.data.nextPage 44 | 45 | this.categoryDetails = { 46 | links: this.indexer.data.category.links, 47 | clicks: this.indexer.data.category.clicks, 48 | lookups: this.indexer.data.category.lookups, 49 | } 50 | 51 | this.loginDetails = this.indexer.login 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/modules/indexers/genericBroadcastInterceptor.ts: -------------------------------------------------------------------------------- 1 | import { ElementHandle } from "puppeteer-core" 2 | 3 | import mainLogger from "../../utils/logger" 4 | import BroadcastInterceptor, { StreamData, StreamDataCallback } from "./broadcastInterceptor" 5 | import { Selector, TextContentSelector } from "./scrapper" 6 | 7 | type StreamLink = { link: string; score?: number } 8 | 9 | export default abstract class GenericBroadcastInterceptor extends BroadcastInterceptor { 10 | constructor( 11 | indexer: string, 12 | broadcastName: string, 13 | protected broadcastLink: string, 14 | streamIndex: number, 15 | ) { 16 | super(indexer, broadcastName, streamIndex) 17 | } 18 | 19 | protected abstract loadPageElement: string 20 | 21 | protected abstract streamItems: Selector[] 22 | 23 | protected abstract positiveScores: Selector[] 24 | 25 | protected abstract link: TextContentSelector[] 26 | 27 | protected abstract clickButton: Selector[] 28 | 29 | protected abstract referer: string 30 | 31 | public override async getStream(): Promise { 32 | const logger = mainLogger.child({ 33 | name: "BroadcastInterceptor", 34 | func: "getStream", 35 | data: { 36 | broadcastName: this.broadcastName, 37 | }, 38 | }) 39 | logger.debug("Getting stream") 40 | const page = await this.getPage(this.broadcastLink, this.loadPageElement) 41 | 42 | // Now retrieving the stream links 43 | const ratedStreamsLinks: StreamLink[] = [] 44 | 45 | const streamsItems = await this.getElements(page, this.streamItems) 46 | 47 | for (const streamItem of streamsItems) { 48 | const score = await this.getElements(streamItem, this.positiveScores) 49 | 50 | try { 51 | const link = await this.getTextContent(streamItem, this.link) 52 | ratedStreamsLinks.push({ link, score: score.length }) 53 | } catch (error) { 54 | // Assuming that the link was not found 55 | const elt = (await streamItem) as ElementHandle 56 | const prop = await elt.getProperty("href") 57 | const link = await prop.jsonValue() 58 | ratedStreamsLinks.push({ link, score: score.length }) 59 | } 60 | } 61 | 62 | // Sort the links by the number of stars 63 | const allStreamsLinks = ratedStreamsLinks.sort((itemA, itemB) => itemB.score - itemA.score).map((link) => link.link) 64 | 65 | // Define callback if the clickButtonSelector is defined 66 | const clickButtonCallback: StreamDataCallback = this.clickButton 67 | ? async (streamPage, index) => { 68 | logger.debug("Callback for streamIndex", index) 69 | const btns = await this.getElements(streamPage, this.clickButton) 70 | for (const button of btns) { 71 | logger.debug("Clicking on the button") 72 | await button.click() 73 | } 74 | } 75 | : () => Promise.resolve() 76 | 77 | return this.getStreamData(allStreamsLinks, this.referer, clickButtonCallback) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/modules/indexers/index.ts: -------------------------------------------------------------------------------- 1 | import DynamicBroadcastInterceptor from "./dynamicBroadcastInterceptor" 2 | import DynamicBroadcastsIndexer from "./dynamicBroadcastsIndexer" 3 | 4 | export { DynamicBroadcastInterceptor, DynamicBroadcastsIndexer } 5 | -------------------------------------------------------------------------------- /src/modules/nodeProperties/controller.ts: -------------------------------------------------------------------------------- 1 | import env from "../../config/env" 2 | import { NodePropertiesDocument } from "./model" 3 | import * as NodePropertiesService from "./service" 4 | 5 | export async function setNodeProperty(type: string, key: string, value: string): Promise { 6 | return NodePropertiesService.setNodeProperty(env.nodeUuid, type, key, value) 7 | } 8 | 9 | // export async function getNodeProperty(key: string): Promise { 10 | // return NodePropertiesService.getNodeProperty(env.nodeUuid, key) 11 | // } 12 | 13 | // export async function getNodeProperties(key: string): Promise { 14 | // return NodePropertiesService.getNodeProperties(env.nodeUuid, key) 15 | // } 16 | 17 | export async function getNodePropertiesByType(type: string): Promise { 18 | return NodePropertiesService.getNodePropertiesByType(type) 19 | } 20 | 21 | export async function deleteNodeProperty(key: string): Promise { 22 | return NodePropertiesService.deleteNodeProperty(env.nodeUuid, key) 23 | } 24 | 25 | export async function deleteNodeProperties(): Promise { 26 | return NodePropertiesService.deleteNodeProperties(env.nodeUuid) 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/nodeProperties/index.ts: -------------------------------------------------------------------------------- 1 | import env from "../../config/env" 2 | import mainLogger from "../../utils/logger" 3 | import onExit from "../../utils/onExit" 4 | import * as NodePropertiesController from "./controller" 5 | 6 | export { NodePropertiesController } 7 | 8 | export { NodePropertiesDocument } from "./model" 9 | 10 | onExit(async () => { 11 | const logger = mainLogger.child({ name: "NodeProperties", func: "onExit", data: { nodeUuid: env.nodeUuid } }) 12 | logger.info("Deleting node properties") 13 | await NodePropertiesController.deleteNodeProperties() 14 | logger.info("Node properties deleted") 15 | }, 10) 16 | -------------------------------------------------------------------------------- /src/modules/nodeProperties/model.ts: -------------------------------------------------------------------------------- 1 | // Create a simple model of a unique document in a MongoDB collection. 2 | 3 | import mongoose, { InferSchemaType } from "mongoose" 4 | 5 | const nodePropertiesSchema = new mongoose.Schema( 6 | { 7 | uuid: { 8 | type: String, 9 | required: true, 10 | }, 11 | type: { 12 | type: String, 13 | }, 14 | key: { 15 | type: String, 16 | required: true, 17 | }, 18 | value: { 19 | type: String, 20 | required: true, 21 | }, 22 | }, 23 | { timestamps: true }, 24 | ) 25 | 26 | // Index is uuid + key 27 | nodePropertiesSchema.index({ uuid: 1, key: 1 }, { unique: true }) 28 | 29 | export const NodePropertiesModel = mongoose.model("NodeProperties", nodePropertiesSchema) 30 | 31 | export type NodePropertiesDocument = InferSchemaType 32 | -------------------------------------------------------------------------------- /src/modules/nodeProperties/service.ts: -------------------------------------------------------------------------------- 1 | import { NodePropertiesDocument, NodePropertiesModel } from "./model" 2 | 3 | // Create node data 4 | export async function setNodeProperty( 5 | uuid: string, 6 | type: string, 7 | key: string, 8 | value: string, 9 | ): Promise { 10 | return NodePropertiesModel.findOneAndUpdate( 11 | { uuid, key }, 12 | { $set: { type, value } }, 13 | { upsert: true, new: true, runValidators: true }, 14 | ) 15 | } 16 | 17 | // Get node data 18 | export async function getNodeProperty(uuid: string, key: string): Promise { 19 | return NodePropertiesModel.findOne({ uuid, key }).orFail() 20 | } 21 | 22 | export async function getNodePropertiesByType(type: string): Promise { 23 | return NodePropertiesModel.find({ type }) 24 | } 25 | 26 | // Get key node data 27 | export async function getNodeProperties(uuid: string, key: string): Promise { 28 | return NodePropertiesModel.find({ uuid, key }) 29 | } 30 | 31 | export async function deleteNodeProperty(uuid: string, key: string): Promise { 32 | await NodePropertiesModel.deleteOne({ uuid, key }) 33 | } 34 | 35 | export async function deleteNodeProperties(uuid: string): Promise { 36 | await NodePropertiesModel.deleteMany({ uuid }) 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/publishers/implementations/gotify.ts: -------------------------------------------------------------------------------- 1 | import { Application, GotifyAPI } from "../../../api/gotify" 2 | import mainLogger from "../../../utils/logger" 3 | import { CategoryDocument } from "../../category" 4 | import MarkdownPublisher from "../markdown" 5 | 6 | class GotifyPublisher extends MarkdownPublisher { 7 | public name = "Gotify" 8 | 9 | public constructor() { 10 | super() 11 | } 12 | 13 | public async bootstrap(): Promise { 14 | // Nothing to do here 15 | } 16 | 17 | public async start(): Promise { 18 | // Nothing to do here 19 | } 20 | 21 | private generateApplicationName(category: CategoryDocument): string { 22 | return `Broadcastarr - ${category.name}` 23 | } 24 | 25 | private async getOrCreateApplication(category: CategoryDocument): Promise { 26 | const applications = await GotifyAPI.getApplications() 27 | const application = applications.find(({ name }) => name === this.generateApplicationName(category)) 28 | if (application) { 29 | return application 30 | } 31 | return GotifyAPI.createApplication({ name: this.generateApplicationName(category) }) 32 | } 33 | 34 | public async clear(category: CategoryDocument): Promise { 35 | const logger = mainLogger.child({ name: "GotifyPublisher", func: "clear", data: { category: category.name } }) 36 | const applications = await GotifyAPI.getApplications() 37 | const allApplicationsOfCategory = applications.filter(({ name }) => name === this.generateApplicationName(category)) 38 | for (const application of allApplicationsOfCategory) { 39 | logger.info(`Deleting application ${application.name}`) 40 | await GotifyAPI.deleteApplication(application.id) 41 | } 42 | 43 | // Recreate application 44 | logger.info("Recreating application") 45 | const app = await GotifyAPI.createApplication({ name: this.generateApplicationName(category) }) 46 | logger.info(`Application created with id ${app.id}`) 47 | } 48 | 49 | public override async listMessages(category: CategoryDocument): Promise { 50 | const logger = mainLogger.child({ 51 | name: "GotifyPublisher", 52 | func: "listMessages", 53 | data: { category: category.name }, 54 | }) 55 | const application = await this.getOrCreateApplication(category) 56 | logger.info(`Getting messages for application ${application.id}`) 57 | const messages = await GotifyAPI.getMessagesOfApplication(application.id) 58 | return messages.map(({ id }) => `${id}`) 59 | } 60 | 61 | protected override async sendMessage(category: CategoryDocument, message: string): Promise { 62 | const logger = mainLogger.child({ 63 | name: "GotifyPublisher", 64 | func: "sendMessages", 65 | data: { category: category.name }, 66 | }) 67 | const application = await this.getOrCreateApplication(category) 68 | logger.info(`Sending message to application ${application.name}`) 69 | const extras = { 70 | "client::display": { 71 | contentType: "text/markdown", 72 | }, 73 | } 74 | const msg = await GotifyAPI.createMessage(application, { message, extras }) 75 | return [`${msg.id}`] 76 | } 77 | 78 | protected async removeMessages(category: string, ids: string[]): Promise { 79 | const logger = mainLogger.child({ 80 | name: "GotifyPublisher", 81 | func: "removeMessages", 82 | data: { category }, 83 | }) 84 | logger.info(`Removing messages from category ${category}`) 85 | for (const id of ids) { 86 | await GotifyAPI.deleteMessage(id) 87 | } 88 | } 89 | 90 | public async updateChannelName(category: CategoryDocument): Promise { 91 | const logger = mainLogger.child({ 92 | name: "GotifyPublisher", 93 | func: "updateChannelName", 94 | data: { category: category.name }, 95 | }) 96 | logger.silly("Nothing to do here") 97 | } 98 | } 99 | 100 | export default GotifyPublisher 101 | -------------------------------------------------------------------------------- /src/modules/publishers/index.ts: -------------------------------------------------------------------------------- 1 | export * as PublishersController from "./controller" 2 | -------------------------------------------------------------------------------- /src/modules/publishers/model.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { InferSchemaType } from "mongoose" 2 | 3 | const publisherSchema = new mongoose.Schema({ 4 | name: { 5 | type: String, 6 | required: true, 7 | unique: true, 8 | }, 9 | active: { 10 | type: Boolean, 11 | required: true, 12 | }, 13 | }) 14 | 15 | export type PublisherDocument = InferSchemaType 16 | 17 | export const PublisherModel = mongoose.model("Publisher", publisherSchema) 18 | -------------------------------------------------------------------------------- /src/modules/publishers/service.ts: -------------------------------------------------------------------------------- 1 | import { PublisherDocument, PublisherModel } from "./model" 2 | 3 | export async function getPublisher(name: string): Promise { 4 | return PublisherModel.findOne({ name }) 5 | } 6 | 7 | export async function createPublisher(name: string, active: boolean): Promise { 8 | return PublisherModel.create({ name, active }) 9 | } 10 | 11 | export async function getPublishers(data: { active?: boolean }): Promise { 12 | return PublisherModel.find(data) 13 | } 14 | 15 | export async function deletePublisher(name: string): Promise { 16 | await PublisherModel.deleteOne({ name }) 17 | } 18 | 19 | export async function updatePublisher(name: string, data: Partial): Promise { 20 | return PublisherModel.findOneAndUpdate({ name }, data, { new: true, runValidators: true }) 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/releasers/controller.ts: -------------------------------------------------------------------------------- 1 | import { BroadcastDocument } from "../broadcast" 2 | import Jellyfin from "./implementations/jellyfin" 3 | import { ReleaserDocument } from "./model" 4 | import * as Service from "./service" 5 | import { IReleaser } from "./types" 6 | 7 | const implementations: Record IReleaser> = { 8 | Jellyfin, 9 | } 10 | 11 | const publishers: Record = {} 12 | 13 | // Documents functions 14 | export async function getReleaser(name: string): Promise { 15 | return Service.getReleaser(name) 16 | } 17 | 18 | export async function createReleaser(name: string, active: boolean): Promise { 19 | return Service.createReleaser(name, active) 20 | } 21 | 22 | export async function updateActive(name: string, active: boolean): Promise { 23 | return Service.updateReleaser(name, { active }) 24 | } 25 | 26 | export async function getActiveReleasers(): Promise { 27 | return Service.getReleasers({ active: true }) 28 | } 29 | 30 | export async function getAllReleasers(): Promise { 31 | return Service.getReleasers({}) 32 | } 33 | 34 | export async function deleteReleaser(name: string): Promise { 35 | return Service.deleteReleaser(name) 36 | } 37 | 38 | // Releaser functions 39 | async function getReleaserInstance(name: string): Promise { 40 | if (!publishers[name]) { 41 | const releaser = await getReleaser(name) 42 | if (!releaser) { 43 | throw new Error(`Releaser ${name} not found`) 44 | } 45 | const Implementation = implementations[name] 46 | if (!Implementation) { 47 | throw new Error(`Implementation for ${name} not found`) 48 | } 49 | publishers[name] = new Implementation() 50 | } 51 | return publishers[name] 52 | } 53 | 54 | export async function bootstrapReleasers(): Promise { 55 | const releasers = await getAllReleasers() 56 | for (const doc of releasers) { 57 | const releaser = await getReleaserInstance(doc.name) 58 | await releaser.bootstrap() 59 | } 60 | } 61 | 62 | export async function releaseBroadcast(broadcast: BroadcastDocument): Promise { 63 | const releasers = await getAllReleasers() 64 | for (const doc of releasers) { 65 | const releaser = await getReleaserInstance(doc.name) 66 | await releaser.releaseBroadcast(broadcast) 67 | } 68 | } 69 | 70 | export async function unreleaseBroadcast(broadcast: BroadcastDocument): Promise { 71 | const releasers = await getAllReleasers() 72 | for (const doc of releasers) { 73 | const releaser = await getReleaserInstance(doc.name) 74 | await releaser.unreleaseBroadcast(broadcast) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/modules/releasers/index.ts: -------------------------------------------------------------------------------- 1 | export * as ReleasersController from "./controller" 2 | -------------------------------------------------------------------------------- /src/modules/releasers/model.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { InferSchemaType } from "mongoose" 2 | 3 | const releaserSchema = new mongoose.Schema({ 4 | name: { 5 | type: String, 6 | required: true, 7 | unique: true, 8 | }, 9 | active: { 10 | type: Boolean, 11 | required: true, 12 | }, 13 | }) 14 | 15 | export type ReleaserDocument = InferSchemaType 16 | 17 | export const ReleaserModel = mongoose.model("Releaser", releaserSchema) 18 | -------------------------------------------------------------------------------- /src/modules/releasers/service.ts: -------------------------------------------------------------------------------- 1 | import { ReleaserDocument, ReleaserModel } from "./model" 2 | 3 | export async function getReleaser(name: string): Promise { 4 | return ReleaserModel.findOne({ name }) 5 | } 6 | 7 | export async function createReleaser(name: string, active: boolean): Promise { 8 | return ReleaserModel.create({ name, active }) 9 | } 10 | 11 | export async function getReleasers(data: { active?: boolean }): Promise { 12 | return ReleaserModel.find(data) 13 | } 14 | 15 | export async function deleteReleaser(name: string): Promise { 16 | await ReleaserModel.deleteOne({ name }) 17 | } 18 | 19 | export async function updateReleaser(name: string, data: Partial): Promise { 20 | return ReleaserModel.findOneAndUpdate({ name }, data, { new: true, runValidators: true }) 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/releasers/types.ts: -------------------------------------------------------------------------------- 1 | import { BroadcastDocument } from "../broadcast/model" 2 | 3 | export interface IReleaser { 4 | bootstrap(): Promise 5 | releaseBroadcast(broadcast: BroadcastDocument): Promise 6 | unreleaseBroadcast(broadcast: BroadcastDocument): Promise 7 | } 8 | 9 | export function isReleaser(instance: any): instance is IReleaser { 10 | if (typeof instance.bootstrap !== "function") { 11 | throw new Error("Missing or invalid 'bootstrap': must be a function.") 12 | } 13 | 14 | if (typeof instance.releaseBroadcast !== "function") { 15 | throw new Error("Missing or invalid 'releaseBroadcast': must be a function.") 16 | } 17 | 18 | if (typeof instance.unreleaseBroadcast !== "function") { 19 | throw new Error("Missing or invalid 'unreleaseBroadcast': must be a function.") 20 | } 21 | 22 | return true 23 | } 24 | -------------------------------------------------------------------------------- /src/modules/role/controller.ts: -------------------------------------------------------------------------------- 1 | import { RoleDocument } from "./model" 2 | import * as RoleService from "./service" 3 | 4 | export async function hasAbility(roles: string[], ability: string): Promise { 5 | return RoleService.hasAbility(roles, ability) 6 | } 7 | 8 | export async function getRole(name: string): Promise { 9 | return RoleService.getRole(name) 10 | } 11 | 12 | export async function createRole(name: string, abilities: string[]): Promise { 13 | return RoleService.createRole(name, abilities) 14 | } 15 | 16 | export async function getRoles(): Promise { 17 | return RoleService.getRoles() 18 | } 19 | 20 | export async function addAbilities(name: string, abilities: string[]): Promise { 21 | return RoleService.addAbilities(name, abilities) 22 | } 23 | 24 | export async function deleteAbilities(name: string, abilities: string[]): Promise { 25 | return RoleService.deleteAbilities(name, abilities) 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/role/index.ts: -------------------------------------------------------------------------------- 1 | export * as RoleController from "./controller" 2 | 3 | export { RoleDocument as AbilityDocument } from "./model" 4 | -------------------------------------------------------------------------------- /src/modules/role/model.ts: -------------------------------------------------------------------------------- 1 | // Create a simple model of a unique document in a MongoDB collection. 2 | import mongoose, { InferSchemaType } from "mongoose" 3 | 4 | const roleSchema = new mongoose.Schema({ 5 | name: { 6 | type: String, 7 | unique: true, 8 | required: true, 9 | }, 10 | abilities: { 11 | type: [String], 12 | required: true, 13 | default: [], 14 | }, 15 | }) 16 | 17 | export const RoleModel = mongoose.model("Role", roleSchema) 18 | 19 | export type RoleDocument = InferSchemaType 20 | -------------------------------------------------------------------------------- /src/modules/role/service.ts: -------------------------------------------------------------------------------- 1 | import { RoleDocument, RoleModel } from "./model" 2 | 3 | export async function hasAbility(roles: string[], ability: string): Promise { 4 | const aggregate = await RoleModel.aggregate([ 5 | // Match the roles 6 | { $match: { name: { $in: roles } } }, 7 | // Merge the abilities 8 | { $unwind: "$abilities" }, 9 | // Match the ability 10 | { $match: { abilities: ability } }, 11 | ]) 12 | 13 | if (aggregate?.length > 0) { 14 | return true 15 | } 16 | 17 | return false 18 | } 19 | 20 | export async function getRole(name: string): Promise { 21 | return RoleModel.findOne({ name }).orFail() 22 | } 23 | 24 | export async function createRole(name: string, abilities: string[]): Promise { 25 | return RoleModel.create({ name, abilities }) 26 | } 27 | 28 | export async function getRoles(): Promise { 29 | return RoleModel.find() 30 | } 31 | 32 | export async function addAbilities(name: string, abilities: string[]): Promise { 33 | return RoleModel.findOneAndUpdate({ name }, { $addToSet: { abilities } }, { new: true, runValidators: true }).orFail() 34 | } 35 | 36 | export async function deleteAbilities(name: string, abilities: string[]): Promise { 37 | return RoleModel.findOneAndUpdate({ name }, { $pull: { abilities } }, { new: true, runValidators: true }).orFail() 38 | } 39 | -------------------------------------------------------------------------------- /src/modules/scrapper/Orchestrator.ts: -------------------------------------------------------------------------------- 1 | import mainLogger from "../../utils/logger" 2 | import { BroadcastDocument } from "../broadcast" 3 | import { ConfigController } from "../config" 4 | import { CommandDocument } from "../indexer" 5 | import { ScenariosDocument } from "../indexer/model" 6 | import { StreamData } from "../indexers/broadcastInterceptor" 7 | import { generateCommand } from "./commands" 8 | import { Command, Context } from "./commands/command" 9 | import { RunScenarioCommand } from "./commands/scenario/RunScenario" 10 | import { Scrapper } from "./scrapper" 11 | 12 | type Scenario = Map 13 | 14 | export type IndexReturn = { 15 | broadcasts: BroadcastDocument[] 16 | } 17 | 18 | export type InterceptReturn = { 19 | stream: StreamData 20 | streamIndex: number 21 | } 22 | 23 | type RunReturnMap = { 24 | index: IndexReturn 25 | intercept: InterceptReturn 26 | } 27 | 28 | export class Orchestrator extends Scrapper { 29 | protected scenarios: Map 30 | 31 | constructor( 32 | docs: ScenariosDocument, 33 | private execution: Record, 34 | ) { 35 | super() 36 | 37 | // Convert the docs to a map of scenarios 38 | this.scenarios = new Map() 39 | for (const [name, dbScenario] of docs) { 40 | const scenario: Scenario = new Map() 41 | for (const [key, value] of dbScenario as Map) { 42 | scenario.set(key, generateCommand(value, key)) 43 | } 44 | this.scenarios.set(name, scenario) 45 | } 46 | } 47 | 48 | async run(scenario: T): Promise> { 49 | const logger = mainLogger.child({ name: "Orchestrator", func: "run" }) 50 | 51 | const browser = await this.getBrowser() 52 | const browserContext = await browser.createBrowserContext() 53 | const page = await browserContext.newPage() 54 | await page.setCacheEnabled(false) 55 | 56 | const futureLimit = await ConfigController.getNumberConfig("filter-limit-future") 57 | const context: Context, Partial> = { 58 | // The global context 59 | global: { 60 | futureLimit, 61 | }, 62 | // This run context 63 | execution: this.execution, 64 | // A scenario context, emptied at each scenario 65 | scenario: {}, 66 | // A command context, emptied at each command 67 | command: {}, 68 | // The result of the run 69 | result: {}, 70 | } 71 | 72 | // Run a virtual RunScenario command 73 | logger.info("Starting a virtual RunScenario command") 74 | const command = new RunScenarioCommand("VirtualStarter", { scenario }) 75 | await command.execute(page, context, this) 76 | 77 | logger.info("Closing the browser") 78 | await browser.close() 79 | 80 | return context.result 81 | } 82 | 83 | public getScenario(name: string): Scenario { 84 | if (!this.scenarios.has(name)) { 85 | throw new Error(`Scenario ${name} not found`) 86 | } 87 | return this.scenarios.get(name) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/modules/scrapper/commands/broadcast/AssertGroup.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "puppeteer-core" 2 | 3 | import mainLogger from "../../../../utils/logger" 4 | import { GroupController } from "../../../group" 5 | import { AssertGroupCommandArgs } from "../../../indexer" 6 | import { templater } from "../../../templater" 7 | import { CommandClass, Context } from "../command" 8 | 9 | type AssertGroupResult = { 10 | active: boolean 11 | } 12 | 13 | export class AssertGroup extends CommandClass { 14 | async execute(page: Page, context: Context): Promise { 15 | const logger = mainLogger.child({ 16 | name: "AssertGroup", 17 | func: "execute", 18 | data: { name: this.name, url: page.url() }, 19 | }) 20 | const category = templater.renderString(this.args.category, context) 21 | const country = templater.renderString(this.args.country, context) 22 | const name = templater.renderString(this.args.name, context) 23 | // Assert that the group exists 24 | logger.debug(`Asserting group ${name} of country ${country} exists`) 25 | await this.assertGroupExists(category, country, name, false) 26 | 27 | const group = await GroupController.getGroup({ name, category, country }) 28 | context.command.active = group.active 29 | } 30 | 31 | private async assertGroupExists(category: string, country: string, name: string, active: boolean): Promise { 32 | const logger = mainLogger.child({ name: "AssertGroup", func: "assertGroupExists", data: { name: this.name } }) 33 | try { 34 | await GroupController.getGroup({ name, category: category, country }) 35 | logger.debug(`Group ${name} of country ${country} exists`) 36 | return false 37 | } catch (error) { 38 | await GroupController.createGroup({ name, category: category, country }, active) 39 | logger.debug(`Group ${name} of country ${country} created`) 40 | return true 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/modules/scrapper/commands/command.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "puppeteer-core" 2 | 3 | import { Orchestrator } from "../Orchestrator" 4 | 5 | export interface Context< 6 | CommandResult extends Record = Record, 7 | Result extends Record = Record, 8 | > { 9 | // The global context 10 | global: Record 11 | // This run context 12 | execution: Record 13 | // A scenario context, emptied at each scenario of the same run 14 | scenario: Record 15 | // A command context, emptied at each command of the same scenario 16 | command: CommandResult 17 | // The final result of the run 18 | result: Result 19 | } 20 | 21 | type CommandArgs = Record 22 | export interface Command { 23 | name: string 24 | args: T 25 | execute: (page: Page, context: Context, scraper: Orchestrator) => Promise 26 | next?: string 27 | } 28 | 29 | export abstract class CommandClass implements Command { 30 | name: string 31 | 32 | args: T 33 | 34 | next?: string 35 | 36 | abstract execute(page: Page, context: Context, scraper: Orchestrator): Promise 37 | 38 | constructor(name: string, args: T, next?: string) { 39 | this.name = name 40 | this.args = args 41 | if (next) { 42 | this.next = next 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/modules/scrapper/commands/index.ts: -------------------------------------------------------------------------------- 1 | import { CommandDocument } from "../../indexer/commands" 2 | import { AssertGroup } from "./broadcast/AssertGroup" 3 | import { Command } from "./command" 4 | import { ClickCommand } from "./navigation/Click" 5 | import { CounterCommand } from "./navigation/Counter" 6 | import { FillInputCommand } from "./navigation/FillInput" 7 | import { GetValuesCommand } from "./navigation/GetValues" 8 | import { GoToPageCommand } from "./navigation/GoToPage" 9 | import { InterceptResponseCommand } from "./navigation/InterceptResponse" 10 | import { SleepCommand } from "./navigation/Sleep" 11 | import { PrintCommand } from "./scenario/Print" 12 | import { RunScenarioCommand } from "./scenario/RunScenario" 13 | import { SetValuesCommand } from "./scenario/SetValues" 14 | 15 | // Name => Command class mapping 16 | const commandClasses: Record Command> = { 17 | // Broadcast 18 | AssertGroup: AssertGroup, 19 | // Navigation 20 | Click: ClickCommand, 21 | Counter: CounterCommand, 22 | FillInput: FillInputCommand, 23 | GetValues: GetValuesCommand, 24 | GoToPage: GoToPageCommand, 25 | InterceptResponse: InterceptResponseCommand, 26 | Sleep: SleepCommand, 27 | // Scenario 28 | RunScenario: RunScenarioCommand, 29 | SetValues: SetValuesCommand, 30 | Print: PrintCommand, 31 | } 32 | 33 | export function generateCommand(command: CommandDocument, name: string): Command { 34 | if (!command) { 35 | throw new Error("No command provided") 36 | } 37 | if (!command.type) { 38 | throw new Error("No name provided") 39 | } 40 | if (!command.args) { 41 | throw new Error("No args provided") 42 | } 43 | const args = command.args as unknown as any 44 | 45 | const CmdClass = commandClasses[command.type] 46 | if (!CmdClass) { 47 | throw new Error(`Command ${command.type} not found`) 48 | } 49 | return new CmdClass(name, args, command.next) 50 | } 51 | -------------------------------------------------------------------------------- /src/modules/scrapper/commands/navigation/Click.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "puppeteer-core" 2 | 3 | import mainLogger from "../../../../utils/logger" 4 | import { ClickCommandArgs } from "../../../indexer" 5 | import { templater } from "../../../templater" 6 | import { Orchestrator } from "../../Orchestrator" 7 | import { CommandClass, Context } from "../command" 8 | 9 | export class ClickCommand extends CommandClass { 10 | async execute(page: Page, context: Context, _scraper: Orchestrator): Promise { 11 | const logger = mainLogger.child({ name: "Click", func: "execute", data: { name: this.name, url: page.url() } }) 12 | const selector = templater.renderString(this.args.selector, context) 13 | logger.info(`Clicking on ${selector}`) 14 | await page.waitForSelector(selector, { timeout: 5000 }) 15 | 16 | const input = await page.$(selector) 17 | await input.click() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/scrapper/commands/navigation/Counter.ts: -------------------------------------------------------------------------------- 1 | import { Types } from "mongoose" 2 | import { ElementHandle, Page } from "puppeteer-core" 3 | 4 | import mainLogger from "../../../../utils/logger" 5 | import { CounterCommandArgs } from "../../../indexer" 6 | import { templater } from "../../../templater" 7 | import { Orchestrator } from "../../Orchestrator" 8 | import { CommandClass, Context } from "../command" 9 | import { SetValuesCommand } from "../scenario/SetValues" 10 | 11 | export class CounterCommand extends CommandClass { 12 | async execute(page: Page, context: Context, _scrapper: Orchestrator): Promise { 13 | const logger = mainLogger.child({ 14 | name: "CounterCommand", 15 | func: "execute", 16 | data: { name: this.name, url: page.url() }, 17 | }) 18 | const { root, selector, store } = this.args 19 | 20 | let rootElt: ElementHandle | Page = page 21 | if (root) { 22 | const rootSelector = templater.renderString(root, context) 23 | await page.waitForSelector(rootSelector) 24 | rootElt = await page.$(rootSelector) 25 | } 26 | const selectorValue = templater.renderString(selector, context) 27 | 28 | const count = await this.count(rootElt, selectorValue) 29 | logger.debug(`Counted ${count} elements for selector: ${selectorValue}`) 30 | // Store the value in the scenario context 31 | if (store) { 32 | // Run a virtual SetValue command 33 | const command = new SetValuesCommand(`${this.name}-VirtualStorer`, { 34 | values: new Types.DocumentArray([{ store, value: `${count}` }]), 35 | }) 36 | await command.execute(page, context) 37 | } 38 | } 39 | 40 | private async count(root: ElementHandle | Page, selector: string): Promise { 41 | await root.waitForSelector(selector) 42 | const elements = await root.$$(selector) 43 | return elements.length 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/modules/scrapper/commands/navigation/FillInput.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "puppeteer-core" 2 | 3 | import { FillInputCommandArgs } from "../../../indexer" 4 | import { templater } from "../../../templater" 5 | import { CommandClass, Context } from "../command" 6 | 7 | export class FillInputCommand extends CommandClass { 8 | async execute(page: Page, context: Context): Promise { 9 | const selector = templater.renderString(this.args.selector, context) 10 | const values = templater.renderString(this.args.value, context) 11 | await page.waitForSelector(selector, { timeout: 5000 }) 12 | 13 | const input = await page.$(selector) 14 | await input.click() 15 | await input.type(values, { delay: 150 }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/scrapper/commands/navigation/GetValues.ts: -------------------------------------------------------------------------------- 1 | import { Types } from "mongoose" 2 | import { ElementHandle, Page } from "puppeteer-core" 3 | 4 | import mainLogger from "../../../../utils/logger" 5 | import { GetValueCommandArgs } from "../../../indexer" 6 | import { templater } from "../../../templater" 7 | import { CommandClass, Context } from "../command" 8 | import { SetValuesCommand } from "../scenario/SetValues" 9 | 10 | export class GetValuesCommand extends CommandClass { 11 | async execute(page: Page, context: Context): Promise { 12 | const logger = mainLogger.child({ name: "GetValuesCommand", func: "execute", data: { name: this.name, url: page.url() } }) 13 | 14 | const { root, values } = this.args 15 | let rootElt: ElementHandle | Page = page 16 | if (root) { 17 | const selector = templater.renderString(root, context) 18 | logger.debug(`Waiting for root element: ${selector}`) 19 | rootElt = await page.$(selector) 20 | } 21 | 22 | for (const { selector, attribute, replacements, store, canFail, valueIfFailed, elementExists } of values) { 23 | // Render the selector and attribute 24 | const selectorValue = templater.renderString(selector, context) 25 | const attributeValue = attribute ? templater.renderString(attribute, context) : null 26 | // logger.debug(`Getting value for selector: ${selectorValue}`) 27 | 28 | if (elementExists) { 29 | const exists = await this.elementExists(rootElt, selectorValue) 30 | // logger.debug(`Storing value in context: ${store} = "${exists}"`) 31 | const command = new SetValuesCommand(`${this.name}-VirtualStorer`, { 32 | values: new Types.DocumentArray([{ store, value: `${exists}` }]), 33 | }) 34 | await command.execute(page, context) 35 | return 36 | } 37 | 38 | // Get the value 39 | let value 40 | if (canFail) { 41 | try { 42 | value = await this.getValue(rootElt, selectorValue, attributeValue) 43 | } catch (error) { 44 | logger.error(`Error while getting value: ${error.message}`) 45 | if (!valueIfFailed) { 46 | return 47 | } 48 | value = templater.renderString(valueIfFailed, context) 49 | } 50 | } else { 51 | const rootSelector = root ? templater.renderString(root, context) : "PAGE" 52 | logger.debug(`Getting value for selector: ${selectorValue}, attribute: ${attributeValue}, rootElt selector: ${rootSelector}`) 53 | value = await this.getValue(rootElt, selectorValue, attributeValue) 54 | } 55 | 56 | // Apply replacements 57 | if (replacements) { 58 | for (const { from, to } of replacements) { 59 | const fromValue = templater.renderString(from, context) 60 | const toValue = templater.renderString(to, context) 61 | const regex = new RegExp(fromValue, "g") 62 | value = value.replace(regex, toValue) 63 | } 64 | } 65 | 66 | // Store the value in the scenario context 67 | if (store) { 68 | // Run a virtual SetValue command 69 | // logger.debug(`Storing value in context: ${store} = ${value}`) 70 | const command = new SetValuesCommand(`${this.name}-VirtualStorer`, { 71 | values: new Types.DocumentArray([{ store, value }]), 72 | }) 73 | await command.execute(page, context) 74 | } 75 | } 76 | } 77 | 78 | private async elementExists(root: ElementHandle | Page, selector: string): Promise { 79 | try { 80 | await root.waitForSelector(selector) 81 | return true 82 | } catch (error) { 83 | return false 84 | } 85 | } 86 | 87 | private async getValue(root: ElementHandle | Page, selector: string, attribute?: string): Promise { 88 | if (!attribute) { 89 | return root.$eval(selector, (item) => item.textContent) 90 | } 91 | 92 | if (attribute === "href") { 93 | return root.$eval(selector, (item) => (item as HTMLAnchorElement).href) 94 | } 95 | return root.$eval(selector, (item, attr) => item.getAttribute(attr), attribute) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/modules/scrapper/commands/navigation/GoToPage.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "puppeteer-core" 2 | 3 | import mainLogger from "../../../../utils/logger" 4 | import sleep from "../../../../utils/sleep" 5 | import { GoToPageCommandArgs } from "../../../indexer" 6 | import { templater } from "../../../templater" 7 | import { CommandClass, Context } from "../command" 8 | 9 | export class GoToPageCommand extends CommandClass { 10 | async execute(page: Page, context: Context): Promise { 11 | const logger = mainLogger.child({ name: "GoToPage", func: "execute", data: { ...this.args, url: page.url() } }) 12 | logger.info(`Going to page ${this.args.url}`) 13 | const url = templater.renderString(this.args.url, context) 14 | await page.goto(url, { timeout: 10000, waitUntil: "domcontentloaded" }) 15 | await sleep(1000) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/scrapper/commands/navigation/Sleep.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "puppeteer-core" 2 | 3 | import sleep from "../../../../utils/sleep" 4 | import { SleepCommandArgs } from "../../../indexer" 5 | import { templater } from "../../../templater" 6 | import { CommandClass, Context } from "../command" 7 | 8 | export class SleepCommand extends CommandClass { 9 | async execute(_page: Page, context: Context): Promise { 10 | const duration = templater.renderString(this.args.duration, context) 11 | const delay = parseInt(duration, 10) 12 | await sleep(delay) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/scrapper/commands/scenario/Print.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "puppeteer-core" 2 | 3 | import mainLogger from "../../../../utils/logger" 4 | import { PrintCommandArgs } from "../../../indexer" 5 | import { templater } from "../../../templater" 6 | import { CommandClass, Context } from "../command" 7 | 8 | export class PrintCommand extends CommandClass { 9 | async execute(_page: Page, context: Context): Promise { 10 | const logger = mainLogger.child({ name: "SetValues", func: "execute" }) 11 | const { values } = this.args 12 | 13 | // ******************* 14 | // | PRINT | 15 | // ******************* 16 | for (const value of values) { 17 | try { 18 | logger.info("*****************************************************************") 19 | logger.info(value) 20 | logger.info("*************************** RENDERING ***************************") 21 | logger.info(templater.renderString(value, context)) 22 | logger.info("*****************************************************************") 23 | } catch (error) { 24 | logger.error(`Error rendering value: ${error}`) 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/modules/scrapper/commands/scenario/RunScenario.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon" 2 | import { Page } from "puppeteer-core" 3 | 4 | import env from "../../../../config/env" 5 | import mainLogger from "../../../../utils/logger" 6 | import { RunScenarioCommandArgs } from "../../../indexer" 7 | import { templater } from "../../../templater" 8 | import { Orchestrator } from "../../Orchestrator" 9 | import { CommandClass, Context } from "../command" 10 | 11 | export class RunScenarioCommand extends CommandClass { 12 | async execute(page: Page, context: Context, scrapper: Orchestrator): Promise { 13 | const logger = mainLogger.child({ 14 | name: "RunScenario", 15 | func: "execute", 16 | data: { name: this.name, url: page.url() }, 17 | }) 18 | const newPage = await page.browser().newPage() 19 | await page.setCacheEnabled(false) 20 | await page.setUserAgent(env.browser.userAgent) 21 | 22 | const innerContext: Context = { 23 | ...context, 24 | scenario: {}, 25 | } 26 | 27 | try { 28 | await this.runScenario(newPage, innerContext, scrapper) 29 | // Take a screenshot at the end of the scenario 30 | await this.takeScreenshot(page, scrapper, "success") 31 | } catch (error) { 32 | logger.error(`Error running scenario ${this.args.scenario}: ${error}`) 33 | console.error(error.stack) 34 | 35 | await this.takeScreenshot(page, scrapper, "error") 36 | } 37 | 38 | await newPage.close() 39 | logger.info(`Scenario ${this.args.scenario} completed`) 40 | } 41 | 42 | private async runScenario(page: Page, context: Context, scrapper: Orchestrator): Promise { 43 | const logger = mainLogger.child({ name: "RunScenario", func: "runScenario", date: { url: page.url() } }) 44 | logger.info(`Running scenario ${this.args.scenario}`) 45 | const scenario = scrapper.getScenario(this.args.scenario) 46 | let index = 0 47 | let commandName = "start" 48 | do { 49 | context.command = {} 50 | const command = scenario.get(commandName) 51 | if (!command) { 52 | throw new Error(`Command ${commandName} not found`) 53 | } 54 | logger.debug(`Running command ${command.name}`) 55 | await this.takeScreenshot(page, scrapper, index) 56 | await command.execute(page, context, scrapper) 57 | 58 | // Take a screenshot at the end of the command 59 | if (command.next) { 60 | logger.debug(`Next command: ${command.next}`) 61 | commandName = templater.renderString(command.next, context) 62 | logger.debug(`Next command rendered: ${commandName}`) 63 | } else { 64 | commandName = null 65 | } 66 | index++ 67 | } while (commandName) 68 | logger.info(`Scenario ${this.args.scenario} completed`) 69 | } 70 | 71 | private async takeScreenshot(page: Page, scrapper: Orchestrator, name: string | number): Promise { 72 | if (env.dev) { 73 | await scrapper.screenshot(page, `${DateTime.local().toFormat("HH_mm_ss")}-${this.args.scenario}-${name}`) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/modules/scrapper/commands/scenario/SetValues.ts: -------------------------------------------------------------------------------- 1 | import set from "lodash/set" 2 | import { DateTime } from "luxon" 3 | import { Page } from "puppeteer-core" 4 | 5 | import mainLogger from "../../../../utils/logger" 6 | import { SetValueCommandArgs } from "../../../indexer" 7 | import { templater } from "../../../templater" 8 | import { CommandClass, Context } from "../command" 9 | 10 | export class SetValuesCommand extends CommandClass { 11 | async execute(page: Page, context: Context): Promise { 12 | const logger = mainLogger.child({ name: "SetValues", func: "execute", data: { name: this.name, url: page.url() } }) 13 | logger.info(`Setting values ${JSON.stringify(this.args)}`) 14 | for (const { store, value, isDate, dateFormat, isEmptyArray } of this.args.values) { 15 | const pathValue = templater.renderString(store, context) 16 | const [scope] = pathValue.split(".") 17 | 18 | if (Object.keys(context).includes(scope) === false) { 19 | throw new Error(`Scope '${scope}' does not exist in the context`) 20 | } 21 | if (scope === "global") { 22 | throw new Error("Scope 'global' cannot be modified by a command") 23 | } 24 | 25 | let finaleValue: string | Date | Array = null 26 | if (isDate) { 27 | const renderedValue = templater.renderString(value, context) 28 | 29 | let date: DateTime 30 | if (dateFormat === "X") { 31 | date = DateTime.fromSeconds(parseInt(renderedValue)) 32 | } else if (dateFormat === "x") { 33 | date = DateTime.fromMillis(parseInt(renderedValue)) 34 | } else { 35 | date = DateTime.fromFormat(renderedValue, dateFormat) 36 | } 37 | 38 | if (!date.isValid) { 39 | throw new Error(`Invalid date: ${date.invalidExplanation}`) 40 | } 41 | 42 | finaleValue = date.toJSDate() 43 | } else if (isEmptyArray) { 44 | finaleValue = [] 45 | } else { 46 | finaleValue = templater.renderString(value, context) 47 | } 48 | 49 | set(context, pathValue, finaleValue) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/modules/scrapper/scrapper.ts: -------------------------------------------------------------------------------- 1 | import puppeteer, { Browser, Page } from "puppeteer-core" 2 | 3 | import env from "../../config/env" 4 | import mainLogger from "../../utils/logger" 5 | 6 | export abstract class Scrapper { 7 | private browser: Browser = null 8 | 9 | protected async getBrowser(): Promise { 10 | const logger = mainLogger.child({ name: "Scrapper", func: "getBrowser" }) 11 | logger.silly("getBrowser") 12 | if (!this.browser) { 13 | logger.info("Creating a new browser") 14 | const args = [ 15 | "--disable-gpu", 16 | // "--single-process", 17 | // "--autoplay-policy=no-user-gesture-required", 18 | // "--disable-web-security", 19 | // "--disable-features=IsolateOrigins", 20 | // "--disable-site-isolation-trials", 21 | // "--disable-dev-shm-usage", 22 | "--no-sandbox", 23 | ] 24 | 25 | this.browser = await puppeteer.launch({ 26 | browser: env.browser.browser, 27 | executablePath: env.browser.executablePath, 28 | args, 29 | }) 30 | } 31 | logger.silly("getBrowser done") 32 | 33 | return this.browser 34 | } 35 | 36 | public async screenshot(page: Page, filename: string = `${Date.now()}`): Promise { 37 | await page.screenshot({ path: `${env.imagesFolder}/${filename}.png`, fullPage: true }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/modules/templater/index.ts: -------------------------------------------------------------------------------- 1 | import { TemplateRenderer } from "./templater" 2 | 3 | const templater = new TemplateRenderer() 4 | 5 | export { templater } 6 | -------------------------------------------------------------------------------- /src/modules/templater/templater.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon" 2 | import { Environment } from "nunjucks" 3 | 4 | import mainLogger from "../../utils/logger" 5 | 6 | export class TemplateRenderer extends Environment { 7 | constructor() { 8 | super(null, { autoescape: false, throwOnUndefined: true }) 9 | 10 | this.addGlobal("dateToFormat", (days: number, format: string) => { 11 | if (days === undefined) { 12 | throw new Error("days is required") 13 | } 14 | 15 | if (format === undefined) { 16 | throw new Error("format is required") 17 | } 18 | 19 | const date = DateTime.local().plus({ days }) 20 | return date.toFormat(format) 21 | }) 22 | 23 | this.addFilter("formatDate", (date: string, inputFormat: string, outputFormat: string) => { 24 | if (!date) { 25 | throw new Error("date is required") 26 | } 27 | 28 | if (!inputFormat) { 29 | throw new Error("inputFormat is required") 30 | } 31 | 32 | if (!outputFormat) { 33 | throw new Error("outputFormat is required") 34 | } 35 | 36 | const forbiddenFormats = ["x", "X"] 37 | if (forbiddenFormats.includes(inputFormat)) { 38 | throw new Error(`Invalid input format: ${inputFormat}`) 39 | } 40 | 41 | const parsedDate = DateTime.fromFormat(date, inputFormat) 42 | 43 | if (!parsedDate.isValid) { 44 | throw new Error(`Invalid date ${date} with format ${inputFormat}`) 45 | } 46 | return parsedDate.toFormat(outputFormat) 47 | }) 48 | 49 | this.addGlobal("now", (outputFormat: string) => { 50 | if (!outputFormat) { 51 | throw new Error("outputFormat is required") 52 | } 53 | return DateTime.now().toFormat(outputFormat) 54 | }) 55 | } 56 | 57 | override renderString(template: string, context: Record): string { 58 | const logger = mainLogger.child({ name: "TemplateRenderer", func: "renderString" }) 59 | const templateRegex = /({{.*}})|({%.*%})/g 60 | if (!template || !template.match(templateRegex)) { 61 | return template 62 | } 63 | 64 | try { 65 | // logger.debug("=============================================") 66 | // logger.debug(`Rendering template: |${template}|`) 67 | // logger.debug(`With context: ${JSON.stringify(context)}`) 68 | // logger.debug(`Result: |${super.renderString(template, context)}|`) 69 | return super.renderString(template, context) 70 | } catch (error) { 71 | logger.error(`Error rendering template: ${error}`) 72 | logger.error(`Template: ${template}`) 73 | logger.error(`Context: ${JSON.stringify(context)}`) 74 | throw new Error(`Error rendering template: ${error}`) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/modules/uuid/controller.ts: -------------------------------------------------------------------------------- 1 | import sleep from "../../utils/sleep" 2 | import { UuidDocument } from "./model" 3 | import * as UUIDService from "./service" 4 | 5 | export async function getUUID(): Promise { 6 | return UUIDService.getUUID() 7 | } 8 | 9 | // Export a function to remove the existing uuid 10 | export async function removeUUID(): Promise { 11 | return UUIDService.removeUUID() 12 | } 13 | 14 | export async function awaitUUID(): Promise { 15 | const uuid = await getUUID() 16 | if (uuid) { 17 | return 18 | } 19 | await sleep(1000) 20 | return awaitUUID() 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/uuid/index.ts: -------------------------------------------------------------------------------- 1 | export * as UUIDController from "./controller" 2 | 3 | export { UuidDocument } from "./model" 4 | -------------------------------------------------------------------------------- /src/modules/uuid/model.ts: -------------------------------------------------------------------------------- 1 | // Create a simple model of a unique document in a MongoDB collection. 2 | 3 | import mongoose, { InferSchemaType } from "mongoose" 4 | 5 | const uuidSchema = new mongoose.Schema({ 6 | uuid: { 7 | type: String, 8 | required: true, 9 | unique: true, 10 | }, 11 | state: { 12 | type: String, 13 | required: true, 14 | default: "active", 15 | unique: true, 16 | }, 17 | }) 18 | 19 | export const UuidModel = mongoose.model("Uuid", uuidSchema) 20 | 21 | export type UuidDocument = InferSchemaType 22 | -------------------------------------------------------------------------------- /src/modules/uuid/service.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid" 2 | 3 | import { UuidDocument, UuidModel } from "./model" 4 | 5 | export async function getUUID(): Promise { 6 | // Checking if the uuid is already in the database 7 | const existingUuid = await UuidModel.findOne() 8 | if (existingUuid) { 9 | return existingUuid 10 | } 11 | // Create a new uuid 12 | return UuidModel.create({ uuid: uuidv4() }) 13 | } 14 | 15 | // Export a function to remove the existing uuid 16 | export async function removeUUID(): Promise { 17 | await UuidModel.deleteMany() 18 | } 19 | -------------------------------------------------------------------------------- /src/routes/api/broadcast.ts: -------------------------------------------------------------------------------- 1 | import { Job } from "@hokify/agenda" 2 | import express, { Request } from "express" 3 | 4 | import { jobs } from "../../modules/agenda/agenda" 5 | import { GrabBroadcastStreamOptions } from "../../modules/agenda/options" 6 | import { Tasks } from "../../modules/agenda/tasks" 7 | import mainLogger from "../../utils/logger" 8 | import Params from "../types" 9 | 10 | const router = express.Router() 11 | 12 | const closeWindow = "" 13 | 14 | router.use("/:broadcastId", async (req: Request>, res, next) => { 15 | const logger = mainLogger.child({ name: "API Broadcast", func: "Middleware" }) 16 | const { broadcastId } = req.params 17 | logger.info(`Checking for job for ${broadcastId}`) 18 | const [existingJob] = await jobs(Tasks.GrabBroadcastStream, { data: { broadcastId } }) 19 | if (!existingJob) { 20 | res.status(404).send(`No job found for ${broadcastId}`) 21 | return 22 | } 23 | res.locals.broadcastJob = existingJob 24 | next() 25 | }) 26 | 27 | router.get("/:broadcastId/nextStream", async (req: Request>, res) => { 28 | const logger = mainLogger.child({ name: "API Broadcast", func: "NextStream" }) 29 | logger.info(`Next stream for ${req.params.broadcastId}`) 30 | const existingJob = res.locals.broadcastJob as Job 31 | // Increment the current streamIndex 32 | existingJob.attrs.data.streamIndex++ 33 | existingJob.schedule("now") 34 | await existingJob.save() 35 | 36 | res.send(closeWindow) 37 | }) 38 | 39 | router.get("/:broadcastId/askForStreamNow", async (req: Request>, res) => { 40 | const logger = mainLogger.child({ name: "API Broadcast", func: "AskForStreamNow" }) 41 | logger.info(`Asking for stream now for ${req.params.broadcastId}`) 42 | const existingJob = res.locals.broadcastJob as Job 43 | existingJob.schedule("now") 44 | await existingJob.save() 45 | res.send(closeWindow) 46 | }) 47 | 48 | router.get("/:broadcastId/resetStreamIndex", async (req: Request>, res) => { 49 | const logger = mainLogger.child({ name: "API Broadcast", func: "ResetStreamIndex" }) 50 | logger.info(`Resetting streamIndex for ${req.params.broadcastId}`) 51 | const existingJob = res.locals.broadcastJob as Job 52 | existingJob.attrs.data.streamIndex = 0 53 | existingJob.schedule("now") 54 | await existingJob.save() 55 | 56 | res.send(closeWindow) 57 | }) 58 | 59 | export default router 60 | -------------------------------------------------------------------------------- /src/routes/api/category.ts: -------------------------------------------------------------------------------- 1 | import express, { Request } from "express" 2 | 3 | import { Triggers } from "../../modules/agenda/triggers" 4 | import { CategoryController } from "../../modules/category" 5 | import mainLogger from "../../utils/logger" 6 | import Params from "../types" 7 | 8 | const router = express.Router() 9 | 10 | const closeWindow = "" 11 | 12 | router.get("/:category/reload", async (req: Request>, res) => { 13 | const logger = mainLogger.child({ name: "API Category", func: "reload" }) 14 | const { category } = req.params 15 | logger.info(`Reloading broadcasts for ${category}`) 16 | await CategoryController.reloadCategoryGroups(category) 17 | res.send(closeWindow) 18 | }) 19 | 20 | router.get("/:category/channelName", async (req: Request>, res) => { 21 | const logger = mainLogger.child({ name: "API Category", func: "channelName" }) 22 | const { category } = req.params 23 | logger.info(`Getting channel name for ${category}`) 24 | await Triggers.updateCategoryChannelName(category) 25 | res.send(closeWindow) 26 | }) 27 | 28 | export default router 29 | -------------------------------------------------------------------------------- /src/routes/api/group.ts: -------------------------------------------------------------------------------- 1 | import express, { Request } from "express" 2 | 3 | import { GroupController } from "../../modules/group" 4 | import { PublishersController } from "../../modules/publishers" 5 | import mainLogger from "../../utils/logger" 6 | import Params from "../types" 7 | 8 | const router = express.Router() 9 | 10 | const closeWindow = "" 11 | 12 | // Add Group 13 | router.get( 14 | "/:group/:country/category/:category/add", 15 | async (req: Request>, res) => { 16 | const logger = mainLogger.child({ name: "API Group", func: "Add" }) 17 | const { category, group, country } = req.params 18 | logger.info(`Adding group ${group} to category ${category}`) 19 | await GroupController.createGroup({ name: group, category, country }, true) 20 | res.send(closeWindow) 21 | }, 22 | ) 23 | 24 | // Remove Group 25 | router.get( 26 | "/:group/:country/category/:category/remove", 27 | async (req: Request>, res) => { 28 | const logger = mainLogger.child({ name: "API Group", func: "Remove" }) 29 | const { category, group, country } = req.params 30 | logger.info(`Removing group ${group} from category ${category}`) 31 | const groupDocument = await GroupController.getGroup({ name: group, category, country }) 32 | await PublishersController.unpublishGroup(groupDocument) 33 | await GroupController.removeGroup({ name: group, category, country }) 34 | res.send(closeWindow) 35 | }, 36 | ) 37 | 38 | router.get( 39 | "/:group/:country/category/:category/reload", 40 | async (req: Request>, res) => { 41 | const logger = mainLogger.child({ name: "API Group", func: "Update" }) 42 | const { group, category, country } = req.params 43 | logger.info(`Reload broadcasts for group ${group}`) 44 | // Schedule the task 45 | await GroupController.reload({ name: group, category, country }) 46 | res.send(closeWindow) 47 | }, 48 | ) 49 | 50 | export default router 51 | -------------------------------------------------------------------------------- /src/routes/api/index.ts: -------------------------------------------------------------------------------- 1 | import express, { Request } from "express" 2 | 3 | import { CategoryController } from "../../modules/category" 4 | import { IndexerController } from "../../modules/indexer" 5 | import { UUIDController } from "../../modules/uuid" 6 | import Params from "../types" 7 | import broadcastRouter from "./broadcast" 8 | import categoryRouter from "./category" 9 | import groupRouter from "./group" 10 | import indexerRouter from "./indexer" 11 | import monitorRouter from "./monitor" 12 | 13 | const router = express.Router() 14 | 15 | // Middleware that checks the api key is valid 16 | router.use("/", async (req, res, next) => { 17 | const apiKey = await UUIDController.getUUID() 18 | if (req?.query?.apiKey !== apiKey.uuid) { 19 | res.status(401).send("Invalid api key") 20 | return 21 | } 22 | return next() 23 | }) 24 | 25 | // Quick middleware to check that indexer param is valid and category param is valid 26 | router.use("/", async (req: Request>, _res, next) => { 27 | if (req.params.indexer) { 28 | await IndexerController.getIndexer(req.params.indexer) 29 | } 30 | if (req.params.category) { 31 | await CategoryController.getCategory(req.params.category) 32 | } 33 | return next() 34 | }) 35 | 36 | router.use("/broadcast", broadcastRouter) 37 | router.use("/category", categoryRouter) 38 | router.use("/group", groupRouter) 39 | router.use("/indexer", indexerRouter) 40 | router.use("/monitor", monitorRouter) 41 | 42 | export default router 43 | -------------------------------------------------------------------------------- /src/routes/api/indexer.ts: -------------------------------------------------------------------------------- 1 | import express, { Request } from "express" 2 | 3 | import { Triggers } from "../../modules/agenda/triggers" 4 | import mainLogger from "../../utils/logger" 5 | import Params from "../types" 6 | 7 | const router = express.Router() 8 | 9 | const closeWindow = "" 10 | 11 | router.get("/:indexer/category/:category/reload", async (req: Request>, res) => { 12 | const logger = mainLogger.child({ name: "API Indexer", func: "reload" }) 13 | const { indexer, category } = req.params 14 | logger.info(`Updating broadcasts for ${category} with ${indexer}`) 15 | // Schedule the task 16 | await Triggers.indexCategory(category, indexer) 17 | // Return a javascript to close the window 18 | res.send(closeWindow) 19 | }) 20 | 21 | export default router 22 | -------------------------------------------------------------------------------- /src/routes/api/monitor.ts: -------------------------------------------------------------------------------- 1 | import express from "express" 2 | 3 | import { NodePropertiesController } from "../../modules/nodeProperties" 4 | 5 | const router = express.Router() 6 | 7 | router.get("/openedUrl", async (_req, res) => { 8 | const properties = await NodePropertiesController.getNodePropertiesByType("pages") 9 | 10 | // Construction of the return object being Record> 11 | const urlsRecord: Record> = {} 12 | for (const { uuid, key, value, createdAt } of properties) { 13 | const urls = value.split(",") 14 | urlsRecord[uuid] = urlsRecord[uuid] || {} 15 | urlsRecord[uuid][`${createdAt.toLocaleTimeString()}-${key}`] = urls 16 | } 17 | 18 | res.send(urlsRecord) 19 | }) 20 | 21 | export default router 22 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express" 2 | 3 | import mainLogger from "../utils/logger" 4 | import apiRouter from "./api" 5 | import streamRouter from "./stream" 6 | 7 | const app = express() 8 | app.get("/", (_req, res) => { 9 | const logger = mainLogger.child({ name: "Server", func: "Hello World" }) 10 | logger.info("Hello World") 11 | res.send("Hello World!") 12 | }) 13 | 14 | app.use("/stream", streamRouter) 15 | app.use("/api", apiRouter) 16 | 17 | export default app 18 | -------------------------------------------------------------------------------- /src/routes/stream.ts: -------------------------------------------------------------------------------- 1 | import express from "express" 2 | import proxy from "express-http-proxy" 3 | import NodeCache from "node-cache" 4 | 5 | import { BroadcastController, BroadcastDocument } from "../modules/broadcast" 6 | 7 | const router = express.Router() 8 | 9 | router.get("/test", async (_req, res) => { 10 | res.send("Test") 11 | }) 12 | 13 | // Stream parts 14 | router.use("/broadcast/:broadcastId/", async (req, res, next) => { 15 | // Redirect to the first stream 16 | const { broadcastId } = req.params 17 | const broadcast = await BroadcastController.getBroadcast(broadcastId) 18 | if (!broadcast) { 19 | res.status(404).send("Broadcast not found") 20 | return 21 | } 22 | if (!broadcast.streams || !broadcast.streams.length) { 23 | res.status(404).send("No stream found") 24 | return 25 | } 26 | // Set the channel in the context 27 | res.locals.broadcast = broadcast 28 | next() 29 | }) 30 | 31 | const cache = new NodeCache({ stdTTL: 20, checkperiod: 60 }) 32 | 33 | // Cache middleware 34 | router.use("/broadcast/:broadcastId/", async (req, res, next) => { 35 | const { broadcastId } = req.params 36 | const key = `${broadcastId}${req.url}` 37 | if (cache.has(key)) { 38 | const cached = cache.get(key) 39 | return res.send(cached) 40 | } 41 | return next() 42 | }) 43 | 44 | // /broadcast/:broadcastId/stream redirects to the first stream 45 | router.use("/broadcast/:broadcastId/stream", async (req, res) => { 46 | const [{ url }] = (res.locals.broadcast as BroadcastDocument).streams 47 | const urlObj = new URL(url) 48 | res.redirect(`/stream/broadcast/${req.params.broadcastId}${urlObj.pathname}${urlObj.search}`) 49 | }) 50 | 51 | router.use("/broadcast/:broadcastId/", async (req, res, next) => { 52 | const [{ url, referer }] = (res.locals.broadcast as BroadcastDocument).streams 53 | return proxy(url, { 54 | proxyReqOptDecorator: (proxyReqOpts) => { 55 | proxyReqOpts.headers.referer = referer 56 | return proxyReqOpts 57 | }, 58 | userResDecorator: (_proxyRes, proxyResData, userReq) => { 59 | // Only store .ts queries 60 | if (userReq.url.includes(".ts")) { 61 | const key = `${req.params.broadcastId}${userReq.url}` 62 | cache.set(key, proxyResData) 63 | } 64 | return proxyResData 65 | }, 66 | })(req, res, next) 67 | }) 68 | 69 | export default router 70 | -------------------------------------------------------------------------------- /src/routes/types.ts: -------------------------------------------------------------------------------- 1 | export default interface Params { 2 | indexer: string 3 | category: string 4 | group: string 5 | broadcastId: string 6 | country: string 7 | } 8 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose" 2 | 3 | import DiscordBot from "./bot/discord" 4 | import env from "./config/env" 5 | import { Triggers } from "./modules/agenda/triggers" 6 | import { CategoryController } from "./modules/category" 7 | import { PublishersController } from "./modules/publishers" 8 | import { UUIDController } from "./modules/uuid" 9 | import router from "./routes" 10 | import mainLogger from "./utils/logger" 11 | import onExit from "./utils/onExit" 12 | 13 | // Print the node version 14 | router.listen(env.port, async () => { 15 | const logger = mainLogger.child({ name: "Server", func: "listen", data: { uuid: env.nodeUuid } }) 16 | logger.info(`Listening on port ${env.port}`) 17 | // Check if mongo is up 18 | const mongo = await mongoose.connect(`${env.mongo.url}/${env.mongo.db}`, {}) 19 | logger.info(`Mongo is up on ${mongo.connection.host}:${mongo.connection.port}`) 20 | 21 | if (env.discordBot.active) { 22 | const bot = new DiscordBot() 23 | bot.start() 24 | } 25 | 26 | await PublishersController.startPublishers() 27 | 28 | // Cancel all tasks 29 | const categories = await CategoryController.getCategories() 30 | for (const { name } of categories) { 31 | logger.info(`Scheduling tasks for ${name}`) 32 | await Triggers.publishCategory(name) 33 | } 34 | }) 35 | 36 | onExit(async () => { 37 | await UUIDController.removeUUID() 38 | }) 39 | -------------------------------------------------------------------------------- /src/utils/file.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "fs" 2 | import { join } from "path" 3 | 4 | import mainLogger from "./logger" 5 | 6 | export async function saveFile(fileName: string, content: string): Promise { 7 | const logger = mainLogger.child({ 8 | name: "File", 9 | func: "saveFile", 10 | data: { fileName }, 11 | }) 12 | logger.debug("Saving file") 13 | return writeFileSync(fileName, content, { encoding: "utf-8" }) 14 | } 15 | 16 | export async function removeFile(fileName: string): Promise { 17 | const logger = mainLogger.child({ 18 | name: "File", 19 | func: "removeFile", 20 | data: { fileName }, 21 | }) 22 | logger.debug("Removing file") 23 | return unlinkSync(fileName) 24 | } 25 | 26 | export async function fileExists(fileName: string): Promise { 27 | const logger = mainLogger.child({ 28 | name: "File", 29 | func: "fileExists", 30 | data: { fileName }, 31 | }) 32 | logger.debug("Checking if file exists") 33 | return existsSync(fileName) 34 | } 35 | 36 | export async function emptyFolder(folder: string, filter: string): Promise { 37 | const logger = mainLogger.child({ 38 | name: "File", 39 | func: "emptyFolder", 40 | data: { folder, filter }, 41 | }) 42 | logger.debug("Emptying folder") 43 | const files = readdirSync(folder).filter((file) => file.includes(filter)) 44 | for (const file of files) { 45 | const path = join(folder, file) 46 | logger.debug(`Removing file ${path}`) 47 | await removeFile(path) 48 | } 49 | } 50 | 51 | export async function checkImageExists(imageName: string): Promise { 52 | const logger = mainLogger.child({ 53 | name: "File", 54 | func: "checkImageExists", 55 | data: { imageName }, 56 | }) 57 | logger.debug("CheckImageExists") 58 | const filepath = join(__dirname, "..", "..", "assets", `${imageName}.png`) 59 | return existsSync(filepath) 60 | } 61 | 62 | export async function getImage(imageName: string): Promise { 63 | const logger = mainLogger.child({ 64 | name: "File", 65 | func: "getImage", 66 | data: { imageName }, 67 | }) 68 | logger.debug("Getting image") 69 | const filepath = join(__dirname, "..", "..", "assets", `${imageName}.png`) 70 | return readFileSync(filepath, { encoding: "base64" }) 71 | } 72 | -------------------------------------------------------------------------------- /src/utils/formatter.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon" 2 | 3 | export default function convertTimeToEmoji(rawTime: Date): string { 4 | // Format is HH:MM 5 | const startTime = DateTime.fromJSDate(rawTime) 6 | const time = startTime.toFormat("HH:mm") 7 | const [hour, min] = time.split(":").map((value) => value) 8 | const emoji: Record = { 9 | 0: "0️⃣", 10 | 1: "1️⃣", 11 | 2: "2️⃣", 12 | 3: "3️⃣", 13 | 4: "4️⃣", 14 | 5: "5️⃣", 15 | 6: "6️⃣", 16 | 7: "7️⃣", 17 | 8: "8️⃣", 18 | 9: "9️⃣", 19 | } 20 | return `${emoji[hour[0]]}${emoji[hour[1]]}:${emoji[min[0]]}${emoji[min[1]]}` 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/getEmoji.ts: -------------------------------------------------------------------------------- 1 | import mainLogger from "./logger" 2 | import groupsEmojis from "./types" 3 | 4 | export default function getGroupEmoji(group: string, defaultValue: string = ""): string { 5 | const logger = mainLogger.child({ name: "getEmoji", func: "getGroupEmoji", data: { group } }) 6 | 7 | const emoji = groupsEmojis[group.toLocaleLowerCase()] 8 | if (!emoji) { 9 | logger.warn("No emoji found") 10 | } 11 | return emoji || defaultValue 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/onExit.ts: -------------------------------------------------------------------------------- 1 | type PromiseListener = () => Promise 2 | 3 | type ListenerWithPriority = { 4 | listener: PromiseListener 5 | priority: number 6 | } 7 | 8 | const listeners: ListenerWithPriority[] = [] 9 | 10 | const processExit = async () => { 11 | for (const { listener } of listeners) { 12 | await listener() 13 | } 14 | process.exit(0) 15 | } 16 | 17 | function onExit(listener: PromiseListener, priority = 0): void { 18 | // Add the listener with its priority 19 | listeners.push({ listener, priority }) 20 | 21 | // Sort listeners by priority so the highest runs last 22 | listeners.sort((listener1, listener2) => listener1.priority - listener2.priority) 23 | } 24 | process.on("SIGTERM", processExit) 25 | process.on("SIGINT", processExit) 26 | 27 | export default onExit 28 | -------------------------------------------------------------------------------- /src/utils/silentError.ts: -------------------------------------------------------------------------------- 1 | export default class SilentError extends Error { 2 | constructor(message: string) { 3 | super(message) 4 | this.name = "SilentError" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | // Quick sleep function 2 | export default async function sleep(ms: number): Promise { 3 | return new Promise((resolve) => { 4 | setTimeout(resolve, ms) 5 | }) 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/teamsFlags.ts: -------------------------------------------------------------------------------- 1 | const emojiTeams: Record = { 2 | // France 3 | "🇫🇷": ["France"], 4 | // Japan 5 | "🇯🇵": ["Japon"], 6 | // Sweden 7 | "🇸🇪": ["Suède"], 8 | // Canada 9 | "🇨🇦": ["Canada"], 10 | // Australia 11 | "🇦🇺": ["Australie"], 12 | // USA 13 | "🇺🇸": ["États-Unis"], 14 | // England 15 | "🏴󠁧󠁢󠁥󠁮󠁧󠁿": ["Angleterre"], 16 | // Spain 17 | "🇪🇸": ["Espagne"], 18 | // Italy 19 | "🇮🇹": ["Italie"], 20 | // Belgium 21 | "🇧🇪": ["Belgique"], 22 | // Germany 23 | "🇩🇪": ["Allemagne"], 24 | // Netherlands 25 | "🇳🇱": ["Pays-Bas"], 26 | } 27 | 28 | const femaleEmoji = "♀️" 29 | 30 | // to lowercase 31 | const teamEmojis: Record = Object.entries(emojiTeams).reduce( 32 | (acc, [key, groups]) => ({ 33 | ...acc, 34 | ...groups.reduce((acc2, group) => ({ ...acc2, [group.toLocaleLowerCase()]: key }), {}), 35 | }), 36 | {}, 37 | ) 38 | 39 | export default function convertBroadcastTitle(title: string) { 40 | if (!title.includes(" 🆚 ")) { 41 | return title 42 | } 43 | 44 | const [team1, team2] = title.split(" 🆚 ") 45 | 46 | // If the team is "France F", or "Japan F", we want to get the flag of the country, and replace F with the femaleEmoji 47 | const shouldAddFemaleEmoji = team1.endsWith(" F") || team2.endsWith(" F") 48 | const countryTeam1 = team1.replace(" F", "") 49 | const countryTeam2 = team2.replace(" F", "") 50 | 51 | const team1Print = `${teamEmojis[countryTeam1.trim().toLocaleLowerCase()] ?? countryTeam1}${shouldAddFemaleEmoji ? femaleEmoji : ""}` 52 | const team2Print = `${teamEmojis[countryTeam2.trim().toLocaleLowerCase()] ?? countryTeam2}${shouldAddFemaleEmoji ? femaleEmoji : ""}` 53 | 54 | return `${team1Print} 🆚 ${team2Print}` 55 | } 56 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | const emojiGroup: Record = { 2 | // France 3 | "🇫🇷": [ 4 | "Ligue 1", 5 | "Ligue 2", 6 | "Top 14", 7 | "Top14", 8 | "Pro D2", 9 | "ProD2", 10 | "Coupe de France", 11 | "Coupe de la Ligue", 12 | ], 13 | // England 14 | "🏴󠁧󠁢󠁥󠁮󠁧󠁿": [ 15 | "Premiere League", 16 | "Premier League", 17 | "Championship", 18 | "FA Cup", 19 | "Carabao Cup", 20 | "Premiership", 21 | "Coupe de la Ligue anglaise", 22 | ], 23 | // Spain 24 | "🇪🇸": [ 25 | "Liga", 26 | "La Liga", 27 | "Liga 2", 28 | "Primera Division", 29 | "Laliga", 30 | ], 31 | // Portugal 32 | "🇵🇹": ["Liga Portugal BWIN"], 33 | // Italy 34 | "🇮🇹": ["Serie A", "Coupe Italie"], 35 | // Belgium 36 | "🇧🇪": ["Jupiler Pro League"], 37 | // Germany 38 | "🇩🇪": [ 39 | "Bundesliga", 40 | "Bundesliga 2", 41 | "Coupe Allemagne", 42 | "3.Liga", 43 | ], 44 | // Netherlands 45 | "🇳🇱": ["Eredivisie"], 46 | // Greece 47 | "🇬🇷": ["Super League"], 48 | // Europe 49 | "🇪🇺": [ 50 | "Ligue des Champions", 51 | "Champions League", 52 | "Europa League", 53 | "Ligue Europa", 54 | "Europa Conference League", 55 | "Ligue Des Nations Uefa", 56 | "Ligue Europa Conférence", 57 | "Euro", 58 | "Euro U21", 59 | "Ligue des Nations", 60 | "UEFA Nations League", 61 | "Supercoupe Europe", 62 | ], 63 | // Turkey 64 | "🇹🇷": [ 65 | "Super Lig", 66 | ], 67 | // International 68 | "🌍": [ 69 | "Coupe du Monde", 70 | "Coupe du Monde feminine", 71 | "National Teams", 72 | "Formule 1", 73 | "MotoGP", 74 | "Moto2", 75 | "Moto3", 76 | ], 77 | "🌏": [ 78 | "Pacific Nations Cup", 79 | ], 80 | // Rare 81 | // Argentina 82 | "🇦🇷": ["Torneo LPF", "Copa Argentina"], 83 | // Mexico 84 | "🇲🇽": ["Liga MX"], 85 | // Chile 86 | "🇨🇱": [ 87 | "Copa Chile", 88 | "Campeonato PlanVital", 89 | "Chile Campeonato PlanVital", 90 | ], 91 | // Peru 92 | "🇵🇪": ["Peru Liga 1 Movistar"], 93 | // Colombia 94 | "🇨🇴": ["Colombia Liga BetPlay DIMAYOR", "Copa Colombia"], 95 | // Ecuador 96 | "🇪🇨": ["Ecuador Liga Pro", "Ecuador LigaPro"], 97 | // Concacaf 98 | "🌎": [ 99 | "Gold Cup", 100 | "Copa Sudamericana", 101 | "Leagues Cup", 102 | "Copa Libertadores", 103 | "Ungrouped", 104 | ], 105 | // Uruguay 106 | "🇺🇾": ["Campeonato Uruguayo"], 107 | // USA 108 | "🇺🇸": ["MLS"], 109 | // Friendly 110 | "🤝": [ 111 | "Amical", 112 | "Test match", 113 | ], 114 | "❓": [ 115 | "Null", 116 | ], 117 | // TV Channels 118 | "📺": [ 119 | "TV Channels", 120 | "TVChannels", 121 | ], 122 | } 123 | 124 | // to lowercase 125 | const groupsEmojis: Record = Object.entries(emojiGroup).reduce( 126 | (acc, [key, groups]) => ({ 127 | ...acc, 128 | ...groups.reduce((acc2, group) => ({ ...acc2, [group.toLocaleLowerCase()]: key }), {}), 129 | }), 130 | {}, 131 | ) 132 | 133 | export default groupsEmojis 134 | -------------------------------------------------------------------------------- /src/utils/urlJoin.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path" 2 | 3 | import env from "../config/env" 4 | 5 | export default function urlJoin(apiKey: string, ...args: string[]): string { 6 | const url = join("api", encodeURI(join(...args)), `?apiKey=${apiKey}`) 7 | return `${env.remoteUrl}/${url}` 8 | } 9 | -------------------------------------------------------------------------------- /src/worker.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose" 2 | 3 | import env from "./config/env" 4 | import { agenda, defineAgendaTasks } from "./modules/agenda" 5 | import { Triggers } from "./modules/agenda/triggers" 6 | import { ConfigController } from "./modules/config" 7 | import { PublishersController } from "./modules/publishers" 8 | import { UUIDController } from "./modules/uuid" 9 | import { emptyFolder } from "./utils/file" 10 | import mainLogger from "./utils/logger" 11 | 12 | // Print the node version 13 | mainLogger.info(`Node version: ${process.version}`) 14 | 15 | // Worker 16 | async function worker() { 17 | const logger = mainLogger.child({ name: "Worker", func: "worker" }) 18 | // Check if mongo is up 19 | const mongo = await mongoose.connect(`${env.mongo.url}/${env.mongo.db}`, {}) 20 | logger.info(`Mongo is up on ${mongo.connection.host}:${mongo.connection.port}`) 21 | 22 | await defineAgendaTasks() 23 | 24 | // Await UUID generation 25 | await UUIDController.awaitUUID() 26 | 27 | await PublishersController.startPublishers() 28 | // Start agenda 29 | await agenda.start() 30 | 31 | // If we are in dev mode, we can index the dev category right away 32 | if (env.dev && env.devCategory && env.devIndexer) { 33 | await agenda.cancel({}) 34 | const { value } = await ConfigController.getConfig("delay-simple-IndexCategory") 35 | await ConfigController.setConfig("delay-simple-IndexCategory", "0") 36 | await Triggers.indexCategory(env.devCategory, env.devIndexer) 37 | await ConfigController.setConfig("delay-simple-IndexCategory", value) 38 | // Remove all images in imagesFolder 39 | await emptyFolder(env.imagesFolder, "png") 40 | } 41 | } 42 | 43 | worker() 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "outDir": "build", 5 | "target": "ESNext", 6 | "module": "NodeNext", 7 | "moduleResolution": "NodeNext", 8 | "moduleDetection": "force", 9 | "esModuleInterop": true, 10 | // "strict": true, 11 | "allowJs": true, 12 | "lib": ["ES2023", "DOM"], 13 | "skipLibCheck": true, 14 | "noImplicitAny": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "resolveJsonModule": true, 18 | "noImplicitReturns": true, 19 | "noImplicitThis": true, 20 | "noImplicitOverride": true, 21 | "allowSyntheticDefaultImports": true 22 | }, 23 | "include": [ 24 | "src/**/*" 25 | ], 26 | "exclude": [ 27 | "node_modules" 28 | ] 29 | } 30 | --------------------------------------------------------------------------------