[
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file\n\nversion: 2\nupdates:\n  - package-ecosystem: \"npm\" # See documentation for possible values\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: \"weekly\"\n"
  },
  {
    "path": ".github/workflows/docker-image.yml",
    "content": "name: StreamBot Docker Image CI\n\non:\n  push:\n    branches: [ \"main\" ]\n    paths-ignore:\n      - '.env.example'\n      - 'docker-compose.yml'\n      - 'LICENSE'\n      - 'README.md'\n  release:\n    types: [published]\n    \njobs:\n  build:\n    runs-on: ${{ matrix.runner }}\n    strategy:\n      matrix:\n        include:\n          - runner: ubuntu-latest\n            platform: linux/amd64\n          - runner: ubuntu-24.04-arm\n            platform: linux/arm64\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to Quay.io\n        uses: docker/login-action@v3\n        with:\n          registry: quay.io\n          username: ${{ secrets.QUAY_USERNAME }}\n          password: ${{ secrets.QUAY_ROBOT_TOKEN }}\n\n      - name: Extract Docker metadata\n        id: meta\n        uses: docker/metadata-action@v5.5.1\n        with:\n          images: quay.io/ydrag0n/streambot\n          \n      - name: Build and push by digest\n        id: build\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: Dockerfile\n          platforms: ${{ matrix.platform }}\n          labels: ${{ steps.meta.outputs.labels }}\n          outputs: type=image,name=quay.io/ydrag0n/streambot,push-by-digest=true,name-canonical=true,push=true\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          \n      - name: Export digest\n        run: |\n          mkdir -p /tmp/digests\n          digest=\"${{ steps.build.outputs.digest }}\"\n          touch \"/tmp/digests/${digest#sha256:}\"\n          \n      - name: Upload digest\n        uses: actions/upload-artifact@v4\n        with:\n          name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}\n          path: /tmp/digests/*\n          if-no-files-found: error\n          retention-days: 1\n\n  build-node:\n    runs-on: ${{ matrix.runner }}\n    strategy:\n      matrix:\n        include:\n          - runner: ubuntu-latest\n            platform: linux/amd64\n          - runner: ubuntu-24.04-arm\n            platform: linux/arm64\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to Quay.io\n        uses: docker/login-action@v3\n        with:\n          registry: quay.io\n          username: ${{ secrets.QUAY_USERNAME }}\n          password: ${{ secrets.QUAY_ROBOT_TOKEN }}\n\n      - name: Extract Docker metadata\n        id: meta\n        uses: docker/metadata-action@v5.5.1\n        with:\n          images: quay.io/ydrag0n/streambot\n          \n      - name: Build and push by digest\n        id: build\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: Dockerfile.node\n          platforms: ${{ matrix.platform }}\n          labels: ${{ steps.meta.outputs.labels }}\n          outputs: type=image,name=quay.io/ydrag0n/streambot,push-by-digest=true,name-canonical=true,push=true\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          \n      - name: Export digest\n        run: |\n          mkdir -p /tmp/digests-node\n          digest=\"${{ steps.build.outputs.digest }}\"\n          touch \"/tmp/digests-node/${digest#sha256:}\"\n          \n      - name: Upload digest\n        uses: actions/upload-artifact@v4\n        with:\n          name: digests-node-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}\n          path: /tmp/digests-node/*\n          if-no-files-found: error\n          retention-days: 1\n\n  merge:\n    runs-on: ubuntu-latest\n    needs:\n      - build\n    steps:\n      - name: Download digests\n        uses: actions/download-artifact@v4\n        with:\n          path: /tmp/digests\n          pattern: digests-*\n          merge-multiple: true\n          \n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n        \n      - name: Login to Quay.io\n        uses: docker/login-action@v3\n        with:\n          registry: quay.io\n          username: ${{ secrets.QUAY_USERNAME }}\n          password: ${{ secrets.QUAY_ROBOT_TOKEN }}\n          \n      - name: Extract Docker metadata\n        id: meta\n        uses: docker/metadata-action@v5.5.1\n        with:\n          images: quay.io/ydrag0n/streambot\n          \n      - name: Create manifest list and push\n        working-directory: /tmp/digests\n        run: |\n          docker buildx imagetools create \\\n            --tag quay.io/ydrag0n/streambot:latest \\\n            --tag quay.io/ydrag0n/streambot:${{ github.ref_name }} \\\n            $(printf 'quay.io/ydrag0n/streambot@sha256:%s ' *)\n\n  merge-node:\n    runs-on: ubuntu-latest\n    needs:\n      - build-node\n    steps:\n      - name: Download digests\n        uses: actions/download-artifact@v4\n        with:\n          path: /tmp/digests-node\n          pattern: digests-node-*\n          merge-multiple: true\n          \n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n        \n      - name: Login to Quay.io\n        uses: docker/login-action@v3\n        with:\n          registry: quay.io\n          username: ${{ secrets.QUAY_USERNAME }}\n          password: ${{ secrets.QUAY_ROBOT_TOKEN }}\n          \n      - name: Extract Docker metadata\n        id: meta\n        uses: docker/metadata-action@v5.5.1\n        with:\n          images: quay.io/ydrag0n/streambot\n          \n      - name: Create manifest list and push\n        working-directory: /tmp/digests-node\n        run: |\n          docker buildx imagetools create \\\n            --tag quay.io/ydrag0n/streambot:node \\\n            --tag quay.io/ydrag0n/streambot:node-${{ github.ref_name }} \\\n            $(printf 'quay.io/ydrag0n/streambot@sha256:%s ' *)"
  },
  {
    "path": ".gitignore",
    "content": "LICENSE\nnode_modules/\ndist/\n.env\nbun.lockb\nvideos/\ntmp/\n*.sock\ncookies.txt"
  },
  {
    "path": "Dockerfile",
    "content": "# Use Debian (trixie) as the base image\nFROM node:trixie\n\n# Set the working directory\nWORKDIR /home/bots/StreamBot\n\n# Install minimal dependencies\nRUN apt-get update && apt-get install -y curl ca-certificates unzip && \\\n    apt-get clean && \\\n    rm -rf /var/lib/apt/lists/*\n\n# Install bun and add to PATH\nENV BUN_INSTALL=\"/usr/local/\"\nRUN curl -fsSL https://bun.sh/install | bash\n\n# Install remaining dependencies and clean cache\nRUN apt-get update && apt-get install -y \\\n    build-essential \\\n    python3 \\\n    ffmpeg && \\\n    apt-get clean && \\\n    rm -rf /var/lib/apt/lists/*\n\n# Copy package.json\nCOPY package.json ./\n\n# Install dependencies\nRUN bun install\n\n# Trust all packages\nRUN bun pm trust --all\n\n# Copy the rest of the application code\nCOPY . .\n\n# Verify the application builds\nRUN bun run build\n\n# Specify the port number the container should expose\nEXPOSE 3000\n\n# Create videos folder\nRUN mkdir -p ./videos\n\n# Command to run the application\nCMD [\"bun\", \"run\", \"start\"]\n"
  },
  {
    "path": "Dockerfile.node",
    "content": "# Use Debian (trixie) as the base image\nFROM node:trixie\n\n# Set the working directory\nWORKDIR /home/bots/StreamBot\n\n# Install minimal dependencies\nRUN apt-get update && apt-get install -y curl ca-certificates unzip && \\\n    apt-get clean && \\\n    rm -rf /var/lib/apt/lists/*\n\n# Install remaining dependencies and clean cache\nRUN apt-get update && apt-get install -y \\\n    build-essential \\\n    python3 \\\n    ffmpeg && \\\n    apt-get clean && \\\n    rm -rf /var/lib/apt/lists/*\n\n# Copy package.json\nCOPY package.json ./\n\n# Install dependencies\nRUN npm install\n\n# Copy the rest of the application code\nCOPY . .\n\n# Verify the application builds\nRUN npm run build\n\n# Specify the port number the container should expose\nEXPOSE 3000\n\n# Create videos folder\nRUN mkdir -p ./videos\n\n# Command to run the application\nCMD [\"npm\", \"run\", \"start:node\"]\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n\n<img src=\"src/server/public/favicon.svg\" alt=\"StreamBot Logo\" width=\"400\" height=\"120\"/>\n\n# StreamBot\n\n**A powerful Discord self-bot for streaming videos from multiple sources with a web management interface**\n\n![GitHub release](https://img.shields.io/github/v/release/ysdragon/StreamBot)\n[![CodeFactor](https://www.codefactor.io/repository/github/ysdragon/streambot/badge)](https://www.codefactor.io/repository/github/ysdragon/streambot)\n\n[![Ceasefire Now](https://badge.techforpalestine.org/default)](https://techforpalestine.org/learn-more)\n\n</div>\n\n## 📑 Table of Contents\n\n- [✨ Features](#-features)\n- [📋 Requirements](#-requirements)\n- [🚀 Installation](#-installation)\n- [🎮 Usage](#-usage)\n- [🐳 Docker Setup](#-docker-setup)\n- [🎯 Commands](#-commands)\n- [⚙️ Configuration](#%EF%B8%8F-configuration)\n- [🌐 Web Interface](#-web-interface)\n- [🤝 Contributing](#-contributing)\n- [⚠️ Disclaimer](#%EF%B8%8F-disclaimer)\n- [📝 License](#-license)\n\n## ✨ Features\n\n- 📁 **Local Video Streaming**: Stream videos from your local videos folder\n- 🎬 **YouTube Integration**: Stream YouTube videos with smart search functionality\n- 📺 **YouTube Live Streams**: Direct streaming support for YouTube live content\n- 🟣 **Twitch Support**: Stream Twitch live streams and video-on-demand (VODs)\n- 🔗 **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)\n- 🎵 **Queue System**: Queue multiple videos with auto-play and skip functionality\n- 🌐 **Web Management Interface**: Full-featured web dashboard for video library management\n- 📤 **Video Upload**: Upload videos through the web interface or download from remote URLs\n- 🖼️ **Video Previews**: Generate and view thumbnail previews for all videos\n- ⚙️ **Runtime Configuration**: Adjust streaming parameters and bot settings during runtime\n\n## 📋 Requirements\n\n- **[Bun](https://bun.sh/) v1.1.39+** (recommended) or **[Node.js](https://nodejs.org/) v21+**\n- **[FFmpeg](https://www.ffmpeg.org/)** (the bot will attempt to install it automatically if missing, but manual installation is recommended)\n- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** (automatically downloaded and updated by the bot)\n\n### 💡 Optional\n- 🎮 **GPU with hardware acceleration** for improved streaming performance\n- 🌐 **High-speed internet** for remote video streaming and downloads\n- 💾 **Sufficient disk space** for video storage and cache\n\n## 🚀 Installation\n\nThis project is [hosted on GitHub](https://github.com/ysdragon/StreamBot).\n\n1. **Clone the repository:**\n```bash\ngit clone https://github.com/ysdragon/StreamBot\ncd StreamBot\n```\n\n2. **Install dependencies:**\n\nWith Bun (recommended):\n```bash\nbun install\n```\n\nWith npm:\n```bash\nnpm install\n```\n\n3. **Configure environment:**\n   - Copy `.env.example` to `.env`\n   - Update the configuration values (see [⚙️ Configuration](#%EF%B8%8F-configuration) section)\n   - See the [wiki](https://github.com/ysdragon/StreamBot/wiki/Get-Discord-user-token) for instructions on obtaining your Discord token\n\n4. **Setup complete!** 🎉 Required directories for videos and cache will be created automatically on first run.\n\n## 🎮 Usage\n\n### 🚀 Starting the Bot\n\n**With Bun (recommended):**\n```bash\nbun run start\n```\n\n**With Node.js:**\n```bash\nnpm run build\nnpm run start:node\n```\n\n**With web interface enabled:**\nSet `SERVER_ENABLED=true` in your `.env` file. The web interface runs alongside the bot automatically.\n\nTo run only the web interface without the bot:\n```bash\nbun run server       # With Bun\nnpm run server:node  # With Node.js (after building)\n```\n\n### 📹 Video Playback\n\nAll videos are played through a queue system that automatically advances to the next video when the current one ends.\n\nThe `play` command automatically detects the input type:\n- 📁 Local files from your `VIDEOS_DIR`\n- 🎬 YouTube videos (by URL or search query)\n- 🟣 Twitch streams (live or VOD)\n- 🔗 Any URL supported by yt-dlp\n\nUse `ytsearch` to find YouTube videos, then `play` with the results to stream them. Use `list` to browse your local video collection.\n\n## 🐳 Docker Setup\n\nStreamBot provides ready-to-use Docker configurations for easy deployment.\n\n### 📦 Standard Deployment\n\n1. **Create project directory:**\n```bash\nmkdir streambot && cd streambot\n```\n\n2. **Download Docker Compose configuration:**\n```bash\nwget https://raw.githubusercontent.com/ysdragon/StreamBot/main/docker-compose.yml\n```\n\n3. **Configure environment:**\n   - Edit `docker-compose.yml` to set your environment variables\n   - Ensure video storage directories are properly mounted\n\n4. **Launch StreamBot:**\n```bash\ndocker compose up -d\n```\n\n### ☁️ Cloudflare WARP Deployment\n\nFor enhanced network capabilities with Cloudflare WARP:\n\n1. **Download WARP configuration:**\n```bash\nwget https://raw.githubusercontent.com/ysdragon/StreamBot/main/docker-compose-warp.yml\n```\n\n2. **Configure WARP settings:**\n   - Add your WARP license key to `docker-compose-warp.yml`\n   - Update Discord token and other required environment variables\n\n3. **Launch with WARP:**\n```bash\ndocker compose -f docker-compose-warp.yml up -d\n```\n\n> ⚠️ **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.\n\n## 🎯 Commands\n\n### 📺 Playback Commands\n\n| Command | Description | Aliases |\n|---------|-------------|---------|\n| `play <video_name\\|url\\|search_query>` | Play local video, URL, or search YouTube videos | |\n| `ytsearch <query>` | Search for videos on YouTube | |\n| `stop` | Stop current video playback and clear queue | `leave`, `s` |\n| `skip` | Skip the currently playing video | `next` |\n| `queue` | Display the current video queue | |\n| `list` | Show available local videos | |\n\n### 🔧 Utility Commands\n\n| Command | Description | Aliases |\n|---------|-------------|---------|\n| `status` | Show current streaming status | |\n| `preview <video_name>` | Generate preview thumbnails for a video | |\n| `ping` | Check bot latency | |\n| `help` | Show available commands | |\n\n### 🛡️ Administration Commands\n\n| Command | Description | Aliases |\n|---------|-------------|---------|\n| `config [parameter] [value]` | View or adjust bot configuration parameters (Admin only) | `cfg`, `set` |\n\n## ⚙️ Configuration\n\nStreamBot is configured through environment variables in a `.env` file. Copy `.env.example` to `.env` and modify the values as needed.\n\n### 🔐 Discord Self-Bot Configuration\n\n```bash\n# Required: Your Discord self-bot token\n# See: https://github.com/ysdragon/StreamBot/wiki/Get-Discord-user-token\nTOKEN=\"YOUR_BOT_TOKEN_HERE\"\n\n# Command prefix for bot commands\nPREFIX=\"$\"\n\n# Discord server where the bot will operate\nGUILD_ID=\"YOUR_SERVER_ID\"\n\n# Channel where bot will respond to commands\nCOMMAND_CHANNEL_ID=\"COMMAND_CHANNEL_ID\"\n\n# Voice/video channel where bot will stream\nVIDEO_CHANNEL_ID=\"VIDEO_CHANNEL_ID\"\n\n# Admin user IDs - comma-separated or JSON array format\n# Examples:\n#   ADMIN_IDS=\"123456789,987654321\"\n#   ADMIN_IDS=[\"123456789\",\"987654321\"]\nADMIN_IDS=[\"YOUR_USER_ID_HERE\"]\n```\n\n### 📁 File Management\n\n```bash\n# Directory where video files are stored\nVIDEOS_DIR=\"./videos\"\n\n# Directory for caching video preview thumbnails\nPREVIEW_CACHE_DIR=\"./tmp/preview-cache\"\n```\n\n### 🍪 Content Source Configuration\n\n```bash\n# Path to browser cookies for accessing private/premium content\n# Supports: YouTube Premium, age-restricted content, private videos\nYTDLP_COOKIES_PATH=\"\"\n```\n\n### 🎥 Streaming Configuration\n\n```bash\n# Video Quality Settings\nSTREAM_RESPECT_VIDEO_PARAMS=\"false\"  # Use original video parameters if true\nSTREAM_BITRATE_OVERRIDE=\"false\"      # If true, use STREAM_BITRATE_KBPS even when respecting video params\nSTREAM_WIDTH=\"1280\"                  # Output resolution width\nSTREAM_HEIGHT=\"720\"                  # Output resolution height\nSTREAM_MAX_WIDTH=\"0\"                 # Max width cap (0 = disabled)\nSTREAM_MAX_HEIGHT=\"0\"                # Max height cap (0 = disabled)\nSTREAM_FPS=\"30\"                      # Target frame rate\n\n# Bitrate Settings (affects quality and bandwidth usage)\nSTREAM_BITRATE_KBPS=\"2000\"           # Target bitrate (higher = better quality)\nSTREAM_MAX_BITRATE_KBPS=\"2500\"       # Maximum allowed bitrate\n\n# Performance & Encoding\nSTREAM_HARDWARE_ACCELERATION=\"false\" # Use GPU acceleration if available\nSTREAM_VIDEO_CODEC=\"H264\"            # Codec: H264, H265, VP8, VP9, AV1\n\n# H.264/H.265 Encoding Preset (quality vs speed tradeoff)\n# Options: ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow\nSTREAM_H26X_PRESET=\"ultrafast\"\n```\n\n### 🌐 Web Interface Configuration\n\n```bash\n# Enable/disable the web management interface\nSERVER_ENABLED=\"false\"\n\n# Web interface authentication\nSERVER_USERNAME=\"admin\"\nSERVER_PASSWORD=\"admin\"  # Plain text, bcrypt, or argon2 hash\n\n# Web server port\nSERVER_PORT=\"8080\"\n```\n\n### 🍪 Using Cookies with yt-dlp\n\nTo access private or premium content (like YouTube Premium videos), you can provide a cookies file:\n\n1. **Export cookies from your browser** using a browser extension:\n   - [Get cookies.txt LOCALLY](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc) (Chromium-based browsers)\n   - [cookies.txt](https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/) (Firefox-based browsers)\n\n2. **Save the cookies file** (usually named `cookies.txt`) to a location accessible by the bot\n\n3. **Configure the path** in your `.env` file:\n   ```bash\n   YTDLP_COOKIES_PATH=\"./cookies.txt\"\n   ```\n   Or use the config command at runtime:\n   ```\n   $config ytdlpCookiesPath ./cookies.txt\n   ```\n\n4. **Restart the bot** if you updated the `.env` file\n\n## 🌐 Web Interface\n\nWhen enabled (`SERVER_ENABLED=\"true\"`), StreamBot provides a web-based management interface.\n\n### ✨ Features\n\n- 📋 **Video Library Management**: Browse your video collection with file sizes and detailed information\n- 📤 **Local File Upload**: Upload videos directly with progress tracking\n- 🌐 **Remote URL Download**: Download videos from URLs directly to your library\n- 🖼️ **Video Previews**: Generate and view thumbnail screenshots from different parts of each video\n- 🗑️ **File Management**: Delete videos from your library\n- 📊 **Video Metadata**: View detailed information (duration, resolution, codec, etc.)\n\n### 🔗 Access\n\nAfter enabling and restarting the bot, access the interface at `http://localhost:8080` (or your configured `SERVER_PORT`).\n\n## 🤝 Contributing\n\nContributions are welcome! Feel free to:\n- 🐛 Report bugs via [issues](https://github.com/ysdragon/StreamBot/issues/new)\n- 🔧 Submit [pull requests](https://github.com/ysdragon/StreamBot/pulls)\n- 💡 Suggest new features\n\n## ⚠️ Disclaimer\n\nThis bot may violate Discord's Terms of Service. Use at your own risk.\n\nI disavow before Allah any unethical use of this project.\n\nإبراء الذمة: أتبرأ من أي استخدام غير أخلاقي لهذا المشروع أمام الله.\n\n## 📝 License\n\nLicensed under MIT License. See [LICENSE](https://github.com/ysdragon/StreamBot/blob/main/LICENSE) for details.\n"
  },
  {
    "path": "docker-compose-node.yml",
    "content": "services:\n  streambot:\n    image: quay.io/ydrag0n/streambot:node\n    container_name: streambot\n    restart: always\n    environment:\n      # Selfbot options\n      TOKEN: \"\" # Your Discord self-bot token\n      PREFIX: \"$\" # The prefix used to trigger your self-bot commands\n      GUILD_ID: \"\" # The ID of the Discord server your self-bot will be running on\n      COMMAND_CHANNEL_ID: \"\" # The ID of the Discord channel where your self-bot will respond to commands\n      VIDEO_CHANNEL_ID: \"\" # The ID of the Discord voice/video channel where your self-bot will stream videos\n      ADMIN_IDS: '[\"YOUR_USER_ID_HERE\"]' # A list of Discord user IDs that are considered administrators (comma-separated or JSON array format)\n      # General options\n      VIDEOS_DIR: \"./videos\" # The local path where you store video files\n      PREVIEW_CACHE_DIR: \"./tmp/preview-cache\" # The local path where your self-bot will cache video preview thumbnails\n\n      # yt-dlp options\n      YTDLP_COOKIES_PATH: \"\" # Path to cookies file for yt-dlp (for accessing age-restricted or premium content)\n\n      # Stream options\n      STREAM_RESPECT_VIDEO_PARAMS: \"false\" # This option is used to respect video parameters such as width, height, fps, bitrate, and max bitrate.\n      STREAM_BITRATE_OVERRIDE: \"false\" # If true, use STREAM_BITRATE_KBPS even when respecting video params\n      STREAM_MAX_WIDTH: \"0\" # Max width cap (0 = disabled)\n      STREAM_MAX_HEIGHT: \"0\" # Max height cap (0 = disabled)\n      STREAM_WIDTH: \"1280\" # The width of the video stream in pixels\n      STREAM_HEIGHT: \"720\" # The height of the video stream in pixels\n      STREAM_FPS: \"30\" # The frames per second (FPS) of the video stream\n      STREAM_BITRATE_KBPS: \"2000\" # The bitrate of the video stream in kilobits per second (Kbps)\n      STREAM_MAX_BITRATE_KBPS: \"2500\" # The maximum bitrate of the video stream in kilobits per second (Kbps)\n      STREAM_HARDWARE_ACCELERATION: \"false\" # Whether to use hardware acceleration for video decoding, set to \"true\" to enable, \"false\" to disable\n      STREAM_VIDEO_CODEC: \"H264\" # The video codec to use for the stream, can be \"H264\" or \"H265\" or \"VP8\"\n      \n      # STREAM_H26X_PRESET: Determines the encoding preset for H26x video streams. \n      # If the STREAM_H26X_PRESET environment variable is set, it parses the value \n      # using the parsePreset function. If not set, it defaults to 'ultrafast' for \n      # optimal encoding speed. This preset is only applicable when the codec is \n      # H26x; otherwise, it should be disabled or ignored.\n      # Available presets: \"ultrafast\", \"superfast\", \"veryfast\", \"faster\", \n      # \"fast\", \"medium\", \"slow\", \"slower\", \"veryslow\".\n      STREAM_H26X_PRESET: \"ultrafast\"\n\n      # Videos server options\n      SERVER_ENABLED: \"false\" # Whether to enable the built-in video server\n      SERVER_USERNAME: \"admin\" # The username for the video server's admin interface\n      SERVER_PASSWORD: \"admin\" # The password for the video server's admin interface\n      SERVER_PORT: \"3000\" # The port number the video server will listen on\n    volumes:\n      - ./videos:/home/bots/StreamBot/videos\n    ports:\n      - 3000:3000\n"
  },
  {
    "path": "docker-compose-warp.yml",
    "content": "services:\n  streambot:\n    image: quay.io/ydrag0n/streambot:latest\n    container_name: streambot\n    restart: always\n    environment:\n      # Selfbot options\n      TOKEN: \"\" # Your Discord self-bot token\n      PREFIX: \"$\" # The prefix used to trigger your self-bot commands\n      GUILD_ID: \"\" # The ID of the Discord server your self-bot will be running on\n      COMMAND_CHANNEL_ID: \"\" # The ID of the Discord channel where your self-bot will respond to commands\n      VIDEO_CHANNEL_ID: \"\" # The ID of the Discord voice/video channel where your self-bot will stream videos\n      ADMIN_IDS: [\"YOUR_USER_ID_HERE\"] # A list of Discord user IDs that are considered administrators (comma-separated or JSON array format)\n      # General options\n      VIDEOS_DIR: \"./videos\" # The local path where you store video files\n      PREVIEW_CACHE_DIR: \"./tmp/preview-cache\" # The local path where your self-bot will cache video preview thumbnails\n\n      # yt-dlp options\n      YTDLP_COOKIES_PATH: \"\" # Path to cookies file for yt-dlp (for accessing age-restricted or premium content)\n\n      # Stream options\n      STREAM_RESPECT_VIDEO_PARAMS: \"false\" # This option is used to respect video parameters such as width, height, fps, bitrate, and max bitrate.\n      STREAM_BITRATE_OVERRIDE: \"false\" # If true, use STREAM_BITRATE_KBPS even when respecting video params\n      STREAM_MAX_WIDTH: \"0\" # Max width cap (0 = disabled)\n      STREAM_MAX_HEIGHT: \"0\" # Max height cap (0 = disabled)\n      STREAM_WIDTH: \"1280\" # The width of the video stream in pixels\n      STREAM_HEIGHT: \"720\" # The height of the video stream in pixels\n      STREAM_FPS: \"30\" # The frames per second (FPS) of the video stream\n      STREAM_BITRATE_KBPS: \"2000\" # The bitrate of the video stream in kilobits per second (Kbps)\n      STREAM_MAX_BITRATE_KBPS: \"2500\" # The maximum bitrate of the video stream in kilobits per second (Kbps)\n      STREAM_HARDWARE_ACCELERATION: \"false\" # Whether to use hardware acceleration for video decoding, set to \"true\" to enable, \"false\" to disable\n      STREAM_VIDEO_CODEC: \"H264\" # The video codec to use for the stream, can be \"H264\" or \"H265\" or \"VP8\"\n      \n      # STREAM_H26X_PRESET: Determines the encoding preset for H26x video streams. \n      # If the STREAM_H26X_PRESET environment variable is set, it parses the value \n      # using the parsePreset function. If not set, it defaults to 'ultrafast' for \n      # optimal encoding speed. This preset is only applicable when the codec is \n      # H26x; otherwise, it should be disabled or ignored.\n      # Available presets: \"ultrafast\", \"superfast\", \"veryfast\", \"faster\", \n      # \"fast\", \"medium\", \"slow\", \"slower\", \"veryslow\".\n      STREAM_H26X_PRESET: \"ultrafast\"\n\n      # Videos server options\n      SERVER_ENABLED: \"false\" # Whether to enable the built-in video server\n      SERVER_USERNAME: \"admin\" # The username for the video server's admin interface\n      SERVER_PASSWORD: \"admin\" # The password for the video server's admin interface\n      SERVER_PORT: \"3000\" # The port number the video server will listen on\n    volumes:\n      - ./videos:/home/bots/StreamBot/videos\n    network_mode: \"service:warp\"\n    depends_on:\n      - warp\n  warp:\n    image: caomingjun/warp\n    container_name: warp\n    restart: always\n    devices:\n      - /dev/net/tun:/dev/net/tun\n    ports:\n      - '1080:1080'\n      - '3000:3000'\n    environment:\n      - WARP_SLEEP=2\n      - WARP_LICENSE_KEY= # Your Cloudflare Warp license key\n    cap_add:\n      - NET_ADMIN\n    sysctls:\n      - net.ipv6.conf.all.disable_ipv6=0\n      - net.ipv4.conf.all.src_valid_mark=1\n    volumes:\n      - ./data:/var/lib/cloudflare-warp"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  streambot:\n    image: quay.io/ydrag0n/streambot:latest\n    container_name: streambot\n    restart: always\n    environment:\n      # Selfbot options\n      TOKEN: \"\" # Your Discord self-bot token\n      PREFIX: \"$\" # The prefix used to trigger your self-bot commands\n      GUILD_ID: \"\" # The ID of the Discord server your self-bot will be running on\n      COMMAND_CHANNEL_ID: \"\" # The ID of the Discord channel where your self-bot will respond to commands\n      VIDEO_CHANNEL_ID: \"\" # The ID of the Discord voice/video channel where your self-bot will stream videos\n      ADMIN_IDS: '[\"YOUR_USER_ID_HERE\"]' # A list of Discord user IDs that are considered administrators (comma-separated or JSON array format)\n      # General options\n      VIDEOS_DIR: \"./videos\" # The local path where you store video files\n      PREVIEW_CACHE_DIR: \"./tmp/preview-cache\" # The local path where your self-bot will cache video preview thumbnails\n\n      # yt-dlp options\n      YTDLP_COOKIES_PATH: \"\" # Path to cookies file for yt-dlp (for accessing age-restricted or premium content)\n\n      # Stream options\n      STREAM_RESPECT_VIDEO_PARAMS: \"false\" # This option is used to respect video parameters such as width, height, fps, bitrate, and max bitrate.\n      STREAM_BITRATE_OVERRIDE: \"false\" # If true, use STREAM_BITRATE_KBPS even when respecting video params\n      STREAM_MAX_WIDTH: \"0\" # Max width cap (0 = disabled)\n      STREAM_MAX_HEIGHT: \"0\" # Max height cap (0 = disabled)\n      STREAM_WIDTH: \"1280\" # The width of the video stream in pixels\n      STREAM_HEIGHT: \"720\" # The height of the video stream in pixels\n      STREAM_FPS: \"30\" # The frames per second (FPS) of the video stream\n      STREAM_BITRATE_KBPS: \"2000\" # The bitrate of the video stream in kilobits per second (Kbps)\n      STREAM_MAX_BITRATE_KBPS: \"2500\" # The maximum bitrate of the video stream in kilobits per second (Kbps)\n      STREAM_HARDWARE_ACCELERATION: \"false\" # Whether to use hardware acceleration for video decoding, set to \"true\" to enable, \"false\" to disable\n      STREAM_VIDEO_CODEC: \"H264\" # The video codec to use for the stream, can be \"H264\" or \"H265\" or \"VP8\"\n      \n      # STREAM_H26X_PRESET: Determines the encoding preset for H26x video streams. \n      # If the STREAM_H26X_PRESET environment variable is set, it parses the value \n      # using the parsePreset function. If not set, it defaults to 'ultrafast' for \n      # optimal encoding speed. This preset is only applicable when the codec is \n      # H26x; otherwise, it should be disabled or ignored.\n      # Available presets: \"ultrafast\", \"superfast\", \"veryfast\", \"faster\", \n      # \"fast\", \"medium\", \"slow\", \"slower\", \"veryslow\".\n      STREAM_H26X_PRESET: \"ultrafast\"\n\n      # Videos server options\n      SERVER_ENABLED: \"false\" # Whether to enable the built-in video server\n      SERVER_USERNAME: \"admin\" # The username for the video server's admin interface\n      SERVER_PASSWORD: \"admin\" # The password for the video server's admin interface\n      SERVER_PORT: \"3000\" # The port number the video server will listen on\n    volumes:\n      - ./videos:/home/bots/StreamBot/videos\n    ports:\n      - 3000:3000\n"
  },
  {
    "path": "egg-stream-bot.json",
    "content": "{\n    \"_comment\": \"DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO\",\n    \"meta\": {\n        \"version\": \"PTDL_v2\",\n        \"update_url\": null\n    },\n    \"exported_at\": \"2025-06-30T09:36:07+03:00\",\n    \"name\": \"StreamBot\",\n    \"author\": \"ysdragon@protonmail.com\",\n    \"description\": \"A self bot to stream videos to Discord.\",\n    \"features\": null,\n    \"docker_images\": {\n        \"Bun Latest\": \"ghcr.io\\/parkervcp\\/yolks:bun_latest\"\n    },\n    \"file_denylist\": [],\n    \"startup\": \"if [[ -d .git ]] && [[ {{AUTO_UPDATE}} == \\\"1\\\" ]]; then git pull; fi; if [ -f \\/home\\/container\\/package.json ]; then bun install; fi; bun run start\",\n    \"config\": {\n        \"files\": \"{\\r\\n    \\\".env\\\": {\\r\\n        \\\"parser\\\": \\\"file\\\",\\r\\n        \\\"find\\\": {\\r\\n            \\\"TOKEN\\\": \\\"TOKEN= \\\\\\\"{{env.TOKEN}}\\\\\\\"\\\"\\r\\n        }\\r\\n    }\\r\\n}\",\n        \"startup\": \"{\\r\\n    \\\"done\\\": [\\r\\n        \\\"change this text 1\\\",\\r\\n        \\\"change this text 2\\\"\\r\\n    ]\\r\\n}\",\n        \"logs\": \"{}\",\n        \"stop\": \"^^C\"\n    },\n    \"scripts\": {\n        \"installation\": {\n            \"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\",\n            \"container\": \"ghcr.io\\/parkervcp\\/installers:debian\",\n            \"entrypoint\": \"bash\"\n        }\n    },\n    \"variables\": [\n        {\n            \"name\": \"Auto Update\",\n            \"description\": \"Pull the latest files on startup.\\r\\n0 = false (default)\\r\\n1 = true\",\n            \"env_variable\": \"AUTO_UPDATE\",\n            \"default_value\": \"0\",\n            \"user_viewable\": true,\n            \"user_editable\": true,\n            \"rules\": \"required|boolean\",\n            \"field_type\": \"text\"\n        },\n        {\n            \"name\": \"Bot Version\",\n            \"description\": \"The bot version to install.\",\n            \"env_variable\": \"BOT_VERSION\",\n            \"default_value\": \"\",\n            \"user_viewable\": true,\n            \"user_editable\": true,\n            \"rules\": \"nullable|string\",\n            \"field_type\": \"text\"\n        },\n        {\n            \"name\": \"Bot Token\",\n            \"description\": \"Your real user token for the bot.\\r\\nhttps:\\/\\/github.com\\/ysdragon\\/StreamBot\\/wiki\\/Get-Discord-user-token\",\n            \"env_variable\": \"TOKEN\",\n            \"default_value\": \"\",\n            \"user_viewable\": true,\n            \"user_editable\": true,\n            \"rules\": \"required|string|max:100\",\n            \"field_type\": \"text\"\n        }\n    ]\n}"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"streambot\",\n  \"version\": \"2.0.2\",\n  \"description\": \"A self bot to stream movies/videos to Discord.\",\n  \"main\": \"dist/index.js\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"start\": \"bun src/index.ts\",\n    \"start:node\": \"node dist/index.js\",\n    \"server\": \"bun src/server/index.ts\",\n    \"server:node\": \"node dist/server/index.js\",\n    \"build\": \"tsc\",\n    \"watch\": \"tsc -w\",\n    \"gen-hash\": \"bun src/utils/gen-hash.ts\",\n    \"gen-hash:node\": \"node dist/utils/gen-hash.js\"\n  },\n  \"author\": \"ysdragon\",\n  \"license\": \"MIT\",\n  \"dependencies\": {\n    \"@dank074/discord-video-stream\": \"6.0.0\",\n    \"@types/bcrypt\": \"^6.0.0\",\n    \"@types/bun\": \"^1.3.10\",\n    \"argon2\": \"^0.44.0\",\n    \"axios\": \"^1.13.6\",\n    \"bcrypt\": \"^6.0.0\",\n    \"discord.js-selfbot-v13\": \"^3.7.1\",\n    \"dotenv\": \"^17.3.1\",\n    \"ejs\": \"^5.0.1\",\n    \"express\": \"^5.2.1\",\n    \"express-ejs-layouts\": \"^2.5.1\",\n    \"express-session\": \"^1.19.0\",\n    \"fluent-ffmpeg\": \"^2.1.3\",\n    \"got\": \"^14.6.6\",\n    \"multer\": \"^2.1.1\",\n    \"play-dl\": \"^1.9.7\",\n    \"twitch-m3u8\": \"^1.1.5\",\n    \"winston\": \"^3.19.0\"\n  },\n  \"devDependencies\": {\n    \"@types/express\": \"^5.0.6\",\n    \"@types/express-session\": \"^1.18.2\",\n    \"@types/multer\": \"^2.1.0\",\n    \"typescript\": \"^5.9.3\"\n  }\n}\n"
  },
  {
    "path": "src/commands/base.ts",
    "content": "import { Command, CommandContext } from \"../types/index.js\";\nimport { DiscordUtils } from \"../utils/shared.js\";\n\nexport abstract class BaseCommand implements Command {\n\tabstract name: string;\n\tabstract description: string;\n\tabstract usage: string;\n\taliases?: string[];\n\n\tconstructor(commandManager?: any) {\n\t}\n\n\tabstract execute(context: CommandContext): Promise<void>;\n\n\tprotected async sendError(message: any, error: string): Promise<void> {\n\t\tawait DiscordUtils.sendError(message, error);\n\t}\n\n\tprotected async sendSuccess(message: any, description: string): Promise<void> {\n\t\tawait DiscordUtils.sendSuccess(message, description);\n\t}\n\n\tprotected async sendInfo(message: any, title: string, description: string): Promise<void> {\n\t\tawait DiscordUtils.sendInfo(message, title, description);\n\t}\n\n\tprotected async sendList(message: any, items: string[], type?: string): Promise<void> {\n\t\tawait DiscordUtils.sendList(message, items, type);\n\t}\n\n\tprotected async sendPlaying(message: any, title: string): Promise<void> {\n\t\tawait DiscordUtils.sendPlaying(message, title);\n\t}\n\n\tprotected async sendFinishMessage(message: any): Promise<void> {\n\t\tawait DiscordUtils.sendFinishMessage(message);\n\t}\n}"
  },
  {
    "path": "src/commands/config.ts",
    "content": "import { BaseCommand } from \"./base.js\";\nimport { CommandContext } from \"../types/index.js\";\nimport config, { parseBoolean, parseVideoCodec, parsePreset } from \"../config.js\";\nimport logger from \"../utils/logger.js\";\n\nexport default class ConfigCommand extends BaseCommand {\n\tname = \"config\";\n\tdescription = \"View or adjust bot configuration parameters (Admin only)\";\n\tusage = \"config [parameter] [value]\";\n\taliases = [\"cfg\", \"set\"];\n\n\tasync execute(context: CommandContext): Promise<void> {\n\t\t// Check if user is an admin\n\t\tif (!this.isAdmin(context.message.author.id)) {\n\t\t\tawait this.sendError(context.message, \"You don't have permission to use this command. Admin access required.\");\n\t\t\tlogger.warn(`Unauthorized config command attempt by user ${context.message.author.id}`);\n\t\t\treturn;\n\t\t}\n\n\t\tconst args = context.args;\n\n\t\t// If no arguments, show current config\n\t\tif (args.length === 0) {\n\t\t\tawait this.showConfig(context);\n\t\t\treturn;\n\t\t}\n\n\t\t// If one argument, show specific parameter\n\t\tif (args.length === 1) {\n\t\t\tawait this.showParameter(context, args[0]);\n\t\t\treturn;\n\t\t}\n\n\t\t// If two or more arguments, set parameter\n\t\tconst parameter = args[0].toLowerCase();\n\t\tconst value = args.slice(1).join(' ');\n\t\tawait this.setParameter(context, parameter, value);\n\t}\n\n\tprivate async showConfig(context: CommandContext): Promise<void> {\n\t\tconst configInfo = [\n\t\t\t\"**Stream Options:**\",\n\t\t\t`• respect_video_params: ${config.respect_video_params}`,\n\t\t\t`• bitrateOverride: ${config.bitrateOverride}`,\n\t\t\t`• width: ${config.width}`,\n\t\t\t`• height: ${config.height}`,\n\t\t\t`• fps: ${config.fps}`,\n\t\t\t`• bitrateKbps: ${config.bitrateKbps}`,\n\t\t\t`• maxBitrateKbps: ${config.maxBitrateKbps}`,\n\t\t\t`• maxWidth: ${config.maxWidth || 'None'}`,\n\t\t\t`• maxHeight: ${config.maxHeight || 'None'}`,\n\t\t\t`• hardwareAcceleratedDecoding: ${config.hardwareAcceleratedDecoding}`,\n\t\t\t`• h26xPreset: ${config.h26xPreset}`,\n\t\t\t`• videoCodec: ${config.videoCodec}`,\n\t\t\t\"\",\n\t\t\t\"**General Options:**\",\n\t\t\t`• videosDir: ${config.videosDir}`,\n\t\t\t`• previewCacheDir: ${config.previewCacheDir}`,\n\t\t\t\"\",\n\t\t\t\"**yt-dlp Options:**\",\n\t\t\t`• ytdlpCookiesPath: ${config.ytdlpCookiesPath || '(not set)'}`,\n\t\t\t\"\",\n\t\t\t\"Use `config <parameter>` to view a specific parameter\",\n\t\t\t\"Use `config <parameter> <value>` to change a parameter\"\n\t\t].join('\\n');\n\n\t\tawait this.sendInfo(context.message, 'Bot Configuration', configInfo);\n\t}\n\n\tprivate async showParameter(context: CommandContext, parameter: string): Promise<void> {\n\t\t// Find the actual config key case-insensitively\n\t\tconst key = Object.keys(config).find(k => k.toLowerCase() === parameter.toLowerCase());\n\n\t\tif (!key) {\n\t\t\tawait this.sendError(context.message, `Unknown parameter: ${parameter}`);\n\t\t\treturn;\n\t\t}\n\n\t\tconst value = (config as any)[key];\n\t\tawait this.sendInfo(context.message, `Config: ${key}`, `Current value: \\`${value}\\``);\n\t}\n\n\tprivate async setParameter(context: CommandContext, parameter: string, value: string): Promise<void> {\n\t\t// Find the actual config key case-insensitively\n\t\tconst key = Object.keys(config).find(k => k.toLowerCase() === parameter.toLowerCase());\n\n\t\tif (!key) {\n\t\t\tawait this.sendError(context.message, `Unknown parameter: ${parameter}`);\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tswitch (key) {\n\t\t\t\t// Boolean parameters\n\t\t\t\tcase 'respect_video_params':\n\t\t\t\tcase 'bitrateOverride':\n\t\t\t\tcase 'hardwareAcceleratedDecoding':\n\t\t\t\t\tconst boolValue = parseBoolean(value);\n\t\t\t\t\t(config as any)[key] = boolValue;\n\t\t\t\t\tawait this.sendSuccess(context.message, `Set ${key} to \\`${boolValue}\\``);\n\t\t\t\t\tlogger.info(`Config updated: ${key} = ${boolValue}`);\n\t\t\t\t\tbreak;\n\n\t\t\t\t// Number parameters\n\t\t\t\tcase 'width':\n\t\t\t\tcase 'height':\n\t\t\t\tcase 'fps':\n\t\t\t\tcase 'bitrateKbps':\n\t\t\t\tcase 'maxBitrateKbps':\n\t\t\t\tcase 'maxWidth':\n\t\t\t\tcase 'maxHeight':\n\t\t\t\t\tconst numValue = parseInt(value);\n\t\t\t\t\t\n\t\t\t\t\t// Validate non-negative\n\t\t\t\t\tif (isNaN(numValue) || numValue < 0) {\n\t\t\t\t\t\tawait this.sendError(context.message, `Invalid number value: ${value}. Must be non-negative.`);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\t// Validate positive for essential params\n\t\t\t\t\tif (['width', 'height', 'fps', 'bitrateKbps', 'maxBitrateKbps'].includes(key) && numValue === 0) {\n\t\t\t\t\t\tawait this.sendError(context.message, `Invalid number value: ${value}. Must be greater than 0.`);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\t(config as any)[key] = numValue;\n\t\t\t\t\tawait this.sendSuccess(context.message, `Set ${key} to \\`${numValue}\\``);\n\t\t\t\t\tlogger.info(`Config updated: ${key} = ${numValue}`);\n\t\t\t\t\tbreak;\n\n\t\t\t\t// Video codec\n\t\t\t\tcase 'videoCodec':\n\t\t\t\t\tconst codec = parseVideoCodec(value);\n\t\t\t\t\tif (!codec) {\n\t\t\t\t\t\tawait this.sendError(context.message, `Invalid video codec. Valid options: VP8, H264, H265`);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tconfig.videoCodec = codec;\n\t\t\t\t\tawait this.sendSuccess(context.message, `Set videoCodec to \\`${codec}\\``);\n\t\t\t\t\tlogger.info(`Config updated: videoCodec = ${codec}`);\n\t\t\t\t\tbreak;\n\n\t\t\t\t// H26x preset\n\t\t\t\tcase 'h26xPreset':\n\t\t\t\t\tconst preset = parsePreset(value);\n\t\t\t\t\tif (!preset) {\n\t\t\t\t\t\tawait this.sendError(context.message, `Invalid preset. Valid options: ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow`);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tconfig.h26xPreset = preset;\n\t\t\t\t\tawait this.sendSuccess(context.message, `Set h26xPreset to \\`${preset}\\``);\n\t\t\t\t\tlogger.info(`Config updated: h26xPreset = ${preset}`);\n\t\t\t\t\tbreak;\n\n\t\t\t\t// String parameters\n\t\t\t\tcase 'videosDir':\n\t\t\t\tcase 'previewCacheDir':\n\t\t\t\tcase 'ytdlpCookiesPath':\n\t\t\t\t\t(config as any)[key] = value;\n\t\t\t\t\tawait this.sendSuccess(context.message, `Set ${key} to \\`${value}\\``);\n\t\t\t\t\tlogger.info(`Config updated: ${key} = ${value}`);\n\t\t\t\t\tbreak;\n\n\t\t\t\tdefault:\n\t\t\t\t\tawait this.sendError(context.message, `Cannot modify parameter: ${key}`);\n\t\t\t\t\treturn;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlogger.error(`Error setting config parameter ${parameter}:`, error);\n\t\t\tawait this.sendError(context.message, `Failed to set ${parameter}: ${error}`);\n\t\t}\n\t}\n\n\tprivate isAdmin(userId: string): boolean {\n\t\t// If no admins configured, allow all users (backwards compatibility)\n\t\tif (!config.adminIds || config.adminIds.length === 0) {\n\t\t\treturn true;\n\t\t}\n\t\treturn config.adminIds.includes(userId);\n\t}\n}\n"
  },
  {
    "path": "src/commands/help.ts",
    "content": "import { BaseCommand } from \"./base.js\";\nimport { CommandContext } from \"../types/index.js\";\nimport { CommandManager } from \"./manager.js\";\n\nexport default class HelpCommand extends BaseCommand {\n\tname = \"help\";\n\tdescription = \"Show available commands\";\n\tusage = \"help\";\n\n\tconstructor(private commandManager: CommandManager) {\n\t\tsuper(commandManager);\n\t}\n\n\tasync execute(context: CommandContext): Promise<void> {\n\t\tconst commandList = this.commandManager.getCommandList();\n\n\t\tconst helpText = [\n\t\t\t'📽 **Available Commands**',\n\t\t\t'',\n\t\t\tcommandList,\n\t\t].join('\\n');\n\n\t\tawait context.message.react('📋');\n\t\tawait context.message.reply(helpText);\n\t}\n}"
  },
  {
    "path": "src/commands/list.ts",
    "content": "import { BaseCommand } from \"./base.js\";\nimport { CommandContext, Video } from \"../types/index.js\";\nimport fs from 'fs';\nimport path from 'path';\nimport config from \"../config.js\";\n\nexport default class ListCommand extends BaseCommand {\n\tname = \"list\";\n\tdescription = \"Show available local videos\";\n\tusage = \"list\";\n\n\tasync execute(context: CommandContext): Promise<void> {\n\t\t// Always refresh video list from filesystem\n\t\tconst videoFiles = fs.readdirSync(config.videosDir);\n\t\tconst refreshedVideos = videoFiles.map(file => {\n\t\t\tconst fileName = path.parse(file).name;\n\t\t\treturn { name: fileName, path: path.join(config.videosDir, file) };\n\t\t});\n\n\t\t// Update the videos array in context\n\t\tcontext.videos.length = 0;\n\t\tcontext.videos.push(...refreshedVideos);\n\n\t\tconst videoList = refreshedVideos.map((video, index) => `${index + 1}. \\`${video.name}\\``);\n\t\tif (videoList.length > 0) {\n\t\t\tawait this.sendList(context.message,\n\t\t\t\t[`(${refreshedVideos.length} videos found)`, ...videoList]);\n\t\t} else {\n\t\t\tawait this.sendError(context.message, 'No videos found');\n\t\t}\n\t}\n}"
  },
  {
    "path": "src/commands/manager.ts",
    "content": "import { Command, CommandContext } from \"../types/index.js\";\nimport fs from 'fs';\nimport path from 'path';\nimport logger from '../utils/logger.js';\nimport config from '../config.js';\nimport { ErrorUtils } from '../utils/shared.js';\n\nexport class CommandManager {\n\tprivate commands: Map<string, Command> = new Map();\n\tprivate aliases: Map<string, string> = new Map();\n\n\tconstructor() {\n\t\tthis.loadCommands();\n\t}\n\n\tprivate async loadCommands(): Promise<void> {\n\t\t// Prefer src for Bun (TypeScript native), dist for Node.js (compiled JS)\n\t\tconst isBun = typeof Bun !== 'undefined';\n\t\tconst commandsPath = path.join(process.cwd(), isBun ? 'src' : 'dist', 'commands');\n\n\t\tif (!commandsPath) {\n\t\t\tlogger.error('Could not find commands directory in either dist/ or src/');\n\t\t\treturn;\n\t\t}\n\t\t\n\t\ttry {\n\t\t\tconst commandFiles = fs.readdirSync(commandsPath)\n\t\t\t\t.filter(file => (file.endsWith('.ts') || file.endsWith('.js')) && !file.endsWith('.d.ts') && !file.startsWith('base.') && !file.startsWith('manager.'));\n\n\t\t\tfor (const file of commandFiles) {\n\t\t\t\ttry {\n\t\t\t\t\t// Use appropriate extension based on directory\n\t\t\t\t\tconst isDist = commandsPath.includes('dist');\n\t\t\t\t\tconst fileName = isDist ? file.replace('.ts', '.js') : file;\n\t\t\t\t\tconst filePath = path.join(commandsPath, fileName);\n\t\t\t\t\tconst commandModule = await import(filePath);\n\n\t\t\t\t\t// Look for default export or named export\n\t\t\t\t\tlet CommandClass = commandModule.default || commandModule[Object.keys(commandModule)[0]];\n\n\t\t\t\t\tif (CommandClass && this.isCommand(CommandClass)) {\n\t\t\t\t\t\tconst command = new CommandClass(this) as Command;\n\n\t\t\t\t\t\t// Register command\n\t\t\t\t\t\tthis.commands.set(command.name.toLowerCase(), command);\n\n\t\t\t\t\t\t// Register aliases\n\t\t\t\t\t\tif (command.aliases) {\n\t\t\t\t\t\t\tfor (const alias of command.aliases) {\n\t\t\t\t\t\t\t\tthis.aliases.set(alias.toLowerCase(), command.name.toLowerCase());\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlogger.warn(`File ${file} does not export a valid command`);\n\t\t\t\t\t\tlogger.debug(`Exported keys: ${Object.keys(commandModule).join(', ')}`);\n\t\t\t\t\t\tif (CommandClass) {\n\t\t\t\t\t\t\tlogger.debug(`CommandClass properties: ${Object.keys(CommandClass.prototype || {}).join(', ')}`);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} catch (error) {\n\t\t\t\t\tawait ErrorUtils.handleError(error, `loading command from ${file}`);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlogger.info(`Loaded ${this.commands.size} commands`);\n\t\t} catch (error) {\n\t\t\tawait ErrorUtils.handleError(error, 'loading commands');\n\t\t}\n\t}\n\n\tprivate isCommand(obj: any): obj is new (commandManager?: CommandManager) => Command {\n\t\tif (!obj) return false;\n\n\t\tconst proto = obj.prototype;\n\t\tif (!proto) return false;\n\n\t\tconst hasExecute = 'execute' in proto;\n\t\treturn hasExecute;\n\t}\n\n\tpublic getCommand(name: string): Command | null {\n\t\tconst commandName = name.toLowerCase();\n\t\treturn this.commands.get(commandName) || this.commands.get(this.aliases.get(commandName) || '') || null;\n\t}\n\n\tpublic getAllCommands(): Command[] {\n\t\treturn Array.from(this.commands.values());\n\t}\n\n\tpublic async executeCommand(commandName: string, context: CommandContext): Promise<boolean> {\n\t\tconst command = this.getCommand(commandName);\n\n\t\tif (!command) {\n\t\t\treturn false;\n\t\t}\n\n\t\ttry {\n\t\t\tawait command.execute(context);\n\t\t\treturn true;\n\t\t} catch (error) {\n\t\t\tawait ErrorUtils.handleError(error, `executing command ${commandName}`, context.message);\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tpublic getCommandList(): string {\n\t\tconst commands = this.getAllCommands();\n\t\tconst prefix = config.prefix || '!';\n\t\treturn commands.map(cmd =>\n\t\t\t`**${cmd.name}**: ${cmd.description}\\nUsage: \\`${prefix}${cmd.usage}\\``\n\t\t).join('\\n');\n\t}\n}"
  },
  {
    "path": "src/commands/ping.ts",
    "content": "import { BaseCommand } from \"./base.js\";\nimport { CommandContext } from \"../types/index.js\";\n\nexport default class PingCommand extends BaseCommand {\n\tname = \"ping\";\n\tdescription = \"Check bot latency\";\n\tusage = \"ping\";\n\n\tasync execute(context: CommandContext): Promise<void> {\n\t\tconst sent = await context.message.reply('🏓 Pinging...');\n\t\tconst timeDiff = sent.createdTimestamp - context.message.createdTimestamp;\n\t\tawait sent.edit(`🏓 Pong! Latency: ${timeDiff}ms`);\n\t}\n}"
  },
  {
    "path": "src/commands/play.ts",
    "content": "import { BaseCommand } from \"./base.js\";\nimport { CommandContext } from \"../types/index.js\";\nimport { MediaService } from \"../services/media.js\";\nimport { ErrorUtils, GeneralUtils } from '../utils/shared.js';\nimport fs from 'fs';\nimport path from 'path';\nimport config from \"../config.js\";\n\nexport default class PlayCommand extends BaseCommand {\n\tname = \"play\";\n\tdescription = \"Play local video, URL, or search YouTube videos\";\n\tusage = \"play <video_name|url|search_query>\";\n\n\tprivate mediaService: MediaService;\n\n\tconstructor() {\n\t\tsuper();\n\t\tthis.mediaService = new MediaService();\n\t}\n\n\tasync execute(context: CommandContext): Promise<void> {\n\t\tconst input = context.args.join(' ');\n\n\t\tif (!input) {\n\t\t\tawait this.sendError(context.message, 'Please provide a video name, URL, or search query.');\n\t\t\treturn;\n\t\t}\n\n\t\t// Check if input is a URL (YouTube, Twitch, or direct link)\n\t\tif (GeneralUtils.isValidUrl(input)) {\n\t\t\tawait this.handleUrl(context, input);\n\t\t} else {\n\t\t\t// Refresh video list from disk before matching\n\t\t\tconst videoFiles = fs.readdirSync(config.videosDir);\n\t\t\tconst refreshedVideos = videoFiles.map(file => ({\n\t\t\t\tname: path.parse(file).name,\n\t\t\t\tpath: path.join(config.videosDir, file)\n\t\t\t}));\n\t\t\tcontext.videos.length = 0;\n\t\t\tcontext.videos.push(...refreshedVideos);\n\n\t\t\t// Case-insensitive match\n\t\t\tconst video = context.videos.find(m => m.name.toLowerCase() === input.toLowerCase());\n\n\t\t\tif (video) {\n\t\t\t\tawait this.handleLocalVideo(context, video);\n\t\t\t} else {\n\t\t\t\t// Treat as search query\n\t\t\t\tawait this.handleSearchQuery(context, input);\n\t\t\t}\n\t\t}\n\t}\n\n\n\tprivate async handleLocalVideo(context: CommandContext, video: any): Promise<void> {\n\t\t// Add to queue instead of playing immediately\n\t\tconst success = await context.streamingService.addToQueue(context.message, video.path, video.name);\n\n\t\tif (success) {\n\t\t\t// If not currently playing, start playing from queue\n\t\t\tif (!context.streamStatus.playing) {\n\t\t\t\tawait context.streamingService.playFromQueue(context.message);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate async handleUrl(context: CommandContext, url: string): Promise<void> {\n\t\ttry {\n\t\t\t// For lazy processing, just add to queue - resolution happens when playing\n\t\t\tconst success = await context.streamingService.addToQueue(context.message, url);\n\n\t\t\tif (success) {\n\t\t\t\t// If not currently playing, start playing from queue\n\t\t\t\tif (!context.streamStatus.playing) {\n\t\t\t\t\tawait context.streamingService.playFromQueue(context.message);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tawait ErrorUtils.handleError(error, `processing URL: ${url}`, context.message);\n\t\t}\n\t}\n\n\tprivate async handleSearchQuery(context: CommandContext, query: string): Promise<void> {\n\t\ttry {\n\t\t\t// For lazy processing, add the search query to queue\n\t\t\t// The actual search and resolution will happen when it's time to play\n\t\t\tconst success = await context.streamingService.addToQueue(context.message, query, `Search: ${query}`);\n\n\t\t\tif (success) {\n\t\t\t\t// If not currently playing, start playing from queue\n\t\t\t\tif (!context.streamStatus.playing) {\n\t\t\t\t\tawait context.streamingService.playFromQueue(context.message);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tawait ErrorUtils.handleError(error, 'adding search query to queue', context.message);\n\t\t}\n\t}\n}"
  },
  {
    "path": "src/commands/preview.ts",
    "content": "import { BaseCommand } from \"./base.js\";\nimport { CommandContext } from \"../types/index.js\";\nimport { MessageAttachment } from \"discord.js-selfbot-v13\";\nimport path from 'path';\nimport { ffmpegScreenshot } from \"../utils/ffmpeg.js\";\nimport logger from '../utils/logger.js';\n\nexport default class PreviewCommand extends BaseCommand {\n\tname = \"preview\";\n\tdescription = \"Generate preview thumbnails for a video\";\n\tusage = \"preview <video_name>\";\n\n\tasync execute(context: CommandContext): Promise<void> {\n\t\tconst vid = context.args.join(' ');\n\n\t\tif (!vid) {\n\t\t\tawait this.sendError(context.message, 'Please provide a video name.');\n\t\t\treturn;\n\t\t}\n\n\t\tconst vid_name = context.videos.find(m => m.name === vid);\n\n\t\tif (!vid_name) {\n\t\t\tawait this.sendError(context.message, 'Video not found');\n\t\t\treturn;\n\t\t}\n\n\t\t// React with camera emoji\n\t\tcontext.message.react('📸');\n\n\t\t// Reply with message to indicate that the preview is being generated\n\t\tcontext.message.reply('📸 **Generating preview thumbnails...**');\n\n\t\ttry {\n\t\t\tconst videoFilename = vid_name.name + path.extname(vid_name.path);\n\t\t\tconst thumbnails = await ffmpegScreenshot(videoFilename);\n\n\t\t\tif (thumbnails.length > 0) {\n\t\t\t\tconst attachments: MessageAttachment[] = [];\n\t\t\t\tfor (const screenshotPath of thumbnails) {\n\t\t\t\t\tattachments.push(new MessageAttachment(screenshotPath));\n\t\t\t\t}\n\n\t\t\t\t// Message content\n\t\t\t\tconst content = `📸 **Preview**: \\`${vid_name.name}\\``;\n\n\t\t\t\t// Send message with attachments\n\t\t\t\tawait context.message.reply({\n\t\t\t\t\tcontent,\n\t\t\t\t\tfiles: attachments\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tawait this.sendError(context.message, 'Failed to generate preview thumbnails.');\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlogger.error('Error generating preview thumbnails:', error);\n\t\t\tawait this.sendError(context.message, 'Failed to generate preview thumbnails.');\n\t\t}\n\t}\n}"
  },
  {
    "path": "src/commands/queue.ts",
    "content": "import { BaseCommand } from \"./base.js\";\nimport { CommandContext } from \"../types/index.js\";\n\nexport default class QueueCommand extends BaseCommand {\n\tname = \"queue\";\n\tdescription = \"Display the current video queue\";\n\tusage = \"queue\";\n\n\tasync execute(context: CommandContext): Promise<void> {\n\t\tconst queueItems = context.streamingService.getQueueService().getQueue();\n\t\tconst currentItem = context.streamingService.getQueueService().getCurrent();\n\t\tconst queueStatus = context.streamingService.getQueueService().getQueueStatus();\n\n\t\tif (queueItems.length === 0) {\n\t\t\tawait this.sendInfo(context.message, 'Queue', 'The queue is currently empty.');\n\t\t\treturn;\n\t\t}\n\n\t\tlet queueText = `📋 **Queue** (${queueItems.length} item${queueItems.length !== 1 ? 's' : ''})\\n\\n`;\n\n\t\tif (queueStatus.isPlaying && currentItem) {\n\t\t\tconst status = currentItem.resolved ? '▶️' : '⏳';\n\t\t\tconst title = currentItem.resolved ? currentItem.title : `${currentItem.title} (resolving...)`;\n\t\t\tqueueText += `${status} **Currently Playing:**\\n\\`${title}\\` (requested by ${currentItem.requestedBy})\\n\\n`;\n\t\t}\n\n\t\tqueueText += '**Up Next:**\\n';\n\n\t\t// Show all items that are not currently playing\n\t\tconst upcomingItems = queueItems.filter(item => !queueStatus.isPlaying || item.id !== currentItem?.id);\n\n\t\tif (upcomingItems.length === 0) {\n\t\t\tif (queueStatus.isPlaying && currentItem) {\n\t\t\t\tqueueText += '*No upcoming items*\\n';\n\t\t\t} else {\n\t\t\t\tqueueText += '*Queue is empty*\\n';\n\t\t\t}\n\t\t} else {\n\t\t\tupcomingItems.forEach((item, index) => {\n\t\t\t\tconst position = queueStatus.isPlaying ? index + 1 : index;\n\t\t\t\tconst addedTime = item.addedAt.toLocaleTimeString();\n\t\t\t\tconst status = item.resolved ? '' : '⏳';\n\t\t\t\tconst title = item.resolved ? item.title : `${item.title} (pending)`;\n\t\t\t\tqueueText += `${position + 1}. ${status} \\`${title}\\` (by ${item.requestedBy}) - Added at ${addedTime}\\n`;\n\t\t\t});\n\t\t}\n\n\t\t// Split message if it's too long (Discord has a 2000 character limit)\n\t\tif (queueText.length > 1900) {\n\t\t\tconst firstPart = queueText.substring(0, 1900) + '...';\n\t\t\tconst secondPart = '...(continued)\\n' + queueText.substring(1900);\n\n\t\t\tawait context.message.channel.send(firstPart);\n\t\t\tawait context.message.channel.send(secondPart);\n\t\t} else {\n\t\t\tawait context.message.channel.send(queueText);\n\t\t}\n\t}\n}"
  },
  {
    "path": "src/commands/skip.ts",
    "content": "import { BaseCommand } from \"./base.js\";\nimport { CommandContext } from \"../types/index.js\";\n\nexport default class SkipCommand extends BaseCommand {\n\tname = \"skip\";\n\tdescription = \"Skip the currently playing video\";\n\tusage = \"skip\";\n\taliases = [\"next\"];\n\n\tasync execute(context: CommandContext): Promise<void> {\n\t\tconst currentItem = context.streamingService.getQueueService().getCurrent();\n\t\tconst queueLength = context.streamingService.getQueueService().getLength();\n\n\t\tif (!context.streamStatus.playing) {\n\t\t\tawait this.sendError(context.message, 'No video is currently playing.');\n\t\t\treturn;\n\t\t}\n\n\t\tif (queueLength === 0) {\n\t\t\tawait this.sendError(context.message, 'No videos in queue to skip to.');\n\t\t\treturn;\n\t\t}\n\n\t\t// Skip the current video\n\t\tawait context.streamingService.skipCurrent(context.message);\n\t}\n}"
  },
  {
    "path": "src/commands/status.ts",
    "content": "import { BaseCommand } from \"./base.js\";\nimport { CommandContext } from \"../types/index.js\";\n\nexport default class StatusCommand extends BaseCommand {\n\tname = \"status\";\n\tdescription = \"Show current streaming status\";\n\tusage = \"status\";\n\n\tasync execute(context: CommandContext): Promise<void> {\n\t\tawait this.sendInfo(context.message, 'Status',\n\t\t\t`Joined: ${context.streamStatus.joined}\\nPlaying: ${context.streamStatus.playing}`);\n\t}\n}"
  },
  {
    "path": "src/commands/stop.ts",
    "content": "import { BaseCommand } from \"./base.js\";\nimport { CommandContext } from \"../types/index.js\";\nimport logger from '../utils/logger.js';\n\nexport default class StopCommand extends BaseCommand {\n \tname = \"stop\";\n \tdescription = \"Stop current video playback and clear queue\";\n \tusage = \"stop\";\n\taliases = [\"leave\", \"s\"];\n\n\tasync execute(context: CommandContext): Promise<void> {\n\t\tif (!context.streamStatus.joined) {\n\t\t\tawait this.sendError(context.message, '**Already Stopped!**');\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tcontext.streamStatus.manualStop = true;\n\n\t\t\tawait this.sendSuccess(context.message, 'Stopped playing video and cleared queue.');\n\t\t\tlogger.info(\"Stopped playing video and cleared queue.\");\n\n\t\t\t// Use streaming service to handle the stop and clear queue\n\t\t\tawait context.streamingService.stopAndClearQueue();\n\n\t\t} catch (error) {\n\t\t\tlogger.error(\"Error during force termination:\", error);\n\t\t}\n\t}\n}"
  },
  {
    "path": "src/commands/ytsearch.ts",
    "content": "import { BaseCommand } from \"./base.js\";\nimport { CommandContext } from \"../types/index.js\";\nimport { MediaService } from \"../services/media.js\";\n\nexport default class YTSearchCommand extends BaseCommand {\n\tname = \"ytsearch\";\n\tdescription = \"Search for videos on YouTube\";\n\tusage = \"ytsearch <query>\";\n\n\tprivate mediaService: MediaService;\n\n\tconstructor() {\n\t\tsuper();\n\t\tthis.mediaService = new MediaService();\n\t}\n\n\tasync execute(context: CommandContext): Promise<void> {\n\t\tconst query = context.args.join(' ');\n\n\t\tif (!query) {\n\t\t\tawait this.sendError(context.message, 'Please provide a search query.');\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tconst searchResults = await this.mediaService.searchYouTube(query);\n\t\t\tif (searchResults.length > 0) {\n\t\t\t\tawait this.sendList(context.message, searchResults, \"ytsearch\");\n\t\t\t} else {\n\t\t\t\tawait this.sendError(context.message, 'No videos found.');\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tawait this.sendError(context.message, 'Failed to search for videos.');\n\t\t}\n\t}\n}"
  },
  {
    "path": "src/config.ts",
    "content": "import dotenv from \"dotenv\"\n\ndotenv.config({ quiet: true });\n\nconst VALID_VIDEO_CODECS = ['VP8', 'H264', 'H265', 'VP9', 'AV1'];\n\nexport function parseVideoCodec(value: string): \"VP8\" | \"H264\" | \"H265\" {\n\tif (typeof value === \"string\") {\n\t\tvalue = value.trim().toUpperCase();\n\t}\n\tif (VALID_VIDEO_CODECS.includes(value)) {\n\t\treturn value as \"VP8\" | \"H264\" | \"H265\";\n\t}\n\treturn \"H264\";\n}\n\nexport function parsePreset(value: string): \"ultrafast\" | \"superfast\" | \"veryfast\" | \"faster\" | \"fast\" | \"medium\" | \"slow\" | \"slower\" | \"veryslow\" {\n\tif (typeof value === \"string\") {\n\t\tvalue = value.trim().toLowerCase();\n\t}\n\tswitch (value) {\n\t\tcase \"ultrafast\":\n\t\tcase \"superfast\":\n\t\tcase \"veryfast\":\n\t\tcase \"faster\":\n\t\tcase \"fast\":\n\t\tcase \"medium\":\n\t\tcase \"slow\":\n\t\tcase \"slower\":\n\t\tcase \"veryslow\":\n\t\t\treturn value as \"ultrafast\" | \"superfast\" | \"veryfast\" | \"faster\" | \"fast\" | \"medium\" | \"slow\" | \"slower\" | \"veryslow\";\n\t\tdefault:\n\t\t\treturn \"ultrafast\";\n\t}\n}\n\nexport function parseBoolean(value: string | undefined): boolean {\n\tif (typeof value === \"string\") {\n\t\tvalue = value.trim().toLowerCase();\n\t}\n\tswitch (value) {\n\t\tcase \"true\":\n\t\t\treturn true;\n\t\tdefault:\n\t\t\treturn false;\n\t}\n}\n\nfunction parseAdminIds(value: string): string[] {\n\ttry {\n\t\t// Try to parse as JSON array first\n\t\tconst parsed = JSON.parse(value);\n\t\tif (Array.isArray(parsed)) {\n\t\t\treturn parsed.filter(id => typeof id === 'string' && id.trim() !== '');\n\t\t}\n\t} catch {\n\t\t// If not JSON, try comma-separated values\n\t\tif (value.includes(',')) {\n\t\t\treturn value.split(',').map(id => id.trim()).filter(id => id !== '');\n\t\t}\n\t}\n\t// Single value\n\treturn value.trim() ? [value.trim()] : [];\n}\n\nexport default {\n\t// Selfbot options\n\ttoken: process.env.TOKEN || '',\n\tprefix: process.env.PREFIX || '',\n\tguildId: process.env.GUILD_ID ? process.env.GUILD_ID : '',\n\tcmdChannelId: process.env.COMMAND_CHANNEL_ID ? process.env.COMMAND_CHANNEL_ID : '',\n\tvideoChannelId: process.env.VIDEO_CHANNEL_ID ? process.env.VIDEO_CHANNEL_ID : '',\n\tadminIds: process.env.ADMIN_IDS ? parseAdminIds(process.env.ADMIN_IDS) : [],\n\n\t// General options\n\tvideosDir: process.env.VIDEOS_DIR ? process.env.VIDEOS_DIR : './videos',\n\tpreviewCacheDir: process.env.PREVIEW_CACHE_DIR ? process.env.PREVIEW_CACHE_DIR : './tmp/preview-cache',\n\n\t// yt-dlp options\n\tytdlpCookiesPath: process.env.YTDLP_COOKIES_PATH ? process.env.YTDLP_COOKIES_PATH : '',\n\n\t// Stream options\n\trespect_video_params: process.env.STREAM_RESPECT_VIDEO_PARAMS ? parseBoolean(process.env.STREAM_RESPECT_VIDEO_PARAMS) : false,\n\tbitrateOverride: process.env.STREAM_BITRATE_OVERRIDE ? parseBoolean(process.env.STREAM_BITRATE_OVERRIDE) : false,\n\twidth: process.env.STREAM_WIDTH ? parseInt(process.env.STREAM_WIDTH) : 1280,\n\theight: process.env.STREAM_HEIGHT ? parseInt(process.env.STREAM_HEIGHT) : 720,\n\tfps: process.env.STREAM_FPS ? parseInt(process.env.STREAM_FPS) : 30,\n\tbitrateKbps: process.env.STREAM_BITRATE_KBPS ? parseInt(process.env.STREAM_BITRATE_KBPS) : 1000,\n\tmaxBitrateKbps: process.env.STREAM_MAX_BITRATE_KBPS ? parseInt(process.env.STREAM_MAX_BITRATE_KBPS) : 2500,\n\tmaxWidth: process.env.STREAM_MAX_WIDTH ? parseInt(process.env.STREAM_MAX_WIDTH) : 0,\n\tmaxHeight: process.env.STREAM_MAX_HEIGHT ? parseInt(process.env.STREAM_MAX_HEIGHT) : 0,\n\thardwareAcceleratedDecoding: process.env.STREAM_HARDWARE_ACCELERATION ? parseBoolean(process.env.STREAM_HARDWARE_ACCELERATION) : false,\n\th26xPreset: process.env.STREAM_H26X_PRESET ? parsePreset(process.env.STREAM_H26X_PRESET) : 'ultrafast',\n\tvideoCodec: process.env.STREAM_VIDEO_CODEC ? parseVideoCodec(process.env.STREAM_VIDEO_CODEC) : 'H264',\n\n\t// Videos server options\n\tserver_enabled: process.env.SERVER_ENABLED ? parseBoolean(process.env.SERVER_ENABLED) : false,\n\tserver_username: process.env.SERVER_USERNAME ? process.env.SERVER_USERNAME : 'admin',\n\tserver_password: process.env.SERVER_PASSWORD ? process.env.SERVER_PASSWORD : 'admin',\n\tserver_port: parseInt(process.env.SERVER_PORT ? process.env.SERVER_PORT : '8080'),\n}"
  },
  {
    "path": "src/events/client/ready.ts",
    "content": "import { Client, ActivityOptions } from \"discord.js-selfbot-v13\";\nimport logger from \"../../utils/logger.js\";\nimport { DiscordUtils } from \"../../utils/shared.js\";\n\nexport async function handleReady(client: Client): Promise<void> {\n\tif (client.user) {\n\t\tlogger.info(`${client.user.tag} is ready`);\n\t\tclient.user.setActivity(DiscordUtils.status_idle() as ActivityOptions);\n\t}\n}"
  },
  {
    "path": "src/events/messageCreate.ts",
    "content": "import { Message } from \"discord.js-selfbot-v13\";\nimport { CommandManager } from \"../commands/manager.js\";\nimport { CommandContext, Video, StreamStatus } from \"../types/index.js\";\nimport config from \"../config.js\";\n\nexport async function handleMessageCreate(\n\tmessage: Message,\n\tvideos: Video[],\n\tstreamStatus: StreamStatus,\n\tstreamingService: any,\n\tcommandManager: CommandManager\n): Promise<void> {\n\t// Ignore bots, self, non-command channels, and non-commands\n\tif (\n\t\tmessage.author.bot ||\n\t\tmessage.author.id === message.client.user?.id ||\n\t\t!message.content.startsWith(config.prefix)\n\t) return;\n\n\t// Split command and arguments\n\tconst args = message.content.slice(config.prefix!.length).trim().split(/ +/);\n\n\t// If no command provided, ignore\n\tif (args.length === 0) {\n\t\treturn;\n\t}\n\n\tconst commandName = args.shift()!.toLowerCase();\n\n\tconst context: CommandContext = {\n\t\tmessage,\n\t\targs,\n\t\tvideos,\n\t\tstreamStatus,\n\t\tstreamingService\n\t};\n\n\tconst executed = await commandManager.executeCommand(commandName, context);\n\n\tif (!executed) {\n\t\tawait message.react('❌');\n\t\tawait message.reply(`❌ **Error**: Unknown command. Use \\`${config.prefix}help\\` to see available commands.`);\n\t}\n}\n"
  },
  {
    "path": "src/events/voiceStateUpdate.ts",
    "content": "import { VoiceState } from \"discord.js-selfbot-v13\";\nimport { Client } from \"discord.js-selfbot-v13\";\nimport { DiscordUtils } from \"../utils/shared.js\";\nimport { StreamStatus } from \"../types/index.js\";\n\nexport async function handleVoiceStateUpdate(\n\toldState: VoiceState,\n\tnewState: VoiceState,\n\tstreamStatus: StreamStatus,\n\tclient: Client\n): Promise<void> {\n\t// When exit channel\n\tif (oldState.member?.user.id == client.user?.id) {\n\t\tif (oldState.channelId && !newState.channelId) {\n\t\t\tstreamStatus.joined = false;\n\t\t\tstreamStatus.joinsucc = false;\n\t\t\tstreamStatus.playing = false;\n\t\t\tstreamStatus.channelInfo = {\n\t\t\t\tguildId: \"\",\n\t\t\t\tchannelId: \"\",\n\t\t\t\tcmdChannelId: \"\"\n\t\t\t}\n\t\t\tclient.user?.setActivity(DiscordUtils.status_idle());\n\t\t}\n\t}\n\n\t// When join channel success\n\tif (newState.member?.user.id == client.user?.id) {\n\t\tif (newState.channelId && !oldState.channelId) {\n\t\t\tstreamStatus.joined = true;\n\t\t\tif (newState.guild.id == streamStatus.channelInfo.guildId && newState.channelId == streamStatus.channelInfo.channelId) {\n\t\t\t\tstreamStatus.joinsucc = true;\n\t\t\t}\n\t\t}\n\t}\n}"
  },
  {
    "path": "src/index.ts",
    "content": "import { Client } from \"discord.js-selfbot-v13\";\nimport config from \"./config.js\";\nimport fs from 'fs';\nimport path from 'path';\nimport logger from './utils/logger.js';\nimport { downloadExecutable, checkForUpdatesAndUpdate } from './utils/yt-dlp.js';\n\n// Import event handlers\nimport { handleReady } from './events/client/ready.js';\nimport { handleMessageCreate } from './events/messageCreate.js';\nimport { handleVoiceStateUpdate } from './events/voiceStateUpdate.js';\n\n// Import services\nimport { StreamingService } from './services/streaming.js';\nimport { MediaService } from './services/media.js';\nimport { CommandManager } from './commands/manager.js';\nimport { QueueService } from './services/queue.js';\n\n// Download yt-dlp and check for updates\n(async () => {\n\ttry {\n\t\tawait downloadExecutable();\n\t\tawait checkForUpdatesAndUpdate();\n\t} catch (error) {\n\t\tlogger.error(\"Error during initial yt-dlp setup/update:\", error);\n\t}\n})();\n\n// Create a new instance of Client\nconst client = new Client();\n\n// Stream status object\nconst queueService = new QueueService();\nconst streamStatus = {\n\tjoined: false,\n\tjoinsucc: false,\n\tplaying: false,\n\tmanualStop: false,\n\tchannelInfo: {\n\t\tguildId: config.guildId,\n\t\tchannelId: config.videoChannelId,\n\t\tcmdChannelId: config.cmdChannelId\n\t},\n\tqueue: queueService.getQueueStatus()\n}\n\n// Create services\nconst streamingService = new StreamingService(client, streamStatus);\nconst mediaService = new MediaService();\nconst commandManager = new CommandManager();\n\n// Create the videosFolder dir if it doesn't exist\nif (!fs.existsSync(config.videosDir)) {\n\tfs.mkdirSync(config.videosDir);\n}\n\n// Create previewCache parent dir if it doesn't exist\nif (!fs.existsSync(path.dirname(config.previewCacheDir))) {\n\tfs.mkdirSync(path.dirname(config.previewCacheDir), { recursive: true });\n}\n\n// Create the previewCache dir if it doesn't exist\nif (!fs.existsSync(config.previewCacheDir)) {\n\tfs.mkdirSync(config.previewCacheDir);\n}\n\n// Get all video files\nconst videoFiles = fs.readdirSync(config.videosDir);\n\n// Create an array of video objects\nlet videos = videoFiles.map(file => {\n\tconst fileName = path.parse(file).name;\n\treturn { name: fileName, path: path.join(config.videosDir, file) };\n});\n\n// Print available videos\nif (videos.length > 0) {\n\tlogger.info(`Available videos:\\n${videos.map(m => m.name).join('\\n')}`);\n}\n\n// Event handlers\nclient.on(\"ready\", async () => {\n\tawait handleReady(client);\n});\n\nclient.on('voiceStateUpdate', async (oldState, newState) => {\n\tawait handleVoiceStateUpdate(oldState, newState, streamStatus, client);\n});\n\nclient.on('messageCreate', async (message) => {\n\tawait handleMessageCreate(message, videos, streamStatus, streamingService, commandManager);\n});\n\n// Handle uncaught exceptions\nprocess.on('uncaughtException', (error) => {\n\tif (!(error instanceof Error && error.message.includes('SIGTERM'))) {\n\t\tlogger.error('Uncaught Exception:', error);\n\t\treturn\n\t}\n});\n\n// Run server if enabled in config\nif (config.server_enabled) {\n\t// Run server.ts\n\timport('./server/index.js');\n}\n\n// Login to Discord\nclient.login(config.token);"
  },
  {
    "path": "src/server/index.ts",
    "content": "import express from \"express\";\nimport session from \"express-session\";\nimport expressLayouts from \"express-ejs-layouts\";\nimport path from \"path\";\nimport fs from \"fs\";\nimport config from \"../config.js\";\nimport logger from \"../utils/logger.js\";\nimport { stringify, prettySize } from \"./utils/helpers.js\";\n\n// Import middleware\nimport { requireAuth } from \"./middleware/auth.js\";\n\n// Import route handlers\nimport authRoutes from \"./routes/auth.js\";\nimport dashboardRoutes from \"./routes/dashboard.js\";\nimport uploadRoutes from \"./routes/upload.js\";\nimport previewRoutes from \"./routes/preview.js\";\n\nconst app = express();\n\n// Configure EJS templating engine\napp.set('view engine', 'ejs');\napp.set('views', path.join(process.cwd(), 'src/server/views'));\n\n// Use express-ejs-layouts\napp.use(expressLayouts);\napp.set('layout', 'layouts/main');\n\n// Middleware\napp.use(express.urlencoded({ extended: true }));\napp.use(session({\n\tsecret: 'streambot-2024',\n\tresave: false,\n\tsaveUninitialized: true,\n\tcookie: { secure: process.env.NODE_ENV === 'production' }\n}));\n\n// Serve static files from public directory\napp.use(express.static(path.join(process.cwd(), 'src/server/public')));\n\n// Make helper functions available to all templates\napp.use((req, res, next) => {\n\tres.locals.stringify = stringify;\n\tres.locals.prettySize = prettySize;\n\tnext();\n});\n\n// Apply authentication middleware to all routes except login\napp.use(requireAuth);\n\n// Routes\napp.use('/', authRoutes);\napp.use('/', dashboardRoutes);\napp.use('/', uploadRoutes);\napp.use('/', previewRoutes);\n\n// Create necessary directories\nif (!fs.existsSync(config.videosDir)) {\n\tfs.mkdirSync(config.videosDir);\n}\n\nif (!fs.existsSync(path.dirname(config.previewCacheDir))) {\n\tfs.mkdirSync(path.dirname(config.previewCacheDir), { recursive: true });\n}\n\nif (!fs.existsSync(config.previewCacheDir)) {\n\tfs.mkdirSync(config.previewCacheDir);\n}\n\n// Start server\napp.listen(config.server_port, () => {\n\tlogger.info(`Server is running on port ${config.server_port}`);\n});\n\nexport default app;"
  },
  {
    "path": "src/server/middleware/auth.ts",
    "content": "import { Request, Response, NextFunction } from 'express';\n\nexport const authMiddleware = (req: Request, res: Response, next: NextFunction) => {\n\tif ((req.session as { user?: unknown }).user) {\n\t\tnext();\n\t} else {\n\t\tres.redirect(\"/login\");\n\t}\n};\n\nexport const requireAuth = (req: Request, res: Response, next: NextFunction) => {\n\tif (req.path === \"/login\") {\n\t\tnext();\n\t} else {\n\t\tauthMiddleware(req, res, next);\n\t}\n};"
  },
  {
    "path": "src/server/middleware/multer.ts",
    "content": "import multer, { StorageEngine } from 'multer';\nimport path from 'path';\nimport config from '../../config.js';\n\n// Define the type for the file object\ninterface MulterFile extends Express.Multer.File {\n\toriginalname: string;\n}\n\n// Configure multer storage\nexport const storage: StorageEngine = multer.diskStorage({\n\tdestination: (req: Express.Request, file: MulterFile, cb: (error: Error | null, destination: string) => void) => {\n\t\tcb(null, config.videosDir);\n\t},\n\tfilename: (req: Express.Request, file: MulterFile, cb: (error: Error | null, filename: string) => void) => {\n\t\tcb(null, file.originalname);\n\t},\n});\n\n// Create the multer upload instance\nexport const upload = multer({ storage });"
  },
  {
    "path": "src/server/public/css/main.css",
    "content": ":root {\n\t--bs-body-color: #212529;\n\t--bs-body-bg: #f8f9fa;\n\t--bs-border-color: #dee2e6;\n}\n\n.dark-mode {\n\t--bs-body-color: #f8f9fa;\n\t--bs-body-bg: #212529;\n\t--bs-border-color: #495057;\n}\n\nbody {\n\tcolor: var(--bs-body-color);\n\tbackground-color: var(--bs-body-bg);\n\ttransition: background-color 0.3s ease, color 0.3s ease;\n}\n\n.card,\n.modal-content {\n\tbackground-color: var(--bs-body-bg);\n\tborder-color: var(--bs-border-color);\n\tbox-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);\n}\n\n.table {\n\tcolor: var(--bs-body-color);\n\tfont-size: 0.875rem;\n}\n\n\n.theme-toggle {\n\tposition: fixed;\n\ttop: 20px;\n\tright: 20px;\n\tz-index: 1001;\n}\n\n.logout-button {\n\tposition: fixed;\n\ttop: 20px;\n\tright: 90px;\n\tz-index: 1001;\n}\n\n.dark-mode .nav-tabs .nav-link.active {\n\tcolor: #fff;\n\tbackground-color: #495057;\n\tborder-color: #495057;\n}\n\n.dark-mode .table-striped>tbody>tr:nth-of-type(odd) {\n\tcolor: #fff;\n\tbackground-color: rgba(255, 255, 255, 0.05);\n}\n\n.dark-mode .table-striped>tbody>tr:nth-of-type(even) {\n\tbackground-color: rgba(255, 255, 255, 0.02);\n}\n\n.dark-mode .table td {\n\tcolor: #f8f9fa;\n\tborder-color: #495057;\n}\n\n.dark-mode .table th {\n\tcolor: #f8f9fa;\n\tborder-color: #495057;\n\tbackground-color: #343a40;\n}\n\n.dark-mode .btn-outline-primary,\n.dark-mode .btn-outline-secondary,\n.dark-mode .btn-outline-danger {\n\tcolor: #fff;\n}\n\n.dark-mode input[type=\"file\"] {\n\tcolor: var(--bs-body-color);\n}\n\n.dark-mode input[type=\"file\"]::file-selector-button {\n\tcolor: var(--bs-body-color);\n\tbackground-color: var(--bs-body-bg);\n\tborder-color: var(--bs-border-color);\n}\n\n.dark-mode input[type=\"file\"]::file-selector-button:hover {\n\tcolor: var(--bs-body-color);\n\tbackground-color: rgba(255, 255, 255, 0.1);\n\tborder-color: var(--bs-border-color);\n}\n\n.dark-mode input[type=\"file\"]::file-selector-button:focus {\n\tcolor: var(--bs-body-color);\n\tbackground-color: rgba(255, 255, 255, 0.15);\n\tborder-color: #80bdff;\n\tbox-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n\ninput[type=\"file\"].user-interacted:invalid {\n\tborder-color: #dc3545;\n}\n\ninput[type=\"file\"]:valid {\n\tborder-color: #28a745;\n}\n\n\ninput[type=\"file\"] {\n\tborder-color: var(--bs-border-color);\n}\n\n\ninput[type=\"file\"]:not(.user-interacted) {\n\tborder-color: var(--bs-border-color) !important;\n}\n\n.form-text {\n\tfont-size: 0.875rem;\n\tmargin-top: 0.25rem;\n}\n\n\n.modal-content {\n\tborder: none;\n\tborder-radius: 0.5rem;\n\tbox-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);\n}\n\n.modal-header {\n\tborder-bottom: 1px solid var(--bs-border-color);\n\tborder-radius: 0.5rem 0.5rem 0 0;\n\tpadding: 1rem 1.5rem;\n}\n\n.modal-header .btn-close {\n\tposition: absolute;\n\tright: 1rem;\n\ttop: 50%;\n\ttransform: translateY(-50%);\n\tmargin: 0;\n\tpadding: 0.5rem;\n\tborder-radius: 0.25rem;\n}\n\n.modal-body {\n\tpadding: 1.5rem;\n\ttext-align: center;\n}\n\n.modal-footer {\n\tborder-top: 1px solid var(--bs-border-color);\n\tborder-radius: 0 0 0.5rem 0.5rem;\n\tpadding: 1rem 1.5rem;\n}\n\n.modal-footer .btn {\n\tmin-width: 100px;\n}\n\n.dark-mode .modal-content {\n\tbackground-color: var(--bs-body-bg);\n\tcolor: var(--bs-body-color);\n}\n\n.dark-mode .modal-header {\n\tbackground-color: var(--bs-body-bg);\n\tborder-color: var(--bs-border-color);\n\tcolor: var(--bs-body-color);\n}\n\n.dark-mode .modal-body {\n\tbackground-color: var(--bs-body-bg);\n\tcolor: var(--bs-body-color);\n}\n\n.dark-mode .modal-footer {\n\tbackground-color: var(--bs-body-bg);\n\tborder-color: var(--bs-border-color);\n}\n\n.dark-mode .modal-backdrop {\n\tbackground-color: rgba(0, 0, 0, 0.8);\n}\n\n\n.dark-mode #deleteFileName {\n\tcolor: #f8f9fa !important;\n}\n\n.dark-mode .modal .modal-title {\n\tcolor: #f8f9fa;\n}\n\n.dark-mode .modal .text-muted {\n\tcolor: #adb5bd !important;\n}\n\n.dark-mode .modal .modal-body h6 {\n\tcolor: #f8f9fa;\n}\n\n\n\n.modal-header .btn-close:hover {\n\tbackground-color: rgba(0, 0, 0, 0.1);\n}\n\n.dark-mode .modal-header .btn-close:hover {\n\tbackground-color: rgba(255, 255, 255, 0.1);\n}\n\n\n.modal.fade .modal-dialog {\n\ttransition: transform 0.3s ease-out;\n\ttransform: translate(0, -50px);\n}\n\n.modal.show .modal-dialog {\n\ttransform: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n\t.modal.fade .modal-dialog {\n\t\ttransition: none;\n\t}\n}\n\n\n.dark-mode input::placeholder {\n\tcolor: #6c757d;\n}\n\n.dark-mode .form-control {\n\tcolor: #f8f9fa;\n\tbackground-color: #343a40;\n\tborder-color: #495057;\n}\n\n.dark-mode .form-control:focus {\n\tcolor: #f8f9fa;\n\tbackground-color: #343a40;\n\tborder-color: #80bdff;\n\tbox-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.dark-mode .text-muted {\n\tcolor: #adb5bd !important;\n}\n\n.dark-mode .card-header {\n\tcolor: #f8f9fa;\n\tbackground-color: #495057;\n\tborder-bottom: 1px solid #6c757d;\n}\n\n.dark-mode .card-title {\n\tcolor: #f8f9fa;\n}\n\n.card {\n\tborder: 1px solid rgba(255, 255, 255, 0.125);\n\ttransition: transform 0.3s ease-in-out;\n}\n\n.card:hover {\n\ttransform: translateY(-5px);\n}\n\n\n.table td {\n\tword-break: break-word;\n}\n\n#imageSlider {\n\tmax-width: 100%;\n\theight: auto;\n}\n\n.carousel-item img {\n\tobject-fit: contain;\n\theight: 400px;\n\tbackground-color: #000;\n\ttransition: opacity 0.3s ease-in-out;\n}\n\n.carousel-item img[loading=\"lazy\"] {\n\topacity: 0;\n}\n\n.carousel-item img[loading=\"lazy\"].loaded {\n\topacity: 1;\n}\n\n\n.toast-container {\n\tposition: fixed;\n\ttop: 20px;\n\tright: 20px;\n\tz-index: 9999;\n\tmax-width: 400px;\n\tpointer-events: none;\n}\n\n@media (max-width: 576px) {\n\t.toast-container {\n\t\tleft: 20px;\n\t\tright: 20px;\n\t\tmax-width: none;\n\t}\n}\n\n\n.toast {\n\tdisplay: flex;\n\talign-items: flex-start;\n\tmin-width: 300px;\n\tmax-width: 400px;\n\tmargin-bottom: 10px;\n\tpadding: 16px 20px;\n\tborder-radius: 12px;\n\tbox-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n\tborder: 1px solid transparent;\n\tbackdrop-filter: blur(10px);\n\tpointer-events: auto;\n\ttransform: translateX(100%);\n\ttransition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n\tposition: relative;\n\toverflow: hidden;\n}\n\n.toast.show {\n\ttransform: translateX(0);\n\tanimation: toastSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.toast.removing {\n\ttransform: translateX(100%);\n\topacity: 0;\n\tanimation: toastSlideOut 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.toast.success {\n\tbackground-color: #d1e7dd;\n\tborder-color: #badbcc;\n\tcolor: #0f5132;\n}\n\n.toast.error {\n\tbackground-color: #f8d7da;\n\tborder-color: #f5c2c7;\n\tcolor: #721c24;\n}\n\n.toast.warning {\n\tbackground-color: #fff3cd;\n\tborder-color: #ffecb5;\n\tcolor: #664d03;\n}\n\n.toast.info {\n\tbackground-color: #d1ecf1;\n\tborder-color: #bee5eb;\n\tcolor: #0c5460;\n}\n\n.toast.dark-mode {\n\tbackground-color: rgba(33, 37, 41, 0.95);\n\tborder-color: rgba(73, 80, 87, 0.5);\n\tcolor: #f8f9fa;\n}\n\n.toast.dark-mode.success {\n\tbackground-color: rgba(25, 135, 84, 0.2);\n\tborder-color: rgba(25, 135, 84, 0.3);\n\tcolor: #d1e7dd;\n}\n\n.toast.dark-mode.error {\n\tbackground-color: rgba(220, 53, 69, 0.2);\n\tborder-color: rgba(220, 53, 69, 0.3);\n\tcolor: #f8d7da;\n}\n\n.toast.dark-mode.warning {\n\tbackground-color: rgba(255, 193, 7, 0.2);\n\tborder-color: rgba(255, 193, 7, 0.3);\n\tcolor: #fff3cd;\n}\n\n.toast.dark-mode.info {\n\tbackground-color: rgba(13, 202, 240, 0.2);\n\tborder-color: rgba(13, 202, 240, 0.3);\n\tcolor: #d1ecf1;\n}\n\n\n.toast-icon {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\twidth: 24px;\n\theight: 24px;\n\tmargin-right: 12px;\n\tmargin-top: 2px;\n\tflex-shrink: 0;\n}\n\n.toast-icon i {\n\tfont-size: 16px;\n}\n\n.toast.success .toast-icon {\n\tcolor: #198754;\n}\n\n.toast.error .toast-icon {\n\tcolor: #dc3545;\n}\n\n.toast.warning .toast-icon {\n\tcolor: #ffc107;\n}\n\n.toast.info .toast-icon {\n\tcolor: #0dcaf0;\n}\n\n.toast.dark-mode .toast-icon {\n\tcolor: #f8f9fa;\n}\n\n\n.toast-content {\n\tflex: 1;\n\tfont-size: 14px;\n\tline-height: 1.4;\n\tfont-weight: 500;\n}\n\n.toast-title {\n\tfont-weight: 600;\n\tmargin-bottom: 4px;\n\tfont-size: 15px;\n}\n\n.toast-message {\n\tfont-weight: 400;\n\tmargin: 0;\n}\n\n\n.toast-close {\n\tbackground: none;\n\tborder: none;\n\tcolor: currentColor;\n\topacity: 0.6;\n\tcursor: pointer;\n\tpadding: 4px;\n\tmargin-left: 12px;\n\tborder-radius: 4px;\n\ttransition: opacity 0.2s ease;\n\tflex-shrink: 0;\n}\n\n.toast-close:hover {\n\topacity: 1;\n\tbackground-color: rgba(0, 0, 0, 0.1);\n}\n\n.toast.dark-mode .toast-close:hover {\n\tbackground-color: rgba(255, 255, 255, 0.1);\n}\n\n.toast-close i {\n\tfont-size: 14px;\n}\n\n\n.toast-progress {\n\tposition: absolute;\n\tbottom: 0;\n\tleft: 0;\n\theight: 3px;\n\tbackground-color: currentColor;\n\topacity: 0.3;\n\ttransition: width linear;\n\tborder-radius: 0 0 12px 12px;\n}\n\n\n@keyframes toastSlideIn {\n\tfrom {\n\t\ttransform: translateX(100%);\n\t\topacity: 0;\n\t}\n\n\tto {\n\t\ttransform: translateX(0);\n\t\topacity: 1;\n\t}\n}\n\n@keyframes toastSlideOut {\n\tfrom {\n\t\ttransform: translateX(0);\n\t\topacity: 1;\n\t}\n\n\tto {\n\t\ttransform: translateX(100%);\n\t\topacity: 0;\n\t}\n}\n\n\n\n@media (prefers-reduced-motion: reduce) {\n\t.toast {\n\t\ttransition: none;\n\t\tanimation: none;\n\t}\n\n\t.toast.show,\n\t.toast.removing {\n\t\ttransform: translateX(0);\n\t}\n}\n\n.btn-icon {\n\tpadding: 0.25rem 0.5rem;\n\tfont-size: 0.875rem;\n\tline-height: 1.5;\n\tborder-radius: 0.2rem;\n}\n\n.btn-icon svg,\n.btn-icon i {\n\twidth: 1rem;\n\theight: 1rem;\n\tfont-size: 1rem;\n}\n\n.login-bg {\n\tbackground-color: var(--bs-body-bg);\n\tmin-height: 100vh;\n}\n\n.login-card {\n\tbackground-color: var(--bs-body-bg);\n\tborder-color: var(--bs-border-color);\n}\n\n.login-card .card-body {\n\tpadding: 2rem;\n}\n\n.login-logo {\n\twidth: 64px;\n\theight: 64px;\n\tmargin-bottom: 1rem;\n}\n\n.form-control,\n.input-group-text {\n\tbackground-color: var(--bs-body-bg);\n\tborder-color: var(--bs-border-color);\n\tcolor: var(--bs-body-color);\n}\n\n.form-control:focus {\n\tbackground-color: var(--bs-body-bg);\n\tcolor: var(--bs-body-color);\n}\n\n.btn-primary {\n\tbackground-color: #007bff;\n\tborder-color: #007bff;\n}\n\n.btn-primary:hover {\n\tbackground-color: #0056b3;\n\tborder-color: #0056b3;\n}\n\n.dark-mode .btn-outline-primary:hover {\n\tcolor: #fff;\n\tbackground-color: #0d6efd;\n\tborder-color: #0d6efd;\n}\n\n.dark-mode .btn-outline-secondary:hover {\n\tcolor: #fff;\n\tbackground-color: #6c757d;\n\tborder-color: #6c757d;\n}\n\n.dark-mode .btn-outline-danger:hover {\n\tcolor: #fff;\n\tbackground-color: #dc3545;\n\tborder-color: #dc3545;\n}\n\n.btn:disabled {\n\topacity: 0.65;\n\tcursor: not-allowed;\n}\n\n.spinner-border-sm {\n\twidth: 1rem;\n\theight: 1rem;\n}\n\n.loading-overlay {\n\tposition: fixed;\n\ttop: 0;\n\tleft: 0;\n\tright: 0;\n\tbottom: 0;\n\tbackground-color: rgba(0, 0, 0, 0.7);\n\tdisplay: none;\n\talign-items: center;\n\tjustify-content: center;\n\tz-index: 9999;\n\tcolor: white;\n}\n\n.loading-overlay.show {\n\tdisplay: flex;\n}\n\n.progress-indicator {\n\tbackground-color: #007bff;\n\theight: 4px;\n\tposition: fixed;\n\ttop: 0;\n\tleft: 0;\n\ttransition: width 0.3s ease;\n\tz-index: 10000;\n}\n\n\n.btn:focus,\n.form-control:focus,\n.nav-link:focus {\n\toutline: 2px solid #007bff;\n\toutline-offset: 2px;\n\tbox-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n\n.skip-link {\n\tposition: absolute;\n\ttop: -40px;\n\tleft: 6px;\n\tbackground: #000;\n\tcolor: #fff;\n\tpadding: 8px;\n\ttext-decoration: none;\n\tz-index: 10000;\n}\n\n.skip-link:focus {\n\ttop: 6px;\n}\n\n\n@media (prefers-contrast: high) {\n\t:root {\n\t\t--bs-body-color: #000000;\n\t\t--bs-body-bg: #ffffff;\n\t\t--bs-border-color: #000000;\n\t}\n\n\t.dark-mode {\n\t\t--bs-body-color: #ffffff;\n\t\t--bs-body-bg: #000000;\n\t\t--bs-border-color: #ffffff;\n\t}\n}\n\n\n.progress {\n\tborder-radius: 0.375rem;\n\tbox-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n}\n\n.progress-bar {\n\ttransition: width 0.3s ease;\n\tfont-weight: 600;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n}\n\n.progress-bar.bg-info {\n\tbackground-color: #0dcaf0 !important;\n}\n\n#localProgressText,\n#remoteProgressText {\n\tfont-size: 0.875rem;\n\tfont-weight: 600;\n}\n\n\n.upload-form.uploading .btn-primary {\n\tpointer-events: none;\n}\n\n.upload-form.uploading .btn-secondary:not(.d-none) {\n\tdisplay: inline-flex !important;\n}\n\n\n.dark-mode .progress {\n\tbackground-color: rgba(255, 255, 255, 0.1);\n}\n\n.dark-mode .progress-bar {\n\tbackground-color: #0d6efd;\n}\n\n.dark-mode .progress-bar.bg-info {\n\tbackground-color: #0dcaf0 !important;\n}\n\n.dark-mode .progress-bar.bg-success {\n\tbackground-color: #198754 !important;\n}\n\n\n.image-container {\n\tposition: relative;\n\twidth: 100%;\n\theight: 400px;\n\tbackground-color: #000;\n\toverflow: hidden;\n}\n\n.image-loading,\n.image-error {\n\tposition: absolute;\n\ttop: 0;\n\tleft: 0;\n\twidth: 100%;\n\theight: 100%;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tbackground-color: #000;\n\tcolor: #fff;\n}\n\n.image-loading.show {\n\tdisplay: flex;\n}\n\n.image-error.show {\n\tdisplay: flex;\n}\n\n.preview-image {\n\twidth: 100%;\n\theight: 100%;\n\tobject-fit: contain;\n\tbackground-color: #000;\n\ttransition: opacity 0.3s ease-in-out;\n}\n\n.preview-image.loaded {\n\topacity: 1;\n}\n\n.preview-image.loading {\n\topacity: 0;\n}\n\n.preview-image.error {\n\tdisplay: none;\n}\n\n.spinner-border {\n\twidth: 3rem;\n\theight: 3rem;\n}\n\n.image-loading .spinner-border {\n\tanimation: spinner-border 0.75s linear infinite;\n}\n\n@keyframes spinner-border {\n\tto {\n\t\ttransform: rotate(360deg);\n\t}\n}\n\n.dark-mode .image-loading,\n.dark-mode .image-error {\n\tbackground-color: #000;\n\tcolor: #f8f9fa;\n}\n\n.dark-mode .image-loading .text-muted,\n.dark-mode .image-error .text-muted {\n\tcolor: #adb5bd !important;\n}\n\n\n\n@media (prefers-reduced-motion: reduce) {\n\t* {\n\t\tanimation-duration: 0.01ms !important;\n\t\tanimation-iteration-count: 1 !important;\n\t\ttransition-duration: 0.01ms !important;\n\t}\n\n\t.card:hover {\n\t\ttransform: none;\n\t}\n\n\t.spinner-border {\n\t\tanimation: none;\n\t}\n}"
  },
  {
    "path": "src/server/public/js/main.js",
    "content": "\nconst themeToggle = document.getElementById('themeToggle');\nconst prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');\n\nfunction setTheme(isDark) {\n\tdocument.body.classList.toggle('dark-mode', isDark);\n\tconst icon = themeToggle.querySelector('i');\n\tif (icon) {\n\t\ticon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';\n\t}\n}\n\nsetTheme(prefersDarkScheme.matches);\n\nthemeToggle.addEventListener('click', () => {\n\tdocument.body.classList.toggle('dark-mode');\n\tsetTheme(document.body.classList.contains('dark-mode'));\n});\n\nprefersDarkScheme.addEventListener('change', (e) => setTheme(e.matches));\n\nclass ToastManager {\n\tconstructor() {\n\t\tthis.container = document.getElementById('toast-container');\n\t\tthis.toasts = new Set();\n\t\tthis.queue = [];\n\t\tthis.maxToasts = 5;\n\t\tthis.defaultDuration = 4000;\n\t\tthis.init();\n\t}\n\n\tinit() {\n\t\tif (!this.container) {\n\t\t\tconsole.error('Toast container not found');\n\t\t\treturn;\n\t\t}\n\n\t\t\n\t\tif (!this.container) {\n\t\t\tthis.container = document.createElement('div');\n\t\t\tthis.container.id = 'toast-container';\n\t\t\tthis.container.className = 'toast-container';\n\t\t\tthis.container.setAttribute('role', 'region');\n\t\t\tthis.container.setAttribute('aria-label', 'Notifications');\n\t\t\tthis.container.setAttribute('aria-live', 'polite');\n\t\t\tdocument.body.appendChild(this.container);\n\t\t}\n\t}\n\n\tgetIcon(type) {\n\t\tconst icons = {\n\t\t\tsuccess: 'fas fa-check-circle',\n\t\t\terror: 'fas fa-exclamation-circle',\n\t\t\twarning: 'fas fa-exclamation-triangle',\n\t\t\tinfo: 'fas fa-info-circle'\n\t\t};\n\t\treturn icons[type] || icons.info;\n\t}\n\n\tgetAriaRole(type) {\n\t\treturn type === 'error' ? 'alert' : 'status';\n\t}\n\n\tcreateToast(message, type = 'info', title = '', duration = null) {\n\t\tconst toastId = 'toast-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);\n\n\t\tconst toast = document.createElement('div');\n\t\ttoast.id = toastId;\n\t\ttoast.className = `toast ${type}`;\n\t\ttoast.setAttribute('role', this.getAriaRole(type));\n\t\ttoast.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite');\n\t\ttoast.setAttribute('aria-atomic', 'true');\n\n\t\t\n\t\tif (document.body.classList.contains('dark-mode')) {\n\t\t\ttoast.classList.add('dark-mode');\n\t\t}\n\n\t\tconst content = `\n\t\t\t<div class=\"toast-icon\">\n\t\t\t\t<i class=\"${this.getIcon(type)}\" aria-hidden=\"true\"></i>\n\t\t\t</div>\n\t\t\t<div class=\"toast-content\">\n\t\t\t\t${title ? `<div class=\"toast-title\">${title}</div>` : ''}\n\t\t\t\t<div class=\"toast-message\">${message}</div>\n\t\t\t</div>\n\t\t\t<button class=\"toast-close\" onclick=\"toastManager.removeToast('${toastId}')\" aria-label=\"Close notification\">\n\t\t\t\t<i class=\"fas fa-times\" aria-hidden=\"true\"></i>\n\t\t\t</button>\n\t\t\t<div class=\"toast-progress\" style=\"width: 100%\"></div>\n\t\t`;\n\n\t\ttoast.innerHTML = content;\n\t\tthis.container.appendChild(toast);\n\n\t\t\n\t\tsetTimeout(() => {\n\t\t\ttoast.classList.add('show');\n\t\t}, 10);\n\n\t\t\n\t\tconst progressBar = toast.querySelector('.toast-progress');\n\t\tif (progressBar) {\n\t\t\tprogressBar.style.transition = `width ${duration || this.defaultDuration}ms linear`;\n\t\t\tsetTimeout(() => {\n\t\t\t\tprogressBar.style.width = '0%';\n\t\t\t}, 10);\n\t\t}\n\n\t\t\n\t\tconst autoRemove = setTimeout(() => {\n\t\t\tthis.removeToast(toastId);\n\t\t}, duration || this.defaultDuration);\n\n\t\t\n\t\tconst toastObj = {\n\t\t\tid: toastId,\n\t\t\telement: toast,\n\t\t\ttimer: autoRemove\n\t\t};\n\n\t\tthis.toasts.add(toastObj);\n\n\t\t\n\t\tthis.manageQueue();\n\n\t\treturn toastId;\n\t}\n\n\tremoveToast(toastId) {\n\t\tconst toast = document.getElementById(toastId);\n\t\tif (!toast) return;\n\n\t\t\n\t\tconst toastObj = Array.from(this.toasts).find(t => t.id === toastId);\n\t\tif (toastObj) {\n\t\t\tclearTimeout(toastObj.timer);\n\t\t\tthis.toasts.delete(toastObj);\n\t\t}\n\n\t\t\n\t\ttoast.classList.add('removing');\n\t\tsetTimeout(() => {\n\t\t\tif (toast.parentNode) {\n\t\t\t\ttoast.parentNode.removeChild(toast);\n\t\t\t}\n\t\t}, 300);\n\n\t\t\n\t\tthis.processQueue();\n\t}\n\n\tmanageQueue() {\n\t\t\n\t\tif (this.toasts.size >= this.maxToasts) {\n\t\t\tconst oldestToast = this.toasts.values().next().value;\n\t\t\tif (oldestToast) {\n\t\t\t\tthis.removeToast(oldestToast.id);\n\t\t\t}\n\t\t}\n\t}\n\n\tprocessQueue() {\n\t\t\n\t\tif (this.queue.length > 0 && this.toasts.size < this.maxToasts) {\n\t\t\tconst nextToast = this.queue.shift();\n\t\t\tthis.createToast(...nextToast);\n\t\t}\n\t}\n\n\t\n\tsuccess(message, title = 'Success', duration = null) {\n\t\treturn this.createToast(message, 'success', title, duration);\n\t}\n\n\terror(message, title = 'Error', duration = null) {\n\t\treturn this.createToast(message, 'error', title, duration);\n\t}\n\n\twarning(message, title = 'Warning', duration = null) {\n\t\treturn this.createToast(message, 'warning', title, duration);\n\t}\n\n\tinfo(message, title = 'Info', duration = null) {\n\t\treturn this.createToast(message, 'info', title, duration);\n\t}\n}\n\n\nconst toastManager = new ToastManager();\n\n\nfunction showToast(message, type = 'info', title = '', duration = null) {\n\treturn toastManager.createToast(message, type, title, duration);\n}\n\n\nfunction showSuccessToast(message, title = 'Success', duration = null) {\n\treturn toastManager.success(message, title, duration);\n}\n\nfunction showErrorToast(message, title = 'Error', duration = null) {\n\treturn toastManager.error(message, title, duration);\n}\n\nfunction showWarningToast(message, title = 'Warning', duration = null) {\n\treturn toastManager.warning(message, title, duration);\n}\n\nfunction showInfoToast(message, title = 'Info', duration = null) {\n\treturn toastManager.info(message, title, duration);\n}\n\nfunction copyFileName(name) {\n\t// Try modern clipboard API first\n\tif (navigator.clipboard && window.isSecureContext) {\n\t\tnavigator.clipboard.writeText(name).then(() => {\n\t\t\tshowSuccessToast(name + \" copied to clipboard!\", \"Copied!\");\n\t\t}).catch(async err => {\n\t\t\tconsole.error('Failed to copy text: ', err);\n\t\t\tawait fallbackCopyTextToClipboard(name);\n\t\t});\n\t} else {\n\t\tfallbackCopyTextToClipboard(name);\n\t}\n}\n\nasync function fallbackCopyTextToClipboard(text) {\n\tconst textArea = document.createElement(\"textarea\");\n\ttextArea.value = text;\n\n\t// Avoid scrolling to bottom\n\ttextArea.style.top = \"0\";\n\ttextArea.style.left = \"0\";\n\ttextArea.style.position = \"fixed\";\n\ttextArea.style.opacity = \"0\";\n\n\tdocument.body.appendChild(textArea);\n\ttextArea.focus();\n\ttextArea.select();\n\n\ttry {\n\t\t// Use modern clipboard API if available, even in fallback\n\t\tif (navigator.clipboard && window.isSecureContext) {\n\t\t\tawait navigator.clipboard.writeText(text);\n\t\t\tshowSuccessToast(text + \" copied to clipboard!\", \"Copied!\");\n\t\t} else {\n\t\t\t// Final fallback: select and inform user to copy manually\n\t\t\tconst selection = window.getSelection();\n\t\t\tconst range = document.createRange();\n\t\t\trange.selectNodeContents(textArea);\n\t\t\tselection.removeAllRanges();\n\t\t\tselection.addRange(range);\n\t\t\ttextArea.setSelectionRange(0, text.length);\n\n\t\t\t// Show info message that user needs to manually copy\n\t\t\tshowInfoToast(\"Text selected - please copy manually (Ctrl+C or Cmd+C)\", \"Manual Copy\");\n\t\t}\n\t} catch (err) {\n\t\tconsole.error('Fallback: Could not copy text: ', err);\n\t\tshowErrorToast(\"Failed to copy to clipboard\", \"Copy Error\");\n\t}\n\n\tdocument.body.removeChild(textArea);\n}\n\n\nfunction initLazyLoading() {\n\tif ('IntersectionObserver' in window) {\n\t\tconst imageObserver = new IntersectionObserver((entries, observer) => {\n\t\t\tentries.forEach(entry => {\n\t\t\t\tif (entry.isIntersecting) {\n\t\t\t\t\tconst img = entry.target;\n\t\t\t\t\timg.classList.add('loaded');\n\t\t\t\t\tobserver.unobserve(img);\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\n\t\t\n\t\tdocument.querySelectorAll('img[loading=\"lazy\"]').forEach(img => {\n\t\t\timageObserver.observe(img);\n\t\t});\n\t} else {\n\t\t\n\t\tdocument.querySelectorAll('img[loading=\"lazy\"]').forEach(img => {\n\t\t\timg.classList.add('loaded');\n\t\t});\n\t}\n}\n\n\ndocument.addEventListener('DOMContentLoaded', initLazyLoading);\n\n\nfunction initFormLoadingStates() {\n\t\n\tconst localForm = document.getElementById('localUploadForm');\n\tconst localBtn = document.getElementById('localUploadBtn');\n\tconst localSpinner = localBtn?.querySelector('.spinner-border');\n\n\tif (localForm && localBtn && localSpinner) {\n\t\tlocalForm.addEventListener('submit', function (e) {\n\t\t\tlocalBtn.disabled = true;\n\t\t\tlocalSpinner.classList.remove('d-none');\n\t\t\tlocalBtn.querySelector('span:not(.spinner-border)').textContent = 'Uploading...';\n\t\t});\n\t}\n\n\t\n\tconst remoteForm = document.getElementById('remoteUploadForm');\n\tconst remoteBtn = document.getElementById('remoteUploadBtn');\n\tconst remoteSpinner = remoteBtn?.querySelector('.spinner-border');\n\n\tif (remoteForm && remoteBtn && remoteSpinner) {\n\t\tremoteForm.addEventListener('submit', function (e) {\n\t\t\tremoteBtn.disabled = true;\n\t\t\tremoteSpinner.classList.remove('d-none');\n\t\t\tremoteBtn.querySelector('span:not(.spinner-border)').textContent = 'Uploading...';\n\t\t});\n\t}\n}\n\n\nfunction showGlobalLoading(message = 'Loading...') {\n\tconst overlay = document.getElementById('loadingOverlay');\n\tconst text = document.getElementById('loadingText');\n\tconst progress = document.getElementById('progressIndicator');\n\n\tif (text) text.textContent = message;\n\tif (overlay) overlay.classList.add('show');\n\tif (progress) progress.style.width = '0%';\n\n\t\n\tif (faviconManager) faviconManager.showLoading();\n}\n\nfunction hideGlobalLoading() {\n\tconst overlay = document.getElementById('loadingOverlay');\n\tconst progress = document.getElementById('progressIndicator');\n\n\tif (overlay) overlay.classList.remove('show');\n\tif (progress) progress.style.width = '0%';\n\n\t\n\tif (faviconManager) faviconManager.hideLoading();\n}\n\nfunction updateProgress(percentage) {\n\tconst progress = document.getElementById('progressIndicator');\n\tif (progress) {\n\t\tprogress.style.width = Math.min(100, Math.max(0, percentage)) + '%';\n\t}\n}\n\n\nfunction validateVideoFile(file) {\n\tconst videoTypes = [\n\t\t'video/mp4',\n\t\t'video/avi',\n\t\t'video/mov',\n\t\t'video/quicktime',\n\t\t'video/x-msvideo',\n\t\t'video/webm',\n\t\t'video/mkv',\n\t\t'video/x-matroska',\n\t\t'video/3gpp',\n\t\t'video/x-flv'\n\t];\n\n\t\n\tif (videoTypes.includes(file.type)) {\n\t\treturn true;\n\t}\n\n\t\n\tconst videoExtensions = ['.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv', '.3gp', '.m4v', '.asf', '.rm', '.vob'];\n\tconst fileName = file.name.toLowerCase();\n\tconst hasVideoExtension = videoExtensions.some(ext => fileName.endsWith(ext));\n\n\treturn hasVideoExtension;\n}\n\n\ndocument.addEventListener('DOMContentLoaded', function () {\n\tinitFormLoadingStates();\n\n\t\n\tconst localFileInput = document.getElementById('localFileInput');\n\tif (localFileInput) {\n\t\t\n\t\tlocalFileInput.addEventListener('focus', function (e) {\n\t\t\te.target.classList.add('user-interacted');\n\t\t});\n\n\t\tlocalFileInput.addEventListener('change', function (e) {\n\t\t\tconst file = e.target.files[0];\n\t\t\tif (file && !validateVideoFile(file)) {\n\t\t\t\tshowWarningToast('Please select a valid video file (MP4, AVI, MOV, MKV, WebM, etc.)', 'Invalid File Type');\n\t\t\t\te.target.value = '';\n\t\t\t}\n\t\t});\n\t}\n\n\t\n\tdocument.addEventListener('keydown', function (e) {\n\t\tconst deleteModal = document.getElementById('deleteModal');\n\t\tconst modal = bootstrap.Modal.getInstance(deleteModal);\n\n\t\t\n\t\tif (e.key === 'Escape' && modal && deleteModal.classList.contains('show')) {\n\t\t\tmodal.hide();\n\t\t}\n\t});\n});\n\n\nfunction showDeleteModal(fileName, deleteUrl) {\n\tconst modal = new bootstrap.Modal(document.getElementById('deleteModal'));\n\tconst fileNameElement = document.getElementById('deleteFileName');\n\tconst confirmBtn = document.getElementById('confirmDeleteBtn');\n\n\t\n\tfileNameElement.textContent = fileName;\n\n\t\n\tconfirmBtn.href = deleteUrl;\n\n\t\n\tconfirmBtn.addEventListener('click', function (e) {\n\t\t\n\t\tsetTimeout(() => {\n\t\t\t\n\t\t\tmodal.hide();\n\t\t}, 100);\n\t});\n\n\t\n\tmodal.show();\n}\n\n\nfunction handleDeleteSuccess() {\n\tconst modal = bootstrap.Modal.getInstance(document.getElementById('deleteModal'));\n\tif (modal) {\n\t\tmodal.hide();\n\t}\n\tshowSuccessToast('Video file deleted successfully!', 'File Deleted');\n\t\n\tsetTimeout(() => {\n\t\twindow.location.reload();\n\t}, 1500);\n}\n\n\nlet localUploadXHR = null;\nlet remoteUploadXHR = null;\n\n\nfunction startLocalUpload() {\n\tconst fileInput = document.getElementById('localFileInput');\n\tconst progressContainer = document.getElementById('localProgressContainer');\n\tconst progressBar = document.getElementById('localProgressBar');\n\tconst progressText = document.getElementById('localProgressText');\n\tconst progressStatus = document.getElementById('localProgressStatus');\n\tconst uploadBtn = document.getElementById('localUploadBtn');\n\tconst cancelBtn = document.getElementById('localCancelBtn');\n\n\tif (!fileInput.files[0]) {\n\t\tshowWarningToast('Please select a file first', 'No File Selected');\n\t\treturn;\n\t}\n\n\tconst file = fileInput.files[0];\n\tconst formData = new FormData();\n\tformData.append('file', file);\n\n\t\n\tprogressContainer.classList.remove('d-none');\n\tuploadBtn.classList.add('d-none');\n\tcancelBtn.classList.remove('d-none');\n\n\t\n\tlocalUploadXHR = new XMLHttpRequest();\n\n\tlocalUploadXHR.upload.addEventListener('progress', function (e) {\n\t\tif (e.lengthComputable) {\n\t\t\tconst percentComplete = Math.round((e.loaded / e.total) * 100);\n\t\t\tprogressBar.style.width = percentComplete + '%';\n\t\t\tprogressText.textContent = percentComplete + '%';\n\t\t\tprogressStatus.textContent = `Uploading... ${formatFileSize(e.loaded)} / ${formatFileSize(e.total)}`;\n\t\t}\n\t});\n\n\tlocalUploadXHR.addEventListener('load', function () {\n\t\ttry {\n\t\t\tif (localUploadXHR.status === 200) {\n\t\t\t\tconst response = JSON.parse(localUploadXHR.responseText);\n\t\t\t\tprogressBar.classList.remove('progress-bar-animated', 'progress-bar-striped');\n\t\t\t\tprogressBar.classList.add('bg-success');\n\t\t\t\tprogressText.textContent = '100%';\n\t\t\t\tprogressStatus.textContent = 'Upload completed successfully!';\n\n\t\t\t\tshowSuccessToast('File uploaded successfully!', 'Upload Complete');\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\twindow.location.reload();\n\t\t\t\t}, 2000);\n\t\t\t} else {\n\t\t\t\tshowErrorToast('Upload failed. Please try again.', 'Upload Error');\n\t\t\t\tresetLocalUpload();\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconsole.error('Error processing upload response:', error);\n\t\t\tshowErrorToast('Upload failed due to processing error.', 'Upload Error');\n\t\t\tresetLocalUpload();\n\t\t}\n\t});\n\n\tlocalUploadXHR.addEventListener('error', function () {\n\t\tshowErrorToast('Upload failed. Please check your connection.', 'Connection Error');\n\t\tresetLocalUpload();\n\t});\n\n\tlocalUploadXHR.open('POST', '/api/upload');\n\tlocalUploadXHR.send(formData);\n}\n\nfunction cancelLocalUpload() {\n\tif (localUploadXHR) {\n\t\tlocalUploadXHR.abort();\n\t\t// Clean up event listeners to prevent memory leaks\n\t\tlocalUploadXHR.onload = null;\n\t\tlocalUploadXHR.onerror = null;\n\t\tlocalUploadXHR.upload.onprogress = null;\n\t}\n\tresetLocalUpload();\n\tshowInfoToast('Upload cancelled', 'Upload Cancelled');\n}\n\nfunction resetLocalUpload() {\n\tconst progressContainer = document.getElementById('localProgressContainer');\n\tconst progressBar = document.getElementById('localProgressBar');\n\tconst progressText = document.getElementById('localProgressText');\n\tconst progressStatus = document.getElementById('localProgressStatus');\n\tconst uploadBtn = document.getElementById('localUploadBtn');\n\tconst cancelBtn = document.getElementById('localCancelBtn');\n\n\tprogressContainer.classList.add('d-none');\n\tprogressBar.style.width = '0%';\n\tprogressBar.classList.add('progress-bar-animated', 'progress-bar-striped');\n\tprogressBar.classList.remove('bg-success');\n\tprogressText.textContent = '0%';\n\tprogressStatus.textContent = 'Starting upload...';\n\tuploadBtn.classList.remove('d-none');\n\tcancelBtn.classList.add('d-none');\n\n\tlocalUploadXHR = null;\n}\n\n\nfunction startRemoteUpload() {\n\tconst urlInput = document.getElementById('remoteFileInput');\n\tconst progressContainer = document.getElementById('remoteProgressContainer');\n\tconst progressBar = document.getElementById('remoteProgressBar');\n\tconst progressText = document.getElementById('remoteProgressText');\n\tconst progressStatus = document.getElementById('remoteProgressStatus');\n\tconst uploadBtn = document.getElementById('remoteUploadBtn');\n\tconst cancelBtn = document.getElementById('remoteCancelBtn');\n\n\tif (!urlInput.value) {\n\t\tshowWarningToast('Please enter a valid URL', 'Missing URL');\n\t\treturn;\n\t}\n\n\t\n\tprogressContainer.classList.remove('d-none');\n\tuploadBtn.classList.add('d-none');\n\tcancelBtn.classList.remove('d-none');\n\n\t\n\tlet progress = 0;\n\tconst progressInterval = setInterval(() => {\n\t\tprogress += Math.random() * 15; \n\n\t\tif (progress >= 90) {\n\t\t\tprogress = 90; \n\t\t\tclearInterval(progressInterval);\n\t\t}\n\n\t\tprogressBar.style.width = Math.round(progress) + '%';\n\t\tprogressText.textContent = Math.round(progress) + '%';\n\t\tprogressStatus.textContent = 'Downloading...';\n\t}, 500);\n\n\t\n\tconst formData = new FormData();\n\tformData.append('link', urlInput.value);\n\n\tremoteUploadXHR = new XMLHttpRequest();\n\n\tremoteUploadXHR.addEventListener('load', function () {\n\t\tclearInterval(progressInterval);\n\n\t\ttry {\n\t\t\tif (remoteUploadXHR.status === 200) {\n\t\t\t\tconst response = JSON.parse(remoteUploadXHR.responseText);\n\t\t\t\tprogressBar.classList.remove('progress-bar-animated', 'progress-bar-striped');\n\t\t\t\tprogressBar.classList.add('bg-success');\n\t\t\t\tprogressBar.style.width = '100%';\n\t\t\t\tprogressText.textContent = '100%';\n\t\t\t\tprogressStatus.textContent = 'Download completed successfully!';\n\n\t\t\t\tshowSuccessToast('Remote file downloaded successfully!', 'Download Complete');\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\twindow.location.reload();\n\t\t\t\t}, 2000);\n\t\t\t} else {\n\t\t\t\tshowErrorToast('Download failed. Please check the URL.', 'Download Error');\n\t\t\t\tresetRemoteUpload();\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconsole.error('Error processing download response:', error);\n\t\t\tshowErrorToast('Download failed due to processing error.', 'Download Error');\n\t\t\tresetRemoteUpload();\n\t\t}\n\t});\n\n\tremoteUploadXHR.addEventListener('error', function () {\n\t\tclearInterval(progressInterval);\n\t\tshowErrorToast('Download failed. Please check your connection.', 'Connection Error');\n\t\tresetRemoteUpload();\n\t});\n\n\tremoteUploadXHR.open('POST', '/api/remote_upload');\n\tremoteUploadXHR.send(formData);\n}\n\nfunction cancelRemoteUpload() {\n\tif (remoteUploadXHR) {\n\t\tremoteUploadXHR.abort();\n\t\t// Clean up event listeners to prevent memory leaks\n\t\tremoteUploadXHR.onload = null;\n\t\tremoteUploadXHR.onerror = null;\n\t}\n\tresetRemoteUpload();\n\tshowInfoToast('Download cancelled', 'Download Cancelled');\n}\n\nfunction resetRemoteUpload() {\n\tconst progressContainer = document.getElementById('remoteProgressContainer');\n\tconst progressBar = document.getElementById('remoteProgressBar');\n\tconst progressText = document.getElementById('remoteProgressText');\n\tconst progressStatus = document.getElementById('remoteProgressStatus');\n\tconst uploadBtn = document.getElementById('remoteUploadBtn');\n\tconst cancelBtn = document.getElementById('remoteCancelBtn');\n\n\tprogressContainer.classList.add('d-none');\n\tprogressBar.style.width = '0%';\n\tprogressBar.classList.add('progress-bar-animated', 'progress-bar-striped');\n\tprogressBar.classList.remove('bg-success');\n\tprogressText.textContent = '0%';\n\tprogressStatus.textContent = 'Starting download...';\n\tuploadBtn.classList.remove('d-none');\n\tcancelBtn.classList.add('d-none');\n\n\tremoteUploadXHR = null;\n}\n\n\nfunction formatFileSize(bytes) {\n\tif (bytes === 0) return '0 Bytes';\n\tconst k = 1024;\n\tconst sizes = ['Bytes', 'KB', 'MB', 'GB'];\n\tconst i = Math.floor(Math.log(bytes) / Math.log(k));\n\treturn parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n}\n\n\nclass PreviewImageManager {\n\tconstructor() {\n\t\tthis.init();\n\t}\n\n\tinit() {\n\t\tthis.setupImageLoading();\n\t\tthis.setupLazyLoading();\n\t}\n\n\tsetupImageLoading() {\n\t\tconst imageContainers = document.querySelectorAll('.image-container');\n\n\t\timageContainers.forEach((container, index) => {\n\t\t\tconst img = container.querySelector('.preview-image');\n\t\t\tconst loadingDiv = container.querySelector('.image-loading');\n\t\t\tconst errorDiv = container.querySelector('.image-error');\n\n\t\t\tif (!img) return;\n\n\t\t\t\n\t\t\tthis.showLoadingState(container);\n\n\t\t\t\n\t\t\timg.addEventListener('load', () => {\n\t\t\t\tthis.showImageState(container);\n\t\t\t\timg.classList.add('loaded');\n\t\t\t});\n\n\t\t\t\n\t\t\timg.addEventListener('error', () => {\n\t\t\t\tthis.showErrorState(container);\n\t\t\t});\n\n\t\t\t\n\t\t\tif (img.complete && img.naturalHeight !== 0) {\n\t\t\t\tthis.showImageState(container);\n\t\t\t\timg.classList.add('loaded');\n\t\t\t}\n\t\t});\n\t}\n\n\tsetupLazyLoading() {\n\t\t\n\t\tif ('IntersectionObserver' in window) {\n\t\t\tconst imageObserver = new IntersectionObserver((entries, observer) => {\n\t\t\t\tentries.forEach(entry => {\n\t\t\t\t\tif (entry.isIntersecting) {\n\t\t\t\t\t\tconst container = entry.target;\n\t\t\t\t\t\tconst img = container.querySelector('.preview-image');\n\n\t\t\t\t\t\tif (img && !img.src) {\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tconst carouselItem = container.closest('.carousel-item');\n\t\t\t\t\t\t\tif (carouselItem) {\n\t\t\t\t\t\t\t\tconst previewUrl = carouselItem.getAttribute('data-preview-url');\n\t\t\t\t\t\t\t\tif (previewUrl) {\n\t\t\t\t\t\t\t\t\timg.src = previewUrl;\n\t\t\t\t\t\t\t\t\tthis.showLoadingState(container);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tobserver.unobserve(container);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t});\n\n\t\t\t\n\t\t\tdocument.querySelectorAll('.image-container').forEach(container => {\n\t\t\t\timageObserver.observe(container);\n\t\t\t});\n\t\t}\n\t}\n\n\tshowLoadingState(container) {\n\t\tconst img = container.querySelector('.preview-image');\n\t\tconst loadingDiv = container.querySelector('.image-loading');\n\t\tconst errorDiv = container.querySelector('.image-error');\n\n\t\tif (loadingDiv) loadingDiv.classList.remove('d-none');\n\t\tif (loadingDiv) loadingDiv.classList.add('show');\n\t\tif (errorDiv) errorDiv.classList.add('d-none');\n\t\tif (errorDiv) errorDiv.classList.remove('show');\n\t\tif (img) img.style.display = 'none';\n\t}\n\n\tshowImageState(container) {\n\t\tconst img = container.querySelector('.preview-image');\n\t\tconst loadingDiv = container.querySelector('.image-loading');\n\t\tconst errorDiv = container.querySelector('.image-error');\n\n\t\tif (loadingDiv) loadingDiv.classList.add('d-none');\n\t\tif (loadingDiv) loadingDiv.classList.remove('show');\n\t\tif (errorDiv) errorDiv.classList.add('d-none');\n\t\tif (errorDiv) errorDiv.classList.remove('show');\n\t\tif (img) img.style.display = 'block';\n\t}\n\n\tshowErrorState(container) {\n\t\tconst img = container.querySelector('.preview-image');\n\t\tconst loadingDiv = container.querySelector('.image-loading');\n\t\tconst errorDiv = container.querySelector('.image-error');\n\n\t\tif (loadingDiv) loadingDiv.classList.add('d-none');\n\t\tif (loadingDiv) loadingDiv.classList.remove('show');\n\t\tif (errorDiv) errorDiv.classList.remove('d-none');\n\t\tif (errorDiv) errorDiv.classList.add('show');\n\t\tif (img) img.style.display = 'none';\n\t}\n\n\tretryLoad(container) {\n\t\tconst img = container.querySelector('.preview-image');\n\t\tconst carouselItem = container.closest('.carousel-item');\n\n\t\tif (img && carouselItem) {\n\t\t\tconst previewUrl = carouselItem.getAttribute('data-preview-url');\n\t\t\tif (previewUrl) {\n\t\t\t\t\n\t\t\t\timg.src = '';\n\t\t\t\tthis.showLoadingState(container);\n\n\t\t\t\t\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\timg.src = previewUrl;\n\t\t\t\t}, 500);\n\t\t\t}\n\t\t}\n\t}\n}\n\n\nconst previewImageManager = new PreviewImageManager();\n\n\nclass FaviconManager {\n\tconstructor() {\n\t\tthis.faviconSvg = '/favicon.svg';\n\t\tthis.faviconPng = '/favicon-32x32.png';\n\t\tthis.init();\n\t}\n\n\tinit() {\n\t\tthis.updateFavicon();\n\t\tthis.setupThemeListener();\n\t\tthis.setupLoadingListener();\n\t}\n\n\tupdateFavicon() {\n\t\tconst isDark = document.body.classList.contains('dark-mode');\n\t\tconst favicon = document.querySelector('link[rel=\"icon\"][type=\"image/svg+xml\"]');\n\n\t\tif (favicon) {\n\t\t\t\n\t\t\tconst timestamp = Date.now();\n\t\t\tfavicon.href = `${this.faviconSvg}?t=${timestamp}`;\n\t\t}\n\t}\n\n\tsetupThemeListener() {\n\t\t\n\t\tconst observer = new MutationObserver((mutations) => {\n\t\t\tmutations.forEach((mutation) => {\n\t\t\t\tif (mutation.type === 'attributes' && mutation.attributeName === 'class') {\n\t\t\t\t\tthis.updateFavicon();\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\n\t\tobserver.observe(document.body, {\n\t\t\tattributes: true,\n\t\t\tattributeFilter: ['class']\n\t\t});\n\t}\n\n\tsetupLoadingListener() {\n\t\t\n\t\tdocument.body.classList.add('loading');\n\n\t\t\n\t\twindow.addEventListener('load', () => {\n\t\t\tdocument.body.classList.remove('loading');\n\t\t});\n\n\t\t\n\t\tconst originalFetch = window.fetch;\n\t\twindow.fetch = function (...args) {\n\t\t\tdocument.body.classList.add('loading');\n\t\t\treturn originalFetch.apply(this, args).finally(() => {\n\t\t\t\t\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\tdocument.body.classList.remove('loading');\n\t\t\t\t}, 100);\n\t\t\t});\n\t\t};\n\t}\n\n\tshowLoading() {\n\t\tdocument.body.classList.add('loading');\n\t}\n\n\thideLoading() {\n\t\tdocument.body.classList.remove('loading');\n\t}\n}\n\nconst faviconManager = new FaviconManager();\n\n"
  },
  {
    "path": "src/server/public/site.webmanifest",
    "content": "{\n  \"name\": \"StreamBot Video Manager\",\n  \"short_name\": \"StreamBot\",\n  \"description\": \"Upload, manage, and preview your video files\",\n  \"start_url\": \"/\",\n  \"display\": \"standalone\",\n  \"background_color\": \"#ffffff\",\n  \"theme_color\": \"#007bff\",\n  \"orientation\": \"portrait\",\n  \"icons\": [\n    {\n      \"src\": \"/favicon-16x16.png\",\n      \"sizes\": \"16x16\",\n      \"type\": \"image/png\",\n      \"purpose\": \"any maskable\"\n    },\n    {\n      \"src\": \"/favicon-32x32.png\",\n      \"sizes\": \"32x32\",\n      \"type\": \"image/png\",\n      \"purpose\": \"any maskable\"\n    },\n    {\n      \"src\": \"/apple-touch-icon.png\",\n      \"sizes\": \"180x180\",\n      \"type\": \"image/png\",\n      \"purpose\": \"any maskable\"\n    }\n  ]\n}"
  },
  {
    "path": "src/server/routes/auth.ts",
    "content": "import { Router } from 'express';\nimport bcrypt from 'bcrypt';\nimport argon2 from 'argon2';\nimport config from '../../config.js';\nimport logger from '../../utils/logger.js';\n\nconst router = Router();\n\n// Login route - GET\nrouter.get(\"/login\", (req, res) => {\n\tres.render('pages/login', {\n\t\terror: req.query.error === '1',\n\t\tshowLogout: false\n\t});\n});\n\n// Login route - POST\nrouter.post(\"/login\", async (req, res) => {\n\tconst { username, password } = req.body;\n\t\n\tlet isPasswordMatch = false;\n\t\n\t// Check if the stored password is a hash or plain text\n\tif (config.server_password.startsWith('$argon2')) {\n\t\t// Argon2 hash\n\t\ttry {\n\t\t\tisPasswordMatch = await argon2.verify(config.server_password, password);\n\t\t} catch (err) {\n\t\t\tlogger.error(\"Error verifying argon2 password:\", err);\n\t\t\tisPasswordMatch = false;\n\t\t}\n\t} else if (config.server_password.startsWith('$2')) {\n\t\t// Bcrypt hash\n\t\tisPasswordMatch = await bcrypt.compare(password, config.server_password);\n\t} else {\n\t\t// Plain text (not recommended)\n\t\tisPasswordMatch = password === config.server_password;\n\t}\n\t\n\tif (username === config.server_username && isPasswordMatch) {\n\t\t(req.session as { user?: unknown }).user = username;\n\t\tres.redirect(\"/\");\n\t} else {\n\t\tres.redirect(\"/login?error=1\");\n\t}\n});\n\n// Logout route\nrouter.get(\"/logout\", (req, res) => {\n\treq.session.destroy((err) => {\n\t\tif (err) {\n\t\t\tlogger.error(\"Error destroying session:\", err);\n\t\t}\n\t\tres.redirect(\"/login\");\n\t});\n});\n\nexport default router;"
  },
  {
    "path": "src/server/routes/dashboard.ts",
    "content": "import { Router } from 'express';\nimport fs from 'fs';\nimport path from 'path';\nimport config from '../../config.js';\nimport logger from '../../utils/logger.js';\nimport { prettySize } from '../utils/helpers.js';\n\nconst router = Router();\n\n// Main dashboard route\nrouter.get(\"/\", (req, res) => {\n\tfs.readdir(config.videosDir, (err, files) => {\n\t\tif (err) {\n\t\t\tlogger.error(err);\n\t\t\tres.status(500).send(\"Internal Server Error\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst fileList = files.map((file) => {\n\t\t\tconst stats = fs.statSync(path.join(config.videosDir, file));\n\t\t\treturn { name: file, size: prettySize(stats.size) };\n\t\t});\n\n\t\tres.render('pages/dashboard', {\n\t\t\tfiles: fileList,\n\t\t\tshowLogout: true\n\t\t});\n\t});\n});\n\n\nexport default router;"
  },
  {
    "path": "src/server/routes/preview.ts",
    "content": "import { Router } from 'express';\nimport fs from 'fs';\nimport path from 'path';\nimport ffmpeg from 'fluent-ffmpeg';\nimport config from '../../config.js';\nimport logger from '../../utils/logger.js';\nimport { ffmpegScreenshot } from '../../utils/ffmpeg.js';\nimport { stringify } from '../utils/helpers.js';\n\nconst router = Router();\n\n// Preview route\nrouter.get(\"/preview/:file\", (req, res) => {\n\tconst file = req.params.file;\n\tif (!fs.existsSync(path.join(config.videosDir, file))) {\n\t\tres.status(404).send(\"Not Found\");\n\t\treturn;\n\t}\n\n\tffmpeg.ffprobe(`${config.videosDir}/${file}`, (err, metadata) => {\n\t\tif (err) {\n\t\t\tlogger.error(err);\n\t\t\tres.status(500).send(\"Internal Server Error\");\n\t\t\treturn;\n\t\t}\n\n\t\t// Generate preview images\n\t\tconst previews = [];\n\t\tfor (let i = 1; i <= 5; i++) {\n\t\t\tpreviews.push(`/api/preview/${file}/${i}`);\n\t\t}\n\n\t\tres.render('pages/preview', {\n\t\t\tfilename: file,\n\t\t\tmetadata: metadata,\n\t\t\tpreviews: previews,\n\t\t\tshowLogout: true\n\t\t});\n\t});\n});\n\n// Generate preview of video file using ffmpeg, cache it to previewCache and serve it\nrouter.get(\"/api/preview/:file/:id\", async (req, res) => {\n\tconst file = req.params.file;\n\tconst id = parseInt(req.params.id, 10);\n\n\t// id should be 1, 2, 3, 4 or 5\n\tif (id < 1 || id > 5) {\n\t\tres.status(404).send(\"Not Found\");\n\t\treturn;\n\t}\n\n\t// check if preview exists\n\tconst previewFile = path.resolve(config.previewCacheDir, `${file}-${id}.jpg`);\n\tif (fs.existsSync(previewFile)) {\n\t\tres.sendFile(previewFile);\n\t} else {\n\t\ttry {\n\t\t\tawait ffmpegScreenshot(file);\n\t\t} catch (err) {\n\t\t\tlogger.error(err);\n\t\t\tres.status(500).send(\"Internal Server Error\");\n\t\t\treturn;\n\t\t}\n\t\tres.sendFile(previewFile);\n\t}\n});\n\n// Delete route\nrouter.get(\"/delete/:file\", (req, res) => {\n\tconst file = req.params.file;\n\tconst filePath = path.join(config.videosDir, file);\n\n\tif (fs.existsSync(filePath)) {\n\t\tfs.unlink(filePath, (err) => {\n\t\t\tif (err) {\n\t\t\t\tlogger.error(err);\n\t\t\t\tres.status(500).send(\"Internal Server Error\");\n\t\t\t} else {\n\t\t\t\tres.redirect(\"/\");\n\t\t\t}\n\t\t});\n\t} else {\n\t\tres.status(404).send(\"Not Found\");\n\t}\n});\n\nexport default router;"
  },
  {
    "path": "src/server/routes/upload.ts",
    "content": "import { Router } from 'express';\nimport axios from 'axios';\nimport https from 'https';\nimport fs from 'fs';\nimport path from 'path';\nimport config from '../../config.js';\nimport logger from '../../utils/logger.js';\nimport { upload } from '../middleware/multer.js';\n\nconst router = Router();\nconst agent = new https.Agent({ rejectUnauthorized: false });\n\n// Upload route - local file with progress support\nrouter.post(\"/api/upload\", upload.single(\"file\"), (req, res) => {\n \tres.json({\n \t\tsuccess: true,\n \t\tmessage: \"File uploaded successfully\",\n \t\tfilename: req.file.filename,\n \t\tsize: req.file.size\n \t});\n });\n\n// Upload route - remote file with progress support\nrouter.post(\"/api/remote_upload\", upload.single(\"link\"), async (req, res) => {\n \tconst link = req.body.link;\n \tconst filename = link.substring(link.lastIndexOf('/') + 1);\n \tconst filepath = path.join(config.videosDir, filename);\n\n \ttry {\n \t\t// First, get the file info to determine size\n \t\tconst headResponse = await axios.head(link, { httpsAgent: agent });\n \t\tconst totalSize = parseInt(headResponse.headers['content-length'], 10);\n\n \t\t// Set up progress tracking\n \t\tlet downloaded = 0;\n \t\tlet lastProgressSent = 0;\n\n \t\tconst response = await axios.get(link, {\n \t\t\tresponseType: \"stream\",\n \t\t\thttpsAgent: agent,\n \t\t\tonDownloadProgress: (progressEvent) => {\n \t\t\t\tdownloaded = progressEvent.loaded;\n \t\t\t\tconst percentCompleted = Math.round((downloaded * 100) / totalSize);\n\n \t\t\t\t// Send progress updates every 2%\n \t\t\t\tif (percentCompleted - lastProgressSent >= 2) {\n \t\t\t\t\tlastProgressSent = percentCompleted;\n \t\t\t\t\tlogger.info(`Remote download progress: ${percentCompleted}%`);\n \t\t\t\t}\n \t\t\t}\n \t\t});\n\n \t\tconst writer = fs.createWriteStream(filepath);\n\n \t\tresponse.data.on('data', (chunk) => {\n \t\t\tdownloaded += chunk.length;\n \t\t\tconst percentCompleted = Math.round((downloaded * 100) / totalSize);\n\n \t\t\t// Send progress updates every 2%\n \t\t\tif (percentCompleted - lastProgressSent >= 2) {\n \t\t\t\tlastProgressSent = percentCompleted;\n \t\t\t\tlogger.info(`Remote download progress: ${percentCompleted}%`);\n \t\t\t}\n \t\t});\n\n \t\tresponse.data.pipe(writer);\n\n \t\twriter.on(\"finish\", () => {\n \t\t\tres.json({\n \t\t\t\tsuccess: true,\n \t\t\t\tmessage: \"Remote file downloaded successfully\",\n \t\t\t\tfilename: filename,\n \t\t\t\tsize: downloaded\n \t\t\t});\n \t\t});\n\n \t\twriter.on(\"error\", (err) => {\n \t\t\tlogger.error(err);\n \t\t\tres.status(500).json({ success: false, message: \"Error downloading file\" });\n \t\t});\n \t} catch (err) {\n \t\tlogger.error(err);\n \t\tres.status(500).json({ success: false, message: \"Error downloading file\" });\n \t}\n });\n\nexport default router;"
  },
  {
    "path": "src/server/utils/helpers.ts",
    "content": "// Helper function to stringify objects for display in templates\nexport function stringify(obj: any): string {\n\t// if string, return it\n\tif (typeof obj === \"string\") {\n\t\treturn obj;\n\t}\n\n\tif (Array.isArray(obj)) {\n\t\treturn `<ul>${obj.map(item => {\n\t\t\treturn `<li>${stringify(item)}</li>`;\n\t\t}).join(\"\")}</ul>`;\n\t} else {\n\t\tif (typeof obj === \"object\") {\n\t\t\treturn `<ul>${Object.keys(obj).map(key => {\n\t\t\t\treturn `<li>${key}: ${stringify(obj[key])}</li>`;\n\t\t\t}).join(\"\")}</ul>`;\n\t\t} else {\n\t\t\treturn String(obj);\n\t\t}\n\t}\n}\n\n// Helper function to format file size\nexport function prettySize(bytes: number): string {\n\tconst units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n\tlet i = 0;\n\twhile (bytes >= 1024 && i < units.length - 1) {\n\t\tbytes /= 1024;\n\t\ti++;\n\t}\n\treturn `${bytes.toFixed(2)} ${units[i]}`;\n}"
  },
  {
    "path": "src/server/views/layouts/main.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n\t<meta charset=\"UTF-8\">\n\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n\t<meta name=\"description\" content=\"StreamBot Video Manager - Upload, manage, and preview your video files\">\n\t<meta name=\"keywords\" content=\"video, streaming, upload, manager, preview\">\n\t<meta name=\"author\" content=\"StreamBot\">\n\t<meta name=\"robots\" content=\"noindex, nofollow\">\n\n\t<!-- Open Graph / Facebook -->\n\t<meta property=\"og:type\" content=\"website\">\n\t<meta property=\"og:title\" content=\"StreamBot Video Manager\">\n\t<meta property=\"og:description\" content=\"Upload, manage, and preview your video files\">\n\t<meta property=\"og:site_name\" content=\"StreamBot\">\n\n\t<!-- Twitter -->\n\t<meta property=\"twitter:card\" content=\"summary_large_image\">\n\t<meta property=\"twitter:title\" content=\"StreamBot Video Manager\">\n\t<meta property=\"twitter:description\" content=\"Upload, manage, and preview your video files\">\n\n\t<meta http-equiv=\"X-Content-Type-Options\" content=\"nosniff\">\n\t<meta http-equiv=\"X-Frame-Options\" content=\"DENY\">\n\t<meta http-equiv=\"X-XSS-Protection\" content=\"1; mode=block\">\n\t<meta http-equiv=\"Referrer-Policy\" content=\"strict-origin-when-cross-origin\">\n\n\t<link rel=\"preload\" href=\"https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/css/bootstrap.min.css\" as=\"style\" crossorigin>\n\t<link rel=\"preload\" href=\"/css/main.css\" as=\"style\">\n\t<link rel=\"preload\" href=\"https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/js/bootstrap.min.js\" as=\"script\" crossorigin>\n\n\t<link rel=\"dns-prefetch\" href=\"//cdnjs.cloudflare.com\">\n\n\t<!-- Favicon -->\n\t<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n\t<link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/favicon-32x32.png\">\n\t<link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/favicon-16x16.png\">\n\t<link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/apple-touch-icon.png\">\n\t<link rel=\"manifest\" href=\"/site.webmanifest\">\n\t<link rel=\"mask-icon\" href=\"/safari-pinned-tab.svg\" color=\"#007bff\">\n\t<meta name=\"msapplication-TileColor\" content=\"#007bff\">\n\t<meta name=\"theme-color\" content=\"#ffffff\">\n\n\t<title>StreamBot Video Manager</title>\n\n\t<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/css/bootstrap.min.css\" integrity=\"sha512-2bBQCjcnw658Lho4nlXJcc6WkV/UxpE/sAokbXPxQNGqmNdQrWqtw26Ns9kFF/yG792pKR1Sx8/Y1Lf1XN4GKA==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\">\n\t<link rel=\"stylesheet\" href=\"/css/main.css\">\n\n\t<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css\" integrity=\"sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\" />\n\t<noscript><link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css\"></noscript>\n</head>\n<body>\n\t<a href=\"#main-content\" class=\"skip-link\">Skip to main content</a>\n\t<div id=\"toast-container\" class=\"toast-container\" role=\"region\" aria-label=\"Notifications\" aria-live=\"polite\"></div>\n\t<div id=\"loadingOverlay\" class=\"loading-overlay\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"loadingText\" aria-hidden=\"true\">\n\t\t<div class=\"text-center\">\n\t\t\t<div class=\"spinner-border text-light mb-3\" role=\"status\" aria-hidden=\"true\">\n\t\t\t\t<span class=\"visually-hidden\">Loading...</span>\n\t\t\t</div>\n\t\t\t<div id=\"loadingText\">Loading...</div>\n\t\t</div>\n\t</div>\n\t<div class=\"progress-indicator\" id=\"progressIndicator\" style=\"width: 0%;\" role=\"progressbar\" aria-valuenow=\"0\" aria-valuemin=\"0\" aria-valuemax=\"100\" aria-label=\"Loading progress\"></div>\n\t<div class=\"theme-toggle\">\n\t\t<button id=\"themeToggle\" class=\"btn btn-outline-primary\" aria-label=\"Toggle dark mode\" title=\"Toggle dark mode\">\n\t\t\t<i class=\"fas fa-moon\" aria-hidden=\"true\"></i>\n\t\t</button>\n\t</div>\n\n\t<% if (typeof showLogout !== 'undefined' && showLogout) { %>\n\t<div class=\"logout-button\">\n\t\t<a href=\"/logout\" class=\"btn btn-danger\">\n\t\t\t<i class=\"fas fa-sign-out-alt me-1\" aria-hidden=\"true\"></i>Logout\n\t\t</a>\n\t</div>\n\t<% } %>\n\n\t<%- body %>\n\n\t<script src=\"https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/js/bootstrap.min.js\" integrity=\"sha512-nKXmKvJyiGQy343jatQlzDprflyB5c+tKCzGP3Uq67v+lmzfnZUi/ZT+fc6ITZfSC5HhaBKUIvr/nTLCV+7F+Q==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n\n\t<script defer src=\"https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js\" integrity=\"sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n\t<script defer src=\"https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.11.8/umd/popper.min.js\" integrity=\"sha512-TPh2Oxlg1zp+kz3nFA0C5vVC6leG/6mm1z9+mA81MI5eaUVqasPLO8Cuk4gMF4gUfP5etR73rgU/8PNMsSesoQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n\t<script defer src=\"/js/main.js\"></script>\n\n\t<script>\n\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\t// Show loading toast for better UX\n\t\t\tconst toast = document.getElementById('toast');\n\t\t\tif (toast) {\n\t\t\t\ttoast.style.display = 'none';\n\t\t\t}\n\t\t});\n\t</script>\n</body>\n</html>"
  },
  {
    "path": "src/server/views/pages/dashboard.ejs",
    "content": "<div class=\"container my-5\" id=\"main-content\">\n\t<div class=\"d-flex justify-content-between align-items-center mb-4\">\n\t\t<h1 class=\"mb-0\">StreamBot Video Manager</h1>\n\t</div>\n\n\t<ul class=\"nav nav-tabs mb-3\" id=\"myTab\" role=\"tablist\">\n\t\t<li class=\"nav-item\" role=\"presentation\">\n\t\t\t<button class=\"nav-link active\" id=\"list-tab\"\n\t\t\t\tdata-bs-toggle=\"tab\" data-bs-target=\"#list\" type=\"button\" role=\"tab\"\n\t\t\t\taria-controls=\"list\" aria-selected=\"true\" aria-label=\"View file list\">File List</button>\n\t\t</li>\n\t\t<li class=\"nav-item\" role=\"presentation\">\n\t\t\t<button class=\"nav-link\" id=\"upload-tab\"\n\t\t\t\tdata-bs-toggle=\"tab\" data-bs-target=\"#upload\" type=\"button\" role=\"tab\"\n\t\t\t\taria-controls=\"upload\" aria-selected=\"false\" aria-label=\"Upload new files\">Upload</button>\n\t\t</li>\n\t</ul>\n\n\t<div class=\"tab-content\" id=\"myTabContent\">\n\t\t<div class=\"tab-pane fade show active\" id=\"list\" role=\"tabpanel\" aria-labelledby=\"list-tab\">\n\t\t\t<div class=\"card\">\n\t\t\t\t<div class=\"card-header\">\n\t\t\t\t\t<h5 class=\"card-title mb-0\">Video Files</h5>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"card-body\">\n\t\t\t\t\t<div class=\"table-responsive\">\n\t\t\t\t\t\t<table class=\"table table-striped\" role=\"table\" aria-label=\"Video files list\">\n\t\t\t\t\t\t\t<thead>\n\t\t\t\t\t\t\t\t<tr role=\"row\">\n\t\t\t\t\t\t\t\t\t<th scope=\"col\" role=\"columnheader\">Name</th>\n\t\t\t\t\t\t\t\t\t<th scope=\"col\" role=\"columnheader\">Size</th>\n\t\t\t\t\t\t\t\t\t<th scope=\"col\" role=\"columnheader\">Actions</th>\n\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t</thead>\n\t\t\t\t\t\t\t<tbody>\n\t\t\t\t\t\t\t\t<% if (typeof files !== 'undefined' && files.length > 0) { %>\n\t\t\t\t\t\t\t\t\t<% files.forEach(function(file) { %>\n\t\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t\t<td><%= file.name %></td>\n\t\t\t\t\t\t\t\t\t\t<td><%= file.size %></td>\n\t\t\t\t\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t\t\t\t\t<a href=\"/preview/<%= encodeURIComponent(file.name) %>\" class=\"btn btn-sm btn-outline-primary me-1 btn-icon\"\n\t\t\t\t\t\t\t\t\t\t\t   aria-label=\"Preview <%= file.name %>\" title=\"Preview <%= file.name %>\">\n\t\t\t\t\t\t\t\t\t\t\t\t<i class=\"fas fa-eye\" aria-hidden=\"true\"></i>\n\t\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t\t\t<button class=\"btn btn-sm btn-outline-secondary me-1 btn-icon\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tonclick=\"copyFileName('<%= file.name %>')\"\n\t\t\t\t\t\t\t\t\t\t\t\t\taria-label=\"Copy <%= file.name %> to clipboard\"\n\t\t\t\t\t\t\t\t\t\t\t\t\ttitle=\"Copy <%= file.name %> to clipboard\">\n\t\t\t\t\t\t\t\t\t\t\t\t<i class=\"fas fa-clipboard\" aria-hidden=\"true\"></i>\n\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t<button class=\"btn btn-sm btn-outline-danger btn-icon\"\n\t\t\t\t\t\t\t\t\t\t\t\t\taria-label=\"Delete <%= file.name %>\"\n\t\t\t\t\t\t\t\t\t\t\t\t\ttitle=\"Delete <%= file.name %>\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tonclick=\"showDeleteModal('<%= file.name %>', '/delete/<%= encodeURIComponent(file.name) %>')\">\n\t\t\t\t\t\t\t\t\t\t\t\t<i class=\"fas fa-trash\" aria-hidden=\"true\"></i>\n\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t<% }); %>\n\t\t\t\t\t\t\t\t<% } else { %>\n\t\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t\t<td colspan=\"3\" class=\"text-center text-muted\">No video files found</td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t<% } %>\n\t\t\t\t\t\t\t</tbody>\n\t\t\t\t\t\t</table>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"tab-pane fade\" id=\"upload\" role=\"tabpanel\" aria-labelledby=\"upload-tab\">\n\t\t\t<div class=\"row\">\n\t\t\t\t<div class=\"col-md-6 mb-3\">\n\t\t\t\t\t<div class=\"card\">\n\t\t\t\t\t\t<div class=\"card-header\">\n\t\t\t\t\t\t\t<h5 class=\"card-title mb-0\">Upload</h5>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"card-body\">\n\t\t\t\t\t\t\t<form action=\"/api/upload\" method=\"post\" enctype=\"multipart/form-data\" id=\"localUploadForm\">\n\t\t\t\t\t\t\t\t<div class=\"mb-3\">\n\t\t\t\t\t\t\t\t\t<label for=\"localFileInput\" class=\"form-label\">Choose video file</label>\n\t\t\t\t\t\t\t\t\t<input type=\"file\" class=\"form-control\" id=\"localFileInput\" name=\"file\" accept=\"video/*\" required>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div class=\"mb-3 d-none\" id=\"localProgressContainer\">\n\t\t\t\t\t\t\t\t\t<label class=\"form-label\">Upload Progress</label>\n\t\t\t\t\t\t\t\t\t<div class=\"progress\" style=\"height: 25px;\">\n\t\t\t\t\t\t\t\t\t\t<div class=\"progress-bar progress-bar-striped progress-bar-animated\"\n\t\t\t\t\t\t\t\t\t\t\t id=\"localProgressBar\" role=\"progressbar\" style=\"width: 0%\">\n\t\t\t\t\t\t\t\t\t\t\t<span id=\"localProgressText\">0%</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<small class=\"text-muted\" id=\"localProgressStatus\">Starting upload...</small>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div class=\"d-flex gap-2\">\n\t\t\t\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-primary\" id=\"localUploadBtn\" onclick=\"startLocalUpload()\">\n\t\t\t\t\t\t\t\t\t\t<span class=\"spinner-border spinner-border-sm d-none\" role=\"status\" aria-hidden=\"true\"></span>\n\t\t\t\t\t\t\t\t\t\t<i class=\"fas fa-upload me-1\" aria-hidden=\"true\"></i>Upload\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-secondary d-none\" id=\"localCancelBtn\" onclick=\"cancelLocalUpload()\">\n\t\t\t\t\t\t\t\t\t\t<i class=\"fas fa-times me-1\" aria-hidden=\"true\"></i>Cancel\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</form>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"col-md-6 mb-3\">\n\t\t\t\t\t<div class=\"card\">\n\t\t\t\t\t\t<div class=\"card-header\">\n\t\t\t\t\t\t\t<h5 class=\"card-title mb-0\">Remote Upload</h5>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"card-body\">\n\t\t\t\t\t\t\t<form action=\"/api/remote_upload\" method=\"post\" enctype=\"multipart/form-data\" id=\"remoteUploadForm\">\n\t\t\t\t\t\t\t\t<div class=\"mb-3\">\n\t\t\t\t\t\t\t\t\t<label for=\"remoteFileInput\" class=\"form-label\">Enter video URL</label>\n\t\t\t\t\t\t\t\t\t<input type=\"url\" class=\"form-control\" id=\"remoteFileInput\" name=\"link\" placeholder=\"https://example.com/video.mp4\" required>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div class=\"mb-3 d-none\" id=\"remoteProgressContainer\">\n\t\t\t\t\t\t\t\t\t<label class=\"form-label\">Download Progress</label>\n\t\t\t\t\t\t\t\t\t<div class=\"progress\" style=\"height: 25px;\">\n\t\t\t\t\t\t\t\t\t\t<div class=\"progress-bar progress-bar-striped progress-bar-animated bg-info\"\n\t\t\t\t\t\t\t\t\t\t\t id=\"remoteProgressBar\" role=\"progressbar\" style=\"width: 0%\">\n\t\t\t\t\t\t\t\t\t\t\t<span id=\"remoteProgressText\">0%</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<small class=\"text-muted\" id=\"remoteProgressStatus\">Starting download...</small>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div class=\"d-flex gap-2\">\n\t\t\t\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-primary\" id=\"remoteUploadBtn\" onclick=\"startRemoteUpload()\">\n\t\t\t\t\t\t\t\t\t\t<span class=\"spinner-border spinner-border-sm d-none\" role=\"status\" aria-hidden=\"true\"></span>\n\t\t\t\t\t\t\t\t\t\t<i class=\"fas fa-download me-1\" aria-hidden=\"true\"></i>Download\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t<button type=\"button\" class=\"btn btn-secondary d-none\" id=\"remoteCancelBtn\" onclick=\"cancelRemoteUpload()\">\n\t\t\t\t\t\t\t\t\t\t<i class=\"fas fa-times me-1\" aria-hidden=\"true\"></i>Cancel\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</form>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\n\t<!-- Delete Confirmation Modal -->\n\t<div class=\"modal fade\" id=\"deleteModal\" tabindex=\"-1\" aria-labelledby=\"deleteModalLabel\" aria-hidden=\"true\">\n\t\t<div class=\"modal-dialog modal-dialog-centered\">\n\t\t\t<div class=\"modal-content\">\n\t\t\t\t<div class=\"modal-header\">\n\t\t\t\t\t<h5 class=\"modal-title\" id=\"deleteModalLabel\">\n\t\t\t\t\t\t<i class=\"fas fa-exclamation-triangle text-warning me-2\" aria-hidden=\"true\"></i>\n\t\t\t\t\t\tConfirm Deletion\n\t\t\t\t\t</h5>\n\t\t\t\t\t<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\" style=\"margin: 0;\"></button>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"modal-body\">\n\t\t\t\t\t<div class=\"text-center mb-3\">\n\t\t\t\t\t\t<i class=\"fas fa-trash text-danger fa-3x mb-3\" aria-hidden=\"true\"></i>\n\t\t\t\t\t\t<h6 class=\"mb-3\">Are you sure you want to delete this video file?</h6>\n\t\t\t\t\t\t<p class=\"mb-2 text-muted\">\n\t\t\t\t\t\t\t<strong id=\"deleteFileName\"></strong>\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<small class=\"text-muted\">This action cannot be undone.</small>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"modal-footer justify-content-center\">\n\t\t\t\t\t<button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">\n\t\t\t\t\t\t<i class=\"fas fa-times me-1\" aria-hidden=\"true\"></i>Cancel\n\t\t\t\t\t</button>\n\t\t\t\t\t<a id=\"confirmDeleteBtn\" href=\"#\" class=\"btn btn-danger\">\n\t\t\t\t\t\t<i class=\"fas fa-trash me-1\" aria-hidden=\"true\"></i>Delete File\n\t\t\t\t\t</a>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</div>"
  },
  {
    "path": "src/server/views/pages/login.ejs",
    "content": "<div class=\"container-fluid min-vh-100 d-flex align-items-center justify-content-center py-5 login-bg\">\n\t<div class=\"card shadow-lg login-card\" style=\"max-width: 400px; width: 100%;\">\n\t\t<div class=\"card-body p-5\">\n\t\t\t<div class=\"text-center mb-4\">\n\t\t\t\t<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\" fill=\"currentColor\" class=\"bi bi-camera-reels text-primary login-logo\" viewBox=\"0 0 16 16\">\n\t\t\t\t\t<path d=\"M6 3a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM1 3a2 2 0 1 0 4 0 2 2 0 0 0-4 0z\"/>\n\t\t\t\t\t<path d=\"M9 6h.5a2 2 0 0 1 1.983 1.738l3.11-1.382A1 1 0 0 1 16 7.269v7.462a1 1 0 0 1-1.406.913l-3.111-1.382A2 2 0 0 1 9.5 16H2a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h7zm6 8.73V7.27l-3.5 1.555v4.35l3.5 1.556zM1 8v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1z\"/>\n\t\t\t\t\t<path d=\"M9 6a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM7 3a2 2 0 1 1 4 0 2 2 0 0 1-4 0z\"/>\n\t\t\t\t</svg>\n\t\t\t\t<h2 class=\"mt-3 mb-0 font-weight-bold\">StreamBot Video Manager</h2>\n\t\t\t\t<p class=\"text-muted\">Please sign in to continue</p>\n\t\t\t</div>\n\t\t\t<% if (typeof error !== 'undefined' && error) { %>\n\t\t\t<div class=\"alert alert-danger\" role=\"alert\">Invalid username or password</div>\n\t\t\t<% } %>\n\t\t\t<form action=\"/login\" method=\"POST\">\n\t\t\t\t<div class=\"mb-3\">\n\t\t\t\t\t<label for=\"username\" class=\"form-label\">Username</label>\n\t\t\t\t\t<div class=\"input-group\">\n\t\t\t\t\t\t<span class=\"input-group-text\">\n\t\t\t\t\t\t\t<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" class=\"bi bi-person\" viewBox=\"0 0 16 16\">\n\t\t\t\t\t\t\t\t<path d=\"M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z\"/>\n\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required autofocus>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"mb-3\">\n\t\t\t\t\t<label for=\"password\" class=\"form-label\">Password</label>\n\t\t\t\t\t<div class=\"input-group\">\n\t\t\t\t\t\t<span class=\"input-group-text\">\n\t\t\t\t\t\t\t<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" class=\"bi bi-lock\" viewBox=\"0 0 16 16\">\n\t\t\t\t\t\t\t\t<path d=\"M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2zM5 8h6a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1z\"/>\n\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<input type=\"password\" class=\"form-control\" id=\"password\" name=\"password\" required>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"d-grid\">\n\t\t\t\t\t<button type=\"submit\" class=\"btn btn-primary btn-lg\">Sign In</button>\n\t\t\t\t</div>\n\t\t\t</form>\n\t\t</div>\n\t</div>\n</div>"
  },
  {
    "path": "src/server/views/pages/preview.ejs",
    "content": "<div class=\"container-fluid py-4\">\n\t<h1 class=\"h3 mb-4\">File Preview</h1>\n\t<h2 class=\"h5 mb-4\"><%= filename %></h2>\n\t<div class=\"row\">\n\t\t<div class=\"col-lg-4 mb-4\">\n\t\t\t<div class=\"card\">\n\t\t\t\t<div class=\"card-header\">\n\t\t\t\t\t<h3 class=\"h5 mb-0\">Metadata</h3>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"card-body p-0\">\n\t\t\t\t\t<div class=\"table-responsive\">\n\t\t\t\t\t\t<table class=\"table table-striped table-sm mb-0\">\n\t\t\t\t\t\t\t<tbody>\n\t\t\t\t\t\t\t\t<% if (typeof metadata !== 'undefined' && metadata.format) { %>\n\t\t\t\t\t\t\t\t\t<% Object.entries(metadata.format).forEach(function([key, value]) { %>\n\t\t\t\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t\t\t\t<td class=\"fw-bold\"><%= key %></td>\n\t\t\t\t\t\t\t\t\t\t<td><%- stringify(value) %></td>\n\t\t\t\t\t\t\t\t\t</tr>\n\t\t\t\t\t\t\t\t\t<% }); %>\n\t\t\t\t\t\t\t\t<% } %>\n\t\t\t\t\t\t\t</tbody>\n\t\t\t\t\t\t</table>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"col-lg-8\">\n\t\t\t<div class=\"card\">\n\t\t\t\t<div class=\"card-header\">\n\t\t\t\t\t<h3 class=\"h5 mb-0\">Preview</h3>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"card-body p-0\">\n\t\t\t\t\t<div id=\"imageSlider\" class=\"carousel slide\" data-bs-ride=\"carousel\">\n\t\t\t\t\t\t<div class=\"carousel-inner\">\n\t\t\t\t\t\t\t<% if (typeof previews !== 'undefined' && previews.length > 0) { %>\n\t\t\t\t\t\t\t\t<% previews.forEach(function(preview, index) { %>\n\t\t\t\t\t\t\t\t<div class=\"carousel-item <%= index === 0 ? 'active' : '' %>\" data-preview-url=\"<%= preview %>\">\n\t\t\t\t\t\t\t\t\t<div class=\"image-container\" data-image-index=\"<%= index %>\">\n\t\t\t\t\t\t\t\t\t\t<img src=\"<%= preview %>\" class=\"d-block w-100 preview-image\" alt=\"Preview <%= index + 1 %>\" loading=\"<%= index < 2 ? 'eager' : 'lazy' %>\" style=\"display: none;\">\n\t\t\t\t\t\t\t\t\t\t<div class=\"image-loading d-none\">\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"d-flex flex-column align-items-center justify-content-center h-100\">\n\t\t\t\t\t\t\t\t\t\t\t\t<div class=\"spinner-border text-primary mb-3\" role=\"status\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span class=\"visually-hidden\">Generating preview...</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div class=\"text-center\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<small class=\"text-muted\">Generating preview image <%= index + 1 %>...</small>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div class=\"image-error d-none\">\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"d-flex flex-column align-items-center justify-content-center h-100\">\n\t\t\t\t\t\t\t\t\t\t\t\t<i class=\"fas fa-exclamation-triangle text-warning fa-3x mb-3\" aria-hidden=\"true\"></i>\n\t\t\t\t\t\t\t\t\t\t\t\t<div class=\"text-center mb-3\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<small class=\"text-muted\">Failed to load preview <%= index + 1 %></small>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<button class=\"btn btn-sm btn-outline-primary\" onclick=\"previewImageManager.retryLoad(this.closest('.image-container'))\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<i class=\"fas fa-redo me-1\" aria-hidden=\"true\"></i>Retry\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<% }); %>\n\t\t\t\t\t\t\t<% } else { %>\n\t\t\t\t\t\t\t\t<div class=\"carousel-item active\">\n\t\t\t\t\t\t\t\t\t<div class=\"d-flex align-items-center justify-content-center\" style=\"height: 400px; background-color: #000; color: #fff;\">\n\t\t\t\t\t\t\t\t\t\t<span>No preview available</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<% } %>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<% if (typeof previews !== 'undefined' && previews.length > 1) { %>\n\t\t\t\t\t\t<button class=\"carousel-control-prev\" type=\"button\" data-bs-target=\"#imageSlider\" data-bs-slide=\"prev\">\n\t\t\t\t\t\t\t<span class=\"carousel-control-prev-icon\" aria-hidden=\"true\"></span>\n\t\t\t\t\t\t\t<span class=\"visually-hidden\">Previous</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button class=\"carousel-control-next\" type=\"button\" data-bs-target=\"#imageSlider\" data-bs-slide=\"next\">\n\t\t\t\t\t\t\t<span class=\"carousel-control-next-icon\" aria-hidden=\"true\"></span>\n\t\t\t\t\t\t\t<span class=\"visually-hidden\">Next</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<% } %>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\t<div class=\"mt-4\">\n\t\t<a href=\"/\" class=\"btn btn-primary\">Back to File List</a>\n\t</div>\n</div>\n"
  },
  {
    "path": "src/services/media.ts",
    "content": "import { getStream, getVod } from 'twitch-m3u8';\nimport { TwitchStream, MediaSource } from '../types/index.js';\nimport config from \"../config.js\";\nimport logger from '../utils/logger.js';\nimport { Youtube } from '../utils/youtube.js';\nimport ytdl, { downloadToTempFile } from '../utils/yt-dlp.js';\nimport { GeneralUtils } from '../utils/shared.js';\nimport { YTResponse } from '../types/index.js';\nimport path from 'path';\n\nexport class MediaService {\n\tprivate youtube: Youtube;\n\n\tconstructor() {\n\t\tthis.youtube = new Youtube();\n\t}\n\n\tpublic async resolveMediaSource(url: string): Promise<MediaSource | null> {\n\t\ttry {\n\t\t\tif (url.includes('youtube.com/') || url.includes('youtu.be/')) {\n\t\t\t\treturn await this._resolveYouTubeSource(url);\n\t\t\t} else if (url.includes('twitch.tv/')) {\n\t\t\t\treturn await this._resolveTwitchSource(url);\n\t\t\t} else if (GeneralUtils.isLocalFile(url)) {\n\t\t\t\treturn this._resolveLocalSource(url);\n\t\t\t} else if (GeneralUtils.isValidUrl(url)) {\n\t\t\t\treturn this._resolveDirectUrlSource(url);\n\t\t\t} else {\n\t\t\t\treturn this.searchAndPlayYouTube(url);\n\t\t\t}\n\n\t\t\treturn null;\n\t\t} catch (error) {\n\t\t\tlogger.error(\"Failed to resolve media source:\", error);\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tprivate async _resolveYouTubeSource(url: string): Promise<MediaSource | null> {\n\t\tconst videoDetails = await this.youtube.getVideoInfo(url);\n\t\tif (!videoDetails) return null;\n\n\t\tconst isLive = videoDetails.videoDetails?.isLiveContent || false;\n\t\tconst streamUrl = isLive ? await this.youtube.getLiveStreamUrl(url) : url;\n\n\t\tif (streamUrl) {\n\t\t\treturn {\n\t\t\t\turl: streamUrl,\n\t\t\t\ttitle: videoDetails.title,\n\t\t\t\ttype: 'youtube',\n\t\t\t\tisLive: isLive,\n\t\t\t};\n\t\t}\n\t\treturn null;\n\t}\n\n\tpublic async getTwitchStreamUrl(url: string): Promise<string | null> {\n\t\ttry {\n\t\t\t// Handle VODs\n\t\t\tif (url.includes('/videos/')) {\n\t\t\t\tconst vodId = url.split('/videos/').pop() as string;\n\t\t\t\tconst vodInfo = await getVod(vodId);\n\t\t\t\tconst vod = vodInfo.find((stream: TwitchStream) => stream.resolution === `${config.width}x${config.height}`) || vodInfo[0];\n\t\t\t\tif (vod?.url) {\n\t\t\t\t\treturn vod.url;\n\t\t\t\t}\n\t\t\t\tlogger.error(\"No VOD URL found\");\n\t\t\t\treturn null;\n\t\t\t} else {\n\t\t\t\tconst twitchId = url.split('/').pop() as string;\n\t\t\t\tconst streams = await getStream(twitchId);\n\t\t\t\tconst stream = streams.find((stream: TwitchStream) => stream.resolution === `${config.width}x${config.height}`) || streams[0];\n\t\t\t\tif (stream?.url) {\n\t\t\t\t\treturn stream.url;\n\t\t\t\t}\n\t\t\t\tlogger.error(\"No Stream URL found\");\n\t\t\t\treturn null;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlogger.error(\"Failed to get Twitch stream URL:\", error);\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tpublic async downloadYouTubeVideo(url: string): Promise<string | null> {\n\t\ttry {\n\t\t\tconst ytDlpDownloadOptions = {\n\t\t\t\tformat: `bestvideo[height<=${config.height || 720}][ext=mp4]+bestaudio[ext=m4a]/bestvideo[height<=${config.height || 720}]+bestaudio/best[height<=${config.height || 720}]/best`,\n\t\t\t\tnoPlaylist: true,\n\t\t\t};\n\n\t\t\tconst tempFilePath = await downloadToTempFile(url, ytDlpDownloadOptions);\n\t\t\treturn tempFilePath;\n\t\t} catch (error) {\n\t\t\tlogger.error(\"Failed to download YouTube video:\", error);\n\t\t\treturn null;\n\t\t}\n\t\n\t}\n\n\tprivate async _resolveTwitchSource(url: string): Promise<MediaSource | null> {\n\t\tconst streamUrl = await this.getTwitchStreamUrl(url);\n\t\tif (streamUrl) {\n\t\t\tconst twitchId = url.split('/').pop() as string;\n\t\t\treturn {\n\t\t\t\turl: streamUrl,\n\t\t\t\ttitle: `twitch.tv/${twitchId}`,\n\t\t\t\ttype: 'twitch'\n\t\t\t};\n\t\t}\n\t\treturn null;\n\t}\n\n\tprivate _resolveLocalSource(url: string): MediaSource {\n\t\treturn {\n\t\t\turl,\n\t\t\ttitle: path.basename(url, path.extname(url)),\n\t\t\ttype: 'local'\n\t\t};\n\t}\n\n\tprivate async _resolveDirectUrlSource(url: string): Promise<MediaSource> {\n\t\t// First try to get metadata using yt-dlp\n\t\ttry {\n\t\t\tconst metadata = await ytdl(url, {\n\t\t\t\tdumpJson: true,\n\t\t\t\tskipDownload: true,\n\t\t\t\tnoWarnings: true,\n\t\t\t\tquiet: true\n\t\t\t}) as YTResponse;\n\n\t\t\t// If yt-dlp succeeds, use the extracted metadata\n\t\t\tif (metadata && metadata.title) {\n\t\t\t\t// Get the best available format URL\n\t\t\t\tlet streamUrl = url;\n\t\t\t\tif (metadata.formats && Array.isArray(metadata.formats) && metadata.formats.length > 0) {\n\t\t\t\t\t// Find the format with both audio and video, preferring higher quality\n\t\t\t\t\tconst bestFormat = metadata.formats\n\t\t\t\t\t\t.filter((format) => format.url && format.ext !== 'm3u8') // Avoid HLS streams\n\t\t\t\t\t\t.sort((a, b) => {\n\t\t\t\t\t\t\t// Prefer formats with both audio and video\n\t\t\t\t\t\t\tconst aScore = (a.vcodec && a.vcodec !== 'none' ? 1 : 0) + (a.acodec && a.acodec !== 'none' ? 1 : 0) + (a.height || 0) / 1000;\n\t\t\t\t\t\t\tconst bScore = (b.vcodec && b.vcodec !== 'none' ? 1 : 0) + (b.acodec && b.acodec !== 'none' ? 1 : 0) + (b.height || 0) / 1000;\n\t\t\t\t\t\t\treturn bScore - aScore;\n\t\t\t\t\t\t})[0];\n\n\t\t\t\t\tif (bestFormat && bestFormat.url) {\n\t\t\t\t\t\tstreamUrl = bestFormat.url;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\treturn {\n\t\t\t\t\turl: streamUrl,\n\t\t\t\t\ttitle: metadata.title,\n\t\t\t\t\ttype: 'url'\n\t\t\t\t};\n\t\t\t}\n\t\t} catch (error) {\n\t\t\t// yt-dlp failed, log debug info and continue to fallback\n\t\t\tlogger.debug(\"yt-dlp failed to extract metadata for URL:\", url, error);\n\t\t}\n\n\t\t// Fallback to original URL parsing logic\n\t\tlet title = \"Direct URL\";\n\t\ttry {\n\t\t\tconst urlObj = new URL(url);\n\t\t\tconst pathname = urlObj.pathname;\n\t\t\tconst filename = pathname.split('/').pop();\n\n\t\t\tif (filename && filename.includes('.')) {\n\t\t\t\ttitle = decodeURIComponent(filename.replace(/\\.[^/.]+$/, \"\"));\n\t\t\t} else if (pathname !== '/' && pathname.length > 1) {\n\t\t\t\tconst pathSegment = pathname.split('/').pop();\n\t\t\t\tif (pathSegment) {\n\t\t\t\t\ttitle = decodeURIComponent(pathSegment);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tlogger.debug(\"Could not parse URL for title extraction:\", url);\n\t\t}\n\n\t\treturn {\n\t\t\turl,\n\t\t\ttitle,\n\t\t\ttype: 'url'\n\t\t};\n\t}\n\n\tpublic async searchYouTube(query: string, limit: number = 5): Promise<string[]> {\n\t\ttry {\n\t\t\treturn await this.youtube.search(query, limit);\n\t\t} catch (error) {\n\t\t\tlogger.error(\"Failed to search YouTube:\", error);\n\t\t\treturn [];\n\t\t}\n\t}\n\n\tpublic async searchAndPlayYouTube(query: string): Promise<MediaSource | null> {\n\t\ttry {\n\t\t\tconst searchResult = await this.youtube.searchAndGetPageUrl(query);\n\t\t\tif (searchResult.pageUrl && searchResult.title) {\n\t\t\t\treturn {\n\t\t\t\t\turl: searchResult.pageUrl,\n\t\t\t\t\ttitle: searchResult.title,\n\t\t\t\t\ttype: 'youtube'\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn null;\n\t\t} catch (error) {\n\t\t\tlogger.error(\"Failed to search and play YouTube:\", error);\n\t\t\treturn null;\n\t\t}\n\t}\n}"
  },
  {
    "path": "src/services/queue.ts",
    "content": "import { VideoQueue, QueueItem, MediaSource } from \"../types/index.js\";\nimport { MediaService } from \"./media.js\";\nimport logger from '../utils/logger.js';\n\nexport class QueueService {\n\tprivate mediaService: MediaService;\n\tprivate queue: VideoQueue;\n\n\tconstructor() {\n\t\tthis.mediaService = new MediaService();\n\t\tthis.queue = {\n\t\t\titems: [],\n\t\t\tcurrentIndex: -1,\n\t\t\tisPlaying: false\n\t\t};\n\t}\n\n\t/**\n\t * Add a video to the queue\n\t */\n\tpublic async addToQueue(\n\t\tmediaSource: MediaSource,\n\t\trequestedBy: string,\n\t\toriginalInput?: string\n\t): Promise<QueueItem> {\n\t\treturn this.add(\n\t\t\tmediaSource.url,\n\t\t\tmediaSource.title,\n\t\t\trequestedBy,\n\t\t\tmediaSource.type,\n\t\t\tmediaSource.isLive,\n\t\t\toriginalInput || mediaSource.url\n\t\t);\n\t}\n\n\t/**\n\t * Add a media source to the queue\n\t */\n\tpublic async add(\n\t\turl: string,\n\t\ttitle: string,\n\t\trequestedBy: string,\n\t\ttype: 'youtube' | 'twitch' | 'local' | 'url' = 'url',\n\t\tisLive: boolean = false,\n\t\toriginalInput?: string\n\t): Promise<QueueItem> {\n\t\tconst queueItem: QueueItem = {\n\t\t\tid: this.generateId(),\n\t\t\turl,\n\t\t\ttitle,\n\t\t\ttype,\n\t\t\tisLive,\n\t\t\trequestedBy,\n\t\t\taddedAt: new Date(),\n\t\t\toriginalInput: originalInput || url,\n\t\t\tresolved: originalInput === url,\n\t\t};\n\n\t\tthis.queue.items.push(queueItem);\n\t\tlogger.info(`Added to queue: ${title} (requested by ${requestedBy}, resolved: ${queueItem.resolved})`);\n\n\t\treturn queueItem;\n\t}\n\n\t/**\n\t * Get the next item in the queue\n\t */\n\tgetNext(): QueueItem | null {\n\t\tif (this.queue.items.length === 0) {\n\t\t\tthis.queue.currentIndex = -1;\n\t\t\treturn null;\n\t\t}\n\n\t\tif (this.queue.currentIndex < this.queue.items.length - 1) {\n\t\t\tthis.queue.currentIndex++;\n\t\t\treturn this.queue.items[this.queue.currentIndex];\n\t\t}\n\n\t\t// No more items, reset currentIndex to -1\n\t\tthis.queue.currentIndex = -1;\n\t\treturn null;\n\t}\n\n\t/**\n\t * Get the current playing item\n\t */\n\tgetCurrent(): QueueItem | null {\n\t\tif (this.queue.items.length === 0) {\n\t\t\tthis.queue.currentIndex = -1;\n\t\t\treturn null;\n\t\t}\n\n\t\tif (this.queue.currentIndex >= 0 && this.queue.currentIndex < this.queue.items.length) {\n\t\t\treturn this.queue.items[this.queue.currentIndex];\n\t\t}\n\n\t\t// If currentIndex is invalid, try to find a valid index\n\t\tif (this.queue.items.length > 0) {\n\t\t\t// If currentIndex is too high, set it to the last item\n\t\t\tif (this.queue.currentIndex >= this.queue.items.length) {\n\t\t\t\tthis.queue.currentIndex = this.queue.items.length - 1;\n\t\t\t\treturn this.queue.items[this.queue.currentIndex];\n\t\t\t}\n\t\t\t// If currentIndex is negative, set it to 0\n\t\t\tif (this.queue.currentIndex < 0) {\n\t\t\t\tthis.queue.currentIndex = 0;\n\t\t\t\treturn this.queue.items[this.queue.currentIndex];\n\t\t\t}\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t/**\n\t * Skip to the next item in the queue\n\t */\n\tskip(): QueueItem | null {\n\t\tconst currentItem = this.getCurrent();\n\t\tif (currentItem) {\n\t\t\t// Remove the current item since we're skipping it\n\t\t\tthis.removeFromQueue(currentItem.id);\n\t\t}\n\n\t\tconst nextItem = this.getNext();\n\n\t\t// Ensure currentIndex is valid after skip\n\t\tif (nextItem && this.queue.currentIndex >= 0) {\n\t\t\t// Verify the current item matches what we expect\n\t\t\tconst verifyCurrent = this.getCurrent();\n\t\t\tif (!verifyCurrent || verifyCurrent.id !== nextItem.id) {\n\t\t\t\tconst correctIndex = this.queue.items.findIndex(item => item.id === nextItem.id);\n\t\t\t\tif (correctIndex !== -1) {\n\t\t\t\t\tthis.queue.currentIndex = correctIndex;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn nextItem;\n\t}\n\n\t/**\n\t * Remove an item from the queue by ID\n\t */\n\tremoveFromQueue(id: string): boolean {\n\t\tconst index = this.queue.items.findIndex(item => item.id === id);\n\t\tif (index !== -1) {\n\t\t\tthis.queue.items.splice(index, 1);\n\n\t\t\t// Adjust current index if necessary\n\t\t\tif (index < this.queue.currentIndex) {\n\t\t\t\t// Removed item was before current item, decrement index\n\t\t\t\tthis.queue.currentIndex--;\n\t\t\t} else if (index === this.queue.currentIndex) {\n\t\t\t\t// Removed the current item itself, decrement index\n\t\t\t\tthis.queue.currentIndex--;\n\t\t\t}\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\t/**\n\t * Clear the entire queue\n\t */\n\tclearQueue(): void {\n\t\tthis.queue.items = [];\n\t\tthis.queue.currentIndex = -1;\n\t\tthis.queue.isPlaying = false;\n\t\tlogger.info('Queue cleared');\n\t}\n\n\t/**\n\t * Reset the current index to -1 (no current item)\n\t */\n\tresetCurrentIndex(): void {\n\t\tthis.queue.currentIndex = -1;\n\t}\n\n\t/**\n\t * Get all items in the queue\n\t */\n\tgetQueue(): QueueItem[] {\n\t\treturn [...this.queue.items];\n\t}\n\n\t/**\n\t * Get the queue status\n\t */\n\tgetQueueStatus(): VideoQueue {\n\t\treturn { ...this.queue };\n\t}\n\n\t/**\n\t * Set the playing state\n\t */\n\tsetPlaying(isPlaying: boolean): void {\n\t\tthis.queue.isPlaying = isPlaying;\n\t}\n\n\t/**\n\t * Check if the queue is empty\n\t */\n\tisEmpty(): boolean {\n\t\treturn this.queue.items.length === 0;\n\t}\n\n\t/**\n\t * Get the queue length\n\t */\n\tgetLength(): number {\n\t\treturn this.queue.items.length;\n\t}\n\n\t/**\n\t * Move an item to a different position in the queue\n\t */\n\tmoveItem(fromIndex: number, toIndex: number): boolean {\n\t\tif (fromIndex < 0 || fromIndex >= this.queue.items.length ||\n\t\t\ttoIndex < 0 || toIndex >= this.queue.items.length) {\n\t\t\treturn false;\n\t\t}\n\n\t\tconst item = this.queue.items.splice(fromIndex, 1)[0];\n\t\tthis.queue.items.splice(toIndex, 0, item);\n\n\t\t// Adjust current index if necessary\n\t\tif (fromIndex === this.queue.currentIndex) {\n\t\t\tthis.queue.currentIndex = toIndex;\n\t\t} else if (fromIndex < this.queue.currentIndex && toIndex >= this.queue.currentIndex) {\n\t\t\tthis.queue.currentIndex--;\n\t\t} else if (fromIndex > this.queue.currentIndex && toIndex <= this.queue.currentIndex) {\n\t\t\tthis.queue.currentIndex++;\n\t\t}\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * Generate a unique ID for queue items\n\t */\n\tprivate generateId(): string {\n\t\treturn Date.now().toString(36) + Math.random().toString(36).substr(2);\n\t}\n}"
  },
  {
    "path": "src/services/streaming.ts",
    "content": "import { Client, Message } from \"discord.js-selfbot-v13\";\nimport { Streamer, Utils, prepareStream, playStream } from \"@dank074/discord-video-stream\";\nimport fs from 'fs';\nimport config from \"../config.js\";\nimport { MediaService } from './media.js';\nimport { QueueService } from './queue.js';\nimport { getVideoParams } from \"../utils/ffmpeg.js\";\nimport logger from '../utils/logger.js';\nimport { DiscordUtils, ErrorUtils } from '../utils/shared.js';\nimport { QueueItem, StreamStatus } from '../types/index.js';\n\nexport class StreamingService {\n \tprivate streamer: Streamer;\n \tprivate mediaService: MediaService;\n \tprivate queueService: QueueService;\n \tprivate controller: AbortController | null = null;\n \tprivate streamStatus: StreamStatus;\n \tprivate failedVideos: Set<string> = new Set();\n \tprivate isSkipping: boolean = false;\n\n \tconstructor(client: Client, streamStatus: StreamStatus) {\n \t\tthis.streamer = new Streamer(client);\n \t\tthis.mediaService = new MediaService();\n \t\tthis.queueService = new QueueService();\n \t\tthis.streamStatus = streamStatus;\n \t}\n\n\tpublic getStreamer(): Streamer {\n\t\treturn this.streamer;\n\t}\n\n\tpublic getQueueService(): QueueService {\n\t\treturn this.queueService;\n\t}\n\n\tprivate markVideoAsFailed(videoSource: string): void {\n\t\tthis.failedVideos.add(videoSource);\n\t\tlogger.info(`Marked video as failed: ${videoSource}`);\n\t}\n\n\tpublic async addToQueue(\n\t\tmessage: Message,\n\t\tvideoSource: string,\n\t\ttitle?: string\n\t): Promise<boolean> {\n\t\ttry {\n\t\t\tconst username = message.author.username;\n\t\t\tconst mediaSource = await this.mediaService.resolveMediaSource(videoSource);\n\n\t\t\tif (mediaSource) {\n\t\t\t\tconst queueItem = await this.queueService.addToQueue(mediaSource, username);\n\t\t\t\tawait DiscordUtils.sendSuccess(message, `Added to queue: \\`${queueItem.title}\\``);\n\t\t\t\treturn true;\n\t\t\t} else {\n\t\t\t\t// Fallback for unresolved sources\n\t\t\t\tconst queueItem = await this.queueService.add(\n\t\t\t\t\tvideoSource,\n\t\t\t\t\ttitle || videoSource,\n\t\t\t\t\tusername,\n\t\t\t\t\t'url',\n\t\t\t\t\tfalse,\n\t\t\t\t\tvideoSource\n\t\t\t\t);\n\t\t\t\tawait DiscordUtils.sendSuccess(message, `Added to queue: \\`${queueItem.title}\\``);\n\t\t\t\treturn true;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tawait ErrorUtils.handleError(error, `adding to queue: ${videoSource}`, message);\n\t\t\treturn false;\n\t\t}\n\t}\n\n\n\tpublic async playFromQueue(message: Message): Promise<void> {\n\t\tif (this.streamStatus.playing) {\n\t\t\tawait DiscordUtils.sendError(message, 'Already playing a video. Use skip command to skip current video.');\n\t\t\treturn;\n\t\t}\n\n\t\tconst nextItem = this.queueService.getNext();\n\t\tif (!nextItem) {\n\t\t\tawait DiscordUtils.sendError(message, 'Queue is empty.');\n\t\t\treturn;\n\t\t}\n\n\t\tthis.queueService.setPlaying(true);\n\t\tawait this.playVideoFromQueueItem(message, nextItem);\n\t}\n\n\tpublic async skipCurrent(message: Message): Promise<void> {\n\t\tif (!this.streamStatus.playing) {\n\t\t\tawait DiscordUtils.sendError(message, 'No video is currently playing.');\n\t\t\treturn;\n\t\t}\n\n\t\t// Check if this is the last item in the queue\n\t\tconst queueLength = this.queueService.getLength();\n\t\tconst isLastItem = queueLength <= 1;\n\n\t\t// Prevent concurrent skip operations only if there are more items in queue\n\t\tif (this.isSkipping && !isLastItem) {\n\t\t\tawait DiscordUtils.sendError(message, 'Skip already in progress.');\n\t\t\treturn;\n\t\t}\n\n\t\tthis.isSkipping = true;\n\n\t\ttry {\n\t\t\t// Stop the current stream immediately\n\t\t\tthis.streamStatus.manualStop = true;\n\t\t\tthis.controller?.abort();\n\t\t\tthis.streamer.stopStream();\n\n\t\t\tconst currentItem = this.queueService.getCurrent(); // Get item being skipped\n\t\t\tconst nextItem = this.queueService.skip(); // Advance the queue\n\n\t\t\tif (!nextItem) {\n\t\t\t\t// No more items in queue - stop playback and leave voice channel\n\t\t\t\tawait DiscordUtils.sendInfo(message, 'Queue', 'No more videos in queue.');\n\t\t\t\tthis.queueService.setPlaying(false);\n\t\t\t\tawait this.cleanupStreamStatus();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst currentTitle = currentItem ? currentItem.title : 'current video';\n\t\t\tawait DiscordUtils.sendInfo(message, 'Skipping', `Skipping \\`${currentTitle}\\`. Playing next: \\`${nextItem.title}\\``);\n\n\t\t\t// Reset manual stop flag since we're starting a new video\n\t\t\tthis.streamStatus.manualStop = false;\n\n\t\t\t// Skip cleanup since we're playing the next item immediately\n\t\t\tawait this.playVideoFromQueueItem(message, nextItem);\n\t\t} finally {\n\t\t\tthis.isSkipping = false;\n\t\t}\n\t}\n\n\tprivate async playVideoFromQueueItem(message: Message, queueItem: QueueItem): Promise<void> {\n\t\t// Ensure queue is marked as playing\n\t\tthis.queueService.setPlaying(true);\n\n\t\t// Collect video parameters if respect_video_params is enabled\n\t\tlet videoParams = undefined;\n\t\tif (config.respect_video_params) {\n\t\t\tvideoParams = await this.getVideoParameters(queueItem.url);\n\t\t}\n\n\t\t// Log playing video\n\t\tlogger.info(`Playing from queue: ${queueItem.title} (${queueItem.url})`);\n\n\t\t// Use streaming service to play the video with video parameters\n\t\tawait this.playVideo(message, queueItem.url, queueItem.title, videoParams);\n\t}\n\n\tprivate async getVideoParameters(videoUrl: string): Promise<{ width: number, height: number, fps?: number, bitrate?: number } | undefined> {\n\t\ttry {\n\t\t\tconst resolution = await getVideoParams(videoUrl);\n\t\t\tlogger.info(`Video parameters: ${resolution.width}x${resolution.height}, FPS: ${resolution.fps || 'unknown'}, Bitrate: ${resolution.bitrate || 'unknown'}`);\n\t\t\t\n\t\t\tlet bitrateKbps: number | undefined;\n\t\t\tif (resolution.bitrate) {\n\t\t\t\tbitrateKbps = Math.round(parseInt(resolution.bitrate) / 1000);\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\twidth: resolution.width,\n\t\t\t\theight: resolution.height,\n\t\t\t\tfps: resolution.fps,\n\t\t\t\tbitrate: bitrateKbps\n\t\t\t};\n\t\t} catch (error) {\n\t\t\tawait ErrorUtils.handleError(error, 'determining video parameters');\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tprivate async ensureVoiceConnection(guildId: string, channelId: string, title?: string): Promise<void> {\n\t\t// Only join voice if not already connected\n\t\tif (!this.streamStatus.joined || !this.streamer.voiceConnection) {\n\t\t\tawait this.streamer.joinVoice(guildId, channelId);\n\t\t\tthis.streamStatus.joined = true;\n\t\t}\n\t\tthis.streamStatus.playing = true;\n\t\tthis.streamStatus.channelInfo = { guildId, channelId, cmdChannelId: config.cmdChannelId! };\n\n\t\tif (title) {\n\t\t\tthis.streamer.client.user?.setActivity(DiscordUtils.status_watch(title));\n\t\t}\n\n\t\t// Wait for voice connection to be fully ready\n\t\tawait new Promise(resolve => setTimeout(resolve, 2000));\n\n\t\t// Verify voice connection exists\n\t\tif (!this.streamer.voiceConnection) {\n\t\t\tthrow new Error('Voice connection is not established');\n\t\t}\n\t}\n\n\tprivate setupStreamConfiguration(videoParams?: { width: number, height: number, fps?: number, bitrate?: number }): any {\n\t\tlet width = videoParams?.width || config.width;\n\t\tlet height = videoParams?.height || config.height;\n\t\tlet frameRate = videoParams?.fps || config.fps;\n\t\tlet bitrateVideo = config.bitrateKbps;\n\n\t\t// If respecting video params, use video bitrate unless overridden\n\t\tif (videoParams && videoParams.bitrate && !config.bitrateOverride) {\n\t\t\tbitrateVideo = videoParams.bitrate;\n\t\t}\n\n\t\t// Resolution capping\n\t\tif (config.maxWidth > 0 || config.maxHeight > 0) {\n\t\t\tconst ratio = width / height;\n\t\t\tif (config.maxWidth > 0 && width > config.maxWidth) {\n\t\t\t\twidth = config.maxWidth;\n\t\t\t\theight = Math.round(width / ratio);\n\t\t\t}\n\t\t\tif (config.maxHeight > 0 && height > config.maxHeight) {\n\t\t\t\theight = config.maxHeight;\n\t\t\t\twidth = Math.round(height * ratio);\n\t\t\t}\n\t\t\t// Ensure even dimensions\n\t\t\twidth = Math.round(width / 2) * 2;\n\t\t\theight = Math.round(height / 2) * 2;\n\t\t}\n\n\t\treturn {\n\t\t\twidth,\n\t\t\theight,\n\t\t\tframeRate,\n\t\t\tbitrateVideo,\n\t\t\tbitrateVideoMax: config.maxBitrateKbps,\n\t\t\tvideoCodec: Utils.normalizeVideoCodec(config.videoCodec),\n\t\t\thardwareAcceleratedDecoding: config.hardwareAcceleratedDecoding,\n\t\t\tminimizeLatency: false,\n\t\t\th26xPreset: config.h26xPreset\n\t\t};\n\t}\n\n\tprivate async executeStream(inputForFfmpeg: any, streamOpts: any, message: Message, title: string, videoSource: string): Promise<void> {\n\t\tconst { command, output: ffmpegOutput } = prepareStream(inputForFfmpeg, streamOpts, this.controller!.signal);\n\n\t\tcommand.on(\"error\", (err, stdout, stderr) => {\n\t\t\t// Don't log error if it's due to manual stop\n\t\t\tif (!this.streamStatus.manualStop && this.controller && !this.controller.signal.aborted) {\n\t\t\t\tlogger.error(\"An error happened with ffmpeg:\", err.message);\n\t\t\t\tif (stdout) {\n\t\t\t\t\tlogger.error(\"ffmpeg stdout:\", stdout);\n\t\t\t\t}\n\t\t\t\tif (stderr) {\n\t\t\t\t\tlogger.error(\"ffmpeg stderr:\", stderr);\n\t\t\t\t}\n\t\t\t\tthis.controller.abort();\n\t\t\t}\n\t\t});\n\n\t\tawait playStream(ffmpegOutput, this.streamer, undefined, this.controller!.signal)\n\t\t\t.catch((err) => {\n\t\t\t\tif (this.controller && !this.controller.signal.aborted) {\n\t\t\t\t\tlogger.error('playStream error:', err);\n\t\t\t\t\t// Send error message to user\n\t\t\t\t\tDiscordUtils.sendError(message, `Stream error: ${err.message || 'Unknown error'}`).catch(e =>\n\t\t\t\t\t\tlogger.error('Failed to send error message:', e)\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tif (this.controller && !this.controller.signal.aborted) this.controller.abort();\n\t\t\t});\n\n\t\t// Only log as finished if we didn't have an error and weren't manually stopped\n\t\tif (this.controller && !this.controller.signal.aborted && !this.streamStatus.manualStop) {\n\t\t\tlogger.info(`Finished playing: ${title || videoSource}`);\n\t\t} else if (this.streamStatus.manualStop) {\n\t\t\tlogger.info(`Stopped playing: ${title || videoSource}`);\n\t\t} else {\n\t\t\tlogger.info(`Failed playing: ${title || videoSource}`);\n\t\t}\n\t}\n\n\tprivate async handleQueueAdvancement(message: Message): Promise<void> {\n\t\tawait DiscordUtils.sendFinishMessage(message);\n\n\t\t// The video finished playing, so remove it from the queue\n\t\tconst finishedItem = this.queueService.getCurrent();\n\t\tif (finishedItem) {\n\t\t\tthis.queueService.removeFromQueue(finishedItem.id);\n\t\t}\n\n\t\t// Get the next item in the queue.\n\t\tconst nextItem = this.queueService.getNext();\n\n\t\tif (nextItem) {\n\t\t\tlogger.info(`Auto-playing next item from queue: ${nextItem.title}`);\n\t\t\tsetTimeout(() => {\n\t\t\t\tthis.playVideoFromQueueItem(message, nextItem).catch(err =>\n\t\t\t\t\tErrorUtils.handleError(err, 'auto-playing next item')\n\t\t\t\t);\n\t\t\t}, 1000);\n\t\t} else {\n\t\t\t// No more items in the queue, so stop playback and clean up\n\t\t\tthis.queueService.setPlaying(false);\n\t\t\tlogger.info('No more items in queue, playback stopped');\n\t\t\tawait this.cleanupStreamStatus();\n\t\t}\n\t}\n\n\tprivate async handleDownload(message: Message, videoSource: string, title?: string): Promise<string | null> {\n\t\tconst downloadMessage = await message.reply(`📥 Downloading \\`${title || 'YouTube video'}\\`...`).catch(e => {\n\t\t\tlogger.warn(\"Failed to send 'Downloading...' message:\", e);\n\t\t\treturn null;\n\t\t});\n\n\t\ttry {\n\t\t\tlogger.info(`Downloading ${title || videoSource}...`);\n\t\t\tconst tempFilePath = await this.mediaService.downloadYouTubeVideo(videoSource);\n\n\t\t\tif (tempFilePath) {\n\t\t\t\tlogger.info(`Finished downloading ${title || videoSource}`);\n\t\t\t\tif (downloadMessage) {\n\t\t\t\t\tawait downloadMessage.delete().catch(e => logger.warn(\"Failed to delete 'Downloading...' message:\", e));\n\t\t\t\t}\n\t\t\t\treturn tempFilePath;\n\t\t\t}\n\t\t\tthrow new Error('Download failed, no temp file path returned.');\n\t\t} catch (error) {\n\t\t\tlogger.error(`Failed to download YouTube video: ${videoSource}`, error);\n\t\t\tconst errorMessage = `❌ Failed to download \\`${title || 'YouTube video'}\\`.`;\n\t\t\tif (downloadMessage) {\n\t\t\t\tawait downloadMessage.edit(errorMessage).catch(e => logger.warn(\"Failed to edit 'Downloading...' message:\", e));\n\t\t\t} else {\n\t\t\t\tawait DiscordUtils.sendError(message, `Failed to download video: ${error instanceof Error ? error.message : String(error)}`);\n\t\t\t}\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tprivate async prepareVideoSource(message: Message, videoSource: string, title?: string): Promise<{ inputForFfmpeg: any, tempFilePath: string | null }> {\n\t\tconst mediaSource = await this.mediaService.resolveMediaSource(videoSource);\n\n\t\tif (mediaSource && mediaSource.type === 'youtube' && !mediaSource.isLive) {\n\t\t\tconst tempFilePath = await this.handleDownload(message, videoSource, title);\n\t\t\tif (tempFilePath) {\n\t\t\t\treturn { inputForFfmpeg: tempFilePath, tempFilePath };\n\t\t\t}\n\t\t\t// Download failed, throw to stop playback\n\t\t\tthrow new Error('Failed to prepare video source due to download failure.');\n\t\t}\n\n\t\treturn { inputForFfmpeg: mediaSource ? mediaSource.url : videoSource, tempFilePath: null };\n\t}\n\n\tprivate async executeStreamWorkflow(input: any, options: any, message: Message, title: string, source: string): Promise<void> {\n\t\tthis.controller = new AbortController();\n\t\tawait this.executeStream(input, options, message, title, source);\n\t}\n\n\tprivate async finalizeStream(message: Message, tempFile: string | null): Promise<void> {\n\t\tif (!this.streamStatus.manualStop && this.controller && !this.controller.signal.aborted) {\n\t\t\tawait this.handleQueueAdvancement(message);\n\t\t} else {\n\t\t\tthis.queueService.setPlaying(false);\n\t\t\tthis.queueService.resetCurrentIndex();\n\t\t\tawait this.cleanupStreamStatus();\n\t\t}\n\n\t\tif (tempFile) {\n\t\t\ttry {\n\t\t\t\tfs.unlinkSync(tempFile);\n\t\t\t} catch (cleanupError) {\n\t\t\t\tlogger.error(`Failed to delete temp file ${tempFile}:`, cleanupError);\n\t\t\t}\n\t\t}\n\t}\n\n\tpublic async playVideo(message: Message, videoSource: string, title?: string, videoParams?: { width: number, height: number, fps?: number, bitrate?: number }): Promise<void> {\n\t\tconst [guildId, channelId] = [config.guildId, config.videoChannelId];\n\t\tthis.streamStatus.manualStop = false;\n\n\t\tif (title) {\n\t\t\tconst currentQueueItem = this.queueService.getCurrent();\n\t\t\tif (currentQueueItem?.title === title) {\n\t\t\t\tthis.queueService.setPlaying(true);\n\t\t\t}\n\t\t}\n\n\t\tlet tempFile: string | null = null;\n\t\ttry {\n\t\t\tconst { inputForFfmpeg, tempFilePath } = await this.prepareVideoSource(message, videoSource, title);\n\t\t\ttempFile = tempFilePath;\n\n\t\t\tawait this.ensureVoiceConnection(guildId, channelId, title);\n\t\t\tawait DiscordUtils.sendPlaying(message, title || videoSource);\n\n\t\t\tconst streamOpts = this.setupStreamConfiguration(videoParams);\n\t\t\tawait this.executeStreamWorkflow(inputForFfmpeg, streamOpts, message, title || videoSource, videoSource);\n\t\t} catch (error) {\n\t\t\tawait ErrorUtils.handleError(error, `playing video: ${title || videoSource}`);\n\t\t\tif (this.controller && !this.controller.signal.aborted) this.controller.abort();\n\t\t\tthis.markVideoAsFailed(videoSource);\n\t\t} finally {\n\t\t\tawait this.finalizeStream(message, tempFile);\n\t\t}\n\t}\n\n\tpublic async cleanupStreamStatus(): Promise<void> {\n\t\ttry {\n\t\t\tthis.controller?.abort();\n\t\t\tthis.streamer.stopStream();\n\n\t\t\t// Only leave voice if we're not playing another video\n\t\t\t// Check if there are items in queue that might be played\n\t\t\tconst hasQueueItems = !this.queueService.isEmpty();\n\t\t\tif (!hasQueueItems) {\n\t\t\t\tthis.streamer.leaveVoice();\n\t\t\t\tthis.streamStatus.joined = false;\n\t\t\t\tthis.streamStatus.joinsucc = false;\n\t\t\t}\n\n\t\t\tthis.streamer.client.user?.setActivity(DiscordUtils.status_idle());\n\n\t\t\t// Reset all status flags\n\t\t\tthis.streamStatus.playing = false;\n\t\t\tthis.streamStatus.manualStop = false;\n\t\t\tthis.streamStatus.channelInfo = {\n\t\t\t\tguildId: \"\",\n\t\t\t\tchannelId: \"\",\n\t\t\t\tcmdChannelId: \"\",\n\t\t\t};\n\t\t} catch (error) {\n\t\t\tawait ErrorUtils.handleError(error, \"cleanup stream status\");\n\t\t}\n\t}\n\n\tpublic async stopAndClearQueue(): Promise<void> {\n\t\t// Clear the queue\n\t\tthis.queueService.clearQueue();\n\t\tlogger.info(\"Queue cleared by stop command\");\n\n\t\t// Then cleanup the stream\n\t\tawait this.cleanupStreamStatus();\n\t}\n\n}"
  },
  {
    "path": "src/types/index.ts",
    "content": "export interface VideoFormat {\n\thasVideo: boolean;\n\thasAudio: boolean;\n\turl: string;\n\tbitrate?: number;\n\tqualityLabel?: string;\n\tcontainer?: string;\n\tisLiveContent?: boolean;\n}\n\nexport interface YouTubeVideo {\n\tid?: string;\n\ttitle: string;\n\tformats: VideoFormat[];\n\tvideoDetails?: {\n\t\tisLiveContent: boolean;\n\t};\n}\n\nexport interface TwitchStream {\n\tquality: string;\n\tresolution: string;\n\turl: string;\n}\n\nexport interface YTFormat {\n\tasr: number,\n\tfilesize: number,\n\tformat_id: string,\n\tformat_note: string,\n\tfps: number,\n\theight: number,\n\tquality: number,\n\ttbr: number,\n\tvbr?: number,\n\turl: string,\n\twidth: number,\n\text: string,\n\tvcodec: string,\n\tacodec: string,\n\tabr: number,\n\tdownloader_options: unknown,\n\tcontainer: string,\n\tformat: string,\n\tprotocol: string,\n\thttp_headers: unknown\n}\n\nexport interface YTThumbnail {\n\theight: number,\n\turl: string,\n\twidth: number,\n\tresolution: string,\n\tid: string,\n}\n\nexport interface YTResponse {\n\tid: string,\n\ttitle: string,\n\tformats: YTFormat[],\n\tthumbnails: YTThumbnail[],\n\tdescription: string,\n\tupload_date: string,\n\tuploader: string,\n\tuploader_id: string,\n\tuploader_url: string,\n\tchannel_id: string,\n\tchannel_url: string,\n\tduration: number,\n\tview_count: number,\n\taverage_rating: number,\n\tage_limit: number,\n\twebpage_url: string,\n\tcategories: string[],\n\ttags: string[],\n\tis_live: boolean,\n\tlike_count: number,\n\tdislike_count: number,\n\tchannel: string,\n\ttrack: string,\n\tartist: string,\n\tcreator: string,\n\talt_title: string,\n\textractor: string,\n\twebpage_url_basename: string,\n\textractor_key: string,\n\tplaylist: string,\n\tplaylist_index: number,\n\tthumbnail: string,\n\tdisplay_id: string,\n\trequested_subtitles: unknown,\n\tasr: number,\n\tfilesize: number,\n\tformat_id: string,\n\tformat_note: string,\n\tfps: number,\n\theight: number,\n\tquality: number,\n\ttbr: number,\n\turl: string,\n\twidth: number,\n\text: string,\n\tvcodec: string,\n\tacodec: string,\n\tabr: number,\n\tdownloader_options: { http_chunk_size: number },\n\tcontainer: string,\n\tformat: string,\n\tprotocol: string,\n\thttp_headers: unknown,\n\tfulltitle: string,\n\t_filename: string\n}\n\nexport interface YTFlags {\n\thelp?: boolean,\n\tversion?: boolean,\n\tupdate?: boolean,\n\tignoreErrors?: boolean,\n\tabortOnError?: boolean,\n\tdumpUserAgent?: boolean,\n\tlistExtractors?: boolean,\n\textractorDescriptions?: boolean,\n\tforceGenericExtractor?: boolean,\n\tdefaultSearch?: string,\n\tignoreConfig?: boolean,\n\tconfigLocation?: string,\n\tflatPlaylist?: boolean,\n\tmarkWatched?: boolean,\n\tnoColor?: boolean,\n\tproxy?: string,\n\tsocketTimeout?: number,\n\tsourceAddress?: string,\n\tforceIpv4?: boolean,\n\tforceIpv6?: boolean,\n\tgeoVerificationProxy?: string,\n\tgeoBypass?: boolean,\n\tgeoBypassCountry?: string,\n\tgeoBypassIpBlock?: string,\n\tplaylistStart?: number,\n\tplaylistEnd?: number | \"last\",\n\tplaylistItems?: string,\n\tmatchTitle?: string,\n\trejectTitle?: string,\n\tmaxDownloads?: number,\n\tminFilesize?: string,\n\tmaxFilesize?: string,\n\tdate?: string,\n\tdatebefore?: string,\n\tdateafter?: string,\n\tminViews?: number,\n\tmaxViews?: number,\n\tmatchFilter?: string,\n\tnoPlaylist?: boolean,\n\tyesPlaylist?: boolean,\n\tageLimit?: number,\n\tdownloadArchive?: string,\n\tincludeAds?: boolean,\n\tlimitRate?: string,\n\tretries?: number | \"infinite\",\n\tskipUnavailableFragments?: boolean,\n\tabortOnUnavailableFragment?: boolean,\n\tkeepFragments?: boolean,\n\tbufferSize?: string,\n\tnoResizeBuffer?: boolean,\n\thttpChunkSize?: string,\n\tplaylistReverse?: boolean,\n\tplaylistRandom?: boolean,\n\txattrSetFilesize?: boolean,\n\thlsPreferNative?: boolean,\n\thlsPreferFfmpeg?: boolean,\n\thlsUseMpegts?: boolean,\n\texternalDownloader?: string,\n\texternalDownloaderArgs?: string,\n\tbatchFile?: string,\n\tid?: boolean,\n\toutput?: string,\n\toutputNaPlaceholder?: string,\n\tautonumberStart?: number,\n\trestrictFilenames?: boolean,\n\tnoOverwrites?: boolean,\n\tcontinue?: boolean,\n\tnoPart?: boolean,\n\tnoMtime?: boolean,\n\twriteDescription?: boolean,\n\twriteInfoJson?: boolean,\n\twriteAnnotations?: boolean,\n\tloadInfoJson?: string,\n\tcookies?: string,\n\tcacheDir?: string,\n\tnoCacheDir?: boolean,\n\trmCacheDir?: boolean,\n\twriteThumbnail?: boolean,\n\twriteAllThumbnails?: boolean,\n\tlistThumbnails?: boolean,\n\tquiet?: boolean,\n\tnoWarnings?: boolean,\n\tsimulate?: boolean,\n\tskipDownload?: boolean,\n\tgetUrl?: boolean,\n\tgetTitle?: boolean,\n\tgetId?: boolean,\n\tgetThumbnail?: boolean,\n\tgetDuration?: boolean,\n\tgetFilename?: boolean,\n\tgetFormat?: boolean,\n\tdumpJson?: boolean,\n\tdumpSingleJson?: boolean,\n\tprintJson?: boolean,\n\tnewline?: boolean,\n\tnoProgress?: boolean,\n\tconsoleTitle?: boolean,\n\tverbose?: boolean,\n\tdumpPages?: boolean,\n\twritePages?: boolean,\n\tprintTraffic?: boolean,\n\tcallHome?: boolean,\n\tencoding?: string,\n\tnoCheckCertificate?: boolean,\n\tpreferInsecure?: boolean,\n\tuserAgent?: string,\n\treferer?: string,\n\taddHeader?: string,\n\tbidiWorkaround?: boolean,\n\tsleepInterval?: number,\n\tmaxSleepInterval?: number,\n\tformat?: string,\n\tallFormats?: boolean,\n\tpreferFreeFormats?: boolean,\n\tlistFormats?: boolean,\n\tyoutubeSkipDashManifest?: boolean,\n\tmergeOutputFormat?: string,\n\twriteSub?: boolean,\n\twriteAutoSub?: boolean,\n\tallSubs?: boolean,\n\tlistSubs?: boolean,\n\tsubFormat?: string,\n\tsubLang?: string,\n\tusername?: string,\n\tpassword?: string,\n\ttwofactor?: string,\n\tnetrc?: boolean,\n\tvideoPassword?: string,\n\tapMso?: string,\n\tapUsername?: string,\n\tapPassword?: string,\n\tapListMso?: boolean,\n\textractAudio?: boolean,\n\taudioFormat?: string,\n\taudioQuality?: number,\n\trecodeVideo?: string,\n\tpostprocessorArgs?: string,\n\tkeepVideo?: boolean,\n\tnoPostOverwrites?: boolean,\n\tembedSubs?: boolean,\n\tembedThumbnail?: boolean,\n\taddMetadata?: boolean,\n\tmetadataFromFile?: string,\n\txattrs?: boolean,\n\tfixup?: string,\n\tpreferAvconv?: boolean,\n\tpreferFfmpeg?: boolean,\n\tffmpegLocation?: string,\n\texec?: string,\n\tconvertSubs?: string\n}\n\nexport interface CommandContext {\n\tmessage: any;\n\targs: string[];\n\tvideos: Video[];\n\tstreamStatus: StreamStatus;\n\tstreamingService: any;\n}\n\nexport interface StreamStatus {\n \tjoined: boolean;\n \tjoinsucc: boolean;\n \tplaying: boolean;\n \tmanualStop: boolean;\n \tchannelInfo: {\n \t\tguildId: string;\n \t\tchannelId: string;\n \t\tcmdChannelId: string;\n \t};\n \tqueue: VideoQueue;\n}\n\nexport interface Video {\n\tname: string;\n\tpath: string;\n}\n\nexport interface Command {\n\tname: string;\n\tdescription: string;\n\tusage: string;\n\taliases?: string[];\n\texecute(context: CommandContext): Promise<void>;\n}\n\nexport interface MediaSource {\n \turl: string;\n \ttitle: string;\n \ttype: 'youtube' | 'twitch' | 'local' | 'url';\n \tisLive?: boolean;\n}\n\nexport interface QueueItem {\n  \tid: string;\n  \turl: string;\n  \ttitle: string;\n  \ttype: MediaSource['type'];\n  \tisLive?: boolean;\n  \trequestedBy: string;\n  \taddedAt: Date;\n  \toriginalInput?: string;\n  \tresolved?: boolean;\n}\n\nexport interface VideoQueue {\n  items: QueueItem[];\n  currentIndex: number;\n  isPlaying: boolean;\n}"
  },
  {
    "path": "src/utils/ffmpeg.ts",
    "content": "import config from \"../config.js\";\nimport ffmpeg from \"fluent-ffmpeg\"\nimport logger from \"./logger.js\";\n\nconst ffmpegRunning: { [key: string]: boolean } = {};\n\nexport async function ffmpegScreenshot(video: string): Promise<string[]> {\n\treturn new Promise<string[]>((resolve, reject) => {\n\t\tif (ffmpegRunning[video]) {\n\t\t\t// Wait for ffmpeg to finish\n\t\t\tconst wait = (images: string[]) => {\n\t\t\t\tif (ffmpegRunning[video] == false) {\n\t\t\t\t\tresolve(images);\n\t\t\t\t}\n\t\t\t\tsetTimeout(() => wait(images), 100);\n\t\t\t}\n\t\t\twait([]);\n\t\t\treturn;\n\t\t}\n\t\tffmpegRunning[video] = true;\n\t\tconst ts = ['10%', '30%', '50%', '70%', '90%'];\n\t\tconst images: string[] = [];\n\n\t\tconst takeScreenshots = (i: number) => {\n\t\t\tif (i >= ts.length) {\n\t\t\t\tffmpegRunning[video] = false;\n\t\t\t\tresolve(images);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tffmpeg(`${config.videosDir}/${video}`)\n\t\t\t\t.on(\"end\", () => {\n\t\t\t\t\tconst screenshotPath = `${config.previewCacheDir}/${video}-${i + 1}.jpg`;\n\t\t\t\t\timages.push(screenshotPath);\n\t\t\t\t\ttakeScreenshots(i + 1);\n\t\t\t\t})\n\t\t\t\t.on(\"error\", (err: Error) => {\n\t\t\t\t\tffmpegRunning[video] = false;\n\t\t\t\t\treject(err);\n\t\t\t\t})\n\t\t\t\t.screenshots({\n\t\t\t\t\tcount: 1,\n\t\t\t\t\tfilename: `${video}-${i + 1}.jpg`,\n\t\t\t\t\ttimestamps: [ts[i]],\n\t\t\t\t\tfolder: config.previewCacheDir\n\t\t\t\t});\n\t\t};\n\n\t\ttakeScreenshots(0);\n\t});\n}\n\n// Checking video params\nexport async function getVideoParams(videoPath: string): Promise<{ width: number, height: number, bitrate: string, maxbitrate: string, fps: number }> {\n\treturn new Promise((resolve, reject) => {\n\t\tffmpeg.ffprobe(videoPath, (err, metadata) => {\n\t\t\tif (err) {\n\t\t\t\treturn reject(err);\n\t\t\t}\n\n\t\t\tconst videoStream = metadata.streams.find(stream => stream.codec_type === 'video');\n\n\t\t\tif (videoStream && videoStream.width && videoStream.height && videoStream.bit_rate) {\n\t\t\t\tconst rFrameRate = videoStream.r_frame_rate || videoStream.avg_frame_rate;\n\n\t\t\t\tif (rFrameRate) {\n\t\t\t\t\tconst [numerator, denominator] = rFrameRate.split('/').map(Number);\n\t\t\t\t\tvideoStream.fps = numerator / denominator;\n\t\t\t\t} else {\n\t\t\t\t\tvideoStream.fps = 0\n\t\t\t\t}\n\n\t\t\t\tresolve({ width: videoStream.width, height: videoStream.height, bitrate: videoStream.bit_rate, maxbitrate: videoStream.maxBitrate, fps: videoStream.fps });\n\t\t\t} else {\n\t\t\t\treject(new Error('Unable to get Resolution.'));\n\t\t\t}\n\t\t});\n\t});\n}\n\n"
  },
  {
    "path": "src/utils/gen-hash.ts",
    "content": "import * as bcrypt from 'bcrypt';\r\nimport argon2 from 'argon2';\r\n\r\nconst password = process.argv[2];\r\nconst hashType = process.argv[3] || 'argon2';\r\n\r\nif (!password) {\r\n\tconsole.error('Usage: bun/node run gen-hash <password> [type]');\r\n    console.error('\\tExample: bun/node run gen-hash mySecurePassword123 argon2');\r\n\tconsole.error('\\tExample: bun/node run gen-hash mySecurePassword123 bcrypt');\r\n\tconsole.error('\\nSupported types: argon2 (default), bcrypt');\r\n\tprocess.exit(1);\r\n}\r\n\r\nif (hashType !== 'argon2' && hashType !== 'bcrypt') {\r\n\tconsole.error(`Error: Invalid hash type \"${hashType}\"`);\r\n\tconsole.error('Supported types: argon2, bcrypt');\r\n\tprocess.exit(1);\r\n}\r\n\r\nlet hash: string;\r\n\r\nif (hashType === 'argon2') {\r\n\thash = await argon2.hash(password, {\r\n\t\ttype: argon2.argon2id,\r\n\t\tmemoryCost: 65536, // 64 MB\r\n\t\ttimeCost: 3,\r\n\t\tparallelism: 4\r\n\t});\r\n\tconsole.log('\\n✅ Argon2 hash generated successfully!\\n');\r\n} else {\r\n\tconst saltRounds = 10;\r\n\thash = bcrypt.hashSync(password, saltRounds);\r\n\tconsole.log('\\n✅ Bcrypt hash generated successfully!\\n');\r\n}\r\n\r\nconst escapedHash = hash.replace(/\\$/g, '\\\\$'); \r\nconsole.log('Add this to your .env file:');\r\nconsole.log(`SERVER_PASSWORD = \"${escapedHash}\"`);\r\nconsole.log('\\nRaw hash:');\r\nconsole.log(escapedHash);\r\n"
  },
  {
    "path": "src/utils/logger.ts",
    "content": "import winston from 'winston';\n\n// Custom log format\nconst logFormat = winston.format.combine(\n\twinston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),\n\twinston.format.colorize(),\n\twinston.format.printf(({ level, message, timestamp }) => {\n\t\treturn `[${timestamp}] ${level}: ${message}`;\n\t})\n);\n\n// Create logger instance\nconst logger = winston.createLogger({\n\tlevel: 'info',\n\tformat: logFormat,\n\ttransports: [\n\t\t// Console output\n\t\tnew winston.transports.Console()\n\t]\n});\n\nexport default logger;\n"
  },
  {
    "path": "src/utils/shared.ts",
    "content": "import { Message, ActivityOptions } from \"discord.js-selfbot-v13\";\nimport config from \"../config.js\";\nimport logger from \"./logger.js\";\nimport fs from 'fs';\n\n/**\n * Shared utility functions for Discord bot operations\n */\nexport const DiscordUtils = {\n\t/**\n\t * Create idle status for Discord bot\n\t */\n\tstatus_idle(): ActivityOptions {\n\t\treturn {\n\t\t\tname: config.prefix + \"help\",\n\t\t\ttype: 'WATCHING'\n\t\t};\n\t},\n\n\t/**\n\t * Create watching status for Discord bot\n\t */\n\tstatus_watch(name: string): ActivityOptions {\n\t\treturn {\n\t\t\tname: `${name}`,\n\t\t\ttype: 'WATCHING'\n\t\t};\n\t},\n\n\t/**\n\t * Send error message with reaction\n\t */\n\tasync sendError(message: Message, error: string): Promise<void> {\n\t\tawait message.react('❌');\n\t\tawait message.reply(`❌ **Error**: ${error}`);\n\t},\n\n\t/**\n\t * Send success message with reaction\n\t */\n\tasync sendSuccess(message: Message, description: string): Promise<void> {\n\t\tawait message.react('✅');\n\t\tawait message.channel.send(`✅ **Success**: ${description}`);\n\t},\n\n\t/**\n\t * Send info message with reaction\n\t */\n\tasync sendInfo(message: Message, title: string, description: string): Promise<void> {\n\t\tawait message.react('ℹ️');\n\t\tawait message.channel.send(`ℹ️ **${title}**: ${description}`);\n\t},\n\n\t/**\n\t * Send playing message with reaction\n\t */\n\tasync sendPlaying(message: Message, title: string): Promise<void> {\n\t\tconst content = `📽 **Now Playing**: \\`${title}\\``;\n\t\tawait Promise.all([\n\t\t\tmessage.react('▶️'),\n\t\t\tmessage.reply(content)\n\t\t]);\n\t},\n\n\t/**\n\t * Send finish message\n\t */\n\tasync sendFinishMessage(message: Message): Promise<void> {\n\t\tconst content = '⏹️ **Finished**: Finished playing video.';\n\t\tawait message.channel.send(content);\n\t},\n\n\t/**\n\t * Send list message with reaction\n\t */\n\tasync sendList(message: Message, items: string[], type?: string): Promise<void> {\n\t\tawait message.react('📋');\n\t\tif (type == \"ytsearch\") {\n\t\t\tawait message.reply(`📋 **Search Results**:\\n${items.join('\\n')}`);\n\t\t} else if (type == \"refresh\") {\n\t\t\tawait message.reply(`📋 **Video list refreshed**:\\n${items.join('\\n')}`);\n\t\t} else {\n\t\t\tawait message.channel.send(`📋 **Local Videos List**:\\n${items.join('\\n')}`);\n\t\t}\n\t}\n};\n\n/**\n * Error handling utilities\n */\nexport const ErrorUtils = {\n\t/**\n\t * Handle and log errors consistently\n\t */\n\tasync handleError(error: any, context: string, message?: Message): Promise<void> {\n\t\tlogger.error(`Error in ${context}:`, error);\n\n\t\tif (message) {\n\t\t\tawait DiscordUtils.sendError(message, `An error occurred: ${error.message || 'Unknown error'}`);\n\t\t}\n\t},\n\n\t/**\n\t * Handle async operation errors\n\t */\n\tasync withErrorHandling<T>(\n\t\toperation: () => Promise<T>,\n\t\tcontext: string,\n\t\tmessage?: Message\n\t): Promise<T | null> {\n\t\ttry {\n\t\t\treturn await operation();\n\t\t} catch (error) {\n\t\t\tawait this.handleError(error, context, message);\n\t\t\treturn null;\n\t\t}\n\t}\n};\n\n/**\n * General utility functions\n */\nexport const GeneralUtils = {\n\t/**\n\t * Check if input is a valid streaming URL\n\t */\n\tisValidUrl(input: string): boolean {\n\t\tif (!input || typeof input !== 'string') {\n\t\t\treturn false;\n\t\t}\n\n\t\t// Check for common streaming platforms\n\t\treturn input.includes('youtube.com/') ||\n\t\t\t   input.includes('youtu.be/') ||\n\t\t\t   input.includes('twitch.tv/') ||\n\t\t\t   input.startsWith('http://') ||\n\t\t\t   input.startsWith('https://');\n\t},\n\n\t/**\n\t * Check if a path is a local file\n\t */\n\tisLocalFile(filePath: string): boolean {\n\t\ttry {\n\t\t\treturn fs.existsSync(filePath) && fs.lstatSync(filePath).isFile();\n\t\t} catch (error) {\n\t\t\treturn false;\n\t\t}\n\t}\n};"
  },
  {
    "path": "src/utils/youtube.ts",
    "content": "import ytdl_dlp from './yt-dlp.js';\nimport logger from './logger.js';\nimport yts from 'play-dl';\nimport { YouTubeVideo, YTResponse } from '../types/index.js';\n\nexport class Youtube {\n\tasync getVideoInfo(url: string): Promise<YouTubeVideo | null> {\n\t\ttry {\n\t\t\tconst videoData = await ytdl_dlp(url, { dumpSingleJson: true, noPlaylist: true }) as YTResponse;\n\n\t\t\tif (typeof videoData === 'object' && videoData !== null && videoData.id && videoData.title) {\n\t\t\t\treturn {\n\t\t\t\t\tid: videoData.id,\n\t\t\t\t\ttitle: videoData.title,\n\t\t\t\t\tformats: [],\n\t\t\t\t\tvideoDetails: {\n\t\t\t\t\t\tisLiveContent: videoData.is_live === true || (videoData as any).live_status === 'is_live'\n\t\t\t\t\t}\n\t\t\t\t};\n\t\t\t}\n\t\t\tlogger.warn(`Failed to parse video info from yt-dlp for URL: ${url}. Data: ${JSON.stringify(videoData)}`);\n\t\t\treturn null;\n\t\t} catch (error) {\n\t\t\tlogger.error(`Failed to get video info using yt-dlp for URL ${url}:`, error);\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tasync searchAndGetPageUrl(title: string): Promise<{ pageUrl: string | null, title: string | null }> {\n\t\ttry {\n\t\t\tconst results = await yts.search(title, { limit: 1 });\n\t\t\tif (results.length === 0 || !results[0]?.url) {\n\t\t\t\tlogger.warn(`No video found on YouTube for title: \"${title}\" using play-dl.`);\n\t\t\t\treturn { pageUrl: null, title: null };\n\t\t\t}\n\t\t\t\n\t\t\treturn { pageUrl: results[0].url, title: results[0].title || null };\n\t\t} catch (error) {\n\t\t\tlogger.error(`Video search for page URL failed for title \"${title}\":`, error);\n\t\t\treturn { pageUrl: null, title: null };\n\t\t}\n\t}\n\n\tasync search(query: string, limit: number = 5): Promise<string[]> {\n\t\ttry {\n\t\t\tconst searchResults = await yts.search(query, { limit });\n\t\t\treturn searchResults.map((video, index) =>\n\t\t\t\t`${index + 1}. \\`${video.title}\\``\n\t\t\t);\n\t\t} catch (error) {\n\t\t\tlogger.warn(`No videos found with the given title: \"${query}\"`);\n\t\t\treturn [];\n\t\t}\n\t}\n\n\tasync getLiveStreamUrl(youtubePageUrl: string): Promise<string | null> {\n\t\ttry {\n\t\t\tconst streamUrl = await ytdl_dlp(youtubePageUrl, {\n\t\t\t\tgetUrl: true,\n\t\t\t\tformat: 'best[protocol=https]/best[protocol=http]/best',\n\t\t\t\tnoPlaylist: true,\n\t\t\t\tquiet: true,\n\t\t\t});\n\n\t\t\tif (typeof streamUrl === 'string' && streamUrl.trim()) {\n\t\t\t\tlogger.info(`Got live stream URL for ${youtubePageUrl}: ${streamUrl.trim()}`);\n\t\t\t\treturn streamUrl.trim();\n\t\t\t}\n\t\t\tlogger.warn(`yt-dlp did not return a valid live stream URL for: ${youtubePageUrl}. Received: ${streamUrl}`);\n\t\t\treturn null;\n\t\t} catch (error) {\n\t\t\tlogger.error(`Failed to get live stream URL using yt-dlp for ${youtubePageUrl}:`, error);\n\t\t\treturn null;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/utils/yt-dlp.ts",
    "content": "import { existsSync, mkdirSync, writeFileSync, unlinkSync } from \"node:fs\";\nimport nodePath from \"node:path\";\nimport process from \"node:process\";\nimport os from \"node:os\";\nimport crypto from \"node:crypto\";\nimport got from \"got\";\nimport { YTFlags } from \"../types/index.js\";\nimport logger from \"./logger.js\";\nimport config from \"../config.js\";\nimport { spawn } from \"node:child_process\";\n\nlet determinedFilename: string;\nconst platform = process.platform;\nconst arch = process.arch;\n\nif (platform === \"win32\") {\n\tif (arch === \"x64\") {\n\t\tdeterminedFilename = \"yt-dlp.exe\";\n\t}\n\telse if (arch === \"ia32\") {    \n\t\tdeterminedFilename = \"yt-dlp_x86.exe\";\n\t}\n} else if (platform === \"darwin\") {\n\tdeterminedFilename = \"yt-dlp_macos\";\n} else if (platform === \"linux\") {\n\tif (arch === \"arm64\") {\n\t\tdeterminedFilename = \"yt-dlp_linux_aarch64\";\n\t} else if (arch === \"arm\") {\n\t\tdeterminedFilename = \"yt-dlp_linux_armv7l\";\n\t} else if (arch === \"x64\") {\n\t\tdeterminedFilename = \"yt-dlp\";\n\t} else {\n\t\tlogger.warn(`Unsupported Linux architecture '${arch}' for yt-dlp. Falling back to generic 'yt-dlp'. Download might fail.`);\n\t\tdeterminedFilename = \"yt-dlp\";\n\t}\n} else {\n\tlogger.warn(`Unsupported OS '${platform}' for yt-dlp. Attempting to use generic 'yt-dlp'. Download might fail.`);\n\tdeterminedFilename = \"yt-dlp\";\n}\n\nconst filename = determinedFilename;\nconst scriptsPath = nodePath.resolve(process.cwd(), \"scripts\");\nconst exePath = nodePath.resolve(scriptsPath, filename);\n\nfunction args(url: string, options: Partial<YTFlags>): string[] {\n\tconst optArgs: string[] = [];\n\t\n\t// Add cookies file if configured\n\tif (config.ytdlpCookiesPath && existsSync(config.ytdlpCookiesPath)) {\n\t\toptArgs.push('--cookies');\n\t\toptArgs.push(config.ytdlpCookiesPath);\n\t}\n\t\n\tfor (const [key, val] of Object.entries(options)) {\n\t\tif (val === null || val === undefined) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst flag = key.replaceAll(/[A-Z]/gu, ms => `-${ms.toLowerCase()}`);\n\n\t\tif (typeof val === \"boolean\") {\n\t\t\tif (val) {\n\t\t\t\toptArgs.push(`--${flag}`);\n\t\t\t} else {\n\t\t\t\toptArgs.push(`--no-${flag}`);\n\t\t\t}\n\t\t} else {\n\t\t\toptArgs.push(`--${flag}`);\n\t\t\toptArgs.push(String(val));\n\t\t}\n\t}\n\treturn [url, ...optArgs];\n}\n\nfunction json(str: string) {\n\ttry {\n\t\treturn JSON.parse(str);\n\t} catch {\n\t\treturn str;\n\t}\n}\n\nexport async function downloadExecutable() {\n\tif (!existsSync(exePath)) {\n\t\tlogger.info(\"yt-dlp not found, downloading...\");\n\t\tconst releases = await got.get(\"https://api.github.com/repos/yt-dlp/yt-dlp/releases?per_page=1\").json();\n\t\tconst release = releases[0];\n\t\tconst asset = release.assets.find(ast => ast.name === filename);\n\t\tconst version = release.tag_name;\n\t\t\n\t\tawait new Promise((resolve, reject) => {\n\t\t\tgot.get(asset.browser_download_url).buffer().then(x => {\n\t\t\t\tmkdirSync(scriptsPath, { recursive: true });\n\t\t\t\twriteFileSync(exePath, x, { mode: 0o777 });\n\t\t\t\treturn 0;\n\t\t\t}).then(resolve).catch(reject);\n\t\t});\n\t\tlogger.info(`yt-dlp ${version} downloaded successfully`);\n\t}\n}\n\nexport function exec(url: string, options: Partial<YTFlags> = {}, spawnOptions: Record<string, any> = {}) {\n\treturn spawn(exePath, args(url, options), {\n\t\twindowsHide: true,\n\t\t...spawnOptions,\n\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"]\n\t});\n}\n\nexport default async function ytdl(url: string, options: Partial<YTFlags> = {}, spawnOptions: Record<string, any> = {}) {\n\treturn new Promise((resolve, reject) => {\n\t\tlet data = \"\";\n\t\tlet errorData = \"\";\n\n\t\tconst proc = exec(url, options, spawnOptions);\n\n\t\tproc.stdout?.on('data', (chunk) => {\n\t\t\tdata += chunk.toString();\n\t\t});\n\n\t\tproc.stderr?.on('data', (chunk) => {\n\t\t\terrorData += chunk.toString();\n\t\t});\n\n\t\tproc.on('close', (exitCode) => {\n\t\t\tif (exitCode !== 0) {\n\t\t\t\tlogger.error(`yt-dlp process exited with code ${exitCode}. Stderr: ${errorData}`);\n\t\t\t\treject(new Error(`yt-dlp failed with exit code ${exitCode}: ${errorData || data}`));\n\t\t\t} else {\n\t\t\t\tresolve(json(data));\n\t\t\t}\n\t\t});\n\n\t\tproc.on('error', (error) => {\n\t\t\tlogger.error(`yt-dlp process error:`, error);\n\t\t\treject(error);\n\t\t});\n\t});\n}\n\nexport async function downloadToTempFile(url: string, options: Partial<YTFlags> = {}): Promise<string> {\n\tawait downloadExecutable();\n\n\tconst tempDir = os.tmpdir();\n\tconst tempFilename = `ytdlp_temp_${crypto.randomBytes(6).toString('hex')}.mp4`;\n\tconst tempFilePath = nodePath.join(tempDir, tempFilename);\n\n\tconst downloadOptions: Partial<YTFlags> = {\n\t\t...options,\n\t\toutput: tempFilePath,\n\t\tquiet: true,\n\t\tnoWarnings: true,\n\t};\n\n\tconst proc = spawn(exePath, args(url, downloadOptions), {\n\t\twindowsHide: true,\n\t\tstdio: [\"ignore\", \"ignore\", \"pipe\"]\n\t});\n\n\tlet errorData = \"\";\n\tproc.stderr?.on('data', (chunk) => {\n\t\terrorData += chunk.toString();\n\t});\n\n\tconst exitCode = await new Promise<number>((resolve) => {\n\t\tproc.on('close', (code) => resolve(code || 0));\n\t\tproc.on('error', () => resolve(1));\n\t});\n\n\tif (exitCode !== 0) {\n\t\tif (existsSync(tempFilePath)) {\n\t\t\ttry {\n\t\t\t\tunlinkSync(tempFilePath);\n\t\t\t} catch (cleanupError) {\n\t\t\t\tlogger.warn(`Failed to cleanup temp file ${tempFilePath} after yt-dlp error:`, cleanupError);\n\t\t\t}\n\t\t}\n\t\tconst errorMessage = `yt-dlp failed to download to temp file. Exit code: ${exitCode}. Stderr: ${errorData.trim()}`;\n\t\tlogger.error(errorMessage);\n\t\tthrow new Error(errorMessage);\n\t}\n\n\tif (!existsSync(tempFilePath)) {\n\t\tconst errorMessage = `yt-dlp exited successfully but temp file ${tempFilePath} was not created. Stderr: ${errorData.trim()}`;\n\t\tlogger.error(errorMessage);\n\t\tthrow new Error(errorMessage);\n\t}\n\t\n\treturn tempFilePath;\n}\n\nexport async function checkForUpdatesAndUpdate(): Promise<void> {\n\ttry {\n\t\tawait downloadExecutable();\n\t\tconst updateProc = spawn(exePath, [\"--update\"], {\n\t\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\t});\n\n\t\tlet stdoutData = \"\";\n\t\tlet stderrData = \"\";\n\n\t\tupdateProc.stdout?.on('data', (chunk) => {\n\t\t\tstdoutData += chunk.toString();\n\t\t});\n\n\t\tupdateProc.stderr?.on('data', (chunk) => {\n\t\t\tstderrData += chunk.toString();\n\t\t});\n\n\t\tconst exitCode = await new Promise<number>((resolve) => {\n\t\t\tupdateProc.on('close', (code) => resolve(code || 0));\n\t\t\tupdateProc.on('error', () => resolve(1));\n\t\t});\n\n\t\tif (exitCode === 0) {\n\t\t\tif (stdoutData.includes(\"Updated yt-dlp to\")) {\n\t\t\t\tlogger.info(`yt-dlp updated successfully. Output: ${stdoutData.trim()}`);\n\t\t\t}\n\t\t} else {\n\t\t\tlogger.warn(`yt-dlp update check failed or an update was not straightforward. Exit code: ${exitCode}.`);\n\t\t\tif (stdoutData.trim()) logger.warn(`yt-dlp update stdout: ${stdoutData.trim()}`);\n\t\t\tif (stderrData.trim()) logger.error(`yt-dlp update stderr: ${stderrData.trim()}`);\n\t\t}\n\t} catch (error) {\n\t\tlogger.error(\"Error during yt-dlp update check process:\", error);\n\t}\n}"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    /* Visit https://aka.ms/tsconfig to read more about this file */\n    \"emitDecoratorMetadata\": true,\n    \"experimentalDecorators\": true,\n    \"sourceMap\": true,\n    \"noImplicitAny\": false,\n    \"removeComments\": true,\n    \"preserveConstEnums\": true,\n    \"noEmitHelpers\": true,\n    \"outDir\": \"dist\",\n    \"rootDir\": \"src\",\n    /* Language and Environment */\n    \"target\": \"ESNext\", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */\n    /* Modules */\n    \"module\": \"NodeNext\", /* Specify what module code is generated. */\n    \"moduleResolution\": \"NodeNext\",\n    /* Emit */\n    \"importHelpers\": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */\n    \"esModuleInterop\": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */\n    \"forceConsistentCasingInFileNames\": true, /* Ensure that casing is correct in imports. */\n    /* Type Checking */\n    \"strict\": true, /* Enable all strict type-checking options. */\n    \"strictNullChecks\": false, /* When type checking, take into account null and undefined. */\n    \"skipLibCheck\": true, /* Skip type checking all .d.ts files. */\n\n    \"allowJs\": true, \n    \"declaration\": true\n  }\n}"
  }
]