Repository: ysdragon/StreamBot Branch: main Commit: fe918ed4cc73 Files: 55 Total size: 183.6 KB Directory structure: gitextract_raib73cm/ ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ └── docker-image.yml ├── .gitignore ├── Dockerfile ├── Dockerfile.node ├── README.md ├── docker-compose-node.yml ├── docker-compose-warp.yml ├── docker-compose.yml ├── egg-stream-bot.json ├── package.json ├── src/ │ ├── commands/ │ │ ├── base.ts │ │ ├── config.ts │ │ ├── help.ts │ │ ├── list.ts │ │ ├── manager.ts │ │ ├── ping.ts │ │ ├── play.ts │ │ ├── preview.ts │ │ ├── queue.ts │ │ ├── skip.ts │ │ ├── status.ts │ │ ├── stop.ts │ │ └── ytsearch.ts │ ├── config.ts │ ├── events/ │ │ ├── client/ │ │ │ └── ready.ts │ │ ├── messageCreate.ts │ │ └── voiceStateUpdate.ts │ ├── index.ts │ ├── server/ │ │ ├── index.ts │ │ ├── middleware/ │ │ │ ├── auth.ts │ │ │ └── multer.ts │ │ ├── public/ │ │ │ ├── css/ │ │ │ │ └── main.css │ │ │ ├── js/ │ │ │ │ └── main.js │ │ │ └── site.webmanifest │ │ ├── routes/ │ │ │ ├── auth.ts │ │ │ ├── dashboard.ts │ │ │ ├── preview.ts │ │ │ └── upload.ts │ │ ├── utils/ │ │ │ └── helpers.ts │ │ └── views/ │ │ ├── layouts/ │ │ │ └── main.ejs │ │ └── pages/ │ │ ├── dashboard.ejs │ │ ├── login.ejs │ │ └── preview.ejs │ ├── services/ │ │ ├── media.ts │ │ ├── queue.ts │ │ └── streaming.ts │ ├── types/ │ │ └── index.ts │ └── utils/ │ ├── ffmpeg.ts │ ├── gen-hash.ts │ ├── logger.ts │ ├── shared.ts │ ├── youtube.ts │ └── yt-dlp.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "npm" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" ================================================ FILE: .github/workflows/docker-image.yml ================================================ name: StreamBot Docker Image CI on: push: branches: [ "main" ] paths-ignore: - '.env.example' - 'docker-compose.yml' - 'LICENSE' - 'README.md' release: types: [published] jobs: build: runs-on: ${{ matrix.runner }} strategy: matrix: include: - runner: ubuntu-latest platform: linux/amd64 - runner: ubuntu-24.04-arm platform: linux/arm64 steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Quay.io uses: docker/login-action@v3 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_ROBOT_TOKEN }} - name: Extract Docker metadata id: meta uses: docker/metadata-action@v5.5.1 with: images: quay.io/ydrag0n/streambot - name: Build and push by digest id: build uses: docker/build-push-action@v6 with: context: . file: Dockerfile platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=quay.io/ydrag0n/streambot,push-by-digest=true,name-canonical=true,push=true cache-from: type=gha cache-to: type=gha,mode=max - name: Export digest run: | mkdir -p /tmp/digests digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - name: Upload digest uses: actions/upload-artifact@v4 with: name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 build-node: runs-on: ${{ matrix.runner }} strategy: matrix: include: - runner: ubuntu-latest platform: linux/amd64 - runner: ubuntu-24.04-arm platform: linux/arm64 steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Quay.io uses: docker/login-action@v3 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_ROBOT_TOKEN }} - name: Extract Docker metadata id: meta uses: docker/metadata-action@v5.5.1 with: images: quay.io/ydrag0n/streambot - name: Build and push by digest id: build uses: docker/build-push-action@v6 with: context: . file: Dockerfile.node platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=quay.io/ydrag0n/streambot,push-by-digest=true,name-canonical=true,push=true cache-from: type=gha cache-to: type=gha,mode=max - name: Export digest run: | mkdir -p /tmp/digests-node digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests-node/${digest#sha256:}" - name: Upload digest uses: actions/upload-artifact@v4 with: name: digests-node-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} path: /tmp/digests-node/* if-no-files-found: error retention-days: 1 merge: runs-on: ubuntu-latest needs: - build steps: - name: Download digests uses: actions/download-artifact@v4 with: path: /tmp/digests pattern: digests-* merge-multiple: true - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Quay.io uses: docker/login-action@v3 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_ROBOT_TOKEN }} - name: Extract Docker metadata id: meta uses: docker/metadata-action@v5.5.1 with: images: quay.io/ydrag0n/streambot - name: Create manifest list and push working-directory: /tmp/digests run: | docker buildx imagetools create \ --tag quay.io/ydrag0n/streambot:latest \ --tag quay.io/ydrag0n/streambot:${{ github.ref_name }} \ $(printf 'quay.io/ydrag0n/streambot@sha256:%s ' *) merge-node: runs-on: ubuntu-latest needs: - build-node steps: - name: Download digests uses: actions/download-artifact@v4 with: path: /tmp/digests-node pattern: digests-node-* merge-multiple: true - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Quay.io uses: docker/login-action@v3 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_ROBOT_TOKEN }} - name: Extract Docker metadata id: meta uses: docker/metadata-action@v5.5.1 with: images: quay.io/ydrag0n/streambot - name: Create manifest list and push working-directory: /tmp/digests-node run: | docker buildx imagetools create \ --tag quay.io/ydrag0n/streambot:node \ --tag quay.io/ydrag0n/streambot:node-${{ github.ref_name }} \ $(printf 'quay.io/ydrag0n/streambot@sha256:%s ' *) ================================================ FILE: .gitignore ================================================ LICENSE node_modules/ dist/ .env bun.lockb videos/ tmp/ *.sock cookies.txt ================================================ FILE: Dockerfile ================================================ # Use Debian (trixie) as the base image FROM node:trixie # Set the working directory WORKDIR /home/bots/StreamBot # Install minimal dependencies RUN apt-get update && apt-get install -y curl ca-certificates unzip && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* # Install bun and add to PATH ENV BUN_INSTALL="/usr/local/" RUN curl -fsSL https://bun.sh/install | bash # Install remaining dependencies and clean cache RUN apt-get update && apt-get install -y \ build-essential \ python3 \ ffmpeg && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* # Copy package.json COPY package.json ./ # Install dependencies RUN bun install # Trust all packages RUN bun pm trust --all # Copy the rest of the application code COPY . . # Verify the application builds RUN bun run build # Specify the port number the container should expose EXPOSE 3000 # Create videos folder RUN mkdir -p ./videos # Command to run the application CMD ["bun", "run", "start"] ================================================ FILE: Dockerfile.node ================================================ # Use Debian (trixie) as the base image FROM node:trixie # Set the working directory WORKDIR /home/bots/StreamBot # Install minimal dependencies RUN apt-get update && apt-get install -y curl ca-certificates unzip && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* # Install remaining dependencies and clean cache RUN apt-get update && apt-get install -y \ build-essential \ python3 \ ffmpeg && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* # Copy package.json COPY package.json ./ # Install dependencies RUN npm install # Copy the rest of the application code COPY . . # Verify the application builds RUN npm run build # Specify the port number the container should expose EXPOSE 3000 # Create videos folder RUN mkdir -p ./videos # Command to run the application CMD ["npm", "run", "start:node"] ================================================ FILE: README.md ================================================
StreamBot Logo # StreamBot **A powerful Discord self-bot for streaming videos from multiple sources with a web management interface** ![GitHub release](https://img.shields.io/github/v/release/ysdragon/StreamBot) [![CodeFactor](https://www.codefactor.io/repository/github/ysdragon/streambot/badge)](https://www.codefactor.io/repository/github/ysdragon/streambot) [![Ceasefire Now](https://badge.techforpalestine.org/default)](https://techforpalestine.org/learn-more)
## 📑 Table of Contents - [✨ Features](#-features) - [📋 Requirements](#-requirements) - [🚀 Installation](#-installation) - [🎮 Usage](#-usage) - [🐳 Docker Setup](#-docker-setup) - [🎯 Commands](#-commands) - [⚙️ Configuration](#%EF%B8%8F-configuration) - [🌐 Web Interface](#-web-interface) - [🤝 Contributing](#-contributing) - [⚠️ Disclaimer](#%EF%B8%8F-disclaimer) - [📝 License](#-license) ## ✨ Features - 📁 **Local Video Streaming**: Stream videos from your local videos folder - 🎬 **YouTube Integration**: Stream YouTube videos with smart search functionality - 📺 **YouTube Live Streams**: Direct streaming support for YouTube live content - 🟣 **Twitch Support**: Stream Twitch live streams and video-on-demand (VODs) - 🔗 **Direct URL Streaming**: Stream from any URL supported by [yt-dlp](https://github.com/yt-dlp/yt-dlp) (thousands of video sites including Vimeo, Dailymotion, Facebook, Instagram, news sites, and more) - 🎵 **Queue System**: Queue multiple videos with auto-play and skip functionality - 🌐 **Web Management Interface**: Full-featured web dashboard for video library management - 📤 **Video Upload**: Upload videos through the web interface or download from remote URLs - 🖼️ **Video Previews**: Generate and view thumbnail previews for all videos - ⚙️ **Runtime Configuration**: Adjust streaming parameters and bot settings during runtime ## 📋 Requirements - **[Bun](https://bun.sh/) v1.1.39+** (recommended) or **[Node.js](https://nodejs.org/) v21+** - **[FFmpeg](https://www.ffmpeg.org/)** (the bot will attempt to install it automatically if missing, but manual installation is recommended) - **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** (automatically downloaded and updated by the bot) ### 💡 Optional - 🎮 **GPU with hardware acceleration** for improved streaming performance - 🌐 **High-speed internet** for remote video streaming and downloads - 💾 **Sufficient disk space** for video storage and cache ## 🚀 Installation This project is [hosted on GitHub](https://github.com/ysdragon/StreamBot). 1. **Clone the repository:** ```bash git clone https://github.com/ysdragon/StreamBot cd StreamBot ``` 2. **Install dependencies:** With Bun (recommended): ```bash bun install ``` With npm: ```bash npm install ``` 3. **Configure environment:** - Copy `.env.example` to `.env` - Update the configuration values (see [⚙️ Configuration](#%EF%B8%8F-configuration) section) - See the [wiki](https://github.com/ysdragon/StreamBot/wiki/Get-Discord-user-token) for instructions on obtaining your Discord token 4. **Setup complete!** 🎉 Required directories for videos and cache will be created automatically on first run. ## 🎮 Usage ### 🚀 Starting the Bot **With Bun (recommended):** ```bash bun run start ``` **With Node.js:** ```bash npm run build npm run start:node ``` **With web interface enabled:** Set `SERVER_ENABLED=true` in your `.env` file. The web interface runs alongside the bot automatically. To run only the web interface without the bot: ```bash bun run server # With Bun npm run server:node # With Node.js (after building) ``` ### 📹 Video Playback All videos are played through a queue system that automatically advances to the next video when the current one ends. The `play` command automatically detects the input type: - 📁 Local files from your `VIDEOS_DIR` - 🎬 YouTube videos (by URL or search query) - 🟣 Twitch streams (live or VOD) - 🔗 Any URL supported by yt-dlp Use `ytsearch` to find YouTube videos, then `play` with the results to stream them. Use `list` to browse your local video collection. ## 🐳 Docker Setup StreamBot provides ready-to-use Docker configurations for easy deployment. ### 📦 Standard Deployment 1. **Create project directory:** ```bash mkdir streambot && cd streambot ``` 2. **Download Docker Compose configuration:** ```bash wget https://raw.githubusercontent.com/ysdragon/StreamBot/main/docker-compose.yml ``` 3. **Configure environment:** - Edit `docker-compose.yml` to set your environment variables - Ensure video storage directories are properly mounted 4. **Launch StreamBot:** ```bash docker compose up -d ``` ### ☁️ Cloudflare WARP Deployment For enhanced network capabilities with Cloudflare WARP: 1. **Download WARP configuration:** ```bash wget https://raw.githubusercontent.com/ysdragon/StreamBot/main/docker-compose-warp.yml ``` 2. **Configure WARP settings:** - Add your WARP license key to `docker-compose-warp.yml` - Update Discord token and other required environment variables 3. **Launch with WARP:** ```bash docker compose -f docker-compose-warp.yml up -d ``` > ⚠️ **Note:** The web interface is not available in WARP mode because the WARP container uses network isolation that prevents external access to the web server port. ## 🎯 Commands ### 📺 Playback Commands | Command | Description | Aliases | |---------|-------------|---------| | `play ` | Play local video, URL, or search YouTube videos | | | `ytsearch ` | Search for videos on YouTube | | | `stop` | Stop current video playback and clear queue | `leave`, `s` | | `skip` | Skip the currently playing video | `next` | | `queue` | Display the current video queue | | | `list` | Show available local videos | | ### 🔧 Utility Commands | Command | Description | Aliases | |---------|-------------|---------| | `status` | Show current streaming status | | | `preview ` | Generate preview thumbnails for a video | | | `ping` | Check bot latency | | | `help` | Show available commands | | ### 🛡️ Administration Commands | Command | Description | Aliases | |---------|-------------|---------| | `config [parameter] [value]` | View or adjust bot configuration parameters (Admin only) | `cfg`, `set` | ## ⚙️ Configuration StreamBot is configured through environment variables in a `.env` file. Copy `.env.example` to `.env` and modify the values as needed. ### 🔐 Discord Self-Bot Configuration ```bash # Required: Your Discord self-bot token # See: https://github.com/ysdragon/StreamBot/wiki/Get-Discord-user-token TOKEN="YOUR_BOT_TOKEN_HERE" # Command prefix for bot commands PREFIX="$" # Discord server where the bot will operate GUILD_ID="YOUR_SERVER_ID" # Channel where bot will respond to commands COMMAND_CHANNEL_ID="COMMAND_CHANNEL_ID" # Voice/video channel where bot will stream VIDEO_CHANNEL_ID="VIDEO_CHANNEL_ID" # Admin user IDs - comma-separated or JSON array format # Examples: # ADMIN_IDS="123456789,987654321" # ADMIN_IDS=["123456789","987654321"] ADMIN_IDS=["YOUR_USER_ID_HERE"] ``` ### 📁 File Management ```bash # Directory where video files are stored VIDEOS_DIR="./videos" # Directory for caching video preview thumbnails PREVIEW_CACHE_DIR="./tmp/preview-cache" ``` ### 🍪 Content Source Configuration ```bash # Path to browser cookies for accessing private/premium content # Supports: YouTube Premium, age-restricted content, private videos YTDLP_COOKIES_PATH="" ``` ### 🎥 Streaming Configuration ```bash # Video Quality Settings STREAM_RESPECT_VIDEO_PARAMS="false" # Use original video parameters if true STREAM_BITRATE_OVERRIDE="false" # If true, use STREAM_BITRATE_KBPS even when respecting video params STREAM_WIDTH="1280" # Output resolution width STREAM_HEIGHT="720" # Output resolution height STREAM_MAX_WIDTH="0" # Max width cap (0 = disabled) STREAM_MAX_HEIGHT="0" # Max height cap (0 = disabled) STREAM_FPS="30" # Target frame rate # Bitrate Settings (affects quality and bandwidth usage) STREAM_BITRATE_KBPS="2000" # Target bitrate (higher = better quality) STREAM_MAX_BITRATE_KBPS="2500" # Maximum allowed bitrate # Performance & Encoding STREAM_HARDWARE_ACCELERATION="false" # Use GPU acceleration if available STREAM_VIDEO_CODEC="H264" # Codec: H264, H265, VP8, VP9, AV1 # H.264/H.265 Encoding Preset (quality vs speed tradeoff) # Options: ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow STREAM_H26X_PRESET="ultrafast" ``` ### 🌐 Web Interface Configuration ```bash # Enable/disable the web management interface SERVER_ENABLED="false" # Web interface authentication SERVER_USERNAME="admin" SERVER_PASSWORD="admin" # Plain text, bcrypt, or argon2 hash # Web server port SERVER_PORT="8080" ``` ### 🍪 Using Cookies with yt-dlp To access private or premium content (like YouTube Premium videos), you can provide a cookies file: 1. **Export cookies from your browser** using a browser extension: - [Get cookies.txt LOCALLY](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc) (Chromium-based browsers) - [cookies.txt](https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/) (Firefox-based browsers) 2. **Save the cookies file** (usually named `cookies.txt`) to a location accessible by the bot 3. **Configure the path** in your `.env` file: ```bash YTDLP_COOKIES_PATH="./cookies.txt" ``` Or use the config command at runtime: ``` $config ytdlpCookiesPath ./cookies.txt ``` 4. **Restart the bot** if you updated the `.env` file ## 🌐 Web Interface When enabled (`SERVER_ENABLED="true"`), StreamBot provides a web-based management interface. ### ✨ Features - 📋 **Video Library Management**: Browse your video collection with file sizes and detailed information - 📤 **Local File Upload**: Upload videos directly with progress tracking - 🌐 **Remote URL Download**: Download videos from URLs directly to your library - 🖼️ **Video Previews**: Generate and view thumbnail screenshots from different parts of each video - 🗑️ **File Management**: Delete videos from your library - 📊 **Video Metadata**: View detailed information (duration, resolution, codec, etc.) ### 🔗 Access After enabling and restarting the bot, access the interface at `http://localhost:8080` (or your configured `SERVER_PORT`). ## 🤝 Contributing Contributions are welcome! Feel free to: - 🐛 Report bugs via [issues](https://github.com/ysdragon/StreamBot/issues/new) - 🔧 Submit [pull requests](https://github.com/ysdragon/StreamBot/pulls) - 💡 Suggest new features ## ⚠️ Disclaimer This bot may violate Discord's Terms of Service. Use at your own risk. I disavow before Allah any unethical use of this project. إبراء الذمة: أتبرأ من أي استخدام غير أخلاقي لهذا المشروع أمام الله. ## 📝 License Licensed under MIT License. See [LICENSE](https://github.com/ysdragon/StreamBot/blob/main/LICENSE) for details. ================================================ FILE: docker-compose-node.yml ================================================ services: streambot: image: quay.io/ydrag0n/streambot:node container_name: streambot restart: always environment: # Selfbot options TOKEN: "" # Your Discord self-bot token PREFIX: "$" # The prefix used to trigger your self-bot commands GUILD_ID: "" # The ID of the Discord server your self-bot will be running on COMMAND_CHANNEL_ID: "" # The ID of the Discord channel where your self-bot will respond to commands VIDEO_CHANNEL_ID: "" # The ID of the Discord voice/video channel where your self-bot will stream videos ADMIN_IDS: '["YOUR_USER_ID_HERE"]' # A list of Discord user IDs that are considered administrators (comma-separated or JSON array format) # General options VIDEOS_DIR: "./videos" # The local path where you store video files PREVIEW_CACHE_DIR: "./tmp/preview-cache" # The local path where your self-bot will cache video preview thumbnails # yt-dlp options YTDLP_COOKIES_PATH: "" # Path to cookies file for yt-dlp (for accessing age-restricted or premium content) # Stream options STREAM_RESPECT_VIDEO_PARAMS: "false" # This option is used to respect video parameters such as width, height, fps, bitrate, and max bitrate. STREAM_BITRATE_OVERRIDE: "false" # If true, use STREAM_BITRATE_KBPS even when respecting video params STREAM_MAX_WIDTH: "0" # Max width cap (0 = disabled) STREAM_MAX_HEIGHT: "0" # Max height cap (0 = disabled) STREAM_WIDTH: "1280" # The width of the video stream in pixels STREAM_HEIGHT: "720" # The height of the video stream in pixels STREAM_FPS: "30" # The frames per second (FPS) of the video stream STREAM_BITRATE_KBPS: "2000" # The bitrate of the video stream in kilobits per second (Kbps) STREAM_MAX_BITRATE_KBPS: "2500" # The maximum bitrate of the video stream in kilobits per second (Kbps) STREAM_HARDWARE_ACCELERATION: "false" # Whether to use hardware acceleration for video decoding, set to "true" to enable, "false" to disable STREAM_VIDEO_CODEC: "H264" # The video codec to use for the stream, can be "H264" or "H265" or "VP8" # STREAM_H26X_PRESET: Determines the encoding preset for H26x video streams. # If the STREAM_H26X_PRESET environment variable is set, it parses the value # using the parsePreset function. If not set, it defaults to 'ultrafast' for # optimal encoding speed. This preset is only applicable when the codec is # H26x; otherwise, it should be disabled or ignored. # Available presets: "ultrafast", "superfast", "veryfast", "faster", # "fast", "medium", "slow", "slower", "veryslow". STREAM_H26X_PRESET: "ultrafast" # Videos server options SERVER_ENABLED: "false" # Whether to enable the built-in video server SERVER_USERNAME: "admin" # The username for the video server's admin interface SERVER_PASSWORD: "admin" # The password for the video server's admin interface SERVER_PORT: "3000" # The port number the video server will listen on volumes: - ./videos:/home/bots/StreamBot/videos ports: - 3000:3000 ================================================ FILE: docker-compose-warp.yml ================================================ services: streambot: image: quay.io/ydrag0n/streambot:latest container_name: streambot restart: always environment: # Selfbot options TOKEN: "" # Your Discord self-bot token PREFIX: "$" # The prefix used to trigger your self-bot commands GUILD_ID: "" # The ID of the Discord server your self-bot will be running on COMMAND_CHANNEL_ID: "" # The ID of the Discord channel where your self-bot will respond to commands VIDEO_CHANNEL_ID: "" # The ID of the Discord voice/video channel where your self-bot will stream videos ADMIN_IDS: ["YOUR_USER_ID_HERE"] # A list of Discord user IDs that are considered administrators (comma-separated or JSON array format) # General options VIDEOS_DIR: "./videos" # The local path where you store video files PREVIEW_CACHE_DIR: "./tmp/preview-cache" # The local path where your self-bot will cache video preview thumbnails # yt-dlp options YTDLP_COOKIES_PATH: "" # Path to cookies file for yt-dlp (for accessing age-restricted or premium content) # Stream options STREAM_RESPECT_VIDEO_PARAMS: "false" # This option is used to respect video parameters such as width, height, fps, bitrate, and max bitrate. STREAM_BITRATE_OVERRIDE: "false" # If true, use STREAM_BITRATE_KBPS even when respecting video params STREAM_MAX_WIDTH: "0" # Max width cap (0 = disabled) STREAM_MAX_HEIGHT: "0" # Max height cap (0 = disabled) STREAM_WIDTH: "1280" # The width of the video stream in pixels STREAM_HEIGHT: "720" # The height of the video stream in pixels STREAM_FPS: "30" # The frames per second (FPS) of the video stream STREAM_BITRATE_KBPS: "2000" # The bitrate of the video stream in kilobits per second (Kbps) STREAM_MAX_BITRATE_KBPS: "2500" # The maximum bitrate of the video stream in kilobits per second (Kbps) STREAM_HARDWARE_ACCELERATION: "false" # Whether to use hardware acceleration for video decoding, set to "true" to enable, "false" to disable STREAM_VIDEO_CODEC: "H264" # The video codec to use for the stream, can be "H264" or "H265" or "VP8" # STREAM_H26X_PRESET: Determines the encoding preset for H26x video streams. # If the STREAM_H26X_PRESET environment variable is set, it parses the value # using the parsePreset function. If not set, it defaults to 'ultrafast' for # optimal encoding speed. This preset is only applicable when the codec is # H26x; otherwise, it should be disabled or ignored. # Available presets: "ultrafast", "superfast", "veryfast", "faster", # "fast", "medium", "slow", "slower", "veryslow". STREAM_H26X_PRESET: "ultrafast" # Videos server options SERVER_ENABLED: "false" # Whether to enable the built-in video server SERVER_USERNAME: "admin" # The username for the video server's admin interface SERVER_PASSWORD: "admin" # The password for the video server's admin interface SERVER_PORT: "3000" # The port number the video server will listen on volumes: - ./videos:/home/bots/StreamBot/videos network_mode: "service:warp" depends_on: - warp warp: image: caomingjun/warp container_name: warp restart: always devices: - /dev/net/tun:/dev/net/tun ports: - '1080:1080' - '3000:3000' environment: - WARP_SLEEP=2 - WARP_LICENSE_KEY= # Your Cloudflare Warp license key cap_add: - NET_ADMIN sysctls: - net.ipv6.conf.all.disable_ipv6=0 - net.ipv4.conf.all.src_valid_mark=1 volumes: - ./data:/var/lib/cloudflare-warp ================================================ FILE: docker-compose.yml ================================================ services: streambot: image: quay.io/ydrag0n/streambot:latest container_name: streambot restart: always environment: # Selfbot options TOKEN: "" # Your Discord self-bot token PREFIX: "$" # The prefix used to trigger your self-bot commands GUILD_ID: "" # The ID of the Discord server your self-bot will be running on COMMAND_CHANNEL_ID: "" # The ID of the Discord channel where your self-bot will respond to commands VIDEO_CHANNEL_ID: "" # The ID of the Discord voice/video channel where your self-bot will stream videos ADMIN_IDS: '["YOUR_USER_ID_HERE"]' # A list of Discord user IDs that are considered administrators (comma-separated or JSON array format) # General options VIDEOS_DIR: "./videos" # The local path where you store video files PREVIEW_CACHE_DIR: "./tmp/preview-cache" # The local path where your self-bot will cache video preview thumbnails # yt-dlp options YTDLP_COOKIES_PATH: "" # Path to cookies file for yt-dlp (for accessing age-restricted or premium content) # Stream options STREAM_RESPECT_VIDEO_PARAMS: "false" # This option is used to respect video parameters such as width, height, fps, bitrate, and max bitrate. STREAM_BITRATE_OVERRIDE: "false" # If true, use STREAM_BITRATE_KBPS even when respecting video params STREAM_MAX_WIDTH: "0" # Max width cap (0 = disabled) STREAM_MAX_HEIGHT: "0" # Max height cap (0 = disabled) STREAM_WIDTH: "1280" # The width of the video stream in pixels STREAM_HEIGHT: "720" # The height of the video stream in pixels STREAM_FPS: "30" # The frames per second (FPS) of the video stream STREAM_BITRATE_KBPS: "2000" # The bitrate of the video stream in kilobits per second (Kbps) STREAM_MAX_BITRATE_KBPS: "2500" # The maximum bitrate of the video stream in kilobits per second (Kbps) STREAM_HARDWARE_ACCELERATION: "false" # Whether to use hardware acceleration for video decoding, set to "true" to enable, "false" to disable STREAM_VIDEO_CODEC: "H264" # The video codec to use for the stream, can be "H264" or "H265" or "VP8" # STREAM_H26X_PRESET: Determines the encoding preset for H26x video streams. # If the STREAM_H26X_PRESET environment variable is set, it parses the value # using the parsePreset function. If not set, it defaults to 'ultrafast' for # optimal encoding speed. This preset is only applicable when the codec is # H26x; otherwise, it should be disabled or ignored. # Available presets: "ultrafast", "superfast", "veryfast", "faster", # "fast", "medium", "slow", "slower", "veryslow". STREAM_H26X_PRESET: "ultrafast" # Videos server options SERVER_ENABLED: "false" # Whether to enable the built-in video server SERVER_USERNAME: "admin" # The username for the video server's admin interface SERVER_PASSWORD: "admin" # The password for the video server's admin interface SERVER_PORT: "3000" # The port number the video server will listen on volumes: - ./videos:/home/bots/StreamBot/videos ports: - 3000:3000 ================================================ FILE: egg-stream-bot.json ================================================ { "_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO", "meta": { "version": "PTDL_v2", "update_url": null }, "exported_at": "2025-06-30T09:36:07+03:00", "name": "StreamBot", "author": "ysdragon@protonmail.com", "description": "A self bot to stream videos to Discord.", "features": null, "docker_images": { "Bun Latest": "ghcr.io\/parkervcp\/yolks:bun_latest" }, "file_denylist": [], "startup": "if [[ -d .git ]] && [[ {{AUTO_UPDATE}} == \"1\" ]]; then git pull; fi; if [ -f \/home\/container\/package.json ]; then bun install; fi; bun run start", "config": { "files": "{\r\n \".env\": {\r\n \"parser\": \"file\",\r\n \"find\": {\r\n \"TOKEN\": \"TOKEN= \\\"{{env.TOKEN}}\\\"\"\r\n }\r\n }\r\n}", "startup": "{\r\n \"done\": [\r\n \"change this text 1\",\r\n \"change this text 2\"\r\n ]\r\n}", "logs": "{}", "stop": "^^C" }, "scripts": { "installation": { "script": "#!\/bin\/bash\r\n# Bun App Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n\r\n# Define color codes\r\ndeclare -A colors=(\r\n [\"GREEN\"]='\\033[0;32m'\r\n [\"YELLOW\"]='\\033[0;33m'\r\n [\"NC\"]='\\033[0m'\r\n)\r\n\r\n# Logger function\r\nlog() {\r\n local level=$1\r\n local message=$2\r\n local color=${colors[$3]}\r\n \r\n if [ -z \"$color\" ]; then\r\n color=${colors[NC]}\r\n fi\r\n \r\n printf \"${color}[$level]${colors[NC]} $message\\n\"\r\n}\r\n\r\nlog \"INFO\" \"Installing deps...\" \"GREEN\"\r\napt update\r\napt install -qq -y git curl jq file unzip make gcc g++ python3 python3-dev libtool ffmpeg\r\nmkdir -p \/mnt\/server\r\ncd \/mnt\/server\r\n\r\n\r\nmkdir -p \/mnt\/server\r\ncd \/mnt\/server\r\n\r\nGIT_ADDRESS=\"https:\/\/github.com\/ysdragon\/StreamBot.git\"\r\n\r\n## pull git js bot repo\r\nif [ \"$(ls -A \/mnt\/server)\" ]; then\r\n log \"INFO\" \"\/mnt\/server directory is not empty.\" \"GREEN\"\r\n if [ -d .git ]; then\r\n log \"INFO\" \".git directory exists\" \"GREEN\"\r\n if [ -f .git\/config ]; then\r\n log \"INFO\" \"loading info from git config\" \"GREEN\"\r\n ORIGIN=$(git config --get remote.origin.url)\r\n else\r\n exit 10\r\n fi\r\n fi\r\n\r\n if [ \"${ORIGIN}\" == \"${GIT_ADDRESS}\" ]; then\r\n log \"INFO\" \"Updating from GitHub...\" \"GREEN\"\r\n git pull\r\n fi\r\nelse\r\n if [ -z ${BOT_VERSION} ]; then\r\n log \"INFO\" \"Installing default branch (main)...\" \"GREEN\"\r\n git clone ${GIT_ADDRESS} .\r\n else\r\n log \"INFO\" \"Installing version ${BOT_VERSION}\" \"GREEN\"\r\n git clone --single-branch --branch v${BOT_VERSION} ${GIT_ADDRESS} .\r\n fi\r\nfi\r\n\r\n# Copy .env.example to .env\r\ncp .env.example .env\r\n\r\nlog \"INFO\" \"Installation completed!\" \"GREEN\"\r\n\r\nexit 0", "container": "ghcr.io\/parkervcp\/installers:debian", "entrypoint": "bash" } }, "variables": [ { "name": "Auto Update", "description": "Pull the latest files on startup.\r\n0 = false (default)\r\n1 = true", "env_variable": "AUTO_UPDATE", "default_value": "0", "user_viewable": true, "user_editable": true, "rules": "required|boolean", "field_type": "text" }, { "name": "Bot Version", "description": "The bot version to install.", "env_variable": "BOT_VERSION", "default_value": "", "user_viewable": true, "user_editable": true, "rules": "nullable|string", "field_type": "text" }, { "name": "Bot Token", "description": "Your real user token for the bot.\r\nhttps:\/\/github.com\/ysdragon\/StreamBot\/wiki\/Get-Discord-user-token", "env_variable": "TOKEN", "default_value": "", "user_viewable": true, "user_editable": true, "rules": "required|string|max:100", "field_type": "text" } ] } ================================================ FILE: package.json ================================================ { "name": "streambot", "version": "2.0.2", "description": "A self bot to stream movies/videos to Discord.", "main": "dist/index.js", "type": "module", "scripts": { "start": "bun src/index.ts", "start:node": "node dist/index.js", "server": "bun src/server/index.ts", "server:node": "node dist/server/index.js", "build": "tsc", "watch": "tsc -w", "gen-hash": "bun src/utils/gen-hash.ts", "gen-hash:node": "node dist/utils/gen-hash.js" }, "author": "ysdragon", "license": "MIT", "dependencies": { "@dank074/discord-video-stream": "6.0.0", "@types/bcrypt": "^6.0.0", "@types/bun": "^1.3.10", "argon2": "^0.44.0", "axios": "^1.13.6", "bcrypt": "^6.0.0", "discord.js-selfbot-v13": "^3.7.1", "dotenv": "^17.3.1", "ejs": "^5.0.1", "express": "^5.2.1", "express-ejs-layouts": "^2.5.1", "express-session": "^1.19.0", "fluent-ffmpeg": "^2.1.3", "got": "^14.6.6", "multer": "^2.1.1", "play-dl": "^1.9.7", "twitch-m3u8": "^1.1.5", "winston": "^3.19.0" }, "devDependencies": { "@types/express": "^5.0.6", "@types/express-session": "^1.18.2", "@types/multer": "^2.1.0", "typescript": "^5.9.3" } } ================================================ FILE: src/commands/base.ts ================================================ import { Command, CommandContext } from "../types/index.js"; import { DiscordUtils } from "../utils/shared.js"; export abstract class BaseCommand implements Command { abstract name: string; abstract description: string; abstract usage: string; aliases?: string[]; constructor(commandManager?: any) { } abstract execute(context: CommandContext): Promise; protected async sendError(message: any, error: string): Promise { await DiscordUtils.sendError(message, error); } protected async sendSuccess(message: any, description: string): Promise { await DiscordUtils.sendSuccess(message, description); } protected async sendInfo(message: any, title: string, description: string): Promise { await DiscordUtils.sendInfo(message, title, description); } protected async sendList(message: any, items: string[], type?: string): Promise { await DiscordUtils.sendList(message, items, type); } protected async sendPlaying(message: any, title: string): Promise { await DiscordUtils.sendPlaying(message, title); } protected async sendFinishMessage(message: any): Promise { await DiscordUtils.sendFinishMessage(message); } } ================================================ FILE: src/commands/config.ts ================================================ import { BaseCommand } from "./base.js"; import { CommandContext } from "../types/index.js"; import config, { parseBoolean, parseVideoCodec, parsePreset } from "../config.js"; import logger from "../utils/logger.js"; export default class ConfigCommand extends BaseCommand { name = "config"; description = "View or adjust bot configuration parameters (Admin only)"; usage = "config [parameter] [value]"; aliases = ["cfg", "set"]; async execute(context: CommandContext): Promise { // Check if user is an admin if (!this.isAdmin(context.message.author.id)) { await this.sendError(context.message, "You don't have permission to use this command. Admin access required."); logger.warn(`Unauthorized config command attempt by user ${context.message.author.id}`); return; } const args = context.args; // If no arguments, show current config if (args.length === 0) { await this.showConfig(context); return; } // If one argument, show specific parameter if (args.length === 1) { await this.showParameter(context, args[0]); return; } // If two or more arguments, set parameter const parameter = args[0].toLowerCase(); const value = args.slice(1).join(' '); await this.setParameter(context, parameter, value); } private async showConfig(context: CommandContext): Promise { const configInfo = [ "**Stream Options:**", `• respect_video_params: ${config.respect_video_params}`, `• bitrateOverride: ${config.bitrateOverride}`, `• width: ${config.width}`, `• height: ${config.height}`, `• fps: ${config.fps}`, `• bitrateKbps: ${config.bitrateKbps}`, `• maxBitrateKbps: ${config.maxBitrateKbps}`, `• maxWidth: ${config.maxWidth || 'None'}`, `• maxHeight: ${config.maxHeight || 'None'}`, `• hardwareAcceleratedDecoding: ${config.hardwareAcceleratedDecoding}`, `• h26xPreset: ${config.h26xPreset}`, `• videoCodec: ${config.videoCodec}`, "", "**General Options:**", `• videosDir: ${config.videosDir}`, `• previewCacheDir: ${config.previewCacheDir}`, "", "**yt-dlp Options:**", `• ytdlpCookiesPath: ${config.ytdlpCookiesPath || '(not set)'}`, "", "Use `config ` to view a specific parameter", "Use `config ` to change a parameter" ].join('\n'); await this.sendInfo(context.message, 'Bot Configuration', configInfo); } private async showParameter(context: CommandContext, parameter: string): Promise { // Find the actual config key case-insensitively const key = Object.keys(config).find(k => k.toLowerCase() === parameter.toLowerCase()); if (!key) { await this.sendError(context.message, `Unknown parameter: ${parameter}`); return; } const value = (config as any)[key]; await this.sendInfo(context.message, `Config: ${key}`, `Current value: \`${value}\``); } private async setParameter(context: CommandContext, parameter: string, value: string): Promise { // Find the actual config key case-insensitively const key = Object.keys(config).find(k => k.toLowerCase() === parameter.toLowerCase()); if (!key) { await this.sendError(context.message, `Unknown parameter: ${parameter}`); return; } try { switch (key) { // Boolean parameters case 'respect_video_params': case 'bitrateOverride': case 'hardwareAcceleratedDecoding': const boolValue = parseBoolean(value); (config as any)[key] = boolValue; await this.sendSuccess(context.message, `Set ${key} to \`${boolValue}\``); logger.info(`Config updated: ${key} = ${boolValue}`); break; // Number parameters case 'width': case 'height': case 'fps': case 'bitrateKbps': case 'maxBitrateKbps': case 'maxWidth': case 'maxHeight': const numValue = parseInt(value); // Validate non-negative if (isNaN(numValue) || numValue < 0) { await this.sendError(context.message, `Invalid number value: ${value}. Must be non-negative.`); return; } // Validate positive for essential params if (['width', 'height', 'fps', 'bitrateKbps', 'maxBitrateKbps'].includes(key) && numValue === 0) { await this.sendError(context.message, `Invalid number value: ${value}. Must be greater than 0.`); return; } (config as any)[key] = numValue; await this.sendSuccess(context.message, `Set ${key} to \`${numValue}\``); logger.info(`Config updated: ${key} = ${numValue}`); break; // Video codec case 'videoCodec': const codec = parseVideoCodec(value); if (!codec) { await this.sendError(context.message, `Invalid video codec. Valid options: VP8, H264, H265`); return; } config.videoCodec = codec; await this.sendSuccess(context.message, `Set videoCodec to \`${codec}\``); logger.info(`Config updated: videoCodec = ${codec}`); break; // H26x preset case 'h26xPreset': const preset = parsePreset(value); if (!preset) { await this.sendError(context.message, `Invalid preset. Valid options: ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow`); return; } config.h26xPreset = preset; await this.sendSuccess(context.message, `Set h26xPreset to \`${preset}\``); logger.info(`Config updated: h26xPreset = ${preset}`); break; // String parameters case 'videosDir': case 'previewCacheDir': case 'ytdlpCookiesPath': (config as any)[key] = value; await this.sendSuccess(context.message, `Set ${key} to \`${value}\``); logger.info(`Config updated: ${key} = ${value}`); break; default: await this.sendError(context.message, `Cannot modify parameter: ${key}`); return; } } catch (error) { logger.error(`Error setting config parameter ${parameter}:`, error); await this.sendError(context.message, `Failed to set ${parameter}: ${error}`); } } private isAdmin(userId: string): boolean { // If no admins configured, allow all users (backwards compatibility) if (!config.adminIds || config.adminIds.length === 0) { return true; } return config.adminIds.includes(userId); } } ================================================ FILE: src/commands/help.ts ================================================ import { BaseCommand } from "./base.js"; import { CommandContext } from "../types/index.js"; import { CommandManager } from "./manager.js"; export default class HelpCommand extends BaseCommand { name = "help"; description = "Show available commands"; usage = "help"; constructor(private commandManager: CommandManager) { super(commandManager); } async execute(context: CommandContext): Promise { const commandList = this.commandManager.getCommandList(); const helpText = [ '📽 **Available Commands**', '', commandList, ].join('\n'); await context.message.react('📋'); await context.message.reply(helpText); } } ================================================ FILE: src/commands/list.ts ================================================ import { BaseCommand } from "./base.js"; import { CommandContext, Video } from "../types/index.js"; import fs from 'fs'; import path from 'path'; import config from "../config.js"; export default class ListCommand extends BaseCommand { name = "list"; description = "Show available local videos"; usage = "list"; async execute(context: CommandContext): Promise { // Always refresh video list from filesystem const videoFiles = fs.readdirSync(config.videosDir); const refreshedVideos = videoFiles.map(file => { const fileName = path.parse(file).name; return { name: fileName, path: path.join(config.videosDir, file) }; }); // Update the videos array in context context.videos.length = 0; context.videos.push(...refreshedVideos); const videoList = refreshedVideos.map((video, index) => `${index + 1}. \`${video.name}\``); if (videoList.length > 0) { await this.sendList(context.message, [`(${refreshedVideos.length} videos found)`, ...videoList]); } else { await this.sendError(context.message, 'No videos found'); } } } ================================================ FILE: src/commands/manager.ts ================================================ import { Command, CommandContext } from "../types/index.js"; import fs from 'fs'; import path from 'path'; import logger from '../utils/logger.js'; import config from '../config.js'; import { ErrorUtils } from '../utils/shared.js'; export class CommandManager { private commands: Map = new Map(); private aliases: Map = new Map(); constructor() { this.loadCommands(); } private async loadCommands(): Promise { // Prefer src for Bun (TypeScript native), dist for Node.js (compiled JS) const isBun = typeof Bun !== 'undefined'; const commandsPath = path.join(process.cwd(), isBun ? 'src' : 'dist', 'commands'); if (!commandsPath) { logger.error('Could not find commands directory in either dist/ or src/'); return; } try { const commandFiles = fs.readdirSync(commandsPath) .filter(file => (file.endsWith('.ts') || file.endsWith('.js')) && !file.endsWith('.d.ts') && !file.startsWith('base.') && !file.startsWith('manager.')); for (const file of commandFiles) { try { // Use appropriate extension based on directory const isDist = commandsPath.includes('dist'); const fileName = isDist ? file.replace('.ts', '.js') : file; const filePath = path.join(commandsPath, fileName); const commandModule = await import(filePath); // Look for default export or named export let CommandClass = commandModule.default || commandModule[Object.keys(commandModule)[0]]; if (CommandClass && this.isCommand(CommandClass)) { const command = new CommandClass(this) as Command; // Register command this.commands.set(command.name.toLowerCase(), command); // Register aliases if (command.aliases) { for (const alias of command.aliases) { this.aliases.set(alias.toLowerCase(), command.name.toLowerCase()); } } } else { logger.warn(`File ${file} does not export a valid command`); logger.debug(`Exported keys: ${Object.keys(commandModule).join(', ')}`); if (CommandClass) { logger.debug(`CommandClass properties: ${Object.keys(CommandClass.prototype || {}).join(', ')}`); } } } catch (error) { await ErrorUtils.handleError(error, `loading command from ${file}`); } } logger.info(`Loaded ${this.commands.size} commands`); } catch (error) { await ErrorUtils.handleError(error, 'loading commands'); } } private isCommand(obj: any): obj is new (commandManager?: CommandManager) => Command { if (!obj) return false; const proto = obj.prototype; if (!proto) return false; const hasExecute = 'execute' in proto; return hasExecute; } public getCommand(name: string): Command | null { const commandName = name.toLowerCase(); return this.commands.get(commandName) || this.commands.get(this.aliases.get(commandName) || '') || null; } public getAllCommands(): Command[] { return Array.from(this.commands.values()); } public async executeCommand(commandName: string, context: CommandContext): Promise { const command = this.getCommand(commandName); if (!command) { return false; } try { await command.execute(context); return true; } catch (error) { await ErrorUtils.handleError(error, `executing command ${commandName}`, context.message); return false; } } public getCommandList(): string { const commands = this.getAllCommands(); const prefix = config.prefix || '!'; return commands.map(cmd => `**${cmd.name}**: ${cmd.description}\nUsage: \`${prefix}${cmd.usage}\`` ).join('\n'); } } ================================================ FILE: src/commands/ping.ts ================================================ import { BaseCommand } from "./base.js"; import { CommandContext } from "../types/index.js"; export default class PingCommand extends BaseCommand { name = "ping"; description = "Check bot latency"; usage = "ping"; async execute(context: CommandContext): Promise { const sent = await context.message.reply('🏓 Pinging...'); const timeDiff = sent.createdTimestamp - context.message.createdTimestamp; await sent.edit(`🏓 Pong! Latency: ${timeDiff}ms`); } } ================================================ FILE: src/commands/play.ts ================================================ import { BaseCommand } from "./base.js"; import { CommandContext } from "../types/index.js"; import { MediaService } from "../services/media.js"; import { ErrorUtils, GeneralUtils } from '../utils/shared.js'; import fs from 'fs'; import path from 'path'; import config from "../config.js"; export default class PlayCommand extends BaseCommand { name = "play"; description = "Play local video, URL, or search YouTube videos"; usage = "play "; private mediaService: MediaService; constructor() { super(); this.mediaService = new MediaService(); } async execute(context: CommandContext): Promise { const input = context.args.join(' '); if (!input) { await this.sendError(context.message, 'Please provide a video name, URL, or search query.'); return; } // Check if input is a URL (YouTube, Twitch, or direct link) if (GeneralUtils.isValidUrl(input)) { await this.handleUrl(context, input); } else { // Refresh video list from disk before matching const videoFiles = fs.readdirSync(config.videosDir); const refreshedVideos = videoFiles.map(file => ({ name: path.parse(file).name, path: path.join(config.videosDir, file) })); context.videos.length = 0; context.videos.push(...refreshedVideos); // Case-insensitive match const video = context.videos.find(m => m.name.toLowerCase() === input.toLowerCase()); if (video) { await this.handleLocalVideo(context, video); } else { // Treat as search query await this.handleSearchQuery(context, input); } } } private async handleLocalVideo(context: CommandContext, video: any): Promise { // Add to queue instead of playing immediately const success = await context.streamingService.addToQueue(context.message, video.path, video.name); if (success) { // If not currently playing, start playing from queue if (!context.streamStatus.playing) { await context.streamingService.playFromQueue(context.message); } } } private async handleUrl(context: CommandContext, url: string): Promise { try { // For lazy processing, just add to queue - resolution happens when playing const success = await context.streamingService.addToQueue(context.message, url); if (success) { // If not currently playing, start playing from queue if (!context.streamStatus.playing) { await context.streamingService.playFromQueue(context.message); } } } catch (error) { await ErrorUtils.handleError(error, `processing URL: ${url}`, context.message); } } private async handleSearchQuery(context: CommandContext, query: string): Promise { try { // For lazy processing, add the search query to queue // The actual search and resolution will happen when it's time to play const success = await context.streamingService.addToQueue(context.message, query, `Search: ${query}`); if (success) { // If not currently playing, start playing from queue if (!context.streamStatus.playing) { await context.streamingService.playFromQueue(context.message); } } } catch (error) { await ErrorUtils.handleError(error, 'adding search query to queue', context.message); } } } ================================================ FILE: src/commands/preview.ts ================================================ import { BaseCommand } from "./base.js"; import { CommandContext } from "../types/index.js"; import { MessageAttachment } from "discord.js-selfbot-v13"; import path from 'path'; import { ffmpegScreenshot } from "../utils/ffmpeg.js"; import logger from '../utils/logger.js'; export default class PreviewCommand extends BaseCommand { name = "preview"; description = "Generate preview thumbnails for a video"; usage = "preview "; async execute(context: CommandContext): Promise { const vid = context.args.join(' '); if (!vid) { await this.sendError(context.message, 'Please provide a video name.'); return; } const vid_name = context.videos.find(m => m.name === vid); if (!vid_name) { await this.sendError(context.message, 'Video not found'); return; } // React with camera emoji context.message.react('📸'); // Reply with message to indicate that the preview is being generated context.message.reply('📸 **Generating preview thumbnails...**'); try { const videoFilename = vid_name.name + path.extname(vid_name.path); const thumbnails = await ffmpegScreenshot(videoFilename); if (thumbnails.length > 0) { const attachments: MessageAttachment[] = []; for (const screenshotPath of thumbnails) { attachments.push(new MessageAttachment(screenshotPath)); } // Message content const content = `📸 **Preview**: \`${vid_name.name}\``; // Send message with attachments await context.message.reply({ content, files: attachments }); } else { await this.sendError(context.message, 'Failed to generate preview thumbnails.'); } } catch (error) { logger.error('Error generating preview thumbnails:', error); await this.sendError(context.message, 'Failed to generate preview thumbnails.'); } } } ================================================ FILE: src/commands/queue.ts ================================================ import { BaseCommand } from "./base.js"; import { CommandContext } from "../types/index.js"; export default class QueueCommand extends BaseCommand { name = "queue"; description = "Display the current video queue"; usage = "queue"; async execute(context: CommandContext): Promise { const queueItems = context.streamingService.getQueueService().getQueue(); const currentItem = context.streamingService.getQueueService().getCurrent(); const queueStatus = context.streamingService.getQueueService().getQueueStatus(); if (queueItems.length === 0) { await this.sendInfo(context.message, 'Queue', 'The queue is currently empty.'); return; } let queueText = `📋 **Queue** (${queueItems.length} item${queueItems.length !== 1 ? 's' : ''})\n\n`; if (queueStatus.isPlaying && currentItem) { const status = currentItem.resolved ? '▶️' : '⏳'; const title = currentItem.resolved ? currentItem.title : `${currentItem.title} (resolving...)`; queueText += `${status} **Currently Playing:**\n\`${title}\` (requested by ${currentItem.requestedBy})\n\n`; } queueText += '**Up Next:**\n'; // Show all items that are not currently playing const upcomingItems = queueItems.filter(item => !queueStatus.isPlaying || item.id !== currentItem?.id); if (upcomingItems.length === 0) { if (queueStatus.isPlaying && currentItem) { queueText += '*No upcoming items*\n'; } else { queueText += '*Queue is empty*\n'; } } else { upcomingItems.forEach((item, index) => { const position = queueStatus.isPlaying ? index + 1 : index; const addedTime = item.addedAt.toLocaleTimeString(); const status = item.resolved ? '' : '⏳'; const title = item.resolved ? item.title : `${item.title} (pending)`; queueText += `${position + 1}. ${status} \`${title}\` (by ${item.requestedBy}) - Added at ${addedTime}\n`; }); } // Split message if it's too long (Discord has a 2000 character limit) if (queueText.length > 1900) { const firstPart = queueText.substring(0, 1900) + '...'; const secondPart = '...(continued)\n' + queueText.substring(1900); await context.message.channel.send(firstPart); await context.message.channel.send(secondPart); } else { await context.message.channel.send(queueText); } } } ================================================ FILE: src/commands/skip.ts ================================================ import { BaseCommand } from "./base.js"; import { CommandContext } from "../types/index.js"; export default class SkipCommand extends BaseCommand { name = "skip"; description = "Skip the currently playing video"; usage = "skip"; aliases = ["next"]; async execute(context: CommandContext): Promise { const currentItem = context.streamingService.getQueueService().getCurrent(); const queueLength = context.streamingService.getQueueService().getLength(); if (!context.streamStatus.playing) { await this.sendError(context.message, 'No video is currently playing.'); return; } if (queueLength === 0) { await this.sendError(context.message, 'No videos in queue to skip to.'); return; } // Skip the current video await context.streamingService.skipCurrent(context.message); } } ================================================ FILE: src/commands/status.ts ================================================ import { BaseCommand } from "./base.js"; import { CommandContext } from "../types/index.js"; export default class StatusCommand extends BaseCommand { name = "status"; description = "Show current streaming status"; usage = "status"; async execute(context: CommandContext): Promise { await this.sendInfo(context.message, 'Status', `Joined: ${context.streamStatus.joined}\nPlaying: ${context.streamStatus.playing}`); } } ================================================ FILE: src/commands/stop.ts ================================================ import { BaseCommand } from "./base.js"; import { CommandContext } from "../types/index.js"; import logger from '../utils/logger.js'; export default class StopCommand extends BaseCommand { name = "stop"; description = "Stop current video playback and clear queue"; usage = "stop"; aliases = ["leave", "s"]; async execute(context: CommandContext): Promise { if (!context.streamStatus.joined) { await this.sendError(context.message, '**Already Stopped!**'); return; } try { context.streamStatus.manualStop = true; await this.sendSuccess(context.message, 'Stopped playing video and cleared queue.'); logger.info("Stopped playing video and cleared queue."); // Use streaming service to handle the stop and clear queue await context.streamingService.stopAndClearQueue(); } catch (error) { logger.error("Error during force termination:", error); } } } ================================================ FILE: src/commands/ytsearch.ts ================================================ import { BaseCommand } from "./base.js"; import { CommandContext } from "../types/index.js"; import { MediaService } from "../services/media.js"; export default class YTSearchCommand extends BaseCommand { name = "ytsearch"; description = "Search for videos on YouTube"; usage = "ytsearch "; private mediaService: MediaService; constructor() { super(); this.mediaService = new MediaService(); } async execute(context: CommandContext): Promise { const query = context.args.join(' '); if (!query) { await this.sendError(context.message, 'Please provide a search query.'); return; } try { const searchResults = await this.mediaService.searchYouTube(query); if (searchResults.length > 0) { await this.sendList(context.message, searchResults, "ytsearch"); } else { await this.sendError(context.message, 'No videos found.'); } } catch (error) { await this.sendError(context.message, 'Failed to search for videos.'); } } } ================================================ FILE: src/config.ts ================================================ import dotenv from "dotenv" dotenv.config({ quiet: true }); const VALID_VIDEO_CODECS = ['VP8', 'H264', 'H265', 'VP9', 'AV1']; export function parseVideoCodec(value: string): "VP8" | "H264" | "H265" { if (typeof value === "string") { value = value.trim().toUpperCase(); } if (VALID_VIDEO_CODECS.includes(value)) { return value as "VP8" | "H264" | "H265"; } return "H264"; } export function parsePreset(value: string): "ultrafast" | "superfast" | "veryfast" | "faster" | "fast" | "medium" | "slow" | "slower" | "veryslow" { if (typeof value === "string") { value = value.trim().toLowerCase(); } switch (value) { case "ultrafast": case "superfast": case "veryfast": case "faster": case "fast": case "medium": case "slow": case "slower": case "veryslow": return value as "ultrafast" | "superfast" | "veryfast" | "faster" | "fast" | "medium" | "slow" | "slower" | "veryslow"; default: return "ultrafast"; } } export function parseBoolean(value: string | undefined): boolean { if (typeof value === "string") { value = value.trim().toLowerCase(); } switch (value) { case "true": return true; default: return false; } } function parseAdminIds(value: string): string[] { try { // Try to parse as JSON array first const parsed = JSON.parse(value); if (Array.isArray(parsed)) { return parsed.filter(id => typeof id === 'string' && id.trim() !== ''); } } catch { // If not JSON, try comma-separated values if (value.includes(',')) { return value.split(',').map(id => id.trim()).filter(id => id !== ''); } } // Single value return value.trim() ? [value.trim()] : []; } export default { // Selfbot options token: process.env.TOKEN || '', prefix: process.env.PREFIX || '', guildId: process.env.GUILD_ID ? process.env.GUILD_ID : '', cmdChannelId: process.env.COMMAND_CHANNEL_ID ? process.env.COMMAND_CHANNEL_ID : '', videoChannelId: process.env.VIDEO_CHANNEL_ID ? process.env.VIDEO_CHANNEL_ID : '', adminIds: process.env.ADMIN_IDS ? parseAdminIds(process.env.ADMIN_IDS) : [], // General options videosDir: process.env.VIDEOS_DIR ? process.env.VIDEOS_DIR : './videos', previewCacheDir: process.env.PREVIEW_CACHE_DIR ? process.env.PREVIEW_CACHE_DIR : './tmp/preview-cache', // yt-dlp options ytdlpCookiesPath: process.env.YTDLP_COOKIES_PATH ? process.env.YTDLP_COOKIES_PATH : '', // Stream options respect_video_params: process.env.STREAM_RESPECT_VIDEO_PARAMS ? parseBoolean(process.env.STREAM_RESPECT_VIDEO_PARAMS) : false, bitrateOverride: process.env.STREAM_BITRATE_OVERRIDE ? parseBoolean(process.env.STREAM_BITRATE_OVERRIDE) : false, width: process.env.STREAM_WIDTH ? parseInt(process.env.STREAM_WIDTH) : 1280, height: process.env.STREAM_HEIGHT ? parseInt(process.env.STREAM_HEIGHT) : 720, fps: process.env.STREAM_FPS ? parseInt(process.env.STREAM_FPS) : 30, bitrateKbps: process.env.STREAM_BITRATE_KBPS ? parseInt(process.env.STREAM_BITRATE_KBPS) : 1000, maxBitrateKbps: process.env.STREAM_MAX_BITRATE_KBPS ? parseInt(process.env.STREAM_MAX_BITRATE_KBPS) : 2500, maxWidth: process.env.STREAM_MAX_WIDTH ? parseInt(process.env.STREAM_MAX_WIDTH) : 0, maxHeight: process.env.STREAM_MAX_HEIGHT ? parseInt(process.env.STREAM_MAX_HEIGHT) : 0, hardwareAcceleratedDecoding: process.env.STREAM_HARDWARE_ACCELERATION ? parseBoolean(process.env.STREAM_HARDWARE_ACCELERATION) : false, h26xPreset: process.env.STREAM_H26X_PRESET ? parsePreset(process.env.STREAM_H26X_PRESET) : 'ultrafast', videoCodec: process.env.STREAM_VIDEO_CODEC ? parseVideoCodec(process.env.STREAM_VIDEO_CODEC) : 'H264', // Videos server options server_enabled: process.env.SERVER_ENABLED ? parseBoolean(process.env.SERVER_ENABLED) : false, server_username: process.env.SERVER_USERNAME ? process.env.SERVER_USERNAME : 'admin', server_password: process.env.SERVER_PASSWORD ? process.env.SERVER_PASSWORD : 'admin', server_port: parseInt(process.env.SERVER_PORT ? process.env.SERVER_PORT : '8080'), } ================================================ FILE: src/events/client/ready.ts ================================================ import { Client, ActivityOptions } from "discord.js-selfbot-v13"; import logger from "../../utils/logger.js"; import { DiscordUtils } from "../../utils/shared.js"; export async function handleReady(client: Client): Promise { if (client.user) { logger.info(`${client.user.tag} is ready`); client.user.setActivity(DiscordUtils.status_idle() as ActivityOptions); } } ================================================ FILE: src/events/messageCreate.ts ================================================ import { Message } from "discord.js-selfbot-v13"; import { CommandManager } from "../commands/manager.js"; import { CommandContext, Video, StreamStatus } from "../types/index.js"; import config from "../config.js"; export async function handleMessageCreate( message: Message, videos: Video[], streamStatus: StreamStatus, streamingService: any, commandManager: CommandManager ): Promise { // Ignore bots, self, non-command channels, and non-commands if ( message.author.bot || message.author.id === message.client.user?.id || !message.content.startsWith(config.prefix) ) return; // Split command and arguments const args = message.content.slice(config.prefix!.length).trim().split(/ +/); // If no command provided, ignore if (args.length === 0) { return; } const commandName = args.shift()!.toLowerCase(); const context: CommandContext = { message, args, videos, streamStatus, streamingService }; const executed = await commandManager.executeCommand(commandName, context); if (!executed) { await message.react('❌'); await message.reply(`❌ **Error**: Unknown command. Use \`${config.prefix}help\` to see available commands.`); } } ================================================ FILE: src/events/voiceStateUpdate.ts ================================================ import { VoiceState } from "discord.js-selfbot-v13"; import { Client } from "discord.js-selfbot-v13"; import { DiscordUtils } from "../utils/shared.js"; import { StreamStatus } from "../types/index.js"; export async function handleVoiceStateUpdate( oldState: VoiceState, newState: VoiceState, streamStatus: StreamStatus, client: Client ): Promise { // When exit channel if (oldState.member?.user.id == client.user?.id) { if (oldState.channelId && !newState.channelId) { streamStatus.joined = false; streamStatus.joinsucc = false; streamStatus.playing = false; streamStatus.channelInfo = { guildId: "", channelId: "", cmdChannelId: "" } client.user?.setActivity(DiscordUtils.status_idle()); } } // When join channel success if (newState.member?.user.id == client.user?.id) { if (newState.channelId && !oldState.channelId) { streamStatus.joined = true; if (newState.guild.id == streamStatus.channelInfo.guildId && newState.channelId == streamStatus.channelInfo.channelId) { streamStatus.joinsucc = true; } } } } ================================================ FILE: src/index.ts ================================================ import { Client } from "discord.js-selfbot-v13"; import config from "./config.js"; import fs from 'fs'; import path from 'path'; import logger from './utils/logger.js'; import { downloadExecutable, checkForUpdatesAndUpdate } from './utils/yt-dlp.js'; // Import event handlers import { handleReady } from './events/client/ready.js'; import { handleMessageCreate } from './events/messageCreate.js'; import { handleVoiceStateUpdate } from './events/voiceStateUpdate.js'; // Import services import { StreamingService } from './services/streaming.js'; import { MediaService } from './services/media.js'; import { CommandManager } from './commands/manager.js'; import { QueueService } from './services/queue.js'; // Download yt-dlp and check for updates (async () => { try { await downloadExecutable(); await checkForUpdatesAndUpdate(); } catch (error) { logger.error("Error during initial yt-dlp setup/update:", error); } })(); // Create a new instance of Client const client = new Client(); // Stream status object const queueService = new QueueService(); const streamStatus = { joined: false, joinsucc: false, playing: false, manualStop: false, channelInfo: { guildId: config.guildId, channelId: config.videoChannelId, cmdChannelId: config.cmdChannelId }, queue: queueService.getQueueStatus() } // Create services const streamingService = new StreamingService(client, streamStatus); const mediaService = new MediaService(); const commandManager = new CommandManager(); // Create the videosFolder dir if it doesn't exist if (!fs.existsSync(config.videosDir)) { fs.mkdirSync(config.videosDir); } // Create previewCache parent dir if it doesn't exist if (!fs.existsSync(path.dirname(config.previewCacheDir))) { fs.mkdirSync(path.dirname(config.previewCacheDir), { recursive: true }); } // Create the previewCache dir if it doesn't exist if (!fs.existsSync(config.previewCacheDir)) { fs.mkdirSync(config.previewCacheDir); } // Get all video files const videoFiles = fs.readdirSync(config.videosDir); // Create an array of video objects let videos = videoFiles.map(file => { const fileName = path.parse(file).name; return { name: fileName, path: path.join(config.videosDir, file) }; }); // Print available videos if (videos.length > 0) { logger.info(`Available videos:\n${videos.map(m => m.name).join('\n')}`); } // Event handlers client.on("ready", async () => { await handleReady(client); }); client.on('voiceStateUpdate', async (oldState, newState) => { await handleVoiceStateUpdate(oldState, newState, streamStatus, client); }); client.on('messageCreate', async (message) => { await handleMessageCreate(message, videos, streamStatus, streamingService, commandManager); }); // Handle uncaught exceptions process.on('uncaughtException', (error) => { if (!(error instanceof Error && error.message.includes('SIGTERM'))) { logger.error('Uncaught Exception:', error); return } }); // Run server if enabled in config if (config.server_enabled) { // Run server.ts import('./server/index.js'); } // Login to Discord client.login(config.token); ================================================ FILE: src/server/index.ts ================================================ import express from "express"; import session from "express-session"; import expressLayouts from "express-ejs-layouts"; import path from "path"; import fs from "fs"; import config from "../config.js"; import logger from "../utils/logger.js"; import { stringify, prettySize } from "./utils/helpers.js"; // Import middleware import { requireAuth } from "./middleware/auth.js"; // Import route handlers import authRoutes from "./routes/auth.js"; import dashboardRoutes from "./routes/dashboard.js"; import uploadRoutes from "./routes/upload.js"; import previewRoutes from "./routes/preview.js"; const app = express(); // Configure EJS templating engine app.set('view engine', 'ejs'); app.set('views', path.join(process.cwd(), 'src/server/views')); // Use express-ejs-layouts app.use(expressLayouts); app.set('layout', 'layouts/main'); // Middleware app.use(express.urlencoded({ extended: true })); app.use(session({ secret: 'streambot-2024', resave: false, saveUninitialized: true, cookie: { secure: process.env.NODE_ENV === 'production' } })); // Serve static files from public directory app.use(express.static(path.join(process.cwd(), 'src/server/public'))); // Make helper functions available to all templates app.use((req, res, next) => { res.locals.stringify = stringify; res.locals.prettySize = prettySize; next(); }); // Apply authentication middleware to all routes except login app.use(requireAuth); // Routes app.use('/', authRoutes); app.use('/', dashboardRoutes); app.use('/', uploadRoutes); app.use('/', previewRoutes); // Create necessary directories if (!fs.existsSync(config.videosDir)) { fs.mkdirSync(config.videosDir); } if (!fs.existsSync(path.dirname(config.previewCacheDir))) { fs.mkdirSync(path.dirname(config.previewCacheDir), { recursive: true }); } if (!fs.existsSync(config.previewCacheDir)) { fs.mkdirSync(config.previewCacheDir); } // Start server app.listen(config.server_port, () => { logger.info(`Server is running on port ${config.server_port}`); }); export default app; ================================================ FILE: src/server/middleware/auth.ts ================================================ import { Request, Response, NextFunction } from 'express'; export const authMiddleware = (req: Request, res: Response, next: NextFunction) => { if ((req.session as { user?: unknown }).user) { next(); } else { res.redirect("/login"); } }; export const requireAuth = (req: Request, res: Response, next: NextFunction) => { if (req.path === "/login") { next(); } else { authMiddleware(req, res, next); } }; ================================================ FILE: src/server/middleware/multer.ts ================================================ import multer, { StorageEngine } from 'multer'; import path from 'path'; import config from '../../config.js'; // Define the type for the file object interface MulterFile extends Express.Multer.File { originalname: string; } // Configure multer storage export const storage: StorageEngine = multer.diskStorage({ destination: (req: Express.Request, file: MulterFile, cb: (error: Error | null, destination: string) => void) => { cb(null, config.videosDir); }, filename: (req: Express.Request, file: MulterFile, cb: (error: Error | null, filename: string) => void) => { cb(null, file.originalname); }, }); // Create the multer upload instance export const upload = multer({ storage }); ================================================ FILE: src/server/public/css/main.css ================================================ :root { --bs-body-color: #212529; --bs-body-bg: #f8f9fa; --bs-border-color: #dee2e6; } .dark-mode { --bs-body-color: #f8f9fa; --bs-body-bg: #212529; --bs-border-color: #495057; } body { color: var(--bs-body-color); background-color: var(--bs-body-bg); transition: background-color 0.3s ease, color 0.3s ease; } .card, .modal-content { background-color: var(--bs-body-bg); border-color: var(--bs-border-color); box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); } .table { color: var(--bs-body-color); font-size: 0.875rem; } .theme-toggle { position: fixed; top: 20px; right: 20px; z-index: 1001; } .logout-button { position: fixed; top: 20px; right: 90px; z-index: 1001; } .dark-mode .nav-tabs .nav-link.active { color: #fff; background-color: #495057; border-color: #495057; } .dark-mode .table-striped>tbody>tr:nth-of-type(odd) { color: #fff; background-color: rgba(255, 255, 255, 0.05); } .dark-mode .table-striped>tbody>tr:nth-of-type(even) { background-color: rgba(255, 255, 255, 0.02); } .dark-mode .table td { color: #f8f9fa; border-color: #495057; } .dark-mode .table th { color: #f8f9fa; border-color: #495057; background-color: #343a40; } .dark-mode .btn-outline-primary, .dark-mode .btn-outline-secondary, .dark-mode .btn-outline-danger { color: #fff; } .dark-mode input[type="file"] { color: var(--bs-body-color); } .dark-mode input[type="file"]::file-selector-button { color: var(--bs-body-color); background-color: var(--bs-body-bg); border-color: var(--bs-border-color); } .dark-mode input[type="file"]::file-selector-button:hover { color: var(--bs-body-color); background-color: rgba(255, 255, 255, 0.1); border-color: var(--bs-border-color); } .dark-mode input[type="file"]::file-selector-button:focus { color: var(--bs-body-color); background-color: rgba(255, 255, 255, 0.15); border-color: #80bdff; box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); } input[type="file"].user-interacted:invalid { border-color: #dc3545; } input[type="file"]:valid { border-color: #28a745; } input[type="file"] { border-color: var(--bs-border-color); } input[type="file"]:not(.user-interacted) { border-color: var(--bs-border-color) !important; } .form-text { font-size: 0.875rem; margin-top: 0.25rem; } .modal-content { border: none; border-radius: 0.5rem; box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); } .modal-header { border-bottom: 1px solid var(--bs-border-color); border-radius: 0.5rem 0.5rem 0 0; padding: 1rem 1.5rem; } .modal-header .btn-close { position: absolute; right: 1rem; top: 50%; transform: translateY(-50%); margin: 0; padding: 0.5rem; border-radius: 0.25rem; } .modal-body { padding: 1.5rem; text-align: center; } .modal-footer { border-top: 1px solid var(--bs-border-color); border-radius: 0 0 0.5rem 0.5rem; padding: 1rem 1.5rem; } .modal-footer .btn { min-width: 100px; } .dark-mode .modal-content { background-color: var(--bs-body-bg); color: var(--bs-body-color); } .dark-mode .modal-header { background-color: var(--bs-body-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); } .dark-mode .modal-body { background-color: var(--bs-body-bg); color: var(--bs-body-color); } .dark-mode .modal-footer { background-color: var(--bs-body-bg); border-color: var(--bs-border-color); } .dark-mode .modal-backdrop { background-color: rgba(0, 0, 0, 0.8); } .dark-mode #deleteFileName { color: #f8f9fa !important; } .dark-mode .modal .modal-title { color: #f8f9fa; } .dark-mode .modal .text-muted { color: #adb5bd !important; } .dark-mode .modal .modal-body h6 { color: #f8f9fa; } .modal-header .btn-close:hover { background-color: rgba(0, 0, 0, 0.1); } .dark-mode .modal-header .btn-close:hover { background-color: rgba(255, 255, 255, 0.1); } .modal.fade .modal-dialog { transition: transform 0.3s ease-out; transform: translate(0, -50px); } .modal.show .modal-dialog { transform: none; } @media (prefers-reduced-motion: reduce) { .modal.fade .modal-dialog { transition: none; } } .dark-mode input::placeholder { color: #6c757d; } .dark-mode .form-control { color: #f8f9fa; background-color: #343a40; border-color: #495057; } .dark-mode .form-control:focus { color: #f8f9fa; background-color: #343a40; border-color: #80bdff; box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); } .dark-mode .text-muted { color: #adb5bd !important; } .dark-mode .card-header { color: #f8f9fa; background-color: #495057; border-bottom: 1px solid #6c757d; } .dark-mode .card-title { color: #f8f9fa; } .card { border: 1px solid rgba(255, 255, 255, 0.125); transition: transform 0.3s ease-in-out; } .card:hover { transform: translateY(-5px); } .table td { word-break: break-word; } #imageSlider { max-width: 100%; height: auto; } .carousel-item img { object-fit: contain; height: 400px; background-color: #000; transition: opacity 0.3s ease-in-out; } .carousel-item img[loading="lazy"] { opacity: 0; } .carousel-item img[loading="lazy"].loaded { opacity: 1; } .toast-container { position: fixed; top: 20px; right: 20px; z-index: 9999; max-width: 400px; pointer-events: none; } @media (max-width: 576px) { .toast-container { left: 20px; right: 20px; max-width: none; } } .toast { display: flex; align-items: flex-start; min-width: 300px; max-width: 400px; margin-bottom: 10px; padding: 16px 20px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border: 1px solid transparent; backdrop-filter: blur(10px); pointer-events: auto; transform: translateX(100%); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative; overflow: hidden; } .toast.show { transform: translateX(0); animation: toastSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); } .toast.removing { transform: translateX(100%); opacity: 0; animation: toastSlideOut 0.3s cubic-bezier(0.4, 0, 0.2, 1); } .toast.success { background-color: #d1e7dd; border-color: #badbcc; color: #0f5132; } .toast.error { background-color: #f8d7da; border-color: #f5c2c7; color: #721c24; } .toast.warning { background-color: #fff3cd; border-color: #ffecb5; color: #664d03; } .toast.info { background-color: #d1ecf1; border-color: #bee5eb; color: #0c5460; } .toast.dark-mode { background-color: rgba(33, 37, 41, 0.95); border-color: rgba(73, 80, 87, 0.5); color: #f8f9fa; } .toast.dark-mode.success { background-color: rgba(25, 135, 84, 0.2); border-color: rgba(25, 135, 84, 0.3); color: #d1e7dd; } .toast.dark-mode.error { background-color: rgba(220, 53, 69, 0.2); border-color: rgba(220, 53, 69, 0.3); color: #f8d7da; } .toast.dark-mode.warning { background-color: rgba(255, 193, 7, 0.2); border-color: rgba(255, 193, 7, 0.3); color: #fff3cd; } .toast.dark-mode.info { background-color: rgba(13, 202, 240, 0.2); border-color: rgba(13, 202, 240, 0.3); color: #d1ecf1; } .toast-icon { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; margin-right: 12px; margin-top: 2px; flex-shrink: 0; } .toast-icon i { font-size: 16px; } .toast.success .toast-icon { color: #198754; } .toast.error .toast-icon { color: #dc3545; } .toast.warning .toast-icon { color: #ffc107; } .toast.info .toast-icon { color: #0dcaf0; } .toast.dark-mode .toast-icon { color: #f8f9fa; } .toast-content { flex: 1; font-size: 14px; line-height: 1.4; font-weight: 500; } .toast-title { font-weight: 600; margin-bottom: 4px; font-size: 15px; } .toast-message { font-weight: 400; margin: 0; } .toast-close { background: none; border: none; color: currentColor; opacity: 0.6; cursor: pointer; padding: 4px; margin-left: 12px; border-radius: 4px; transition: opacity 0.2s ease; flex-shrink: 0; } .toast-close:hover { opacity: 1; background-color: rgba(0, 0, 0, 0.1); } .toast.dark-mode .toast-close:hover { background-color: rgba(255, 255, 255, 0.1); } .toast-close i { font-size: 14px; } .toast-progress { position: absolute; bottom: 0; left: 0; height: 3px; background-color: currentColor; opacity: 0.3; transition: width linear; border-radius: 0 0 12px 12px; } @keyframes toastSlideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes toastSlideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } @media (prefers-reduced-motion: reduce) { .toast { transition: none; animation: none; } .toast.show, .toast.removing { transform: translateX(0); } } .btn-icon { padding: 0.25rem 0.5rem; font-size: 0.875rem; line-height: 1.5; border-radius: 0.2rem; } .btn-icon svg, .btn-icon i { width: 1rem; height: 1rem; font-size: 1rem; } .login-bg { background-color: var(--bs-body-bg); min-height: 100vh; } .login-card { background-color: var(--bs-body-bg); border-color: var(--bs-border-color); } .login-card .card-body { padding: 2rem; } .login-logo { width: 64px; height: 64px; margin-bottom: 1rem; } .form-control, .input-group-text { background-color: var(--bs-body-bg); border-color: var(--bs-border-color); color: var(--bs-body-color); } .form-control:focus { background-color: var(--bs-body-bg); color: var(--bs-body-color); } .btn-primary { background-color: #007bff; border-color: #007bff; } .btn-primary:hover { background-color: #0056b3; border-color: #0056b3; } .dark-mode .btn-outline-primary:hover { color: #fff; background-color: #0d6efd; border-color: #0d6efd; } .dark-mode .btn-outline-secondary:hover { color: #fff; background-color: #6c757d; border-color: #6c757d; } .dark-mode .btn-outline-danger:hover { color: #fff; background-color: #dc3545; border-color: #dc3545; } .btn:disabled { opacity: 0.65; cursor: not-allowed; } .spinner-border-sm { width: 1rem; height: 1rem; } .loading-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.7); display: none; align-items: center; justify-content: center; z-index: 9999; color: white; } .loading-overlay.show { display: flex; } .progress-indicator { background-color: #007bff; height: 4px; position: fixed; top: 0; left: 0; transition: width 0.3s ease; z-index: 10000; } .btn:focus, .form-control:focus, .nav-link:focus { outline: 2px solid #007bff; outline-offset: 2px; box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); } .skip-link { position: absolute; top: -40px; left: 6px; background: #000; color: #fff; padding: 8px; text-decoration: none; z-index: 10000; } .skip-link:focus { top: 6px; } @media (prefers-contrast: high) { :root { --bs-body-color: #000000; --bs-body-bg: #ffffff; --bs-border-color: #000000; } .dark-mode { --bs-body-color: #ffffff; --bs-body-bg: #000000; --bs-border-color: #ffffff; } } .progress { border-radius: 0.375rem; box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); } .progress-bar { transition: width 0.3s ease; font-weight: 600; display: flex; align-items: center; justify-content: center; } .progress-bar.bg-info { background-color: #0dcaf0 !important; } #localProgressText, #remoteProgressText { font-size: 0.875rem; font-weight: 600; } .upload-form.uploading .btn-primary { pointer-events: none; } .upload-form.uploading .btn-secondary:not(.d-none) { display: inline-flex !important; } .dark-mode .progress { background-color: rgba(255, 255, 255, 0.1); } .dark-mode .progress-bar { background-color: #0d6efd; } .dark-mode .progress-bar.bg-info { background-color: #0dcaf0 !important; } .dark-mode .progress-bar.bg-success { background-color: #198754 !important; } .image-container { position: relative; width: 100%; height: 400px; background-color: #000; overflow: hidden; } .image-loading, .image-error { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background-color: #000; color: #fff; } .image-loading.show { display: flex; } .image-error.show { display: flex; } .preview-image { width: 100%; height: 100%; object-fit: contain; background-color: #000; transition: opacity 0.3s ease-in-out; } .preview-image.loaded { opacity: 1; } .preview-image.loading { opacity: 0; } .preview-image.error { display: none; } .spinner-border { width: 3rem; height: 3rem; } .image-loading .spinner-border { animation: spinner-border 0.75s linear infinite; } @keyframes spinner-border { to { transform: rotate(360deg); } } .dark-mode .image-loading, .dark-mode .image-error { background-color: #000; color: #f8f9fa; } .dark-mode .image-loading .text-muted, .dark-mode .image-error .text-muted { color: #adb5bd !important; } @media (prefers-reduced-motion: reduce) { * { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } .card:hover { transform: none; } .spinner-border { animation: none; } } ================================================ FILE: src/server/public/js/main.js ================================================ const themeToggle = document.getElementById('themeToggle'); const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)'); function setTheme(isDark) { document.body.classList.toggle('dark-mode', isDark); const icon = themeToggle.querySelector('i'); if (icon) { icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon'; } } setTheme(prefersDarkScheme.matches); themeToggle.addEventListener('click', () => { document.body.classList.toggle('dark-mode'); setTheme(document.body.classList.contains('dark-mode')); }); prefersDarkScheme.addEventListener('change', (e) => setTheme(e.matches)); class ToastManager { constructor() { this.container = document.getElementById('toast-container'); this.toasts = new Set(); this.queue = []; this.maxToasts = 5; this.defaultDuration = 4000; this.init(); } init() { if (!this.container) { console.error('Toast container not found'); return; } if (!this.container) { this.container = document.createElement('div'); this.container.id = 'toast-container'; this.container.className = 'toast-container'; this.container.setAttribute('role', 'region'); this.container.setAttribute('aria-label', 'Notifications'); this.container.setAttribute('aria-live', 'polite'); document.body.appendChild(this.container); } } getIcon(type) { const icons = { success: 'fas fa-check-circle', error: 'fas fa-exclamation-circle', warning: 'fas fa-exclamation-triangle', info: 'fas fa-info-circle' }; return icons[type] || icons.info; } getAriaRole(type) { return type === 'error' ? 'alert' : 'status'; } createToast(message, type = 'info', title = '', duration = null) { const toastId = 'toast-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); const toast = document.createElement('div'); toast.id = toastId; toast.className = `toast ${type}`; toast.setAttribute('role', this.getAriaRole(type)); toast.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite'); toast.setAttribute('aria-atomic', 'true'); if (document.body.classList.contains('dark-mode')) { toast.classList.add('dark-mode'); } const content = `
${title ? `
${title}
` : ''}
${message}
`; toast.innerHTML = content; this.container.appendChild(toast); setTimeout(() => { toast.classList.add('show'); }, 10); const progressBar = toast.querySelector('.toast-progress'); if (progressBar) { progressBar.style.transition = `width ${duration || this.defaultDuration}ms linear`; setTimeout(() => { progressBar.style.width = '0%'; }, 10); } const autoRemove = setTimeout(() => { this.removeToast(toastId); }, duration || this.defaultDuration); const toastObj = { id: toastId, element: toast, timer: autoRemove }; this.toasts.add(toastObj); this.manageQueue(); return toastId; } removeToast(toastId) { const toast = document.getElementById(toastId); if (!toast) return; const toastObj = Array.from(this.toasts).find(t => t.id === toastId); if (toastObj) { clearTimeout(toastObj.timer); this.toasts.delete(toastObj); } toast.classList.add('removing'); setTimeout(() => { if (toast.parentNode) { toast.parentNode.removeChild(toast); } }, 300); this.processQueue(); } manageQueue() { if (this.toasts.size >= this.maxToasts) { const oldestToast = this.toasts.values().next().value; if (oldestToast) { this.removeToast(oldestToast.id); } } } processQueue() { if (this.queue.length > 0 && this.toasts.size < this.maxToasts) { const nextToast = this.queue.shift(); this.createToast(...nextToast); } } success(message, title = 'Success', duration = null) { return this.createToast(message, 'success', title, duration); } error(message, title = 'Error', duration = null) { return this.createToast(message, 'error', title, duration); } warning(message, title = 'Warning', duration = null) { return this.createToast(message, 'warning', title, duration); } info(message, title = 'Info', duration = null) { return this.createToast(message, 'info', title, duration); } } const toastManager = new ToastManager(); function showToast(message, type = 'info', title = '', duration = null) { return toastManager.createToast(message, type, title, duration); } function showSuccessToast(message, title = 'Success', duration = null) { return toastManager.success(message, title, duration); } function showErrorToast(message, title = 'Error', duration = null) { return toastManager.error(message, title, duration); } function showWarningToast(message, title = 'Warning', duration = null) { return toastManager.warning(message, title, duration); } function showInfoToast(message, title = 'Info', duration = null) { return toastManager.info(message, title, duration); } function copyFileName(name) { // Try modern clipboard API first if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(name).then(() => { showSuccessToast(name + " copied to clipboard!", "Copied!"); }).catch(async err => { console.error('Failed to copy text: ', err); await fallbackCopyTextToClipboard(name); }); } else { fallbackCopyTextToClipboard(name); } } async function fallbackCopyTextToClipboard(text) { const textArea = document.createElement("textarea"); textArea.value = text; // Avoid scrolling to bottom textArea.style.top = "0"; textArea.style.left = "0"; textArea.style.position = "fixed"; textArea.style.opacity = "0"; document.body.appendChild(textArea); textArea.focus(); textArea.select(); try { // Use modern clipboard API if available, even in fallback if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); showSuccessToast(text + " copied to clipboard!", "Copied!"); } else { // Final fallback: select and inform user to copy manually const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(textArea); selection.removeAllRanges(); selection.addRange(range); textArea.setSelectionRange(0, text.length); // Show info message that user needs to manually copy showInfoToast("Text selected - please copy manually (Ctrl+C or Cmd+C)", "Manual Copy"); } } catch (err) { console.error('Fallback: Could not copy text: ', err); showErrorToast("Failed to copy to clipboard", "Copy Error"); } document.body.removeChild(textArea); } function initLazyLoading() { if ('IntersectionObserver' in window) { const imageObserver = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; img.classList.add('loaded'); observer.unobserve(img); } }); }); document.querySelectorAll('img[loading="lazy"]').forEach(img => { imageObserver.observe(img); }); } else { document.querySelectorAll('img[loading="lazy"]').forEach(img => { img.classList.add('loaded'); }); } } document.addEventListener('DOMContentLoaded', initLazyLoading); function initFormLoadingStates() { const localForm = document.getElementById('localUploadForm'); const localBtn = document.getElementById('localUploadBtn'); const localSpinner = localBtn?.querySelector('.spinner-border'); if (localForm && localBtn && localSpinner) { localForm.addEventListener('submit', function (e) { localBtn.disabled = true; localSpinner.classList.remove('d-none'); localBtn.querySelector('span:not(.spinner-border)').textContent = 'Uploading...'; }); } const remoteForm = document.getElementById('remoteUploadForm'); const remoteBtn = document.getElementById('remoteUploadBtn'); const remoteSpinner = remoteBtn?.querySelector('.spinner-border'); if (remoteForm && remoteBtn && remoteSpinner) { remoteForm.addEventListener('submit', function (e) { remoteBtn.disabled = true; remoteSpinner.classList.remove('d-none'); remoteBtn.querySelector('span:not(.spinner-border)').textContent = 'Uploading...'; }); } } function showGlobalLoading(message = 'Loading...') { const overlay = document.getElementById('loadingOverlay'); const text = document.getElementById('loadingText'); const progress = document.getElementById('progressIndicator'); if (text) text.textContent = message; if (overlay) overlay.classList.add('show'); if (progress) progress.style.width = '0%'; if (faviconManager) faviconManager.showLoading(); } function hideGlobalLoading() { const overlay = document.getElementById('loadingOverlay'); const progress = document.getElementById('progressIndicator'); if (overlay) overlay.classList.remove('show'); if (progress) progress.style.width = '0%'; if (faviconManager) faviconManager.hideLoading(); } function updateProgress(percentage) { const progress = document.getElementById('progressIndicator'); if (progress) { progress.style.width = Math.min(100, Math.max(0, percentage)) + '%'; } } function validateVideoFile(file) { const videoTypes = [ 'video/mp4', 'video/avi', 'video/mov', 'video/quicktime', 'video/x-msvideo', 'video/webm', 'video/mkv', 'video/x-matroska', 'video/3gpp', 'video/x-flv' ]; if (videoTypes.includes(file.type)) { return true; } const videoExtensions = ['.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv', '.3gp', '.m4v', '.asf', '.rm', '.vob']; const fileName = file.name.toLowerCase(); const hasVideoExtension = videoExtensions.some(ext => fileName.endsWith(ext)); return hasVideoExtension; } document.addEventListener('DOMContentLoaded', function () { initFormLoadingStates(); const localFileInput = document.getElementById('localFileInput'); if (localFileInput) { localFileInput.addEventListener('focus', function (e) { e.target.classList.add('user-interacted'); }); localFileInput.addEventListener('change', function (e) { const file = e.target.files[0]; if (file && !validateVideoFile(file)) { showWarningToast('Please select a valid video file (MP4, AVI, MOV, MKV, WebM, etc.)', 'Invalid File Type'); e.target.value = ''; } }); } document.addEventListener('keydown', function (e) { const deleteModal = document.getElementById('deleteModal'); const modal = bootstrap.Modal.getInstance(deleteModal); if (e.key === 'Escape' && modal && deleteModal.classList.contains('show')) { modal.hide(); } }); }); function showDeleteModal(fileName, deleteUrl) { const modal = new bootstrap.Modal(document.getElementById('deleteModal')); const fileNameElement = document.getElementById('deleteFileName'); const confirmBtn = document.getElementById('confirmDeleteBtn'); fileNameElement.textContent = fileName; confirmBtn.href = deleteUrl; confirmBtn.addEventListener('click', function (e) { setTimeout(() => { modal.hide(); }, 100); }); modal.show(); } function handleDeleteSuccess() { const modal = bootstrap.Modal.getInstance(document.getElementById('deleteModal')); if (modal) { modal.hide(); } showSuccessToast('Video file deleted successfully!', 'File Deleted'); setTimeout(() => { window.location.reload(); }, 1500); } let localUploadXHR = null; let remoteUploadXHR = null; function startLocalUpload() { const fileInput = document.getElementById('localFileInput'); const progressContainer = document.getElementById('localProgressContainer'); const progressBar = document.getElementById('localProgressBar'); const progressText = document.getElementById('localProgressText'); const progressStatus = document.getElementById('localProgressStatus'); const uploadBtn = document.getElementById('localUploadBtn'); const cancelBtn = document.getElementById('localCancelBtn'); if (!fileInput.files[0]) { showWarningToast('Please select a file first', 'No File Selected'); return; } const file = fileInput.files[0]; const formData = new FormData(); formData.append('file', file); progressContainer.classList.remove('d-none'); uploadBtn.classList.add('d-none'); cancelBtn.classList.remove('d-none'); localUploadXHR = new XMLHttpRequest(); localUploadXHR.upload.addEventListener('progress', function (e) { if (e.lengthComputable) { const percentComplete = Math.round((e.loaded / e.total) * 100); progressBar.style.width = percentComplete + '%'; progressText.textContent = percentComplete + '%'; progressStatus.textContent = `Uploading... ${formatFileSize(e.loaded)} / ${formatFileSize(e.total)}`; } }); localUploadXHR.addEventListener('load', function () { try { if (localUploadXHR.status === 200) { const response = JSON.parse(localUploadXHR.responseText); progressBar.classList.remove('progress-bar-animated', 'progress-bar-striped'); progressBar.classList.add('bg-success'); progressText.textContent = '100%'; progressStatus.textContent = 'Upload completed successfully!'; showSuccessToast('File uploaded successfully!', 'Upload Complete'); setTimeout(() => { window.location.reload(); }, 2000); } else { showErrorToast('Upload failed. Please try again.', 'Upload Error'); resetLocalUpload(); } } catch (error) { console.error('Error processing upload response:', error); showErrorToast('Upload failed due to processing error.', 'Upload Error'); resetLocalUpload(); } }); localUploadXHR.addEventListener('error', function () { showErrorToast('Upload failed. Please check your connection.', 'Connection Error'); resetLocalUpload(); }); localUploadXHR.open('POST', '/api/upload'); localUploadXHR.send(formData); } function cancelLocalUpload() { if (localUploadXHR) { localUploadXHR.abort(); // Clean up event listeners to prevent memory leaks localUploadXHR.onload = null; localUploadXHR.onerror = null; localUploadXHR.upload.onprogress = null; } resetLocalUpload(); showInfoToast('Upload cancelled', 'Upload Cancelled'); } function resetLocalUpload() { const progressContainer = document.getElementById('localProgressContainer'); const progressBar = document.getElementById('localProgressBar'); const progressText = document.getElementById('localProgressText'); const progressStatus = document.getElementById('localProgressStatus'); const uploadBtn = document.getElementById('localUploadBtn'); const cancelBtn = document.getElementById('localCancelBtn'); progressContainer.classList.add('d-none'); progressBar.style.width = '0%'; progressBar.classList.add('progress-bar-animated', 'progress-bar-striped'); progressBar.classList.remove('bg-success'); progressText.textContent = '0%'; progressStatus.textContent = 'Starting upload...'; uploadBtn.classList.remove('d-none'); cancelBtn.classList.add('d-none'); localUploadXHR = null; } function startRemoteUpload() { const urlInput = document.getElementById('remoteFileInput'); const progressContainer = document.getElementById('remoteProgressContainer'); const progressBar = document.getElementById('remoteProgressBar'); const progressText = document.getElementById('remoteProgressText'); const progressStatus = document.getElementById('remoteProgressStatus'); const uploadBtn = document.getElementById('remoteUploadBtn'); const cancelBtn = document.getElementById('remoteCancelBtn'); if (!urlInput.value) { showWarningToast('Please enter a valid URL', 'Missing URL'); return; } progressContainer.classList.remove('d-none'); uploadBtn.classList.add('d-none'); cancelBtn.classList.remove('d-none'); let progress = 0; const progressInterval = setInterval(() => { progress += Math.random() * 15; if (progress >= 90) { progress = 90; clearInterval(progressInterval); } progressBar.style.width = Math.round(progress) + '%'; progressText.textContent = Math.round(progress) + '%'; progressStatus.textContent = 'Downloading...'; }, 500); const formData = new FormData(); formData.append('link', urlInput.value); remoteUploadXHR = new XMLHttpRequest(); remoteUploadXHR.addEventListener('load', function () { clearInterval(progressInterval); try { if (remoteUploadXHR.status === 200) { const response = JSON.parse(remoteUploadXHR.responseText); progressBar.classList.remove('progress-bar-animated', 'progress-bar-striped'); progressBar.classList.add('bg-success'); progressBar.style.width = '100%'; progressText.textContent = '100%'; progressStatus.textContent = 'Download completed successfully!'; showSuccessToast('Remote file downloaded successfully!', 'Download Complete'); setTimeout(() => { window.location.reload(); }, 2000); } else { showErrorToast('Download failed. Please check the URL.', 'Download Error'); resetRemoteUpload(); } } catch (error) { console.error('Error processing download response:', error); showErrorToast('Download failed due to processing error.', 'Download Error'); resetRemoteUpload(); } }); remoteUploadXHR.addEventListener('error', function () { clearInterval(progressInterval); showErrorToast('Download failed. Please check your connection.', 'Connection Error'); resetRemoteUpload(); }); remoteUploadXHR.open('POST', '/api/remote_upload'); remoteUploadXHR.send(formData); } function cancelRemoteUpload() { if (remoteUploadXHR) { remoteUploadXHR.abort(); // Clean up event listeners to prevent memory leaks remoteUploadXHR.onload = null; remoteUploadXHR.onerror = null; } resetRemoteUpload(); showInfoToast('Download cancelled', 'Download Cancelled'); } function resetRemoteUpload() { const progressContainer = document.getElementById('remoteProgressContainer'); const progressBar = document.getElementById('remoteProgressBar'); const progressText = document.getElementById('remoteProgressText'); const progressStatus = document.getElementById('remoteProgressStatus'); const uploadBtn = document.getElementById('remoteUploadBtn'); const cancelBtn = document.getElementById('remoteCancelBtn'); progressContainer.classList.add('d-none'); progressBar.style.width = '0%'; progressBar.classList.add('progress-bar-animated', 'progress-bar-striped'); progressBar.classList.remove('bg-success'); progressText.textContent = '0%'; progressStatus.textContent = 'Starting download...'; uploadBtn.classList.remove('d-none'); cancelBtn.classList.add('d-none'); remoteUploadXHR = null; } function formatFileSize(bytes) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } class PreviewImageManager { constructor() { this.init(); } init() { this.setupImageLoading(); this.setupLazyLoading(); } setupImageLoading() { const imageContainers = document.querySelectorAll('.image-container'); imageContainers.forEach((container, index) => { const img = container.querySelector('.preview-image'); const loadingDiv = container.querySelector('.image-loading'); const errorDiv = container.querySelector('.image-error'); if (!img) return; this.showLoadingState(container); img.addEventListener('load', () => { this.showImageState(container); img.classList.add('loaded'); }); img.addEventListener('error', () => { this.showErrorState(container); }); if (img.complete && img.naturalHeight !== 0) { this.showImageState(container); img.classList.add('loaded'); } }); } setupLazyLoading() { if ('IntersectionObserver' in window) { const imageObserver = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { const container = entry.target; const img = container.querySelector('.preview-image'); if (img && !img.src) { const carouselItem = container.closest('.carousel-item'); if (carouselItem) { const previewUrl = carouselItem.getAttribute('data-preview-url'); if (previewUrl) { img.src = previewUrl; this.showLoadingState(container); } } } observer.unobserve(container); } }); }); document.querySelectorAll('.image-container').forEach(container => { imageObserver.observe(container); }); } } showLoadingState(container) { const img = container.querySelector('.preview-image'); const loadingDiv = container.querySelector('.image-loading'); const errorDiv = container.querySelector('.image-error'); if (loadingDiv) loadingDiv.classList.remove('d-none'); if (loadingDiv) loadingDiv.classList.add('show'); if (errorDiv) errorDiv.classList.add('d-none'); if (errorDiv) errorDiv.classList.remove('show'); if (img) img.style.display = 'none'; } showImageState(container) { const img = container.querySelector('.preview-image'); const loadingDiv = container.querySelector('.image-loading'); const errorDiv = container.querySelector('.image-error'); if (loadingDiv) loadingDiv.classList.add('d-none'); if (loadingDiv) loadingDiv.classList.remove('show'); if (errorDiv) errorDiv.classList.add('d-none'); if (errorDiv) errorDiv.classList.remove('show'); if (img) img.style.display = 'block'; } showErrorState(container) { const img = container.querySelector('.preview-image'); const loadingDiv = container.querySelector('.image-loading'); const errorDiv = container.querySelector('.image-error'); if (loadingDiv) loadingDiv.classList.add('d-none'); if (loadingDiv) loadingDiv.classList.remove('show'); if (errorDiv) errorDiv.classList.remove('d-none'); if (errorDiv) errorDiv.classList.add('show'); if (img) img.style.display = 'none'; } retryLoad(container) { const img = container.querySelector('.preview-image'); const carouselItem = container.closest('.carousel-item'); if (img && carouselItem) { const previewUrl = carouselItem.getAttribute('data-preview-url'); if (previewUrl) { img.src = ''; this.showLoadingState(container); setTimeout(() => { img.src = previewUrl; }, 500); } } } } const previewImageManager = new PreviewImageManager(); class FaviconManager { constructor() { this.faviconSvg = '/favicon.svg'; this.faviconPng = '/favicon-32x32.png'; this.init(); } init() { this.updateFavicon(); this.setupThemeListener(); this.setupLoadingListener(); } updateFavicon() { const isDark = document.body.classList.contains('dark-mode'); const favicon = document.querySelector('link[rel="icon"][type="image/svg+xml"]'); if (favicon) { const timestamp = Date.now(); favicon.href = `${this.faviconSvg}?t=${timestamp}`; } } setupThemeListener() { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'attributes' && mutation.attributeName === 'class') { this.updateFavicon(); } }); }); observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); } setupLoadingListener() { document.body.classList.add('loading'); window.addEventListener('load', () => { document.body.classList.remove('loading'); }); const originalFetch = window.fetch; window.fetch = function (...args) { document.body.classList.add('loading'); return originalFetch.apply(this, args).finally(() => { setTimeout(() => { document.body.classList.remove('loading'); }, 100); }); }; } showLoading() { document.body.classList.add('loading'); } hideLoading() { document.body.classList.remove('loading'); } } const faviconManager = new FaviconManager(); ================================================ FILE: src/server/public/site.webmanifest ================================================ { "name": "StreamBot Video Manager", "short_name": "StreamBot", "description": "Upload, manage, and preview your video files", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#007bff", "orientation": "portrait", "icons": [ { "src": "/favicon-16x16.png", "sizes": "16x16", "type": "image/png", "purpose": "any maskable" }, { "src": "/favicon-32x32.png", "sizes": "32x32", "type": "image/png", "purpose": "any maskable" }, { "src": "/apple-touch-icon.png", "sizes": "180x180", "type": "image/png", "purpose": "any maskable" } ] } ================================================ FILE: src/server/routes/auth.ts ================================================ import { Router } from 'express'; import bcrypt from 'bcrypt'; import argon2 from 'argon2'; import config from '../../config.js'; import logger from '../../utils/logger.js'; const router = Router(); // Login route - GET router.get("/login", (req, res) => { res.render('pages/login', { error: req.query.error === '1', showLogout: false }); }); // Login route - POST router.post("/login", async (req, res) => { const { username, password } = req.body; let isPasswordMatch = false; // Check if the stored password is a hash or plain text if (config.server_password.startsWith('$argon2')) { // Argon2 hash try { isPasswordMatch = await argon2.verify(config.server_password, password); } catch (err) { logger.error("Error verifying argon2 password:", err); isPasswordMatch = false; } } else if (config.server_password.startsWith('$2')) { // Bcrypt hash isPasswordMatch = await bcrypt.compare(password, config.server_password); } else { // Plain text (not recommended) isPasswordMatch = password === config.server_password; } if (username === config.server_username && isPasswordMatch) { (req.session as { user?: unknown }).user = username; res.redirect("/"); } else { res.redirect("/login?error=1"); } }); // Logout route router.get("/logout", (req, res) => { req.session.destroy((err) => { if (err) { logger.error("Error destroying session:", err); } res.redirect("/login"); }); }); export default router; ================================================ FILE: src/server/routes/dashboard.ts ================================================ import { Router } from 'express'; import fs from 'fs'; import path from 'path'; import config from '../../config.js'; import logger from '../../utils/logger.js'; import { prettySize } from '../utils/helpers.js'; const router = Router(); // Main dashboard route router.get("/", (req, res) => { fs.readdir(config.videosDir, (err, files) => { if (err) { logger.error(err); res.status(500).send("Internal Server Error"); return; } const fileList = files.map((file) => { const stats = fs.statSync(path.join(config.videosDir, file)); return { name: file, size: prettySize(stats.size) }; }); res.render('pages/dashboard', { files: fileList, showLogout: true }); }); }); export default router; ================================================ FILE: src/server/routes/preview.ts ================================================ import { Router } from 'express'; import fs from 'fs'; import path from 'path'; import ffmpeg from 'fluent-ffmpeg'; import config from '../../config.js'; import logger from '../../utils/logger.js'; import { ffmpegScreenshot } from '../../utils/ffmpeg.js'; import { stringify } from '../utils/helpers.js'; const router = Router(); // Preview route router.get("/preview/:file", (req, res) => { const file = req.params.file; if (!fs.existsSync(path.join(config.videosDir, file))) { res.status(404).send("Not Found"); return; } ffmpeg.ffprobe(`${config.videosDir}/${file}`, (err, metadata) => { if (err) { logger.error(err); res.status(500).send("Internal Server Error"); return; } // Generate preview images const previews = []; for (let i = 1; i <= 5; i++) { previews.push(`/api/preview/${file}/${i}`); } res.render('pages/preview', { filename: file, metadata: metadata, previews: previews, showLogout: true }); }); }); // Generate preview of video file using ffmpeg, cache it to previewCache and serve it router.get("/api/preview/:file/:id", async (req, res) => { const file = req.params.file; const id = parseInt(req.params.id, 10); // id should be 1, 2, 3, 4 or 5 if (id < 1 || id > 5) { res.status(404).send("Not Found"); return; } // check if preview exists const previewFile = path.resolve(config.previewCacheDir, `${file}-${id}.jpg`); if (fs.existsSync(previewFile)) { res.sendFile(previewFile); } else { try { await ffmpegScreenshot(file); } catch (err) { logger.error(err); res.status(500).send("Internal Server Error"); return; } res.sendFile(previewFile); } }); // Delete route router.get("/delete/:file", (req, res) => { const file = req.params.file; const filePath = path.join(config.videosDir, file); if (fs.existsSync(filePath)) { fs.unlink(filePath, (err) => { if (err) { logger.error(err); res.status(500).send("Internal Server Error"); } else { res.redirect("/"); } }); } else { res.status(404).send("Not Found"); } }); export default router; ================================================ FILE: src/server/routes/upload.ts ================================================ import { Router } from 'express'; import axios from 'axios'; import https from 'https'; import fs from 'fs'; import path from 'path'; import config from '../../config.js'; import logger from '../../utils/logger.js'; import { upload } from '../middleware/multer.js'; const router = Router(); const agent = new https.Agent({ rejectUnauthorized: false }); // Upload route - local file with progress support router.post("/api/upload", upload.single("file"), (req, res) => { res.json({ success: true, message: "File uploaded successfully", filename: req.file.filename, size: req.file.size }); }); // Upload route - remote file with progress support router.post("/api/remote_upload", upload.single("link"), async (req, res) => { const link = req.body.link; const filename = link.substring(link.lastIndexOf('/') + 1); const filepath = path.join(config.videosDir, filename); try { // First, get the file info to determine size const headResponse = await axios.head(link, { httpsAgent: agent }); const totalSize = parseInt(headResponse.headers['content-length'], 10); // Set up progress tracking let downloaded = 0; let lastProgressSent = 0; const response = await axios.get(link, { responseType: "stream", httpsAgent: agent, onDownloadProgress: (progressEvent) => { downloaded = progressEvent.loaded; const percentCompleted = Math.round((downloaded * 100) / totalSize); // Send progress updates every 2% if (percentCompleted - lastProgressSent >= 2) { lastProgressSent = percentCompleted; logger.info(`Remote download progress: ${percentCompleted}%`); } } }); const writer = fs.createWriteStream(filepath); response.data.on('data', (chunk) => { downloaded += chunk.length; const percentCompleted = Math.round((downloaded * 100) / totalSize); // Send progress updates every 2% if (percentCompleted - lastProgressSent >= 2) { lastProgressSent = percentCompleted; logger.info(`Remote download progress: ${percentCompleted}%`); } }); response.data.pipe(writer); writer.on("finish", () => { res.json({ success: true, message: "Remote file downloaded successfully", filename: filename, size: downloaded }); }); writer.on("error", (err) => { logger.error(err); res.status(500).json({ success: false, message: "Error downloading file" }); }); } catch (err) { logger.error(err); res.status(500).json({ success: false, message: "Error downloading file" }); } }); export default router; ================================================ FILE: src/server/utils/helpers.ts ================================================ // Helper function to stringify objects for display in templates export function stringify(obj: any): string { // if string, return it if (typeof obj === "string") { return obj; } if (Array.isArray(obj)) { return `
    ${obj.map(item => { return `
  • ${stringify(item)}
  • `; }).join("")}
`; } else { if (typeof obj === "object") { return `
    ${Object.keys(obj).map(key => { return `
  • ${key}: ${stringify(obj[key])}
  • `; }).join("")}
`; } else { return String(obj); } } } // Helper function to format file size export function prettySize(bytes: number): string { const units = ["B", "KB", "MB", "GB", "TB"]; let i = 0; while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; } return `${bytes.toFixed(2)} ${units[i]}`; } ================================================ FILE: src/server/views/layouts/main.ejs ================================================ StreamBot Video Manager
<% if (typeof showLogout !== 'undefined' && showLogout) { %> <% } %> <%- body %> ================================================ FILE: src/server/views/pages/dashboard.ejs ================================================

StreamBot Video Manager

Video Files
<% if (typeof files !== 'undefined' && files.length > 0) { %> <% files.forEach(function(file) { %> <% }); %> <% } else { %> <% } %>
Name Size Actions
<%= file.name %> <%= file.size %>
No video files found
Upload
0%
Starting upload...
Remote Upload
0%
Starting download...
================================================ FILE: src/server/views/pages/login.ejs ================================================ ================================================ FILE: src/server/views/pages/preview.ejs ================================================

File Preview

<%= filename %>

Metadata

<% if (typeof metadata !== 'undefined' && metadata.format) { %> <% Object.entries(metadata.format).forEach(function([key, value]) { %> <% }); %> <% } %>
<%= key %> <%- stringify(value) %>

Preview

================================================ FILE: src/services/media.ts ================================================ import { getStream, getVod } from 'twitch-m3u8'; import { TwitchStream, MediaSource } from '../types/index.js'; import config from "../config.js"; import logger from '../utils/logger.js'; import { Youtube } from '../utils/youtube.js'; import ytdl, { downloadToTempFile } from '../utils/yt-dlp.js'; import { GeneralUtils } from '../utils/shared.js'; import { YTResponse } from '../types/index.js'; import path from 'path'; export class MediaService { private youtube: Youtube; constructor() { this.youtube = new Youtube(); } public async resolveMediaSource(url: string): Promise { try { if (url.includes('youtube.com/') || url.includes('youtu.be/')) { return await this._resolveYouTubeSource(url); } else if (url.includes('twitch.tv/')) { return await this._resolveTwitchSource(url); } else if (GeneralUtils.isLocalFile(url)) { return this._resolveLocalSource(url); } else if (GeneralUtils.isValidUrl(url)) { return this._resolveDirectUrlSource(url); } else { return this.searchAndPlayYouTube(url); } return null; } catch (error) { logger.error("Failed to resolve media source:", error); return null; } } private async _resolveYouTubeSource(url: string): Promise { const videoDetails = await this.youtube.getVideoInfo(url); if (!videoDetails) return null; const isLive = videoDetails.videoDetails?.isLiveContent || false; const streamUrl = isLive ? await this.youtube.getLiveStreamUrl(url) : url; if (streamUrl) { return { url: streamUrl, title: videoDetails.title, type: 'youtube', isLive: isLive, }; } return null; } public async getTwitchStreamUrl(url: string): Promise { try { // Handle VODs if (url.includes('/videos/')) { const vodId = url.split('/videos/').pop() as string; const vodInfo = await getVod(vodId); const vod = vodInfo.find((stream: TwitchStream) => stream.resolution === `${config.width}x${config.height}`) || vodInfo[0]; if (vod?.url) { return vod.url; } logger.error("No VOD URL found"); return null; } else { const twitchId = url.split('/').pop() as string; const streams = await getStream(twitchId); const stream = streams.find((stream: TwitchStream) => stream.resolution === `${config.width}x${config.height}`) || streams[0]; if (stream?.url) { return stream.url; } logger.error("No Stream URL found"); return null; } } catch (error) { logger.error("Failed to get Twitch stream URL:", error); return null; } } public async downloadYouTubeVideo(url: string): Promise { try { const ytDlpDownloadOptions = { format: `bestvideo[height<=${config.height || 720}][ext=mp4]+bestaudio[ext=m4a]/bestvideo[height<=${config.height || 720}]+bestaudio/best[height<=${config.height || 720}]/best`, noPlaylist: true, }; const tempFilePath = await downloadToTempFile(url, ytDlpDownloadOptions); return tempFilePath; } catch (error) { logger.error("Failed to download YouTube video:", error); return null; } } private async _resolveTwitchSource(url: string): Promise { const streamUrl = await this.getTwitchStreamUrl(url); if (streamUrl) { const twitchId = url.split('/').pop() as string; return { url: streamUrl, title: `twitch.tv/${twitchId}`, type: 'twitch' }; } return null; } private _resolveLocalSource(url: string): MediaSource { return { url, title: path.basename(url, path.extname(url)), type: 'local' }; } private async _resolveDirectUrlSource(url: string): Promise { // First try to get metadata using yt-dlp try { const metadata = await ytdl(url, { dumpJson: true, skipDownload: true, noWarnings: true, quiet: true }) as YTResponse; // If yt-dlp succeeds, use the extracted metadata if (metadata && metadata.title) { // Get the best available format URL let streamUrl = url; if (metadata.formats && Array.isArray(metadata.formats) && metadata.formats.length > 0) { // Find the format with both audio and video, preferring higher quality const bestFormat = metadata.formats .filter((format) => format.url && format.ext !== 'm3u8') // Avoid HLS streams .sort((a, b) => { // Prefer formats with both audio and video const aScore = (a.vcodec && a.vcodec !== 'none' ? 1 : 0) + (a.acodec && a.acodec !== 'none' ? 1 : 0) + (a.height || 0) / 1000; const bScore = (b.vcodec && b.vcodec !== 'none' ? 1 : 0) + (b.acodec && b.acodec !== 'none' ? 1 : 0) + (b.height || 0) / 1000; return bScore - aScore; })[0]; if (bestFormat && bestFormat.url) { streamUrl = bestFormat.url; } } return { url: streamUrl, title: metadata.title, type: 'url' }; } } catch (error) { // yt-dlp failed, log debug info and continue to fallback logger.debug("yt-dlp failed to extract metadata for URL:", url, error); } // Fallback to original URL parsing logic let title = "Direct URL"; try { const urlObj = new URL(url); const pathname = urlObj.pathname; const filename = pathname.split('/').pop(); if (filename && filename.includes('.')) { title = decodeURIComponent(filename.replace(/\.[^/.]+$/, "")); } else if (pathname !== '/' && pathname.length > 1) { const pathSegment = pathname.split('/').pop(); if (pathSegment) { title = decodeURIComponent(pathSegment); } } } catch (e) { logger.debug("Could not parse URL for title extraction:", url); } return { url, title, type: 'url' }; } public async searchYouTube(query: string, limit: number = 5): Promise { try { return await this.youtube.search(query, limit); } catch (error) { logger.error("Failed to search YouTube:", error); return []; } } public async searchAndPlayYouTube(query: string): Promise { try { const searchResult = await this.youtube.searchAndGetPageUrl(query); if (searchResult.pageUrl && searchResult.title) { return { url: searchResult.pageUrl, title: searchResult.title, type: 'youtube' }; } return null; } catch (error) { logger.error("Failed to search and play YouTube:", error); return null; } } } ================================================ FILE: src/services/queue.ts ================================================ import { VideoQueue, QueueItem, MediaSource } from "../types/index.js"; import { MediaService } from "./media.js"; import logger from '../utils/logger.js'; export class QueueService { private mediaService: MediaService; private queue: VideoQueue; constructor() { this.mediaService = new MediaService(); this.queue = { items: [], currentIndex: -1, isPlaying: false }; } /** * Add a video to the queue */ public async addToQueue( mediaSource: MediaSource, requestedBy: string, originalInput?: string ): Promise { return this.add( mediaSource.url, mediaSource.title, requestedBy, mediaSource.type, mediaSource.isLive, originalInput || mediaSource.url ); } /** * Add a media source to the queue */ public async add( url: string, title: string, requestedBy: string, type: 'youtube' | 'twitch' | 'local' | 'url' = 'url', isLive: boolean = false, originalInput?: string ): Promise { const queueItem: QueueItem = { id: this.generateId(), url, title, type, isLive, requestedBy, addedAt: new Date(), originalInput: originalInput || url, resolved: originalInput === url, }; this.queue.items.push(queueItem); logger.info(`Added to queue: ${title} (requested by ${requestedBy}, resolved: ${queueItem.resolved})`); return queueItem; } /** * Get the next item in the queue */ getNext(): QueueItem | null { if (this.queue.items.length === 0) { this.queue.currentIndex = -1; return null; } if (this.queue.currentIndex < this.queue.items.length - 1) { this.queue.currentIndex++; return this.queue.items[this.queue.currentIndex]; } // No more items, reset currentIndex to -1 this.queue.currentIndex = -1; return null; } /** * Get the current playing item */ getCurrent(): QueueItem | null { if (this.queue.items.length === 0) { this.queue.currentIndex = -1; return null; } if (this.queue.currentIndex >= 0 && this.queue.currentIndex < this.queue.items.length) { return this.queue.items[this.queue.currentIndex]; } // If currentIndex is invalid, try to find a valid index if (this.queue.items.length > 0) { // If currentIndex is too high, set it to the last item if (this.queue.currentIndex >= this.queue.items.length) { this.queue.currentIndex = this.queue.items.length - 1; return this.queue.items[this.queue.currentIndex]; } // If currentIndex is negative, set it to 0 if (this.queue.currentIndex < 0) { this.queue.currentIndex = 0; return this.queue.items[this.queue.currentIndex]; } } return null; } /** * Skip to the next item in the queue */ skip(): QueueItem | null { const currentItem = this.getCurrent(); if (currentItem) { // Remove the current item since we're skipping it this.removeFromQueue(currentItem.id); } const nextItem = this.getNext(); // Ensure currentIndex is valid after skip if (nextItem && this.queue.currentIndex >= 0) { // Verify the current item matches what we expect const verifyCurrent = this.getCurrent(); if (!verifyCurrent || verifyCurrent.id !== nextItem.id) { const correctIndex = this.queue.items.findIndex(item => item.id === nextItem.id); if (correctIndex !== -1) { this.queue.currentIndex = correctIndex; } } } return nextItem; } /** * Remove an item from the queue by ID */ removeFromQueue(id: string): boolean { const index = this.queue.items.findIndex(item => item.id === id); if (index !== -1) { this.queue.items.splice(index, 1); // Adjust current index if necessary if (index < this.queue.currentIndex) { // Removed item was before current item, decrement index this.queue.currentIndex--; } else if (index === this.queue.currentIndex) { // Removed the current item itself, decrement index this.queue.currentIndex--; } return true; } return false; } /** * Clear the entire queue */ clearQueue(): void { this.queue.items = []; this.queue.currentIndex = -1; this.queue.isPlaying = false; logger.info('Queue cleared'); } /** * Reset the current index to -1 (no current item) */ resetCurrentIndex(): void { this.queue.currentIndex = -1; } /** * Get all items in the queue */ getQueue(): QueueItem[] { return [...this.queue.items]; } /** * Get the queue status */ getQueueStatus(): VideoQueue { return { ...this.queue }; } /** * Set the playing state */ setPlaying(isPlaying: boolean): void { this.queue.isPlaying = isPlaying; } /** * Check if the queue is empty */ isEmpty(): boolean { return this.queue.items.length === 0; } /** * Get the queue length */ getLength(): number { return this.queue.items.length; } /** * Move an item to a different position in the queue */ moveItem(fromIndex: number, toIndex: number): boolean { if (fromIndex < 0 || fromIndex >= this.queue.items.length || toIndex < 0 || toIndex >= this.queue.items.length) { return false; } const item = this.queue.items.splice(fromIndex, 1)[0]; this.queue.items.splice(toIndex, 0, item); // Adjust current index if necessary if (fromIndex === this.queue.currentIndex) { this.queue.currentIndex = toIndex; } else if (fromIndex < this.queue.currentIndex && toIndex >= this.queue.currentIndex) { this.queue.currentIndex--; } else if (fromIndex > this.queue.currentIndex && toIndex <= this.queue.currentIndex) { this.queue.currentIndex++; } return true; } /** * Generate a unique ID for queue items */ private generateId(): string { return Date.now().toString(36) + Math.random().toString(36).substr(2); } } ================================================ FILE: src/services/streaming.ts ================================================ import { Client, Message } from "discord.js-selfbot-v13"; import { Streamer, Utils, prepareStream, playStream } from "@dank074/discord-video-stream"; import fs from 'fs'; import config from "../config.js"; import { MediaService } from './media.js'; import { QueueService } from './queue.js'; import { getVideoParams } from "../utils/ffmpeg.js"; import logger from '../utils/logger.js'; import { DiscordUtils, ErrorUtils } from '../utils/shared.js'; import { QueueItem, StreamStatus } from '../types/index.js'; export class StreamingService { private streamer: Streamer; private mediaService: MediaService; private queueService: QueueService; private controller: AbortController | null = null; private streamStatus: StreamStatus; private failedVideos: Set = new Set(); private isSkipping: boolean = false; constructor(client: Client, streamStatus: StreamStatus) { this.streamer = new Streamer(client); this.mediaService = new MediaService(); this.queueService = new QueueService(); this.streamStatus = streamStatus; } public getStreamer(): Streamer { return this.streamer; } public getQueueService(): QueueService { return this.queueService; } private markVideoAsFailed(videoSource: string): void { this.failedVideos.add(videoSource); logger.info(`Marked video as failed: ${videoSource}`); } public async addToQueue( message: Message, videoSource: string, title?: string ): Promise { try { const username = message.author.username; const mediaSource = await this.mediaService.resolveMediaSource(videoSource); if (mediaSource) { const queueItem = await this.queueService.addToQueue(mediaSource, username); await DiscordUtils.sendSuccess(message, `Added to queue: \`${queueItem.title}\``); return true; } else { // Fallback for unresolved sources const queueItem = await this.queueService.add( videoSource, title || videoSource, username, 'url', false, videoSource ); await DiscordUtils.sendSuccess(message, `Added to queue: \`${queueItem.title}\``); return true; } } catch (error) { await ErrorUtils.handleError(error, `adding to queue: ${videoSource}`, message); return false; } } public async playFromQueue(message: Message): Promise { if (this.streamStatus.playing) { await DiscordUtils.sendError(message, 'Already playing a video. Use skip command to skip current video.'); return; } const nextItem = this.queueService.getNext(); if (!nextItem) { await DiscordUtils.sendError(message, 'Queue is empty.'); return; } this.queueService.setPlaying(true); await this.playVideoFromQueueItem(message, nextItem); } public async skipCurrent(message: Message): Promise { if (!this.streamStatus.playing) { await DiscordUtils.sendError(message, 'No video is currently playing.'); return; } // Check if this is the last item in the queue const queueLength = this.queueService.getLength(); const isLastItem = queueLength <= 1; // Prevent concurrent skip operations only if there are more items in queue if (this.isSkipping && !isLastItem) { await DiscordUtils.sendError(message, 'Skip already in progress.'); return; } this.isSkipping = true; try { // Stop the current stream immediately this.streamStatus.manualStop = true; this.controller?.abort(); this.streamer.stopStream(); const currentItem = this.queueService.getCurrent(); // Get item being skipped const nextItem = this.queueService.skip(); // Advance the queue if (!nextItem) { // No more items in queue - stop playback and leave voice channel await DiscordUtils.sendInfo(message, 'Queue', 'No more videos in queue.'); this.queueService.setPlaying(false); await this.cleanupStreamStatus(); return; } const currentTitle = currentItem ? currentItem.title : 'current video'; await DiscordUtils.sendInfo(message, 'Skipping', `Skipping \`${currentTitle}\`. Playing next: \`${nextItem.title}\``); // Reset manual stop flag since we're starting a new video this.streamStatus.manualStop = false; // Skip cleanup since we're playing the next item immediately await this.playVideoFromQueueItem(message, nextItem); } finally { this.isSkipping = false; } } private async playVideoFromQueueItem(message: Message, queueItem: QueueItem): Promise { // Ensure queue is marked as playing this.queueService.setPlaying(true); // Collect video parameters if respect_video_params is enabled let videoParams = undefined; if (config.respect_video_params) { videoParams = await this.getVideoParameters(queueItem.url); } // Log playing video logger.info(`Playing from queue: ${queueItem.title} (${queueItem.url})`); // Use streaming service to play the video with video parameters await this.playVideo(message, queueItem.url, queueItem.title, videoParams); } private async getVideoParameters(videoUrl: string): Promise<{ width: number, height: number, fps?: number, bitrate?: number } | undefined> { try { const resolution = await getVideoParams(videoUrl); logger.info(`Video parameters: ${resolution.width}x${resolution.height}, FPS: ${resolution.fps || 'unknown'}, Bitrate: ${resolution.bitrate || 'unknown'}`); let bitrateKbps: number | undefined; if (resolution.bitrate) { bitrateKbps = Math.round(parseInt(resolution.bitrate) / 1000); } return { width: resolution.width, height: resolution.height, fps: resolution.fps, bitrate: bitrateKbps }; } catch (error) { await ErrorUtils.handleError(error, 'determining video parameters'); return undefined; } } private async ensureVoiceConnection(guildId: string, channelId: string, title?: string): Promise { // Only join voice if not already connected if (!this.streamStatus.joined || !this.streamer.voiceConnection) { await this.streamer.joinVoice(guildId, channelId); this.streamStatus.joined = true; } this.streamStatus.playing = true; this.streamStatus.channelInfo = { guildId, channelId, cmdChannelId: config.cmdChannelId! }; if (title) { this.streamer.client.user?.setActivity(DiscordUtils.status_watch(title)); } // Wait for voice connection to be fully ready await new Promise(resolve => setTimeout(resolve, 2000)); // Verify voice connection exists if (!this.streamer.voiceConnection) { throw new Error('Voice connection is not established'); } } private setupStreamConfiguration(videoParams?: { width: number, height: number, fps?: number, bitrate?: number }): any { let width = videoParams?.width || config.width; let height = videoParams?.height || config.height; let frameRate = videoParams?.fps || config.fps; let bitrateVideo = config.bitrateKbps; // If respecting video params, use video bitrate unless overridden if (videoParams && videoParams.bitrate && !config.bitrateOverride) { bitrateVideo = videoParams.bitrate; } // Resolution capping if (config.maxWidth > 0 || config.maxHeight > 0) { const ratio = width / height; if (config.maxWidth > 0 && width > config.maxWidth) { width = config.maxWidth; height = Math.round(width / ratio); } if (config.maxHeight > 0 && height > config.maxHeight) { height = config.maxHeight; width = Math.round(height * ratio); } // Ensure even dimensions width = Math.round(width / 2) * 2; height = Math.round(height / 2) * 2; } return { width, height, frameRate, bitrateVideo, bitrateVideoMax: config.maxBitrateKbps, videoCodec: Utils.normalizeVideoCodec(config.videoCodec), hardwareAcceleratedDecoding: config.hardwareAcceleratedDecoding, minimizeLatency: false, h26xPreset: config.h26xPreset }; } private async executeStream(inputForFfmpeg: any, streamOpts: any, message: Message, title: string, videoSource: string): Promise { const { command, output: ffmpegOutput } = prepareStream(inputForFfmpeg, streamOpts, this.controller!.signal); command.on("error", (err, stdout, stderr) => { // Don't log error if it's due to manual stop if (!this.streamStatus.manualStop && this.controller && !this.controller.signal.aborted) { logger.error("An error happened with ffmpeg:", err.message); if (stdout) { logger.error("ffmpeg stdout:", stdout); } if (stderr) { logger.error("ffmpeg stderr:", stderr); } this.controller.abort(); } }); await playStream(ffmpegOutput, this.streamer, undefined, this.controller!.signal) .catch((err) => { if (this.controller && !this.controller.signal.aborted) { logger.error('playStream error:', err); // Send error message to user DiscordUtils.sendError(message, `Stream error: ${err.message || 'Unknown error'}`).catch(e => logger.error('Failed to send error message:', e) ); } if (this.controller && !this.controller.signal.aborted) this.controller.abort(); }); // Only log as finished if we didn't have an error and weren't manually stopped if (this.controller && !this.controller.signal.aborted && !this.streamStatus.manualStop) { logger.info(`Finished playing: ${title || videoSource}`); } else if (this.streamStatus.manualStop) { logger.info(`Stopped playing: ${title || videoSource}`); } else { logger.info(`Failed playing: ${title || videoSource}`); } } private async handleQueueAdvancement(message: Message): Promise { await DiscordUtils.sendFinishMessage(message); // The video finished playing, so remove it from the queue const finishedItem = this.queueService.getCurrent(); if (finishedItem) { this.queueService.removeFromQueue(finishedItem.id); } // Get the next item in the queue. const nextItem = this.queueService.getNext(); if (nextItem) { logger.info(`Auto-playing next item from queue: ${nextItem.title}`); setTimeout(() => { this.playVideoFromQueueItem(message, nextItem).catch(err => ErrorUtils.handleError(err, 'auto-playing next item') ); }, 1000); } else { // No more items in the queue, so stop playback and clean up this.queueService.setPlaying(false); logger.info('No more items in queue, playback stopped'); await this.cleanupStreamStatus(); } } private async handleDownload(message: Message, videoSource: string, title?: string): Promise { const downloadMessage = await message.reply(`📥 Downloading \`${title || 'YouTube video'}\`...`).catch(e => { logger.warn("Failed to send 'Downloading...' message:", e); return null; }); try { logger.info(`Downloading ${title || videoSource}...`); const tempFilePath = await this.mediaService.downloadYouTubeVideo(videoSource); if (tempFilePath) { logger.info(`Finished downloading ${title || videoSource}`); if (downloadMessage) { await downloadMessage.delete().catch(e => logger.warn("Failed to delete 'Downloading...' message:", e)); } return tempFilePath; } throw new Error('Download failed, no temp file path returned.'); } catch (error) { logger.error(`Failed to download YouTube video: ${videoSource}`, error); const errorMessage = `❌ Failed to download \`${title || 'YouTube video'}\`.`; if (downloadMessage) { await downloadMessage.edit(errorMessage).catch(e => logger.warn("Failed to edit 'Downloading...' message:", e)); } else { await DiscordUtils.sendError(message, `Failed to download video: ${error instanceof Error ? error.message : String(error)}`); } return null; } } private async prepareVideoSource(message: Message, videoSource: string, title?: string): Promise<{ inputForFfmpeg: any, tempFilePath: string | null }> { const mediaSource = await this.mediaService.resolveMediaSource(videoSource); if (mediaSource && mediaSource.type === 'youtube' && !mediaSource.isLive) { const tempFilePath = await this.handleDownload(message, videoSource, title); if (tempFilePath) { return { inputForFfmpeg: tempFilePath, tempFilePath }; } // Download failed, throw to stop playback throw new Error('Failed to prepare video source due to download failure.'); } return { inputForFfmpeg: mediaSource ? mediaSource.url : videoSource, tempFilePath: null }; } private async executeStreamWorkflow(input: any, options: any, message: Message, title: string, source: string): Promise { this.controller = new AbortController(); await this.executeStream(input, options, message, title, source); } private async finalizeStream(message: Message, tempFile: string | null): Promise { if (!this.streamStatus.manualStop && this.controller && !this.controller.signal.aborted) { await this.handleQueueAdvancement(message); } else { this.queueService.setPlaying(false); this.queueService.resetCurrentIndex(); await this.cleanupStreamStatus(); } if (tempFile) { try { fs.unlinkSync(tempFile); } catch (cleanupError) { logger.error(`Failed to delete temp file ${tempFile}:`, cleanupError); } } } public async playVideo(message: Message, videoSource: string, title?: string, videoParams?: { width: number, height: number, fps?: number, bitrate?: number }): Promise { const [guildId, channelId] = [config.guildId, config.videoChannelId]; this.streamStatus.manualStop = false; if (title) { const currentQueueItem = this.queueService.getCurrent(); if (currentQueueItem?.title === title) { this.queueService.setPlaying(true); } } let tempFile: string | null = null; try { const { inputForFfmpeg, tempFilePath } = await this.prepareVideoSource(message, videoSource, title); tempFile = tempFilePath; await this.ensureVoiceConnection(guildId, channelId, title); await DiscordUtils.sendPlaying(message, title || videoSource); const streamOpts = this.setupStreamConfiguration(videoParams); await this.executeStreamWorkflow(inputForFfmpeg, streamOpts, message, title || videoSource, videoSource); } catch (error) { await ErrorUtils.handleError(error, `playing video: ${title || videoSource}`); if (this.controller && !this.controller.signal.aborted) this.controller.abort(); this.markVideoAsFailed(videoSource); } finally { await this.finalizeStream(message, tempFile); } } public async cleanupStreamStatus(): Promise { try { this.controller?.abort(); this.streamer.stopStream(); // Only leave voice if we're not playing another video // Check if there are items in queue that might be played const hasQueueItems = !this.queueService.isEmpty(); if (!hasQueueItems) { this.streamer.leaveVoice(); this.streamStatus.joined = false; this.streamStatus.joinsucc = false; } this.streamer.client.user?.setActivity(DiscordUtils.status_idle()); // Reset all status flags this.streamStatus.playing = false; this.streamStatus.manualStop = false; this.streamStatus.channelInfo = { guildId: "", channelId: "", cmdChannelId: "", }; } catch (error) { await ErrorUtils.handleError(error, "cleanup stream status"); } } public async stopAndClearQueue(): Promise { // Clear the queue this.queueService.clearQueue(); logger.info("Queue cleared by stop command"); // Then cleanup the stream await this.cleanupStreamStatus(); } } ================================================ FILE: src/types/index.ts ================================================ export interface VideoFormat { hasVideo: boolean; hasAudio: boolean; url: string; bitrate?: number; qualityLabel?: string; container?: string; isLiveContent?: boolean; } export interface YouTubeVideo { id?: string; title: string; formats: VideoFormat[]; videoDetails?: { isLiveContent: boolean; }; } export interface TwitchStream { quality: string; resolution: string; url: string; } export interface YTFormat { asr: number, filesize: number, format_id: string, format_note: string, fps: number, height: number, quality: number, tbr: number, vbr?: number, url: string, width: number, ext: string, vcodec: string, acodec: string, abr: number, downloader_options: unknown, container: string, format: string, protocol: string, http_headers: unknown } export interface YTThumbnail { height: number, url: string, width: number, resolution: string, id: string, } export interface YTResponse { id: string, title: string, formats: YTFormat[], thumbnails: YTThumbnail[], description: string, upload_date: string, uploader: string, uploader_id: string, uploader_url: string, channel_id: string, channel_url: string, duration: number, view_count: number, average_rating: number, age_limit: number, webpage_url: string, categories: string[], tags: string[], is_live: boolean, like_count: number, dislike_count: number, channel: string, track: string, artist: string, creator: string, alt_title: string, extractor: string, webpage_url_basename: string, extractor_key: string, playlist: string, playlist_index: number, thumbnail: string, display_id: string, requested_subtitles: unknown, asr: number, filesize: number, format_id: string, format_note: string, fps: number, height: number, quality: number, tbr: number, url: string, width: number, ext: string, vcodec: string, acodec: string, abr: number, downloader_options: { http_chunk_size: number }, container: string, format: string, protocol: string, http_headers: unknown, fulltitle: string, _filename: string } export interface YTFlags { help?: boolean, version?: boolean, update?: boolean, ignoreErrors?: boolean, abortOnError?: boolean, dumpUserAgent?: boolean, listExtractors?: boolean, extractorDescriptions?: boolean, forceGenericExtractor?: boolean, defaultSearch?: string, ignoreConfig?: boolean, configLocation?: string, flatPlaylist?: boolean, markWatched?: boolean, noColor?: boolean, proxy?: string, socketTimeout?: number, sourceAddress?: string, forceIpv4?: boolean, forceIpv6?: boolean, geoVerificationProxy?: string, geoBypass?: boolean, geoBypassCountry?: string, geoBypassIpBlock?: string, playlistStart?: number, playlistEnd?: number | "last", playlistItems?: string, matchTitle?: string, rejectTitle?: string, maxDownloads?: number, minFilesize?: string, maxFilesize?: string, date?: string, datebefore?: string, dateafter?: string, minViews?: number, maxViews?: number, matchFilter?: string, noPlaylist?: boolean, yesPlaylist?: boolean, ageLimit?: number, downloadArchive?: string, includeAds?: boolean, limitRate?: string, retries?: number | "infinite", skipUnavailableFragments?: boolean, abortOnUnavailableFragment?: boolean, keepFragments?: boolean, bufferSize?: string, noResizeBuffer?: boolean, httpChunkSize?: string, playlistReverse?: boolean, playlistRandom?: boolean, xattrSetFilesize?: boolean, hlsPreferNative?: boolean, hlsPreferFfmpeg?: boolean, hlsUseMpegts?: boolean, externalDownloader?: string, externalDownloaderArgs?: string, batchFile?: string, id?: boolean, output?: string, outputNaPlaceholder?: string, autonumberStart?: number, restrictFilenames?: boolean, noOverwrites?: boolean, continue?: boolean, noPart?: boolean, noMtime?: boolean, writeDescription?: boolean, writeInfoJson?: boolean, writeAnnotations?: boolean, loadInfoJson?: string, cookies?: string, cacheDir?: string, noCacheDir?: boolean, rmCacheDir?: boolean, writeThumbnail?: boolean, writeAllThumbnails?: boolean, listThumbnails?: boolean, quiet?: boolean, noWarnings?: boolean, simulate?: boolean, skipDownload?: boolean, getUrl?: boolean, getTitle?: boolean, getId?: boolean, getThumbnail?: boolean, getDuration?: boolean, getFilename?: boolean, getFormat?: boolean, dumpJson?: boolean, dumpSingleJson?: boolean, printJson?: boolean, newline?: boolean, noProgress?: boolean, consoleTitle?: boolean, verbose?: boolean, dumpPages?: boolean, writePages?: boolean, printTraffic?: boolean, callHome?: boolean, encoding?: string, noCheckCertificate?: boolean, preferInsecure?: boolean, userAgent?: string, referer?: string, addHeader?: string, bidiWorkaround?: boolean, sleepInterval?: number, maxSleepInterval?: number, format?: string, allFormats?: boolean, preferFreeFormats?: boolean, listFormats?: boolean, youtubeSkipDashManifest?: boolean, mergeOutputFormat?: string, writeSub?: boolean, writeAutoSub?: boolean, allSubs?: boolean, listSubs?: boolean, subFormat?: string, subLang?: string, username?: string, password?: string, twofactor?: string, netrc?: boolean, videoPassword?: string, apMso?: string, apUsername?: string, apPassword?: string, apListMso?: boolean, extractAudio?: boolean, audioFormat?: string, audioQuality?: number, recodeVideo?: string, postprocessorArgs?: string, keepVideo?: boolean, noPostOverwrites?: boolean, embedSubs?: boolean, embedThumbnail?: boolean, addMetadata?: boolean, metadataFromFile?: string, xattrs?: boolean, fixup?: string, preferAvconv?: boolean, preferFfmpeg?: boolean, ffmpegLocation?: string, exec?: string, convertSubs?: string } export interface CommandContext { message: any; args: string[]; videos: Video[]; streamStatus: StreamStatus; streamingService: any; } export interface StreamStatus { joined: boolean; joinsucc: boolean; playing: boolean; manualStop: boolean; channelInfo: { guildId: string; channelId: string; cmdChannelId: string; }; queue: VideoQueue; } export interface Video { name: string; path: string; } export interface Command { name: string; description: string; usage: string; aliases?: string[]; execute(context: CommandContext): Promise; } export interface MediaSource { url: string; title: string; type: 'youtube' | 'twitch' | 'local' | 'url'; isLive?: boolean; } export interface QueueItem { id: string; url: string; title: string; type: MediaSource['type']; isLive?: boolean; requestedBy: string; addedAt: Date; originalInput?: string; resolved?: boolean; } export interface VideoQueue { items: QueueItem[]; currentIndex: number; isPlaying: boolean; } ================================================ FILE: src/utils/ffmpeg.ts ================================================ import config from "../config.js"; import ffmpeg from "fluent-ffmpeg" import logger from "./logger.js"; const ffmpegRunning: { [key: string]: boolean } = {}; export async function ffmpegScreenshot(video: string): Promise { return new Promise((resolve, reject) => { if (ffmpegRunning[video]) { // Wait for ffmpeg to finish const wait = (images: string[]) => { if (ffmpegRunning[video] == false) { resolve(images); } setTimeout(() => wait(images), 100); } wait([]); return; } ffmpegRunning[video] = true; const ts = ['10%', '30%', '50%', '70%', '90%']; const images: string[] = []; const takeScreenshots = (i: number) => { if (i >= ts.length) { ffmpegRunning[video] = false; resolve(images); return; } ffmpeg(`${config.videosDir}/${video}`) .on("end", () => { const screenshotPath = `${config.previewCacheDir}/${video}-${i + 1}.jpg`; images.push(screenshotPath); takeScreenshots(i + 1); }) .on("error", (err: Error) => { ffmpegRunning[video] = false; reject(err); }) .screenshots({ count: 1, filename: `${video}-${i + 1}.jpg`, timestamps: [ts[i]], folder: config.previewCacheDir }); }; takeScreenshots(0); }); } // Checking video params export async function getVideoParams(videoPath: string): Promise<{ width: number, height: number, bitrate: string, maxbitrate: string, fps: number }> { return new Promise((resolve, reject) => { ffmpeg.ffprobe(videoPath, (err, metadata) => { if (err) { return reject(err); } const videoStream = metadata.streams.find(stream => stream.codec_type === 'video'); if (videoStream && videoStream.width && videoStream.height && videoStream.bit_rate) { const rFrameRate = videoStream.r_frame_rate || videoStream.avg_frame_rate; if (rFrameRate) { const [numerator, denominator] = rFrameRate.split('/').map(Number); videoStream.fps = numerator / denominator; } else { videoStream.fps = 0 } resolve({ width: videoStream.width, height: videoStream.height, bitrate: videoStream.bit_rate, maxbitrate: videoStream.maxBitrate, fps: videoStream.fps }); } else { reject(new Error('Unable to get Resolution.')); } }); }); } ================================================ FILE: src/utils/gen-hash.ts ================================================ import * as bcrypt from 'bcrypt'; import argon2 from 'argon2'; const password = process.argv[2]; const hashType = process.argv[3] || 'argon2'; if (!password) { console.error('Usage: bun/node run gen-hash [type]'); console.error('\tExample: bun/node run gen-hash mySecurePassword123 argon2'); console.error('\tExample: bun/node run gen-hash mySecurePassword123 bcrypt'); console.error('\nSupported types: argon2 (default), bcrypt'); process.exit(1); } if (hashType !== 'argon2' && hashType !== 'bcrypt') { console.error(`Error: Invalid hash type "${hashType}"`); console.error('Supported types: argon2, bcrypt'); process.exit(1); } let hash: string; if (hashType === 'argon2') { hash = await argon2.hash(password, { type: argon2.argon2id, memoryCost: 65536, // 64 MB timeCost: 3, parallelism: 4 }); console.log('\n✅ Argon2 hash generated successfully!\n'); } else { const saltRounds = 10; hash = bcrypt.hashSync(password, saltRounds); console.log('\n✅ Bcrypt hash generated successfully!\n'); } const escapedHash = hash.replace(/\$/g, '\\$'); console.log('Add this to your .env file:'); console.log(`SERVER_PASSWORD = "${escapedHash}"`); console.log('\nRaw hash:'); console.log(escapedHash); ================================================ FILE: src/utils/logger.ts ================================================ import winston from 'winston'; // Custom log format const logFormat = winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.colorize(), winston.format.printf(({ level, message, timestamp }) => { return `[${timestamp}] ${level}: ${message}`; }) ); // Create logger instance const logger = winston.createLogger({ level: 'info', format: logFormat, transports: [ // Console output new winston.transports.Console() ] }); export default logger; ================================================ FILE: src/utils/shared.ts ================================================ import { Message, ActivityOptions } from "discord.js-selfbot-v13"; import config from "../config.js"; import logger from "./logger.js"; import fs from 'fs'; /** * Shared utility functions for Discord bot operations */ export const DiscordUtils = { /** * Create idle status for Discord bot */ status_idle(): ActivityOptions { return { name: config.prefix + "help", type: 'WATCHING' }; }, /** * Create watching status for Discord bot */ status_watch(name: string): ActivityOptions { return { name: `${name}`, type: 'WATCHING' }; }, /** * Send error message with reaction */ async sendError(message: Message, error: string): Promise { await message.react('❌'); await message.reply(`❌ **Error**: ${error}`); }, /** * Send success message with reaction */ async sendSuccess(message: Message, description: string): Promise { await message.react('✅'); await message.channel.send(`✅ **Success**: ${description}`); }, /** * Send info message with reaction */ async sendInfo(message: Message, title: string, description: string): Promise { await message.react('ℹ️'); await message.channel.send(`ℹ️ **${title}**: ${description}`); }, /** * Send playing message with reaction */ async sendPlaying(message: Message, title: string): Promise { const content = `📽 **Now Playing**: \`${title}\``; await Promise.all([ message.react('▶️'), message.reply(content) ]); }, /** * Send finish message */ async sendFinishMessage(message: Message): Promise { const content = '⏹️ **Finished**: Finished playing video.'; await message.channel.send(content); }, /** * Send list message with reaction */ async sendList(message: Message, items: string[], type?: string): Promise { await message.react('📋'); if (type == "ytsearch") { await message.reply(`📋 **Search Results**:\n${items.join('\n')}`); } else if (type == "refresh") { await message.reply(`📋 **Video list refreshed**:\n${items.join('\n')}`); } else { await message.channel.send(`📋 **Local Videos List**:\n${items.join('\n')}`); } } }; /** * Error handling utilities */ export const ErrorUtils = { /** * Handle and log errors consistently */ async handleError(error: any, context: string, message?: Message): Promise { logger.error(`Error in ${context}:`, error); if (message) { await DiscordUtils.sendError(message, `An error occurred: ${error.message || 'Unknown error'}`); } }, /** * Handle async operation errors */ async withErrorHandling( operation: () => Promise, context: string, message?: Message ): Promise { try { return await operation(); } catch (error) { await this.handleError(error, context, message); return null; } } }; /** * General utility functions */ export const GeneralUtils = { /** * Check if input is a valid streaming URL */ isValidUrl(input: string): boolean { if (!input || typeof input !== 'string') { return false; } // Check for common streaming platforms return input.includes('youtube.com/') || input.includes('youtu.be/') || input.includes('twitch.tv/') || input.startsWith('http://') || input.startsWith('https://'); }, /** * Check if a path is a local file */ isLocalFile(filePath: string): boolean { try { return fs.existsSync(filePath) && fs.lstatSync(filePath).isFile(); } catch (error) { return false; } } }; ================================================ FILE: src/utils/youtube.ts ================================================ import ytdl_dlp from './yt-dlp.js'; import logger from './logger.js'; import yts from 'play-dl'; import { YouTubeVideo, YTResponse } from '../types/index.js'; export class Youtube { async getVideoInfo(url: string): Promise { try { const videoData = await ytdl_dlp(url, { dumpSingleJson: true, noPlaylist: true }) as YTResponse; if (typeof videoData === 'object' && videoData !== null && videoData.id && videoData.title) { return { id: videoData.id, title: videoData.title, formats: [], videoDetails: { isLiveContent: videoData.is_live === true || (videoData as any).live_status === 'is_live' } }; } logger.warn(`Failed to parse video info from yt-dlp for URL: ${url}. Data: ${JSON.stringify(videoData)}`); return null; } catch (error) { logger.error(`Failed to get video info using yt-dlp for URL ${url}:`, error); return null; } } async searchAndGetPageUrl(title: string): Promise<{ pageUrl: string | null, title: string | null }> { try { const results = await yts.search(title, { limit: 1 }); if (results.length === 0 || !results[0]?.url) { logger.warn(`No video found on YouTube for title: "${title}" using play-dl.`); return { pageUrl: null, title: null }; } return { pageUrl: results[0].url, title: results[0].title || null }; } catch (error) { logger.error(`Video search for page URL failed for title "${title}":`, error); return { pageUrl: null, title: null }; } } async search(query: string, limit: number = 5): Promise { try { const searchResults = await yts.search(query, { limit }); return searchResults.map((video, index) => `${index + 1}. \`${video.title}\`` ); } catch (error) { logger.warn(`No videos found with the given title: "${query}"`); return []; } } async getLiveStreamUrl(youtubePageUrl: string): Promise { try { const streamUrl = await ytdl_dlp(youtubePageUrl, { getUrl: true, format: 'best[protocol=https]/best[protocol=http]/best', noPlaylist: true, quiet: true, }); if (typeof streamUrl === 'string' && streamUrl.trim()) { logger.info(`Got live stream URL for ${youtubePageUrl}: ${streamUrl.trim()}`); return streamUrl.trim(); } logger.warn(`yt-dlp did not return a valid live stream URL for: ${youtubePageUrl}. Received: ${streamUrl}`); return null; } catch (error) { logger.error(`Failed to get live stream URL using yt-dlp for ${youtubePageUrl}:`, error); return null; } } } ================================================ FILE: src/utils/yt-dlp.ts ================================================ import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"; import nodePath from "node:path"; import process from "node:process"; import os from "node:os"; import crypto from "node:crypto"; import got from "got"; import { YTFlags } from "../types/index.js"; import logger from "./logger.js"; import config from "../config.js"; import { spawn } from "node:child_process"; let determinedFilename: string; const platform = process.platform; const arch = process.arch; if (platform === "win32") { if (arch === "x64") { determinedFilename = "yt-dlp.exe"; } else if (arch === "ia32") { determinedFilename = "yt-dlp_x86.exe"; } } else if (platform === "darwin") { determinedFilename = "yt-dlp_macos"; } else if (platform === "linux") { if (arch === "arm64") { determinedFilename = "yt-dlp_linux_aarch64"; } else if (arch === "arm") { determinedFilename = "yt-dlp_linux_armv7l"; } else if (arch === "x64") { determinedFilename = "yt-dlp"; } else { logger.warn(`Unsupported Linux architecture '${arch}' for yt-dlp. Falling back to generic 'yt-dlp'. Download might fail.`); determinedFilename = "yt-dlp"; } } else { logger.warn(`Unsupported OS '${platform}' for yt-dlp. Attempting to use generic 'yt-dlp'. Download might fail.`); determinedFilename = "yt-dlp"; } const filename = determinedFilename; const scriptsPath = nodePath.resolve(process.cwd(), "scripts"); const exePath = nodePath.resolve(scriptsPath, filename); function args(url: string, options: Partial): string[] { const optArgs: string[] = []; // Add cookies file if configured if (config.ytdlpCookiesPath && existsSync(config.ytdlpCookiesPath)) { optArgs.push('--cookies'); optArgs.push(config.ytdlpCookiesPath); } for (const [key, val] of Object.entries(options)) { if (val === null || val === undefined) { continue; } const flag = key.replaceAll(/[A-Z]/gu, ms => `-${ms.toLowerCase()}`); if (typeof val === "boolean") { if (val) { optArgs.push(`--${flag}`); } else { optArgs.push(`--no-${flag}`); } } else { optArgs.push(`--${flag}`); optArgs.push(String(val)); } } return [url, ...optArgs]; } function json(str: string) { try { return JSON.parse(str); } catch { return str; } } export async function downloadExecutable() { if (!existsSync(exePath)) { logger.info("yt-dlp not found, downloading..."); const releases = await got.get("https://api.github.com/repos/yt-dlp/yt-dlp/releases?per_page=1").json(); const release = releases[0]; const asset = release.assets.find(ast => ast.name === filename); const version = release.tag_name; await new Promise((resolve, reject) => { got.get(asset.browser_download_url).buffer().then(x => { mkdirSync(scriptsPath, { recursive: true }); writeFileSync(exePath, x, { mode: 0o777 }); return 0; }).then(resolve).catch(reject); }); logger.info(`yt-dlp ${version} downloaded successfully`); } } export function exec(url: string, options: Partial = {}, spawnOptions: Record = {}) { return spawn(exePath, args(url, options), { windowsHide: true, ...spawnOptions, stdio: ["ignore", "pipe", "pipe"] }); } export default async function ytdl(url: string, options: Partial = {}, spawnOptions: Record = {}) { return new Promise((resolve, reject) => { let data = ""; let errorData = ""; const proc = exec(url, options, spawnOptions); proc.stdout?.on('data', (chunk) => { data += chunk.toString(); }); proc.stderr?.on('data', (chunk) => { errorData += chunk.toString(); }); proc.on('close', (exitCode) => { if (exitCode !== 0) { logger.error(`yt-dlp process exited with code ${exitCode}. Stderr: ${errorData}`); reject(new Error(`yt-dlp failed with exit code ${exitCode}: ${errorData || data}`)); } else { resolve(json(data)); } }); proc.on('error', (error) => { logger.error(`yt-dlp process error:`, error); reject(error); }); }); } export async function downloadToTempFile(url: string, options: Partial = {}): Promise { await downloadExecutable(); const tempDir = os.tmpdir(); const tempFilename = `ytdlp_temp_${crypto.randomBytes(6).toString('hex')}.mp4`; const tempFilePath = nodePath.join(tempDir, tempFilename); const downloadOptions: Partial = { ...options, output: tempFilePath, quiet: true, noWarnings: true, }; const proc = spawn(exePath, args(url, downloadOptions), { windowsHide: true, stdio: ["ignore", "ignore", "pipe"] }); let errorData = ""; proc.stderr?.on('data', (chunk) => { errorData += chunk.toString(); }); const exitCode = await new Promise((resolve) => { proc.on('close', (code) => resolve(code || 0)); proc.on('error', () => resolve(1)); }); if (exitCode !== 0) { if (existsSync(tempFilePath)) { try { unlinkSync(tempFilePath); } catch (cleanupError) { logger.warn(`Failed to cleanup temp file ${tempFilePath} after yt-dlp error:`, cleanupError); } } const errorMessage = `yt-dlp failed to download to temp file. Exit code: ${exitCode}. Stderr: ${errorData.trim()}`; logger.error(errorMessage); throw new Error(errorMessage); } if (!existsSync(tempFilePath)) { const errorMessage = `yt-dlp exited successfully but temp file ${tempFilePath} was not created. Stderr: ${errorData.trim()}`; logger.error(errorMessage); throw new Error(errorMessage); } return tempFilePath; } export async function checkForUpdatesAndUpdate(): Promise { try { await downloadExecutable(); const updateProc = spawn(exePath, ["--update"], { stdio: ["ignore", "pipe", "pipe"], }); let stdoutData = ""; let stderrData = ""; updateProc.stdout?.on('data', (chunk) => { stdoutData += chunk.toString(); }); updateProc.stderr?.on('data', (chunk) => { stderrData += chunk.toString(); }); const exitCode = await new Promise((resolve) => { updateProc.on('close', (code) => resolve(code || 0)); updateProc.on('error', () => resolve(1)); }); if (exitCode === 0) { if (stdoutData.includes("Updated yt-dlp to")) { logger.info(`yt-dlp updated successfully. Output: ${stdoutData.trim()}`); } } else { logger.warn(`yt-dlp update check failed or an update was not straightforward. Exit code: ${exitCode}.`); if (stdoutData.trim()) logger.warn(`yt-dlp update stdout: ${stdoutData.trim()}`); if (stderrData.trim()) logger.error(`yt-dlp update stderr: ${stderrData.trim()}`); } } catch (error) { logger.error("Error during yt-dlp update check process:", error); } } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { /* Visit https://aka.ms/tsconfig to read more about this file */ "emitDecoratorMetadata": true, "experimentalDecorators": true, "sourceMap": true, "noImplicitAny": false, "removeComments": true, "preserveConstEnums": true, "noEmitHelpers": true, "outDir": "dist", "rootDir": "src", /* Language and Environment */ "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ /* Modules */ "module": "NodeNext", /* Specify what module code is generated. */ "moduleResolution": "NodeNext", /* Emit */ "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ /* Type Checking */ "strict": true, /* Enable all strict type-checking options. */ "strictNullChecks": false, /* When type checking, take into account null and undefined. */ "skipLibCheck": true, /* Skip type checking all .d.ts files. */ "allowJs": true, "declaration": true } }