diff --git a/.env.example b/.env.example index 4f90252e..1419d84e 100644 --- a/.env.example +++ b/.env.example @@ -109,8 +109,8 @@ owner.list="" # Список разработчиков, ук ffmpeg.path="ffmpeg" # Путь до исполняемого файла ffmpeg duration.optimization=15 # Время от конца для блокировки смены потока -audio.fade=10 # Длительность плавного включения/выключения -audio.swap.fade=3 # Длительность плавного включения/выключения при переключении трека или применения фильтра +audio.fade=5 # Длительность плавного включения/выключения +audio.swap.fade=2 # Длительность плавного включения/выключения при переключении трека или применения фильтра audio.volume=50 # Громкость аудио ######################################################################################################## @@ -120,16 +120,17 @@ audio.volume=50 # Громкость аудио ######################################################################################################## cache=off # Глобальный параметр кеширования cache.file=off # Кеширование в файлы (аудио, данные треков, логи), при отключении все будет хранится в памяти -cache.dir="../build/.cache" # Куда все будет кешироваться +cache.dir="build/.cache" # Куда все будет кешироваться ######################################################################################################## ### APIs Sub System ### ### Здесь можно задать кол-во выдаваемых объектов ### ######################################################################################################## -APIs.limit.search=8 #Кол-во треков в поиске -APIs.limit.author=8 #Кол-во треков при запросе автора -APIs.limit.playlist=70 #Кол-во треков при получении плейлиста -APIs.limit.album=70 #Кол-во треков при получении альбома +APIs.limit.search=8 # Кол-во треков в поиске +APIs.limit.author=8 # Кол-во треков при запросе автора +APIs.limit.playlist=70 # Кол-во треков при получении плейлиста +APIs.limit.album=70 # Кол-во треков при получении альбома +APIs.limit.related=10 # Кол-во треков при похожих треков ######################################################################################################## ### Имена или индикаторы emoji внутри системы discord ### @@ -179,7 +180,6 @@ button.autoplay="♾" button.queue="📑" button.lyrics="💽" button.filters="🎛" -button.replay="🔄" button.stop="🟥" ######################################################################################################## diff --git a/.github/images/src.png b/.github/images/src.png index ab49c895..1f65f8e5 100644 Binary files a/.github/images/src.png and b/.github/images/src.png differ diff --git a/README.md b/README.md index 06e1f14f..89a31233 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

🌟 Discord Music Bot 💫

Incredible bot with its own voice/audio engine, scalable architecture, multiple filters and support for 6 music platforms.

-

Audio quality surpasses lavalink, don't believe me? Listen for yourself! Works without any drops even on ARM!

+

Audio quality surpasses lavalink and used E2EE 🔐, don't believe me? Listen for yourself! Works without any drops even on ARM!

English @@ -22,9 +22,6 @@ License - - Latest release - All downloads @@ -41,27 +38,30 @@ - 👤 [`SNIPPIK`](https://github.com/SNIPPIK) - 💡 [`GHOST-OF-THE-ABYSS`](https://github.com/GHOST-OF-THE-ABYSS) — ideas and suggestions -📢 Please report any errors or omissions in [Issues](https://github.com/SNIPPIK/UnTitles/issues) -🚫 The bot does not work 24/7 — it may be unavailable! +📢 Please report any errors or omissions in [`Issues`](https://github.com/SNIPPIK/UnTitles/issues) or [`Discord`](https://discord.gg/qMf2Sv3) +🚫 The bot does not work **24/7** — it may be unavailable! [![Invite](https://img.shields.io/badge/Add%20the%20bot-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.com/oauth2/authorize?client_id=623170593268957214) [![Server](https://img.shields.io/badge/Support%20Server-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/qMf2Sv3) > [!WARNING] -> ⚠️ WatKLOK (UnTitles) is a complex technical project, which is supported exclusively by 1 author `SNIPPIK` -> Incorrect use, removal of authorship or appropriation will lead to the closure of the public repository +> ⚠️ WatKLOK (UnTitles) is a complex technical project maintained exclusively by 1 author, `SNIPPIK` +> Incorrect use, removal of authorship, or attribution will result in the closure of the public repository. +> +> Audio issues +> If your internet connection is unstable, losses will occur regardless. +> It is impossible to completely eliminate `packet lost` due to the `UDP` protocol and other `discord` limitations. -> [!IMPORTANT] -> If there is no response from YouTube - install `ytdlp-nodejs`. It is strongly recommended to enable caching -> `main` — stable, but rarely updated branch -> `beta` — newest fixes and features, may be unstable +> [!TIP] +> I recommend enabling the caching system in `.env`. This will allow tracks to be played even with a complete platform lock. +> However, the voice system is simply not allowed to lose audio packets, even under critical load! --- ### ⚠️ Hardware requirements | Data from Ryzen 7 5700x3D | 1 player -- CPU: 0-0.1% -- Ram: ~80 MB, it all depends on the number of tracks, the load on the platforms, namely YouTube! -- Disk: ~50 MB, 200 GB is enough for caching (1.5k tracks ~1.2 GB) +- CPU: `0-0.1%` +- RAM: `~80 MB`, it all depends on the number of tracks, platform load, discord cache! +- Disk: `~50 MB`, `200 GB` is enough for caching (1.5k tracks ~1.2 GB) --- @@ -75,10 +75,12 @@ while (performance.now() - startBlock < 100) {} }, 200); ``` #### 🔊 Voice engine -- Implementation of [Voice Gateway Version 8](https://discord.com/developers/docs/topics/voice-connections) [`(WebSocket + UDP + SRTP + Opus + Sodium)`](src/core/voice) + [End-to-End Encryption (DAVE Protocol)](https://discord.com/developers/docs/topics/voice-connections#endtoend-encryption-dave-protocol) +- Implementation of [**Voice Gateway Version 8**](https://discord.com/developers/docs/topics/voice-connections) [`(WebSocket + UDP + SRTP + Opus + Sodium)`](src/core/voice) + [**End-to-End Encryption (E2EE 🔐)**](https://discord.com/developers/docs/topics/voice-connections#endtoend-encryption-dave-protocol) - Full implementation of **SRTP**: `aead_aes256_gcm`, `xchacha20_poly1305` (via libraries) +- Best audio player compared to **open source** solutions! - Does not require any opus encoders/decoders, has its own opus encoder by parsing method! -- Adaptive system for sending audio packets, its own `Jitter Buffer`! +- Adaptive `jitter buffer`, takes into account both network delays and process latency! +- Automatic voice connection delay calibration to maintain network delays. - Requires **FFmpeg**, it is responsible for audio and filters! - Supported: Autoplay, Repeat, Shuffle, Replay and more functions - Works even with strong **event loop lag**! @@ -86,26 +88,26 @@ while (performance.now() - startBlock < 100) {} - It is possible to reuse audio without conversion if it is less than 8 minutes long - Smooth **fade-in/fade-out** transition between tracks, even with **skip**, **seek** and **tp**. - There is a system of smooth transition from one audio to another `Hot audio swap` -- 16+ filters, you can add your own without complex digging in the code [filters](src/core/player/filters.json) +- 16+ filters, you can add your own without complex digging in the code [**filters**](src/core/player/filters.json) - There is support for long videos, Live video is still raw. - There is an explicit synchronization of the audio stream, without audio filters! #### 🌐 Platforms -- Supported: `YouTube`, `Spotify`, `VK`, `Yandex-Music`, `SoundCloud`, `Deezer` -- Audio: `YouTube`, `VK`, `Yandex-Music` **(MP3 + Lossless)**, `SoundCloud` -- Precise search in the absence of audio, by time and names by syllables -- There is a search on other platforms in the absence of audio! -- There is support for `related`(**related tracks**), inclusion of similar tracks -- Platforms work in a separate **worker** (thread) for performance -- Everything is described in detail, there are examples and a bunch of interfaces for typing -- Easy expansion and addition of new platforms via `Dynamic Loader - Handler` +- Supported platforms: `YouTube`, `Spotify`, `VK`, `Yandex-Music`, `SoundCloud`, `Deezer` +- Audio: `YouTube`, `VK`, `Yandex-Music` (MP3 + Lossless), `SoundCloud` +- Audio search on other platforms is available, even if the platform refuses to serve audio! +- Completely `fallback` system: a track missing on one platform will be found on another! Errors are not a problem! +- Related support `(including related tracks)` is available. +- Platforms run in a separate worker `(thread)` for performance. +- Everything is described in detail, with examples and a ton of interfaces for typing. +- Easy extension and addition of new platforms via the `Dynamic Loader - Handler`. #### 🌍 Localization -- Available languages: `English`, `Russian` ([file with languages](src/structures/locale/languages.json)) +- Available languages: `English`, `Russian` ([**file with languages**](src/structures/locale/languages.json)) - You can add any language supported by discord --- # 🔩 Other functionality -#### Own system [handlers](src/handlers) +#### Own system [**handlers**](src/handlers) - Universal loader: [`commands`](src/handlers/commands), [`events`](src/handlers/events), [`components`](src/handlers/components), [`middlewares`](src/handlers/middlewares), [`rest`](src/handlers/rest) - Own framework for commands, buttons, menu selectors, events - Decorators and interfaces are used, including typing @@ -114,13 +116,13 @@ while (performance.now() - startBlock < 100) {} #### 💡 Adaptive loop - It is not afraid of **event loop** and **drift**, it just takes them into account not as a problem, but as parameters! - The loop can work ahead from 0 to 2 ms to process objects in the loop! -- Audio sending is built on it! +- Works on its own calculations, not on `newTime - oldTime`, calculations of function delay, discrepancies after execution, etc. - Cycle accuracy `±0.05 ms` with `process.hrtime` + `performance.now` #### ⚙️ Internal tools -- [`SetArray`](src/structures/tools/SetArray.ts) - 2 in one Array and Set in one class +- [`SetArray`](src/structures/array/index.set.ts) - 2 in one Array and Set in one class - [`Cycle`](src/structures/tools/Cycle.ts) - Manages the message update system and sending audio packets -- [`TypedEmitter`](src/structures/tools/TypedEmitter.ts) - typed `EventEmitterAsyncResource` +- [`TypedEmitter`](src/structures/tools/TypedEmitter.ts) - Custom event emitter based object - [`SimpleWorker`](src/structures/tools/SimpleWorker.ts) - Class for working with threads --- @@ -142,6 +144,7 @@ while (performance.now() - startBlock < 100) {} | `/remove` | ✅ | value | Delete track | | `/seek` | ❌ | 00:00, int | Seeking time track | | `/skip` | ✅ | (back, to, next) | Skip tracks | +| `/repeat` | ✅ | type | Type repeat | | `/queue` | ✅ | {destroy, list} | Queue management | | `/avatar` | ✅ | {user} | User avatar | | `/voice` | ✅ | (join, leave, tribune) | Voice channel | @@ -149,8 +152,8 @@ while (performance.now() - startBlock < 100) {} --- ## 🚀 Quick start -> Node.js or Bun is required, as well as FFmpeg installed -> All configuration is written in `.env` +> You need Node.js and FFmpeg installed. +> All parameters are specified in `.env`, don't forget to copy it to `.build` and customize it. ```shell # Clone git clone https://github.com/SNIPPIK/UnTitles @@ -170,10 +173,9 @@ bun run start-bun ``` --- -[![TypeScript](https://img.shields.io/badge/typescript-5.9.2-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) -[![Bun](https://img.shields.io/badge/bun-1.2.25-6DA55F?style=for-the-badge&logo=bun&logoColor=white&color=white)](https://bun.com/) +[![TypeScript](https://img.shields.io/badge/typescript-5.9.3-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![NodeJS](https://img.shields.io/badge/node.js-23.0.0-6DA55F?style=for-the-badge&logo=node.js&logoColor=white)](https://nodejs.org/en) -[![Discord.js](https://img.shields.io/badge/discord.js-14.22-%23CB3837.svg?style=for-the-badge&logo=discord.js&logoColor=white&color=purple)](https://discord.js.org/) +[![Discord.js](https://img.shields.io/badge/discord.js-14.24.2-%23CB3837.svg?style=for-the-badge&logo=discord.js&logoColor=white&color=purple)](https://discord.js.org/) [![WS](https://img.shields.io/badge/ws-8.18.3-%23CB3837.svg?style=for-the-badge&logo=socket&logoColor=white)](https://www.npmjs.com/package/ws) [![FFmpeg](https://img.shields.io/badge/FFmpeg-7.*.*-%23CB3837.svg?style=for-the-badge&logo=ffmpeg&logoColor=white&color)](https://ffmpeg.org/) --- diff --git a/README_RU.md b/README_RU.md index f083de6e..2a9debaf 100644 --- a/README_RU.md +++ b/README_RU.md @@ -2,8 +2,8 @@

🌟 Discord Music Bot 💫

-

Невероятный бот с собственным голосовым/аудио движком, масштабируемой архитектурой, множеством фильтров и поддержкой 6 музыкальных платформ.

-

Качество аудио превосходит lavalink, не верите? Послушайте сами! Работает без просадок даже на ARM!

+

Невероятный бот с собственным голосовым/аудио движком, масштабируемой архитектурой, множеством фильтров и поддержкой 6 музыкальных платформ.

+

Качество аудио превосходит lavalink и использует E2EE 🔐, не верите? Послушайте сами! Работает без просадок даже на ARM!

@@ -23,9 +23,6 @@ License - - Latest release - All downloads @@ -42,28 +39,30 @@ - 👤 [`SNIPPIK`](https://github.com/SNIPPIK) - 💡 [`GHOST-OF-THE-ABYSS`](https://github.com/GHOST-OF-THE-ABYSS) — идеи и предложения -📢 Об ошибках и недочётах просим сообщать в [Issues](https://github.com/SNIPPIK/UnTitles/issues) +📢 Об ошибках и недочётах просим сообщать в [`Issues`](https://github.com/SNIPPIK/UnTitles/issues) или [`Discord`](https://discord.gg/qMf2Sv3) 🚫 Бот не работает 24/7 — он может быть недоступен! [![Invite](https://img.shields.io/badge/Add%20the%20bot-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.com/oauth2/authorize?client_id=623170593268957214) [![Server](https://img.shields.io/badge/Support%20Server-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/qMf2Sv3) -> [!WARNING] +> [!WARNING] > ⚠️ WatKLOK (UnTitles) — это сложный технический проект, который поддерживается исключительно 1 автором `SNIPPIK` -> Некорректное использование, удаление авторства или присвоение приведут к закрытию публичного репозитория - +> Некорректное использование, удаление авторства или присвоение приведут к закрытию публичного репозитория +> +> Audio issues +> Если ваш интернет не стабилен потери будут в любом случае. +> Полностью устранить `packet lost` не возможно, из-за протокола `UDP` и прочих ограничений `discord` -> [!IMPORTANT] -> Если нет ответа от YouTube — установите `ytdlp-nodejs`. Настоятельно рекомендуется включить кеширование -> `main` — стабильная, но редко обновляемая ветка -> `beta` — новейшие фиксы и функции, может быть нестабильной +> [!TIP] +> Рекомендую включить систему кэширования в `.env`, в таком случае можно включать треки даже при полной блокировке платформы +> Но голосовой системе просто не дозволено терять аудио пакеты даже при критической нагрузке! --- ### ⚠️ Требования к железу | Данные с Ryzen 7 5700x3D | 1 плеер - CPU: 0-0.1% -- Ram: ~80 MB, все зависит от кол-ва треков, нагрузки на платформы, а именно youtube! -- Disk: ~50 MB, для кеширования хватает 200 GB (1.5к треков ~1.2 GB) +- RAM: `~80 MB`, все зависит от кол-ва треков, нагрузки на платформы, кеша discord! +- Disk: `~50 MB`, для кеширования хватает `200 GB` (1.5к треков ~1.2 GB) --- @@ -77,10 +76,12 @@ setInterval(() => { }, 200); ``` #### 🔊 Голосовой движок -- Реализация [Voice Gateway Version 8](https://discord.com/developers/docs/topics/voice-connections) [`(WebSocket + UDP + SRTP + Opus + Sodium)`](src/core/voice) + [End-to-End Encryption (DAVE Protocol)](https://discord.com/developers/docs/topics/voice-connections#endtoend-encryption-dave-protocol) +- Реализация [**Voice Gateway Version 8**](https://discord.com/developers/docs/topics/voice-connections) [`(WebSocket + UDP + SRTP + Opus + Sodium)`](src/core/voice) + [**End-to-End Encryption (E2EE 🔐)**](https://discord.com/developers/docs/topics/voice-connections#endtoend-encryption-dave-protocol) - Полная реализация **SRTP**: `aead_aes256_gcm`, `xchacha20_poly1305` (через библиотеки) +- Лучший аудио плеер по сравнению с **open source** решениями - Не требует никаких opus encoders/decoders, имеет свой opus encoder по методу парсинга! -- Адаптивная система отправки аудио пакетов, свой `Jitter Buffer`! +- Свой `jitter buffer`, очень схож с WebRTP по логике! +- Авто калибровка задержки голосового подключения, для компенсации задержек сети - Требуется **FFmpeg**, он отвечает за аудио и фильтры! - Поддерживаются: Autoplay, Repeat, Shuffle, Replay и другие функции. - Работает даже при сильном **event loop lag**! @@ -88,20 +89,20 @@ setInterval(() => { - Есть возможность переиспользовать аудио без конвертации если оно длительностью менее 8 мин - Плавный **fade-in/fade-out** переход между треками, даже при **skip**, **seek** и **тп**. - Есть система плавного перехода от одного аудио к другому `Hot audio swap` -- 16+ фильтров, можно добавить свои без сложного копания в коде [filters](src/core/player/filters.json) -- Есть поддержка длинных видео, Live видео пока сыровато. +- 16+ фильтров, можно добавить свои без сложного копания в коде [**filters**](src/core/player/filters.json) +- Есть поддержка длинных видео, Live, пока сыровато. - Присутствует явная синхронизация аудио потока, без аудио фильтров! #### 🌐 Платформы - Поддерживаются: `YouTube`, `Spotify`, `VK`, `Yandex-Music`, `SoundCloud`, `Deezer` - Аудио: `YouTube`, `VK`, `Yandex-Music` **(MP3 + Lossless)**, `SoundCloud` -- Точный поиск при отсутствии аудио, через время и названия по слогам -- Есть поиск на других платформах при отсутствии аудио! +- Есть поиск аудио на других платформах, даже если платформа не хочет отдавать аудио! +- Полностью `fallback` система, нет трека на 1 платформе найдется на другой! - Есть поддержка `related`(**похожих треков**), включение похожих треков -- Платформы работают в отдельном **worker** (потоке) для производительности +- Платформы работают в отдельном **worker** (потоке) для лучшей производительности - Все подробно расписано, есть примеры и куча интерфейсов для типизации - Легкое расширение и добавление новых платформ через `Динамический загрузчик - Handler` #### 🌍 Локализация -- Доступные языки: `English`, `Русский` ([файл с языками](src/structures/locale/languages.json)) +- Доступные языки: `English`, `Русский` ([**файл с языками**](src/structures/locale/languages.json)) - Можно добавить любой язык поддерживаемый discord --- @@ -116,13 +117,13 @@ setInterval(() => { #### 💡 Адаптивный цикл - Не боится **event loop** и **drift**, он просто учитывает их не как проблему, а как параметры! - Цикл может срабатывать на опережение от 0 до 2 ms для обработки объектов в цикле! -- Аудио отправка построена именно на нем! +- Работает на своих вычислениях, а не на `newTime - oldTime`, вычисления задержки функции, расхождения после выполнения и прочее - Точность цикла `±0.05 ms` при `process.hrtime` + `performance.now` #### ⚙️ Внутренние инструменты -- [`SetArray`](src/structures/tools/SetArray.ts) - 2 в одном Array и Set в один класс +- [`SetArray`](src/structures/array/index.set.ts) - 2 в одном Array и Set в один класс - [`Cycle`](src/structures/tools/Cycle.ts) - Управляет системой обновления сообщений и отправкой аудио пакетов -- [`TypedEmitter`](src/structures/tools/TypedEmitter.ts) - типизированный `EventEmitterAsyncResource` +- [`TypedEmitter`](src/structures/tools/TypedEmitter.ts) - Пользовательский объект на основе `object` - [`SimpleWorker`](src/structures/tools/SimpleWorker.ts) - Класс для работы с потоками --- @@ -144,6 +145,7 @@ setInterval(() => { | `/remove` | ✅ | value | Удаление трека | | `/seek` | ❌ | 00:00, int | Перемотка времени трека | | `/skip` | ✅ | (back, to, next) | Пропуск треков | +| `/repeat` | ✅ | type | Тип повтора | | `/queue` | ✅ | {destroy, list} | Управление очередью | | `/avatar` | ✅ | {user} | Аватар пользователя | | `/voice` | ✅ | (join, leave, tribune) | Голосовой канал | @@ -151,8 +153,8 @@ setInterval(() => { --- ## 🚀 Быстрый старт -> Необходимы Node.js или Bun, а также установленный FFmpeg -> Вся конфигурация прописана в `.env` +> Необходим Node.js, а также установленный FFmpeg +> Все параметры прописаны в `.env`, не забудьте скопировать его в `.build` и настроить его под себя ```shell # Клонируем git clone https://github.com/SNIPPIK/UnTitles @@ -164,18 +166,12 @@ npm install # Запуск через Node.js # настройка переменных окружения в build/.env npm run build && npm run start - -# Запуск через Bun (пока не работает) -# настройка переменных окружения в ./env -npm i dotenv -bun run start-bun ``` --- -[![TypeScript](https://img.shields.io/badge/typescript-5.9.2-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) -[![Bun](https://img.shields.io/badge/bun-1.2.25-6DA55F?style=for-the-badge&logo=bun&logoColor=white&color=white)](https://bun.com/) +[![TypeScript](https://img.shields.io/badge/typescript-5.9.3-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![NodeJS](https://img.shields.io/badge/node.js-23.0.0-6DA55F?style=for-the-badge&logo=node.js&logoColor=white)](https://nodejs.org/en) -[![Discord.js](https://img.shields.io/badge/discord.js-14.22-%23CB3837.svg?style=for-the-badge&logo=discord.js&logoColor=white&color=purple)](https://discord.js.org/) +[![Discord.js](https://img.shields.io/badge/discord.js-14.24.2-%23CB3837.svg?style=for-the-badge&logo=discord.js&logoColor=white&color=purple)](https://discord.js.org/) [![WS](https://img.shields.io/badge/ws-8.18.3-%23CB3837.svg?style=for-the-badge&logo=socket&logoColor=white)](https://www.npmjs.com/package/ws) [![FFmpeg](https://img.shields.io/badge/FFmpeg-7.*.*-%23CB3837.svg?style=for-the-badge&logo=ffmpeg&logoColor=white&color)](https://ffmpeg.org/) --- diff --git a/package-lock.json b/package-lock.json index e642b4f4..df77daad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,40 +1,40 @@ { "name": "untitles", - "version": "0.3.2", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "untitles", - "version": "0.3.2", + "version": "0.4.0", "license": "BSD-3-Clause + custom restriction", "dependencies": { - "discord-api-types": "^0.38.26", - "discord.js": "^14.22.1", + "discord-api-types": "^0.38.36", + "discord.js": "14.25.1", "ws": "^8.18.3" }, "devDependencies": { - "@types/node": "^24.5.2", + "@types/node": "^24.10.1", "tsconfig-paths": "^4.2.0", - "typescript": "^5.9.2" + "typescript": "^5.9.3" }, "engines": { - "node": ">=23.0.0" + "node": ">=24.0.0" }, "optionalDependencies": { - "@snazzah/davey": "^0.1.6" + "@snazzah/davey": "^0.1.8" } }, "node_modules/@discordjs/builders": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.11.3.tgz", - "integrity": "sha512-p3kf5eV49CJiRTfhtutUCeivSyQ/l2JlKodW1ZquRwwvlOWmG9+6jFShX6x8rUiYhnP6wKI96rgN/SXMy5e5aw==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.0.tgz", + "integrity": "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q==", "license": "Apache-2.0", "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.16", + "discord-api-types": "^0.38.31", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -56,12 +56,12 @@ } }, "node_modules/@discordjs/formatters": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.1.tgz", - "integrity": "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", "license": "Apache-2.0", "dependencies": { - "discord-api-types": "^0.38.1" + "discord-api-types": "^0.38.33" }, "engines": { "node": ">=16.11.0" @@ -106,10 +106,13 @@ } }, "node_modules/@discordjs/util": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz", - "integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, "engines": { "node": ">=18" }, @@ -153,20 +156,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", - "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", + "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.0.4", + "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", - "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", "license": "MIT", "optional": true, "dependencies": { @@ -174,9 +177,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz", - "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "license": "MIT", "optional": true, "dependencies": { @@ -184,15 +187,15 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", + "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" } }, "node_modules/@sapphire/async-queue": { @@ -229,9 +232,9 @@ } }, "node_modules/@snazzah/davey": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@snazzah/davey/-/davey-0.1.6.tgz", - "integrity": "sha512-wKJDQ7iobl3rvuQDXLC2yZdpuVxPvMnbyjyPpkcETqPfqNVrdyX9zSdV74dnkpx7aLpINEmKh8ZEIlCIJA2h1w==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@snazzah/davey/-/davey-0.1.8.tgz", + "integrity": "sha512-f4NkCtU/4r9DUohp636qkZkxCF19v8Xq6W/1m4IKY2emZm+M2IdhWIv4GQxog2bQBWLNRRLD4DNM/UXEUb8W/A==", "license": "MIT", "optional": true, "engines": { @@ -241,26 +244,26 @@ "url": "https://github.com/sponsors/Snazzah" }, "optionalDependencies": { - "@snazzah/davey-android-arm-eabi": "0.1.6", - "@snazzah/davey-android-arm64": "0.1.6", - "@snazzah/davey-darwin-arm64": "0.1.6", - "@snazzah/davey-darwin-x64": "0.1.6", - "@snazzah/davey-freebsd-x64": "0.1.6", - "@snazzah/davey-linux-arm-gnueabihf": "0.1.6", - "@snazzah/davey-linux-arm64-gnu": "0.1.6", - "@snazzah/davey-linux-arm64-musl": "0.1.6", - "@snazzah/davey-linux-x64-gnu": "0.1.6", - "@snazzah/davey-linux-x64-musl": "0.1.6", - "@snazzah/davey-wasm32-wasi": "0.1.6", - "@snazzah/davey-win32-arm64-msvc": "0.1.6", - "@snazzah/davey-win32-ia32-msvc": "0.1.6", - "@snazzah/davey-win32-x64-msvc": "0.1.6" + "@snazzah/davey-android-arm-eabi": "0.1.8", + "@snazzah/davey-android-arm64": "0.1.8", + "@snazzah/davey-darwin-arm64": "0.1.8", + "@snazzah/davey-darwin-x64": "0.1.8", + "@snazzah/davey-freebsd-x64": "0.1.8", + "@snazzah/davey-linux-arm-gnueabihf": "0.1.8", + "@snazzah/davey-linux-arm64-gnu": "0.1.8", + "@snazzah/davey-linux-arm64-musl": "0.1.8", + "@snazzah/davey-linux-x64-gnu": "0.1.8", + "@snazzah/davey-linux-x64-musl": "0.1.8", + "@snazzah/davey-wasm32-wasi": "0.1.8", + "@snazzah/davey-win32-arm64-msvc": "0.1.8", + "@snazzah/davey-win32-ia32-msvc": "0.1.8", + "@snazzah/davey-win32-x64-msvc": "0.1.8" } }, "node_modules/@snazzah/davey-android-arm-eabi": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@snazzah/davey-android-arm-eabi/-/davey-android-arm-eabi-0.1.6.tgz", - "integrity": "sha512-6Fso+kxvvIcmUdTgU4etHjvEZUwGwvIk+SUYxKTRZKz/S62pZvcFeZfbofpQC5ZIlt/rdp7l+4IM62J7PUduxQ==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@snazzah/davey-android-arm-eabi/-/davey-android-arm-eabi-0.1.8.tgz", + "integrity": "sha512-kRrrU2AldUEG8+Qqj+d5bofm1xzHEX2e657TFHOzKXObU7igqINFL61opcb30cMHcnMTGthV3bFFlh6ky0xrqw==", "cpu": [ "arm" ], @@ -274,9 +277,9 @@ } }, "node_modules/@snazzah/davey-android-arm64": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@snazzah/davey-android-arm64/-/davey-android-arm64-0.1.6.tgz", - "integrity": "sha512-5ZGLumjewJAmGAcHqSHb2+KZSSufdNY++/GouzqdQXfhs2bSNBPuHpNn94u6//5UK0o73udJ6B1H/uLOLfEBLQ==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@snazzah/davey-android-arm64/-/davey-android-arm64-0.1.8.tgz", + "integrity": "sha512-ttGznhY/vy0aUI+wwvt9Ati0U4gujKKjVwBOaOY6Ug+DKaPIc3Sah73XMXoX9QB9TyUBkH7lxvhpI+VneIcg4w==", "cpu": [ "arm64" ], @@ -290,9 +293,9 @@ } }, "node_modules/@snazzah/davey-darwin-arm64": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@snazzah/davey-darwin-arm64/-/davey-darwin-arm64-0.1.6.tgz", - "integrity": "sha512-0k6gOm29bcznz4ND1gfJVKeCxfyFw/EtfhPQvQ2PPJToSIaSvVqfYIlj/v9ogWW/lzuPI4EbLP0b6hnZkKidbQ==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@snazzah/davey-darwin-arm64/-/davey-darwin-arm64-0.1.8.tgz", + "integrity": "sha512-c75j0DuAWZ20XgaTVCXzks2Gl9L0+dC0ID1NSdvoOqBRCphjHBUSf1te2hdWJKI3Dq1uPaJ00JL6fYUkpb+Wxw==", "cpu": [ "arm64" ], @@ -306,9 +309,9 @@ } }, "node_modules/@snazzah/davey-darwin-x64": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@snazzah/davey-darwin-x64/-/davey-darwin-x64-0.1.6.tgz", - "integrity": "sha512-y9UuymB5JTi9LSwjsCZDf/mjI6nAum1+uYX2h4xdO+VUxXQSAR4B2mr3lCI7l9KwYqW7JVDN5wETithAkXcTYA==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@snazzah/davey-darwin-x64/-/davey-darwin-x64-0.1.8.tgz", + "integrity": "sha512-/SAvTmkEc73R4JSAfwVGMcVzwtjvmduaZHWnF49fCO1iFGR688U8k4W1obM9JkaNoLg4LPvU4HGMuDvfxh/gUA==", "cpu": [ "x64" ], @@ -322,9 +325,9 @@ } }, "node_modules/@snazzah/davey-freebsd-x64": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@snazzah/davey-freebsd-x64/-/davey-freebsd-x64-0.1.6.tgz", - "integrity": "sha512-G0XzHi+pZqTZ5Zr7Z66J6oGOG07+Obw7f0CwD9nAJcSFlKnd8wYzTjL+krHfQxmLHnuA5w/9df+M9oDJDcGcJw==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@snazzah/davey-freebsd-x64/-/davey-freebsd-x64-0.1.8.tgz", + "integrity": "sha512-91gPSY0gcbq/o7yqpBfWyrck7yZ4nciH3LJlk55tpC+NExedWUtH2HvsqVqqoS8TmNu+1msT0+5nJsq65flWPQ==", "cpu": [ "x64" ], @@ -338,9 +341,9 @@ } }, "node_modules/@snazzah/davey-linux-arm-gnueabihf": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm-gnueabihf/-/davey-linux-arm-gnueabihf-0.1.6.tgz", - "integrity": "sha512-RaxTzO8iJfDvj4a8OcXRwcP+2WfaCcno28ZWFMTI0pHEviG3MfLH5COAIvtMQvg0XfC+HgFC4YA1d29S8Dhvbg==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm-gnueabihf/-/davey-linux-arm-gnueabihf-0.1.8.tgz", + "integrity": "sha512-nJQOsykF5XFngsxxv6zKQX0bPfqE2YotlcPajHKVR7COSbrYgFXZ+Cck+nILiDqpCU+4fk7ae+8Knx181qbWYg==", "cpu": [ "arm" ], @@ -354,9 +357,9 @@ } }, "node_modules/@snazzah/davey-linux-arm64-gnu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm64-gnu/-/davey-linux-arm64-gnu-0.1.6.tgz", - "integrity": "sha512-2BIJSWs4rHq4U9A7B6WtF1LzwYJrbFUz5SQVmwqwQXKJ8cm81iizqclDGWr3zFGiVPTXLZ/+G3wnQNDB54oABQ==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm64-gnu/-/davey-linux-arm64-gnu-0.1.8.tgz", + "integrity": "sha512-IaALezD2F3Qt2n1GL5Wm2aQEjeiJI6gzSEhEHYiqyZUM/LygCyRXSDkoPAvHGuu3Xwy5m58Z20pfukxA49xbGQ==", "cpu": [ "arm64" ], @@ -370,9 +373,9 @@ } }, "node_modules/@snazzah/davey-linux-arm64-musl": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm64-musl/-/davey-linux-arm64-musl-0.1.6.tgz", - "integrity": "sha512-N1egO+HT8cvSdIGCzJNRVH3ZhxCIYKVYxEkfzVZaBx26snN8NF737YTVRldl84w//3tdgohyl27yrn+dMkWS2Q==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-arm64-musl/-/davey-linux-arm64-musl-0.1.8.tgz", + "integrity": "sha512-OhEwQmZ/ntiqHoSAxRe1qVfELdKMREqwlf52vhPMFVa4XNyT6jNYXGanTMjYX2+jhH9SlJ7cqsADm1VFvjRnGA==", "cpu": [ "arm64" ], @@ -386,9 +389,9 @@ } }, "node_modules/@snazzah/davey-linux-x64-gnu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-gnu/-/davey-linux-x64-gnu-0.1.6.tgz", - "integrity": "sha512-RAC96Y//HHoMP+1MUf4rOkBq5Nx6GCiOGeGsNXt7r02lbIthoFEPYFQdbfc9jYA79k67gpzmCa0N5ws7ZLVU5A==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-gnu/-/davey-linux-x64-gnu-0.1.8.tgz", + "integrity": "sha512-yghgG7iXZUHy734Cq3PcgrbRnLhhB233JNTX5VPRxRqdwFAg2MzAJ2iSWpP12K6hSqKq9hw0sdt8CNpr0mEXjQ==", "cpu": [ "x64" ], @@ -402,9 +405,9 @@ } }, "node_modules/@snazzah/davey-linux-x64-musl": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-musl/-/davey-linux-x64-musl-0.1.6.tgz", - "integrity": "sha512-nBvxJTKlQFP9UsQ7ah78L+rGdcwLWKDR8z/knut/M+UZLe37vaponJAbY3F5ZqGAcfqJbwUi/CXR77t9E+TDmw==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@snazzah/davey-linux-x64-musl/-/davey-linux-x64-musl-0.1.8.tgz", + "integrity": "sha512-WwCiAge27ZOEu7NRx5NFjpCAkGcUGHGtoROBY4ElRYE0Tp3DfuzWU06qSu6JBQPlzhTTBN29X2/kGP8iRUwqnQ==", "cpu": [ "x64" ], @@ -418,25 +421,25 @@ } }, "node_modules/@snazzah/davey-wasm32-wasi": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@snazzah/davey-wasm32-wasi/-/davey-wasm32-wasi-0.1.6.tgz", - "integrity": "sha512-hvpZH6a4mYZiXv6vdZaFwjPProgFtb3k4BoMvEEJZDXsEPuIDgp+d2BX5Q9nVazdnJa/6JR/XCuObzugPWp0Ew==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@snazzah/davey-wasm32-wasi/-/davey-wasm32-wasi-0.1.8.tgz", + "integrity": "sha512-VAUY6eMhiS4HkHDh0SCjVbwf2SUR/cHs0IzS02p4lYwuUaqPFQnraxMNZ6R2MNOK8NmtPbCpWvi7kYMxMJuL2A==", "cpu": [ "wasm32" ], "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" + "@napi-rs/wasm-runtime": "^1.0.7" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@snazzah/davey-win32-arm64-msvc": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-arm64-msvc/-/davey-win32-arm64-msvc-0.1.6.tgz", - "integrity": "sha512-iuxYXXa0Z8eAEZotAlMYUc5DCy3VonRXQMm8/w2EvM/ZzGBI7SMap0GhPf6HjArEW32ETarTLh1s/Yi/jhFPDQ==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-arm64-msvc/-/davey-win32-arm64-msvc-0.1.8.tgz", + "integrity": "sha512-4ZcEhyfuRH+ni4pHBrgm+2zumwgaac5+OGOKHEGezJmUd3omn/iKpcj9Z0+Y9qQ8UGslzBrgesW46ENXoOfv2Q==", "cpu": [ "arm64" ], @@ -450,9 +453,9 @@ } }, "node_modules/@snazzah/davey-win32-ia32-msvc": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-ia32-msvc/-/davey-win32-ia32-msvc-0.1.6.tgz", - "integrity": "sha512-QEubcCIBR+ZZoQzRzJuOuKcH2IaF2pFXU+t48ITHG1o2WL4NAnvc3IpfVQGhbkr+DlydZ6fKNMMEemd1pRZzRA==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-ia32-msvc/-/davey-win32-ia32-msvc-0.1.8.tgz", + "integrity": "sha512-znL43C+hxocxLgk/zbgCukOSNW14HYQE5G6fNeWtANHVCstkTN7hrJVbZfBnuKD+Jw1RbCs4gxLsYja1n1iZnA==", "cpu": [ "ia32" ], @@ -466,9 +469,9 @@ } }, "node_modules/@snazzah/davey-win32-x64-msvc": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-x64-msvc/-/davey-win32-x64-msvc-0.1.6.tgz", - "integrity": "sha512-8tR3o+amQOHJL8QzSwuSCCave+jm3SC1m1OKSh9Coy4wN/XoJN0XQUxqzA7ineSClAMW3yIO0ShFmMlMIXsC0A==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@snazzah/davey-win32-x64-msvc/-/davey-win32-x64-msvc-0.1.8.tgz", + "integrity": "sha512-JKIco1miwtM4NgVwU/H9TJdUaSlJ+kdtydy3+tiV9cmJv0u1SM2NpwjV85H44xAQWW2zcRBHP0ZDriciHw09qQ==", "cpu": [ "x64" ], @@ -482,9 +485,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", - "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "license": "MIT", "optional": true, "dependencies": { @@ -492,12 +495,12 @@ } }, "node_modules/@types/node": { - "version": "24.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", - "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", "dependencies": { - "undici-types": "~7.12.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/ws": { @@ -510,9 +513,9 @@ } }, "node_modules/@vladfrangu/async_event_emitter": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.6.tgz", - "integrity": "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==", + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", "license": "MIT", "engines": { "node": ">=v14.0.0", @@ -520,28 +523,28 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.26", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.26.tgz", - "integrity": "sha512-xpmPviHjIJ6dFu1eNwNDIGQ3N6qmPUUYFVAx/YZ64h7ZgPkTcKjnciD8bZe8Vbeji7yS5uYljyciunpq0J5NSw==", + "version": "0.38.36", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.36.tgz", + "integrity": "sha512-qrbUbjjwtyeBg5HsAlm1C859epfOyiLjPqAOzkdWlCNsZCWJrertnETF/NwM8H+waMFU58xGSc5eXUfXah+WTQ==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" ] }, "node_modules/discord.js": { - "version": "14.22.1", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.22.1.tgz", - "integrity": "sha512-3k+Kisd/v570Jr68A1kNs7qVhNehDwDJAPe4DZ2Syt+/zobf9zEcuYFvsfIaAOgCa0BiHMfOOKQY4eYINl0z7w==", + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", "license": "Apache-2.0", "dependencies": { - "@discordjs/builders": "^1.11.2", + "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", - "@discordjs/formatters": "^0.6.1", + "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", - "@discordjs/util": "^1.1.1", + "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.16", + "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", @@ -640,9 +643,9 @@ "license": "0BSD" }, "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -663,9 +666,9 @@ } }, "node_modules/undici-types": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", - "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, "node_modules/ws": { diff --git a/package.json b/package.json index f1a67949..4931de7f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "untitles", - "version": "0.3.2", + "version": "0.4.0", "homepage": "https://github.com/SNIPPIK/UnTitles", "bugs": "https://github.com/SNIPPIK/UnTitles/issues", "author": { @@ -12,25 +12,26 @@ "engineStrict": true, "types": "src/index.ts", "engines": { - "node": ">=23.0.0" + "node": ">=24.0.0" }, "dependencies": { - "discord-api-types": "^0.38.26", - "discord.js": "^14.22.1", + "discord-api-types": "^0.38.36", + "discord.js": "14.25.1", "ws": "^8.18.3" }, "optionalDependencies": { - "@snazzah/davey": "^0.1.6" + "@snazzah/davey": "^0.1.8" }, "devDependencies": { - "@types/node": "^24.5.2", + "@types/node": "^24.10.1", "tsconfig-paths": "^4.2.0", - "typescript": "^5.9.2" + "typescript": "^5.9.3" }, "scripts": { + "start": "cd build && node -r tsconfig-paths/register src --ShardManager --optimize_for_size --expose-gc --experimental-require-module --no-compilation-cache", "start-bun": "bun src", - "start": "cd build && node -r tsconfig-paths/register src --ShardManager --optimize_for_size --expose-gc", - "auto": "npm run build && npm run start", - "build": "tsc" + "build": "tsc", + + "dev:start": "npm run build && cd build && node -r tsconfig-paths/register src --optimize_for_size --expose-gc" } } diff --git a/src/core/audio/opus.ts b/src/core/audio/opus.ts index b14cd119..53c8659c 100644 --- a/src/core/audio/opus.ts +++ b/src/core/audio/opus.ts @@ -1,12 +1,39 @@ -import { Writable, Transform, TransformOptions, WritableOptions } from "node:stream"; +import { Transform, TransformOptions, Writable, WritableOptions } from "node:stream"; import { TypedEmitter } from "#structures"; /** * @author SNIPPIK - * @description Заголовок для поиска opus - * @const OGG_MAGIC + * @description Вспомогательная функция для создания буфера из строки + * @const fromString + * @private */ -const OGG_MAGIC = Buffer.from("OggS"); +const fromString = (str: string): Buffer => Buffer.from(str); + +/** + * @author SNIPPIK + * @description Константы и сигнатуры Ogg/Opus + * @const OGG_CONSTANTS + * @private + */ +const OGG_CONSTANTS = { + // Магическая сигнатура "OggS" + CAPTURE_PATTERN: fromString("OggS"), + // Заголовки Opus + OPUS_HEAD: fromString("OpusHead"), + OPUS_TAGS: fromString("OpusTags"), + // Размеры фиксированных частей заголовка + PAGE_HEADER_SIZE: 27, + // Смещения в заголовке Ogg + OFFSET_VERSION: 4, + OFFSET_TYPE: 5, + OFFSET_GRANULE: 6, + OFFSET_SERIAL: 14, + OFFSET_SEQ: 18, + OFFSET_CRC: 22, + OFFSET_SEGMENTS_COUNT: 26, + OFFSET_SEGMENT_TABLE: 27, +}; + /** * @author SNIPPIK @@ -20,24 +47,10 @@ export const SILENT_FRAME = Buffer.from([0xF8, 0xFF, 0xFE]); * @author SNIPPIK * @description Длительность opus фрейма в ms * @const OPUS_FRAME_SIZE + * @public */ export const OPUS_FRAME_SIZE = 20; -/** - * @author SNIPPIK - * @description Размер opus аудио пакета - * @const OPUS_FRAME_LENGTH - */ -const OPUS_FRAME_LENGTH = 255; - -/** - * @author SNIPPIK - * @description Пустой фрейм для предотвращения чтения null - * @const EMPTY_FRAME - */ -const EMPTY_FRAME = Buffer.alloc(0); - - /** * @author SNIPPIK * @description Базовый класс декодера, ищет opus фрагменты в ogg потоке @@ -45,117 +58,139 @@ const EMPTY_FRAME = Buffer.alloc(0); * @extends TypedEmitter * @private */ -class BaseEncoder extends TypedEmitter { - /** - * @description Отправлен ли 1 аудио пакет - * @private - */ - private _first = true; +class OggOpusParser extends TypedEmitter { + /** Остаток данных от предыдущего фрейма, который не удалось обработать */ + private _remainder: Buffer | null = null; - /** - * @description Временный буфер, для общения между функциями - * @private - */ - public _buffer: Buffer = EMPTY_FRAME; + /** Серийный номер битового потока, к которому мы "привязались" */ + private _bitstreamSerial: number | null = null; /** * @description Функция ищущая актуальный для взятия фрагмент - * @private + * @public */ public parseAvailablePages = (chunk: Buffer) => { - this._buffer = Buffer.concat([this._buffer, chunk]); + // Объединяем входящий фрейм с остатком от предыдущего (если есть) + let buffer = this._remainder ? Buffer.concat([this._remainder, chunk]) : chunk; - // Начинаем обработку буфера с начала - const size = this._buffer.length; let offset = 0; + const totalLength = buffer.length; - // Основной цикл обработки страниц в OGG-потоке - // Цикл продолжается, пока доступно хотя бы 27 байт — минимальный размер заголовка страницы - while (offset + 27 <= size) { - // Проверяем, соответствует ли текущая позиция сигнатуре "OggS" (OGG_MAGIC) - // Это "магическая строка", которая всегда должна быть в начале страницы - if (!this._buffer.subarray(offset, offset + 4).equals(OGG_MAGIC)) { - // Если не совпадает, пытаемся найти ближайшую следующую сигнатуру OGG - const next = this._buffer.indexOf(OGG_MAGIC, offset + 1); + // 2. Цикл обработки страниц Ogg внутри буфера + while (true) { + // Проверка: хватает ли данных хотя бы на минимальный заголовок страницы (27 байт) + if (totalLength - offset < OGG_CONSTANTS.PAGE_HEADER_SIZE) { + break; + } - // Если ничего не найдено — выходим из цикла, т.к. Не можем синхронизироваться - if (next === -1) break; + // Проверка сигнатуры "OggS" + // Мы используем subarray для сравнения без копирования памяти + if (!buffer.subarray(offset, offset + 4).equals(OGG_CONSTANTS.CAPTURE_PATTERN)) { + // Критическая ошибка: потеряна синхронизация или неверный формат. + // В продакшене можно попробовать найти следующее вхождение "OggS" (resync), + // но для простоты выбрасываем ошибку. + this.emit("error", new Error("OggS capture pattern not found. Stream might be corrupted.")); + return; + } - // Перемещаемся к найденной сигнатуре и пробуем снова - offset = next; - continue; + // Читаем версию структуры (должна быть 0) + const version = buffer.readUInt8(offset + OGG_CONSTANTS.OFFSET_VERSION); + if (version !== 0) { + this.emit("error", new Error(`Unsupported Ogg stream structure version: ${version}`)); + return; + } + + // Получаем количество сегментов в этой странице + const pageSegmentsCount = buffer.readUInt8(offset + OGG_CONSTANTS.OFFSET_SEGMENTS_COUNT); + + // Проверка: хватает ли данных на таблицу сегментов + // Заголовок (27) + Таблица сегментов (N байт) + if (totalLength - offset < OGG_CONSTANTS.PAGE_HEADER_SIZE + pageSegmentsCount) { + break; } - // Проверяем, доступен ли весь заголовок страницы (27 байт) - // Бывает, что заголовок ещё не весь пришёл — тогда ждём следующих данных - else if (offset + 27 > this._buffer.length) break; + // Читаем таблицу сегментов (Lacing values) для расчета размера данных + let pageDataSize = 0; + const segmentTableStart = offset + OGG_CONSTANTS.PAGE_HEADER_SIZE; - // Байты [offset + 26] содержит количество сегментов (Lacing Table Entries) - // Каждая запись определяет длину одного Opus-пакета (фрагмента) - const pageSegments = this._buffer.readUInt8(offset + 26); - const headerLength = 27 + pageSegments; + for (let i = 0; i < pageSegmentsCount; i++) { + pageDataSize += buffer.readUInt8(segmentTableStart + i); + } - // Проверяем, пришла ли вся сегментная таблица - // Если нет — выходим, ждём следующих данных - if (offset + headerLength > size) break; + // Полный размер страницы = Заголовок + Таблица сегментов + Сами данные + const totalPageSize = OGG_CONSTANTS.PAGE_HEADER_SIZE + pageSegmentsCount + pageDataSize; - // Проверяем, получена ли вся страница - // Если нет — выход из цикла до прихода полной страницы - const segmentTable = this._buffer.subarray(offset + 27, offset + 27 + pageSegments); - const totalSegmentLength = segmentTable.reduce((sum, val) => sum + val, 0); - const fullPageLength = headerLength + totalSegmentLength; + // Проверка: загружена ли вся страница целиком? + if (totalLength - offset < totalPageSize) { + break; + } - // Извлекаем содержимое страницы — начиная с конца таблицы и до конца страницы - if (offset + fullPageLength > size) break; + // --- ОБРАБОТКА СТРАНИЦЫ --- - // Передаём таблицу сегментов и payload в обработчике, который выделяет Opus-пакеты - const payload = this._buffer.subarray(offset + headerLength, offset + fullPageLength); - this.extractPackets(segmentTable, payload); + // Проверяем Serial Number. Если это первая страница, запоминаем его. + const serial = buffer.readUInt32BE(offset + OGG_CONSTANTS.OFFSET_SERIAL); - // Смещаем offset на конец текущей страницы и продолжаем со следующей - offset += fullPageLength; + if (this._bitstreamSerial === null) this._bitstreamSerial = serial; + else if (this._bitstreamSerial !== serial) { + // Это страница из другого логического потока (мультиплексирование), пропускаем её + offset += totalPageSize; + continue; + } + + // Извлекаем пакеты данных + let dataStart = segmentTableStart + pageSegmentsCount; + let packetSize = 0; + + // Итерируемся по таблице сегментов снова, чтобы собрать пакеты + for (let i = 0; i < pageSegmentsCount; i++) { + const segmentSize = buffer.readUInt8(segmentTableStart + i); + packetSize += segmentSize; + + // Если размер сегмента < 255, это конец логического пакета + if (segmentSize < 255) { + const packet = buffer.subarray(dataStart, dataStart + packetSize); + this.extractPackets(packet); + + // Сдвигаем указатель данных на следующий пакет + dataStart += packetSize; + packetSize = 0; + } + } + + // Сдвигаем глобальный offset на размер обработанной страницы + offset += totalPageSize; } - // После выхода из цикла: обрезаем буфер, удаляя обработанные байты - // Это важно, чтобы избежать переполнения и сохранить только "хвост", который ещё не разобран - this._buffer = this._buffer.subarray(offset); + // Сохраняем необработанный остаток для следующего вызова + if (offset < totalLength) { + // Копируем остаток в новый буфер, чтобы не удерживать ссылку на огромный старый chunk + this._remainder = Buffer.from(buffer.subarray(offset)); + } + + // Если нет остатка, тогда просто удаляем его + // Ключевая оптимизация, при серьезной нагрузке спасает от OggS capture pattern not found + else this._remainder = null; }; /** - * @description Функция выделяющая opus пакет для отправки и передается через событие frame - * @param segmentTable - Буфер сегментов - * @param payload - Данные для корректного поиска сегмента + * @description Обработка и маршрутизация извлеченного пакета + * @param packet - Аудио данные * @private */ - private extractPackets = (segmentTable: Buffer, payload: Buffer) => { - let currentPacket: Buffer[] = [], payloadOffset = 0; - - // Проверяем все фреймы - for (const segmentLength of segmentTable) { - currentPacket.push(payload.subarray(payloadOffset, payloadOffset + segmentLength)); - payloadOffset += segmentLength; - - // Если сегмент меньше 255 — пакет окончен - if (segmentLength < OPUS_FRAME_LENGTH) { - const packet = Buffer.concat(currentPacket); - currentPacket = []; - - // Если найден заголовок - // 19 - Head frame - if (isOpusHead(packet)) continue; - - // Если найден тег - // 296 - Tags frame - else if (isOpusTags(packet)) continue; - - // Если не отправлен 1 opus frame - else if (this._first) { - this.emit("frame", SILENT_FRAME); - this._first = false; - } - - this.emit("frame", packet); - } + private extractPackets(packet: Buffer): void { + const signature = packet.subarray(0, 8); + + // Проверяем сигнатуру является ли это заголовок + if (signature.equals(OGG_CONSTANTS.OPUS_HEAD)) { + // Мы не пушим OpusHead в readable аудио данных, обычно это метаданные. + this.emit("head", packet); + } else if (signature.equals(OGG_CONSTANTS.OPUS_TAGS)) { + // Теги комментариев + this.emit("tags", packet); + } else { + // Это аудио данные. Отправляем дальше. + // Обычно первый пакет после заголовков должен быть отправлен. + this.emit("frame", packet); } }; @@ -164,12 +199,13 @@ class BaseEncoder extends TypedEmitter { * @public */ public destroy() { - // Отправляем пустой пакет последним this.emit("frame", SILENT_FRAME); - this._first = null; - this._buffer = null; + this._remainder = null; + this._bitstreamSerial = null; + // Освобождаем emitter + this.removeAllListeners(); super.destroy(); }; } @@ -187,7 +223,7 @@ export class BufferedEncoder extends Writable { * @description Базовый класс декодера * @private */ - public encoder = new BaseEncoder(); + public parser = new OggOpusParser(); /** * @description Создаем класс @@ -196,15 +232,18 @@ export class BufferedEncoder extends Writable { */ public constructor(options: WritableOptions = { autoDestroy: true }) { super(options); - this.encoder.on("frame", this.emit.bind(this, "frame")); + this.parser.on("frame", this.emit.bind(this, "frame")); + this.parser.on("head", (frame) => this.emit("head", frame)); + this.parser.on("tags", (frame) => this.emit("tags", frame)); + this.parser.on("error", (err) => this.emit("error", err)); }; /** * @description Функция для работы чтения * @protected */ - public _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { - this.encoder.parseAvailablePages(chunk); + public async _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void) { + this.parser.parseAvailablePages(chunk); return callback(); }; @@ -214,11 +253,11 @@ export class BufferedEncoder extends Writable { * @public */ public _destroy(error: Error) { - this.encoder.destroy(); - this.encoder = null; + this.parser.destroy(); + this.parser = null; - super.destroy(error); this.removeAllListeners(); + super.destroy(error); }; } @@ -235,7 +274,7 @@ export class PipeEncoder extends Transform { * @description Базовый класс декодера * @private */ - private encoder = new BaseEncoder(); + private parser = new OggOpusParser(); /** * @description Создаем класс @@ -244,15 +283,18 @@ export class PipeEncoder extends Transform { */ public constructor(options: TransformOptions = { autoDestroy: true }) { super(Object.assign(options, { readableObjectMode: true })); - this.encoder.on("frame", this.push.bind(this)); + this.parser.on("frame", this.push.bind(this)); + this.parser.on("head", (frame) => this.emit("head", frame)); + this.parser.on("tags", (frame) => this.emit("tags", frame)); + this.parser.on("error", (err) => this.emit("error", err)); }; /** * @description При получении данных через pipe или write, модифицируем их для одобрения со стороны discord * @public */ - public _transform = (chunk: Buffer, _: any, done: () => any) => { - this.encoder.parseAvailablePages(chunk); + public _transform = async (chunk: Buffer, _: any, done: () => any) => { + this.parser.parseAvailablePages(chunk); return done(); }; @@ -262,51 +304,19 @@ export class PipeEncoder extends Transform { * @public */ public _destroy(error: Error) { - this.encoder.destroy(); - this.encoder = null; + this.parser.destroy(); + this.parser = null; - super.destroy(error); this.removeAllListeners(); + super.destroy(error); }; } - -/** - * @author SNIPPIK - * @description По строковый расчет opusHead - * @param packet - */ -function isOpusHead(packet: Buffer): boolean { - // "OpusHead" в ASCII: 0x4F 0x70 0x75 0x73 0x48 0x65 0x61 0x64 - return ( - packet.length >= 8 && - packet[4] === 0x48 && // 'H' - packet[5] === 0x65 && // 'e' - packet[6] === 0x61 && // 'a' - packet[7] === 0x64 // 'd' - ); -} - -/** - * @author SNIPPIK - * @description По строковый расчет opusTags - * @param packet - */ -function isOpusTags(packet: Buffer): boolean { - // "OpusTags" в ASCII: 0x4F 0x70 0x75 0x73 0x54 0x61 0x67 0x73 - return ( - packet.length >= 8 && - packet[4] === 0x54 && // 'T' - packet[5] === 0x61 && // 'a' - packet[6] === 0x67 && // 'g' - packet[7] === 0x73 // 's' - ); -} - /** * @author SNIPPIK * @description События для типизации декодера * @interface EncoderEvents + * @private */ interface EncoderEvents { /** @@ -326,4 +336,10 @@ interface EncoderEvents { * @param frame - Основной фрагмент opus потока */ "frame": (frame: Buffer) => void; + + /** + * @description Получение ошибки при конвертировании аудио + * @param error - ошибка + */ + "error": (error: Error) => void; } \ No newline at end of file diff --git a/src/core/audio/process.ts b/src/core/audio/process.ts index c03bf0db..934d6018 100644 --- a/src/core/audio/process.ts +++ b/src/core/audio/process.ts @@ -43,7 +43,6 @@ export class Process { */ public constructor(args: string[], name: string = ffmpeg_path) { const index_resource = args.indexOf("-i"); - const index_seek = args.indexOf("-ss"); // Проверяем на наличие ссылки в пути if (index_resource !== -1) { @@ -53,14 +52,7 @@ export class Process { if (isLink) args.unshift("-reconnect", "1", "-reconnect_delay_max", "5", "-reconnect_on_network_error", "1"); } - // Проверяем на наличие пропуска времени - if (index_seek !== -1) { - const seek = parseInt(args.at(index_seek + 1)); - - // Если указано не число - if (isNaN(seek) || !seek) args.splice(index_seek, 2); - } - + // Добавляем аргументы отключения видео и логирования args.unshift("-vn", "-loglevel", "error", "-hide_banner"); this._process = spawn(name, args, { env: { PATH: process.env.PATH }, @@ -68,26 +60,33 @@ export class Process { shell: false }); + // Добавляем события к процессу for (let event of ["end", "error", "exit"]) { - this._process.once(event, this.destroy); + if (this._process) this._process.once(event, this.destroy); } }; /** * @description Удаляем и отключаемся от процесса + * @returns void * @private */ public destroy = () => { if (this._process) { + // Отключаем все точки данных и удаляем их for (const std of [this._process.stdout, this._process.stderr, this._process.stdin]) { std.removeAllListeners(); std.destroy(); } + // Отключаем события this._process.removeAllListeners(); + // Убиваем процесс this._process.kill("SIGKILL"); - this._process = null; } + + // Удаляем данные процесса + this._process = null; }; } diff --git a/src/core/audio/resource.ts b/src/core/audio/resource.ts index 113e0fa4..7f4071c5 100644 --- a/src/core/audio/resource.ts +++ b/src/core/audio/resource.ts @@ -1,7 +1,9 @@ import { BufferedEncoder, OPUS_FRAME_SIZE, PipeEncoder } from "./opus"; import { Logger } from "#structures/logger"; import { TypedEmitter } from "#structures"; +import type { Track } from "#core/queue"; import { Process } from "./process"; +import { db } from "#app/db"; /** * @author SNIPPIK @@ -45,8 +47,7 @@ class AudioBuffer { * @public */ public set position(position) { - if (position > this.size || position < 0) return; - this._position = position; + this._position = Math.max(0, Math.min(position, this.size)); }; /** @@ -71,10 +72,11 @@ class AudioBuffer { * @public */ public clear = () => { - // Удаляем буферы + this._chunks = []; this._chunks.length = 0; - this._position = null; this._chunks = null; + this._position = null; + this._position = null; }; } @@ -90,19 +92,35 @@ abstract class BaseAudioResource extends TypedEmitter { * @description Можно ли читать поток * @protected */ - protected _readable = false; + protected _readable: boolean; + + /** + * @description Последнее заданное значение затухания + * @protected + */ + protected _afade = 0; /** - * @description Параметр seek, для вычисления времени проигрывания + * @description Модификатор скорости фильтров * @protected */ - protected _seek = 0; + protected _afade_modificator = 1; + + /** + * @description Модификатор скорости высчитанный из фильтров + * @public + */ + public get speed() { + return this._afade_modificator; + }; /** * @description Если чтение возможно * @public */ - public abstract get readable(): boolean; + public get readable(): boolean { + return this._readable; + }; /** * @description Duration в секундах с учётом текущей позиции в буфере и seek-а (предыдущего смещения) @@ -125,14 +143,80 @@ abstract class BaseAudioResource extends TypedEmitter { */ public abstract get packets(): number; + /** + * @description Создание аргументов для FFmpeg + * @protected + */ + protected get arguments(): string[] { + const { seek, track } = this.options; + const args = [ + "-ss", `${seek ?? 0}`, + "-i", track.link, + ]; + + if (track.isBuffered) args.unshift("-accurate_seek"); + + return [ + "-vn", + ...args, + ...this.filters, + + // Указываем формат аудио (ogg/opus) + "-acodec", "libopus", + "-frame_duration", "20", + "-f", "opus", + "pipe:1" + ]; + }; + + /** + * @description Собираем фильтры для ffmpeg + * @protected + */ + protected get filters(): string[] { + const { volume, filters, track, seek } = this.options; + const afade = [ + `volume=${volume / 150}` + ]; + + // Если есть используемые фильтры + if (filters) afade.unshift(filters); + + // Добавляем стартовое время приглушения + afade.push(`afade=t=in:st=0:d=${this._afade}`); + + // Если можно использовать приглушение + if (track.time.total > 0) { + afade.push( + `afade=t=out:st=${Math.max(track.time.total, seek - track.time.total - db.queues.options.fade)}:d=${db.queues.options.fade}` + ); + } + + // Отдаем готовые фильтры + return [ + "-af", afade.join(",") + ]; + }; + /** * @description Создаем класс и задаем параметры * @constructor * @protected */ - protected constructor(protected input_data: AudioResourceOptions) { + protected constructor(public options: AudioResourceOptions) { super(); - this._seek = (input_data.options.seek * 1e3) / OPUS_FRAME_SIZE; + // Ищем модификатор скорости (asetrate, tempo) + let modificator: number = 1.0; + + try { + // Иначе проверяем текущие фильтры + if (options.filters) modificator = Math.max(1.0, getSpeedMultiplier(options.filters)); + } catch (error) { + console.log(error); + } + + this._afade_modificator = modificator; + this._afade = !this.options.swapped ? db.queues.options.fade : db.queues.options.swapFade; }; /** @@ -146,7 +230,7 @@ abstract class BaseAudioResource extends TypedEmitter { const path = options.events.path ? options.input[options.events.path] : options.input; // Запускаем прослушивание события - path["once"](event, (err: any) => { + path["once"](event, (err: Error) => { if (event === "error") this.emit("error", new Error(`AudioResource get ${err}`)); options.events.destroy_callback(options.input); }); @@ -173,8 +257,7 @@ abstract class BaseAudioResource extends TypedEmitter { super.destroy(); this._readable = null; - this._seek = null; - this.input_data = null; + this.options = null; }; } @@ -193,8 +276,7 @@ abstract class BaseAudioResource extends TypedEmitter { export class BufferedAudioResource extends BaseAudioResource { /** * @description Список аудио буферов, для временного хранения - * @protected - * @readonly + * @private */ private _buffer = new AudioBuffer(); @@ -214,8 +296,8 @@ export class BufferedAudioResource extends BaseAudioResource { public get duration() { if (!this._buffer || !this._buffer?.position) return 0; - const time = (this._buffer.position + this._seek) * OPUS_FRAME_SIZE; - return Math.abs(time / 1e3); + const time = this._buffer.position * OPUS_FRAME_SIZE; + return time / 1e3 + this.options.seek; }; /** @@ -245,7 +327,15 @@ export class BufferedAudioResource extends BaseAudioResource { * @public */ public set seek(seek: number) { - this._buffer.position = (seek * 1e3 + OPUS_FRAME_SIZE) / OPUS_FRAME_SIZE; + const index = (seek * 1e3) / OPUS_FRAME_SIZE; + + // Если указано неподходящие значение + if (index > this._buffer.size || index < this._buffer.size) { + this._buffer.position = 0; + return; + } + + this._buffer.position = index; }; /** @@ -255,8 +345,6 @@ export class BufferedAudioResource extends BaseAudioResource { */ public constructor(config: AudioResourceOptions) { super(config); - - const { path, options } = config; const decoder = new BufferedEncoder({ highWaterMark: 512 * 5 // Буфер на ~1:14 }); @@ -280,10 +368,8 @@ export class BufferedAudioResource extends BaseAudioResource { decode: (input) => { input.on("frame", (packet: Buffer) => { if (this._buffer) { - setImmediate(() => { - // Сообщаем что поток можно начать читать - if (!this._buffer.position) this.emit("readable"); - }); + // Сообщаем что поток можно начать читать + if (!this._readable) setImmediate(() => { this.emit("readable");}); // Если создал класс буфера, начинаем кеширование пакетов if (packet) this._buffer.packet = packet; @@ -295,22 +381,7 @@ export class BufferedAudioResource extends BaseAudioResource { // Процесс (FFmpeg) this.input({ // Создание потока - input: new Process([ - // Пропуск времени - "-ss", `${options.seek ?? 0}`, - - // Файл или ссылка на ресурс - "-i", path, - - // Подключаем фильтры - "-af", options.filters, - - // Указываем формат аудио (ogg/opus) - "-acodec", "libopus", - "-frame_duration", "20", - "-f", "opus", - "pipe:" - ]), + input: new Process(this.arguments), // Управление событиями events: { @@ -326,7 +397,7 @@ export class BufferedAudioResource extends BaseAudioResource { }, // Начало кодирования - decode: (input: Process) => { + decode: (input) => { input.stdout.pipe(decoder); }, }); @@ -336,10 +407,10 @@ export class BufferedAudioResource extends BaseAudioResource { * @description Обновление потока, без потерь * @public */ - public refresh = () => { - this._seek = 0; + /*public refresh = () => { this._buffer.position = 0; - }; + this._seek = 0; + };*/ /** * @description Удаляем ненужные данные @@ -347,7 +418,7 @@ export class BufferedAudioResource extends BaseAudioResource { */ public destroy = () => { super.destroy(); - this._buffer.clear(); + this._buffer?.clear(); this._buffer = null; }; } @@ -378,14 +449,6 @@ export class PipeAudioResource extends BaseAudioResource { */ private played = 0; - /** - * @description Если чтение возможно - * @public - */ - public get readable(): boolean { - return this.encoder.readable; - }; - /** * @description Выдаем фрагмент потока * @help (время пакета 20ms) @@ -418,8 +481,8 @@ export class PipeAudioResource extends BaseAudioResource { public get duration() { if (!this.played) return 0; - const time = (this.played + this._seek) * OPUS_FRAME_SIZE; - return Math.abs(time / 1e3); + const time = this.played * OPUS_FRAME_SIZE; + return time / 1e3 + this.options.seek; }; /** @@ -428,7 +491,7 @@ export class PipeAudioResource extends BaseAudioResource { * @public */ public set seek(seek: number) { - let steps = (seek * 1e3 + OPUS_FRAME_SIZE) / OPUS_FRAME_SIZE; + let steps = ((seek * 1e3) / OPUS_FRAME_SIZE); // Если диапазон слишком мал или большой if (steps >= 0 || steps > this.packets) return; @@ -447,7 +510,6 @@ export class PipeAudioResource extends BaseAudioResource { */ public constructor(config: AudioResourceOptions) { super(config); - const {path, options} = config; // Расшифровщик this.input({ @@ -476,22 +538,7 @@ export class PipeAudioResource extends BaseAudioResource { // Процесс (FFmpeg) this.input({ // Создание потока - input: new Process([ - // Пропуск времени - "-ss", `${options.seek ?? 0}`, - - // Файл или ссылка на ресурс - "-i", path, - - // Подключаем фильтры - "-af", options.filters, - - // Указываем формат аудио (ogg/opus) - "-acodec", "libopus", - "-frame_duration", "20", - "-f", "opus", - "pipe:" - ]), + input: new Process(this.arguments), // Управление событиями events: { @@ -517,31 +564,130 @@ export class PipeAudioResource extends BaseAudioResource { */ public destroy = () => { super.destroy(); - this.played = null; this.encoder = null; }; } +/** + * @author SNIPPIK + * @description Регулярное выражение для захвата числового множителя из строки 'asetrate=48000*X'. + * @example "asetrate=48000*1.2" -> "1.2" + * @const ASSETRATE_MULTIPLIER_PATTERN + * @private + */ +const ASSETRATE_MULTIPLIER_PATTERN = /^asetrate=48000\*([\d\.]+)(?:,.*)?$/; + +/** + * @author SNIPPIK + * @description Регулярное выражение для захвата числового множителя из строки 'atempo=X'. + * @example "atempo=2" -> "2" + * @const ATEMPO_MULTIPLIER_PATTERN + * @private + */ +const ATEMPO_MULTIPLIER_PATTERN = /^atempo=([\d\.]+)(?:,.*)?$/; + +/** + * @author SNIPPIK + * @description Извлекает числовой множитель (rate) из фильтра asetrate. + * @param filtersString Строка фильтров FFmpeg. + * @returns Извлеченное значение как строка, или null. + * @function extractAsetrateMultiplier + * @private + */ +function extractAsetrateMultiplier(filtersString: string): string | null { + const match = filtersString.match(ASSETRATE_MULTIPLIER_PATTERN); + return match ? match[1] : null; +} + +/** + * @author SNIPPIK + * @description Извлекает числовой множитель (rate) из фильтра atempo. + * @param filtersString Строка фильтров FFmpeg. + * @returns Извлеченное значение как строка, или null. + * @function extractAtempoMultiplier + * @private + */ +function extractAtempoMultiplier(filtersString: string): string | null { + const match = filtersString.match(ATEMPO_MULTIPLIER_PATTERN); + return match ? match[1] : null; +} + +/** + * @author SNIPPIK + * @description Центральная функция для получения множителя скорости (Speed Multiplier) + * из строки фильтров, проверяя сначала asetrate, затем atempo. + * @param filtersString Строка фильтров FFmpeg. + * @returns Числовой множитель скорости или 1.0, если не найден. + * @function getSpeedMultiplier + * @private + */ +function getSpeedMultiplier(filtersString: string): number { + if (!filtersString) return 1.0; + + // Извлекаем множитель asetrate + const asetrateStr = extractAsetrateMultiplier(filtersString); + + // Конвертируем в число. Если не найдено, используем 1.0 (нет изменения) + const asetrateMultiplier = asetrateStr ? parseFloat(asetrateStr) : 1.0; + + // Извлекаем множитель atempo + const atempoStr = extractAtempoMultiplier(filtersString); + + // Конвертируем в число. Если не найдено, используем 1.0 (нет изменения) + const atempoMultiplier = atempoStr ? parseFloat(atempoStr) : 1.0; + + // Общий множитель - это произведение (умножение) двух эффектов. + const totalMultiplier = asetrateMultiplier * atempoMultiplier; + + // Проверка на NaN и возврат результата. + return isNaN(totalMultiplier) ? 1.0 : totalMultiplier; +} /** * @author SNIPPIK * @description Параметры для создания класса AudioResource * @interface AudioResourceOptions + * @private */ interface AudioResourceOptions { - path: string; - options: { - seek?: number; - filters?: string; - } + /** + * @description Трек который надо включить + * @public + */ + track: Track; + + /** + * @description Громкость аудио потока + * @public + */ + volume: number; + + /** + * @description Время пропуска, с этой временной точки включится аудио + * @public + */ + seek?: number; + + /** + * @description Фильтры ffmpeg для включения через filter_complex + * @public + */ + filters: string; + + /** + * @description Смена аудио потока? + * @public + */ + swapped: boolean; } /** * @author SNIPPIK * @description События аудио потока * @interface AudioResourceEvents + * @private */ interface AudioResourceEvents { /** @@ -573,6 +719,7 @@ interface AudioResourceEvents { * @author SNIPPIK * @description Параметры для функции совмещения потоков * @interface AudioResourceInput + * @private */ interface AudioResourceInput { /** @@ -596,12 +743,12 @@ interface AudioResourceInput { * @description Если надо конкретно откуда-то отслеживать события * @readonly */ - path?: string + path?: string; }; /** * @description Как начать передавать данные из потока * @readonly */ - readonly decode: (input: T) => void; + readonly decode?: (input: T) => void; } \ No newline at end of file diff --git a/src/core/player/controllers/filters.ts b/src/core/player/controllers/filters.ts index 5be13609..1b9e08f0 100644 --- a/src/core/player/controllers/filters.ts +++ b/src/core/player/controllers/filters.ts @@ -1,6 +1,5 @@ import type { LocalizationMap } from "discord-api-types/v10"; import { SetArray } from "#structures"; -import { db } from "#app/db"; /** * @author SNIPPIK @@ -10,39 +9,84 @@ import { db } from "#app/db"; */ export class ControllerFilters extends SetArray { /** - * @description Сжимаем фильтры для работы ffmpeg - * @returns string + * @description Скомпилированные фильтры, заранее подготовленные + * @private + */ + private _filters: string = null; + + /** + * @description Выдаем скомпилированные фильтры + * @public + */ + public get filters() { + return this._filters; + }; + + /** + * @description Добавляем фильтр/ы из списка + * @param item - Фильтр * @public */ - public toString = (time: number, volume: number, isSwap = false) => { - const { fade, optimization, swapFade } = db.queues.options; - const filters: string[] = [`volume=${volume / 150}`]; - const fade_int = isSwap ? swapFade : fade; - const live = time === 0; - - // Если есть приглушение аудио - if (fade_int) { - filters.push(`afade=t=in:st=0:d=${fade_int}`); - - // Если есть время трека - if (typeof time === "number" && time >= optimization && !live) { - const start = time - (fade + 5); - - if (start > 0) filters.push(`afade=t=out:st=${start}:d=${fade + 5}`); - } + public add(item: T) { + super.add(item); + this._filters = this.parseFilters(); + return this; + }; + + /** + * @description Удаляем фильтр/ы из списка + * @param item - Фильтр + * @public + */ + public delete(item: T) { + super.delete(item); + this._filters = this.parseFilters(); + return true; + }; + + /** + * @description Подготавливаем фильтры в строчку + * @private + */ + private parseFilters = () => { + const filters: string[] = []; + + // Добавляем пользовательские фильтры + for (const { filter, args, argument } of this) { + if (!filter || typeof filter !== "string") continue; + const argString = args ? `${filter}${argument ?? ""}` : filter; + filters.push(argString.trim()); } - // Если трек не live - if (!live) { - // Берем данные из всех фильтров - for (const enabled of this) { - const {filter, args, argument} = enabled; + return filters.join(","); + }; + + /** + * @description Проверяем совместимость фильтров + * @param filter - Сам фильтр + * @public + */ + public hasUnsupported = (filter: T): null | [string, string] => { + // Делаем проверку на совместимость + // Проверяем, не конфликтует ли новый фильтр с уже включёнными + for (const enabledFilter of this) { + // Новый фильтр несовместим с уже включённым? + if (filter.unsupported && filter.unsupported?.includes(enabledFilter.name)) return [filter.name, enabledFilter.name]; - filters.push(args ? `${filter}${argument ?? ""}` : filter); - } + // Уже включённый фильтр несовместим с новым? + else if (enabledFilter.unsupported.includes(filter.name)) return [enabledFilter.name, filter.name]; } - return filters.join(",") + return null; + }; + + /** + * @description Чистим фильтры включая подготовленные + * @public + */ + public clear() { + this._filters = null; + super.clear(); }; } diff --git a/src/core/player/filters.json b/src/core/player/filters.json index 0b45a23f..bb9f26bf 100644 --- a/src/core/player/filters.json +++ b/src/core/player/filters.json @@ -1,162 +1,19 @@ [ - { - "name": "stable", - "locale": { - "en-US": "Normalizes and stabilizes the volume for consistent loudness.", - "ru": "Нормализует и стабилизирует громкость для постоянного уровня звука." - }, - "unsupported": [], - "filter": "loudnorm", - "args": false - }, - { - "name": "nightcore", - "locale": { - "en-US": "Speeds up playback by 20% and raises pitch for a nightcore effect.", - "ru": "Ускоряет воспроизведение на 20% и повышает тональность для эффекта nightcore." - }, - "unsupported": ["vaporwave", "demonic", "reverse", "phaser", "vinyl"], - "filter": "asetrate=48000*1.20,aresample=128000", - "args": false - }, - { - "name": "vaporwave", - "locale": { - "en-US": "Slows down playback and lowers pitch, creating a vaporwave atmosphere.", - "ru": "Замедляет воспроизведение и понижает тональность, создавая атмосферу vaporwave." - }, - "unsupported": ["nightcore", "demonic", "speed", "treble", "reverse", "vinyl"], - "filter": "asetrate=48000*0.8,aresample=48000,atempo=1.1", - "args": false - }, - { - "name": "demonic", - "locale": { - "en-US": "Deep, distorted effect with echoes — makes a track sound demonic.", - "ru": "Глубокий и искажённый эффект с эхо — превращает трек в демонический." - }, - "unsupported": ["stable", "nightcore", "vaporwave", "bass", "speed", "vinyl"], - "filter": "loudnorm=I=-16:TP=-1.5:LRA=11,asetrate=48000*1.20,aresample=128000,bass=g=5,aecho=0.8:0.9:1000:0.3", - "args": false - }, - { - "name": "vinyl", - "locale": { - "en-US": "Adds vinyl record hiss, crackle, and a retro lo-fi effect.", - "ru": "Добавляет шипение, потрескивания и ретро lo-fi эффект винила." - }, - "unsupported": ["nightcore", "demonic", "vaporwave"], - "filter": "acrusher=bits=8:mode=log:mix=0.7,highpass=f=200,lowpass=f=5000", - "args": false - }, - { - "name": "speed", - "locale": { - "en-US": "Changes playback speed (1.0 = normal, up to 3x faster).", - "ru": "Изменяет скорость воспроизведения (1.0 = нормальная, до 3х быстрее)." - }, - "unsupported": [], - "filter": "atempo=", - "args": [1, 3] - }, - { - "name": "bass", - "locale": { - "en-US": "Boosts low frequencies for deeper and punchier bass.", - "ru": "Усиливает низкие частоты для более глубокого и мощного баса." - }, - "unsupported": ["demonic"], - "filter": "bass=g=", - "args": [1, 30] - }, - { - "name": "sub_boost", - "locale": { - "en-US": "Emphasizes sub-bass frequencies for a deeper rumble.", - "ru": "Подчёркивает саб-бас частоты для более глубокого звучания." - }, - "unsupported": ["demonic"], - "filter": "asubboost", - "args": false - }, - { - "name": "mono", - "locale": { - "en-US": "Mixes both stereo channels into a single mono output.", - "ru": "Объединяет оба стерео канала в один моно сигнал." - }, - "unsupported": ["8d"], - "filter": "pan=mono|c0=.5*c0+.5*c1", - "args": false - }, - { - "name": "treble", - "locale": { - "en-US": "Boosts high frequencies up to 20 kHz for brighter sound.", - "ru": "Усиливает высокие частоты до 20 kHz для более яркого звука." - }, - "unsupported": [], - "filter": "treble=g=5", - "args": false - }, - { - "name": "reverse", - "locale": { - "en-US": "Plays the audio track backwards.", - "ru": "Воспроизводит аудио трек в обратном направлении." - }, - "unsupported": [], - "filter": "areverse", - "args": false - }, - { - "name": "flanger", - "locale": { - "en-US": "Creates a sweeping, ‘flying’ sound with time-delayed signal mixing.", - "ru": "Создаёт переливающийся «летящий» звук через смешивание с задержкой." - }, - "unsupported": [], - "filter": "flanger", - "args": false - }, - { - "name": "haas", - "locale": { - "en-US": "Haas effect (precedence) widens stereo image using channel delay.", - "ru": "Эффект Хааса (предшествования) расширяет стерео за счёт задержки каналов." - }, - "unsupported": ["mono"], - "filter": "haas", - "args": false - }, - { - "name": "echo", - "locale": { - "en-US": "Adds multiple repeating echoes to the audio.", - "ru": "Добавляет многократные повторяющиеся эхо к звуку." - }, - "unsupported": [], - "filter": "aecho=0.8:0.9:1000:0.3", - "args": false - }, - { - "name": "8d", - "locale": { - "en-US": "Simulates 3D spatial audio by rotating sound around the listener.", - "ru": "Имитирует 3D-пространство, вращая звук вокруг слушателя." - }, - "unsupported": ["mono"], - "filter": "apulsator=hz=0.09", - "args": false - }, - { - "name": "phaser", - "locale": { - "en-US": "Applies a sweeping phase effect, creating a ‘shimmering’ sound.", - "ru": "Накладывает фазовый сдвиг, создавая «мерцающий» эффект звучания." - }, - "unsupported": [], - "filter": "aphaser=in_gain=0.4", - "args": false - } + { "name": "normalize", "locale": { "en-US": "Normalizes volume for consistent loudness.", "ru": "Нормализует громкость для равномерного звучания." }, "filter": "dynaudnorm=p=0.9:s=5", "args": false }, + { "name": "nightcore", "locale": { "en-US": "Increases speed by 20 % and raises pitch.", "ru": "Ускоряет воспроизведение на 20 % и повышает тональность." }, "unsupported": ["vaporwave","demonic","reverse","phaser","vinyl","normalize"], "filter": "asetrate=48000*1.25,aresample=128000", "args": false }, + { "name": "vaporwave", "locale": { "en-US": "Slows down playback and lowers pitch.", "ru": "Замедляет воспроизведение и понижает тональность." }, "unsupported": ["nightcore","demonic","speed","treble","reverse","vinyl","normalize"], "filter": "asetrate=48000*0.8,aresample=48000,atempo=1.1", "args": false }, + { "name": "demonic", "locale": { "en-US": "Deepens voice with distortion and echo.", "ru": "Понижает голос с добавлением искажения и эха." }, "unsupported": ["normalize","nightcore","vaporwave","bass","speed","vinyl"], "filter": "loudnorm=I=-16:TP=-1.5:LRA=11,asetrate=48000*1.20,aresample=128000,bass=g=5,aecho=0.8:0.9:1000:0.3", "args": false }, + { "name": "vinyl", "locale": { "en-US": "Adds vinyl record noise and slight distortion.", "ru": "Добавляет шум и потрескивания виниловой пластинки." }, "unsupported": ["nightcore","demonic","vaporwave","normalize"], "filter": "acrusher=bits=8:mode=log:mix=0.7,highpass=f=200,lowpass=f=5000", "args": false }, + { "name": "speed", "locale": { "en-US": "Adjusts playback speed (0.5× – 3×).", "ru": "Изменяет скорость воспроизведения (0.5× – 3×)." }, "filter": "atempo=", "args": [0.5, 3] }, + { "name": "bass", "locale": { "en-US": "Boosts low frequencies.", "ru": "Усиливает низкие частоты." }, "unsupported": ["demonic"], "filter": "bass=g=", "args": [1, 30] }, + { "name": "subboost", "locale": { "en-US": "Enhances sub-bass frequencies (30–60 Hz).", "ru": "Усиливает суб-бас (30–60 Гц)." }, "unsupported": ["demonic"], "filter": "asubboost", "args": false }, + { "name": "mono", "locale": { "en-US": "Converts stereo to mono.", "ru": "Преобразует стерео в моно." }, "unsupported": ["8d","stereo_swap"], "filter": "pan=mono|c0=.5*c0+.5*c1", "args": false }, + { "name": "stereo_swap", "locale": { "en-US": "Swaps left and right channels.", "ru": "Меняет левый и правый каналы местами." }, "unsupported": ["mono","8d"], "filter": "pan=stereo|c0=c1|c1=c0", "args": false }, + { "name": "treble", "locale": { "en-US": "Boosts high frequencies.", "ru": "Усиливает высокие частоты." }, "filter": "treble=g=", "args": [-10, 15] }, + { "name": "reverse", "locale": { "en-US": "Plays audio in reverse.", "ru": "Воспроизводит аудио в обратном направлении." }, "filter": "areverse", "args": false }, + { "name": "flanger", "locale": { "en-US": "Applies flanger effect.", "ru": "Добавляет эффект фленджера." }, "filter": "flanger", "args": false }, + { "name": "haas", "locale": { "en-US": "Widens stereo image using Haas effect.", "ru": "Расширяет стереобазу эффектом Хааса." }, "unsupported": ["mono"], "filter": "haas", "args": false }, + { "name": "echo", "locale": { "en-US": "Adds repeating echo.", "ru": "Добавляет повторяющееся эхо." }, "filter": "aecho=0.8:0.9:1000:0.3", "args": false }, + { "name": "8d", "locale": { "en-US": "Creates rotating surround effect.", "ru": "Создаёт эффект вращающегося звука вокруг слушателя." }, "unsupported": ["mono","stereo_swap"], "filter": "apulsator=hz=0.09", "args": false }, + { "name": "phaser", "locale": { "en-US": "Applies phaser effect.", "ru": "Добавляет эффект фазера." }, "filter": "aphaser=in_gain=0.4", "args": false } ] \ No newline at end of file diff --git a/src/core/player/index.ts b/src/core/player/index.ts index b0941343..dfee2886 100644 --- a/src/core/player/index.ts +++ b/src/core/player/index.ts @@ -14,24 +14,32 @@ export interface AudioPlayerEvents { * @description Событие при котором плеер начинает завершение текущего трека * @param player - Текущий плеер * @param seek - Время пропуска если оно есть + * @returns void + * @readonly */ readonly "player/ended": (player: AudioPlayer, seek: number) => void; /** * @description Событие при котором плеер ожидает новый трек * @param player - Текущий плеер + * @returns void + * @readonly */ readonly "player/wait": (player: AudioPlayer) => void; /** * @description Событие при котором плеер встает на паузу и ожидает дальнейших действий * @param player - Текущий плеер + * @returns void + * @readonly */ readonly "player/pause": (player: AudioPlayer) => void; /** * @description Событие при котором плеер начинает проигрывание * @param player - Текущий плеер + * @returns void + * @readonly */ readonly "player/playing": (player: AudioPlayer) => void; @@ -41,6 +49,8 @@ export interface AudioPlayerEvents { * @param err - Ошибка в формате string * @param skip - Если надо пропустить трек * @param position - Позиция трека в очереди + * @returns void + * @readonly */ readonly "player/error": (player: AudioPlayer, err: string, track?: {skip: boolean, position: number}) => void; } \ No newline at end of file diff --git a/src/core/player/modules/audio.ts b/src/core/player/modules/audio.ts index 202cc380..72dd61e5 100644 --- a/src/core/player/modules/audio.ts +++ b/src/core/player/modules/audio.ts @@ -1,6 +1,23 @@ import { BufferedAudioResource, PipeAudioResource } from "#core/audio"; +import { Logger } from "#structures"; import { db } from "#app/db"; +/** + * @author SNIPPIK + * @description Время ожидания потока live трека + * @const TIMEOUT_STREAM_PIPE + * @private + */ +const TIMEOUT_STREAM_PIPE = 15e3; + +/** + * @author SNIPPIK + * @description Время ожидания потока трека + * @const TIMEOUT_STREAM_BUFFERED + * @private + */ +const TIMEOUT_STREAM_BUFFERED = 10e3; + /** * @author SNIPPIK * @description Класс для управления включенным потоком, хранит в себе все данные потока @@ -15,37 +32,34 @@ export class PlayerAudio { * @description Поток, расшифровывает ogg/opus в чистый opus он же sl16e * @private */ - private _audio: T; + private _audio: T | null; /** * @description Поток, находящийся в ожидании загрузки и проигрывания * @private */ - private _pre_audio: T; + private _pre_audio: T | null; /** * @description Таймер чтения аудио потока, для авто удаления * @private */ - private _timeout: NodeJS.Timeout | null = null; + private _timeout: NodeJS.Timeout | null; /** * @description Громкость аудио, по умолчанию берется параметр из db/env - * @protected + * @private */ private _volume = db.queues.options.volume; /** * @description Изменяем значение громкости у аудио - * @param vol - Громкость допустимый диапазон (10-200) + * @param volume - Громкость допустимый диапазон (10-200) * @public */ - public set volume(vol: number) { - if (vol > 200) vol = 200; - else if (vol < 10) vol = 10; - + public set volume(volume: number) { // Меняем параметр - this._volume = vol; + this._volume = volume > 200 ? 200 : volume < 1 ? 10 : volume; }; /** @@ -80,41 +94,46 @@ export class PlayerAudio { */ public set preload(stream: T) { // Если уже есть пред-загруженное аудио - if (this._pre_audio) this._pre_audio?.destroy(); + if (this._pre_audio) { + clearTimeout(this._timeout); + this._pre_audio.destroy(); + } // Записываем аудио в пред-загруженные this._pre_audio = stream; - // Если аудио поток не ответил в течении указанного времени - this._timeout = setTimeout(() => { - // Отправляем данные событию для отображения ошибки - stream.emit("error", new Error("Timeout: the stream has been exceeded!")); - }, 10e3); - // Отслеживаем аудио поток на ошибки - (stream as BufferedAudioResource).once("error", async () => { + (stream as BufferedAudioResource).once("error", (error) => { // Удаляем таймер clearTimeout(this._timeout); // Уничтожаем новый аудио поток stream.destroy(); this._pre_audio = null; + + Logger.log("ERROR", error); }); // Отслеживаем аудио поток на готовность к чтению - (stream as BufferedAudioResource).once("readable", async () => { - const oldStream = this._audio; - + (stream as BufferedAudioResource).once("readable", () => { // Удаляем таймер clearTimeout(this._timeout); + // Если есть активный поток + if (this._audio) { + this._audio.destroy(); + } + // Перезаписываем текущий поток this._audio = stream; this._pre_audio = null; - - // Если есть активный поток - if (oldStream) oldStream.destroy(); }); + + // Установка таймера ожидания + const waitTime = stream.options.track.isBuffered ? TIMEOUT_STREAM_BUFFERED : TIMEOUT_STREAM_PIPE; + this._timeout = setTimeout(() => { + stream.emit("error", Error("Timeout: the stream has been exceeded!")); + }, waitTime); }; /** diff --git a/src/core/player/modules/progress.ts b/src/core/player/modules/progress.ts index 0bc881fa..c95a1ddd 100644 --- a/src/core/player/modules/progress.ts +++ b/src/core/player/modules/progress.ts @@ -1,5 +1,46 @@ -import { RestServerSide } from "#handler/rest"; +import type { RestAPIS_Names } from "#handler/rest/index.decorator"; import { env } from "#app/env"; +import { db } from "#app/db"; + +/** + * @author SNIPPIK + * @description Функция для отложенной загрузки кнопок + * @function initButtons + * @private + */ +function initButtons() { + buttons = db.api.platforms.array.reduce((acc, api) => { + const platform = api.name.toLowerCase(); + const inEnv = env.get(`progress.button.${platform}`, null); + + if (inEnv) acc[`button_${platform}`] = inEnv; + return acc; + }, { + button: env.get("progress.button"), + }); +} + +/** + * @author SNIPPIK + * @description Доступные элементы для создания прогресс бара + * @type Elements + * @private + */ +type Elements = "left" | "center" | "right"; + +/** + * @author SNIPPIK + * @description Получение списка для создания прогресс бара + * @param type - Тип элемента + * @private + */ +function initElement(type: "empty" | "not_empty") { + const keys = ["left", "center", "right"]; + return keys.reduce((acc, key) => { + acc[key] = env.get(`progress.${type}.${key}`); + return acc; + }, {} as Record); +} /** * @author SNIPPIK @@ -10,56 +51,20 @@ const emoji = { /** * @description Пустой прогресс бар */ - empty: { - left: env.get("progress.empty.left"), - center: env.get("progress.empty.center"), - right: env.get("progress.empty.right") - }, + empty: initElement("empty"), /** * @description Не пустой прогресс бар */ - upped: { - left: env.get("progress.not_empty.left"), - center: env.get("progress.not_empty.center"), - right: env.get("progress.not_empty.right") - }, - - /** - * @description Разделение прогресс бара, поддерживает платформы - */ - button: env.get("progress.button"), - - /** - * @description Разделение прогресс бара, поддерживает платформы - */ - button_vk: env.get("progress.button.vk"), + upped: initElement("not_empty") +}; - /** - * @description Разделение прогресс бара, поддерживает платформы - */ - button_yandex: env.get("progress.button.yandex"), - - /** - * @description Разделение прогресс бара, поддерживает платформы - */ - button_youtube: env.get("progress.button.youtube"), - - /** - * @description Разделение прогресс бара, поддерживает платформы - */ - button_spotify: env.get("progress.button.spotify"), - - /** - * @description Разделение прогресс бара, поддерживает платформы - */ - button_soundcloud: env.get("progress.button.soundcloud"), - - /** - * @description Разделение прогресс бара, поддерживает платформы - */ - button_deezer: env.get("progress.button.deezer") -} +/** + * @author SNIPPIK + * @description Все найденные кнопки платформ + * @private + */ +let buttons: { [key: string]: string; } = null; /** * @author SNIPPIK @@ -74,7 +79,7 @@ export class PlayerProgress { * @constructor * @public */ - public constructor(private readonly size: number = 15) {}; + public constructor(private size: number = 15) {}; /** * @description Получаем готовый прогресс бар @@ -82,8 +87,10 @@ export class PlayerProgress { * @public */ public bar = ({ duration, platform }: PlayerProgressInput): string => { + if (!buttons) initButtons(); + const { current, total } = duration; - const button = emoji[`button_${platform.toLowerCase()}`] || emoji.button; + const button = buttons[`button_${platform.toLowerCase()}`] ?? buttons["button"]; // Если live-трек if (total === 0) { @@ -116,16 +123,18 @@ export class PlayerProgress { * @author SNIPPIK * @description Данные для создания прогресс бара * @interface PlayerProgressInput + * @private */ interface PlayerProgressInput { /** * @description Название платформы - * @readonly + * @public */ - platform: RestServerSide.API["name"]; + platform: RestAPIS_Names; /** * @description Данные о времени трека + * @public */ duration: { // Текущее время diff --git a/src/core/player/structures/player.ts b/src/core/player/structures/player.ts index f75cb204..166c0a3e 100644 --- a/src/core/player/structures/player.ts +++ b/src/core/player/structures/player.ts @@ -1,6 +1,6 @@ -import { BufferedAudioResource, PipeAudioResource, SILENT_FRAME } from "#core/audio"; +import { type AudioFilter, type AudioPlayerEvents, ControllerFilters } from "#core/player"; +import { BufferedAudioResource, OPUS_FRAME_SIZE, PipeAudioResource, SILENT_FRAME } from "#core/audio"; import { ControllerTracks, ControllerVoice, RepeatType, Track } from "#core/queue"; -import { AudioFilter, AudioPlayerEvents, ControllerFilters } from "#core/player"; import { PlayerProgress } from "../modules/progress"; import { Logger, TypedEmitter } from "#structures"; import type { VoiceConnection } from "#core/voice"; @@ -17,26 +17,19 @@ const Progress = new PlayerProgress(); /** * @author SNIPPIK - * @description Безопасное время для буферизации трека - * @const PLAYER_BUFFERED_TIME - * @public - */ -export const PLAYER_BUFFERED_TIME = 500; - -/** - * @author SNIPPIK - * @description Безопасное время между аудио потоками + * @description Безопасное время между отправкой аудио пакетов * @const PLAYER_PAUSE_OFFSET * @private */ -const PLAYER_PAUSE_OFFSET = 2500; +const PLAYER_PAUSE_OFFSET = 3000; /** * @author SNIPPIK - * @description Поддерживающиеся потоковые аудио стримы - * @type AudioPlayerAudio + * @description Безопасное время между аудио потоками + * @const PLAYER_TIMEOUT_OFFSET + * @private */ -type AudioPlayerAudio = BufferedAudioResource | PipeAudioResource; +const PLAYER_TIMEOUT_OFFSET = 2000; /** * @author SNIPPIK @@ -48,8 +41,13 @@ type AudioPlayerAudio = BufferedAudioResource | PipeAudioResource; * # Особенности * - Плеер не даст загрузить новый трек если прошлый не загружен! Через 10 сек можно будет загрузить новый! * - Поддерживает hot swap, не ломает jitter buffer (AudioPlayerTimeout) + * - Умеет накладывать аудио на аудио, через ffmpeg + * - Высокая надежность, практически невозможно сломать */ export class AudioPlayer extends TypedEmitter { + public _stepCounter: number = 1; // Требуется для подстройки под голосовое соединение + public _counter: number = 0; // Требуется для подстройки под голосовое соединение + /** * @description Текущий статус плеера, при создании он должен быть в ожидании * @private @@ -74,7 +72,7 @@ export class AudioPlayer extends TypedEmitter { * @readonly * @private */ - protected _audio = new PlayerAudio(); + protected _audio = new PlayerAudio(); /** * @description Делаем tracks параметр публичным для использования вне класса @@ -147,6 +145,14 @@ export class AudioPlayer extends TypedEmitter { return `\n\`\`${current.duration()}\`\` ${bar} \`\`${time.split}\`\``; }; + /** + * @description Задержка плеера между отправкой аудио пакетов + * @public + */ + public get latency() { + return this._stepCounter * OPUS_FRAME_SIZE; + }; + /** * @description Проверяем играет ли плеер * @return boolean @@ -157,18 +163,46 @@ export class AudioPlayer extends TypedEmitter { if (this._status === "player/wait" || this._status === "player/pause") return false; // Если голосовое состояние не позволяет отправлять пакеты - else if (!this._voice.connection && !this._voice.connection?.ready) return false; + else if (!this._voice.connection?.isReadyToSend) return false; + + const currentAudio = this._audio.current; // Если поток не читается, переходим в состояние ожидания - else if (!this._audio.current && !this._audio.current.packets || !this._audio.current?.readable) { + if (!currentAudio && currentAudio.packets > 0 || !currentAudio.readable) { this.status = "player/wait"; - this.disableCycle(); + this.cycle = false; return false; } return true; }; + /** + * @description Включение/Отключение плеера в цикла + * @private + */ + private set cycle(isActive: boolean) { + // Подключаем плеер к циклу + if (isActive) { + // Если нет плеера в цикле + if (!db.queues.cycles.players.has(this)) { + db.queues.cycles.players.add(this); + + Logger.log("DEBUG", `[AudioPlayer/${this.id}] pushed in cycle`); + } + } + + // Отключаем плеер от цикла + else if (!isActive) { + // Если есть плеер в цикле + if (db.queues.cycles.players.has(this)) { + db.queues.cycles.players.delete(this); + + Logger.log("DEBUG", `[AudioPlayer/${this.id}] removed from cycle`); + } + } + }; + /** * @description Задаем параметры плеера перед началом работы * @param _tracks - Ссылка на треки из очереди @@ -178,19 +212,19 @@ export class AudioPlayer extends TypedEmitter { * @public */ public constructor( - /** - * @description Уникальный идентификатор сервера, для привязки плеера к серверу - * @protected - * @abstract - */ + /** Ссылка на класс с треками */ protected _tracks: ControllerTracks, + + /** Ссылка на класс с голосовым подключением */ protected _voice: ControllerVoice, - protected id?: string, + + /** Уникальный id плеера */ + public id: string, ) { super(); - // Запускаем проигрывание аудио после создания плеера - setImmediate(this.play); + // Используем arrow function чтобы не потерять контекст и обработать ошибку + process.nextTick(() => this.play().catch(err => this.emit("player/error", this, err))); /** * @description Событие смены позиции плеера @@ -215,7 +249,7 @@ export class AudioPlayer extends TypedEmitter { if (related instanceof Error) Logger.log("ERROR", related); // Если нет похожих треков - else if (!related.length) this.emit("player/error", player, "Autoplay System: failed get related tracks"); + else if (!related.length) player.emit("player/error", player, "Autoplay System: failed get related tracks"); // Добавляем треки else { @@ -242,19 +276,16 @@ export class AudioPlayer extends TypedEmitter { // Если повтор выключен if (repeat === RepeatType.None) { - // Если очередь началась заново if (current + 1 === tracks.total && tracks.position === 0) { - const queue = db.queues.get(player.id); - - return queue.cleanup(); + return db.queues.get(player.id)?.cleanup(); } } } // Через время запускаем трек, что-бы не нарушать работу VoiceSocket // Что будет если нарушить работу VoiceSocket, пинг >=1000 - return player.play(0, PLAYER_PAUSE_OFFSET); + return player.play(0, PLAYER_TIMEOUT_OFFSET); }); /** @@ -265,13 +296,19 @@ export class AudioPlayer extends TypedEmitter { const queue = db.queues.get(player.id); const current = player.tracks.position; + // Позиция трека для сообщения + const position = skip?.position ? skip?.position : current; + + // Выводим сообщение об ошибке + db.events.emitter.emit("message/error", queue, error, position); + // Если надо пропустить трек if (skip) { // Если надо пропустить текущую позицию if (skip.position === current) { // Если плеер играет, то не пропускаем if (player?.audio && player?.audio?.current?.packets > 0) return; - this.emit("player/wait", player); + player.emit("player/wait", player); } // Если следующих треков нет @@ -279,41 +316,9 @@ export class AudioPlayer extends TypedEmitter { player.tracks.remove(skip.position); } - - // Позиция трека для сообщения - const position = skip?.position !== undefined ? skip?.position : current; - - // Выводим сообщение об ошибке - db.events.emitter.emit("message/error", queue, error, position); }); }; - /** - * @description Включение плеера в цикл - * @private - */ - private enableCycle = () => { - // Если нет плеера в цикле - if (!db.queues.cycles.players.has(this)) { - db.queues.cycles.players.add(this); - - Logger.log("DEBUG", `[AudioPlayer/${this.id}] pushed in cycle`); - } - }; - - /** - * @description Отключение плеера от цикла - * @private - */ - private disableCycle = () => { - // Если есть плеер в цикле - if (db.queues.cycles.players.has(this)) { - db.queues.cycles.players.delete(this); - - Logger.log("DEBUG", `[AudioPlayer/${this.id}] removed from cycle`); - } - }; - /** * @description Функция отвечает за циклическое проигрывание, если хотим воспроизвести следующий трек надо избавится от текущего * @param seek - Время трека для пропуска аудио дорожки @@ -321,65 +326,62 @@ export class AudioPlayer extends TypedEmitter { * @param position - Позиция нового трека * @public */ - public play = async (seek: number = 0, timeout: number = 0, position: number = null): Promise => { - const index = typeof position === "number" ? position : this._tracks.indexOf(this._tracks.track); + public play = async (seek: number = 0, timeout: number = 500, position: number = null): Promise => { + let track: Track, index: number; - // Получаем трек следуя позиции - const track = this._tracks.get(index); + // Если позиция явно указана + if (typeof position === "number") { + track = this._tracks.get(position); + index = position; + } + + // Если не указана позиция + else { + track = this._tracks.track; + index = this._tracks.position; + } // Если нет такого трека или статуса if (!track || this._status === null) return; try { - const resource = await this._preloadTrack(index); + // Получаем путь до аудио файла + const resource = await track?.resource; // Если получена ошибка вместо исходника - if (!resource || resource instanceof Error) { + if (resource instanceof Error) { this.emit("player/error", this, `${resource}`, { skip: true, position: index }); return; } - // Создаем аудио поток - const stream = this._readStream(resource, track.time.total, seek); - - // Если нельзя создать аудио поток, поскольку создается другой - if (!stream) return; + // Если другой аудио поток загружается, то запрещаем включение + if (this._audio.preloaded) return null; - // Действия при готовности - const handleReady = () => { - // Производим явную синхронизацию времени - if (this._audio.current) stream.seek = this._audio.current.duration; + Logger.log("DEBUG", `[AudioPlayer/${this.id}] has read ${track.isBuffered ? "buffered" : "piped"} stream ${resource}`); - // Переводим плеер в состояние чтения аудио - this.status = "player/playing"; - - // Меняем позицию если удачно - this._tracks.position = index; - - // Если трек включен в 1 раз - if (seek === 0) { - const queue = db.queues.get(this.id); - db.events.emitter.emit("message/playing", queue); // Отправляем сообщение, если можно + // Выбираем тип аудио + const audio = track.isBuffered ? BufferedAudioResource : PipeAudioResource; + const stream = this._audio.preload = new audio( + { + seek, + filters: this._filters.filters, + volume: this._audio.volume, + swapped: this._audio.current?.packets > 0, + track } - - // Заставляем плеер запускаться самостоятельно - setImmediate(this.enableCycle); - }; + ); // Подключаем события для отслеживания работы потока (временные) - (stream as BufferedAudioResource) - + (stream as any) // Если чтение возможно .once("readable", () => { - const pauseTimeout = this._timer.timeout; + // Время паузы плеера + const pauseTimeout = Math.max(this._timer.timeout - Date.now(), timeout, 0); // Если включить трек сейчас не выйдет - if (pauseTimeout > Date.now() || timeout) { - this._timer.timer = setTimeout(handleReady, timeout ? timeout : pauseTimeout - Date.now()); - return; - } - - return handleReady(); + if (pauseTimeout > 0) this._timer.timer = setTimeout(() => this._onPlayerReadable(index, seek), pauseTimeout); + else this._onPlayerReadable(index, seek); + return null; }) // Если была получена ошибка при чтении @@ -397,6 +399,7 @@ export class AudioPlayer extends TypedEmitter { /** * @description Приостанавливает воспроизведение плеера + * @returns void * @public */ public pause = (): void => { @@ -413,7 +416,7 @@ export class AudioPlayer extends TypedEmitter { this._timer.timeout = Date.now(); // Отключаем плеер от цикла - this.disableCycle(); + this.cycle = false; }; /** @@ -439,7 +442,7 @@ export class AudioPlayer extends TypedEmitter { this._timer.timeout = null; // Подключаем плеер к циклу - this.enableCycle(); + this.cycle = true; return; } @@ -447,7 +450,6 @@ export class AudioPlayer extends TypedEmitter { this._timer.timer = setTimeout(this.resume, pauseTime); }; - /** * @description Останавливаем воспроизведение текущего трека * @returns Promise | void @@ -455,58 +457,38 @@ export class AudioPlayer extends TypedEmitter { */ public stop = (): Promise | void => { if (this._status === "player/wait") return; - this.status = "player/wait"; // Отправляем silent frame в голосовое соединение для паузы звука this._voice.connection.packet = SILENT_FRAME; - }; - - /** - * @description Запуск чтения потока - * @param path - Путь до файла или ссылка на аудио - * @param time - Длительность трека - * @param seek - Время пропуска, трек начнется с указанного времени - */ - protected _readStream = (path: string, time: number = 0, seek: number = 0) => { - Logger.log("DEBUG", `[AudioPlayer/${this.id}] has read stream ${path}`); - // Если другой аудио поток загружается, то запрещаем включение - if (this._audio.preloaded) return null; - - // Выбираем и создаем класс для предоставления аудио потока - return this._audio.preload = new (time > PLAYER_BUFFERED_TIME || time === 0 ? PipeAudioResource : BufferedAudioResource)( - { - path, - options: { - seek, - filters: this._filters.toString(time, this._audio.volume, this._audio.current && this._audio.current?.packets > 0) - } - } - ); + this.status = "player/wait"; }; /** - * @description Пред загрузка трека, если конечно это возможно - * @param position - Позиция трека + * @param index - Номер нового трека + * @param seek - Время перехода к позиции аудио трека + * @returns void + * @private */ - protected _preloadTrack = async (position: number): Promise => { - const track = this._tracks.get(position); - - // Если нет трека в очереди - if (!track) return false; - - // Получаем данные - const path = await track?.resource; + private _onPlayerReadable = (index: number, seek: number) => { + // Если трек включен в 1 раз + if (seek === 0) { + // Отправляем пустышку для смягчения если нет аудио + if (!this._audio.current) this._voice.connection.packet = SILENT_FRAME; + + const queue = db.queues.get(this.id); + if (queue) db.events.emitter.emit("message/playing", queue); // Отправляем сообщение, если можно + } - // Если получена ошибка вместо исходника - if (path instanceof Error) return path; + // Переводим плеер в состояние чтения аудио + this.status = "player/playing"; - // Если нет исходника - else if (!path) return new Error("AudioError\n - Do not getting audio link!"); + // Передаем плеер в цикл + this.cycle = true; - // Если получить трек удалось - return path; - }; + // Меняем позицию если удачно + this._tracks.position = index; + } /** * @description Эта функция частично удаляет плеер и некоторые сопутствующие данные @@ -520,7 +502,7 @@ export class AudioPlayer extends TypedEmitter { if (this._filters.size > 0) this._filters.clear(); // Отключаем плеер от цикла - this.disableCycle(); + this.cycle = false; // Удаляем текущий поток, поскольку он больше не нужен this._audio.destroy(); @@ -537,6 +519,7 @@ export class AudioPlayer extends TypedEmitter { /** * @description Эта функция полностью удаляет плеер и все сопутствующие данные + * @returns void * @public */ public destroy = () => { @@ -552,10 +535,9 @@ export class AudioPlayer extends TypedEmitter { }; } - /** * @author SNIPPIK - * @description Под класс для ограничения времени плеера, для стабильной работы Jitter Buffer + * @description Класс для ограничения временных разрывов плеера, предотвращает разрыв jitter buffer * @class AudioPlayerTimeout * @private */ @@ -574,6 +556,7 @@ class AudioPlayerTimeout { /** * @description Последнее заданное время + * @returns number * @public */ public get timeout() { @@ -602,12 +585,11 @@ class AudioPlayerTimeout { /** * @description Удаляем не нужные данные + * @returns void * @public */ public destroy = () => { this._pauseTimestamp = null; - - if (this._pauseTimeout) clearTimeout(this._pauseTimeout); - this._pauseTimeout = null; + this.timer = null; }; } \ No newline at end of file diff --git a/src/core/queue/controllers/cycle.ts b/src/core/queue/controllers/cycle.ts index 7d72d97e..a8ebcd91 100644 --- a/src/core/queue/controllers/cycle.ts +++ b/src/core/queue/controllers/cycle.ts @@ -1,4 +1,4 @@ -import { CycleInteraction } from "#structures/discord"; +import type { CycleInteraction, MessageComponent } from "#structures/discord"; import { Logger, TaskCycle } from "#structures"; import { OPUS_FRAME_SIZE } from "#core/audio"; import { AudioPlayer } from "#core/player"; @@ -15,180 +15,296 @@ export class ControllerCycles { * @author SNIPPIK * @description Цикл для работы плеера, необходим для отправки пакетов * @class AudioPlayers - * @extends TaskCycle - * @readonly * @public */ - public readonly players = new class AudioPlayers extends TaskCycle { - /** - * @description Текущее время шага - * @private - */ - private _targetDuration = OPUS_FRAME_SIZE; - - /** - * @description Последнее зафиксированное время - * @private - */ - private _lastAdjust = 0; - - /** - * @description Запускаем циклическую систему плееров, весь логический функционал здесь - * @constructor - * @public - */ - public constructor() { - super({ - // Время до следующего прогона цикла - duration: OPUS_FRAME_SIZE, - drift: false, - - // Кастомные функции (если хочется немного изменить логику выполнения) - custom: { - step: () => { - const time = this.time - this.insideTime; - - // === 1. Определяем, нужно ли увеличить длительность шага === - if (time > OPUS_FRAME_SIZE) { - const frames = (Math.ceil(time / OPUS_FRAME_SIZE) + 1) * OPUS_FRAME_SIZE; - - // Если новое время фрейма больше текущего - if (frames > this._targetDuration) this._targetDuration = frames; - } - - else if (this._targetDuration !== OPUS_FRAME_SIZE) { - // Возврат к базовому шагу 20ms - this._targetDuration = OPUS_FRAME_SIZE; - } - - // === 2. Плавная коррекция options.duration с задержкой между изменениями === - const now = this.time; - - if (now - this._lastAdjust >= OPUS_FRAME_SIZE) { - // Если текущее время меньше указанного - if (this.options.duration < this._targetDuration) this.options.duration = Math.min(this.options.duration + OPUS_FRAME_SIZE, this._targetDuration); - - // Если текущее время меньше указанного - else if (this.options.duration > this._targetDuration) this.options.duration = Math.max(this.options.duration - OPUS_FRAME_SIZE, this._targetDuration); - this._lastAdjust = now; - - // Для отладки - //console.log(`[step] duration adjusted to ${this.options.duration} ms, target: ${this._targetDuration} ms | ${this.delay}\nTime: ${this.insideTime} - ${this.time} | ${this.drifting}\n`); - } + public players = new AudioPlayers(); + + /** + * @author SNIPPIK + * @description Цикл для обновления сообщений, необходим для красивого прогресс бара. :D + * @class Messages + * @public + */ + public messages = new Messages(); +} + +/** + * @author SNIPPIK + * @description Раз в N ms пробуем уменьшить Jitter Buffer + * @const PLAYER_INTERVAL + * @private + */ +//const PLAYER_INTERVAL = 10e3; + +/** + * @author SNIPPIK + * @description Время сброса Jitter Buffer + * @const PLAYER_DELAY_COOLDOWN + * @private + */ +//const PLAYER_DELAY_COOLDOWN = 10e3; + +/** + * @author SNIPPIK + * @description Максимальный размер задержки Jitter Buffer + * @const PLAYER_MAX_DELAY + * @private + */ +//const PLAYER_MAX_DELAY = 1000; + +/** + * @author SNIPPIK + * @description Время задержки, при превышении будет добавляться аудио пакет + * @const PLAYER_LATENCY_SIZE + * @private + */ +//const PLAYER_LATENCY_SIZE = 100; +const PLAYER_AVG_FRAMES = 10 + +/** + * @author SNIPPIK + * @description Циклическая система плееров, используется для отправки аудио пакетов + * @class AudioPlayers + * @extends TaskCycle + * @private + */ +class AudioPlayers extends TaskCycle { + /** + * @description Время последней успешной попытки уменьшения duration + * @private + */ + //private _lastDecrease = 0; + + /** + * @description Время последнего неудачного уменьшения. Нужен для cooldown + * @private + */ + //private _lastDecreaseFailed = 0; + + private _avgFrames = []; + private _lastBaseInsert = 0; + private _lastAdjust = 0; + private _targetDuration = 20; + /** + * @description Запускаем циклическую систему плееров, весь логический функционал здесь + * @constructor + * @public + */ + public constructor() { + super({ + // Время до следующего прогона цикла + duration: OPUS_FRAME_SIZE, + + // Кастомные функции (если хочется немного изменить логику выполнения) + custom: { + step: () => { + const now = this.time; + const time = Math.abs(now - this.insideTime); + + // === Рассчитываем новое значение === + let frames = OPUS_FRAME_SIZE; + + // только вверх по 20 ms + if (time > OPUS_FRAME_SIZE) frames = time + OPUS_FRAME_SIZE; + + // === Контроль записи в массив === + const canInsertBase = now - this._lastBaseInsert >= 1000; // прошло 1 сек? + + // разрешено вставлять любое > 20 + if (frames > OPUS_FRAME_SIZE) this._avgFrames.push(frames); + else if (frames <= OPUS_FRAME_SIZE && canInsertBase) { + // базовый 20 можно лишь раз в секунду + this._avgFrames.push(OPUS_FRAME_SIZE); + this._lastBaseInsert = now; } - }, - // Функция проверки - filter: (item) => item.playing && item.voice.connection.ready, - - // Функция отправки аудио фрейма - execute: (player) => { - const size = this.options.duration / OPUS_FRAME_SIZE; - let i = 0; - - /* - // Если цикл держит планку в 20 ms - if (size === 1) { - // Отправляем 1 пакет заранее, для заполнения кольцевого буфера - if (player.audio) { - // Проверяем можно ли отправить пакеты - i = player.audio.current.packets >= 2 ? -1 : 0; - } - }*/ - - // Отправляем пакет/ы в голосовой канал - do { - i++; - player.voice.connection.packet = player.audio.current.packet; - } while (i < size); + // ограничиваем размер массива + if (this._avgFrames.length > PLAYER_AVG_FRAMES) this._avgFrames.shift(); + + // === Среднее значение Jitter === + const avg = this._avgFrames.reduce((a, b) => a + b, 0) / this._avgFrames.length; + + // === Округление по шагу 20ms === + let quantized = Math.ceil(avg / OPUS_FRAME_SIZE) * OPUS_FRAME_SIZE; + if (quantized < OPUS_FRAME_SIZE) quantized = OPUS_FRAME_SIZE; + this._targetDuration = quantized; + + // === 5. Плавная коррекция duration === + if (now - this._lastAdjust >= OPUS_FRAME_SIZE) { + if (this.options.duration < this._targetDuration) this.options.duration = Math.min(this.options.duration + OPUS_FRAME_SIZE, this._targetDuration); + else if (this.options.duration > this._targetDuration) this.options.duration = Math.max(this.options.duration - OPUS_FRAME_SIZE, this._targetDuration); + + this._lastAdjust = now + 2000; + } } - }); - }; - - /** - * @description Чистка цикла от всего + выполнение gc - * @returns void - * @public - */ - public reset = () => { - super.reset(); - - // Запускаем Garbage Collector - setImmediate(() => { - if (typeof global.gc === "function") { - Logger.log("DEBUG", "[Node] running Garbage Collector - running player cycle"); - global.gc(); + }, + + // Функция проверки + filter: (item) => item.playing, + + // Функция отправки аудио фрейма + execute: (player) => { + // latency - задержка соединения + //const latency = player.voice.connection.latency > PLAYER_LATENCY_SIZE ? Math.ceil(player.voice.connection.latency / PLAYER_LATENCY_SIZE) - 1 : 0; + + // Количество фреймов в текущей итерации + let size = this.options.duration / OPUS_FRAME_SIZE; + + // Если есть задержка голосового подключения + /*if (latency > 0 && size <= latency) { + // Инкремент счётчика + player._counter++; + + // Проверяем достижение порога + if (player._counter < player._stepCounter) return; + + // Если достигли — выполняем шаг + player._counter = 0; // сбрасываем + size = latency + size; + }*/ + + // Отправляем пакет/ы в голосовой канал + for (let i = 0; i < size; i++) { + player.voice.connection.packet = player.audio.current.packet; } - }); - }; + + // Указываем кол-во аудио пакетов + player._stepCounter = size; + } + }); }; /** - * @author SNIPPIK - * @description Цикл для обновления сообщений, необходим для красивого прогресс бара. :D - * @class Messages - * @extends TaskCycle - * @readonly + * @description Чистка цикла от всего + выполнение gc + * @returns void * @public */ - public readonly messages = new class Messages extends TaskCycle { - /** - * @description Запускаем циклическую систему сообщений - * @constructor - * @public - */ - public constructor() { - super({ - // Время до следующего прогона цикла - duration: 20e3, - drift: true, - // Кастомные функции (если хочется немного изменить логику выполнения) - custom: { - remove: async (item) => { - try { - await item.delete(); - } catch { - Logger.log("ERROR", `Failed delete message in cycle!`); - } - }, - push: (item) => { - const old = this.find(msg => msg.guildId === item.guildId); - // Удаляем прошлое сообщение - if (old) this.delete(old); - } - }, + public reset = () => { + super.reset(); - // Функция проверки - filter: (message) => !!message.edit && message.createdTimestamp + 10e3 < Date.now(), + // Запускаем Garbage Collector + setImmediate(() => { + if (typeof global.gc === "function") { + Logger.log("DEBUG", "[Node] running Garbage Collector - running in player cycle"); + global.gc(); + } + }); + }; +} - // Функция обновления сообщения - execute: async (message) => { - const queue = db.queues.get(message.guildId); - // Если нет очереди - if (!queue) this.delete(message); - // Если есть поток в плеере - else if (queue.player.audio?.current && queue.player.audio.current.duration > 1) { - const component = queue.components; +/** + * @author SNIPPIK + * @description Время через которое будет создано новое сообщение + * @const MESSAGE_RESEND_TIME + * @private + */ +const MESSAGE_RESEND_TIME = 60e3 * 10; - // Если не получен embed - if (!component) { - this.delete(message); - return; - } +/** + * @author SNIPPIK + * @description Время через которое можно обновлять сообщение + * @const MESSAGE_UPDATE_TIME + * @private + */ +const MESSAGE_UPDATE_TIME = 1e3 * 15; - try { - await message.edit({ components: component }); - } catch (error) { - Logger.log("ERROR", `Failed to edit message in cycle: ${error instanceof Error ? error.message : error}`); +/** + * @author SNIPPIK + * @description Циклическая система сообщений, используется для сообщения о текущем треке + * @class Messages + * @extends TaskCycle + * @private + */ +class Messages extends TaskCycle { + /** + * @description Запускаем циклическую систему сообщений + * @constructor + * @public + */ + public constructor() { + super({ + // Время до следующего прогона цикла + duration: MESSAGE_UPDATE_TIME, - // Если при обновлении произошла ошибка - this.delete(message); - } + // Кастомные функции (если хочется немного изменить логику выполнения) + custom: { + remove: async (item) => { + try { + if (item.deletable) await item.delete(); + } catch { + Logger.log("ERROR", `Failed delete message in cycle!`); } + }, + push: (item) => { + const old = this.find(msg => msg.guildId === item.guildId); + if (old) this.delete(old); } - }); - }; + }, + + // Функция проверки + filter: (message) => !!message.edit && message.editable && message.createdTimestamp + 5e3 < Date.now() && message.editedTimestamp + 5e3 < Date.now(), + + // Функция обновления сообщения + execute: async (message) => { + const queue = db.queues.get(message.guildId); + + // Если нет очереди + if (!queue) this.delete(message); + + const component = queue.components; + + // Если не получен embed + if (!component) { + this.delete(message); + return; + } + + return this.update(message, component); + } + }); }; + + /** + * @description Обновление сообщения принудительно + * @param message - Сообщение + * @param component - Данные для обновления + * @returns Promise + * @public + */ + public update = async (message: T, component: MessageComponent) => { + try { + if (message.editable) await message.edit({ components: component }); + } catch (error) { + Logger.log("ERROR", `Failed to edit message in cycle\n${error instanceof Error ? error.stack : error}`); + + // Если при обновлении произошла ошибка + this.delete(message); + } + }; + + /** + * @description Гарантирует, что сообщение существует и не устарело + * @returns Promise + * @public + */ + public ensure = async (guildId: string, factory: () => Promise): Promise => { + let message = this.find(m => m.guildId === guildId); + + // Если нет сообщения в цикле + if (!message) { + factory().then(this.add).catch(console.error); + return null; + } + + // Если время позволяет пересоздать сообщение о проигрывании + else if (Date.now() - message.createdTimestamp > MESSAGE_RESEND_TIME) { + this.delete(message); + factory().then(this.add).catch(console.error); + return null; + } + + return message; + } } \ No newline at end of file diff --git a/src/core/queue/controllers/tracks.ts b/src/core/queue/controllers/tracks.ts index 1b9a7ac6..7eafda65 100644 --- a/src/core/queue/controllers/tracks.ts +++ b/src/core/queue/controllers/tracks.ts @@ -9,35 +9,39 @@ import { Track } from "#core/queue"; export class ControllerTracks { /** * @description Хранилище треков, хранит в себе все треки. Прошлые и новые! - * @readonly - * @private + * @protected */ - private _current: T[] = []; + protected _current: T[] = []; /** * @description Хранилище треков в оригинальном порядке, необходимо для правильной работы shuffle - * @readonly - * @private + * @protected */ - private _original: T[] = []; + protected _original: T[] = []; /** * @description Текущая позиция в списке - * @private + * @protected */ - private _position = 0; + protected _position = 0; /** * @description Тип повтора - * @private + * @protected */ - private _repeat = RepeatType.None; + protected _repeat = RepeatType.None; /** * @description Смешивание треков - * @private + * @protected */ - private _shuffle = false; + protected _shuffle = false; + + /** + * @description Общее время треков, считается только при добавлении и удалении треков + * @protected + */ + protected _totalTime = 0; /** * @description Новая позиция трека в списке @@ -65,7 +69,7 @@ export class ControllerTracks { /** * @description Текущая позиция трека в очереди - * @return number + * @returns number * @public */ public get position() { @@ -74,7 +78,7 @@ export class ControllerTracks { /** * @description Получаем текущий трек - * @return Track + * @returns Track * @public */ public get track() { @@ -83,7 +87,7 @@ export class ControllerTracks { /** * @description Кол-во треков в очереди с учетом текущей позиции - * @return number + * @returns number * @public */ public get size() { @@ -92,7 +96,7 @@ export class ControllerTracks { /** * @description Общее кол-во треков в очереди - * @return number + * @returns number * @public */ public get total() { @@ -101,6 +105,7 @@ export class ControllerTracks { /** * @description Получаем данные перетасовки + * @returns boolean * @public */ public get shuffle(): boolean { @@ -113,11 +118,13 @@ export class ControllerTracks { * @public */ public set repeat(type) { + if (type === this._repeat) return; this._repeat = type; }; /** * @description Получаем тип повтора + * @returns RepeatType * @public */ public get repeat() { @@ -126,10 +133,13 @@ export class ControllerTracks { /** * @description Общее время треков + * @returns string * @public */ public get time() { - return this._current.reduce((total, track) => total + (track.time?.total || 0), 0).duration(); + // Если класс уже удален + if (!this._totalTime) return "00:00"; + return this._totalTime.duration(); }; /** @@ -144,6 +154,9 @@ export class ControllerTracks { // Добавляем трек в текущую очередь this._current.push(track); + + // Высчитываем время проигрывания + this._totalTime += track.time.total; }; /** @@ -163,6 +176,11 @@ export class ControllerTracks { * @public */ public remove = (position: number) => { + const track = this._current[position]; + + // Высчитываем время проигрывания + this._totalTime -= track.time.total; + // Если трек удаляем из виртуально очереди, то и из оригинальной if (this._shuffle) { const index = this._original.indexOf(this._current[position]); @@ -173,12 +191,14 @@ export class ControllerTracks { this._current.splice(position, 1); // Корректируем позицию, если она больше длины или не равна нулю - if (this._position > position) { - this._position--; - } else if (this._position >= this._current.length) { + if (this._position > position) this._position--; + + // Если позиция больше максимальной + else if (this._position >= this._current.length) { this._position = this._current.length - 1; } + // Если позиция менее 0 if (this._position < 0) this._position = 0; }; @@ -199,12 +219,12 @@ export class ControllerTracks { * @returns T[] * @public */ - public array = (size: number, position?: number): T[] => { - const realPosition = position ?? this._position; - const startIndex = size < 0 ? realPosition + size : realPosition; - const endIndex = size < 0 ? realPosition : realPosition + size; + public array = (size: number, position: number): T[] => { + const startIndex = size < 0 ? position + size : position; + const endIndex = size < 0 ? position : position + size; - return this._current.slice(startIndex, endIndex); + // Отдает список треков с учетом позиции + return this._current.slice(Math.max(0, startIndex), endIndex); }; /** @@ -234,13 +254,7 @@ export class ControllerTracks { } // Восстанавливаем оригинальную очередь - else { - // Меняем треки в текущей очереди на оригинальные - this._current = this._original; - - // Удаляем оригинальные треки, поскольку они теперь и основной ветке - this._original = []; - } + else [this._current, this._original] = [this._original, []]; // Меняем переменную this._shuffle = bol; @@ -252,10 +266,11 @@ export class ControllerTracks { * @public */ public clear = () => { - this._current.length = null; - this._original.length = null; + this._current.length = 0; + this._original.length = 0; this._current = null; this._original = null; + this._totalTime = null; this._position = null; this._repeat = null; @@ -273,17 +288,17 @@ export enum RepeatType { /** * @description Повтор выключен */ - None = 0, + None, /** * @description Повтор одного трека */ - Song = 1, + Song, /** * @description Повтор всех треков */ - Songs = 2, + Songs, /** * @description Бесконечный музыкальный поток diff --git a/src/core/queue/controllers/voice.ts b/src/core/queue/controllers/voice.ts index 31d03226..19101abf 100644 --- a/src/core/queue/controllers/voice.ts +++ b/src/core/queue/controllers/voice.ts @@ -19,10 +19,13 @@ export class ControllerVoice { * @public */ public set connection(connection: T) { + // Если уже есть голосовое подключение if (this.connection) { + // Если можно отключится if (this._connection.disconnect) this._connection.destroy(); } + // Перезаписываем старое на новое this._connection = connection; }; diff --git a/src/core/queue/index.ts b/src/core/queue/index.ts index 8baaf1c5..7a741b2c 100644 --- a/src/core/queue/index.ts +++ b/src/core/queue/index.ts @@ -9,10 +9,10 @@ import { Track } from "./structures/track"; import { env } from "#app/env"; import { db } from "#app/db"; -export * from "./structures/track"; -export * from "./structures/queue"; export * from "./controllers/tracks"; export * from "./controllers/voice"; +export * from "./structures/track"; +export * from "./structures/queue"; /** * @author SNIPPIK @@ -28,17 +28,17 @@ export class ControllerQueues extends Collection { * @readonly * @public */ - public readonly cycles = new ControllerCycles(); + public cycles = new ControllerCycles(); /** * @description Здесь хранятся модификаторы аудио * @readonly * @public */ - public readonly options = { + public options = { optimization: parseInt(env.get("duration.optimization")), volume: parseInt(env.get("audio.volume")), - swapFade: parseInt(env.get("audio.swap.fade")), + swapFade: parseInt(env.get("audio.swap.fade", "5")), fade: parseInt(env.get("audio.fade")) }; @@ -81,7 +81,7 @@ export class ControllerQueues extends Collection { queue.player.removeAllListeners(); // Тихо удаляем очередь - this.remove(queue.message.guildID, true); + this.remove(queue.message.guild_id, true); } Logger.log("DEBUG", `[Queues] has getting max timeout: ${timeout} ms`); @@ -98,7 +98,7 @@ export class ControllerQueues extends Collection { if (player.status === "player/pause") player.resume(); // Запускаем функцию воспроизведения треков - (() => player.play())(); + process.nextTick(player.play); }; /** @@ -107,13 +107,13 @@ export class ControllerQueues extends Collection { * @param item - Добавляемый объект * @private */ - public create = (message: CommandInteraction, item: Track.list | Track | Track[]) => { + public create = (message: CommandInteraction, item: Track.list | Track | Track[]): void => { const items = this._prepareGettingData(item); // Если данных нет if (!items.length) { db.events.emitter.emit("rest/error", message, locale._(message.locale, "player.search.fail")); - return null; + return; } let queue = this.get(message.guildId); @@ -126,35 +126,24 @@ export class ControllerQueues extends Collection { setImmediate(() => { // Установка позиции воспроизведения в зависимости от типа добавленного item queue.tracks.position = items.length ? queue.tracks.total - items.length : 0; - - // Если разные текстовые каналы - if (queue.message.channelID !== message.channelId) { - // Меняем текстовый канал - queue.message = new QueueMessage(message); - } - this.restart_player = queue.player; }); } // Если текстовый канал изменился — обновляем привязку - else if (queue.message.channelID !== message.channelId) { + if (queue.message.channel_id !== message.channelId) { queue.message = new QueueMessage(message); } } // Отправляем сообщение о добавлении треков - if (!Array.isArray(item)) { - db.events.emitter.emit("message/push", queue, message.member, item); - } + if (!Array.isArray(item)) db.events.emitter.emit("message/push", queue, message.member, item); // Добавляем треки items.forEach(track => { track.user = message.member.user; queue.tracks.push(track); }); - - return null; }; /** @@ -187,22 +176,28 @@ export class ControllerQueues extends Collection { export interface QueueEvents { /** * @description Событие при котором коллекция будет отправлять информацию о добавленном треке или плейлисте, альбоме - * @param queue - Очередь сервера + * @param queue - Очередь сервера * @param user - Пользователь включивший трек - * @param items - Трек или плейлист, альбом + * @param items - Трек или плейлист, альбом + * @returns void + * @readonly */ readonly "message/push": (queue: Queue, user: CommandInteraction["member"], items: Track | Track.list) => void; /** * @description Событие при котором коллекция будет отправлять сообщение о текущем треке * @param queue - Очередь сервера + * @returns void + * @readonly */ readonly "message/playing": (queue: Queue) => void; /** * @description Событие при котором коллекция будет отправлять сообщение об ошибке * @param queue - Очередь сервера - * @param error - Ошибка в формате string или в типе Error + * @param error - Ошибка + * @returns void + * @readonly */ readonly "message/error": (queue: Queue, error?: string | Error, position?: number) => void; @@ -211,13 +206,17 @@ export interface QueueEvents { * @param api - Класс платформы запросов * @param message - Сообщение с сервера * @param url - Ссылка на допустимый объект или текст для поиска + * @returns void + * @readonly */ readonly "rest/request": (api: RestClientSide.Request, message: CommandInteraction, url: string) => void; /** * @description Событие при котором будут отправляться ошибки из системы API * @param message - Сообщение с сервера - * @param error - Ошибка в формате string + * @param error - Ошибка + * @returns void + * @readonly */ - readonly "rest/error": (message: CommandInteraction, error: string) => void; + readonly "rest/error": (message: CommandInteraction, error: string | Error) => void; } \ No newline at end of file diff --git a/src/core/queue/modules/message.ts b/src/core/queue/modules/message.ts index 1378671f..ff396636 100644 --- a/src/core/queue/modules/message.ts +++ b/src/core/queue/modules/message.ts @@ -4,6 +4,7 @@ import filters from "#core/player/filters.json"; import type { AudioPlayer } from "#core/player"; import { RepeatType } from "#core/queue"; import { env } from "#app/env"; +import { db } from "#app/db"; /** * @author SNIPPIK @@ -12,10 +13,29 @@ import { env } from "#app/env"; * @public */ export class QueueMessage { - private readonly _guildID: string; - private readonly _channelID: string; - private readonly _voiceID: string; - private _deferred = false; + /** + * @description ID сервера, привязанный к сообщению + * @public + */ + public guild_id: string; + + /** + * @description ID канала, привязанный к сообщению + * @public + */ + public channel_id: string; + + /** + * @description ID канала, привязанный к голосовому каналу + * @public + */ + public voice_id: string; + + /** + * @description Ответил ли бот на сообщение + * @private + */ + private _deferred: boolean; /** * @description Язык сообщения @@ -35,15 +55,6 @@ export class QueueMessage { return this._original.guild; }; - /** - * @description Получение ID сервера - * @returns string - * @public - */ - public get guildID() { - return this._guildID; - }; - /** * @description Получение текущего текстового канала * @returns TextChannel @@ -53,15 +64,6 @@ export class QueueMessage { return this._original.channel; }; - /** - * @description Получение ID текстового канала - * @returns string - * @public - */ - public get channelID() { - return this._channelID; - }; - /** * @description Получение текущего голосового соединения пользователя * @returns VoiceState @@ -71,17 +73,9 @@ export class QueueMessage { return this._original.member.voice; }; - /** - * @description Получение ID голосового канала - * @returns string - * @public - */ - public get voiceID() { - return this._voiceID; - }; - /** * @description Получение класса клиента + * @returns require("discord.js").Client * @public */ public get client() { @@ -91,6 +85,7 @@ export class QueueMessage { /** * @description Параметр отвечает за правильную работу сообщения * @example Ответил ли бот пользователю? + * @returns boolean * @public */ public get replied() { @@ -100,6 +95,7 @@ export class QueueMessage { /** * @description Параметр отвечает за правильную работу сообщения * @example Можно ли ответить на другое сообщение? + * @returns boolean * @public */ public get deferred() { @@ -111,48 +107,64 @@ export class QueueMessage { * @constructor * @public */ - public constructor(private readonly _original: T) { - this._voiceID = _original.member.voice.channelId; - this._channelID = _original.channelId; - this._guildID = _original.guildId; + public constructor(private _original: T) { + this.voice_id = _original.member.voice.channelId; + this.channel_id = _original.channelId; + this.guild_id = _original.guildId; + }; + + /** + * @description Удаление динамического сообщения из системы + * @returns void + * @public + */ + public delete = () => { + // Удаляем старое сообщение, если оно есть + const message = db.queues.cycles.messages.find((msg) => { + return msg.guildId === this.guild_id; + }); + + if (message) db.queues.cycles.messages.delete(message); }; /** * @description Авто отправка сообщения * @param options - Параметры сообщения + * @returns Promise * @public */ public send = (options: {embeds?: EmbedData[], components?: any[], withResponse: boolean, flags?: "Ephemeral" | "IsComponentsV2"}): Promise => { + const ctx = this._original; + try { // Если бот уже ответил на сообщение if (this.replied && !this.deferred) { this._deferred = true; - return this._original.followUp(options as any) as any; + return ctx.followUp(options as any); } // Если можно дать ответ на сообщение else if (!this.deferred && !this.replied) { this._deferred = true; - return this._original.reply(options as any) as any; + return ctx.reply(options as any) as any; } // Отправляем обычное сообщение - return this._original.channel.send(options as any); + return ctx.channel.send(options as any); } catch { this._deferred = false; // Отправляем обычное сообщение - return this._original.channel.send(options as any); + return ctx.channel.send(options as any); } }; } - /** * @author SNIPPIK * @description Класс для создания компонентов-кнопок * @class QueueButtons - * @private + * @public */ export class QueueButtons { /** @@ -180,7 +192,7 @@ export class QueueButtons { QueueButtons.createButton({env: "shuffle", disabled: true}), // Кнопка назад - QueueButtons.createButton({env: "back", disabled: true}), + QueueButtons.createButton({env: "back"}), // Кнопка паузы/продолжить QueueButtons.createButton({emoji: QueueButtons.button.pause, id: "resume_pause"}), @@ -205,10 +217,7 @@ export class QueueButtons { QueueButtons.createButton({env: "stop", style: 4}), // Кнопка текущих фильтров - QueueButtons.createButton({env: "filters", disabled: true}), - - // Кнопка повтора текущего трека - QueueButtons.createButton({env: "replay"}) + QueueButtons.createButton({env: "filters", disabled: true}) ] } ]; @@ -241,6 +250,7 @@ export class QueueButtons { /** * @author SNIPPIK * @description Проверка и выдача кнопок + * @returns ActionRowBuilder * @public */ public component = (player: AudioPlayer) => { @@ -267,7 +277,6 @@ export class QueueButtons { // ⏮ Prev setButton(firstRow[1], { - disabled: !isMultipleTracks, style: isMultipleTracks ? 1 : 2, }); @@ -285,7 +294,7 @@ export class QueueButtons { style: currentRepeatType === RepeatType.None ? 2 : 3, }); - // ⏸ / ▶ Pause / Resume + // ⏸ / ▶ - Pause / Resume setButton(firstRow[2], { emoji: isPaused ? QueueButtons.button.resume : QueueButtons.button.pause, style: isPaused ? 3 : 1, @@ -302,9 +311,10 @@ export class QueueButtons { /** * @description Удаляем компоненты когда они уже не нужны + * @returns void * @public */ - public destroy() { + public destroy = () => { this._buttons = null; this._selector = null; }; @@ -313,9 +323,10 @@ export class QueueButtons { * @author SNIPPIK * @description Делаем проверку id * @param name - Название параметра в env + * @returns object * @private */ - private static checkIDComponent(name: string) { + private static checkIDComponent(name: string){ const id = env.get(name); const int = parseInt(id); @@ -327,6 +338,7 @@ export class QueueButtons { * @author SNIPPIK * @description Создание одной кнопки в одной функции * @param options - Параметры для создания кнопки + * @returns object * @private */ private static createButton(options: any) { diff --git a/src/core/queue/structures/queue.ts b/src/core/queue/structures/queue.ts index d6715aa9..ccf5feef 100644 --- a/src/core/queue/structures/queue.ts +++ b/src/core/queue/structures/queue.ts @@ -1,70 +1,108 @@ -import { ControllerTracks, ControllerVoice } from "#core/queue"; +import { ControllerTracks, ControllerVoice, Track } from "#core/queue"; import { QueueMessage, QueueButtons } from "../modules/message"; -import type { CommandInteraction } from "#structures/discord"; +import { CommandInteraction } from "#structures/discord"; import { VoiceConnection } from "#core/voice"; import { AudioPlayer } from "#core/player"; import { Logger } from "#structures"; -import type { Track } from "./track"; import { db } from "#app/db"; /** * @author SNIPPIK - * @description Класс очереди для управления всей системой, бесконтрольное использование ведет к поломке всего процесса!!! - * @class Queue - * @public + * @description Класс для управления и хранения плеера + * @class ControllerPlayer + * @private */ -export class Queue { +class ControllerPlayer { /** - * @description Время создания очереди - * @private + * @description Текущий экземпляр плеера + * @protected */ - private _timestamp: number = parseInt(Math.max(Date.now() / 1e3).toFixed(0)); + protected _player: T; /** - * @description Сообщение пользователя - * @protected + * @description Хранилище треков, с умной системой управления + * @public */ - protected _message: QueueMessage; + public tracks = new ControllerTracks(); /** - * @description Создаем класс для отображения фильтров - * @protected + * @description Голосовое подключение + * @public */ - protected _buttons: QueueButtons; + public voice = new ControllerVoice(); /** - * @description Плеер для проигрывания музыки - * @protected + * @description Выдаем плеер привязанный к очереди + * @return AudioPlayer + * @public */ - protected _player: AudioPlayer; + public get player() { + // Если плеер уже не доступен + if (!this._player) return null; + return this._player; + }; /** - * @description Хранилище треков, с умной системой управления - * @protected + * @description Создаем класс для управления плеером и составными для проигрывания + * @constructor + * @public */ - protected _tracks: ControllerTracks = new ControllerTracks(); + public constructor({guild_id, channel_id}: initPlayerOptions) { + const oldPlayer = this._player; + + // Если есть старый плеер + if (oldPlayer) oldPlayer.destroy(); + + // Задаем новый плеер + this._player = new AudioPlayer(this.tracks, this.voice, guild_id) as T; + + // Подключаемся к голосовому каналу + this.voice.connection = db.voice.join({ + guild_id, channel_id, + self_deaf: true, + self_mute: false + }, db.adapter.voiceAdapterCreator(guild_id)); + } /** - * @description Голосовое подключение - * @protected + * @description Удаляем данные плеера и подмодулей + * @public */ - protected _voice: ControllerVoice = new ControllerVoice(); + public destroy() { + // Удаляем плеер + this._player.destroy(); + this.tracks.clear(); + this.tracks = null; + this.voice = null; + this._player = null; + }; +} + +/** + * @author SNIPPIK + * @description Класс очереди для управления всей системой, бесконтрольное использование ведет к поломке всего процесса!!! + * @class Queue + * @public + */ +export class Queue extends ControllerPlayer { /** * @description Время создания очереди * @public */ - public get timestamp() { - return this._timestamp; - }; + public timestamp: number = parseInt(Math.max(Date.now() / 1e3).toFixed(0)); /** - * @description Получаем доступ к трекам - * @public + * @description Сообщение пользователя + * @protected */ - public get tracks() { - return this._tracks; - }; + protected _message: QueueMessage; + + /** + * @description Создаем класс для отображения фильтров + * @protected + */ + protected _buttons: QueueButtons; /** * @description Записываем сообщение в базу для дальнейшего использования @@ -72,7 +110,7 @@ export class Queue { * @public */ public set message(message) { - this._cleanupOldMessage(); + this._message?.delete?.(); this._message = message; }; @@ -98,39 +136,6 @@ export class Queue { return this._player; }; - /** - * @description Выдаем плеер привязанный к очереди - * @return AudioPlayer - * @public - */ - public set player(player) { - const oldPlayer = this._player; - - // Задаем новый плеер - this._player = player; - - // Если есть старый плеер - if (oldPlayer) oldPlayer.destroy(); - }; - - /** - * @description Выдаем голосовой канал - * @return VoiceChannel - * @public - */ - public get voice() { - return this._voice; - }; - - /** - * @description Записываем голосовой канал в базу для дальнейшего использования - * @param voice - Сохраняемый голосовой канал - * @public - */ - public set voice(voice) { - this._voice = voice; - }; - /** * @description Создаем очередь для дальнейшей работы, все подключение находятся здесь * @param message - Опции для создания очереди @@ -139,64 +144,49 @@ export class Queue { */ public constructor(message: CommandInteraction) { const queue_message = new QueueMessage(message); - const ID = queue_message.guildID; + const ID = queue_message.guild_id; + + // Задаем плеер + super({ + guild_id: ID, + channel_id: queue_message.voice_id + }); // Добавляем очередь в список очередей db.queues.set(ID, this); - // Создаем плеер - this.player = new AudioPlayer(this._tracks, this._voice, ID); - // Добавляем данные в класс this.message = queue_message; - // Подключаемся к голосовому каналу - this._player.voice.connection = db.voice.join({ - self_deaf: true, - self_mute: false, - guild_id: ID, - channel_id: queue_message.voiceID - }, db.adapter.voiceAdapterCreator(ID)); - // Создаем класс для отображения кнопок this._buttons = new QueueButtons(queue_message); - Logger.log("DEBUG", `[Queue/${ID}] has create`); - }; - - /** - * @description Удаление динамического сообщения из системы - * @private - */ - private _cleanupOldMessage = () => { - // Если введено новое сообщение - if (this._message && this._message.guild) { - // Удаляем старое сообщение, если оно есть - const message = db.queues.cycles.messages.find((msg) => { - return msg.guildId === this._message.guildID; - }); - - if (message) db.queues.cycles.messages.delete(message); - } + Logger.log("LOG", `[Queue/${ID}] has create`); }; /** * @description Выдача компонентов сообщения, такие как кнопки и текст + * @returns ComponentV2 * @public */ public get components() { - const buttons = this._buttons.component(this._player); + // Если класс кнопок (компонентов был уничтожен) + if (!this._buttons) return null; + + const player = this._player, tracks = this.tracks; + const buttons = this._buttons?.component(player); try { - const {api, artist, name, image, user} = this._tracks.track; - const position = this._tracks.position; + const {api, artist, name, image, user} = tracks.track; + const textTracks = tracks.total > 1 ? `| ${tracks.position + 1}/${tracks.total} | ${tracks.time}` : ""; + const latency = `${player.latency}/${player.voice.connection.latency} ms` return [{ "type": 17, // Container "accent_color": api.color, "components": [ { - "type": 9, + "type": 9, // Block "components": [ { "type": 10, @@ -221,7 +211,7 @@ export class Queue { }, { "type": 10, // Text - "content": `-# ${user.username} ● ${getVolumeIndicator(this._player.audio.volume)} ${this._tracks.total > 1 ? `| ${position + 1}/${this._tracks.total} | ${this._tracks.time}` : ""}` + this._player.progress + "content": `-# ${user.username} ● ${getVolumeIndicator(player.audio.volume)} ${textTracks} | ${latency}` + player.progress }, ...buttons ] @@ -236,48 +226,55 @@ export class Queue { /** * @description Эта функция частично удаляет очередь * @warn Автоматически выполняется при удалении через db - * @readonly * @public */ public cleanup = () => { - Logger.log("DEBUG", `[Queue/${this.message.guildID}] has cleanup`); + Logger.log("DEBUG", `[Queue/${this.message.guild_id}] has cleanup`); // Останавливаем плеер - if (this._player) this._player.cleanup(); + this._player.cleanup(); // Для удаления динамического сообщения - this._cleanupOldMessage(); + this._message.delete(); }; /** * @description Эта функция полностью удаляет очередь и все сопутствующие данные, используется в другом классе * @warn Автоматически удаляется через событие VoiceStateUpdate + * @returns void * @protected - * @readonly */ - protected destroy = () => { - Logger.log("DEBUG", `[Queue/${this.message.guildID}] has destroyed`); + public destroy = () => { + Logger.log("LOG", `[Queue/${this.message.guild_id}] has destroyed`); - // Удаляем плеер - if (this._player) this._player.destroy(); - this._tracks.clear(); - - this._tracks = null; this._message = null; - this._timestamp = null; - this._voice = null; - this._player = null; + this.timestamp = null; this._buttons.destroy(); this._buttons = null; + + super.destroy(); }; } /** + * @description Генератор громкости плеера * @param volume - Уровень громкости (0–200) - * @returns строка-индикатор громкости + * @returns string + * @private */ function getVolumeIndicator(volume: number): string { const clamped = Math.max(0, Math.min(volume, 200)); return `${clamped}%`.padStart(4, " "); +} + +/** + * @author SNIPPIK + * @description Данные для запуска плеера + * @interface initPlayerOptions + * @private + */ +interface initPlayerOptions { + guild_id: string; + channel_id: string; } \ No newline at end of file diff --git a/src/core/queue/structures/track.ts b/src/core/queue/structures/track.ts index 6f46f3a4..2608312c 100644 --- a/src/core/queue/structures/track.ts +++ b/src/core/queue/structures/track.ts @@ -1,14 +1,24 @@ import { httpsClient, httpsStatusCode } from "#structures"; import type { RestServerSide } from "#handler/rest"; +import { version, name, homepage } from "package.json"; +import { sdb } from "#worker/db"; import { db } from "#app/db"; /** * @author SNIPPIK - * @description Класс трека, хранит все данные трека, время и аудио ссылку или путь до файла - * @class BaseTrack - * @protected + * @description Безопасное время для буферизации трека + * @const TRACK_BUFFERED_TIME + * @public + */ +const TRACK_BUFFERED_TIME = 500; + +/** + * @author SNIPPIK + * @description Базовый класс трека, для использования трека. Трек не привязан к чему либо! + * @class Track + * @public */ -abstract class BaseTrack { +export class Track { /** * @description Здесь хранятся данные времени трека * @protected @@ -19,7 +29,7 @@ abstract class BaseTrack { * @description Параметр для сохранения lyrics * @protected */ - protected _lyrics: string; + protected _lyrics: string | null; /** * @description Пользователя включивший трек @@ -27,64 +37,6 @@ abstract class BaseTrack { */ protected _user: Track.user; - /** - * @description Получаем данные времени трека - * @returns TrackDuration - * @public - */ - public get time() { - return this._duration; - }; - - /** - * @description Проверяем время и подгоняем к необходимым типам - * @param time - Данные о времени трека - * @public - */ - public set time(time) { - // Если время в числовом формате - if (typeof time?.total === "number") { - this._duration = { split: (time?.total as number).duration(), total: time.total }; - } - // Если что-то другое - else { - // Если время указано в формате 00:00 - if (`${time?.total}`.match(/:/)) { - this._duration = { split: time.total, total: (time.total as string).duration() }; - return; - } - - const total = parseInt(time.total); - - // Время трека - if (isNaN(total) || !total) this._duration = { split: "Live", total: 0 }; - else this._duration = { split: total.duration(), total }; - } - }; - - /** - * @description Создаем трек - * @param _track - Данные трека с учетом - * @param _api - Данне о платформе - * @public - */ - public constructor(protected _track: Track.data, protected _api: RestServerSide.APIBase) { - this.time = _track.time as any; - - // Удаляем мусорные названия из текста - if (_track.artist) _track.artist.title = `${_track.artist?.title}`.replace(/ - Topic|[\/()\[\]"]|[:;]/gi, ""); - _track.title = `${_track.title}`.replace(/Lyrics Video|[\/()\[\]"]|[:;]/gi, ""); - }; -} - - -/** - * @author SNIPPIK - * @description Класс трека, реализует другой класс - * @class Track - * @public - */ -export class Track extends BaseTrack { /** * @description Идентификатор трека * @returns string @@ -170,7 +122,7 @@ export class Track extends BaseTrack { const { username, id, avatar } = author; // Если нет автора трека, то автором станет сам пользователь - if (!this.artist) this._track.artist = { + if (!this._track.artist) this._track.artist = { url: `https://discordapp.com/users/${id}`, title: username }; @@ -182,7 +134,6 @@ export class Track extends BaseTrack { }; }; - /** * @description Получаем ссылку на исходный файл * @returns string @@ -210,6 +161,41 @@ export class Track extends BaseTrack { return this._api; }; + /** + * @description Получаем данные времени трека + * @returns TrackDuration + * @public + */ + public get time() { + return this._duration; + }; + + /** + * @description Проверяем время и подгоняем к необходимым типам + * @param time - Данные о времени трека + * @public + */ + public set time(time) { + // Если время в числовом формате + if (typeof time?.total === "number") { + this._duration = { split: (time?.total as number).duration(), total: time.total }; + } + // Если что-то другое + else { + // Если время указано в формате 00:00 + if (`${time?.total}`.match(/:/)) { + this._duration = { split: time.total, total: (time.total as string).duration() }; + return; + } + + const total = parseInt(time.total); + + // Время трека + if (isNaN(total) || !total) this._duration = { split: "Live", total: 0 }; + else this._duration = { split: total.duration(), total }; + } + }; + /** * @description Проверяем ссылку на доступность и выдаем ее если ссылка имеет код !==200, то обновляем @@ -218,17 +204,25 @@ export class Track extends BaseTrack { */ public get resource(): Promise { return new Promise(async (resolve) => { - const resource = await this._prepareResource(); + for (let i = 0; i <= 2; i++) { + const resource = await _prepareResource(this); - // Если произошла ошибка при получении ресурса - if (resource instanceof Error || !resource) { - this.link = null; + // Если произошла ошибка при получении ресурса + if (resource instanceof Error || !resource) { + this.link = null; - // Если уже нельзя повторить - return resolve(resource); + // Если уже нельзя повторить + if (i === 2) return resolve(resource); + } + + else { + this.link = resource; + break; + } } - else this.link = resource; + // Если не удалось получить аудио ссылку + if (!this.link) return resolve(new Error("AudioError\n - Do not getting audio link!")); // Отдаем ссылку или путь до файла return resolve(this.link); @@ -245,17 +239,19 @@ export class Track extends BaseTrack { // Выдаем повторно текст песни if (this._lyrics || this._duration.total === 0) return resolve(this._lyrics); - // Если ответ не был получен от сервера - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error("Timeout server request")), 10e3) - ); + const api = await Promise.race( + [ + await new httpsClient({ + url: `https://lrclib.net/api/get?artist_name=${encodeURIComponent(this._track.artist.title)}&track_name=${encodeURIComponent(this._track.title)}`, + userAgent: `(${name}; ${version}) ${homepage}` + }).toJson, - let api = await Promise.race([await new httpsClient( - { - url: `https://lrclib.net/api/get?artist_name=${encodeURIComponent(this.artist.title)}&track_name=${encodeURIComponent(this.name)}`, - userAgent: "UnTitles 0.3.0, Music bot, github.com/SNIPPIK/UnTitles" - } - ).toJson, timeoutPromise]) as json | Error; + // Если ответ не был получен от сервера + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout server request")), 10e3) + ) + ] + ) as json | Error; // Если получаем вместо данных ошибку if (api instanceof Error) return resolve(api); @@ -272,63 +268,95 @@ export class Track extends BaseTrack { }; /** - * @description Функция подготавливающая путь до аудио, так же проверяющая его актуальность - * @returns Promise - * @private + * @description Поставщик текстов песен + * @public */ - private _prepareResource = async (): Promise => { - // Если включено кеширование - if (db.cache.audio) { - const status = db.cache.audio.status(this); - - // Если есть кеш аудио, то выдаем его - if (status.status === "ended") { - this.link = status.path; - return status.path; - } - } + public get lyricsProvider() { + return "lrclib.net" + }; - // Если есть данные об исходном файле - if (this.link) { - // Проверяем ссылку на актуальность - if (this.link.startsWith("http")) { - try { - const status = await new httpsClient({url: this.link}).toHead; - const error = httpsStatusCode.parse(status); - - // Если получена ошибка - if (error) return error; - - // Добавляем трек в кеширование - if (db.cache.audio) db.cache.audio.add(this); - return this.link; - } catch (err) { // Если произошла ошибка при проверке статуса - return Error(`Unknown error, ${err}`); - } - } + /** + * @description Трек может быть буферизирован? + * @public + */ + public get isBuffered() { + const current = this._duration.total; + return current < TRACK_BUFFERED_TIME && current !== 0; + }; - // Скорее всего это файл - return this.link; + /** + * @description Создаем трек + * @param _track - Данные трека с учетом + * @param _api - Данне о платформе + * @public + */ + public constructor(protected _track: Track.data, protected _api: RestServerSide.APIBase) { + this.time = _track.time as any; + + // Удаляем мусорные названия из текста + if (_track.artist) _track.artist.title = `${_track.artist?.title}`.replace(/ - Topic|[\/()\[\]"]|[:;]/gi, ""); + _track.title = `${_track.title}`.replace(/Lyrics Video|[\/()\[\]"]|[:;]/gi, ""); + }; +} + +/** + * @description Функция подготавливающая путь до аудио, так же проверяющая его актуальность + * @returns Promise + * @private + */ +async function _prepareResource(track: Track): Promise { + // Если включено кеширование + if (sdb.audio_saver) { + const status = sdb.audio_saver.status(track); + + // Если есть кеш аудио, то выдаем его + if (status.status === "ended") { + track.link = status.path; + return status.path; } + } - // Если нет ссылки на исходный файл - try { - const song = await db.api.fetchAudioLink(this); + const link = track.link; - // Если вместо ссылки получили ошибку - if (song instanceof Error) return song; + // Если есть данные об исходном файле + if (link) { + // Проверяем ссылку на актуальность + if (link.startsWith("http")) { + try { + const status = await new httpsClient({url: link}).toHead; + const error = httpsStatusCode.parse(status); - // Если платформа не хочет давать данные трека - else if (!song) return Error(`The platform does not provide a link`); + // Если получена ошибка + if (error) return error; - this.link = song; - return this._prepareResource(); - } catch (err) { - return Error(`This link track is not available... Fail update link!`); + // Добавляем трек в кеширование + if (sdb.audio_saver) sdb.audio_saver.add(track); + return link; + } catch (err) { // Если произошла ошибка при проверке статуса + return Error(`Unknown error, ${err}`); + } } - }; -} + // Скорее всего это файл + return link; + } + + // Если нет ссылки на исходный файл + try { + const song = await db.api.fetchAudioLink(track); + + // Если вместо ссылки получили ошибку + if (song instanceof Error) return song; + + // Если платформа не хочет давать данные трека + else if (!song) return Error(`The platform does not provide a link`); + + track.link = song; + return _prepareResource(track); + } catch (err) { + return Error(`This link track is not available... Fail update link!`); + } +} /** * @author SNIPPIK @@ -342,7 +370,7 @@ interface TrackDuration { * @readonly * @private */ - split: string; + split?: string; /** * @description Время в секундах @@ -352,7 +380,6 @@ interface TrackDuration { total: number; } - /** * @author SNIPPIK * @description Все интерфейсы для работы с системой треков @@ -419,6 +446,12 @@ export namespace Track { * @interface list */ export interface list { + /** + * @description Уникальный id листа + * @readonly + */ + readonly id: string; + /** * @description Ссылка на плейлист * @readonly diff --git a/src/core/voice/adapter.ts b/src/core/voice/adapter.ts index a9bc696d..4eeb12ce 100644 --- a/src/core/voice/adapter.ts +++ b/src/core/voice/adapter.ts @@ -1,10 +1,11 @@ import type { GatewayVoiceServerUpdateDispatchData, GatewayVoiceStateUpdateDispatchData } from "discord-api-types/v10"; -import { VoiceConnectionConfiguration } from "#core/voice"; +import type { VoiceConnectionConfiguration } from "#core/voice"; import { GatewayOpcodes } from "discord-api-types/v10"; /** * @author SNIPPIK * @description Класс для взаимодействия с клиентским websocket'ом + * @supported `@discordjs/voice`, `other` * @class VoiceAdapters * @abstract * @public @@ -13,25 +14,66 @@ export abstract class VoiceAdapters { /** * @description Коллекция адаптеров для общения голоса с клиентским websocket'ом * @readonly - * @private + * @protected */ - public readonly adapters = new Map(); + protected adapters = new Map(); /** * @description Создание класса * @param client - Класс клиента + * @protected */ protected constructor(protected client: T) {}; /** - * @description Адаптер состояния голоса для этой гильдии, который можно использовать с `@discordjs/voice` для воспроизведения звука в голосовых и сценических каналах. + * @description Адаптер состояния голоса для этой гильдии + * @abstract * @public + * + * ``` + * public voiceAdapterCreator = (guildID: string) => { + * const id = this.client.shardID; + * + * return methods => { + * this.adapters.set(guildID, methods); + * + * return { + * sendPayload: (data) => { + * this.client.ws.shards.get(id).send(data); + * return true; + * }, + * destroy: () => { + * this.adapters.delete(guildID); + * } + * }; + * }; + * }; + * ``` */ public abstract voiceAdapterCreator(guildID: string): DiscordGatewayAdapterCreator; + /** + * @description Реализация смены статуса голосового канала + * @param channelId - ID голосового канала + * @param status - Название заголовка + * @abstract + * @public + * + * ``` + * this.client.rest.put(`/channels/${channelId}/voice-status`, { + * body: { + * status: status + * } + * }); + * + * ``` + */ + public abstract status(channelId: string, status?: string): void; + /** * @description Поиск адаптера голосового соединения из данных и передаче данных VOICE_SERVER_UPDATE * @param payload - Данные голосового состояния + * @public */ public onVoiceServer = (payload: GatewayVoiceServerUpdateDispatchData) => { this.adapters.get(payload.guild_id)?.onVoiceServerUpdate(payload); @@ -40,6 +82,7 @@ export abstract class VoiceAdapters { /** * @description Поиск адаптера голосового соединения из данных и передаче данных VOICE_STATE_UPDATE * @param payload - Данные голосового состояния + * @public */ public onVoiceStateUpdate = (payload: GatewayVoiceStateUpdateDispatchData & { guild_id: string }) => { this.adapters.get(payload.guild_id)?.onVoiceStateUpdate(payload); @@ -74,7 +117,7 @@ export class VoiceAdapter { * @description Пакет текущего голосового состояния * @public */ - state: undefined as GatewayVoiceStateUpdateDispatchData + state: undefined as GatewayVoiceStateUpdateDispatchData }; /** @@ -96,6 +139,7 @@ export class VoiceAdapter { /** * @description Шлюз Discord Адаптер, шлюза Discord. * @interface DiscordGatewayAdapterLibraryMethods + * @public */ export interface DiscordGatewayAdapterLibraryMethods { /** @@ -119,6 +163,7 @@ export interface DiscordGatewayAdapterLibraryMethods { /** * @description Методы, предоставляемые разработчиком адаптера Discord Gateway для DiscordGatewayAdapter. * @interface DiscordGatewayAdapterImplementerMethods + * @public */ export interface DiscordGatewayAdapterImplementerMethods { /** @@ -140,5 +185,6 @@ export interface DiscordGatewayAdapterImplementerMethods { * разработчик вернет некоторые методы, которые может вызывать библиотека - например, для отправки сообщений на * шлюз или для подачи сигнала о том, что адаптер может быть удален. * @type DiscordGatewayAdapterCreator + * @public */ export type DiscordGatewayAdapterCreator = ( methods: DiscordGatewayAdapterLibraryMethods) => DiscordGatewayAdapterImplementerMethods; \ No newline at end of file diff --git a/src/core/voice/connection.ts b/src/core/voice/connection.ts index b3839bff..8b91aa56 100644 --- a/src/core/voice/connection.ts +++ b/src/core/voice/connection.ts @@ -1,21 +1,15 @@ -import { APIVoiceState, GatewayVoiceServerUpdateDispatchData } from "discord-api-types/v10"; -import { DiscordGatewayAdapterCreator, VoiceAdapter } from "./adapter"; -import { GatewayCloseCodes, WebSocketOpcodes } from "#core/voice"; -import { VoiceReceiver } from "#core/voice/managers/receiver"; -import { ClientSRTPSocket } from "./sockets/ClientSRTPSocket"; -import { ClientWebSocket } from "./sockets/ClientWebSocket"; -import { ClientUDPSocket } from "./sockets/ClientUDPSocket"; -import { ClientDAVE } from "#core/voice/sessions/dave"; +import type { APIVoiceState, GatewayVoiceServerUpdateDispatchData } from "discord-api-types/v10"; +import { SpeakerType, VoiceSpeakerManager } from "#core/voice/modules/Speaker"; +import { type DiscordGatewayAdapterCreator, VoiceAdapter } from "./adapter"; +import { GatewayCloseCodes, type WebSocketOpcodes } from "#core/voice"; +import { VoiceReceiver } from "#core/voice/structures/receiver"; +import { VoiceRTPSocket } from "./protocols/VoiceRTPSocket"; +import { VoiceWebSocket } from "./protocols/VoiceWebSocket"; +import { VoiceUDPSocket } from "./protocols/VoiceUDPSocket"; +import { E2EESession } from "#core/voice/managers/E2EE"; import { VoiceOpcodes } from "discord-api-types/voice"; import { Logger } from "#structures"; -/** - * @author SNIPPIK - * @description Время через которое меняется speaking статус - * @const KEEP_SWITCH_SPEAKING - */ -const KEEP_SWITCH_SPEAKING = 5e3; - /** * @author SNIPPIK * @description Подключение к голосовому серверу для воспроизведения аудио в голосовых каналах @@ -23,68 +17,60 @@ const KEEP_SWITCH_SPEAKING = 5e3; * @public */ export class VoiceConnection { + /** + * @description Таймер переподключения + * @private + */ + private _reconnectTimer: NodeJS.Timeout | null = null; + /** * @description Класс слушателя, если надо слушать пользователей * @usage нужно указать self_deaf = false - * @readonly * @public */ - public receiver: VoiceReceiver; + public receiver: VoiceReceiver | null; /** * @description Функции для общения с websocket клиента - * @readonly * @public */ - public adapter: VoiceAdapter = new VoiceAdapter(); + public adapter: VoiceAdapter | null = new VoiceAdapter(); /** - * @description Клиент WebSocket, ключевой класс для общения с Discord Voice Gateway - * @private - */ - protected websocket: ClientWebSocket = new ClientWebSocket(); - - /** - * @description Клиент UDP соединения, ключевой класс для отправки пакетов - * @private - */ - protected clientUDP: ClientUDPSocket = new ClientUDPSocket(); - - /** - * @description Клиент RTP, ключевой класс для шифрования пакетов для отправки через UDP + * @description Менеджер спикера * @private */ - protected clientSRTP: ClientSRTPSocket; + private speaker: VoiceSpeakerManager | null = new VoiceSpeakerManager(this); /** - * @description Клиент Dave, для работы сквозного шифрования - * @protected + * @description Клиент WebSocket, ключевой класс для общения с Discord Voice Gateway + * @public */ - protected clientDave: ClientDAVE; + public websocket: VoiceWebSocket | null = new VoiceWebSocket(); /** - * @description Таймер для автоматического отключения Speaking - * @private + * @description Клиент UDP соединения, ключевой класс для отправки пакетов + * @public */ - private speakingTimeout: NodeJS.Timeout | null = null; + public udp: VoiceUDPSocket | null = new VoiceUDPSocket(); /** - * @description Текущее состояние Speaking (включен/выключен) - * @private + * @description Клиент RTP, ключевой класс для шифрования пакетов для отправки через UDP + * @public */ - private _speaking: SpeakerType = SpeakerType.disable; + public sRTP: VoiceRTPSocket | null; /** - * @description Список клиентов в голосовом состоянии - * @private + * @description Клиент Dave, для работы сквозного шифрования + * @public */ - private _clients = new Set(); + public e2EE: E2EESession | null; /** * @description Дополнительные данные подключения * @private */ - private _attention = { + public _attention = { ssrc: null as number, secret_key: null as number[], }; @@ -109,67 +95,61 @@ export class VoiceConnection { * @public */ public set packet(frame: Buffer) { - if (this._status === VoiceConnectionStatus.ready && frame) { - this.speaking = this.defaultSpeaker; - this.resetSpeakingTimeout(); + // Если статус позволяет отправлять аудио + if (!frame || this._status !== VoiceConnectionStatus.ready) return; - // Если есть клиенты для шифрования и отправки - if (this.clientUDP && this.clientSRTP) { - this.clientUDP.packet = this.clientSRTP.packet(this.clientDave?.encrypt(frame) ?? frame); - } + // Если есть клиенты для шифрования и отправки + else if (!this.udp || !this.sRTP) return; + + // Меняем состояние спикера + this.speaker.speaking = this.speaker.default; + + // Возможно ли использовать E2EE + const encrypted = this.e2EE.encrypt(frame) ?? frame; + this.udp.packet = this.sRTP.packet(encrypted); + }; + + /** + * @description Отправляем нетронутый аудио фрейм + * @param frame + * @public + */ + public set raw_packet(frame: Buffer) { + if (this._status === VoiceConnectionStatus.ready && frame) { + // Отправляем не тронутый аудио фрейм + if (this.udp) this.udp.packet = frame; } + }; - else this.speaking = SpeakerType.fake; + /** + * @description Текущая задержка голосового подключения + * @public + */ + public get latency() { + return this.websocket?.latency || 40; }; /** * @description Готовность голосового подключения * @public */ - public get ready(): boolean { + public get isReadyToSend(): boolean { // Если статус не готовности if (this._status !== VoiceConnectionStatus.ready) return false; // Если нет клиентов для передачи аудио - else if (!this.clientSRTP && !this.clientUDP) return false; + else if (!this.sRTP && !this.udp) return false; // Если что-то не так с websocket подключением else if (this.websocket && this.websocket.status !== "connected") return false; - // Если основных данных нет - return this.clientUDP.connected; - }; - - /** - * @description Указанный тип спикера - * @private - */ - private get defaultSpeaker(): SpeakerType { - return this.configuration.self_speaker ?? SpeakerType.enable; - }; - - /** - * @description Отправляет пакет голосовому шлюзу, указывающий на то, что клиент начал/прекратил отправку аудио. - * @param speaking - Следует ли показывать клиента говорящим или нет - * @public - */ - public set speaking(speaking: SpeakerType) { - // Если нельзя по состоянию или уже бот говорит - if (this._speaking === speaking || !this.websocket) return; + // Если есть E2EE шифрование + else if (E2EESession.version > 0) { + if (!this.e2EE?.session?.ready) return false; + } - // Меняем состояние спикера - this._speaking = speaking; - - // Обновляем статус голоса - this.websocket.packet = { - op: VoiceOpcodes.Speaking, - d: { - speaking: speaking, - delay: 0, - ssrc: this._attention.ssrc - }, - seq: this.websocket?.sequence ?? -1 - }; + // Если основных данных нет + return this.udp.status === "connected"; }; /** @@ -181,7 +161,7 @@ export class VoiceConnection { this.configuration.channel_id = null; // Удаляем id канала // Отправляем в discord сообщение об отключении бота - return this.adapter.sendPayload(this.configuration); + return this.adapter?.sendPayload(this.configuration); }; /** @@ -189,16 +169,16 @@ export class VoiceConnection { * @param ID - уникальный код канала * @public */ - public set swapChannel(ID: string) { + public set channel(ID: string) { // Прописываем новый id канала - this.configuration = {...this.configuration, channel_id: ID}; - this.adapter.sendPayload(this.configuration); + this.configuration.channel_id = ID; + this.adapter?.sendPayload(this.configuration); }; /** * @description Данные из VOICE_STATE_UPDATE * @returns APIVoiceState - * @private + * @public */ public get voiceState(): APIVoiceState { return this.adapter.packet.state; @@ -207,7 +187,7 @@ export class VoiceConnection { /** * @description Данные из VOICE_SERVER_UPDATE * @returns GatewayVoiceServerUpdateDispatchData - * @private + * @public */ public get serverState(): GatewayVoiceServerUpdateDispatchData { return this.adapter.packet.server; @@ -238,19 +218,22 @@ export class VoiceConnection { * @description Регистрирует пакет `VOICE_STATE_UPDATE` для голосового соединения. Самое главное, он сохраняет идентификатор * канала, к которому подключен клиент. * @param packet - Полученный пакет `VOICE_STATE_UPDATE` - * @private */ onVoiceStateUpdate: (packet) => { this.adapter.packet.state = packet; }, + + /** + * @description Регистрируем удаление данных из класса голосового подключения + */ destroy: this.destroy }); // Инициализируем подключение - this.adapter.sendPayload(this.configuration); + if (this.adapter) this.adapter.sendPayload(this.configuration); this._status = VoiceConnectionStatus.connected; - // Если включен звук бота + // Если включен микрофон бота тогда запускаем класс слушатель if (!configuration.self_deaf) { this.receiver = new VoiceReceiver(this); } @@ -264,6 +247,7 @@ export class VoiceConnection { */ private createWebSocket = (endpoint: string, code?: GatewayCloseCodes) => { this.websocket.connect(endpoint, code); // Подключаемся к endpoint + this.websocket.removeAllListeners(); // Если включен debug режим this.websocket.on("debug", (status, text) => Logger.log("DEBUG", `${status} ${JSON.stringify(text)}`)); @@ -282,7 +266,7 @@ export class VoiceConnection { session_id: this.voiceState.session_id, user_id: this.voiceState.user_id, token: this.serverState.token, - max_dave_protocol_version: ClientDAVE.version + max_dave_protocol_version: E2EESession.version } }; }); @@ -294,9 +278,6 @@ export class VoiceConnection { */ this.websocket.on("ready", ({d}) => { this.createUDPSocket(d); - - // После установки UDP и RTP, включаем speaking - this.resetSpeakingTimeout(); }); /** @@ -306,25 +287,26 @@ export class VoiceConnection { */ this.websocket.on("sessionDescription", ({d}) => { this._status = VoiceConnectionStatus.SessionDescription; - this.speaking = SpeakerType.disable; - - // Если есть поддержка DAVE - if (ClientDAVE.version > 0) { - this.createDaveSession(d.dave_protocol_version); - } + this.speaker.speaking = SpeakerType.disable; // Если уже есть активный RTP - if (this.clientSRTP) { - this.clientSRTP.destroy(); - this.clientSRTP = null; + if (this.sRTP) { + this.sRTP.destroy(); + this.sRTP = null; } // Создаем подключение RTP - this.clientSRTP = new ClientSRTPSocket({ + this.sRTP = new VoiceRTPSocket({ key: new Uint8Array(d.secret_key), ssrc: this._attention.ssrc }); + + // Если есть поддержка DAVE + if (E2EESession.version > 0) { + this.createDaveSession(d.dave_protocol_version); + } + // Сохраняем ключ, для повторного использования this._attention.secret_key = d.secret_key; @@ -338,7 +320,7 @@ export class VoiceConnection { * @code 7 */ this.websocket.on("resumed", () => { - this.speaking = SpeakerType.disable; + this.speaker.speaking = SpeakerType.disable; this.websocket.packet = { op: VoiceOpcodes.Resume, d: { @@ -356,20 +338,28 @@ export class VoiceConnection { * @code 1000-4022 */ this.websocket.on("close", (code, reason) => { - if (code >= 1000 && code <= 1002 || code === 4002 || this._status === VoiceConnectionStatus.reconnecting) return this.destroy(); + // Очищаем предыдущий таймер если он был + if (this._reconnectTimer) clearTimeout(this._reconnectTimer); + + const fatalCodes = [4002, 4004, 4011, 4012, 4014, 4016]; + const isFatal = (code >= 1000 && code <= 1002) || fatalCodes.includes(code); + + if (isFatal || this._status === VoiceConnectionStatus.reconnecting) { + return this.destroy(); + } // Подключения больше не существует else if (code === 4006 || code === 4003) { this.serverState.endpoint = null; - this.voiceState.session_id = null; - this.adapter.sendPayload(this.configuration); + //this.voiceState.session_id = null; + this.adapter?.sendPayload(this.configuration); return; // Здесь происходит пересоздание ws подключения } // Меняем статус на переподключение this._status = VoiceConnectionStatus.reconnecting; - setTimeout(() => { + this._reconnectTimer = setTimeout(() => { this.websocket?.emit("debug", `[${code}/${reason}]`, `Voice Connection reconstruct ws... 500 ms`); this.createWebSocket(this.serverState.endpoint, code); }, 500); @@ -379,7 +369,9 @@ export class VoiceConnection { * @description Если websocket получил не предвиденную ошибку, то отключаемся * @status WS Error */ - this.websocket.on("error", () => { + this.websocket.on("error", (err) => { + Logger.log("ERROR", err); + this._status = VoiceConnectionStatus.disconnected; this.disconnect; this.destroy(); @@ -389,16 +381,11 @@ export class VoiceConnection { * @description Если подключились новые клиенты * @event ClientConnect */ - this.websocket.on("ClientConnect", ({d}) => { - for (const id of d.user_ids) this._clients.add(id); - }); - - /** - * @description Если отключается клиент - * @event ClientDisconnect - */ - this.websocket.on("ClientDisconnect", ({d}) => { - this._clients.delete(d.user_id); + this.websocket.on("UsersRJC", ({d}) => { + if ("user_id" in d) this.speaker.clients.delete(d.user_id); + else { + for (const id of d.user_ids) this.speaker.clients.add(id); + } }); }; @@ -408,47 +395,52 @@ export class VoiceConnection { * @private */ private createUDPSocket = (d: WebSocketOpcodes.ready["d"]) => { - this.clientUDP.connect(d); // Подключаемся по UDP к серверу + this.udp.connect(d); // Подключаемся по UDP к серверу /** - * @description Передаем реальный ip, port для общения с discord - * @status SelectProtocol - * @code 1 + * @description Получаем данные для отправки аудио пакетов + * @description RTP discovery */ - this.clientUDP.once("connected", ({ip, port}) => { - this.websocket.packet = { - op: VoiceOpcodes.SelectProtocol, - d: { - protocol: "udp", - data: { - address: ip, - port: port, - mode: ClientSRTPSocket.mode + this.udp.discovery(d.ssrc) + .then((data) => { + if (data instanceof Error) return this.destroy(); + + this.websocket.packet = { + op: VoiceOpcodes.SelectProtocol, + d: { + protocol: "udp", + data: { + address: data.ip, + port: data.port, + mode: VoiceRTPSocket.mode + } } - } - }; - }); + }; + }) + + // Если не удается получить путь до сервера UDP + .catch(this.destroy); /** * @description Если UDP подключение разорвет соединение принудительно * @event close */ - this.clientUDP.on("close", () => { + this.udp.once("close", () => { // Если голосовое подключение полностью отключено if (this._status === VoiceConnectionStatus.disconnected) return; + // Предупреждение о закрытии и запуске заново + this.websocket.emit("warn", `UDP Close. Reinitializing UDP socket...`); + // Пересоздаем подключение this.createUDPSocket(d); - - // Debug - this.websocket.emit("warn", `UDP Close. Reinitializing UDP socket...`); }); /** * @description Ловим ошибки при отправке пакетов * @event error */ - this.clientUDP.on("error", (error) => { + this.udp.once("error", (error) => { // Если произведена попытка подключения к закрытому каналу if (`${error}`.match(/Not found IPv4 address/)) { if (this.disconnect) this.destroy(); @@ -469,7 +461,21 @@ export class VoiceConnection { */ private createDaveSession = (version: number) => { const { user_id, channel_id } = this.adapter.packet.state; - const session = this.clientDave = new ClientDAVE(version, user_id, channel_id); + let session: E2EESession; + + // Отключаем все события от ws + this.websocket.removeListener("daveSession"); + this.websocket.removeListener("binary"); + + // Если уже есть активная сессия + if (this.e2EE) { + this.e2EE.destroy(); + this.e2EE = null; + session = this.e2EE = new E2EESession(version, user_id, channel_id); + } + + // Если сессии нет + else session = this.e2EE = new E2EESession(version, user_id, channel_id); /** * @description Получаем коды dave от WebSocket @@ -497,6 +503,7 @@ export class VoiceConnection { else if (op === VoiceOpcodes.DavePrepareEpoch) session.prepareEpoch = d; } catch (err) { Logger.log("ERROR", `[Voice/${this.configuration.guild_id}] DAVE error: ${err}`); + const transitionId = typeof d === "object" && d ? d["transition_id"] ?? 0 : 0; // Optional: попробовать сбросить сессию или пересоздать DAVE try { @@ -504,7 +511,7 @@ export class VoiceConnection { this.websocket.packet = { op: VoiceOpcodes.DaveMlsInvalidCommitWelcome, d: { - transition_id: d["transition_id"] ?? 0 + transition_id: transitionId } }; } catch (fallbackErr) { @@ -518,40 +525,48 @@ export class VoiceConnection { * @code 21-31 */ this.websocket.on("binary", ({op, payload}) => { - if (this._status !== VoiceConnectionStatus.ready && !this.clientDave) return; + switch (op) { + // Учетные данные и открытый ключ для внешнего отправителя MLS + case VoiceOpcodes.DaveMlsExternalSender: { + this.e2EE.externalSender = payload; + return; + } - // Учетные данные и открытый ключ для внешнего отправителя MLS - if (op === VoiceOpcodes.DaveMlsExternalSender) this.clientDave.externalSender = payload; + // Предложения MLS, которые будут добавлены или отозваны + case VoiceOpcodes.DaveMlsProposals: { + const dd = this.e2EE.processProposals(payload, this.speaker.clients); - // Предложения MLS, которые будут добавлены или отозваны - else if (op === VoiceOpcodes.DaveMlsProposals) { - const dd = this.clientDave.processProposals(payload, this._clients); - if (dd) this.websocket.packet = Buffer.concat([new Uint8Array([VoiceOpcodes.DaveMlsCommitWelcome]), dd]); - } + // Если есть смысл менять протокол + if (dd) this.websocket.packet = Buffer.concat([OPCODE_DAVE_MLS_WELCOME, dd]); + return; + } - // MLS Commit будет обработан для предстоящего перехода - else if (op === VoiceOpcodes.DaveMlsAnnounceCommitTransition) { - const { transition_id, success } = this.clientDave.processCommit(payload); + // MLS Commit будет обработан для предстоящего перехода + case VoiceOpcodes.DaveMlsAnnounceCommitTransition: { + const { transition_id, success } = this.e2EE.processMLSTransit("commit", payload); - // Если успешно - if (success) { - if (transition_id !== 0) this.websocket.packet = { - op: VoiceOpcodes.DaveTransitionReady, - d: { transition_id }, - }; + // Если успешно + if (success) { + if (transition_id !== 0) this.websocket.packet = { + op: VoiceOpcodes.DaveTransitionReady, + d: { transition_id }, + }; + } + + return; } - } - // MLS Добро пожаловать в группу для предстоящего перехода - else if (op === VoiceOpcodes.DaveMlsWelcome) { - const { transition_id, success } = this.clientDave.processWelcome(payload); + // MLS Добро пожаловать в группу для предстоящего перехода + case VoiceOpcodes.DaveMlsWelcome: { + const { transition_id, success } = this.e2EE.processMLSTransit("welcome", payload); - // Если успешно - if (success) { - if (transition_id !== 0) this.websocket.packet = { - op: VoiceOpcodes.DaveTransitionReady, - d: { transition_id }, - }; + // Если успешно + if (success) { + if (transition_id !== 0) this.websocket.packet = { + op: VoiceOpcodes.DaveTransitionReady, + d: { transition_id }, + }; + } } } }); @@ -562,8 +577,9 @@ export class VoiceConnection { * @event */ session.on("key", (key) => { + // Если голосовое подключение готово if (this._status === VoiceConnectionStatus.ready || this._status === VoiceConnectionStatus.SessionDescription) { - this.websocket.packet = Buffer.concat([new Uint8Array([VoiceOpcodes.DaveMlsKeyPackage]), key]); + this.websocket.packet = Buffer.concat([OPCODE_DAVE_MLS_KEY, key]); } }); @@ -572,12 +588,13 @@ export class VoiceConnection { * @event */ session.on("invalidateTransition", (transitionId) => { + // Если голосовое подключение готово if (this._status === VoiceConnectionStatus.ready || this._status === VoiceConnectionStatus.SessionDescription) { this.websocket.packet = { op: VoiceOpcodes.DaveMlsInvalidCommitWelcome, d: { transition_id: transitionId - }, + } }; } }); @@ -594,53 +611,30 @@ export class VoiceConnection { public destroy = () => { Logger.log("DEBUG", `[Voice/${this.configuration.guild_id}] has destroyed`); - // Если есть таймер спикера - if (this.speakingTimeout) clearTimeout(this.speakingTimeout); - - // Уничтожаем подключения - this.websocket?.destroy?.(); - this.clientUDP?.destroy?.(); - this.clientSRTP?.destroy?.(); - this.clientDave?.destroy?.(); - - // Если есть класс слушателя - if (this.receiver) { - this.receiver?.removeAllListeners(); - this.receiver = null; + if (this._reconnectTimer) { + clearTimeout(this._reconnectTimer); + this._reconnectTimer = null; } - // Удаляем адаптер - this.adapter.adapter?.destroy(); - - // Удаляем клиентов - this.clientSRTP = null; + // Использование Optional Chaining для безопасного вызова + this.websocket?.destroy?.(); + this.udp?.destroy?.(); + this.sRTP?.destroy?.(); + this.e2EE?.destroy?.(); + this.adapter?.adapter?.destroy(); + this.speaker?.destroy(); + this.receiver?.removeAllListeners(); + + // Nullify + this.receiver = null; + this.sRTP = null; this.websocket = null; - this.clientUDP = null; - this.clientDave = null; + this.udp = null; + this.e2EE = null; this.adapter = null; - - // Удаляем данные спикера - this.speakingTimeout = null; - this._speaking = null; - - // Чистим список клиентов - this._clients.clear(); - this._clients = null; - - // Меняем статус + this.speaker = null; this._status = null; }; - - /** - * @description Сброс таймера отключения Speaking - * @private - */ - private resetSpeakingTimeout = () => { - if (this.speakingTimeout) clearTimeout(this.speakingTimeout); - - // Выставляем таймер смены на false - this.speakingTimeout = setTimeout(() => { this.speaking = SpeakerType.disable; }, KEEP_SWITCH_SPEAKING); - }; } /** @@ -666,19 +660,6 @@ enum VoiceConnectionStatus { reconnecting = "reconnecting" } -/** - * @author SNIPPIK - * @description Тип спикера - * @enum SpeakerType - * @private - */ -enum SpeakerType { - "disable", - "enable", - "fake", - "priority" = 4 -} - /** * @author SNIPPIK * @description Параметры для создания голосового соединения @@ -690,7 +671,7 @@ export interface VoiceConnectionConfiguration { * @description Идентификатор гильдии * @private */ - guild_id?: string; + guild_id?: string; /** * @description Идентификатор канала @@ -721,4 +702,20 @@ export interface VoiceConnectionConfiguration { * @private */ self_speaker?: SpeakerType; -} \ No newline at end of file +} + +/** + * @author SNIPPIK + * @description Opcode dave mls приветствия + * @const OPCODE_DAVE_MLS_WELCOME + * @private + */ +const OPCODE_DAVE_MLS_WELCOME = new Uint8Array([VoiceOpcodes.DaveMlsCommitWelcome]); + +/** + * @author SNIPPIK + * @description Opcode dave mls ключа пакета + * @const OPCODE_DAVE_MLS_KEY + * @private + */ +const OPCODE_DAVE_MLS_KEY = new Uint8Array([VoiceOpcodes.DaveMlsKeyPackage]); \ No newline at end of file diff --git a/src/core/voice/index.ts b/src/core/voice/index.ts index 57782f57..e08ef00f 100644 --- a/src/core/voice/index.ts +++ b/src/core/voice/index.ts @@ -1,12 +1,12 @@ -import { DiscordGatewayAdapterCreator } from "#core/voice/adapter"; -import { VoiceConnection } from "#core/voice/connection"; +import type { DiscordGatewayAdapterCreator } from "#core/voice/adapter"; import { VoiceOpcodes } from "discord-api-types/voice/v8"; +import { VoiceConnection } from "#core/voice/connection"; import { Collection } from "#structures"; // Voice Sockets -export * from "./sockets/ClientWebSocket"; -export * from "./sockets/ClientUDPSocket"; -export * from "./sockets/ClientSRTPSocket";; +export * from "./protocols/VoiceWebSocket"; +export * from "./protocols/VoiceUDPSocket"; +export * from "./protocols/VoiceRTPSocket"; export * from "./connection"; @@ -15,6 +15,7 @@ export * from "./connection"; * @description Класс для хранения голосовых подключений * @class Voices * @extends Collection + * @public */ export class Voices extends Collection { /** @@ -35,7 +36,7 @@ export class Voices extends Collection { } // Если голосовое соединение не может принимать пакеты - else if (!connection.ready || connection.status === "disconnected") { + else if (!connection.isReadyToSend || connection.status === "disconnected") { this.remove(config.guild_id); connection = new VoiceConnection(config, adapterCreator); this.set(config.guild_id, connection); @@ -50,6 +51,7 @@ export class Voices extends Collection { * @author SNIPPIK * @description Все коды голосового состояния * @namespace WebSocketOpcodes + * @public */ export namespace WebSocketOpcodes { /** diff --git a/src/core/voice/sessions/dave.ts b/src/core/voice/managers/E2EE.ts similarity index 70% rename from src/core/voice/sessions/dave.ts rename to src/core/voice/managers/E2EE.ts index 4e8d5ebc..c6ff4cab 100644 --- a/src/core/voice/sessions/dave.ts +++ b/src/core/voice/managers/E2EE.ts @@ -1,13 +1,11 @@ import type { VoiceDavePrepareEpochData, VoiceDavePrepareTransitionData } from "discord-api-types/voice/v8"; import { Logger, TypedEmitter } from "#structures"; -import { SILENT_FRAME } from "#core/audio"; /** * @author SNIPPIK - * @description Версия протокола dave - * @public + * @description Текущая версия протокола dave */ -let DAVE_PROTOCOL_VERSION: number = 0; +let MAX_E2EE_PROTOCOL: number = 0; /** * @author SNIPPIK @@ -28,30 +26,27 @@ const TRANSITION_EXPIRY_PENDING_DOWNGRADE = 24; * @description Количество пакетов, для которых допускается сбой дешифрования, пока мы не сочтем переход неудачным и не выполним повторную инициализацию. * @const DEFAULT_DECRYPTION_FAILURE_TOLERANCE */ -const DEFAULT_DECRYPTION_FAILURE_TOLERANCE = 36; +//const DEFAULT_DECRYPTION_FAILURE_TOLERANCE = 36; /** * @author SNIPPIK * @description Управляет сеансом группы протокола DAVE. - * @class ClientDAVE + * @class E2EESession * @extends TypedEmitter * @public */ -export class ClientDAVE extends TypedEmitter { +export class E2EESession extends TypedEmitter { /** Последний выполненный идентификатор перехода */ public lastTransition_id?: number; /** Ожидаемый переход */ - private pendingTransition?: VoiceDavePrepareTransitionData; - - /** Был ли данный сеанс ранее понижен в рейтинге */ - private downgraded = false; + private pendingTransitions = new Map(); /** Количество последовательных сбоев, возникших при дешифровании */ - private consecutiveFailures = 0; + //private consecutiveFailures = 0; - /** Количество последовательных сбоев, необходимое для попытки восстановления */ - private readonly failureTolerance: number = DEFAULT_DECRYPTION_FAILURE_TOLERANCE; + /** Был ли данный сеанс ранее понижен в рейтинге */ + private downgraded = false; /** Выполняется ли повторная инициализация сеанса из-за недопустимого перехода */ public reinitializing = false; @@ -60,13 +55,11 @@ export class ClientDAVE extends TypedEmitter { public session: SessionMethods; /** - * @description Доступная версия DAVE + * @description Максимальная доступная версия протокола * @returns number - * @public - * @static */ public static get version(): number { - return DAVE_PROTOCOL_VERSION; + return MAX_E2EE_PROTOCOL; }; /** @@ -75,6 +68,7 @@ export class ClientDAVE extends TypedEmitter { * @public */ public set externalSender(externalSender: Buffer) { + // Если нет запущенной сессии if (!this.session) throw new Error("No session available"); this.session.setExternalSender(externalSender); this.emit("debug", "Set MLS external sender"); @@ -86,13 +80,32 @@ export class ClientDAVE extends TypedEmitter { * @public */ public set prepareEpoch(data: VoiceDavePrepareEpochData) { + if (this.reinitializing) return; + this.emit("debug", `Preparing for epoch (${data.epoch})`); + + // Если есть идентификатор if (data.epoch === 1) { - this.protocolVersion = data.protocol_version; + this.version = data.protocol_version; this.reinit(); } }; + /** + * @description Восстановление после недопустимого перехода путем повторной инициализации. + * @param transitionId - Идентификатор перехода для аннулирования + * @returns void + * @public + */ + public set recoverFromInvalidTransition(transitionId: number) { + if (this.reinitializing) return; + this.emit("debug", `Invalidating transition ${transitionId}`); + this.reinitializing = true; + //this.consecutiveFailures = 0; + this.emit("invalidateTransition", transitionId); + this.reinit(); + }; + /** * @description Создаем класс для управления сеансом DAVE * @constructor @@ -100,7 +113,7 @@ export class ClientDAVE extends TypedEmitter { */ public constructor( /** Используемая версия протокола DAVE */ - private protocolVersion: number, + private version: number, /** Идентификатор пользователя, представленный этим сеансом. */ private user_id: string, @@ -117,24 +130,26 @@ export class ClientDAVE extends TypedEmitter { * @public */ public reinit = (): void => { - if (this.protocolVersion > 0 && this.user_id && this.channel_id) { + // Если можно создать сессию + if (this.version > 0 && this.user_id && this.channel_id) { // Если сессия уже есть if (this.session) { - this.session.reinit(this.protocolVersion, this.user_id, this.channel_id); - this.emit("debug", `Session reinitialized for protocol version ${this.protocolVersion}`); + this.session.reinit(this.version, this.user_id, this.channel_id); + this.emit("debug", `Session reinitialized for protocol version ${this.version}`); } // Если сессии еще нет else { - this.session = new loaded_lib.DAVESession(this.protocolVersion, this.user_id, this.channel_id); - this.emit("debug", `Session initialized for protocol version ${this.protocolVersion}`); + this.session = new loaded_lib.DAVESession(this.version, this.user_id, this.channel_id); + this.emit("debug", `Session initialized for protocol version ${this.version}`); } - // Даем немного времени для отправки ключа - setImmediate(() => { - this.emit("key", this.session.getSerializedKeyPackage()); - }); - } else if (this.session) { + // Отправляем ключ + this.emit("key", this.session.getSerializedKeyPackage()); + } + + // Если уже есть сессия + else if (this.session) { this.session.reset(); this.session.setPassthroughMode(true, TRANSITION_EXPIRY); this.emit("debug", "Session reset"); @@ -149,16 +164,11 @@ export class ClientDAVE extends TypedEmitter { */ public prepareTransition = (data: VoiceDavePrepareTransitionData) => { this.emit("debug", `Preparing for transition (${data.transition_id}, v${data.protocol_version})`); - this.pendingTransition = data; + this.pendingTransitions.set(data.transition_id, data.protocol_version); - // Если включенный идентификатор перехода равен 0, переход предназначен для (повторной) инициализации и может быть выполнен немедленно. if (data.transition_id === 0) this.executeTransition(data.transition_id); - else { - if (data.protocol_version === 0) this.session?.setPassthroughMode(true, TRANSITION_EXPIRY_PENDING_DOWNGRADE); - return true; - } - - return false; + else if (data.protocol_version === 0) this.session?.setPassthroughMode(true, TRANSITION_EXPIRY_PENDING_DOWNGRADE); + return data.transition_id !== 0; }; /** @@ -169,55 +179,31 @@ export class ClientDAVE extends TypedEmitter { */ public executeTransition = (transition_id: number) => { this.emit("debug", `Executing transition (${transition_id})`); - if (!this.pendingTransition) { + + // Если нет данных для смены версии DAVE + if (!this.pendingTransitions.has(transition_id)) { this.emit("debug", `Received execute transition, but we don't have a pending transition for ${transition_id}`); - return null; + return false; } - let transitioned = false; - if (transition_id === this.pendingTransition.transition_id) { - const oldVersion = this.protocolVersion; - this.protocolVersion = this.pendingTransition.protocol_version; - - // Управляйте обновлениями и откладывайте понижения - if (oldVersion !== this.protocolVersion && this.protocolVersion === 0) { - this.downgraded = true; - this.emit("debug", "Session downgraded"); - } else if (transition_id > 0 && this.downgraded) { - this.downgraded = false; - this.session?.setPassthroughMode(true, TRANSITION_EXPIRY); - this.emit("debug", "Session upgraded"); - } - - // В будущем мы также хотели бы подать сигнал DAVESession о переходе, но на данный момент поддерживается только версия v1. - transitioned = true; - this.reinitializing = false; - this.lastTransition_id = transition_id; - this.emit("debug", `Transition executed (v${oldVersion} -> v${this.protocolVersion}, id: ${transition_id})`); - } else { - this.emit( - "debug", - `Received execute transition for an unexpected transition id (expected: ${this.pendingTransition.transition_id}, actual: ${transition_id})`, - ); + const oldVersion = this.version; + this.version = this.pendingTransitions.get(transition_id)!; + + // Управление обновлениями и понижение версии + if (oldVersion !== this.version && this.version === 0) { + this.downgraded = true; + this.emit("debug", "Session downgraded"); + } else if (transition_id > 0 && this.downgraded) { + this.downgraded = false; + this.session?.setPassthroughMode(true, TRANSITION_EXPIRY); + this.emit("debug", "Session upgraded"); } - this.pendingTransition = undefined; - return transitioned; - }; - - /** - * @description Восстановление после недопустимого перехода путем повторной инициализации. - * @param transitionId - Идентификатор перехода для аннулирования - * @returns void - * @public - */ - public recoverFromInvalidTransition = (transitionId: number): void => { - if (this.reinitializing) return; - this.emit("debug", `Invalidating transition ${transitionId}`); - this.reinitializing = true; - this.consecutiveFailures = 0; - this.emit("invalidateTransition", transitionId); - this.reinit(); + // В будущем можно будет подать сигнал DAVESession о переходе, но на данный момент поддерживается только версия v1. + this.lastTransition_id = transition_id; + this.emit("debug", `Transition executed (v${oldVersion} -> v${this.version}, id: ${transition_id})`); + this.pendingTransitions.delete(transition_id); + return true; }; /** @@ -227,11 +213,9 @@ export class ClientDAVE extends TypedEmitter { * @returns Buffer * @public */ - public processProposals = (payload: Buffer, connectedClients: Set): Buffer | undefined => { + public processProposals = (payload: Buffer, connectedClients: Set): Buffer | null => { if (!this.session) throw new Error("No session available"); - this.emit("debug", "MLS proposals processed"); - const { commit, welcome } = this.session.processProposals( payload.readUInt8(0) as 0 | 1, payload.subarray(1), @@ -239,58 +223,35 @@ export class ClientDAVE extends TypedEmitter { ); if (!commit) return null; - return welcome ? Buffer.concat([commit, welcome]) : commit; + + return Buffer.concat([commit, welcome]); }; /** * @description Обрабатывает фиксацию из группы MLS. + * @param type - Тип вызова * @param payload - Полезная нагрузка * @returns TransitionResult * @public */ - public processCommit = (payload: Buffer): TransitionResult => { + public processMLSTransit = (type: "commit" | "welcome", payload: Buffer): TransitionResult => { if (!this.session) throw new Error("No session available"); const transition_id = payload.readUInt16BE(0); + const flag = payload.subarray(2); try { - this.session.processCommit(payload.subarray(2)); + this.session[type === "commit" ? "processCommit" : "processWelcome"](flag); if (transition_id === 0) { this.reinitializing = false; this.lastTransition_id = transition_id; - } else this.pendingTransition = { transition_id, protocol_version: this.protocolVersion }; + } else this.pendingTransitions.set(transition_id, this.version); - this.emit("debug", `MLS commit processed (transition id: ${transition_id})`); + this.emit("debug", `MLS ${type} processed (transition id: ${transition_id})`); return { transition_id, success: true }; } catch (error) { - this.emit("debug", `MLS commit errored from transition ${transition_id}: ${error}`); - this.recoverFromInvalidTransition(transition_id); - return { transition_id, success: false }; - } - }; - - /** - * @description Обрабатывает приветствие от группы MLS. - * @param payload - Полезная нагрузка - * @returns TransitionResult - * @public - */ - public processWelcome = (payload: Buffer): TransitionResult => { - if (!this.session) throw new Error("No session available"); - const transition_id = payload.readUInt16BE(0); - - try { - this.session.processWelcome(payload.subarray(2)); - if (transition_id === 0) { - this.reinitializing = false; - this.lastTransition_id = transition_id; - } else this.pendingTransition = { transition_id, protocol_version: this.protocolVersion }; - - this.emit("debug", `MLS welcome processed (transition id: ${transition_id})`); - return { transition_id, success: true }; - } catch (error) { - this.emit("debug", `MLS welcome errored from transition ${transition_id}: ${error}`); - this.recoverFromInvalidTransition(transition_id); + this.emit("debug", `MLS ${type} errored from transition ${transition_id}: ${error}`); + this.recoverFromInvalidTransition = transition_id; return { transition_id, success: false }; } }; @@ -302,8 +263,7 @@ export class ClientDAVE extends TypedEmitter { * @public */ public encrypt = (packet: Buffer) => { - if (this.protocolVersion === 0 || !this.session?.ready || packet.equals(SILENT_FRAME)) return packet; - return this.session.encryptOpus(packet); + return (this.version === 0 || !this.session?.ready) ? null : this.session.encryptOpus(packet); }; /** @@ -313,35 +273,34 @@ export class ClientDAVE extends TypedEmitter { * @returns Buffer * @public */ - public decrypt = (packet: Buffer, userId: string) => { - const canDecrypt = this.session?.ready && (this.protocolVersion !== 0 || this.session?.canPassthrough(userId)); - if (packet.equals(SILENT_FRAME) || !canDecrypt || !this.session) return packet; + /*public decrypt = (packet: Buffer, userId: string) => { + const canDecrypt = this.session?.ready && (this.version !== 0 || this.session.canPassthrough(userId)); + + // Если невозможно расшифровать opus frame + if (!canDecrypt || !this.session) return null; try { const buffer = this.session.decrypt(userId, loaded_lib.MediaType.AUDIO, packet); this.consecutiveFailures = 0; return buffer; } catch (error) { - if (!this.reinitializing && !this.pendingTransition) { + if (!this.reinitializing && this.pendingTransitions.size === 0) { this.consecutiveFailures++; this.emit("debug", `Failed to decrypt a packet (${this.consecutiveFailures} consecutive fails)`); - if (this.consecutiveFailures > this.failureTolerance) { - if (this.lastTransition_id) this.recoverFromInvalidTransition(this.lastTransition_id); + if (this.consecutiveFailures > DEFAULT_DECRYPTION_FAILURE_TOLERANCE) { + if (this.lastTransition_id) this.recoverFromInvalidTransition = this.lastTransition_id; else throw error; } } else if (this.reinitializing) { this.emit("debug", 'Failed to decrypt a packet (reinitializing session)'); - } else if (this.pendingTransition) { - this.emit( - "debug", - `Failed to decrypt a packet (pending transition ${this.pendingTransition.transition_id} to v${this.pendingTransition.protocol_version})`, - ); + } else if (this.pendingTransitions.size > 0) { + this.emit('debug', `Failed to decrypt a packet (${this.pendingTransitions.size} pending transition[s])`); } } return null; - }; + };*/ /** * @description Сбрасывает сеанс и удаляет его @@ -350,33 +309,36 @@ export class ClientDAVE extends TypedEmitter { */ public destroy = () => { super.destroy(); + this.removeAllListeners(); try { this.session?.reset?.(); - } catch {} + } catch (error) { + Logger.log("ERROR", `Failed to destroy DAVE session: ${error}`); + } this.session = null; this.reinitializing = null; this.user_id = null; this.channel_id = null; this.lastTransition_id = null; - this.pendingTransition = null; + this.pendingTransitions.clear(); + this.pendingTransitions = null; this.downgraded = null; - this.pendingTransition = null; }; } /** * @author SNIPPIK * @description События класса DAVESession - * @interface ClientDAVE + * @interface E2EESession */ -export interface ClientDAVEEvents { +export interface ClientE2EEEvents { // Ошибка?! Какая ошибка - "error": (error: Error) => void + "error": (error: Error) => void; // Для отладки - "debug": (message: string) => void + "debug": (message: string) => void; // Получение ключа "key": (message: Buffer) => void; @@ -526,7 +488,7 @@ let loaded_lib: any = null; const library = await import(name); delete require.cache[require.resolve(name)]; - DAVE_PROTOCOL_VERSION = library?.DAVE_PROTOCOL_VERSION as number; + MAX_E2EE_PROTOCOL = library?.DAVE_PROTOCOL_VERSION as number; loaded_lib = library; return; } catch {} diff --git a/src/core/voice/managers/heartbeat.ts b/src/core/voice/managers/heartbeat.ts index ee93214a..547c2635 100644 --- a/src/core/voice/managers/heartbeat.ts +++ b/src/core/voice/managers/heartbeat.ts @@ -28,9 +28,6 @@ export class HeartbeatManager { /** Количество пропущенных ACK */ private misses = 0; - /** Количество переподключений подряд */ - private reconnects = 0; - /** Интервал между heartbeat-сообщениями */ public intervalMs = 0; @@ -50,20 +47,12 @@ export class HeartbeatManager { return this.misses; }; - /** - * @description Получаем количество подрядных попыток переподключения - * @public - */ - public get reconnectAttempts() { - return this.reconnects; - }; - /** * @param hooks - Объект с внешними методами: send, onTimeout, onAck * @constructor * @public */ - public constructor(private readonly hooks: HeartbeatHooks) {} + public constructor(private hooks: HeartbeatHooks) {} /** * @description Запускаем heartbeat с заданным интервалом @@ -78,7 +67,7 @@ export class HeartbeatManager { // Устанавливаем интервал отправки heartbeat this.interval = setInterval(() => { this.lastSentTime = Date.now(); - this.hooks.send(); // отправляем heartbeat + this.hooks?.send?.(); // отправляем heartbeat this.setTimeout(); // запускаем ожидание ack }, this.intervalMs); }; @@ -110,7 +99,7 @@ export class HeartbeatManager { this.misses = 0; if (this.timeout) clearTimeout(this.timeout); - this.hooks.onAck(latency); // передаём задержку наружу + this.hooks?.onAck?.(latency); // передаём задержку наружу }; /** @@ -128,21 +117,19 @@ export class HeartbeatManager { }; /** - * @description Сбросить счётчик reconnect'ов - * @returns void - * @public - */ - public resetReconnects = () => { - this.reconnects = 0; - }; - - /** - * @description Увеличить счётчик reconnect'ов (на 1) + * @description Останавливаем все heartbeat процессы и удаляем все данные * @returns void * @public */ - public increaseReconnect = () => { - this.reconnects++; + public destroy = () => { + this.stop(); + + this.misses = null; + this.lastAckTime = null; + this.lastSentTime = null; + this.misses = null; + this.intervalMs = null; + this.hooks = null; }; } @@ -157,7 +144,7 @@ type HeartbeatHooks = { * @readonly * @private */ - readonly send: () => void; + readonly send?: () => void; /** * @description Метод вызывается, если не получен HEARTBEAT_ACK вовремя @@ -172,5 +159,5 @@ type HeartbeatHooks = { * @readonly * @private */ - readonly onAck: (latency: number) => void; + readonly onAck?: (latency: number) => void; }; \ No newline at end of file diff --git a/src/core/voice/modules/Speaker.ts b/src/core/voice/modules/Speaker.ts new file mode 100644 index 00000000..5108d497 --- /dev/null +++ b/src/core/voice/modules/Speaker.ts @@ -0,0 +1,160 @@ +import { HeartbeatManager } from "#core/voice/managers/heartbeat"; +import { VoiceOpcodes } from "discord-api-types/voice"; +import { VoiceConnection } from "#core/voice"; + +/** + * @author SNIPPIK + * @description Время через которое делается проверка speaking статус + * @const KEEP_SWITCH_SPEAKING + * @private + */ +const KEEP_SWITCH_SPEAKING = 10e3; + +/** + * @author SNIPPIK + * @description Максимальное значение счетчика активности + * @const MAX_SIZE_VALUE + * @private + */ +const MAX_SIZE_VALUE = 2 ** 32 - 1; + +/** + * @author SNIPPIK + * @description Класс управляющий голосовым состоянием спикера + * @class VoiceSpeakerManager + * @public + */ +export class VoiceSpeakerManager { + /** + * @description Текущий тип спикера + * @private + */ + private _type: SpeakerType = SpeakerType.disable; + + /** + * @description Список клиентов в голосовом состоянии + * @private + */ + public clients = new Set(); + + /** + * @description Менеджер жизни спикера + * @private + */ + private _heartbeat: HeartbeatManager; + + /** + * @description Данные для поддержания подключения UDP + * @private + */ + private keepAlive = { + /** + * @description Буфер, используемый для записи счетчика активности + * @readonly + * @private + */ + buffer: Buffer.alloc(4), + + /** + * @description Счетчика активности + * @private + */ + counter: 0 + }; + + /** + * @description Указанный тип спикера + * @private + */ + public get default(): SpeakerType { + return this.voice.configuration.self_speaker ?? SpeakerType.enable; + }; + + /** + * @description Задаем тип спикера + * @param type + * @public + */ + public set type(type) { + this._type = type; + }; + + /** + * @description Выдаем текущий тип спикера + * @public + */ + public get type() { + return this._type; + }; + + /** + * @description + * @param speaking + */ + public set speaking(speaking: SpeakerType) { + this._heartbeat.ack(); + + // Если нельзя по состоянию или уже бот говорит + if (this._type === speaking || !this.voice.websocket) return; + + // Меняем состояние спикера + this._type = speaking; + + // Обновляем статус голоса + this.voice.websocket.packet = { + op: VoiceOpcodes.Speaking, + d: { + speaking: speaking, + delay: 0, + ssrc: this.voice._attention.ssrc + }, + seq: this.voice.websocket?.sequence ?? -1 + }; + }; + + /** + * @description Получаем ссылку на voice класс + * @param voice + * @public + */ + public constructor(protected voice: VoiceConnection) { + const heartbeat = this._heartbeat = new HeartbeatManager({ + onTimeout: () => { + if (this.keepAlive.counter >= MAX_SIZE_VALUE) this.keepAlive.counter = 0; + this.keepAlive.buffer.writeUInt32BE(this.keepAlive.counter++, 0); + voice.raw_packet = this.keepAlive.buffer; + + // Отключаем спикер + this.speaking = SpeakerType.disable; + } + }); + + heartbeat.start(KEEP_SWITCH_SPEAKING); + }; + + /** + * @description Уничтожаем класс спикера + * @public + */ + public destroy = () => { + this._heartbeat.destroy(); + this._heartbeat = null; + + // Чистим список клиентов + this.clients.clear(); + this.clients = null; + }; +} + +/** + * @author SNIPPIK + * @description Тип спикера + * @enum SpeakerType + * @private + */ +export enum SpeakerType { + "disable", + "enable", + "fake", + "priority" = 4 +} \ No newline at end of file diff --git a/src/core/voice/sockets/ClientSRTPSocket.ts b/src/core/voice/protocols/VoiceRTPSocket.ts similarity index 60% rename from src/core/voice/sockets/ClientSRTPSocket.ts rename to src/core/voice/protocols/VoiceRTPSocket.ts index 9437aee5..107a8ac0 100644 --- a/src/core/voice/sockets/ClientSRTPSocket.ts +++ b/src/core/voice/protocols/VoiceRTPSocket.ts @@ -1,16 +1,5 @@ import crypto from "node:crypto"; -/** - * @author SNIPPIK - * @description Поддерживаемые типы шифрования - * @const Encryption - * @private - */ -const Encryption: { name: EncryptionModes, nonce: Buffer } = { - name: null, - nonce: null -}; - /** * @author SNIPPIK * @description Время до следующей проверки жизни @@ -38,38 +27,23 @@ const MAX_32BIT = 2 ** 32; /** * @author SNIPPIK * @description Класс для шифрования данных через библиотеки sodium или нативным способом - * @class ClientSRTPSocket + * @class VoiceRTPSocket * @public */ -export class ClientSRTPSocket { - /** - * @description Пустой заголовок RTP, для использования внутри класса - * @private - */ - private _RTP_HEAD = Buffer.allocUnsafe(12); +export class VoiceRTPSocket { + /** Пустой заголовок RTP, для использования внутри класса */ + private head = Buffer.allocUnsafe(12); - /** - * @description Пустой буфер - * @private - */ - private _nonce: Buffer = Buffer.from(Encryption.nonce); + /** Пустой буфер */ + private nonce: Buffer = Buffer.from(Encryption.nonce); - /** - * @description Порядковый номер пустого буфера - * @private - */ - private _nonceSize : number; + /** Порядковый номер пустого буфера */ + private _nonceFrame : number; - /** - * @description Последовательность opus фреймов - * @private - */ + /** Последовательность opus фреймов */ private sequence: number; - /** - * @description Время проигрывания opus фреймов (+960) - * @private - */ + /** Время проигрывания opus фреймов (+960) */ private timestamp: number; /** @@ -86,15 +60,10 @@ export class ClientSRTPSocket { * @returns Buffer * @public */ - public get nonceSize() { - // Проверяем что-бы не было превышения int 32 - if (this._nonceSize > MAX_32BIT) this._nonceSize = 0; - - // Записываем в буфер - this._nonce.writeUInt32BE(this._nonceSize, 0); - - this._nonceSize++; // Добавляем к размеру - return this._nonce; + public get nonceFrame() { + this._nonceFrame = (this._nonceFrame + 1) % MAX_32BIT; + this.nonce.writeUInt32BE(this._nonceFrame, 0); + return this.nonce; }; /** @@ -103,19 +72,19 @@ export class ClientSRTPSocket { * @private */ private get header() { - if (this.sequence > MAX_16BIT) this.sequence = 0; // Проверяем что-бы не было превышения int 16 - if (this.timestamp > MAX_32BIT) this.timestamp = 0; // Проверяем что-бы не было превышения int 32 + if (this.sequence >= MAX_16BIT) this.sequence = 0; // Проверяем что-бы не было превышения int 16 + if (this.timestamp >= MAX_32BIT) this.timestamp = 0; // Проверяем что-бы не было превышения int 32 - // Получаем текущий зашоловок - const RTPHead = this._RTP_HEAD; + // Получаем текущий заголовок + const RTPHead = this.head; // Записываем новую последовательность RTPHead.writeUInt16BE(this.sequence, 2); - this.sequence = (this.sequence + 1) & 0xFFFF; + this.sequence++; // Временная метка RTPHead.writeUInt32BE(this.timestamp, 4); - this.timestamp = (this.timestamp + TIMESTAMP_INC) >>> 0; + this.timestamp += TIMESTAMP_INC; // SSRC RTPHead.writeUInt32BE(this.options.ssrc, 8); @@ -129,12 +98,12 @@ export class ClientSRTPSocket { * @public */ public constructor(private options: EncryptorOptions) { - this.sequence = this.randomNBit(16); - this.timestamp = this.randomNBit(32); - this._nonceSize = this.randomNBit(32); + this.sequence = randomNBit(16); + this.timestamp = randomNBit(32); + this._nonceFrame = randomNBit(32); // Version + Flags, Payload Type 120 (Opus) - [this._RTP_HEAD[0], this._RTP_HEAD[1]] = [0x80, 0x78]; + [this.head[0], this.head[1]] = [0x80, 0x78]; }; /** @@ -145,66 +114,11 @@ export class ClientSRTPSocket { */ public packet = (frame: Buffer) => { // Получаем заголовок RTP - const RTPHead = this.header; - - // Получаем nonce буфер 12-24 бит - const nonce = this.nonceSize; - - return this.decodeAudioBuffer(RTPHead, frame, nonce); - }; - - /** - * @description Глубокая кодировка дает возможность шифровать из вне! - * @param RTPHead - Заголовок - * @param packet - Аудио пакет - * @param nonce - Размер nonce - * @returns Buffer - * @public - */ - public decodeAudioBuffer = (RTPHead: Buffer, packet: Buffer, nonce?: Buffer) => { - // Получаем тип шифрования - const mode = ClientSRTPSocket.mode; + const head = this.header; // Получаем nonce буфер 12-24 бит - if (!nonce) nonce = this.nonceSize; - - // Получаем первые 4 байта из буфера - const nonceBuffer = nonce.subarray(0, 4); - - // Шифровка aead_aes256_gcm - if (mode === "aead_aes256_gcm_rtpsize") { - const cipher = crypto.createCipheriv("aes-256-gcm", this.options.key, nonce, { authTagLength: 16 }); - cipher.setAAD(RTPHead); - return Buffer.concat([RTPHead, cipher.update(packet), cipher.final(), cipher.getAuthTag(), nonceBuffer]); - } - - // Шифровка через библиотеку - else if (mode === "aead_xchacha20_poly1305_rtpsize") { - const cryptoPacket = loaded_lib.crypto_aead_xchacha20poly1305_ietf_encrypt(packet, RTPHead, nonce, this.options.key); - return Buffer.concat([RTPHead, cryptoPacket, nonceBuffer]); - } - - // Если нет больше вариантов шифровки - throw new Error(`[Encryption Error]: Unsupported encryption mode "${mode}".`); - }; - - /** - * @description Возвращает случайное число, находящееся в диапазоне n бит - * @param bits - Количество бит - * @returns number - * @private - */ - private randomNBit = (bits: number) => { - const max = 2 ** bits; - const size = Math.ceil(bits / 8); - const maxGenerated = 2 ** (size * 8); - let rand: number; - - do { - rand = crypto.randomBytes(size).readUIntBE(0, size); - } while (rand >= maxGenerated - (maxGenerated % max)); - - return rand % max; + const nonce = this.nonceFrame; + return Encryption.encrypt(frame, head, nonce, this.options.key); }; /** @@ -213,15 +127,26 @@ export class ClientSRTPSocket { * @public */ public destroy = () => { - this._nonceSize = null; - this._nonce = null; + this._nonceFrame = null; this.timestamp = null; this.sequence = null; this.options = null; - this._RTP_HEAD = null; + this.nonce = null; + this.head = null; }; } +/** + * @author SNIPPIK + * @description Возвращает случайное число, находящееся в диапазоне n бит + * @param bits - Количество бит + * @returns number + * @private + */ +function randomNBit(bits: number){ + return crypto.randomInt(0, 1 << bits); +} + /** * @author SNIPPIK * @description Здесь будет находиться найденная библиотека, если она конечно будет найдена @@ -231,22 +156,42 @@ let loaded_lib: Methods.current = {}; /** * @author SNIPPIK - * @description Делаем проверку на наличие поддержки sodium + * @description Поддерживаемые типы шифрования + * @const Encryption + * @private + */ +const Encryption: { + name: EncryptionModes, + nonce: Buffer, + encrypt(plaintext: Buffer, additionalData: Buffer, nonce: Buffer, key: Uint8Array): Buffer; +} = { name: null, nonce: null, encrypt: null }; + +/** + * @author SNIPPIK + * @description Подготавливаем данные для шифрования sodium */ (async () => { // Если поддерживается нативная расшифровка if (crypto.getCiphers().includes("aes-256-gcm")) { Encryption.name = "aead_aes256_gcm_rtpsize"; Encryption.nonce = Buffer.alloc(12); + Encryption.encrypt = (packet, head, nonce, key) => { + // Получаем первые 4 байта из буфера + const nonceBuffer = nonce.subarray(0, 4); + const cipher = crypto.createCipheriv("aes-256-gcm", key, nonce); + + // !! ВАЖНО !!: Устанавливаем заголовок RTP (head) как AAD (Associated Data). + // Это гарантирует, что RTP-заголовок будет аутентифицирован (защищен от подделки), + // но не зашифрован, что соответствует SRTP. + cipher.setAAD(head); + return Buffer.concat([head, cipher.update(packet), cipher.final(), cipher.getAuthTag(), nonceBuffer]); + } return; } // Если нет нативной поддержки шифрования else { - /** - * @author SNIPPIK - * @description Поддерживаемые библиотеки - */ + // Поддерживаемые библиотеки const support_libs: Methods.supported = { sodium: (lib) => ({ crypto_aead_xchacha20poly1305_ietf_encrypt:(plaintext: Buffer, additionalData: Buffer, nonce: Buffer, key: ArrayBufferLike) => { @@ -277,6 +222,12 @@ let loaded_lib: Methods.current = {}; // Добавляем тип шифрования Encryption.name = "aead_xchacha20_poly1305_rtpsize"; Encryption.nonce = Buffer.alloc(24); + Encryption.encrypt = (packet, head, nonce, key) => { + // Получаем первые 4 байта из буфера + const nonceBuffer = nonce.subarray(0, 4); + const cryptoPacket = loaded_lib.crypto_aead_xchacha20poly1305_ietf_encrypt(packet, head, nonce, key); + return Buffer.concat([head, cryptoPacket, nonceBuffer]); + } // Делаем проверку всех доступных библиотек for await (const name of names) { @@ -284,7 +235,6 @@ let loaded_lib: Methods.current = {}; const library = await import(name); if (typeof library?.ready?.then === "function") await library.ready; Object.assign(loaded_lib, support_libs[name](library)); - delete require.cache[require.resolve(name)]; return; } catch {} } @@ -304,6 +254,7 @@ namespace Methods { /** * @description Поддерживаемый запрос к библиотеке * @type supported + * @public */ export type supported = { [name: string]: (lib: any) => current @@ -312,6 +263,7 @@ namespace Methods { /** * @description Новый тип шифровки пакетов * @interface current + * @public */ export interface current { crypto_aead_xchacha20poly1305_ietf_encrypt?(plaintext: Buffer, additionalData: Buffer, nonce: Buffer, key: ArrayBufferLike | Uint8Array): Buffer; @@ -321,6 +273,7 @@ namespace Methods { /** * @author SNIPPIK * @description Все актуальные типы шифровки discord + * @type EncryptionModes * @private */ type EncryptionModes = "aead_aes256_gcm_rtpsize"| "aead_xchacha20_poly1305_rtpsize"; @@ -328,6 +281,8 @@ type EncryptionModes = "aead_aes256_gcm_rtpsize"| "aead_xchacha20_poly1305_rtpsi /** * @author SNIPPIK * @description Параметры для шифрования + * @interface EncryptorOptions + * @public */ export interface EncryptorOptions { ssrc: number; diff --git a/src/core/voice/protocols/VoiceUDPSocket.ts b/src/core/voice/protocols/VoiceUDPSocket.ts new file mode 100644 index 00000000..43c92401 --- /dev/null +++ b/src/core/voice/protocols/VoiceUDPSocket.ts @@ -0,0 +1,205 @@ +import { createSocket, type Socket } from "node:dgram"; +import { type WebSocketOpcodes } from "#core/voice"; +import { TypedEmitter } from "#structures"; +import { isIPv4 } from "node:net"; + +/** + * @author SNIPPIK + * @description Создает udp подключение к Discord Gateway + * @class VoiceUDPSocket + * @extends TypedEmitter + * @public + */ +export class VoiceUDPSocket extends TypedEmitter { + private _status: VoiceUDPSocketStatuses; + + /** Socket UDP подключения */ + private socket: Socket; + + /** Данные подключения, полные данные пакета ready.d */ + public options: WebSocketOpcodes.ready["d"]; + + /** + * @description Отправка данных на сервер + * @param packet - Отправляемый пакет + * @public + */ + public set packet(packet: Buffer) { + // Если статус не позволяет отправить пакет + if (!this._status || this._status === "disconnected") return; + + // Отправляем аудио или буфер пакет + this.socket.send(packet, 0, packet.length, this.options.port, this.options.ip, (err) => { + if (err) this.emit("error", err); + }); + }; + + /** + * @description Получаем текущий статус подключения + * @public + */ + public get status() { + return this._status; + }; + + /** + * @description Подключаемся по UDP подключению + * @param options - Данные для подключения + * @public + */ + public connect = (options: WebSocketOpcodes.ready["d"]) => { + // Не имеет смысла создавать заново если все данные совпадают + if (this.options !== undefined) { + if (options.ip === this.options.ip && options.port === this.options.port && options.ssrc === this.options.ssrc) return; + this.removeAllListeners(); + } + + // Меняем данные + this.options = options; + + // Если уже есть подключение + if (this.socket) this.reset(); + + // Проверяем через какое соединение подключатся + const socket = this.socket = createSocket({ + type: isIPv4(options.ip) ? "udp4" : "udp6", + sendBufferSize: 1024 * 1024 * 5, // 5 MB + reuseAddr: true + }); + // Позволяет процессу умереть, даже если сокет жив + socket.unref(); + + socket.on("error", this.emit.bind(this, "error")); + socket.on("message", this.emit.bind(this, "message")); + + // Если подключение оборвалось + socket.once("close", () => { + this._status = VoiceUDPSocketStatuses.disconnected; + this.emit("close"); + }); + }; + + /** + * @description Просим указать путь до конечной точки + * @returns void + * @public + */ + public discovery = async (ssrc: number): Promise => { + const packet = Buffer.allocUnsafe(74); + packet.writeUInt16BE(1, 0); + packet.writeUInt16BE(70, 2); + packet.writeUInt32BE(ssrc, 4); + + this._status = VoiceUDPSocketStatuses.connecting; + this.packet = packet; + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + // Удаляем слушателя, чтобы не получить отложенный ответ + this.socket.removeAllListeners("message"); + this.socket.removeAllListeners("error"); + + this.destroy(); + resolve(Error("IP Discovery timed out after 5 seconds")); + }, 5000); // Таймаут 5 секунд + + // Ждем получения сообщения после отправки код, для подключения UDP + this.socket.once("message", (packet) => { + clearTimeout(timeout); + + if (packet.readUInt16BE(0) === 2) { + const ip = packet.subarray(8, packet.indexOf(0, 8)).toString("utf8"); + const port = packet.readUInt16BE(packet.length - 2); + + // Если провайдер не предоставляет или нет пути IPV4 + if (!isIPv4(ip)) return resolve(Error("Not found IPv4 address")); + + this._status = VoiceUDPSocketStatuses.connected; + return resolve({ip, port}) + } + + return resolve(Error("Failed to connect from UDP protocol")); + }); + + // Если не удалось получить данные для отправки аудио на UDP сервер + this.socket.once("error", (err) => { + clearTimeout(timeout); + this.destroy(); + resolve(Error(`UDP Error: ${err.message}`)); + }); + }); + }; + + /** + * @description Удаляем UDP подключение + * @returns void + * @private + */ + private reset = () => { + if (this.socket) { + try { + this.socket.disconnect?.(); + this.socket.close?.(); + } catch (err) { + if (err instanceof Error && err.message.includes("Not running")) return; + } + } + + this.socket = null; + }; + + /** + * @description Закрывает сокет, экземпляр не сможет быть повторно использован + * @returns void + * @public + */ + public destroy = () => { + if (this._status === "disconnected") return; + this._status = VoiceUDPSocketStatuses.disconnected; + + this?.removeAllListeners(); + this.socket?.removeAllListeners(); + super.destroy(); + this.reset(); + }; +} + +/** + * @author SNIPPIK + * @description События для UDP + * @interface UDPSocketEvents + * @public + */ +export interface UDPSocketEvents { + /** + * @description Событие при котором сокет получает ответ от сервера + * @param message - Само сообщение + * @readonly + */ + readonly "message": (message: Buffer) => void; + + /** + * @description Событие при котором сокет получает ошибку + * @param error - Ошибка + * @readonly + */ + readonly "error": (error: Error) => void; + + /** + * @description Событие при котором сокет закрывается + * @readonly + */ + readonly "close": () => void; +} + +/** + * @author SNIPPIK + * @description Состояния подключения + * @enum VoiceUDPSocketStatuses + * @private + */ +enum VoiceUDPSocketStatuses { + connected = "connected", + connecting = "connecting", + disconnected = "disconnected", +} \ No newline at end of file diff --git a/src/core/voice/sockets/ClientWebSocket.ts b/src/core/voice/protocols/VoiceWebSocket.ts similarity index 87% rename from src/core/voice/sockets/ClientWebSocket.ts rename to src/core/voice/protocols/VoiceWebSocket.ts index 9ffd68cc..302023aa 100644 --- a/src/core/voice/sockets/ClientWebSocket.ts +++ b/src/core/voice/protocols/VoiceWebSocket.ts @@ -1,7 +1,7 @@ -import { WebSocketOpcodes, GatewayCloseCodes } from "#core/voice"; +import { type WebSocketOpcodes, GatewayCloseCodes } from "#core/voice"; +import { WebSocket, type MessageEvent, type Data } from "ws"; import { VoiceOpcodes } from "discord-api-types/voice/v8"; import { HeartbeatManager } from "../managers/heartbeat"; -import { WebSocket, MessageEvent, Data } from "ws"; import { Logger, TypedEmitter } from "#structures"; import { version, name } from "package.json"; import os from "node:os"; @@ -10,24 +10,26 @@ import os from "node:os"; * @author SNIPPIK * @description Версия user agent для WebSocket * @const user_agent + * @private */ -const user_agent = `SNPK Team (${os.arch()}; ${os.version()}) ${version}/${name}`; +const user_agent = `Node.js (${os.arch()}; ${os.version()}) ${version}/${name}`; /** * @author SNIPPIK * @description Игнорируемые коды закрытия от discord * @const GatewayCloseCodesIgnore + * @private */ const GatewayCloseCodesIgnore: GatewayCloseCodes[] = [4014, 4022]; /** * @author SNIPPIK * @description Клиент для взаимодействия с discord, по методу wss - * @class ClientWebSocket + * @class VoiceWebSocket * @extends TypedEmitter * @public */ -export class ClientWebSocket extends TypedEmitter { +export class VoiceWebSocket extends TypedEmitter { /** * @description Текущий статус подключения клиента * @private @@ -35,22 +37,22 @@ export class ClientWebSocket extends TypedEmitter { private _status: WebSocketStatus = WebSocketStatus.idle; /** - * @description Адрес для подключения по wss + * @description Адрес для подключения по websocket * @private */ private _endpoint: string; /** - * @description Клиент wss + * @description Менеджер жизни подключения * @private */ - private ws: WebSocket; + private _heartbeat: HeartbeatManager; /** - * @description Менеджер жизни подключения + * @description Клиент websocket secure * @private */ - private _heartbeat: HeartbeatManager; + private ws: WebSocket; /** * @description Последовательность запроса @@ -58,6 +60,13 @@ export class ClientWebSocket extends TypedEmitter { */ public sequence: number = -1; + /** + * @description Последняя зафиксированная задержка в ms + * @public + */ + public latency: number = null; + private latencyArray: number[] = []; + /** * @description Текущий статус клиента * @public @@ -84,7 +93,7 @@ export class ClientWebSocket extends TypedEmitter { // Если ws упал if (`${err}`.match(/Cannot read properties of null/)) { // Пробуем подключится заново - this.connect(this._endpoint, 4001); + this.connect(this._endpoint, 4000); return; } @@ -114,21 +123,21 @@ export class ClientWebSocket extends TypedEmitter { // Если не получен HEARTBEAT_ACK вовремя onTimeout: () => { - if (this._heartbeat.missed === 3) { - this._heartbeat.stop(); - - // Если текущий статус не является подключенным - if (this._status !== "connected") { - this.emit("close", 1001, "HEARTBEAT_ACK timeout"); - this.emit("warn", "HEARTBEAT_ACK timeout x3, reconnecting..."); - } - } else { - this.emit("warn", "HEARTBEAT_ACK not received in time"); - } + this.emit("close", 4000, "HEARTBEAT_ACK timeout"); + this.emit("warn", "HEARTBEAT_ACK timeout, reconnecting..."); }, // Получен HEARTBEAT_ACK onAck: (latency) => { + /* Высчитываем задержку подключения */ + this.latencyArray.push(latency); + if (this.latencyArray.length > 10) this.latencyArray.shift(); + + let sum = 0; + for (const v of this.latencyArray) sum += v; + this.latency = parseInt((sum / this.latencyArray.length).toFixed(0)); + + // Отправляем событие об ответе от websocket this.emit("warn", `HEARTBEAT_ACK received. Latency: ${latency} ms`); } }); @@ -276,7 +285,7 @@ export class ClientWebSocket extends TypedEmitter { // Получение heartbeat_interval case VoiceOpcodes.Hello: { - this._heartbeat.start(d["heartbeat_interval"]); + this._heartbeat.start(d.heartbeat_interval); break; } @@ -286,22 +295,16 @@ export class ClientWebSocket extends TypedEmitter { break; } - // Проверка подключения клиента + // Проверка подключения/отключения клиента + case VoiceOpcodes.ClientDisconnect: case VoiceOpcodes.ClientsConnect: { - this.emit("ClientConnect", payload); - break; - } - - // Проверка отключения клиента - case VoiceOpcodes.ClientDisconnect: { - this.emit("ClientDisconnect", payload); + this.emit("UsersRJC", payload); break; } // Получение статуса готовности case VoiceOpcodes.Ready: { this.emit("ready", payload); - this._heartbeat.resetReconnects(); // Сбросить счётчик при успешном подключении break; } @@ -361,10 +364,19 @@ export class ClientWebSocket extends TypedEmitter { if (this.ws) { this.ws.removeAllListeners(); this.ws.close(); + + + if (this.ws && this.ws.readyState !== WebSocket.CLOSED) { + this.ws.terminate(); + } } this.ws = null; - this._heartbeat.stop(); + this.latencyArray.length = 0; + this.latencyArray = []; + + // Если есть менеджер жизни ws + if (this._heartbeat) this._heartbeat.stop(); }; /** @@ -376,10 +388,15 @@ export class ClientWebSocket extends TypedEmitter { this.reset(); super.destroy(); this.sequence = null; - this._heartbeat.stop(); - this._heartbeat = null; this._endpoint = null; this._status = null; + + this.latencyArray = null; + + if (this._heartbeat) { + this._heartbeat.destroy(); + this._heartbeat = null; + } }; } @@ -433,16 +450,10 @@ interface ClientWebSocketEvents { "speaking": (d: WebSocketOpcodes.speaking_get) => void; /** - * @description Если получен код подключения нового клиента - * @constructor - */ - "ClientConnect": (d: WebSocketOpcodes.connect) => void; - - /** - * @description Если получен код отключения клиента + * @description Если добавлен новый пользователь или удален старый * @constructor */ - "ClientDisconnect": (d: WebSocketOpcodes.disconnect) => void; + "UsersRJC": (d: WebSocketOpcodes.connect | WebSocketOpcodes.disconnect) => void; /** * @description Если клиент был отключен из-за отключения бота от голосового канала diff --git a/src/core/voice/sockets/ClientUDPSocket.ts b/src/core/voice/sockets/ClientUDPSocket.ts deleted file mode 100644 index bf26496d..00000000 --- a/src/core/voice/sockets/ClientUDPSocket.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { createSocket, type Socket } from "node:dgram"; -import { type WebSocketOpcodes } from "#core/voice"; -import { TypedEmitter } from "#structures"; -import { isIPv4 } from "node:net"; - -/** - * @author SNIPPIK - * @description Максимальное значение счетчика активности - * @private - */ -const MAX_SIZE_VALUE = 2 ** 32 - 1; - -/** - * @author SNIPPIK - * @description Создает udp подключение к Discord Gateway - * @class ClientUDPSocket - * @public - */ -export class ClientUDPSocket extends TypedEmitter { - /** Параметр подключения */ - private isConnected = false; - - /** - * @description Уничтожен ли класс - * @private - */ - private destroyed = false; - - /** - * @description Socket UDP подключения - * @readonly - * @private - */ - private socket: Socket; - - /** - * @description Данные для поддержания udp соединения - * @private - */ - private keepAlive = { - /** - * @description Интервал для предотвращения разрыва - * @readonly - * @private - */ - interval: null as NodeJS.Timeout, - - /** - * @description Интервал для предотвращения разрыва в миллисекундах - * @readonly - * @private - */ - intervalMs: 0, - - /** - * @description Таймер по истечению которого будет запущен интервал - * @readonly - * @private - */ - timeout: null as NodeJS.Timeout, - - /** - * @description Буфер, используемый для записи счетчика активности - * @readonly - * @private - */ - buffer: Buffer.alloc(4), - - /** - * @description Счетчика активности - * @private - */ - counter: 0 - }; - - /** - * @description Данные подключения, полные данные пакета ready.d - * @public - */ - public options: WebSocketOpcodes.ready["d"]; - - /** - * @description Отправка данных на сервер - * @param packet - Отправляемый пакет - * @public - */ - public set packet(packet: Buffer) { - // Отправляем DAVE(RTP+OPUS) пакет - this.socket.send(packet, 0, packet.length, this.options.port, this.options.ip, (err) => { - if (err) this.emit("error", err); - }); - - this.resetKeepAliveInterval(); - }; - - /** - * @description Подключен ли UDP к серверу - * @public - */ - public get connected() { - return this.isConnected; - }; - - /** - * @description Подключаемся по UDP подключению - * @param options - Данные для подключения - * @public - */ - public connect = (options: WebSocketOpcodes.ready["d"]) => { - this.keepAlive.intervalMs = options.heartbeat_interval; // Меняем интервал - - // Не имеет смысла создавать заново если все данные совпадают - if (this.options !== undefined) { - if (options.ip === this.options.ip && options.port === this.options.port && options.ssrc === this.options.ssrc) return; - this.removeAllListeners(); - } - - // Меняем данные - this.options = options; - - // Если уже есть подключение - if (this.socket) this.reset(); - - // Проверяем через какое соединение подключатся - const socket = this.socket = createSocket({ - type: isIPv4(options.ip) ? "udp4" : "udp6" - }); - - // Отправляем пакет данных для получения реального ip, port - this.discovery(options.ssrc); - - // Если подключение возвращает ошибки - socket.on("error", (err) => { - this.emit("error", err); - }); - - socket.on("message", (msg) => { - this.isConnected = true; - this.emit("message", msg); - }); - - // Если подключение оборвалось - socket.once("close", () => { - this.isConnected = false; - this.emit("close"); - }); - - this.manageKeepAlive(); - }; - - /** - * @description Подключаемся к серверу через UDP подключение - * @returns void - * @public - */ - public discovery = (ssrc: number) => { - this.packet = this.discoveryBuffer(ssrc); - - // Ждем получения сообщения после отправки код, для подключения UDP - this.socket.once("message", (message) => { - if (message.readUInt16BE(0) === 2) { - const packet = Buffer.from(message); - const ip = packet.subarray(8, packet.indexOf(0, 8)).toString("utf8"); - const port = packet.readUInt16BE(packet.length - 2); - - // Если провайдер не предоставляет или нет пути IPV4 - if (!isIPv4(ip)) { - this.emit("error", Error("Not found IPv4 address")); - return; - } - - this.emit("connected", { ip, port }); - } - }); - }; - - /** - * @description Удаляем UDP подключение - * @private - */ - private reset = () => { - if (this.socket) { - try { - this.socket.disconnect?.(); - this.socket.close?.(); - } catch (err) { - if (err instanceof Error && err.message.includes("Not running")) return; - } - } - - this.socket = null; - }; - - /** - * @description Закрывает сокет, экземпляр не сможет быть повторно использован - * @returns void - * @public - */ - public destroy = () => { - if (this.destroyed) return; - this.destroyed = true; - - // Уничтожаем интервал активности - clearInterval(this.keepAlive.interval); - clearTimeout(this.keepAlive.timeout); - - this.socket.removeAllListeners(); - super.destroy(); - - this.keepAlive = null; - this.destroyed = null; - - this.reset(); - }; - - /** - * @description Пакет для создания UDP соединения - * @returns Buffer - * @public - */ - private discoveryBuffer = (ssrc: number) => { - /** Безопасен, поскольку данные будут сразу перезаписаны */ - const packet = Buffer.allocUnsafe(74); - packet.writeUInt16BE(1, 0); - packet.writeUInt16BE(70, 2); - packet.writeUInt32BE(ssrc, 4); - - return packet; - }; - - /** - * @description Функция для запуска интервала для поддержания соединения - * @returns void - * @private - */ - private manageKeepAlive = () => { - if (this.keepAlive.interval) clearInterval(this.keepAlive.interval); - if (this.keepAlive.timeout) clearTimeout(this.keepAlive.timeout); - - // Запускаем интервал (по-умолчанию) - this.keepAlive.interval = setInterval(() => { - if (this.keepAlive.counter > MAX_SIZE_VALUE) this.keepAlive.counter = 0; - - this.keepAlive.buffer.writeUInt32BE(this.keepAlive.counter++, 0); - this.packet = this.keepAlive.buffer; - }, this.keepAlive.intervalMs); - }; - - /** - * @description Сброс таймера для поддерживания KeepAlive - * @returns void - * @private - */ - private resetKeepAliveInterval = () => { - if (this.keepAlive.interval) clearInterval(this.keepAlive.interval); - if (this.keepAlive.timeout) clearTimeout(this.keepAlive.timeout); - - // Выставляем таймер возобновления KeepAlive - this.keepAlive.timeout = setTimeout(() => this.manageKeepAlive(), 2e3); - }; -} - -/** - * @author SNIPPIK - * @description События для UDP - * @interface UDPSocketEvents - * @public - */ -export interface UDPSocketEvents { - /** - * @description Событие при котором сокет получает ответ от сервера - * @param message - Само сообщение - */ - readonly "message": (message: Buffer) => void; - - /** - * @description Событие при котором сокет получает ошибку - * @param error - Ошибка - */ - readonly "error": (error: Error) => void; - - /** - * @description Событие при котором сокет закрывается - */ - readonly "close": () => void; - - /** - * @description Событие при котором будет возращены данные для подключения - */ - readonly "connected": (info: { ip: string; port: number; }) => void; -} \ No newline at end of file diff --git a/src/core/voice/managers/receiver.ts b/src/core/voice/structures/receiver.ts similarity index 89% rename from src/core/voice/managers/receiver.ts rename to src/core/voice/structures/receiver.ts index f69e42d0..5fc35adf 100644 --- a/src/core/voice/managers/receiver.ts +++ b/src/core/voice/structures/receiver.ts @@ -5,6 +5,7 @@ import { TypedEmitter } from "#structures"; * @author SNIPPIK * @description Размер тега авторизации * @const AUTH_TAG_LENGTH + * @private */ const AUTH_TAG_LENGTH = 16; @@ -12,6 +13,7 @@ const AUTH_TAG_LENGTH = 16; * @author SNIPPIK * @description Размер nonce * @const UNPADDED_NONCE_LENGTH + * @private */ const UNPADDED_NONCE_LENGTH = 4; @@ -19,6 +21,7 @@ const UNPADDED_NONCE_LENGTH = 4; * @author SNIPPIK * @description Заголовок discord receive * @const HEADER_EXTENSION_BYTE + * @private */ const HEADER_EXTENSION_BYTE = Buffer.from([0xbe, 0xde]); @@ -47,22 +50,23 @@ export class VoiceReceiver extends TypedEmitter { * @description Запуск класса слушателя, для прослушивания пользователей * @param voice - Голосове подключение * @constructor + * @public */ public constructor(private readonly voice: VoiceConnection) { super(); // Задаем SSRC - voice["websocket"].on("speaking", ({d}) => { + voice.websocket.on("speaking", ({d}) => { this.ssrc = d.ssrc; }); // Если подключается новый пользователь - voice["websocket"].on("ClientConnect", ({d}) => { + voice.websocket.on("ClientConnect", ({d}) => { this._users = d.user_ids; }); // Если отключается пользователь - voice["websocket"].on("ClientDisconnect", ({d}) => { + voice.websocket.on("ClientDisconnect", ({d}) => { const index = this._users.indexOf(d.user_id); // Если есть пользователь @@ -72,7 +76,7 @@ export class VoiceReceiver extends TypedEmitter { }); // Слушаем UDP подключение - voice["clientUDP"].on("message", (message) => { + voice.udp.on("message", (message) => { // Если сообщение меньше размера SSRC if (message.length <= 8) return; @@ -80,7 +84,7 @@ export class VoiceReceiver extends TypedEmitter { if (this.ssrc === ssrc) { // Копируем последние 4 байта незаполненного одноразового значения в заполнение (12 - 4) или (24 - 4) байтов. - message.copy(voice["clientSRTP"]["_nonceBuffer"], 0, message.length - UNPADDED_NONCE_LENGTH); + message.copy(voice.sRTP["_nonceBuffer"], 0, message.length - UNPADDED_NONCE_LENGTH); const audio = this.parsePacket(message); this.emit("speaking", this._users, ssrc, audio); @@ -112,7 +116,7 @@ export class VoiceReceiver extends TypedEmitter { ); */ - let packet = this.voice["clientSRTP"].decodeAudioBuffer(header, encrypted, this.voice["clientSRTP"]["_nonce"]); + let packet = this.voice.sRTP.packet(Buffer.concat([header, encrypted, this.voice.sRTP["_nonce"]])); // Если нет аудио if (!packet) return null; @@ -132,10 +136,11 @@ export class VoiceReceiver extends TypedEmitter { * @author SNIPPIK * @description События слушателя * @interface VoiceReceiverEvents + * @private */ interface VoiceReceiverEvents { /** - * @description Событие когда говорит аользователь + * @description Событие когда говорит пользователь * @param ids - IDs всех говорящих пользователей * @param ssrc - SSRC сессии * @param audio - Аудио пакет от пользователя diff --git a/src/core/player/utils/cache.ts b/src/database/index.saver.ts similarity index 60% rename from src/core/player/utils/cache.ts rename to src/database/index.saver.ts index 904625d0..43bee89f 100644 --- a/src/core/player/utils/cache.ts +++ b/src/database/index.saver.ts @@ -1,7 +1,7 @@ -import { PLAYER_BUFFERED_TIME } from "#core/player"; -import { Logger, PromiseCycle } from "#structures"; -import { Process } from "#core/audio"; -import { Track } from "#core/queue"; +import { PromiseCycle } from "#structures/tools/Cycle"; +import { Process } from "#core/audio/process"; +import { Logger } from "#structures/logger"; +import type { Track } from "#core/queue"; import afs from "node:fs/promises"; import { env } from "#app/env"; import path from "node:path"; @@ -9,94 +9,50 @@ import fs from "node:fs"; /** * @author SNIPPIK - * @description Класс для кеширования аудио и данных о треках - * @class CacheUtility - * @readonly + * @description Утилита для скачивания метаданных треков + * @class MetaSaver * @public */ -export class CacheUtility { +export class MetaSaver { /** - * @description Параметры утилиты кеширования - * @readonly - * @private - */ - private readonly _options = { - /** - * @description Путь до директории с кешированными данными - * @private - */ - dirname: path.resolve(env.get("cache.dir")), - - /** - * @description Можно ли сохранять файлы - */ - inFile: env.get("cache.file"), - - /** - * @description Включена ли система кеширования - */ - isOn: env.get("cache") - }; - - /** - * @description База данных треков - * @readonly - * @private - */ - private readonly data = { - /** - * @description Кешированные треки - */ - tracks: !this.inFile ? new Map() : null as Map, - - /** - * @description Класс кеширования аудио файлов - */ - audio: this.inFile ? new CacheAudio(this._options.dirname) : null as CacheAudio - }; - - /** - * @description Выдаем класс для кеширования аудио - * @returns CacheAudio + * @description Можно ли сохранять файлы + * @returns boolean * @public */ - public get audio(): CacheAudio { - if (!this._options.inFile) return null; - return this.data.audio; - }; + public inFile = env.get("cache.file") as boolean; /** - * @description Путь до директории кеширования + * @description Путь до директории с кешированными данными * @returns string - * @public + * @private */ - public get dirname() { return this._options.dirname; }; + public _dirname = path.resolve(env.get("cache.dir")); /** - * @description Можно ли сохранять кеш в файл - * @returns string - * @public + * @description Бд треков, для повторного использования + * @private */ - public get inFile() { return this._options.inFile; }; + private tracks: Map = !this.inFile ? new Map() : null; /** - * @description Сохраняем данные в класс + * @description Сохраняем трек в локальную базу данных * @param track - Кешируемый трек * @param api - Ссылка на платформу * @returns Promise * @public */ - public set = async (track: Track.data, api: string) => { + public set = async (track: Track.data, api: string): Promise => { + // Если нельзя сохранять в файлы if (this.inFile) { - const filePath = path.join(this.dirname, "Data", api, `${track.id}.json`); + const Path = path.join(this._dirname, "Data", api, `${track.id}.json`); - if (!fs.existsSync(filePath)) { + if (!fs.existsSync(Path)) { try { - const dirPath = path.dirname(filePath); + const dirPath = path.dirname(Path); await afs.mkdir(dirPath, { recursive: true }); // Записываем данные в файл - await afs.writeFile(filePath, JSON.stringify( + await afs.writeFile(Path, JSON.stringify( { track: { ...track, @@ -108,13 +64,15 @@ export class CacheUtility { } } } else { - const song = this.data.tracks.get(track.id); + const song = this.tracks?.get?.(track.id); // Если уже сохранен трек - if (song) return; + if (song) return null; - this.data.tracks.set(track.id, track); + this.tracks.set(track.id, track); } + + return null; }; /** @@ -124,12 +82,13 @@ export class CacheUtility { * @public */ public get = (ID: string): Track.data => { + // Если нельзя сохранять в файлы if (this.inFile) { // Если есть трек в кеше - if (fs.existsSync(`${this.dirname}/Data/${ID}.json`)) { + if (fs.existsSync(`${this._dirname}/Data/${ID}.json`)) { try { // Если трек кеширован в файл - const json = JSON.parse(fs.readFileSync(`${this.dirname}/Data/${ID}.json`, "utf8")); + const json = JSON.parse(fs.readFileSync(`${this._dirname}/Data/${ID}.json`, "utf8")); // Если трек был найден среди файлов if (json) return json.track; @@ -141,7 +100,7 @@ export class CacheUtility { // Если включен режим без кеширования в файл else { - const track = this.data.tracks.get(ID.split("/").at(-1)); + const track = this.tracks?.get?.(ID.split("/").at(-1)); // Если трек кеширован в память, то выдаем данные if (track) return track; @@ -153,21 +112,22 @@ export class CacheUtility { /** * @author SNIPPIK - * @description Класс для сохранения аудио файлов - * @support ogg/opus - * @class CacheAudio - * @extends PromiseCycle - * @private + * @description Утилита для скачивания аудио данных + * @class AudioSaver + * @extends PromiseCycle + * @public */ -class CacheAudio extends PromiseCycle { +export class AudioSaver extends PromiseCycle { /** - * @description Запускаем работу цикла - * @constructor - * @public + * @description Путь до директории с кешированными данными + * @returns string + * @private */ - public constructor(private readonly cache_dir: string) { + public _dirname = path.resolve(env.get("cache.dir")); + + public constructor() { super({ - drift: true, + duration: 30e3, custom: { push: (track) => { // Защита от повторного добавления @@ -181,7 +141,7 @@ class CacheAudio extends PromiseCycle { const names = this.status(item); // Если такой трек уже есть в системе кеширования - if (names.status === "ended" || item.time.total > PLAYER_BUFFERED_TIME || item.time.total === 0) { + if (names.status === "ended" || item.time.total > 500 || item.time.total === 0) { this.delete(item); return false; } @@ -218,10 +178,10 @@ class CacheAudio extends PromiseCycle { // Если запись была завершена ffmpeg.stdout.once("end", () => { - fs.stat(`${status.path}.opus`, (err, stats) => { - // Если файл не проходит проверку - if (err || stats.size < 10) fs.unlink(`${status.path}.opus`, (err) => Logger.log("ERROR", err)); - }); + fs.stat(`${status.path}.opus`, (err, stats) => { + // Если файл не проходит проверку + if (err || stats.size < 10) fs.unlink(`${status.path}.opus`, (err) => Logger.log("ERROR", err)); + }); ffmpeg.destroy(); this.delete(track); @@ -230,7 +190,7 @@ class CacheAudio extends PromiseCycle { }); } }); - }; + } /** * @description Получаем статус скачивания и путь до файла @@ -240,8 +200,8 @@ class CacheAudio extends PromiseCycle { public status = (track: Track | string): { status: "not-ended" | "ended" | "download", path: string } => { let file: string; - if (track instanceof Track) { - file = `${this.cache_dir}/Audio/${track.api.url}/${track.ID}`; + if (typeof track !== "string") { + file = `${this._dirname}/Audio/${track.api.url}/${track.ID}`; // Если трека нет в очереди, значит он есть if (!this.has(track)) { @@ -252,7 +212,7 @@ class CacheAudio extends PromiseCycle { // Выдаем что ничего нет return { status: "not-ended", path: file }; } else { - file = `${this.cache_dir}/Audio/${track}`; + file = `${this._dirname}/Audio/${track}`; // Если файл все таки есть if (fs.existsSync(`${file}.opus`)) return {status: "ended", path: `${file}.opus`}; diff --git a/src/database.ts b/src/database/index.ts similarity index 69% rename from src/database.ts rename to src/database/index.ts index 935f7947..3525c9d7 100644 --- a/src/database.ts +++ b/src/database/index.ts @@ -1,6 +1,6 @@ -import { CacheUtility } from "#core/player/utils/cache"; -import { ControllerQueues, Queue } from "#core/queue"; -import {DiscordClient, DJSVoice} from "#structures/discord"; +import { DiscordClient, DJSVoice } from "#structures/discord"; +import { ControllerQueues, type Queue } from "#core/queue"; +import { isMainThread } from "node:worker_threads"; import { env } from "#app/env"; // Database modules @@ -17,7 +17,7 @@ import { Voices } from "#core/voice"; * @class Database * @public */ -export class Database { +class Database { /** * @description Загружаем класс для хранения запросов на платформы * @readonly @@ -81,13 +81,6 @@ export class Database { */ public readonly voice: Voices; - /** - * @description Класс для кеширования аудио и данных о треках - * @readonly - * @public - */ - public readonly cache: CacheUtility; - /** * @description Для управления белым списком пользователей * @readonly @@ -121,45 +114,44 @@ export class Database { * @public */ public constructor(client?: DiscordClient) { - if (client) { - this.api = new RestObject(); - this.queues = new ControllerQueues(); - this.voice = new Voices(); - this.commands = new Commands(); - this.components = new Components(); - this.events = new Events(); - this.middlewares = new Middlewares(); - - // Если реально клиент - if (client instanceof DiscordClient) { - this.adapter = new DJSVoice(client); - } - - this.whitelist = { - toggle: env.get("whitelist", false), - ids: env.get("whitelist.list", "").split(",") - }; - - this.blacklist = { - toggle: env.get("blacklist", false), - ids: env.get("blacklist.list", "").split(",") - }; - - this.owner = { - guildID: env.get("owner.server", ""), - ids: env.get("owner.list", "").split(",") - }; - - this.images = { - banner: env.get("image.banner"), - disk: env.get("image.currentPlay"), - no_image: env.get("image.not"), - loading: env.get("loading.emoji"), - disk_emoji: env.get("disk.emoji") - }; + // Если запуск произведен в другим потоке + if (!isMainThread) return; + + this.api = new RestObject(); + this.queues = new ControllerQueues(); + this.voice = new Voices(); + this.commands = new Commands(); + this.components = new Components(); + this.events = new Events(); + this.middlewares = new Middlewares(); + + // Если реально клиент + if (client instanceof DiscordClient) { + this.adapter = new DJSVoice(client); } - this.cache = new CacheUtility(); + this.whitelist = { + toggle: env.get("whitelist", false), + ids: env.get("whitelist.list", "").split(",") + }; + + this.blacklist = { + toggle: env.get("blacklist", false), + ids: env.get("blacklist.list", "").split(",") + }; + + this.owner = { + guildID: env.get("owner.server", ""), + ids: env.get("owner.list", "").split(",") + }; + + this.images = { + banner: env.get("image.banner"), + disk: env.get("image.currentPlay"), + no_image: env.get("image.not"), + loading: env.get("loading.emoji"), + disk_emoji: env.get("disk.emoji") + }; }; } @@ -169,13 +161,14 @@ export class Database { * @class Database * @public */ -export var db: Database; +export let db: Database; /** * @author SNIPPIK * @description Инициализирует базу данных + * @function initDatabase * @returns void - * @private + * @public */ export function initDatabase(client: DiscordClient) { if (db) return; diff --git a/src/database/index.worker.ts b/src/database/index.worker.ts new file mode 100644 index 00000000..e817c12f --- /dev/null +++ b/src/database/index.worker.ts @@ -0,0 +1,66 @@ +import { AudioSaver, MetaSaver } from "./index.saver"; +import { isMainThread } from "node:worker_threads"; +import { env } from "#app/env"; + +/** + * @author SNIPPIK + * @description Локальная база данных для использования в других потоках + * @class SharedDatabase + * @public + */ +class SharedDatabase { + /** + * @description Класс для кеширования данных о треках + * @readonly + * @public + */ + public readonly meta_saver: MetaSaver; + + /** + * @description Класс для кеширования аудио + * @readonly + * @public + */ + public readonly audio_saver: AudioSaver; + + /** + * @description Создаем класс для работы с кешем + * @public + */ + public constructor() { + const isCaching = env.get("cache") as boolean; + + if (isCaching) { + // Нужен для работы Rest/API (только в других потоках) + if (!isMainThread) this.meta_saver = new MetaSaver(); + + // Работает в Main + this.audio_saver = new AudioSaver(); + } + }; +} + +/** + * @author SNIPPIK + * @description Экспортируем базу данных глобально + * @class Database + * @public + */ +export let sdb: SharedDatabase; + +/** + * @author SNIPPIK + * @description Инициализирует базу данных для кеширования между потоками + * @function initSharedDatabase + * @returns void + * @public + */ +export function initSharedDatabase() { + if (sdb || process.argv.includes("--ShardManager")) return; + + try { + sdb = new SharedDatabase(); + } catch (err) { + throw new Error(`Fail init shared database: ${err}`); + } +} \ No newline at end of file diff --git a/src/handlers/commands/index.decorator.ts b/src/handlers/commands/index.decorator.ts new file mode 100644 index 00000000..1ba4190c --- /dev/null +++ b/src/handlers/commands/index.decorator.ts @@ -0,0 +1,300 @@ +import { type ApplicationCommandOption, ApplicationCommandType } from "discord.js"; +import type { Locale, LocalizationMap, Permissions } from "discord-api-types/v10"; +import type { RegisteredMiddlewares } from "#handler/middlewares"; +import type { BaseCommand, SubCommand } from "#handler/commands"; +import type { CompeteInteraction } from "#structures/discord"; + +/** + * @author SNIPPIK + * @description Декоратор создающий заголовок команды + * @decorator + * @public + */ +export function Declare(options: DeclareOptionsChatInput | DeclareOptionsUser) { + const CommandType = options.type ?? ApplicationCommandType.ChatInput; + + const [nameKey] = Object.keys(options.names) as Locale[]; + const [descKey] = CommandType === 1 ? Object.keys(options["descriptions"]) as Locale[] : [null]; + + // Загружаем данные в класс + return (target: T) => + class extends target { + name = options.names[nameKey]; + name_localizations = options.names; + + description = CommandType === 1 ? options["descriptions"][descKey] : null; + description_localizations = CommandType === 1 ? options["descriptions"] : null; + + integration_types = options.integration_types?.map(x => x === "GUILD_INSTALL" ? 0 : 1) ?? [0]; + contexts = options.contexts?.map(x => x === "GUILD" ? 0 : x === "BOT_DM" ? 1 : 2) ?? [0]; + owner = options.owner ?? false; + type = CommandType; + } +} + +/** + * @author SNIPPIK + * @description Декоратор под команд + * @decorator + * @public + */ +export function Options(options: (new () => SubCommand)[] | OptionsRecord) { + return (target: T) => + class extends target { + options: SubCommand[] | AutocompleteCommandOption | ChoiceOption[] = Array.isArray(options) + ? options.map(x => new x()) + : Object.values(options).map(normalizeOption); + }; +} + +/** + * @author SNIPPIK + * @description Декоратор ограничений + * @decorator + * @public + */ +export function Middlewares(cbs: RegisteredMiddlewares[]) { + return (target: T) => + class extends target { + middlewares = cbs; + }; +} + +/** + * @author SNIPPIK + * @description Декоратор ограничений + * @decorator + * @public + */ +export function Permissions(permissions: BaseCommand["permissions"]) { + return (target: T) => + class extends target { + permissions = permissions; + }; +} + + +/** + * @author SNIPPIK + * @description Параметры декоратора команды по умолчанию + * @usage Только как компонент для остальных + * @type DeclareOptionsBase + * @private + */ +type DeclareOptionsBase = { + /** + * @description Имена команды на разных языках + * @example Первое именование будет выставлено для других языков как по-умолчанию + * @public + */ + readonly names: LocalizationMap; + + /** + * @description Права на использование команды + * @private + */ + default_member_permissions?: Permissions | null | undefined; + + /** + * @description Контексты установки, в которых доступна команда, только для команд с глобальной областью действия. По умолчанию используются настроенные контексты вашего приложения. + * @public + */ + readonly integration_types?: ("GUILD_INSTALL" | "USER_INSTALL")[]; + + /** + * @description Контекст(ы) взаимодействия, в которых можно использовать команду, только для команд с глобальной областью действия. По умолчанию для новых команд включены все типы контекстов взаимодействия. + * @private + */ + readonly contexts?: ("GUILD" | "BOT_DM" | "PRIVATE_CHANNEL")[]; + + /** + * @description Команду может использовать только разработчик + * @default false + * @readonly + * @public + */ + readonly owner?: boolean; +} + +/** + * @author SNIPPIK + * @description Параметры декоратора команды + * @usage Только как основной компонент для создания команд + * @type DeclareOptionsChatInput + * @private + */ +type DeclareOptionsChatInput = DeclareOptionsBase & { + /** + * @description Тип команды, поддерживаются все доступные типы + * @default ChatInput = 1 + * @public + */ + type?: ApplicationCommandType.ChatInput; + + /** + * @description Описание команды на розных языках + * @example Первое именование будет выставлено для других языков как по-умолчанию + * @public + */ + descriptions: LocalizationMap; +}; + +/** + * @author SNIPPIK + * @description Параметры декоратора команды + * @usage Только как основной компонент для создания команд пользователя + * @type DeclareOptionsUser + * @private + */ +type DeclareOptionsUser = DeclareOptionsBase & { + /** + * @description Тип команды, поддерживаются все доступные типы + * @default ChatInput = 1 + * @public + */ + type: ApplicationCommandType.User | ApplicationCommandType.Message; +}; + +/** + * @author SNIPPIK + * @description Оригинальный элемент выбора + * @interface Choice + * @public + */ +export interface Choice { + /** + * @description Имя действия + */ + name: string; + + /** + * @description Тип возврата данных, нужен для кода разработчика + */ + value: string; + + /** + * @description Перевод имен действий на разные языки + */ + nameLocalizations?: LocalizationMap; +} + + +/** + * @author SNIPPIK + * @description Параметры параметров autocomplete + * @type BaseCommandOption + * @private + */ +type BaseCommandOption = { + /** + * @description Имена команды на разных языках + * @example Первое именование будет выставлено для других языков как по-умолчанию + * @public + */ + names: ApplicationCommandOption['nameLocalizations']; + + /** + * @description Описание команды на разных языках + * @example Первое именование будет выставлено для других языков как по-умолчанию + * @public + */ + descriptions: ApplicationCommandOption["descriptionLocalizations"]; + + /** + * @description Тип вводимых данных + * @public + */ + type: ApplicationCommandOption["type"]; + + /** + * @description Ввод данных обязателен + * @public + */ + required?: boolean; + + /** + * @description Доп параметры для работы slashCommand + * @private + */ + readonly options?: BaseCommandOption[]; +} + +/** + * @author SNIPPIK + * @description Параметры параметров autocomplete + * @type AutocompleteCommandOption + * @public + */ +export type AutocompleteCommandOption = { + /** + * @description Выполнение действия autocomplete + * @default null + * @readonly + * @public + */ + readonly autocomplete?: (options: { + /** + * @description Сообщение пользователя для работы с discord + */ + ctx: CompeteInteraction; + + /** + * @description Аргументы пользователя будут указаны только в том случаем если они есть в команде + */ + args?: any[]; + }) => any; + + /** + * @description Доп параметры для работы slashCommand + * @private + */ + readonly options?: AutocompleteCommandOption[]; +} & BaseCommandOption; + +/** + * @author SNIPPIK + * @description Параметры параметров autocomplete + * @type ChoiceOption + * @public + */ +export type ChoiceOption = { + /** + * @description Список действий на выбор пользователей + * @public + */ + choices?: Choice[]; + + /** + * @description Доп параметры для работы slashCommand + * @private + */ + readonly options?: ChoiceOption[]; +} & BaseCommandOption; + +/** + * @author SNIPPIK + * @description Записываем параметры команды в json формат + * @type OptionsRecord + * @private + */ +type OptionsRecord = Record; + +/** + * @author SNIPPIK + * @description Нормализуем параметры подкоманд для discord api + * @param opt - Параметры подкоманд + * @private + */ +function normalizeOption(opt: BaseCommandOption) { + const [nameKey] = Object.keys(opt.names) as Locale[]; + const [descKey] = Object.keys(opt.descriptions) as Locale[]; + + return { + ...opt, + name: opt.names[nameKey], + nameLocalizations: opt.names, + description: opt.descriptions[descKey], + descriptionLocalizations: opt.descriptions, + options: opt.options?.map(normalizeOption) + }; +} \ No newline at end of file diff --git a/src/handlers/commands/index.ts b/src/handlers/commands/index.ts index b7afe9ec..c8d8dbda 100644 --- a/src/handlers/commands/index.ts +++ b/src/handlers/commands/index.ts @@ -1,13 +1,21 @@ -import { ApplicationCommandOption, ApplicationCommandOptionType, ApplicationCommandType, PermissionsString, Routes } from "discord.js"; -import { CommandInteraction, CompeteInteraction, DiscordClient } from "#structures/discord"; -import type { Locale, LocalizationMap, Permissions } from "discord-api-types/v10"; -import { RegisteredMiddlewares } from "#handler/middlewares"; +import { + ApplicationCommandOption, ApplicationCommandOptionType, ApplicationCommandType, + Client, PermissionsString, Routes +} from "discord.js"; +import type { AutocompleteCommandOption, Choice, ChoiceOption } from "./index.decorator"; +import { type CommandInteraction, DiscordClient } from "#structures/discord"; +import type { LocalizationMap, Permissions } from "discord-api-types/v10"; +import type { RegisteredMiddlewares } from "#handler/middlewares"; import filters from "#core/player/filters.json"; -import { AudioFilter } from "#core/player"; +import type { AudioFilter } from "#core/player"; import { Logger } from "#structures"; import { handler } from "#handler"; import { env } from "#app/env"; + +// Export decorator +export * from "./index.decorator"; + /** * @author SNIPPIK * @description Класс для взаимодействия с командами @@ -70,6 +78,8 @@ export class Commands extends handler { /** * @description Загружаем класс вместе с дочерним + * @constructor + * @public */ public constructor() { super("src/handlers/commands"); @@ -96,7 +106,7 @@ export class Commands extends handler { * @param guildID - ID сервера * @param CommandID - ID Команды */ - public remove = (client: DiscordClient, guildID: string, CommandID: string) => { + public remove = (client: DiscordClient | Client, guildID: string, CommandID: string) => { // Удаление приватной команды if (guildID) client.rest.delete(Routes.applicationGuildCommand(client.user.id, guildID, CommandID)) .then(() => Logger.log("DEBUG", `[App/Commands | ${CommandID}] has removed in guild ${guildID}`)) @@ -248,7 +258,7 @@ export abstract class BaseCommand { * @description Отдаем данные в формате JSON и только необходимые * @public */ - toJSON() { + public toJSON() { return { name: this.name, type: this.type, @@ -286,7 +296,7 @@ export abstract class Command extends BaseCommand { * @description Отдаем данные в формате JSON и только необходимые * @public */ - toJSON = () => { + public toJSON = () => { const options: ApplicationCommandOption[] = []; for (const i of this.options ?? []) { @@ -322,7 +332,7 @@ export abstract class SubCommand extends BaseCommand { * @description Отдаем данные в формате JSON и только необходимые * @public */ - toJSON() { + public toJSON = () => { return { ...super.toJSON(), @@ -348,286 +358,4 @@ export type CommandContext = { * @description Аргументы пользователя будут указаны только в том случаем если они есть в команде */ args?: T[]; -} - - - -/** - * @author SNIPPIK - * @description Параметры декоратора команды по умолчанию - * @usage Только как компонент для остальных - * @type DeclareOptionsBase - */ -type DeclareOptionsBase = { - /** - * @description Имена команды на разных языках - * @example Первое именование будет выставлено для других языков как по-умолчанию - * @public - */ - readonly names: LocalizationMap; - - /** - * @description Права на использование команды - * @private - */ - default_member_permissions?: Permissions | null | undefined; - - /** - * @description Контексты установки, в которых доступна команда, только для команд с глобальной областью действия. По умолчанию используются настроенные контексты вашего приложения. - * @public - */ - readonly integration_types?: ("GUILD_INSTALL" | "USER_INSTALL")[]; - - /** - * @description Контекст(ы) взаимодействия, в которых можно использовать команду, только для команд с глобальной областью действия. По умолчанию для новых команд включены все типы контекстов взаимодействия. - * @private - */ - readonly contexts?: ("GUILD" | "BOT_DM" | "PRIVATE_CHANNEL")[]; - - /** - * @description Команду может использовать только разработчик - * @default false - * @readonly - * @public - */ - readonly owner?: boolean; -} - -/** - * @author SNIPPIK - * @description Параметры декоратора команды - * @usage Только как основной компонент для создания команд - * @type DeclareOptionsChatInput - */ -type DeclareOptionsChatInput = DeclareOptionsBase & { - /** - * @description Тип команды, поддерживаются все доступные типы - * @default ChatInput = 1 - * @public - */ - type?: ApplicationCommandType.ChatInput; - - /** - * @description Описание команды на розных языках - * @example Первое именование будет выставлено для других языков как по-умолчанию - * @public - */ - descriptions: LocalizationMap; -}; - -/** - * @author SNIPPIK - * @description Параметры декоратора команды - * @usage Только как основной компонент для создания команд пользователя - * @type DeclareOptionsUser - */ -type DeclareOptionsUser = DeclareOptionsBase & { - /** - * @description Тип команды, поддерживаются все доступные типы - * @default ChatInput = 1 - * @public - */ - type: ApplicationCommandType.User | ApplicationCommandType.Message; -}; - -/** - * @author SNIPPIK - * @description Декоратор создающий заголовок команды - * @decorator - */ -export function Declare(options: DeclareOptionsChatInput | DeclareOptionsUser) { - const CommandType = options.type ?? ApplicationCommandType.ChatInput; - - const [nameKey] = Object.keys(options.names) as Locale[]; - const [descKey] = CommandType === 1 ? Object.keys(options["descriptions"]) as Locale[] : [null]; - - // Загружаем данные в класс - return (target: T) => - class extends target { - name = options.names[nameKey]; - name_localizations = options.names; - - description = CommandType === 1 ? options["descriptions"][descKey] : null; - description_localizations = CommandType === 1 ? options["descriptions"] : null; - - integration_types = options.integration_types?.map(x => x === "GUILD_INSTALL" ? 0 : 1) ?? [0]; - contexts = options.contexts?.map(x => x === "GUILD" ? 0 : x === "BOT_DM" ? 1 : 2) ?? [0]; - owner = options.owner ?? false; - type = CommandType; - } -} - - - -/** - * @author SNIPPIK - * @description Оригинальный элемент выбора - * @interface Choice - */ -interface Choice { - /** - * @description Имя действия - */ - name: string; - - /** - * @description Тип возврата данных, нужен для кода разработчика - */ - value: string; - - /** - * @description Перевод имен действий на разные языки - */ - nameLocalizations?: LocalizationMap; -} - -/** - * @author SNIPPIK - * @description Параметры параметров autocomplete - */ -type BaseCommandOption = { - /** - * @description Имена команды на разных языках - * @example Первое именование будет выставлено для других языков как по-умолчанию - * @public - */ - names: ApplicationCommandOption['nameLocalizations']; - - /** - * @description Описание команды на разных языках - * @example Первое именование будет выставлено для других языков как по-умолчанию - * @public - */ - descriptions: ApplicationCommandOption["descriptionLocalizations"]; - - /** - * @description Тип вводимых данных - * @public - */ - type: ApplicationCommandOption["type"]; - - /** - * @description Ввод данных обязателен - * @public - */ - required?: boolean; - - /** - * @description Доп параметры для работы slashCommand - * @private - */ - readonly options?: BaseCommandOption[]; -} - -/** - * @author SNIPPIK - * @description Параметры параметров autocomplete - */ -type AutocompleteCommandOption = { - /** - * @description Выполнение действия autocomplete - * @default null - * @readonly - * @public - */ - readonly autocomplete?: (options: { - /** - * @description Сообщение пользователя для работы с discord - */ - ctx: CompeteInteraction; - - /** - * @description Аргументы пользователя будут указаны только в том случаем если они есть в команде - */ - args?: any[]; - }) => any; - - /** - * @description Доп параметры для работы slashCommand - * @private - */ - readonly options?: AutocompleteCommandOption[]; -} & BaseCommandOption; - -/** - * @author SNIPPIK - * @description Параметры параметров autocomplete - */ -type ChoiceOption = { - /** - * @description Список действий на выбор пользователей - * @public - */ - choices?: Choice[]; - - /** - * @description Доп параметры для работы slashCommand - * @private - */ - readonly options?: ChoiceOption[]; -} & BaseCommandOption; - -/** - * @author SNIPPIK - * @description Записываем параметры команды в json формат - */ -type OptionsRecord = Record; - -/** - * @author SNIPPIK - * @description Нормализуем параметры подкоманд для discord api - * @param opt - Параметры подкоманд - * @private - */ -function normalizeOption(opt: BaseCommandOption) { - const [nameKey] = Object.keys(opt.names) as Locale[]; - const [descKey] = Object.keys(opt.descriptions) as Locale[]; - - return { - ...opt, - name: opt.names[nameKey], - nameLocalizations: opt.names, - description: opt.descriptions[descKey], - descriptionLocalizations: opt.descriptions, - options: opt.options?.map(normalizeOption) - }; -} - -/** - * @author SNIPPIK - * @description Декоратор под команд - * @decorator - */ -export function Options(options: (new () => SubCommand)[] | OptionsRecord) { - return (target: T) => - class extends target { - options: SubCommand[] | AutocompleteCommandOption | ChoiceOption[] = Array.isArray(options) - ? options.map(x => new x()) - : Object.values(options).map(normalizeOption); - }; -} - - -/** - * @author SNIPPIK - * @description Декоратор ограничений - * @decorator - */ -export function Middlewares(cbs: RegisteredMiddlewares[]) { - return (target: T) => - class extends target { - middlewares = cbs; - }; -} - -/** - * @author SNIPPIK - * @description Декоратор ограничений - * @decorator - */ -export function Permissions(permissions: BaseCommand["permissions"]) { - return (target: T) => - class extends target { - permissions = permissions; - }; } \ No newline at end of file diff --git a/src/handlers/commands/developer/apis.ts b/src/handlers/commands/slash/developer/apis.ts similarity index 98% rename from src/handlers/commands/developer/apis.ts rename to src/handlers/commands/slash/developer/apis.ts index db9f29a5..82be8247 100644 --- a/src/handlers/commands/developer/apis.ts +++ b/src/handlers/commands/slash/developer/apis.ts @@ -75,7 +75,7 @@ import { db } from "#app/db"; }, required: true, type: ApplicationCommandOptionType["String"], - choices: db.api.allow.map((platform) => { + choices: db.api.array.map((platform) => { return { name: `[${platform.requests.length}] - ${platform.name} | ${platform.url}`, value: platform.name diff --git a/src/handlers/commands/developer/bot.ts b/src/handlers/commands/slash/developer/bot.ts similarity index 100% rename from src/handlers/commands/developer/bot.ts rename to src/handlers/commands/slash/developer/bot.ts diff --git a/src/handlers/commands/music/filters.ts b/src/handlers/commands/slash/music/filters.ts similarity index 85% rename from src/handlers/commands/music/filters.ts rename to src/handlers/commands/slash/music/filters.ts index ab7dff60..8e9b9efe 100644 --- a/src/handlers/commands/music/filters.ts +++ b/src/handlers/commands/slash/music/filters.ts @@ -52,22 +52,18 @@ class AudioFilterPush extends SubCommand { const seek: number = player.audio.current?.duration ?? 0; const name = args && args?.length > 0 ? args[0] : null; const argument = args && args?.length > 1 ? Number(args[1]) : null; - const Filter = filters.find((item) => item.name === name) as AudioFilter; - const findFilter = player.filters.find((fl) => fl.name === name); // Пользователь пытается включить включенный фильтр - if (findFilter) { - return ctx.reply({ - embeds: [ - { - description: locale._(ctx.locale, "command.filter.push.two"), - color: Colors.Yellow - } - ], - flags: "Ephemeral" - }); - } + if (player.filters.has(Filter)) return ctx.reply({ + embeds: [ + { + description: locale._(ctx.locale, "command.filter.push.two"), + color: Colors.Yellow + } + ], + flags: "Ephemeral" + }); // Делаем проверку на аргументы else if (Filter.args) { @@ -86,36 +82,19 @@ class AudioFilterPush extends SubCommand { } } - // Делаем проверку на совместимость - // Проверяем, не конфликтует ли новый фильтр с уже включёнными - for (const enabledFilter of player.filters) { - if (!enabledFilter) continue; - - // Новый фильтр несовместим с уже включённым? - if (Filter.unsupported.includes(enabledFilter.name)) { - return ctx.reply({ - embeds: [ - { - description: locale._(ctx.locale, "command.filter.push.unsupported", [Filter.name, enabledFilter.name]), - color: Colors.DarkRed - } - ], - flags: "Ephemeral" - }); - } + const unsupportedFilters = player.filters.hasUnsupported(Filter); - // Уже включённый фильтр несовместим с новым? - if (enabledFilter.unsupported.includes(Filter.name)) { - return ctx.reply({ - embeds: [ - { - description: locale._(ctx.locale, "command.filter.push.unsupported", [enabledFilter.name, Filter.name]), - color: Colors.DarkRed - } - ], - flags: "Ephemeral" - }); - } + // Проверяем, не конфликтует ли новый фильтр с уже включёнными + if (unsupportedFilters) { + return ctx.reply({ + embeds: [ + { + description: locale._(ctx.locale, "command.filter.push.unsupported", unsupportedFilters), + color: Colors.DarkRed + } + ], + flags: "Ephemeral" + }); } // Добавляем фильтр @@ -123,7 +102,7 @@ class AudioFilterPush extends SubCommand { // Если можно включить фильтр или фильтры сейчас if (player.audio.current.duration < player.tracks.track.time.total - db.queues.options.optimization) { - await player.play(seek); + player.play(seek).catch(console.error); // Сообщаем о включении фильтров return ctx.reply({ @@ -168,7 +147,7 @@ class AudioFilterPush extends SubCommand { } }) class AudioFiltersOff extends SubCommand { - async run({ctx}: CommandContext) { + async run({ctx}: CommandContext) { const queue = db.queues.get(ctx.guildId); const player = queue.player; @@ -286,24 +265,22 @@ class AudioFilterRemove extends SubCommand { const findFilter = player.filters.find((fl) => fl.name === name); // Пользователь пытается выключить выключенный фильтр - if (!findFilter) { - return ctx.reply({ - embeds: [ - { - description: locale._(ctx.locale, "command.filter.remove.two"), - color: Colors.Yellow - } - ], - flags: "Ephemeral" - }); - } + if (!player.filters.has(Filter)) return ctx.reply({ + embeds: [ + { + description: locale._(ctx.locale, "command.filter.remove.two"), + color: Colors.Yellow + } + ], + flags: "Ephemeral" + }); // Удаляем фильтр player.filters.delete(findFilter); // Если можно выключить фильтр или фильтры сейчас if (player.audio.current.duration < player.tracks.track.time.total - db.queues.options.optimization) { - await player.play(seek); + player.play(seek).catch(console.error); // Сообщаем о включении фильтров return ctx.reply({ diff --git a/src/handlers/commands/music/play.ts b/src/handlers/commands/slash/music/play.ts similarity index 74% rename from src/handlers/commands/music/play.ts rename to src/handlers/commands/slash/music/play.ts index 636196aa..f7dd6d0d 100644 --- a/src/handlers/commands/music/play.ts +++ b/src/handlers/commands/slash/music/play.ts @@ -1,5 +1,5 @@ import { Command, CommandContext, Declare, Middlewares, Options, Permissions, SubCommand } from "#handler/commands"; -import { ApplicationCommandOptionType, ApplicationCommandType, Message } from "discord.js"; +import { ApplicationCommandOptionType } from "discord.js"; import { CompeteInteraction } from "#structures/discord"; import { RestClientSide } from "#handler/rest"; import { locale } from "#structures"; @@ -33,7 +33,7 @@ async function allAutoComplete(message: CompeteInteraction, platform: RestClient if (Array.isArray(rest)) { items.push(...rest.map((track) => { return { - name: `🎵 (${track.time.split}) | ${track.artist.title.slice(0, 20)} - ${track.name.slice(0, 60)}`, + name: `🎵 (${track.time.split}) | ${track.artist.title?.slice(0, 20)} - ${track.name?.slice(0, 60)}`, value: track.url, } })); @@ -41,15 +41,15 @@ async function allAutoComplete(message: CompeteInteraction, platform: RestClient // Показываем плейлист else if ("items" in rest) items.push({ - name: `🎶 [${rest.items.length}] - ${rest.title.slice(0, 70)}`, - value: search + name: `🎶 [${rest.items.length}] - ${rest.title?.slice(0, 70)}`, + value: rest.url }); // Показываем трек else { items.push({ - name: `🎵 (${rest.time.split}) | ${rest.artist.title.slice(0, 20)} - ${rest.name.slice(0, 60)}`, - value: search + name: `🎵 (${rest.time.split}) | ${rest.artist.title?.slice(0, 20)} - ${rest.name?.slice(0, 60)}`, + value: rest.url }); } @@ -61,25 +61,6 @@ async function allAutoComplete(message: CompeteInteraction, platform: RestClient } } -/** - * @author SNIPPIK - * @description Получение платформы из поиска - * @param search - Что запросил пользователь - */ -function getPlatform(search: string) { - // Если ссылка - if (search.startsWith("http")) { - const api = db.api.allow.find((pl) => !!pl.filter.exec(search)); - - // Если нет поддержки такой платформы - if (!api) return null; - - return db.api.request(api.name); - } - - return db.api.request("YOUTUBE"); -} - /** * @description Под команда поиска трека * @type SubCommand @@ -106,7 +87,7 @@ function getPlatform(search: string) { }, type: ApplicationCommandOptionType["String"], required: true, - choices: db.api.allow.map((platform) => { + choices: db.api.array.map((platform) => { return { name: `${platform.name.toLowerCase()} | ${platform.url}`, value: platform.name @@ -127,15 +108,15 @@ function getPlatform(search: string) { autocomplete: ({ctx, args}) => { if (!args[1] || args[1] === "") return null; - const platform = db.api.request(args[0] as any); + const platform = db.api.request(args[0]); return allAutoComplete(ctx, platform, args[1]); } } }) class PlaySearchCommand extends SubCommand { - async run({ctx, args}: CommandContext) { - // Запрос к платформе - const platform = db.api.request(args[0] as any); + async run({ctx, args}: CommandContext) { + const platform = db.api.request(args[0]); + await ctx.deferReply(); // Если платформа заблокирована if (platform.block) { @@ -149,7 +130,6 @@ class PlaySearchCommand extends SubCommand { return null; } - await ctx.deferReply(); db.events.emitter.emit("rest/request", platform, ctx, args[1]); return null; }; @@ -168,7 +148,7 @@ class PlaySearchCommand extends SubCommand { descriptions: { "en-US": "Endless track playback mode!", "ru": "Добавление себе подобных треков!" - }, + } }) @Options({ select: { @@ -182,7 +162,7 @@ class PlaySearchCommand extends SubCommand { }, type: ApplicationCommandOptionType["String"], required: true, - choices: db.api.allowRelated.map((platform) => { + choices: db.api.arrayRelated.map((platform) => { return { name: `${platform.name.toLowerCase()} | ${platform.url}`, value: platform.name @@ -210,8 +190,8 @@ class PlaySearchCommand extends SubCommand { }) class PlayRelatedCommand extends SubCommand { async run({ctx, args}: CommandContext) { - // Запрос к платформе - const platform = db.api.request(args[0] as any); + const platform = db.api.request(args[0]); + await ctx.deferReply(); // Если платформа заблокирована if (platform.block) { @@ -225,7 +205,6 @@ class PlayRelatedCommand extends SubCommand { return null; } - await ctx.deferReply(); db.events.emitter.emit("rest/request", platform, ctx, `${args[1]}&list=RD`); return null; }; @@ -253,64 +232,13 @@ class PlayRelatedCommand extends SubCommand { @Options([PlaySearchCommand, PlayRelatedCommand]) @Middlewares(["cooldown", "voice", "another_voice"]) @Permissions({ - client: ["SendMessages", "ViewChannel"] + client: ["Connect", "Speak", "SendMessages", "ViewChannel"] }) class PlayAdvancedCommand extends Command { async run() {} } -/** - * @author SNIPPIK - * @description Базовое включение музыки из сообщения - * @class PlayContextCommand - * @extends Assign - * @public - */ -@Declare({ - names: { - "en-US": "Play", - "ru": "Воспроизвести" - }, - integration_types: ["GUILD_INSTALL"], - type: ApplicationCommandType.Message -}) -@Middlewares(["cooldown", "voice", "another_voice"]) -@Permissions({ - client: ["SendMessages", "ViewChannel"] -}) -class PlayContextCommand extends Command { - async run({ctx, args}: CommandContext) { - const regex = /(https?:\/\/[^\s)]+)/g; - const url = Array.from(args[0].content.matchAll(regex), m => m[1])[0]; - - const platform = getPlatform(url); - - // Если не нашлась платформа - if (!platform) { - db.events.emitter.emit("rest/error", ctx, locale._(ctx.locale, "api.platform.support")); - return null; - } - - // Если платформа заблокирована - else if (platform.block) { - db.events.emitter.emit("rest/error", ctx, locale._(ctx.locale, "api.platform.block")); - return null; - } - - // Если есть проблема с авторизацией на платформе - else if (!platform.auth) { - db.events.emitter.emit("rest/error", ctx, locale._(ctx.locale, "api.platform.auth")); - return null; - } - - await ctx.deferReply(); - db.events.emitter.emit("rest/request", platform, ctx, url); - return null; - }; -} - - /** * @author SNIPPIK * @description Базовое включение музыки @@ -342,18 +270,19 @@ class PlayContextCommand extends Command { required: true, type: ApplicationCommandOptionType["String"], autocomplete: ({ctx, args}) => { - const platform = getPlatform(args[0]); + const platform = db.api.request(args[0]); return allAutoComplete(ctx, platform, args[0]); } } }) @Middlewares(["cooldown", "voice", "another_voice"]) @Permissions({ - client: ["SendMessages", "ViewChannel"] + client: ["Connect", "Speak", "SendMessages", "ViewChannel"], }) class PlayCommand extends Command { - async run({ctx, args}: CommandContext) { - const platform = getPlatform(args[0]); + async run({ctx, args}: CommandContext) { + const platform = db.api.request(args[0]); + await ctx.deferReply(); // Если не нашлась платформа if (!platform) { @@ -373,14 +302,14 @@ class PlayCommand extends Command { return null; } - await ctx.deferReply(); db.events.emitter.emit("rest/request", platform, ctx, args[0]); return null; }; } + /** * @export default * @description Не даем классам или объектам быть доступными везде в проекте */ -export default [ PlayContextCommand, PlayCommand, PlayAdvancedCommand ]; \ No newline at end of file +export default [ PlayCommand, PlayAdvancedCommand ]; \ No newline at end of file diff --git a/src/handlers/commands/music/player.ts b/src/handlers/commands/slash/music/player.ts similarity index 96% rename from src/handlers/commands/music/player.ts rename to src/handlers/commands/slash/music/player.ts index 252a9048..745e717b 100644 --- a/src/handlers/commands/music/player.ts +++ b/src/handlers/commands/slash/music/player.ts @@ -100,13 +100,14 @@ class PlayerStop extends SubCommand { class PlayerVolume extends SubCommand { async run({ctx, args}: CommandContext) { const { player } = db.queues.get(ctx.guildId); + const seek: number = player.audio.current?.duration ?? 0; // Изменение громкости player.audio.volume = parseInt(args[0]); // Если можно изменить громкость сейчас - if (player.audio.current.duration < player.tracks.track.time.total - db.queues.options.optimization) { - player.play(player.audio.current.duration).catch(console.error); + if (seek < player.tracks.track.time.total - db.queues.options.optimization) { + await player.play(seek); // Отправляем сообщение о переключение громкости сейчас return ctx.reply({ diff --git a/src/handlers/commands/music/queue.ts b/src/handlers/commands/slash/music/queue.ts similarity index 87% rename from src/handlers/commands/music/queue.ts rename to src/handlers/commands/slash/music/queue.ts index 2745960f..d4d8935d 100644 --- a/src/handlers/commands/music/queue.ts +++ b/src/handlers/commands/slash/music/queue.ts @@ -35,20 +35,20 @@ import { db } from "#app/db"; autocomplete: ({ ctx, args }) => { const { tracks } = db.queues.get(ctx.guildId); const { position } = tracks; - const center = args[0] ?? position; + const center = args[0] === "0" ? 1 : args[0] - 1; const before = tracks.array(-10, center); const after = tracks.array(10, center); return ctx.respond( [...before, ...after].map((track, i) => { - const index = center - before.length + i; - const isCurrent = index === position; - const Selected = (args[0] - 1) === index; + const value = center - before.length + i; + const isCurrent = value === position; + const Selected = center === value; return { - name: `${index + 1}. ${isCurrent && !Selected ? "🎵" : Selected && !isCurrent ? "➡️" : Selected && isCurrent ? "➡ 🎵️" : "🎶"} (${track.time.split}) | ${track.artist.title.slice(0, 35)} - ${track.name.slice(0, 75)}`, - value: index + name: `${value + 1}. ${isCurrent && !Selected ? "▶️" : Selected && !isCurrent ? "➡️" : Selected && isCurrent ? "➡ 🎵️" : "🎶"} (${track.time.split}) | ${track.artist.title.slice(0, 35)} - ${track.name.slice(0, 75)}`, + value }; }) ); @@ -73,7 +73,7 @@ class QueueList extends SubCommand { } ); - const {artist, url, name, image, api, ID, time, user, link} = track; + const { artist, url, name, image, api, ID, time, user, link } = track; // Отправляем данные о выбранном треке return ctx.reply({ @@ -85,7 +85,7 @@ class QueueList extends SubCommand { icon_url: artist.image.url }, thumbnail: image, - description: `[${name}](${url})\n - ${ID}\n - ${time.split}` + (link && link.startsWith("http") ? `\n - 💽: ❌` : link ? "\n - 💽: ✅" : ""), + description: `[${name}](${url})\n - ${ID}\n - ${time.split}` + (link && link.startsWith("http") ? `\n - 🗃: ❌` : link ? "\n - 🗃: ✅" : ""), color: api.color, footer: { diff --git a/src/handlers/commands/music/remove.ts b/src/handlers/commands/slash/music/remove.ts similarity index 78% rename from src/handlers/commands/music/remove.ts rename to src/handlers/commands/slash/music/remove.ts index 7f46e804..ef193e1a 100644 --- a/src/handlers/commands/music/remove.ts +++ b/src/handlers/commands/slash/music/remove.ts @@ -55,13 +55,12 @@ import { db } from "#app/db"; const tracks = queue.tracks.array(maxSuggestions, startIndex); const highlightIndex = index - startIndex; - - const results = tracks.map((track, i) => ({ - name: `${startIndex + i + 1}. ${i === highlightIndex ? "🗑️" : "🎶"} (${track.time.split}) ${track.name.slice(0, 75)}`, - value: startIndex + i + 1 - })); - - return ctx.respond(results); + return ctx.respond( + tracks.map((track, i) => ({ + name: `${startIndex + i + 1}. ${i === highlightIndex ? "🗑️" : "🎶"} (${track.time.split}) ${track.name.slice(0, 75)}`, + value: startIndex + i + 1 + })) + ); }, } }) @@ -76,30 +75,29 @@ class RemoveTracksCommand extends Command { const track = queue.tracks.get(number); // Если указан трек которого нет - if (!track) { - return ctx.reply({ - embeds: [ - { - description: locale._(ctx.locale, "command.remove.track.fail", [ctx.member]), - color: Colors.DarkRed - } - ], - flags: "Ephemeral" - }); - } + if (!track) return ctx.reply({ + embeds: [ + { + description: locale._(ctx.locale, "command.remove.track.fail", [ctx.member]), + color: Colors.DarkRed + } + ], + flags: "Ephemeral" + }); - const {name, url, api} = track; - // Удаляем трек и очереди - queue.tracks.remove(number); + const { name, url, api } = track; // Если выбран текущий трек - if (number === queue.tracks.position) { + if (number === queue.tracks.position || queue.tracks.total === 1) { // Если треков нет в очереди - if (!queue.tracks.total) return queue.cleanup(); - await queue.player.play(0, 0, queue.tracks.position); + if (!queue.tracks.total || queue.tracks.total === 1) return queue.cleanup(); + setImmediate(() => queue.player.play(0, 0, queue.tracks.position)); } + // Удаляем трек и очереди + queue.tracks.remove(number); + return ctx.reply({ embeds: [ { diff --git a/src/handlers/commands/slash/music/repeat.ts b/src/handlers/commands/slash/music/repeat.ts new file mode 100644 index 00000000..541fdf15 --- /dev/null +++ b/src/handlers/commands/slash/music/repeat.ts @@ -0,0 +1,152 @@ +import { Command, CommandContext, Declare, Middlewares, Options, Permissions } from "#handler/commands"; +import { ApplicationCommandOptionType, Colors } from "discord.js"; +import { RepeatType } from "#core/queue"; +import { locale } from "#structures"; +import { db } from "#app/db"; + +/** + * @author SNIPPIK + * @description Команда для управления повтором очереди! + * @class RepeatCommand + * @extends Command + * @public + */ +@Declare({ + names: { + "en-US": "repeat", + "ru": "повтор" + }, + descriptions: { + "en-US": "Switch the repeat type to any position!", + "ru": "Переключение типа повтора в любую позицию!" + }, + integration_types: ["GUILD_INSTALL"] +}) +@Options({ + type: { + names: { + "en-US": "type", + "ru": "тип" + }, + descriptions: { + "en-US": "Select a repeat type!", + "ru": "Выберите тип повтора!" + }, + type: ApplicationCommandOptionType["String"], + required: true, + choices: [ + { + name: "song", + nameLocalizations: { + "en-US": "song", + "ru": "трек" + }, + value: `${RepeatType.Song}` + }, + { + name: "songs", + nameLocalizations: { + "en-US": "songs", + "ru": "треки" + }, + value: `${RepeatType.Songs}` + }, + { + name: "autoplay", + nameLocalizations: { + "en-US": "autoplay", + "ru": "похожее" + }, + value: `${RepeatType.AutoPlay}` + }, + { + name: "off", + nameLocalizations: { + "en-US": "off", + "ru": "выкл" + }, + value: `${RepeatType.None}` + }, + ] + } +}) +@Middlewares(["cooldown", "queue", "voice", "another_voice", "player-not-playing", "player-wait-stream"]) +@Permissions({ + client: ["SendMessages", "ViewChannel"] +}) +class RepeatCommand extends Command { + async run({ctx, args}: CommandContext) { + const queue = db.queues.get(ctx.guildId), loop = parseInt(args[0]) as RepeatType; + + // Смотрим тип повтора + switch (loop) { + // Выключение повтора + case RepeatType.None: { + queue.tracks.repeat = RepeatType.None; + return ctx.reply({ + flags: "Ephemeral", + embeds: [ + { + description: locale._(ctx.locale, "player.button.repeat.off"), + color: Colors.Green + } + ] + }); + } + + // Включение повтора 1 трека + case RepeatType.Song: { + queue.tracks.repeat = RepeatType.Song; + + return ctx.reply({ + flags: "Ephemeral", + embeds: [ + { + description: locale._(ctx.locale, "player.button.repeat.song"), + color: Colors.Green + } + ] + }); + } + + // Включение повтора треков + case RepeatType.Songs: { + queue.tracks.repeat = RepeatType.Songs; + + return ctx.reply({ + flags: "Ephemeral", + embeds: [ + { + description: locale._(ctx.locale, "player.button.repeat.songs"), + color: Colors.Green + } + ] + }); + } + + // Включение autoplay функции + case RepeatType.AutoPlay: { + queue.tracks.repeat = RepeatType.AutoPlay; + + return ctx.reply({ + flags: "Ephemeral", + embeds: [ + { + description: locale._(ctx.locale, "player.button.repeat.related"), + color: Colors.Green + } + ] + }); + } + + // Если что-то пошло не так + default: return null; + } + } +} + +/** + * @export default + * @description Не даем классам или объектам быть доступными везде в проекте + */ +export default [RepeatCommand]; \ No newline at end of file diff --git a/src/handlers/commands/music/seek.ts b/src/handlers/commands/slash/music/seek.ts similarity index 100% rename from src/handlers/commands/music/seek.ts rename to src/handlers/commands/slash/music/seek.ts diff --git a/src/handlers/commands/music/skip.ts b/src/handlers/commands/slash/music/skip.ts similarity index 98% rename from src/handlers/commands/music/skip.ts rename to src/handlers/commands/slash/music/skip.ts index 66ee371a..32b3e355 100644 --- a/src/handlers/commands/music/skip.ts +++ b/src/handlers/commands/slash/music/skip.ts @@ -67,7 +67,7 @@ class BackPositionCommand extends SubCommand { const {name, url, api} = track; // Переходим к позиции - await player.play(0, 0, number); + player.play(0, 0, number).catch(console.error); return ctx.reply({ embeds: [ @@ -147,7 +147,7 @@ class SkipPositionCommand extends SubCommand { const {name, url, api} = track; // Переходим к позиции - await player.play(0, 0, number); + player.play(0, 0, number).catch(console.error); return ctx.reply({ embeds: [ @@ -230,7 +230,7 @@ class ToPositionCommand extends SubCommand { const {name, url, api} = track; // Переходим к позиции - await player.play(0, 0, number); + player.play(0, 0, number).catch(console.error); return ctx.reply({ embeds: [ diff --git a/src/handlers/commands/utils/report.ts b/src/handlers/commands/slash/utils/report.ts similarity index 99% rename from src/handlers/commands/utils/report.ts rename to src/handlers/commands/slash/utils/report.ts index 3660b439..7a48e76b 100644 --- a/src/handlers/commands/utils/report.ts +++ b/src/handlers/commands/slash/utils/report.ts @@ -31,7 +31,7 @@ class ReportCommand extends Command { const lang = ctx.locale; // Отправляем сообщение в текстовый канал - const msg= await ctx.reply({ + const msg = await ctx.reply({ flags: "IsComponentsV2", components: [ { diff --git a/src/handlers/commands/voice/connection.ts b/src/handlers/commands/slash/voice/connection.ts similarity index 90% rename from src/handlers/commands/voice/connection.ts rename to src/handlers/commands/slash/voice/connection.ts index 99f9d7c9..a0cceca8 100644 --- a/src/handlers/commands/voice/connection.ts +++ b/src/handlers/commands/slash/voice/connection.ts @@ -68,7 +68,7 @@ class VoiceJoinCommand extends SubCommand { } // Смена канала - voiceConnection.swapChannel = VoiceChannel.id; + voiceConnection.channel = VoiceChannel.id; } // Подключаемся к голосовому каналу без очереди @@ -104,24 +104,33 @@ class VoiceJoinCommand extends SubCommand { } }) class VoiceLeaveCommand extends SubCommand { - async run({ctx, args}: CommandContext) { + async run({ctx}: CommandContext) { const { guildId } = ctx; - - const voiceConnection = db.voice.get(guildId); + const VoiceConnection = db.voice.get(guildId); const queue = db.queues.get(guildId); - const VoiceChannel = args[0] ?? ctx.member.voice.channel; - /// Если есть очередь, то удаляем ее! - if (queue) queue.cleanup(); + // Если бот не подключен к голосовому каналу + if (!VoiceConnection) { + return ctx.reply({ + embeds: [ + { + color: Colors.Green, + description: locale._(ctx.locale, "voice.leave.fail", [`<#${VoiceConnection.configuration.channel_id}>`]) + } + ], + flags: "Ephemeral" + }); + } - // Отключаемся от голосового канала - if (!voiceConnection.disconnect) return null; + /// Если есть очередь, то удаляем ее! + else if (queue) queue.cleanup(); + db.voice.remove(guildId); return ctx.reply({ embeds: [ { color: Colors.Green, - description: locale._(ctx.locale, "voice.leave", [VoiceChannel]) + description: locale._(ctx.locale, "voice.leave", [`<#${VoiceConnection.configuration.channel_id}>`]) } ], flags: "Ephemeral" diff --git a/src/handlers/commands/ui/music/play.ts b/src/handlers/commands/ui/music/play.ts new file mode 100644 index 00000000..9ff460e7 --- /dev/null +++ b/src/handlers/commands/ui/music/play.ts @@ -0,0 +1,65 @@ +import { Command, CommandContext, Declare, Middlewares, Permissions } from "#handler/commands"; +import { ApplicationCommandType, Message } from "discord.js"; +import { locale } from "#structures"; +import { db } from "#app/db"; + +/** + * @author SNIPPIK + * @description Базовое включение музыки из сообщения + * @class PlayContextCommand + * @extends Assign + * @public + */ +@Declare({ + names: { + "en-US": "Play", + "ru": "Воспроизвести" + }, + integration_types: ["GUILD_INSTALL"], + type: ApplicationCommandType.Message +}) +@Middlewares(["cooldown", "voice", "another_voice"]) +@Permissions({ + client: ["SendMessages", "ViewChannel"] +}) +class PlayContextCommand extends Command { + async run({ctx, args}: CommandContext) { + const url = Array.from(args[0].content.matchAll(/(https?:\/\/[^\s)]+)/g), m => m[1])[0]; + await ctx.deferReply(); + + // Если не найдена ссылка на трек или прочее... + if (!url) { + db.events.emitter.emit("rest/error", ctx, locale._(ctx.locale, "api.platform.support")); + return null; + } + + const platform = db.api.request(url); + + // Если не нашлась платформа + if (!platform) { + db.events.emitter.emit("rest/error", ctx, locale._(ctx.locale, "api.platform.support")); + return null; + } + + // Если платформа заблокирована + else if (platform.block) { + db.events.emitter.emit("rest/error", ctx, locale._(ctx.locale, "api.platform.block")); + return null; + } + + // Если есть проблема с авторизацией на платформе + else if (!platform.auth) { + db.events.emitter.emit("rest/error", ctx, locale._(ctx.locale, "api.platform.auth")); + return null; + } + + db.events.emitter.emit("rest/request", platform, ctx, url); + return null; + }; +} + +/** + * @export default + * @description Не даем классам или объектам быть доступными везде в проекте + */ +export default [ PlayContextCommand ]; \ No newline at end of file diff --git a/src/handlers/commands/utils/avatar.ts b/src/handlers/commands/ui/utils/avatar.ts similarity index 100% rename from src/handlers/commands/utils/avatar.ts rename to src/handlers/commands/ui/utils/avatar.ts diff --git a/src/handlers/components/buttons/player/back.ts b/src/handlers/components/buttons/player/back.ts index 59f0a548..154d234f 100644 --- a/src/handlers/components/buttons/player/back.ts +++ b/src/handlers/components/buttons/player/back.ts @@ -1,7 +1,6 @@ import { Component, DeclareComponent } from "#handler/components"; import { Middlewares } from "#handler/commands"; import { Colors } from "#structures/discord"; -import { RepeatType } from "#core/queue"; import { locale } from "#structures"; import { db } from "#app/db"; @@ -18,29 +17,40 @@ import { db } from "#app/db"; class ButtonBack extends Component<"button"> { public callback: Component<"button">["callback"] = (ctx) => { const queue = db.queues.get(ctx.guildId); - const repeat = queue.tracks.repeat; const position = queue.tracks.position; - // Делаем повтор временным - if (repeat === RepeatType.None) queue.tracks.repeat = RepeatType.Songs; + // Если трек уже какое-то время играет + if ( + // Если есть аудио поток + queue.player.audio.current && + // Если время аудио позволяет вернутся + (queue.player.audio.current?.duration > db.queues.options.optimization) + ) { + // Запускаем проигрывание текущего трека + queue.player.play(0, 0, queue.player.tracks.position).catch(console.error); + + // Сообщаем о том что музыка начата с начала + return ctx.reply({ + flags: "Ephemeral", + embeds: [ + { + description: locale._(ctx.locale, "player.button.replay", [queue.tracks.track.name]), + color: Colors.Green + } + ] + }); + } // Если позиция меньше или равна 0 if (position <= 0) { - // Переключаем на 0 позицию - queue.tracks.position = queue.tracks.total - 1; - // Переключаемся на последний трек - queue.player.play(0, 0, queue.tracks.position).catch(console.error); + queue.player.play(0, 0, queue.tracks.total - 1).catch(console.error); } - else { // Переключаемся на прошлый трек queue.player.play(0, 0, position - 1).catch(console.error); } - // Возвращаем повтор - queue.tracks.repeat = repeat; - // Уведомляем пользователя о смене трека return ctx.reply({ flags: "Ephemeral", diff --git a/src/handlers/components/buttons/player/lyrics.ts b/src/handlers/components/buttons/player/lyrics.ts index 3346313b..509e1ff2 100644 --- a/src/handlers/components/buttons/player/lyrics.ts +++ b/src/handlers/components/buttons/player/lyrics.ts @@ -20,7 +20,9 @@ class ButtonLyrics extends Component<"button"> { const track = queue.tracks.track; // Ожидаем ответа от кода со стороны Discord - await ctx.deferReply().catch(() => {}); + await ctx.deferReply(); + + // Сообщение let msg: CycleInteraction; // Получаем текст песни @@ -40,7 +42,7 @@ class ButtonLyrics extends Component<"button"> { url: track.url, icon_url: track.artist.image.url }, - description: `\`\`\`css\n${item !== undefined ? item : locale._(ctx.locale, "player.button.lyrics.fail")}\n\`\`\``, + description: `\`\`\`css\n${item !== undefined ? item : locale._(ctx.locale, "player.button.lyrics.fail", [track.lyricsProvider])}\n\`\`\``, timestamp: new Date() as any } ] @@ -63,7 +65,7 @@ class ButtonLyrics extends Component<"button"> { url: track.url, icon_url: track.artist.image.url }, - description: `\`\`\`css\n${locale._(ctx.locale, "player.button.lyrics.fail")}\n\`\`\``, + description: `\`\`\`css\n${locale._(ctx.locale, "player.button.lyrics.fail", [track.lyricsProvider])}\n\`\`\``, timestamp: new Date() as any } ] @@ -71,7 +73,7 @@ class ButtonLyrics extends Component<"button"> { }) - setTimeout(() => msg.deletable ? msg.delete().catch(() => null) : null, 40e3); + if (msg) setTimeout(() => msg?.deletable ? msg.delete().catch(() => null) : null, 40e3); }; } /** diff --git a/src/handlers/components/buttons/player/queue.ts b/src/handlers/components/buttons/player/queue.ts index af856285..d11d353b 100644 --- a/src/handlers/components/buttons/player/queue.ts +++ b/src/handlers/components/buttons/player/queue.ts @@ -1,7 +1,7 @@ import { Component, DeclareComponent } from "#handler/components"; import { Middlewares } from "#handler/commands"; -import { Colors } from "#structures/discord"; import { locale, Logger } from "#structures"; +import { Colors } from "#structures/discord"; import { db } from "#app/db"; /** @@ -21,7 +21,7 @@ class ButtonQueue extends Component<"button"> { const pageSize = 5; // Текущая страница (с 1) - let page = Math.floor(queue.tracks.position / pageSize); + let page = 0; // Общее количество страниц (минимум 1) const pages = Math.max(1, Math.ceil(queue.tracks.total / pageSize)); diff --git a/src/handlers/components/buttons/player/replay.ts b/src/handlers/components/buttons/player/replay.ts deleted file mode 100644 index c63fbcaf..00000000 --- a/src/handlers/components/buttons/player/replay.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Component, DeclareComponent } from "#handler/components"; -import { Middlewares } from "#handler/commands"; -import { Colors } from "#structures/discord"; -import { locale } from "#structures"; -import { db } from "#app/db"; - -/** - * @description Кнопка replay, отвечает за проигрывание заново - * @class ButtonReplay - * @extends Component - * @loadeble - */ -@DeclareComponent({ - name: "replay" -}) -@Middlewares(["queue", "another_voice", "voice", "player-wait-stream"]) -class ButtonReplay extends Component<"button"> { - public callback: Component<"button">["callback"] = (ctx) => { - const queue = db.queues.get(ctx.guildId); - - // Запускаем проигрывание текущего трека - queue.player.play(0, 0, queue.player.tracks.position).catch(console.error); - - // Сообщаем о том что музыка начата с начала - return ctx.reply({ - flags: "Ephemeral", - embeds: [ - { - description: locale._(ctx.locale, "player.button.replay", [queue.tracks.track.name]), - color: Colors.Green - } - ] - }); - }; -} - -/** - * @export default - * @description Не даем классам или объектам быть доступными везде в проекте - */ -export default [ButtonReplay]; \ No newline at end of file diff --git a/src/handlers/components/index.decorator.ts b/src/handlers/components/index.decorator.ts new file mode 100644 index 00000000..15b90c04 --- /dev/null +++ b/src/handlers/components/index.decorator.ts @@ -0,0 +1,15 @@ +import type { SupportButtons, SupportSelector } from "#handler/components/index"; + +/** + * @author SNIPPIK + * @description Декоратор создающий заголовок команды + * @decorator + * @public + */ +export function DeclareComponent(options: {name: SupportSelector | SupportButtons}) { + // Загружаем данные в класс + return (target: T) => + class extends target { + name = options.name; + } +} \ No newline at end of file diff --git a/src/handlers/components/index.ts b/src/handlers/components/index.ts index 7646b807..13bb7a85 100644 --- a/src/handlers/components/index.ts +++ b/src/handlers/components/index.ts @@ -1,8 +1,10 @@ +import { buttonInteraction, SelectMenuInteract } from "#structures/discord"; import { RegisteredMiddlewares } from "#handler/middlewares"; -import { buttonInteraction } from "#structures/discord"; -import { AnySelectMenuInteraction } from "discord.js"; import { handler } from "#handler"; +// Export decorator +export * from "./index.decorator"; + /** * @author SNIPPIK * @description Загружаем динамические компоненты для работы с ними @@ -18,6 +20,7 @@ export class Components extends handler { /** * @description Регистрируем кнопки в эко системе бота + * @returns void * @public */ public register = () => { @@ -27,6 +30,7 @@ export class Components extends handler { /** * @description Выдача кнопки из всей базы * @param name - Название кнопки + * @public */ public get = (name: string) => { return this.files.find((button) => button.name === name); @@ -37,31 +41,36 @@ export class Components extends handler { * @author SNIPPIK * @description Доступные кнопки * @type SupportButtons + * @public */ -export type SupportButtons = "resume_pause" | "shuffle" | "replay" | "repeat" | "lyrics" | "queue" | "skip" | "stop" | "back" | "filters"; +export type SupportButtons = "resume_pause" | "shuffle" | "repeat" | "lyrics" | "queue" | "skip" | "stop" | "back" | "filters"; /** * @author SNIPPIK * @description Доступные селекторы меню * @type SupportSelector + * @public */ export type SupportSelector = "filter_select"; /** * @author SNIPPIK - * @description + * @description Тип поддержки компонента + * @public */ export type SupportComponent = { /** * @description Название кнопки + * @public */ name?: T extends "button" ? SupportButtons : SupportSelector; /** * @description Функция выполнения кнопки * @param msg - Сообщение пользователя + * @public */ - callback?: (ctx: T extends "button" ? buttonInteraction : AnySelectMenuInteraction) => any; + callback?: (ctx: T extends "button" ? buttonInteraction : SelectMenuInteract) => any; /** * @description Права для использования той или иной команды @@ -75,20 +84,10 @@ export type SupportComponent = { /** * @author SNIPPIK * @description Класс для создания компонентов + * @class Component + * @implements SupportComponent + * @public */ export class Component implements SupportComponent { public callback: SupportComponent["callback"]; -} - -/** - * @author SNIPPIK - * @description Декоратор создающий заголовок команды - * @decorator - */ -export function DeclareComponent(options: {name: SupportSelector | SupportButtons}) { - // Загружаем данные в класс - return (target: T) => - class extends target { - name = options.name; - } } \ No newline at end of file diff --git a/src/handlers/components/selectors/filterSelect.ts b/src/handlers/components/selectors/filterSelect.ts index fb64a0b7..4b956859 100644 --- a/src/handlers/components/selectors/filterSelect.ts +++ b/src/handlers/components/selectors/filterSelect.ts @@ -1,8 +1,8 @@ import { Component, DeclareComponent } from "#handler/components"; import { Middlewares } from "#handler/commands"; import filters from "#core/player/filters.json"; +import type { AudioFilter } from "#core/player"; import { Colors } from "#structures/discord"; -import { AudioFilter } from "#core/player"; import { locale } from "#structures"; import { db } from "#app/db"; @@ -20,14 +20,12 @@ class FilterSelector extends Component<"selector"> { public callback: Component["callback"] = (ctx) => { const { player } = db.queues.get(ctx.guildId); const Filter = filters.find((item) => item.name === ctx["values"][0]) as AudioFilter; - const findFilter = player.filters.find((fl) => fl.name === Filter.name); const seek: number = player.audio.current?.duration ?? 0; - /* Отключаем фильтр */ // Если есть включенный фильтр - if (findFilter) { - player.filters.delete(findFilter); + if (player.filters.has(Filter)) { + player.filters.delete(Filter); // Если можно выключить фильтр или фильтры сейчас if (player.audio.current.duration < player.tracks.track.time.total - db.queues.options.optimization) { @@ -60,8 +58,20 @@ class FilterSelector extends Component<"selector"> { }); } + const unsupportedFilters = player.filters.hasUnsupported(Filter); + + // Проверяем, не конфликтует ли новый фильтр с уже включёнными + if (unsupportedFilters) return ctx.reply({ + embeds: [ + { + description: locale._(ctx.locale, "command.filter.push.unsupported", unsupportedFilters), + color: Colors.DarkRed + } + ], + flags: "Ephemeral" + }); - /* Включаем фильтр */ + /* Добавляем фильтр */ player.filters.add(Filter); // Если можно включить фильтр или фильтры сейчас diff --git a/src/handlers/events/discord/ClientGuilds.ts b/src/handlers/events/discord/ClientGuilds.ts index a1fda50e..c153d2d0 100644 --- a/src/handlers/events/discord/ClientGuilds.ts +++ b/src/handlers/events/discord/ClientGuilds.ts @@ -1,108 +1,129 @@ -import { Assign, Logger } from "#structures"; -import { Event } from "#handler/events"; +import { DeclareEvent, Event, EventOn, SupportEventCallback } from "#handler/events"; +import { ChannelType, Events, PermissionsBitField, TextChannel } from "discord.js"; import { homepage } from "package.json"; -import { Events } from "discord.js"; +import { Logger } from "#structures"; import { db } from "#app/db"; +// Список прав, которые проверяем +const REQUIRED_PERMISSIONS = [ + PermissionsBitField.Flags.SendMessages, // Отправка сообщений + PermissionsBitField.Flags.EmbedLinks, // Вставка ссылок/встраиваемых сообщений + PermissionsBitField.Flags.ViewChannel +]; + /** * @author SNIPPIK * @description Класс события GuildCreate * @class GuildCreate - * @extends Assign + * @extends Event * @event Events.GuildCreate * @public * * @license BSD-3-Clause + custom restriction | Эта команда защищена лицензией проекта, изменение или удаление строго запрещено!!! */ -class GuildCreate extends Assign> { - public constructor() { - super({ - name: Events.GuildCreate, - type: "client", - once: false, - execute: (guild) => { - const id = guild.client.shard?.ids[0] ?? 0; - Logger.log("LOG", `[Core/${id}] has ${Logger.color(32, `added a new guild ${guild.id}`)}`); +@EventOn() +@DeclareEvent({ + name: Events.GuildCreate, + type: "client" +}) +class GuildCreate extends Event { + run: SupportEventCallback = (guild) => { + const id = guild.client.shard?.["ids"][0] ?? 0; + Logger.log("LOG", `[Core/${id}] has ${Logger.color(32, `added a new guild ${guild.id}`)}`); - // Получаем владельца сервера - const owner = guild.members.cache.get(guild.ownerId); + const channel = guild.channels.cache.find((ch): ch is TextChannel => { + if (ch.type !== ChannelType.GuildText) return false; - // Если владельца не удалось найти - if (!owner) return null; + const perms = ch.permissionsFor(guild.members.me!); + if (!perms) return false; - try { - // Отправляем сообщение владельцу сервера - return owner.send({ - flags: "IsComponentsV2", - components: [ - { - "type": 17, // Container - "components": [ - { - "type": 12, // Media - items: [ - { - "media": { - "url": db.images.banner - } - } - ] - }, + return REQUIRED_PERMISSIONS.every(p => perms.has(p)); + }); + // Если владельца не удалось найти + if (!channel) return null; + + try { + // Отправляем сообщение владельцу сервера + return channel.send({ + flags: "IsComponentsV2", + components: [ + { + "type": 17, // Container + "components": [ + { + "type": 12, // Media + items: [ { - "type": 10, // Text - "content": `# 💫 For owner of Guild ||${guild}|| \n` + - `👋 Hi listener, thanks for adding the bot to your server, if it wasn't you, another user with privilege could have done it\n` + - `## 💣 Features\n` + - `- 💵 No premium\n` + - `- 🪛 Not using lava services such as lavalink, lavaplayer\n` + - `- 🎶 Smooth transitions between tracks, they are still raw!\n` + - `- 🪪 More detailed track data with dynamic message about the current track\n` + - `- 🎛 Access to filters, yes you have full access to audio filters, many bots provide paid access!`, - }, - { - "type": 14, // Separator - "divider": true, - "spacing": 1 - }, - { - "type": 10, // Text - "content": `## 📑 Support\n`+ - `- 📣 If you find a mistake or have any ideas, please post them on github, discord\n` + - `- 🗃 Default support platform: YouTube, Spotify, SoundCloud, Yandex, VK` + "media": { + "url": db.images.banner + } } ] }, + { - type: 1, - components: [ - // Help Guild - { - type: 2, - style: 5, - url: "https://discord.gg/qMf2Sv3", - emoji: { name: "📨" }, - label: "Official server" - }, + "type": 10, // Text + "content": `# 💫 For users Guild ||${guild}|| \n` + + `👋 Hi listeners, thanks for adding the bot to your server, if it wasn't you, another user with privilege could have done it\n` + + `## 🔊 Voice Engine [without lavalink]\n` + + ` - 🎧 Full **Voice Gateway v8** implementation\n` + + ` - 🔐 Full **SRTP + E2EE** support\n` + + ` - 🎶 Best open-source audio player alternative\n` + + ` - 📦 Adaptive audio packet system with custom \`Jitter Buffer\`\n` + + ` - 🔁 Supported: Autoplay, Repeat, Shuffle, Replay, and more\n` + + `## 🎵 Audio\n` + + ` - 🔄 Reuse audio <8 minutes without conversion\n` + + ` - 🎶 Smooth **fade-in/fade-out**, skip, seek & tp transitions\n` + + ` - 🔀 \`Hot audio swap\` between tracks\n` + + ` - 🎚 16+ built-in filters + custom filter support\n` + + ` - 📺 Long video support & raw Live video\n` + + ` - ⏱ Explicit audio stream synchronization without filters\n` + + `## 🌐 Platforms\n` + + ` - 🌍 Supported: ${db.api.platforms.array.map((api) => db.api.platforms.authorization.includes(api.name) || db.api.platforms.block.includes(api.name) ? `\`${api.name}\`` : `~~${api.name}~~`)}\n` + + ` - 🎵 Audio: ${db.api.platforms.audio.map((api) => `\`${api}\``)}\n` + + ` - 🔍 Precise search by time, name syllables, and related tracks` + }, + { + "type": 14, // Separator + "divider": true, + "spacing": 1 + }, + { + "type": 10, // Text + "content": `## 📑 Support\n`+ + `- 📣 If you find a mistake or have any ideas, please post them on github, discord` + } + ] + }, + { + type: 1, + components: [ + // Help Guild + { + type: 2, + style: 5, + url: "https://discord.gg/qMf2Sv3", + emoji: { name: "📨" }, + label: "Official server" + }, - // Github - { - type: 2, - style: 5, - url: homepage as string, - emoji: { name: "🔗" }, - label: "Github" - } - ] + // Github + { + type: 2, + style: 5, + url: homepage as string, + emoji: { name: "🔗" }, + label: "Github" } ] - }) - } catch (err) { - console.log(err); - return null; - } - } - }); + } + ] + }) + } catch (err) { + console.log(err); + return null; + } }; } @@ -115,23 +136,21 @@ class GuildCreate extends Assign> { * @event Events.GuildDelete * @public */ -class GuildRemove extends Assign> { - public constructor() { - super({ - name: Events.GuildDelete, - type: "client", - once: false, - execute: async (guild) => { - const id = guild.client.shard?.ids[0] ?? 0; - Logger.log("LOG", `[Core/${id}] has ${Logger.color(31, `remove a guild ${guild.id}`)}`); +@EventOn() +@DeclareEvent({ + name: Events.GuildDelete, + type: "client" +}) +class GuildRemove extends Event { + run: SupportEventCallback = async (guild) => { + const id = 0//guild.client.shard?.["ids"][0] ?? 0; + Logger.log("LOG", `[Core/${id}] has ${Logger.color(31, `remove a guild ${guild.id}`)}`); - // Получаем очередь - const queue = db.queues.get(guild.id); + // Получаем очередь + const queue = db.queues.get(guild.id); - // Если есть очередь - if (queue) db.queues.remove(guild.id); - } - }); + // Если есть очередь + if (queue) db.queues.remove(guild.id); }; } diff --git a/src/handlers/events/discord/ClientReady.ts b/src/handlers/events/discord/ClientReady.ts index 51f96826..31d05229 100644 --- a/src/handlers/events/discord/ClientReady.ts +++ b/src/handlers/events/discord/ClientReady.ts @@ -1,26 +1,24 @@ -import { Assign, Logger } from "#structures"; -import { Event } from "#handler/events"; +import { DeclareEvent, Event, EventOn, SupportEventCallback } from "#handler/events"; import { Events } from "discord.js"; +import { Logger } from "#structures"; /** * @author SNIPPIK * @description Класс события ClientReady * @class ClientReady - * @extends Assign + * @extends Event * @event Events.ClientReady * @public */ -class ClientReady extends Assign> { - public constructor() { - super({ - name: Events.ClientReady, - type: "client", - once: false, - execute: (client) => { - const id = client.shard?.ids[0] ?? 0; - Logger.log("LOG", `[Core/${id}] on ${Logger.color(32, `${client.guilds.cache.size} guilds`)}`); - } - }); +@EventOn() +@DeclareEvent({ + name: Events.ClientReady, + type: "client" +}) +class ClientReady extends Event { + run: SupportEventCallback = async (client) => { + const id = client.shard?.["ids"][0] ?? 0; + Logger.log("LOG", `[Core/${id}] on ${Logger.color(32, `${client.guilds.cache.size} guilds`)}`); }; } diff --git a/src/handlers/events/discord/InteractionCreate.ts b/src/handlers/events/discord/InteractionCreate.ts index ec089279..bac2a833 100644 --- a/src/handlers/events/discord/InteractionCreate.ts +++ b/src/handlers/events/discord/InteractionCreate.ts @@ -1,87 +1,90 @@ -import type { AnySelectMenuInteraction, AutocompleteInteraction, ButtonInteraction, ChatInputCommandInteraction } from "discord.js"; -import { CommandInteraction, Colors } from "#structures/discord"; +import type { AutocompleteInteraction, ButtonInteraction, ChatInputCommandInteraction } from "discord.js"; +import { CommandInteraction, Colors, SelectMenuInteract } from "#structures/discord"; +import { DeclareEvent, Event, EventOn, SupportEventCallback } from "#handler/events"; import { ChannelType, Events, InteractionType } from "discord.js"; -import { Assign, Logger, locale } from "#structures"; +import { Logger, locale } from "#structures"; import { SubCommand } from "#handler/commands"; -import { Event } from "#handler/events"; import { db } from "#app/db"; /** * @author SNIPPIK * @description Класс для взаимодействия бота с slash commands, buttons * @class InteractionCreate - * @extends Assign + * @extends Event * @event Events.InteractionCreate * @public */ -class Interaction extends Assign> { - /** - * @description Создание события - * @public - */ - public constructor() { - super({ - name: Events.InteractionCreate, - type: "client", - once: false, - execute: (ctx) => { - // Если включен режим белого списка - if (db.whitelist.toggle) { - // Если нет пользователя в списке просто его игнорируем - if (db.whitelist.ids.length > 0 && !db.whitelist.ids.includes(ctx.user.id)) { - if (!("reply" in ctx)) return; - - return ctx.reply({ - flags: "Ephemeral", - embeds: [ - { - description: locale._(ctx.locale, "interaction.whitelist", [ctx.member]), - color: Colors.Yellow - } - ] - }); - } - } +@EventOn() +@DeclareEvent({ + name: Events.InteractionCreate, + type: "client" +}) +class Interaction extends Event { + run: SupportEventCallback = async (ctx) => { + // Если включен режим белого списка + if (db.whitelist.toggle) { + // Если нет пользователя в списке просто его игнорируем + if (db.whitelist.ids.length > 0 && !db.whitelist.ids.includes(ctx.user.id)) { + if (!("reply" in ctx)) return; + + return ctx.reply({ + flags: "Ephemeral", + embeds: [ + { + description: locale._(ctx.locale, "interaction.whitelist", [ctx.member]), + color: Colors.Yellow + } + ] + }); + } + } - // Если включен режим черного списка - else if (db.blacklist.toggle) { - // Если нет пользователя в списке просто его игнорируем - if (db.blacklist.ids.length > 0 && !db.blacklist.ids.includes(ctx.user.id)) { - if (!("reply" in ctx)) return; - - return ctx.reply({ - flags: "Ephemeral", - embeds: [ - { - description: locale._(ctx.locale, "interaction.blacklist", [ctx.member]), - color: Colors.Yellow - } - ] - }); - } - } + // Если включен режим черного списка + else if (db.blacklist.toggle) { + // Если нет пользователя в списке просто его игнорируем + if (db.blacklist.ids.length > 0 && !db.blacklist.ids.includes(ctx.user.id)) { + if (!("reply" in ctx)) return; + + return ctx.reply({ + flags: "Ephemeral", + embeds: [ + { + description: locale._(ctx.locale, "interaction.blacklist", [ctx.member]), + color: Colors.Yellow + } + ] + }); + } + } - // Если используется функция ответа от бота - if (ctx.type === InteractionType.ApplicationCommandAutocomplete) { - Logger.log("DEBUG", `[${ctx.user.username}] run autocomplete ${ctx?.commandName}`); - return this.SelectAutocomplete(ctx); - } + /** + * @description Смотрим тип запроса + * @protected + */ + switch (ctx.type) { + // Если используется функция ответа от бота + case InteractionType.ApplicationCommandAutocomplete: { + Logger.log("DEBUG", `[${ctx.user.username}] run autocomplete ${ctx?.commandName}`); + return this.SelectAutocomplete(ctx); + } - // Если пользователь использует команду - else if (ctx.type === InteractionType.ApplicationCommand) { - Logger.log("DEBUG", `[${ctx.user.username}] run command ${ctx?.commandName}`); - return this.SelectCommand(ctx as any); - } + // Если пользователь использует команду + case InteractionType.ApplicationCommand: { + Logger.log("DEBUG", `[${ctx.user.username}] run command ${ctx?.commandName}`); + return this.SelectCommand(ctx as any); + } - // Действия выбора/кнопок - else if (ctx.type == InteractionType.MessageComponent) { - Logger.log("DEBUG", `[${ctx.user.username}] run component ${ctx?.["customId"]}`); - return this.SelectComponent(ctx); - } + // Действия выбора/кнопок + case InteractionType.MessageComponent: { + Logger.log("DEBUG", `[${ctx.user.username}] run component ${ctx.customId} | ${ctx?.["values"]}`); + return this.SelectComponent(ctx); + } - return null; + default: { + Logger.log("WARN", `User: ${ctx.user.username}, used unsupported type ${ctx.type}`); + ctx.deleteReply("@original").catch(() => null); } - }); + } }; /** @@ -90,7 +93,7 @@ class Interaction extends Assign> { * @readonly * @private */ - private readonly SelectCommand = (ctx: ChatInputCommandInteraction) => { + private readonly SelectCommand = async (ctx: ChatInputCommandInteraction) => { const command = db.commands.get(ctx.commandName); // Если нет команды @@ -110,7 +113,10 @@ class Interaction extends Assign> { // Проверка middleware if (command.middlewares?.length > 0) { for (const rule of db.middlewares.array) { - if (command.middlewares.includes(rule.name) && !rule.callback(ctx)) return null; + if (command.middlewares.includes(rule.name) && !rule.callback(ctx)) { + Logger.log("DEBUG", `[${ctx.user.username}] ${rule.name} has dont entered`); + return null; + } } } @@ -129,7 +135,7 @@ class Interaction extends Assign> { if (botPerms?.length && !botPerms.every(perm => ctx.guild?.members.me?.permissionsIn(ctx.channel)?.has(perm) && ctx.member.voice.channel ? ctx.guild?.members.me?.permissionsIn(ctx.member.voice.channel)?.has(perm) : true) ) { - return ctx.reply(locale._(ctx.locale, "interaction.permission.client", [ctx.member])); + return ctx.member.send(locale._(ctx.locale, "interaction.permission.client", [`<@${ctx.client.user.id}>`])); } } @@ -137,7 +143,7 @@ class Interaction extends Assign> { const subcommand: SubCommand = command.options?.find((sub) => sub.name === ctx.options["_subcommand"] && "run" in sub) as any; // Ищем аргументы - const args: any[] = ctx.options?.["_hoistedOptions"]?.map(f => f[f.name] ?? f.value) ?? []; + const args: any[] = ctx.options?.["_hoistedOptions"]?.map(f => f.name === "type" ? f.value : f[f.name] ?? f.value) ?? []; // Запускаем команду return (subcommand ?? command).run({ ctx, args }); @@ -149,14 +155,14 @@ class Interaction extends Assign> { * @readonly * @private */ - private readonly SelectAutocomplete = (ctx: AutocompleteInteraction) => { + private readonly SelectAutocomplete = async (ctx: AutocompleteInteraction) => { const command = db.commands.get(ctx.commandName); // Если не найдена команда if (!command) return null; // Ищем аргументы - const args: any[] = ctx.options?.["_hoistedOptions"]?.map(f => f[f.name] ?? f.value) ?? []; + const args: any[] = ctx.options?.["_hoistedOptions"]?.map(f => f.name === "type" ? f.value : f[f.name] ?? f.value) ?? []; if (args.length === 0 || args.some(a => a === "")) return null; const subName = ctx.options["_subcommand"]; @@ -174,9 +180,8 @@ class Interaction extends Assign> { * @readonly * @private */ - private readonly SelectComponent = (ctx: ButtonInteraction | AnySelectMenuInteraction) => { + private readonly SelectComponent = async (ctx: ButtonInteraction | SelectMenuInteract) => { const component = db.components.get(ctx.customId); - // Если не найден такой компонент if (!component) return null; @@ -185,7 +190,10 @@ class Interaction extends Assign> { // Делаем проверку ограничений if (middlewares?.length > 0) { for (const rule of db.middlewares.array) { - if (middlewares.includes(rule.name) && !rule.callback(ctx)) return null; + if (middlewares.includes(rule.name) && !rule.callback(ctx)) { + Logger.log("DEBUG", `[${ctx.user.username}] ${rule.name} has dont entered`); + return null; + } } } diff --git a/src/handlers/events/discord/VoiceStateUpdate.ts b/src/handlers/events/discord/VoiceStateUpdate.ts index 395bf11f..03210781 100644 --- a/src/handlers/events/discord/VoiceStateUpdate.ts +++ b/src/handlers/events/discord/VoiceStateUpdate.ts @@ -1,98 +1,97 @@ -import { Event } from "#handler/events"; -import { Assign } from "#structures"; +import { DeclareEvent, Event, EventOn, SupportEventCallback } from "#handler/events"; import { Events } from "discord.js"; import { db } from "#app/db"; /** * @author SNIPPIK * @description Временная база данных + * @const temple_db + * @private */ const temple_db = new Map(); /** * @author SNIPPIK * @description Время для отключения бота от голосового канала + * @const timeout + * @private */ -const timeout = 15; +const timeout = 30; /** * @author SNIPPIK * @description Класс события VoiceStateUpdate * @class VoiceStateUpdate - * @extends Assign + * @extends Event * @event Events.VoiceStateUpdate * @public */ -class VoiceStateUpdate extends Assign> { - public constructor() { - super({ - name: Events.VoiceStateUpdate, - type: "client", - once: false, - execute: (oldState, newState) => { - setImmediate(() => { - const guild = oldState.guild || newState.guild; - const guildID = guild.id; - - const voice = db.voice.get(guildID); - const queue = db.queues.get(guildID); - const temp = temple_db.get(guildID); - - // Если нет гс и очереди, то не продолжаем - if (!voice && !queue) return; - - // Если бота нет в голосовом канале, но есть очередь - else if (!voice && queue) db.queues.remove(guildID); - - // Если есть гс, но нет очереди - else if (voice && !queue) { - const members = guild.members.me.voice.channel?.members?.filter(member => !member.user.bot).size ?? 0; - - // Если есть пользователи - if (members == 0) db.voice.remove(guildID); - } - - // Если есть гс и очередь - else { - const meVoice = !!guild.members.me.voice.channel?.members?.find(member => member.id === guild.members.me.id); - - // Если бота выгнали из голосового канала - if (!meVoice) { - db.voice.remove(guildID); - db.queues.remove(guildID); - } - - const members = guild.members.me.voice.channel?.members?.filter(member => !member.user.bot).size ?? 0; - - // Если есть пользователи - if (members > 0) { - // Если есть таймер для удаления очереди - if (temp) { - clearTimeout(temp); - temple_db.delete(guildID); - - // Снимаем плеер с паузы, если она есть! - if (queue && queue?.player?.status === "player/pause") queue.player.resume(); - } - } - - // Если нет пользователей - else { - // Если нет таймера для удаления очереди - if (!temp) { - // Ставим плеер на паузу - if (queue && queue?.player?.status === "player/playing") queue.player.pause(); - - temple_db.set(guildID, setTimeout(() => { - if (queue) db.queues.remove(guildID); - if (voice) db.voice.remove(guildID); - }, timeout * 1e3)); - } - } - } - }); +@EventOn() +@DeclareEvent({ + name: Events.VoiceStateUpdate, + type: "client" +}) +class VoiceStateUpdate extends Event { + run: SupportEventCallback = (oldState, newState) => { + const guild = oldState.guild || newState.guild; + const guildID = guild.id; + + const voice = db.voice.get(guildID); + const queue = db.queues.get(guildID); + const temp = temple_db.get(guildID); + + // Если нет гс и очереди, то не продолжаем + if (!voice && !queue) return; + + // Если бота нет в голосовом канале, но есть очередь + else if (!voice && queue) db.queues.remove(guildID); + + // Если есть гс, но нет очереди + else if (voice && !queue) { + const members = guild.members.me.voice.channel?.members?.filter(member => !member.user.bot).size ?? 0; + + // Если есть пользователи + if (members == 0) db.voice.remove(guildID); + } + + // Если есть гс и очередь + else { + const meVoice = !!guild.members.me.voice.channel?.members?.find(member => member.id === guild.members.me.id); + + // Если бота выгнали из голосового канала + if (!meVoice) { + db.voice.remove(guildID); + db.queues.remove(guildID); } - }); + + const members = guild.members.me.voice.channel?.members?.filter(member => !member.user.bot).size ?? 0; + + // Если есть пользователи + if (members > 0) { + // Если есть таймер для удаления очереди + if (temp) { + clearTimeout(temp); + temple_db.delete(guildID); + + // Снимаем плеер с паузы, если она есть! + if (queue && queue?.player?.status === "player/pause") queue.player.resume(); + } + } + + // Если нет пользователей + else { + // Если нет таймера для удаления очереди + if (!temp) { + // Ставим плеер на паузу + if (queue && queue?.player?.status === "player/playing") queue.player.pause(); + + temple_db.set(guildID, setTimeout(() => { + if (queue) db.queues.remove(guildID); + if (voice) db.voice.remove(guildID); + }, timeout * 1e3)); + } + } + } }; } diff --git a/src/handlers/events/index.decorator.ts b/src/handlers/events/index.decorator.ts new file mode 100644 index 00000000..4fa7a17d --- /dev/null +++ b/src/handlers/events/index.decorator.ts @@ -0,0 +1,42 @@ +import type { SupportEvent, SupportKeysOfEvents } from "#handler/events"; + +/** + * @author SNIPPIK + * @description Декоратор создающий заголовок события + * @decorator + * @public + */ +export function DeclareEvent(options: SupportEvent) { + // Загружаем данные в класс + return (target: T) => + class extends target { + name = options.name; + type = options.type; + } +} + +/** + * @author SNIPPIK + * @description Декоратор задающий разовое событие + * @decorator + * @public + */ +export function EventOnce() { + return (target: T) => + class extends target { + once = true; + } +} + +/** + * @author SNIPPIK + * @description Декоратор задающий много-разовое событие + * @decorator + * @public + */ +export function EventOn() { + return (target: T) => + class extends target { + once = false; + } +} \ No newline at end of file diff --git a/src/handlers/events/index.ts b/src/handlers/events/index.ts index 4730e540..1911c82e 100644 --- a/src/handlers/events/index.ts +++ b/src/handlers/events/index.ts @@ -1,10 +1,13 @@ -import { DiscordClient } from "#structures/discord"; -import { AudioPlayerEvents } from "#core/player"; +import type { DiscordClient } from "#structures/discord"; +import type { AudioPlayerEvents } from "#core/player"; +import type { ClientEvents } from "discord.js"; +import type { QueueEvents } from "#core/queue"; import { TypedEmitter } from "#structures"; -import { QueueEvents } from "#core/queue"; -import { ClientEvents } from "discord.js"; import { handler } from "#handler"; +// Export decorator +export * from "./index.decorator"; + /** * @author SNIPPIK * @description Класс для взаимодействия с событиями @@ -12,7 +15,7 @@ import { handler } from "#handler"; * @extends handler * @public */ -export class Events extends handler> { +export class Events extends handler> { /** * @description Вспомогательный класс для событий, по умолчанию используется для players, queues * @readonly @@ -30,43 +33,83 @@ export class Events extends handler> { /** * @description Регистрируем ивенты в эко системе бота + * @returns void * @public */ public register = (client: DiscordClient) => { if (this.size > 0) { - this.emitter.removeAllListeners(); - // Отключаем только загруженные события for (let item of this.files) { - client.off(item.name as any, item.execute); + client.off(item.name as any, item.run); } } // Загружаем события заново + this.emitter.removeAllListeners(); this.load(); // Проверяем ивенты for (let item of this.files) { - if (item?.type === "client") client[item.once ? "once" : "on"](item.name as any, item.execute); - else this.emitter[item.once ? "once" : "on"](item.name, item.execute); + if (item?.type === "client") client[item.once ? "once" : "on"](item.name as any, item.run); + else this.emitter[item.once ? "once" : "on"](item.name as any, item.run); } }; } +/** + * @author SNIPPIK + * @description Поддерживаемые названия событий + * @type SupportKeysOfEvents + * @public + */ +export type SupportKeysOfEvents = keyof ClientEvents | keyof QueueEvents | keyof AudioPlayerEvents; + +/** + * @author SNIPPIK + * @description Функция выполнение с типами данных + * @type SupportEventCallback + * @public + */ +export type SupportEventCallback = T extends keyof QueueEvents ? QueueEvents[T] : T extends keyof AudioPlayerEvents ? AudioPlayerEvents[T] : T extends keyof ClientEvents ? (...args: ClientEvents[T]) => void : never; + +/** + * @author SNIPPIK + * @description Декоратор события + * @type SupportEvent + * @public + */ +export type SupportEvent = { + /** + * @description Название событие + * @default null + * @readonly + * @public + */ + name?: T extends keyof QueueEvents ? keyof QueueEvents : T extends keyof AudioPlayerEvents ? keyof AudioPlayerEvents : keyof ClientEvents; + + /** + * @description Тип события + * @default null + * @readonly + * @public + */ + type?: T extends keyof QueueEvents | keyof AudioPlayerEvents ? "player" : "client"; +} + /** * @author SNIPPIK * @description Интерфейс для событий * @class Event * @public */ -export abstract class Event { +export abstract class Event implements SupportEvent { /** * @description Название событие * @default null * @readonly * @public */ - readonly name: T extends keyof QueueEvents ? keyof QueueEvents : T extends keyof AudioPlayerEvents ? keyof AudioPlayerEvents : keyof ClientEvents; + public name: SupportEvent["name"]; /** * @description Тип события @@ -74,10 +117,10 @@ export abstract class Event["type"]; /** - * @description Тип выполнения события + * @description Логика выполнения события * @default null * @readonly * @public @@ -90,5 +133,5 @@ export abstract class Event void : never; + run: SupportEventCallback } \ No newline at end of file diff --git a/src/handlers/events/player/messages.ts b/src/handlers/events/player/messages.ts index 00afad77..a6693d2f 100644 --- a/src/handlers/events/player/messages.ts +++ b/src/handlers/events/player/messages.ts @@ -1,6 +1,6 @@ +import { DeclareEvent, Event, EventOn, SupportEventCallback } from "#handler/events"; import { Colors } from "#structures/discord"; -import { Assign, locale } from "#structures"; -import { Event } from "#handler/events"; +import { locale } from "#structures"; import { Track } from "#core/queue"; import { db } from "#app/db"; @@ -8,50 +8,48 @@ import { db } from "#app/db"; * @author SNIPPIK * @description Сообщение об ошибке * @class message_error - * @extends Assign + * @extends Event * @event message/error * @public */ -class message_error extends Assign> { - public constructor() { - super({ - name: "message/error", - type: "player", - once: false, - execute: async (queue, error, position) => { - // Если нет треков или трека?! - if (!queue || !queue?.tracks || !queue?.tracks!.track) return null; +@EventOn() +@DeclareEvent({ + name: "message/error", + type: "player" +}) +class message_error extends Event<"message/error"> { + run: SupportEventCallback<"message/error"> = async (queue, error, position) => { + // Если нет треков или трека?! + if (!queue || !queue?.tracks || !queue?.tracks!.track) return null; - // Данные трека - const {api, artist, image, user, name} = position ? queue.tracks.get(position) : queue.tracks.track; + // Данные трека + const { api, artist, image, user, name } = position ? queue.tracks.get(position) : queue.tracks.track; - // Создаем сообщение - const message = await queue.message.send({ - embeds: [{ - color: api.color, thumbnail: image, timestamp: new Date(), - fields: [ - { - name: locale._(queue.message.locale, "player.current.playing"), - value: `\`\`\`${name}\`\`\`` - }, - { - name: locale._(queue.message.locale, "player.current.error"), - value: `\`\`\`js\n${error}\`\`\`` - } - ], - author: {name: artist.title, url: artist.url, iconURL: artist.image.url}, - footer: { - text: `${user.username} | ${queue.tracks.time} | 🎶: ${queue.tracks.size}`, - iconURL: user?.avatar - } - }], - withResponse: true - }); - - // Если есть ответ от отправленного сообщения - if (message) setTimeout(() => message.deletable ? message.delete().catch(() => null) : null, 20e3); - } + // Создаем сообщение + const message = await queue.message.send({ + embeds: [{ + color: api.color, thumbnail: image, timestamp: new Date(), + fields: [ + { + name: locale._(queue.message.locale, "player.current.playing"), + value: `\`\`\`${name}\`\`\`` + }, + { + name: locale._(queue.message.locale, "player.current.error"), + value: `\`\`\`js\n${error}\`\`\`` + } + ], + author: {name: artist.title, url: artist.url, iconURL: artist.image.url}, + footer: { + text: `${user.username} | ${queue.tracks.time} | 🎶: ${queue.tracks.size}`, + iconURL: user?.avatar + } + }], + withResponse: true }); + + // Если есть ответ от отправленного сообщения + if (message) setTimeout(() => message.deletable ? message.delete().catch(() => null) : null, 20e3); } } @@ -59,93 +57,85 @@ class message_error extends Assign> { * @author SNIPPIK * @description Сообщение о добавленном треке или плейлисте * @class message_push - * @extends Assign + * @extends Event * @event message/push * @public */ -class message_push extends Assign> { - public constructor() { - super({ - name: "message/push", - type: "player", - once: false, - execute: async (queue, user, obj) => { - const {artist, image} = obj; +@EventOn() +@DeclareEvent({ + name: "message/push", + type: "player" +}) +class message_push extends Event<"message/push"> { + run: SupportEventCallback<"message/push"> = async (queue, user, obj) => { + const {artist, image} = obj; - // Отправляем сообщение, о том что было добавлено в очередь - const msg = await queue.message.send({ - withResponse: true, - embeds: [{ - color: obj["api"] ? obj["api"]["color"] : Colors.Blue, - thumbnail: typeof image === "string" ? {url: image} : image ?? {url: db.images.no_image}, - footer: { - iconURL: user.avatarURL(), - text: `${user.displayName}` - }, - author: { - name: artist?.title, - url: artist?.url, - iconURL: db.images.disk - }, - fields: [ - { - name: locale._(queue.message.locale, "player.queue.push"), - value: obj instanceof Track ? - // Если один трек в списке - `\`\`\`[${obj.time.split}] - ${obj.name}\`\`\`` : + // Отправляем сообщение, о том что было добавлено в очередь + const msg = await queue.message.send({ + withResponse: true, + embeds: [{ + color: obj["api"] ? obj["api"]["color"] : Colors.Blue, + thumbnail: typeof image === "string" ? {url: image} : image ?? {url: db.images.no_image}, + footer: { + iconURL: user.avatarURL(), + text: `${user.displayName}` + }, + author: { + name: artist?.title, + url: artist?.url, + iconURL: db.images.disk + }, + fields: [ + { + name: locale._(queue.message.locale, "player.queue.push"), + value: obj instanceof Track ? + // Если один трек в списке + `\`\`\`[${obj.time.split}] - ${obj.name}\`\`\`` : - // Если добавляется список треков (альбом или плейлист) - `${obj.items.slice(0, 5).map((track, index) => { - return `\`${index + 1}\` ${track.name_replace}`; - }).join("\n")}${obj.items.length > 5 ? locale._(queue.message.locale, "player.queue.push.more", [obj.items.length - 5]) : ""} + // Если добавляется список треков (альбом или плейлист) + `${obj.items.slice(0, 5).map((track, index) => { + return `\`${index + 1}\` ${track.name_replace}`; + }).join("\n")}${obj.items.length > 5 ? locale._(queue.message.locale, "player.queue.push.more", [obj.items.length - 5]) : ""} ` - } - ] - }] - }); - - // Если есть ответ от отправленного сообщения - if (msg) setTimeout(() => msg.deletable ? msg.delete().catch(() => null) : null, 12e3); - } + } + ] + }] }); - }; + + // Если есть ответ от отправленного сообщения + if (msg) setTimeout(() => msg.deletable ? msg.delete().catch(() => null) : null, 12e3); + } } /** * @author SNIPPIK * @description Сообщение о том что сейчас играет * @class message_playing - * @extends Assign + * @extends Event * @event message/playing * @public */ -class message_playing extends Assign> { - public constructor() { - super({ - name: "message/playing", - type: "player", - once: false, - execute: (queue) => { - setImmediate(async () => { - // Отправляем сообщение - const message = await queue.message.send({ - components: queue.components, - withResponse: true, - flags: "IsComponentsV2" - }); - - // Если есть ответ от отправленного сообщения - if (message) { - // Добавляем новое сообщение в базу с сообщениями, для последующего обновления - if (!db.queues.cycles.messages.has(message)) { - // Добавляем сообщение в базу для обновления - db.queues.cycles.messages.add(message); - } - } - }); - } +@EventOn() +@DeclareEvent({ + name: "message/playing", + type: "player" +}) +class message_playing extends Event<"message/playing"> { + run: SupportEventCallback<"message/playing"> = async (queue) => { + const message = await db.queues.cycles.messages.ensure(queue.message.guild_id, () => { + return queue.message.send({ + components: queue.components, + withResponse: true, + flags: "IsComponentsV2" + }); }); - }; + + // Меняем статус голосового канала + db.adapter.status(queue.message.voice_id, `${db.images.disk_emoji} | ${queue.tracks.track.name}`); + + // Если есть сообщение + if (message) db.queues.cycles.messages.update(message, queue.components).catch(() => null); + } } /** diff --git a/src/handlers/events/player/requests.ts b/src/handlers/events/player/requests.ts index bdaaa245..0787609e 100644 --- a/src/handlers/events/player/requests.ts +++ b/src/handlers/events/player/requests.ts @@ -1,7 +1,7 @@ -import { Colors, CommandInteraction } from "#structures/discord"; -import { Logger, Assign, locale } from "#structures"; -import type { RestClientSide } from "#handler/rest"; -import type { Event } from "#handler/events"; +import { DeclareEvent, Event, EventOn, SupportEventCallback } from "#handler/events"; +import { Colors, type CommandInteraction } from "#structures/discord"; +import type { RestClientSide} from "#handler/rest"; +import { Logger, locale } from "#structures"; import type { Track } from "#core/queue"; import { db } from "#app/db"; @@ -9,49 +9,53 @@ import { db } from "#app/db"; * @author SNIPPIK * @description Выполнение запроса пользователя через внутреннее API * @class rest_request - * @extends Assign + * @extends Event * @event rest/request * @public */ -class rest_request extends Assign> { - public constructor() { - super({ - name: "rest/request", - type: "player", - once: false, - execute: async (platform, ctx, url) => { - // Получаем функцию запроса данных с платформы - const api = platform.request(url); +@EventOn() +@DeclareEvent({ + name: "rest/request", + type: "player" +}) +class rest_request extends Event<"rest/request"> { + run: SupportEventCallback<"rest/request"> = async (platform, ctx, url) => { + // Получаем функцию запроса данных с платформы + const api = platform.request(url); - // Проверка поддержки запроса - if (!api?.type) return db.events.emitter.emit("rest/error", ctx, locale._(ctx.locale, "api.platform.support")); + // Проверка поддержки запроса + if (!api?.type) return db.events.emitter.emit("rest/error", ctx, locale._(ctx.locale, "api.platform.support")); - // Предупреждение о запуске запроса - const message = await this._sendRequestMessage(ctx, platform, api.type); + // Предупреждение о запуске запроса + const message = await this._sendRequestMessage(ctx, platform, api.type); - let rest: Error | Track[] | Track.list | Track; - try { - rest = await Promise.race([api.request(), - new Promise((resolve) => { - setTimeout(() => resolve(new Error(locale._(ctx.locale, "api.platform.timeout"))), 15e3) - }) - ]); + let rest: Error | Track[] | Track.list | Track; + try { + rest = await Promise.race( + [ + // Делаем запрос к платформе + api.request(), - if (message) message(); - } catch (err) { - if (message) message(); - Logger.log("ERROR", err as Error); - return db.events.emitter.emit("rest/error", ctx,`**${platform.platform}.${api.type}**\n**❯** **${err}**`); - } + // Создаем обертку с таймером по достижению которого будет выдана ошибка вместо запроса + new Promise((resolve) => { + setTimeout(() => resolve(new Error(locale._(ctx.locale, "api.platform.timeout"))), 15e3) + }) + ] + ); - // Обработка результата - if (rest instanceof Error) return db.events.emitter.emit("rest/error", ctx, locale._(ctx.locale, "api.platform.error", [rest])); + if (message) message(); + } catch (err) { + if (message) message(); + Logger.log("ERROR", err as Error); + return db.events.emitter.emit("rest/error", ctx,`**${platform.platform}.${api.type}**\n**❯** **${err}**`); + } - // Добавление в очередь - return db.queues.create(ctx, rest); - } - }); - }; + // Обработка результата + if (rest instanceof Error) return db.events.emitter.emit("rest/error", ctx, locale._(ctx.locale, "api.platform.error", [rest])); + + // Добавление в очередь + return db.queues.create(ctx, rest); + } /** * @description Отправка сообщение о начале запроса @@ -90,35 +94,51 @@ class rest_request extends Assign> { * @author SNIPPIK * @description Если при выполнении запроса пользователя произошла ошибка * @class rest_error - * @extends Assign + * @extends Event * @event rest/error * @public */ -class rest_error extends Assign> { - public constructor() { - super({ - name: "rest/error", - type: "player", - once: false, - execute: async (message, error) => { - Logger.log("ERROR", `[Rest/API] ${error}`); - - const options = { - embeds: [{ - title: locale._(message.locale, "api.error"), - description: error, - color: Colors.DarkRed - }] - } +@EventOn() +@DeclareEvent({ + name: "rest/error", + type: "player" +}) +class rest_error extends Event<"rest/error"> { + run: SupportEventCallback<"rest/error"> = async (message, error) => { + try { + const msg = await message.channel.send({ + components: [{ + "type": 17, // Container + "accent_color": Colors.DarkRed, + components: [ + { + "type": 9, // Block + "components": [ + { + "type": 10, + "content": locale._(message.locale, "api.error") + }, + { + "type": 10, + "content": `\`\`\`css\n${error}\n\`\`\`` + } + ], + "accessory": { + "type": 11, + "media": { + "url": message.client.user.avatarURL() + } + } + }, + ] + }], + flags: "IsComponentsV2" + }); - try { - let msg = await message.channel.send(options); - setTimeout(() => msg.deletable ? msg.delete().catch(() => null) : null, 15e3); - } catch (err) { - console.error(err); - } - } - }); + if (msg && msg?.deletable) setTimeout(() => msg.delete().catch(() => null), 15e3); + } catch (error) { + Logger.log("ERROR", error as Error); + } }; } diff --git a/src/handlers/index.ts b/src/handlers/index.ts index e07ec9bf..321068bd 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -10,15 +10,12 @@ import fs from "node:fs"; * @public */ export abstract class handler { - /** - * @description Загруженные файлы, именно файлы не пути к файлам - * @readonly - * @private - */ - private readonly _files = new SetArray(); + /** Загруженные файлы, именно файлы не пути к файлам */ + private _files = new SetArray(); /** * @description Выдаем все загруженные файлы + * @returns SetArray * @protected */ protected get files() { @@ -27,6 +24,7 @@ export abstract class handler { /** * @description Кол-во загруженных элементов + * @returns number * @public */ public get size() { @@ -36,12 +34,14 @@ export abstract class handler { /** * @description Даем классу необходимые данные * @param directory - Имя директории + * @constructor * @protected */ - protected constructor(private readonly directory: string) {}; + protected constructor(private directory: string) {}; /** * @description Загружаем директории полностью, за исключением index файлов + * @returns void * @protected */ protected load = () => { @@ -63,6 +63,8 @@ export abstract class handler { /** * @description Поиск файлов загрузки * @param dirPath - Путь до директории + * @returns void + * @private */ private _loadRecursive = (dirPath: string) => { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); @@ -75,11 +77,8 @@ export abstract class handler { // Если это файл else if (entry.isFile()) { - // Если это не файл ts или js - if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".js")) continue; - - // Не загружаем index файлы (они являются загрузочными) - if (entry.name.startsWith("index")) continue; + // Если это не файл ts или js, не загружаем index файлы (они являются загрузочными) + if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".js") || entry.name.startsWith("index")) continue; this._push(fullPath); } @@ -89,6 +88,8 @@ export abstract class handler { /** * @description Добавляем загруженный файл в коллекцию файлов * @param path - Путь до файла + * @returns void + * @private */ private _push = (path: string) => { const imported: { default?: T } | json = require(path); @@ -107,6 +108,7 @@ export abstract class handler { if (obj.prototype) this._files.add(new obj(null)); else this._files.add(obj); } + return; } diff --git a/src/handlers/middlewares/global/cooldown.ts b/src/handlers/middlewares/global/cooldown.ts index 2b0cd6d7..91d197f4 100644 --- a/src/handlers/middlewares/global/cooldown.ts +++ b/src/handlers/middlewares/global/cooldown.ts @@ -9,7 +9,10 @@ import {db} from "#app/db"; * @description База данных для системы ожидания * @private */ -const cooldown = env.get("cooldown", true) ? { time: parseInt(env.get("cooldown.time", "2")), db: new Map() }: null; +const cooldown = env.get("cooldown", true) ? { + time: parseInt(env.get("cooldown.time", "2")), + db: new Map() +}: null; /** * @author SNIPPIK diff --git a/src/handlers/middlewares/index.ts b/src/handlers/middlewares/index.ts index 64de0f40..b3a9ba6d 100644 --- a/src/handlers/middlewares/index.ts +++ b/src/handlers/middlewares/index.ts @@ -1,5 +1,5 @@ -import { ButtonInteraction, AnySelectMenuInteraction } from "discord.js"; -import { CommandInteraction } from "#structures/discord"; +import type { CommandInteraction, SelectMenuInteract } from "#structures/discord"; +import type { ButtonInteraction } from "discord.js"; import { handler } from "#handler"; /** @@ -17,9 +17,10 @@ export type RegisteredMiddlewares = "voice" | "queue" | "another_voice" | "playe * @extends handler * @public */ -export class Middlewares> extends handler { +export class Middlewares> extends handler { /** * @description Производим поиск по функции + * @returns T[] * @public */ public get array() { @@ -28,6 +29,8 @@ export class Middlewares void * @public */ - public register = this.load + public register = this.load; /** * @description Производим фильтрацию по функции * @param predicate - Функция поиска + * @returns T[] * @public */ public filter(predicate: (item: T) => boolean) { diff --git a/src/handlers/middlewares/voice/VoiceChannel.ts b/src/handlers/middlewares/voice/VoiceChannel.ts index 2d7f95df..f7027e24 100644 --- a/src/handlers/middlewares/voice/VoiceChannel.ts +++ b/src/handlers/middlewares/voice/VoiceChannel.ts @@ -74,7 +74,7 @@ class OtherVoiceChannel extends Assign> { // Если нет пользователей в голосовом канале очереди if (users.size === 0) { queue.message = new QueueMessage(ctx); - queue.voice.connection.swapChannel = VoiceChannel.id; + queue.voice.connection.channel = VoiceChannel.id; // Сообщаем о подключении к другому каналу ctx.channel.send({ diff --git a/src/handlers/rest/default/deezer.ts b/src/handlers/rest/default/deezer.ts index 292472b6..7bd2c09b 100644 --- a/src/handlers/rest/default/deezer.ts +++ b/src/handlers/rest/default/deezer.ts @@ -1,6 +1,17 @@ import { DeclareRest, OptionsRest, RestServerSide } from "#handler/rest"; import { httpsClient, locale } from "#structures"; +/** + * @author SNIPPIK + * @description Взаимодействие с платформой Deezer, динамический плагин + * # Types + * - Playlist - Любой открытый плейлист. + * - Artist - Популярные треки автора с учетом лимита + * - Search - Поиск треков, пока не доступны плейлисты, альбомы, авторы + * @Specification Rest Deezer API + * @Audio Не доступно нативное получение + */ + /** * @author SNIPPIK * @description Динамически загружаемый класс @@ -30,33 +41,32 @@ class RestDeezerAPI extends RestServerSide.API { { name: "album", filter: /(album)\/[0-9]+/i, - execute: (url, {limit}) => { - const ID = /[0-9]+/i.exec(url)?.at(0)?.split("album")?.at(0); - - return new Promise(async (resolve) => { - // Если ID альбома не удалось извлечь из ссылки - if (!ID) return resolve(locale.err( "api.request.id.album")); - - try { - // Создаем запрос - const api = await this.API(`album/${ID}`); - - // Если запрос выдал ошибку то - if (api instanceof Error) return resolve(api); - - const tracks = api.tracks.data.splice(0, limit); - const songs = tracks.map(this.track); - - return resolve({ - url, - title: api.title, - items: songs, - image: api.cover_xl - }); - } catch (e) { - return resolve(Error(`[APIs]: ${e}`)) - } - }); + execute: async (url, {limit}) => { + const ID = this.getID(/[0-9]+/i, url)?.split("album")?.at(0); + + // Если ID альбома не удалось извлечь из ссылки + if (!ID) return locale.err( "api.request.id.album"); + + try { + // Создаем запрос + const api = await this.API(`album/${ID}`); + + // Если запрос выдал ошибку то + if (api instanceof Error) return api; + + const tracks = api.tracks.data.splice(0, limit); + const songs = tracks.map(this.track); + + return { + id: ID, + url, + title: api.title, + items: songs, + image: api.cover_xl + }; + } catch (e) { + return Error(`[APIs]: ${e}`); + } } }, @@ -67,37 +77,35 @@ class RestDeezerAPI extends RestServerSide.API { { name: "playlist", filter: /(playlist)\/[0-9]+/i, - execute: (url, {limit}) => { - const ID = /[0-9]+/i.exec(url).pop(); - - return new Promise(async (resolve) => { - if (!ID) return resolve(locale.err("api.request.id.playlist")); - - try { - // Создаем запрос - const api = await this.API(`playlist/${ID}`); - - // Если запрос выдал ошибку то - if (api instanceof Error) return resolve(api); - else if (api?.tracks?.data?.length === 0) return resolve(locale.err("api.request.fail.msg", ["Not found tracks in playlist"])); - - const tracks: any[] = api.tracks.data?.splice(0, limit); - const songs = tracks.map(this.track); - - return resolve({ - url, - title: api.title, - image: api.picture_xl, - items: songs, - artist: { - title: api.creator.name, - url: `https://${this.url}/${api.creator.type === "user" ? "profile" : "artist"}/${api.creator.id}` - } - }); - } catch (e) { - return resolve(Error(`[APIs]: ${e}`)) - } - }); + execute: async (url, {limit}) => { + const ID = this.getID(/[0-9]+/i, url); + + if (!ID) return locale.err("api.request.id.playlist"); + + try { + // Создаем запрос + const api = await this.API(`playlist/${ID}`); + + // Если запрос выдал ошибку то + if (api instanceof Error) return api; + else if (api?.tracks?.data?.length === 0) return locale.err("api.request.fail.msg", ["Not found tracks in playlist"]); + + const tracks: any[] = api.tracks.data?.splice(0, limit); + const songs = tracks.map(this.track); + + return { + url, + title: api.title, + image: api.picture_xl, + items: songs, + artist: { + title: api.creator.name, + url: `https://${this.url}/${api.creator.type === "user" ? "profile" : "artist"}/${api.creator.id}` + } + }; + } catch (e) { + return Error(`[APIs]: ${e}`); + } } }, @@ -108,26 +116,22 @@ class RestDeezerAPI extends RestServerSide.API { { name: "artist", filter: /(artist)\/[0-9]+/i, - execute: (url, {limit}) => { - const ID = /(artist)\/[0-9]+/i.exec(url)?.at(0)?.split("artist")?.at(0); - - return new Promise(async (resolve) => { - // Если ID автора не удалось извлечь из ссылки - if (!ID) return resolve(locale.err("api.request.id.author")); - - try { - // Создаем запрос - const api = await this.API(`artist/${ID}/top`); - - // Если запрос выдал ошибку то - if (api instanceof Error) return resolve(api); - const tracks = api.data.splice(0, limit).map(this.track); - - return resolve(tracks); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) - } - }); + execute: async (url, {limit}) => { + const ID = this.getID(/(artist)\/[0-9]+/i, url)?.split("artist")?.at(0); + + // Если ID автора не удалось извлечь из ссылки + if (!ID) return locale.err("api.request.id.author"); + + try { + // Создаем запрос + const api = await this.API(`artist/${ID}/top`); + + // Если запрос выдал ошибку то + if (api instanceof Error) return api; + return api.data.splice(0, limit).map(this.track); + } catch (e) { + return new Error(`[APIs]: ${e}`); + } } }, @@ -137,22 +141,19 @@ class RestDeezerAPI extends RestServerSide.API { */ { name: "search", - execute: (query , {limit}) => { - return new Promise(async (resolve) => { - try { - // Создаем запрос - const api = await this.API(`search?q=${encodeURIComponent(query)}`); - - // Обрабатываем ошибки - if (api instanceof Error) return resolve(api); - else if (!api.data) return resolve([]); - - const tracks = api.data.splice(0, limit).map(this.track); - return resolve(tracks); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) - } - }); + execute: async (query , {limit}) => { + try { + // Создаем запрос + const api = await this.API(`search?q=${encodeURIComponent(query)}`); + + // Обрабатываем ошибки + if (api instanceof Error) return api; + else if (!api.data) return []; + + return api.data.splice(0, limit).map(this.track); + } catch (e) { + return new Error(`[APIs]: ${e}`); + } } } ]; @@ -161,7 +162,6 @@ class RestDeezerAPI extends RestServerSide.API { * @description Делаем запрос на {data.api}/methods * @param method - Метод запроса из api * @protected - * @static */ protected API = (method: string): Promise => { return new Promise((resolve) => { @@ -182,7 +182,6 @@ class RestDeezerAPI extends RestServerSide.API { * @description Из полученных данных подготавливаем трек для Audio * @param track - Данные трека * @protected - * @static */ protected track = (track: any) => { const author = track["artist"]?.length ? track["artist"]?.pop() : track["artist"]; diff --git a/src/handlers/rest/default/soundcloud.ts b/src/handlers/rest/default/soundcloud.ts index dc0cc888..c373c82c 100644 --- a/src/handlers/rest/default/soundcloud.ts +++ b/src/handlers/rest/default/soundcloud.ts @@ -1,6 +1,17 @@ import { DeclareRest, OptionsRest, RestServerSide } from "#handler/rest"; import { httpsClient, locale } from "#structures"; +/** + * @author SNIPPIK + * @description Взаимодействие с платформой SoundCloud, динамический плагин + * # Types + * - Track - Любой трек с платформы. Не получится получить платные видео или 18+ + * - Playlist - Любой открытый плейлист. + * - Search - Поиск треков, пока не доступны плейлисты, альбомы, авторы + * @Specification Rest SC API + * @Audio Доступно нативное получение + */ + /** * @author SNIPPIK * @description Динамически загружаемый класс @@ -39,90 +50,89 @@ class RestSoundCloudAPI extends RestServerSide.API { /** * @description Запрос данных о треке * @type "track" + * @private */ { name: "track", filter: /^https?:\/\/soundcloud\.com\/([\w-]+)\/?([\w-]+)(?:\?.*)?$/i, - execute: (url, options) => { + execute: async (url, options) => { const fixed = url.split("?")[0]; - return new Promise(async (resolve) => { - try { - // Создаем запрос - const request = await this.API(`resolve?url=${fixed}`); + try { + // Создаем запрос + const request = await this.API(`resolve?url=${fixed}`); - // Если запрос выдал ошибку то - if (request instanceof Error) return resolve(request); + // Если запрос выдал ошибку то + if (request instanceof Error) return request; - const {api, ClientID} = request; + const {api, ClientID} = request; - // Если был найден трек - if (api.kind === "track") { - const track = this.track(api); + // Если был найден трек + if (api.kind === "track") { + const track = this.track(api); - // Если указано получение аудио - if (options.audio) { - if (api.media.transcodings) { - // Расшифровываем аудио формат - track.audio = await this.getFormat(api.media.transcodings, ClientID); - } + // Если указано получение аудио + if (options.audio) { + if (api.media.transcodings) { + // Расшифровываем аудио формат + track.audio = await this.getFormat(api.media.transcodings, ClientID); } - - return resolve(track); } - return resolve(null); - } catch (e) { - return resolve(new Error(`[APIs/track]: ${e}`)) + + return track; } - }); + + return null; + } catch (e) { + return new Error(`[APIs/track]: ${e}`); + } } }, /** * @description Запрос данных о треке * @type "playlist" + * @private */ { name: "playlist", filter: /sets\/[a-zA-Z0-9]+/i, - execute: (url, {limit}) => { + execute: async (url, {limit}) => { const fixed = url.split("?")[0]; - return new Promise(async (resolve) => { - try { - // Создаем запрос - const request = await this.API(`resolve?url=${fixed}`); - - // Если запрос выдал ошибку то - if (request instanceof Error) return resolve(request); - - const { api } = request; - - // Если был найден плейлист - if (api.kind === "playlist") { - // Если SoundCloud нас обманул со ссылкой, есть нет .tracks, то это просто трек! - if (!api.tracks) return resolve(null); - - // Все доступные треки в плейлисте - const items = api.tracks.filter((i) => i["permalink_url"]).splice(0, limit).map(this.track); - - return resolve({ - url, - title: api.title, - artist: { - url: api.user.permalink_url, - title: api.user.username, - image: api.user.avatar_url, - }, - image: api.artwork_url, - items - }); - } - return resolve(null); - } catch (e) { - return resolve(new Error(`[APIs/playlist]: ${e}`)) + try { + // Создаем запрос + const request = await this.API(`resolve?url=${fixed}`); + + // Если запрос выдал ошибку то + if (request instanceof Error) return request; + + const { api } = request; + + // Если был найден плейлист + if (api.kind === "playlist") { + // Если SoundCloud нас обманул со ссылкой, есть нет .tracks, то это просто трек! + if (!api.tracks) return null; + + // Все доступные треки в плейлисте + const items = api.tracks.filter((i) => i["permalink_url"]).splice(0, limit).map(this.track); + + return { + url, + title: api.title, + artist: { + url: api.user.permalink_url, + title: api.user.username, + image: api.user.avatar_url, + }, + image: api.artwork_url, + items + }; } - }); + return null; + } catch (e) { + return new Error(`[APIs/playlist]: ${e}`); + } } }, @@ -132,23 +142,20 @@ class RestSoundCloudAPI extends RestServerSide.API { */ { name: "search", - execute: (query, options) => { - return new Promise(async (resolve) => { - try { - // Создаем запрос - const request = await this.API(`search/tracks?q=${encodeURIComponent(query)}&limit=${options.limit}`); + execute: async (query, options) => { + try { + // Создаем запрос + const request = await this.API(`search/tracks?q=${encodeURIComponent(query)}&limit=${options.limit}`); - // Если запрос выдал ошибку то - if (request instanceof Error) return resolve(request); + // Если запрос выдал ошибку то + if (request instanceof Error) return request; - const {api} = request; + const { api } = request; - const tracks = api.collection.filter((i) => i.user).map(this.track); - return resolve(tracks); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) - } - }); + return api.collection.filter((i) => i.user).map(this.track); + } catch (e) { + return new Error(`[APIs]: ${e}`); + } } } ]; @@ -157,7 +164,6 @@ class RestSoundCloudAPI extends RestServerSide.API { * @description Получаем страницу и ищем на ней данные * @param url - Ссылка на видео или ID видео * @protected - * @static */ protected API = (url: string): Promise<{api: json, ClientID: string} | Error> => { return new Promise(async (resolve) => { @@ -165,7 +171,7 @@ class RestSoundCloudAPI extends RestServerSide.API { // Если client_id не был получен if (ClientID instanceof Error) return resolve(ClientID); - else if (!ClientID) return resolve(locale.err("api.request.fail")); + else if (!ClientID) return resolve(Error("[API] Fail getting client ID")); const result = await new httpsClient({ url: `${this.options.api}/${url}&client_id=${ClientID}`, @@ -191,7 +197,7 @@ class RestSoundCloudAPI extends RestServerSide.API { try { const parsedPage = await new httpsClient({ - url: "https://soundcloud.com/", + url: `https://${this.url}/`, userAgent: true, headers: { "accept-language": "en-US,en;q=0.9,en-US;q=0.8,en;q=0.7", @@ -222,11 +228,11 @@ class RestSoundCloudAPI extends RestServerSide.API { } }; - /** * @description Проходим все этапы для получения ссылки на поток трека * @param formats - Зашифрованные форматы аудио * @param ClientID - ID клиента + * @protected */ protected getFormat = (formats: any[], ClientID: string): Promise => { return new Promise(async (resolve) => { @@ -241,7 +247,6 @@ class RestSoundCloudAPI extends RestServerSide.API { * @description Подготавливаем трек к отправке * @param track - Данные видео * @protected - * @static */ protected track = (track: json) => { return { diff --git a/src/handlers/rest/default/youtube.ts b/src/handlers/rest/default/youtube.ts index a7eff5da..e3d402e3 100644 --- a/src/handlers/rest/default/youtube.ts +++ b/src/handlers/rest/default/youtube.ts @@ -1,8 +1,111 @@ -import { httpsClient, locale, SimpleWorker } from "#structures"; -import { DeclareRest, RestServerSide } from "#handler/rest"; -import { Track } from "#core/queue"; -import { db } from "#app/db"; -import fs from "node:fs"; +import { DeclareRest, OptionsRest, RestServerSide } from "#handler/rest"; +import { httpsClient, locale } from "#structures"; +import type { Track } from "#core/queue"; +import { sdb } from "#worker/db"; + +/** + * @author SNIPPIK + * @description Взаимодействие с платформой YouTube, динамический плагин + * # Types + * - Video - Любое видео с платформы. Не получится получить спонсорские видео или 18+ + * - Playlist - Любой открытый плейлист. + * - Artist - Последние видео автора с учетом лимита + * - Related - Похожее треки, работает через алгоритмы youtube + * - Search - Поиск видео, пока не доступны плейлисты, альбомы, авторы + * @Specification Rest YT API + * @Audio Доступно нативное получение + */ + +/** + * @author SNIPPIK + * @description Допустимые символы, буквы, цифры для работы с youtube на более похожем уровне api + * @const CPN_CHARS + * @private + */ +const CPN_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +/** + * @author SNIPPIK + * @description Все допустимые заголовки + * @const Clients + * @private + */ +const Clients = { + /** + * @description Запрос страницы, требуется указывать время для правильного запроса + * @audio true - without sig + */ + "ANDROID": { + request: { + cpn: generateClientPlaybackNonce(16), + context: { + client: { + clientName: "ANDROID", + clientVersion: "19.35.36", + platform: "MOBILE", + osName: "Android", + osVersion: "13", + androidSdkVersion: "33", + hl: "en", + gl: "US", + utcOffsetMinutes: -240, + }, + request: { + internalExperimentFlags: [], + useSsl: true, + }, + user: { + lockedSafetyMode: false, + }, + "contentPlaybackContext": { + "html5Preference": "HTML5_PREF_WANTS" + } + }, + contentCheckOk: true, + racyCheckOk: true + }, + headers: { + "Content-Type": "application/json", + "User-Agent": `com.google.android.youtube/19.35.36(Linux; U; Android 13; en_US; SM-S908E Build/TP1A.220624.014) gzip`, + "X-Goog-Api-Format-Version": "2" + } + }, + + /** + * @description Запрос страницы, требуется указывать время для правильного запроса + * @audio true + */ + "WEB": { + request: { + "context": { + "client": { + "hl": "en", + "gl": "US", + "clientName": "WEB", + "clientVersion": "2.20250927.00.00" + } + } + } + }, + + /** + * @description Запрос страницы урезанной + * @audio false + */ + "WEB_EMBEDDED": { + request: { + context: { + client: { + clientName: "WEB_EMBEDDED_PLAYER", + clientVersion: "1.20240723.01.00", + hl: "en", + timeZone: "UTC", + utcOffsetMinutes: 0 + } + } + } + } +}; /** * @author SNIPPIK @@ -17,291 +120,370 @@ import fs from "node:fs"; audio: true, color: 16711680 }) +@OptionsRest({ + AIzaKey: generateFakeApiKey() +}) class RestYouTubeAPI extends RestServerSide.API { readonly requests: RestServerSide.API["requests"] = [ /** * @description Запрос данных об плейлисте * @type "playlist" + * @private */ { name: "playlist", filter: /playlist\?list=[a-zA-Z0-9-_]+/i, - execute: (url, { limit }) => { - const ID = url.match(/playlist\?list=[a-zA-Z0-9-_]+/i).pop(); + execute: async (url, { limit }) => { + const ID = this.getID(/playlist\?list=[a-zA-Z0-9-_]+/i, url); let artist = null; - return new Promise(async (resolve) => { - try { - // Если ID плейлиста не удалось извлечь из ссылки - if (!ID) return resolve(locale.err("api.request.id.playlist")); - - const api = await RestYouTubeAPI.API(`https://www.youtube.com/${ID}`) + try { + // Если ID плейлиста не удалось извлечь из ссылки + if (!ID) return locale.err("api.request.id.playlist"); - // Если при запросе была получена ошибка - if (api instanceof Error) return resolve(api); + const api = await this.pAPI(ID); - // Данные о плейлисте - const playlist = api["microformat"]["microformatDataRenderer"]; + // Если при запросе была получена ошибка + if (api instanceof Error) return api; - // Необработанные видео - const videos: any[] = api["contents"]["twoColumnBrowseResultsRenderer"]["tabs"][0]["tabRenderer"] - .content["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["playlistVideoListRenderer"]["contents"]; + // Данные о плейлисте + const playlist = api["microformat"]["microformatDataRenderer"]; - // Все доступные видео в плейлисте - const items = videos.splice(0, limit).map(({playlistVideoRenderer}) => RestYouTubeAPI.track(playlistVideoRenderer)); + // Необработанные видео + const videos: any[] = api["contents"]["twoColumnBrowseResultsRenderer"]["tabs"][0]["tabRenderer"] + .content["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["playlistVideoListRenderer"]["contents"]; - // Раздел с данными автора - const author = api["sidebar"]["playlistSidebarRenderer"]["items"]; + // Все доступные видео в плейлисте + const items = videos.splice(0, limit).map(({playlistVideoRenderer}) => this.track(playlistVideoRenderer)); - // Если авторов в плейлисте больше 1 - if (author.length > 1) { - const authorData = author[1]["playlistSidebarSecondaryInfoRenderer"]["videoOwner"]["videoOwnerRenderer"]; + // Раздел с данными автора + const author = api["sidebar"]["playlistSidebarRenderer"]["items"]; - // Получаем истинные данные об авторе плейлиста - artist = await RestYouTubeAPI.getChannel({ - id: authorData["navigationEndpoint"]["browseEndpoint"]["browseId"], - name: authorData.title["runs"][0].text - }); - } + // Если авторов в плейлисте больше 1 + if (author.length > 1) { + const authorData = author[1]["playlistSidebarSecondaryInfoRenderer"]["videoOwner"]["videoOwnerRenderer"]; - return resolve({ - url, items, - title: playlist.title, - image: playlist.thumbnail["thumbnails"].pop(), - artist: artist ?? items.at(-1).artist + // Получаем истинные данные об авторе плейлиста + artist = await this.restArtist({ + id: authorData["navigationEndpoint"]["browseEndpoint"]["browseId"], + name: authorData.title["runs"][0].text }); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) } - }); + + return { + url, items, + title: playlist.title ?? "Related videos", + image: playlist.thumbnail["thumbnails"].pop(), + artist: artist ?? items.at(-1).artist + }; + } catch (e) { + console.error(e); + return new Error(`[APIs]: ${e}`); + } } }, /** * @description Запрос треков из волны, для выполнения требуется указать list=RD в ссылке * @type "related" + * @private */ { name: "related", filter: /(watch|embed|youtu\.be|v\/)?([a-zA-Z0-9-_]{11})?(list=RD)/, - execute: (url) => { - return new Promise(async (resolve) => { - const ID = (/(watch|embed|youtu\.be|v\/)?([a-zA-Z0-9-_]{11})/).exec(url)[0]; + execute: async (url) => { + const ID = this.getID(/(watch|embed|youtu\.be|v\/)?([a-zA-Z0-9-_]{11})/, url); - try { - const api = await RestYouTubeAPI.API(`https://www.youtube.com/watch?v=${ID}&hl=en&has_verified=1`); - if (api instanceof Error) return api; - - const related = api.contents?.twoColumnWatchNextResults?.secondaryResults?.secondaryResults?.results ?? []; - const relatedVideos = []; + try { + const api = await this.pAPI(`watch?v=${ID}&hl=en&has_verified=1`); + if (api instanceof Error) return api; - // Подготавливаем данные треков (video) - for (const item of related) { - const render = item.compactVideoRenderer || item.lockupViewModel; + const related = api.contents?.twoColumnWatchNextResults?.secondaryResults?.secondaryResults?.results ?? []; + const relatedVideos = []; - // Если есть недопустимые типы контента - if (!render?.contentType || render?.contentType !== "LOCKUP_CONTENT_TYPE_VIDEO") continue; + // Подготавливаем данные треков (video) + for (const item of related) { + const render = item.compactVideoRenderer || item.lockupViewModel; - const title = render?.rendererContext.accessibilityContext?.label ?? render?.metadata?.lockupMetadataViewModel.title.content; - const duration = (title as string).duration(); + // Если есть недопустимые типы контента + if (!render?.contentType || render?.contentType !== "LOCKUP_CONTENT_TYPE_VIDEO") continue; - // Если время слишком много - if (duration > 800 && !title.match(/album|ALBUM|Album/)) continue; + const title = render?.rendererContext.accessibilityContext?.label ?? render?.metadata?.lockupMetadataViewModel.title.content; + const duration = (title as string).duration(); - relatedVideos.push(RestYouTubeAPI.track({ - videoId: render.contentId, - title: render?.metadata?.lockupMetadataViewModel.title.content, - channelId: "null", - lengthSeconds: duration.duration(), - author: render?.metadata?.lockupMetadataViewModel.metadata?.contentMetadataViewModel.metadataRows[0].metadataParts[0].text.content.split(",")[0], - format: { audio: null } - })); - } + // Если время слишком много + if (duration > 800 && !title.match(/album|ALBUM|Album/)) continue; - return resolve({ - url, - items: relatedVideos, - title: null, - image: null, - artist: null, - }); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)); + relatedVideos.push(this.track({ + videoId: render.contentId, + title: render?.metadata?.lockupMetadataViewModel.title.content, + channelId: "null", + lengthSeconds: duration.duration(), + author: render?.metadata?.lockupMetadataViewModel.metadata?.contentMetadataViewModel.metadataRows[0].metadataParts[0].text.content.split(",")[0], + format: {audio: null} + })); } - }); + + return { + url, + items: relatedVideos, + title: null, + image: null, + artist: null, + }; + } catch (e) { + console.error(e); + return new Error(`[APIs]: ${e}`); + } } }, /** * @description Запрос данных о треке * @type "track" + * @private */ { name: "track", filter: /(watch|embed|youtu\.be|v\/)?([a-zA-Z0-9-_]{11})/, - execute: (url: string, options) => { - const ID = (/(watch|embed|youtu\.be|v\/)?([a-zA-Z0-9-_]{11})/).exec(url)[0]; + execute: async (url, options) => { + const ID = this.getID(/(watch|embed|youtu\.be|v\/)?([a-zA-Z0-9-_]{11})/, url); - return new Promise(async (resolve) => { - try { - // Если ID видео не удалось извлечь из ссылки - if (!ID) return resolve(locale.err("api.request.id.track")); + try { + // Если ID видео не удалось извлечь из ссылки + if (!ID) return locale.err("api.request.id.track"); - const cache = db.cache.get(`${this.url}/${ID}`); + const cache = sdb.meta_saver.get(`${this.url}/${ID}`); - // Если трек есть в кеше - if (cache) { - if (!options.audio) return resolve(cache); + // Если трек есть в кеше + if (cache) { + if (!options.audio) return cache; - // Если включена утилита кеширования аудио - else if (db.cache.audio) { - const check = db.cache.audio.status(`${this.url}/${ID}`); + // Если включена утилита кеширования аудио + else if (sdb.audio_saver) { + const check = sdb.audio_saver.status(`${this.url}/${ID}`); - // Если есть кеш аудио - if (check.status === "ended") { - cache.audio = check.path; - return resolve(cache); - } + // Если есть кеш аудио + if (check.status === "ended") { + cache.audio = check.path; + return cache; } } + } - const api = await RestYouTubeAPI.API(`https://www.youtube.com/watch?v=${ID}&hl=en&has_verified=1`); - - // Если при получении данных возникла ошибка - if (api instanceof Error) return resolve(api); - - // Класс трека - const track = RestYouTubeAPI.track(api["videoDetails"]); - - setImmediate(() => { - // Сохраняем кеш в системе - if (!cache) db.cache.set(track, this.url); - }); + let api = await this.API(ID, options.audio); - // Если указано получение аудио - if (options.audio) { - // Если включена утилита кеширования - if (db.cache.audio) { - const check = db.cache.audio.status(`${this.url}/${ID}`); - - // Если есть кеш аудио - if (check.status === "ended") { - track.audio = check.path; - return resolve(track); - } - } + // Если при получении данных возникла ошибка + if (api instanceof Error || api["playabilityStatus"]["status"] !== "OK") { + // Пробуем получить страницу нативно без API + api = await this.pAPI(`watch?v=${ID}`); - const data = api["streamingData"]; + // Если все равно возникает ошибка + if (api instanceof Error) return api; + } - // dashManifestUrl, hlsManifestUrl - if (data["hlsManifestUrl"]) track.audio = data["hlsManifestUrl"]; - else { - // Если нет форматов - if (!data["formats"]) return resolve(locale.err("api.request.audio.fail", [this.name])); + // Класс трека + const track = this.track(api["videoDetails"]); - // Расшифровываем аудио формат - const format = await RestYouTubeAPI.extractFormat(data, api.html, url); + // Если указано получение аудио + if (options.audio) { + // Если включена утилита кеширования + if (sdb.audio_saver) { + const check = sdb.audio_saver.status(`${this.url}/${ID}`); - // Если есть расшифровка ссылки видео - if (format) track.audio = format["url"]; + // Если есть кеш аудио + if (check.status === "ended") { + track.audio = check.path; + return track; } } - return resolve(track); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) + const data = api["streamingData"]; + + // dashManifestUrl, hlsManifestUrl + if (data["hlsManifestUrl"]) track.audio = data["hlsManifestUrl"]; + else { + // Если есть расшифровка ссылки видео + if (data["formats"]) track.audio = data["formats"][0]["url"]; + } } - }); + + if (!cache && !api?.["videoDetails"]?.["isLive"]) setImmediate(() => { + // Сохраняем кеш в системе + sdb.meta_saver?.set(track, this.url); + }); + + return track; + } catch (e) { + console.error(e); + return new Error(`[APIs]: ${e}`); + } } }, /** * @description Запрос данных треков артиста * @type "artist" + * @private */ { name: "artist", filter: /\/(channel)?(@)/i, - execute: (url: string, {limit}) => { - return new Promise(async (resolve) => { - try { - let ID: string; - - // Получаем истинное id канала - if (url.match(/@/)) ID = `@${url.split("@")[1].split("/")[0]}`; - else ID = `channel/${url.split("channel/")[1]}`; + execute: async (url, {limit}) => { + const ID = this.getID(/^(?:@([^\/]+)|([a-zA-Z0-9_-]+))\/?/, url); - // Создаем запрос - const details = await RestYouTubeAPI.API(`https://www.youtube.com/${ID}/videos`); + try { + // Если ID автора не удалось извлечь из ссылки + if (!ID) return locale.err("api.request.id.author"); - if (details instanceof Error) return resolve(details); + // Создаем запрос + const details = await this.pAPI(`${ID}/videos`); - const author = details["microformat"]["microformatDataRenderer"]; - const tabs: any[] = details?.["contents"]?.["twoColumnBrowseResultsRenderer"]?.["tabs"]; - const contents = (tabs[1] ?? tabs[2])["tabRenderer"]?.content?.["richGridRenderer"]?.["contents"] - ?.filter((video: any) => video?.["richItemRenderer"]?.content?.["videoRenderer"])?.splice(0, limit); + if (details instanceof Error) return details; - // Модифицируем видео - const videos = contents.map(({richItemRenderer}: any) => { - const video = richItemRenderer?.content?.["videoRenderer"]; + const author = details["microformat"]["microformatDataRenderer"]; + const tabs: any[] = details?.["contents"]?.["twoColumnBrowseResultsRenderer"]?.["tabs"]; + const contents = (tabs[1] ?? tabs[2])["tabRenderer"]?.content?.["richGridRenderer"]?.["contents"] + ?.filter((video: any) => video?.["richItemRenderer"]?.content?.["videoRenderer"])?.splice(0, limit); - return { - url: `https://youtu.be/${video["videoId"]}`, title: video.title["runs"][0].text, duration: { full: video["lengthText"]["simpleText"] }, - author: { url: `https://www.youtube.com${ID}`, title: author.title } - } - }); + // Модифицируем видео + return contents.map(({richItemRenderer}: any) => { + const video = richItemRenderer?.content?.["videoRenderer"]; - return resolve(videos); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) - } - }); + return { + url: `https://youtu.be/${video["videoId"]}`, + title: video.title["runs"][0].text, + duration: {full: video["lengthText"]["simpleText"]}, + author: {url: `https://${this.url}/${ID}`, title: author.title} + } + }); + } catch (e) { + console.error(e); + return new Error(`[APIs]: ${e}`); + } }, }, /** * @description Запрос данных по поиску * @type "search" + * @private */ { name: "search", - execute: (query: string, {limit}) => { - return new Promise(async (resolve) => { - try { - // Создаем запрос - const details = await RestYouTubeAPI.API(`https://www.youtube.com/results?search_query=${encodeURIComponent(query)}&sp=QgIIAQ%3D%3D`); + execute: async (query: string, {limit}) => { + try { + // Создаем запрос + const details = await this.pAPI(`results?search_query=${encodeURIComponent(query)}&sp=QgIIAQ%3D%3D`); - // Если при получении данных возникла ошибка - if (details instanceof Error) return resolve(details); + // Если при получении данных возникла ошибка + if (details instanceof Error) return details; - // Найденные видео - const vanilla_videos = details["contents"]?.["twoColumnSearchResultsRenderer"]?.["primaryContents"]?.["sectionListRenderer"]?.["contents"][0]?.["itemSectionRenderer"]?.["contents"]; + // Найденные видео + const vanilla_videos = details["contents"]?.["twoColumnSearchResultsRenderer"]?.["primaryContents"]?.["sectionListRenderer"]?.["contents"][0]?.["itemSectionRenderer"]?.["contents"]; - // Проверяем на наличие видео - if (vanilla_videos?.length === 0 || !vanilla_videos) return resolve(locale.err("api.request.fail")); + // Проверяем на наличие видео + if (vanilla_videos?.length === 0 || !vanilla_videos) return locale.err("api.request.fail"); - const filtered_ = vanilla_videos?.filter((video: json) => video && video?.["videoRenderer"])?.splice(0, limit); - const videos: Track.data[] = filtered_.map(({ videoRenderer }: json) => RestYouTubeAPI.track(videoRenderer)); + const filtered_ = vanilla_videos?.filter((video: json) => video && video?.["videoRenderer"])?.splice(0, limit); + const videos: Track.data[] = filtered_.map(({ videoRenderer }: json) => this.track(videoRenderer)); - return resolve(videos); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) - } - }); + return videos; + } catch (e) { + console.error(e); + return new Error(`[APIs]: ${e}`); + } } } ]; /** - * @description Получаем страницу и ищем на ней данные - * @param url - Ссылка на видео или ID видео + * @description Получаем страницу с данными + * @param ID - ID видео + * @param audio - нужно ли получить аудио * @protected - * @static */ - protected static API = (url: string): Promise => { + protected API = (ID: string, audio: boolean): Promise => { + const client = audio ? Clients.ANDROID : Clients.WEB_EMBEDDED; + return new Promise((resolve) => { new httpsClient({ - url, + method: "POST", + url: `https://www.youtube.com/youtubei/v1/player?key=${this.options.AIzaKey}`, + headers: { + "Content-Type": "application/json", + ...client["headers"] ? client["headers"] : {} + }, + body: JSON.stringify({ + ...client.request, + "videoId": ID + }) + }) + // Получаем исходную страницу + .toJson + + // Получаем результат из Promise + .then((api) => { + // Если возникает ошибка при получении страницы + if (api instanceof Error) return resolve(locale.err("api.request.fail")); + + // Если указано аудио, но его нет! + else if (audio && !api["streamingData"]?.["formats"]) return resolve(locale.err("api.request.fail")); + + // Отдаем данные + return resolve(api); + }) + + // Если происходит ошибка + .catch((err) => resolve(Error(`[APIs]: ${err}`))); + }); + }; + + /** + * @description Подготавливаем трек к отправке + * @param track - Данные видео + * @protected + */ + protected track = (track: json) => { + const title = track.title?.simpleText ?? track.title?.["runs"]?.[0]?.text ?? track.title; + const author = track["shortBylineText"]?.["runs"]?.[0]?.text ?? track.author; + const id = track?.["videoId"] ?? track?.["inlinePlaybackEndpoint"]?.["watchEndpoint"]?.["videoId"] ?? track.contentId; + + try { + return { title, id, + url: `https://youtu.be/${id}`, + artist: { + title: author, + url: `https://www.youtube.com${track["shortBylineText"]["runs"][0]["navigationEndpoint"]["browseEndpoint"]["canonicalBaseUrl"] || track["shortBylineText"]["runs"][0]["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"].url}`, + }, + time: { total: track["lengthSeconds"] ?? track["lengthText"]?.["simpleText"] ?? 0 }, + image: `https://i.ytimg.com/vi/${id}/maxresdefault.jpg`, + audio: track?.format?.url || undefined + }; + } catch { + return { title, id, + artist: { + title: author, + url: `https://www.youtube.com/channel/${track.channelId}` + }, + url: `https://youtu.be/${id}`, + time: { + total: track["lengthSeconds"] ?? track["lengthText"]?.["simpleText"] ?? 0 + }, + image: `https://i.ytimg.com/vi/${id}/maxresdefault.jpg`, + audio: track?.format?.url || undefined + } + } + }; + + /** + * @description Получаем страницу и ищем на ней данные + * @param method - Ссылка на видео или ID видео + * @protected + */ + protected pAPI = (method: string): Promise => { + return new Promise((resolve) => { + new httpsClient({ url: `https://${this.url}/${method}`, userAgent: true, headers: { "accept-language": "en-US,en;q=0.9,en-US;q=0.8,en;q=0.7", @@ -317,7 +499,7 @@ class RestYouTubeAPI extends RestServerSide.API { if (api instanceof Error) return resolve(locale.err("api.request.fail")); // Ищем данные на странице - const data = RestYouTubeAPI.extractInitialDataResponse(api); + const data = this._extractResponse(api); // Если возникает ошибка при поиске на странице if (data instanceof Error) return resolve(data); @@ -331,59 +513,47 @@ class RestYouTubeAPI extends RestServerSide.API { }; /** - * @description Получаем аудио дорожки - * @param data - .streamingData все форматы видео, будет выбран оптимальный - * @param html - Ссылка на html плеер - * @param url - Ссылка на видео + * @description Получаем данные об авторе видео + * @param id - ID канала + * @param name - Название канала, если не будет найден канал будет возвращено название * @protected - * @static */ - protected static extractFormat = async (data: json, html: string, url: string) => { - // Если установлен wrapper ytdlp - if (fs.existsSync("node_modules/ytdlp-nodejs")) { - const { YtDlp } = require("ytdlp-nodejs"); - const ytdlp = new YtDlp(); - - const result = await ytdlp.getInfoAsync(url); - return (result.requested_formats).find((format) => !format.fps) - } + protected restArtist = ({ id, name }: { id: string, name?: string }): Promise => { + return new Promise((resolve) => { + new httpsClient({ + url: `https:/${this.url}/channel/${id}/channels?flow=grid&view=0&pbj=1`, + headers: Clients.WEB.request.context.client + }).toJson.then((channel) => { + if (channel instanceof Error) return resolve(null); - // Запускаем мусорный Signature extractor, очень много мусора за собой оставляет - return new Promise((resolve) => { - SimpleWorker.create({ - file: "src/workers/YouTubeSignatureExtractor.js", - postMessage: { - formats: data["formats"], - html - }, - options: { - execArgv: ["-r", "tsconfig-paths/register"], - workerData: null - }, - callback: (data) => resolve(data) - }); + const data = channel[1]?.response ?? channel?.response ?? null; + const info = data?.header?.["c4TabbedHeaderRenderer"], Channel = data?.metadata?.["channelMetadataRenderer"], + avatar = info?.avatar; + + return resolve({ + title: Channel?.title ?? name ?? "Not found name", + url: `https://${this.url}/channel/${id}`, + image: avatar?.["thumbnails"].pop() ?? null + }); + }).catch(() => resolve(null)); }); }; /** * @description Получаем данные из страницы * @param input - Страница + * @protected */ - protected static extractInitialDataResponse = (input: string): json | Error => { + protected _extractResponse = (input: string): json | Error => { if (typeof input !== "string") return locale.err("api.request.fail"); - // Путь плеера (необходим для расшифровки) - const html5Player = /|"jsUrl":"([^"]+)"/.exec(input); - - let endData: json = { - html: `https://www.youtube.com${html5Player ? html5Player[1] || html5Player[2] : null}` - }; + let endData: json = {}; // Попытка найти ytInitialData JSON const initialDataMatch = input.match(/var ytInitialData = (.*?);<\/script>/); if (initialDataMatch) { try { - endData = { ...endData, ...JSON.parse(initialDataMatch[1]) }; + endData = JSON.parse(initialDataMatch[1]); } catch { // Игнорируем ошибку парсинга initialData } @@ -411,90 +581,41 @@ class RestYouTubeAPI extends RestServerSide.API { // Проверяем статус playabilityStatus, если есть const status = endData.playabilityStatus?.status; - if (status) { - if (status === "LOGIN_REQUIRED") { - return new Error(locale._(locale.language, "api.request.login")); - } else if (status !== "OK") { - const reason = endData.playabilityStatus?.reason || "Not found status error"; - return new Error(locale._(locale.language, "api.request.fail.msg", [reason])); - } + if (status && status !== "OK") { + const reason = endData.playabilityStatus?.reason || "Not found status error"; + return new Error(locale._(locale.language, "api.request.fail.msg", [reason])); } return endData; }; +} +/** + * @author SNIPPIK + * @description Генерируем уникальный индикатор устройства, фейковый конечно + * @param length - Размер + * @private + */ +function generateClientPlaybackNonce(length: number): string { + return Array.from({ length }, () => CPN_CHARS[Math.floor(Math.random() * CPN_CHARS.length)]).join(""); +} - /** - * @description Получаем данные об авторе видео - * @param id - ID канала - * @param name - Название канала, если не будет найден канал будет возвращено название - * @protected - * @static - */ - protected static getChannel = ({ id, name }: { id: string, name?: string }): Promise => { - return new Promise((resolve) => { - new httpsClient({ - url: `https://www.youtube.com/channel/${id}/channels?flow=grid&view=0&pbj=1`, - headers: { - "x-youtube-client-name": "1", - "x-youtube-client-version": "2.20201021.03.00" - } - }).toJson.then((channel) => { - if (channel instanceof Error) return resolve(null); - - const data = channel[1]?.response ?? channel?.response ?? null as any; - const info = data?.header?.["c4TabbedHeaderRenderer"], Channel = data?.metadata?.["channelMetadataRenderer"], - avatar = info?.avatar; - - return resolve({ - title: Channel?.title ?? name ?? "Not found name", - url: `https://www.youtube.com/channel/${id}`, - image: avatar?.["thumbnails"].pop() ?? null - }); - }).catch(() => resolve(null)); - }); - }; +/** + * @author SNIPPIK + * @description Генерируем фейковые ключи для плеера + * @param totalLength - Глобальный размер ключа + * @private + */ +function generateFakeApiKey(totalLength = 34): string { + let key = "AIzaSyAO_"; - /** - * @description Подготавливаем трек к отправке - * @param track - Данные видео - * @protected - * @static - */ - protected static track = (track: json) => { - const title = track.title?.simpleText ?? track.title?.["runs"]?.[0]?.text ?? track.title; - const author = track["shortBylineText"]?.["runs"]?.[0]?.text ?? track.author; - const id = track?.["videoId"] ?? track?.["inlinePlaybackEndpoint"]?.["watchEndpoint"]?.["videoId"] ?? track.contentId; + for (let i = 0; i < totalLength - 4; i++) { + key += CPN_CHARS.charAt(Math.floor(Math.random() * CPN_CHARS.length)); + } - try { - return { title, id, - url: `https://youtu.be/${id}`, - artist: { - title: author, - url: `https://www.youtube.com${track["shortBylineText"]["runs"][0]["navigationEndpoint"]["browseEndpoint"]["canonicalBaseUrl"] || track["shortBylineText"]["runs"][0]["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"].url}`, - }, - time: { total: track["lengthSeconds"] ?? track["lengthText"]?.["simpleText"] ?? 0 }, - image: `https://i.ytimg.com/vi/${id}/maxresdefault.jpg`, - audio: track?.format?.url || undefined - }; - } catch { - return { title, id, - artist: { - title: author, - url: `https://www.youtube.com/channel/${track.channelId}` - }, - url: `https://youtu.be/${id}`, - time: { - total: track["lengthSeconds"] ?? track["lengthText"]?.["simpleText"] ?? 0 - }, - image: `https://i.ytimg.com/vi/${id}/maxresdefault.jpg`, - audio: track?.format?.url || undefined - } - } - }; + return key; } - /** * @export default * @description Делаем классы глобальными diff --git a/src/handlers/rest/index.client.ts b/src/handlers/rest/index.client.ts new file mode 100644 index 00000000..979e503f --- /dev/null +++ b/src/handlers/rest/index.client.ts @@ -0,0 +1,108 @@ +import type { RestServerSide } from "./index.server"; +import type { APIRequests } from "./index"; +import { db } from "#app/db"; + +/** + * @author SNIPPIK + * @description Данные для работы в основной системе бота + * @namespace RestClientSide + * @public + */ +export namespace RestClientSide { + /** + * @description Данные для валидного запроса параллельному процессу + * @interface ClientOptions + * @public + */ + export interface ClientOptions { + platform: RestServerSide.APIBase; + type: keyof APIRequests; + + requestId?: string; + payload: string; + options?: { audio?: boolean; }; + } + + /** + * @description Класс для взаимодействия с конкретной платформой + * @class ClientRestRequest + * @public + */ + export class Request { + /** + * @description Выдаем название + * @return API.platform + * @public + */ + public get platform() { + return this._api.name; + }; + + /** + * @description Выдаем bool, Недоступна ли платформа + * @return boolean + * @public + */ + public get block() { + return db.api.platforms.block.includes(this._api.name); + }; + + /** + * @description Выдаем bool, есть ли доступ к платформе + * @return boolean + * @public + */ + public get auth() { + return this._api.auth !== null; + }; + + /** + * @description Выдаем bool, есть ли доступ к получению аудио у платформы + * @return boolean + * @public + */ + public get audio() { + return this._api.audio; + }; + + /** + * @description Выдаем int, цвет платформы + * @return number + * @public + */ + public get color() { + return this._api.color; + }; + + /** + * @description Ищем платформу из доступных + * @param _api - Данные платформы + * @public + */ + public constructor(private _api: RestServerSide.API) {}; + + /** + * @description Запрос в систему Rest/API, через систему worker + * @param payload - Данные для отправки + * @param options - Параметры для отправки + */ + public request(payload: string, options?: { audio: boolean }) { + const platform = this._api; + const type = platform.requests.find((item) => { + return item.name === payload || typeof payload === "string" && payload.startsWith("http") && item.filter?.test(payload) || item.name === "search" + })?.name; + + return { + // Получение типа запроса + type, + + // Функция запроса на Worker для получения данных + request: () => db.api["request_worker"]( + { + platform, payload, options, type + } + ) + } + }; + } +} \ No newline at end of file diff --git a/src/handlers/rest/index.decorator.ts b/src/handlers/rest/index.decorator.ts new file mode 100644 index 00000000..cc4640f7 --- /dev/null +++ b/src/handlers/rest/index.decorator.ts @@ -0,0 +1,55 @@ +/** + * @author SNIPPIK + * @description Названия всех доступных платформ + * @type RestAPIS_Names + * @public + */ +export type RestAPIS_Names = "YOUTUBE" | "SPOTIFY" | "VK" | "YANDEX" | "SOUNDCLOUD" | "DEEZER"; + +/** + * @author SNIPPIK + * @description Параметры запроса + * @interface RestOptions + * @private + */ +interface RestOptions { + readonly name: RestAPIS_Names; + readonly url: string; + readonly color: number; + readonly audio: boolean; + readonly auth?: string; + readonly filter: RegExp; +} + +/** + * @author SNIPPIK + * @description Декоратор создающий заголовок запроса + * @decorator + * @public + */ +export function DeclareRest(options: RestOptions) { + // Загружаем данные в класс + return (target: T) => + class extends target { + name = options.name; + url = options.url; + color = options.color; + audio = options.audio; + auth = options.auth; + filter = options.filter; + } +} + +/** + * @author SNIPPIK + * @description Дополнительные параметры + * @decorator + * @public + */ +export function OptionsRest(options: T) { + // Загружаем данные в класс + return (target: T) => + class extends target { + options = options; + } +} \ No newline at end of file diff --git a/src/handlers/rest/index.server.ts b/src/handlers/rest/index.server.ts new file mode 100644 index 00000000..e44ca767 --- /dev/null +++ b/src/handlers/rest/index.server.ts @@ -0,0 +1,251 @@ +import type { APIRequests, APIRequestsRaw, APIRequestsLimits } from "./index"; +import type { RestAPIS_Names } from "./index.decorator"; +import type { RestClientSide } from "./index.client"; + +/** + * @author SNIPPIK + * @description Тип параметров функции вызова для каждого запроса + * @type ExecuteParams + * @helper + */ +type ExecuteParams = T extends "track" ? { audio: boolean } : T extends APIRequestsLimits ? { limit: number } : never; + +/** + * @author SNIPPIK + * @description Данные для работы серверной части (Worker) + * @namespace RestServerSide + * @public + */ +export namespace RestServerSide { + /** + * @description Пример класса с типами + * @type APIs + * @public + */ + export type APIs = Record; + + /** + * @description Данные для валидного запроса параллельном процессу + * @type ServerOptions + * @public + */ + export type ServerOptions = RestClientSide.ClientOptions & { + // Название платформы + platform: RestAPIS_Names; + + // Надо ли получить данные в ответ + data?: boolean + }; + + /** + * @description Рекурсивно проходит по всему объекту, оставляя только сериализуемые + * @type Serializable + * @public + */ + export type Serializable = T extends Function ? never : T extends object ? { [K in keyof T]: Serializable } : T; + + /** + * @description Передаваемые данные из worker в основной поток + * @type Result + * @public + */ + export type Result = { + // Номер уникального запроса + requestId: number; + } & (ResultSuccess | ResultError); + + /** + * @description Создаем класс для итоговой платформы для взаимодействия с APIs + * @interface APIBase + * @public + */ + export interface APIBase { + /** + * @description Название платформы + * @readonly + */ + readonly name: RestAPIS_Names; + + /** + * @description Ссылка на платформу + * @readonly + */ + readonly url: string; + + /** + * @description Цвет платформы, в стиле discord + * @readonly + */ + readonly color: number; + } + + /** + * @description Создаем класс для итоговой платформы для взаимодействия с APIs + * @class API + * @implements APIBase + * @public + */ + export class API implements APIBase { + /** + * @description Название платформы + * @readonly + */ + readonly name: RestAPIS_Names; + + /** + * @description Ссылка на платформу + * @readonly + */ + readonly url: string; + + /** + * @description Цвет платформы, в стиле discord + * @readonly + */ + readonly color: number; + + /** + * @description Может ли платформа получать аудио сама. Аудио получается через запрос к track + * @readonly + */ + readonly audio: boolean; + + /** + * @description Если ли данные для авторизации + * @default undefined - данные не требуются + * @readonly + */ + readonly auth?: string; + + /** + * @description Regexp для поиска платформы + * @readonly + */ + readonly filter: RegExp; + + /** + * @description Запросы к данных платформы + * @readonly + */ + readonly requests: (RequestDef<"track"> | RequestDef<"search"> | RequestDef<"artist"> | RequestDef<"related"> | RequestDef<"album"> | RequestDef<"playlist">)[]; + + /** + * @description Доп параметры + * @readonly + */ + readonly options: any; + + /** + * @description Получение ID по ссылке + * @param regexp - Как искать ID + * @param query - Запрос + * @protected + */ + protected getID(regexp: RegExp, query: string): string { + return (regexp).exec(query)[0]; + }; + + /** + * @description Функция запроса данных с сервера + * @constructor + * @protected + */ + protected async API(...args: any): Promise { + return new Error(`Not found method API | ${args}`); + }; + + /** + * @description Функция авторизации платформы + * @protected + */ + protected async authorization(){ + return null; + }; + + /** + * @description Функция подготовки данных трека + * @param _ - Данные трека + * @constructor + * @protected + */ + protected track(_: json): APIRequestsRaw["track"] { + return null; + }; + } + + /** + * @description Доступные запросы для платформ + * @interface RequestDef + * @public + */ + export interface RequestDef { + name: T; + filter?: RegExp; + execute: (url: string, options: ExecuteParams) => Promise; + } + + /** + * @description Данные класса для работы с Rest/API + * @interface Data + * @public + */ + export interface Data { + /** + * @description Все загруженные платформы + * @protected + */ + supported: APIs; + + /** + * @description Платформы с данных для авторизации + * @protected + */ + authorization: RestAPIS_Names[]; + + /** + * @description Платформы с возможности получить аудио + * @warn По-умолчанию запрос идет к track + * @protected + */ + audio: RestAPIS_Names[]; + + /** + * @description Платформы с возможностью получать похожие треки + * @protected + */ + related: RestAPIS_Names[]; + + /** + * @description Заблокированные платформы + * @protected + */ + block: RestAPIS_Names[]; + + /** + * @description Поддерживаемые платформы в array формате, для экономии памяти + * @private + */ + array?: RestServerSide.API[] + } +} + +/** + * @description Если запрос обработан без ошибок + * @type ResultSuccess + * @private + */ +type ResultSuccess = { + status: "success"; + type: T; + result: APIRequestsRaw[T]; +}; + +/** + * @description Если запрос обработан без ошибок + * @type ResultError + * @private + */ +type ResultError = { + status: "error"; + result: Error; +}; \ No newline at end of file diff --git a/src/handlers/rest/index.ts b/src/handlers/rest/index.ts index fe02a895..a8c23eed 100644 --- a/src/handlers/rest/index.ts +++ b/src/handlers/rest/index.ts @@ -1,7 +1,97 @@ import { Logger, SimpleWorker } from "#structures"; +import type { RestServerSide } from "./index.server"; +import { RestClientSide } from "./index.client"; import { Worker } from "node:worker_threads"; import { Track } from "#core/queue"; -import { db } from "#app/db"; + +// Export decorator +export * from "./index.decorator"; +export * from "./index.client"; +export * from "./index.server"; + + +/** + * @author SNIPPIK + * @description Типы запросов с лимитом кол-ва треков при запросе + * @type APIRequestsLimits + * @public + */ +export type APIRequestsLimits = "playlist" | "album" | "search" | "artist" | "related"; + +/** + * @description Helper: all possible requests across platforms + * @type APIRequests + * @helper + * @public + */ +export type APIRequests = { + track: Track + playlist: Track.list + album: Track[] + artist: Track[] + related: Track.list + search: Track[] +} + +/** + * @description Helper: all possible requests across platforms + * @type APIRequestsRaw + * @helper + * @public + */ +export type APIRequestsRaw = { + track: TrackRaw.Data + playlist: TrackRaw.List + album: TrackRaw.List + artist: TrackRaw.Data[] + related: TrackRaw.List + search: TrackRaw.Data[] +} + +/** + * @description Сырые типы данных для дальнейшего использования + * @namespace TrackRaw + * @helper + * @private + */ +namespace TrackRaw { + /** + * @description Сырые данные объекта трека + * @interface Data + * @public + */ + export interface Data { + readonly id: string; + title: string; + readonly url: string; + artist: { title: string; readonly url: string; image?: string } + image: string; + time: { total: string; split?: string } + audio?: string; + } + + /** + * @description Сырые данные объекта списка + * @interface List + * @public + */ + export interface List { + readonly url: string; + readonly title: string; + items: Data[]; + image: string; + artist?: { title: string; readonly url: string; image?: string } + } +} + +/** + * @author SNIPPIK + * @description Разделение слов в названии трека + * @param str - Название + * @const normalize + * @private + */ +const normalize = (str: string) => str.toLowerCase().replace(/[*:\/;-]/gi, "").replace(/\s+/g, " ").trim().split(" "); /** * @author SNIPPIK @@ -12,7 +102,6 @@ import { db } from "#app/db"; export class RestObject { /** * @description Второстепенный поток, динамически создается и удаляется когда не требуется - * @readonly * @private */ private worker: Worker; @@ -21,36 +110,22 @@ export class RestObject { * @description Последний уникальный ID запроса * @private */ - private lastID = 0; + private lastID: number; /** * @description База с платформами * @public */ - public platforms: RestServerSide.Data & { - /** - * @description Поддерживаемые платформы в array формате, для экономии памяти - * @private - */ - array?: RestServerSide.API[] - }; + public platforms: RestServerSide.Data; /** * @description Получаем список всех доступных платформ - * @private - */ - private get array(): RestServerSide.API[] { - if (!this.platforms?.array) this.platforms.array = Object.values(this.platforms.supported); - return this.platforms.array; - }; - - /** - * @description Платформы с доступом к запросам * @returns RestServerSide.API[] * @public */ - public get allow(): RestServerSide.API[] { - return this.array.filter(api => api.auth !== null); + public get array(): RestServerSide.API[] { + if (!this.platforms?.array) this.platforms.array = Object.values(this.platforms.supported).filter(api => api.auth !== null); + return this.platforms.array; }; /** @@ -58,8 +133,8 @@ export class RestObject { * @returns RestServerSide.API[] * @public */ - public get audioSupport(): RestServerSide.API[] { - return this.array.filter(api => api.auth !== null && api.audio !== false && !this.platforms.block.includes(api.name)); + public get arrayAudio(): RestServerSide.API[] { + return this.array.filter(api => api.audio !== false && !this.platforms.block.includes(api.name)); }; /** @@ -67,13 +142,15 @@ export class RestObject { * @returns RestServerSide.API[] * @public */ - public get allowRelated(): RestServerSide.API[] { - return this.array.filter(api => api.auth !== null && api.requests.some((apis) => apis.name === "related")); + public get arrayRelated(): RestServerSide.API[] { + return this.array.filter(api => api.requests.some((apis) => apis.name === "related")); }; /** * @description Генерация уникального ID - * @param reset + * @param reset - Надо ли делать сброс счетчика + * @returns number + * @private */ private generateUniqueId = (reset = false) => { // Если надо сбросить данные @@ -83,8 +160,9 @@ export class RestObject { } // Если большое кол-во запросов - if (this.lastID >= 2 ** 16) this.generateUniqueId(true); + else if (this.lastID >= 2 ** 16) this.generateUniqueId(true); + // Добавляем +1 к ID this.lastID += 1; return this.lastID; }; @@ -95,11 +173,10 @@ export class RestObject { * @public */ public startWorker = async (): Promise => { - this.generateUniqueId(true); - return new Promise(resolve => { + // Создаем поток через менеджер потоков const worker = this.worker = SimpleWorker.create({ - file: "src/workers/RestAPIServerThread", + file: __dirname + "/index.worker", options: { execArgv: ["-r", "tsconfig-paths/register"], workerData: { rest: true }, @@ -108,13 +185,19 @@ export class RestObject { not_destroyed: true, callback: (data) => { this.platforms = data; + + // Сбрасываем уникальный id запроса + this.generateUniqueId(true); return resolve(true); } }); // Если возникнет ошибка, пересоздадим worker worker.once("error", (error) => { - console.log(error); + if (this.lastID >= 5) throw error; + else console.log(error); + + this.lastID++; return this.startWorker(); }); }); @@ -122,25 +205,21 @@ export class RestObject { /** * @description Создание класса для взаимодействия с платформой + * @returns RestClientSide.Request * @public */ - public request = (name: RestServerSide.API["name"]): RestClientSide.Request => { - const platform = this.platform(name); - return platform ? new RestClientSide.Request(platform) : null; + public request = (name: RestServerSide.API["name"] | string): RestClientSide.Request => { + return new RestClientSide.Request(this.platform(name)); }; /** * @description Получаем платформу * @param name - Имя платформы + * @returns RestServerSide.API * @private */ - private platform = (name: RestServerSide.API["name"]) => { - const platform = this.platforms.supported[name]; - - // Если есть такая платформа по имени - if (platform) return platform; - - return this.allow.find((api) => api.name === name); + private platform = (name: RestServerSide.API["name"] | string): RestServerSide.API => { + return this.platforms.supported[name] ?? this.array.find((api) => api.name === name || api.filter.exec(name)); }; /** @@ -151,19 +230,22 @@ export class RestObject { * @private */ private fetch = async (track: Track, array: RestServerSide.API[]): Promise => { - const { name, artist } = track; + const { name, artist, api } = track; // Оригинальный трек по словам - const original = name.toLowerCase().replace(/[^\w\s:;]|_/gi, "").replace(/\s+/gi, " ").split(" "); + const original = normalize(`${artist.title} ${name}`); let link: Track = null, lastError: Error; // Ищем нужную платформу for (const platform of array) { + // Не учитываем платформу трека + if (platform.name === api.name) continue; + // Получаем класс для работы с Worker const platformAPI = this.request(platform.name); // Поиск трека - const search = await platformAPI.request<"search">(`${name} ${artist.title}`).request(); + const search = await platformAPI.request<"search">(`${artist.title} ${name}`).request(); // Если при получении треков произошла ошибка if (search instanceof Error) { @@ -182,11 +264,15 @@ export class RestObject { // Ищем нужный трек // Можно разбить проверку на слова, сравнивать кол-во совпадений, если больше половины то точно подходит const findTrack = search.find((song) => { - const candidate = song.name.toLowerCase().replace(/[^\w\s:;]|_/gi, "").replace(/\s+/gi, " ").split(" "); - const Match = candidate.filter((name, i) => name === original[i]).every((word, i) => word === original[i]); + const candidate = normalize(`${song.artist.title} - ${song.name}`); + const matchCount = candidate.filter(word => original.includes(word)).length; const time = Math.abs(track.time.total - song.time.total); - return (time <= 10 || time === 0) && Match; + return (time <= 5 || time === 0) && // по длительности близко + ( + matchCount === candidate.length || // полное совпадение + matchCount >= Math.floor(candidate.length * 0.6) // ≥60% слов совпало + ); }); // Если отфильтровать треки не удалось @@ -233,32 +319,35 @@ export class RestObject { }; /** - * @description Если надо обновить ссылку на трек или аудио недоступно у платформы + * @description Если надо обновить ссылку на трек или аудио недоступно у платформы, получаем с другой * @param track - Трек у которого надо получить ссылку на исходный файл * @returns Promise * @public */ public fetchAudioLink = async (track: Track): Promise => { const { url, api } = track; - const { authorization, audio } = this.platforms; + const { authorization, audio, block } = this.platforms; try { // Если платформа поддерживает получение аудио и может получать данные - if (authorization.includes(api.name) && audio.includes(api.name)) { + if (authorization.includes(api.name) && audio.includes(api.name) && !block.includes(api.name)) { const song = await this.request(api.name).request<"track">(url, { audio: true }).request(); - // Если получили ошибку - if (song instanceof Error) return null; - - track["_duration"] = song.time; - return song.link; + // Если удалось получить аудио + if (!(song instanceof Error)) { + track.link = song.link; + return song.link; + } } - const song = await this.fetch(track, this.audioSupport); + // Ищем похожий трек на другой платформе + const song = await this.fetch(track, this.arrayAudio); // Если получена ошибка if (song instanceof Error) return song; + track["_duration"] = song.time; + track.link = song.link; return song.link; } catch (err) { Logger.log("ERROR", `[APIs/fetch] ${err}`); @@ -304,7 +393,7 @@ export class RestObject { return item.items; } - const song = await this.fetch(track, this.allowRelated); + const song = await this.fetch(track, this.arrayRelated); // Если получена ошибка if (song instanceof Error) return song; @@ -318,446 +407,72 @@ export class RestObject { /** * @description Создание класса для взаимодействия с платформой, рекомендуются добавлять timeout из-вне + * @returns Promise * @protected - * @readonly */ - protected request_worker({platform, payload, options}: RestClientSide.ClientOptions): Promise { - return new Promise((resolve) => { + protected request_worker({platform, payload, options, type}: RestClientSide.ClientOptions): Promise { + return new Promise(async (resolve) => { const requestId = this.generateUniqueId(); // Генерируем номер запроса // Слушаем сообщение или же ответ - const onMessage = (message: RestServerSide.Result & { requestId?: string }) => { + const onMessage = async (message: RestServerSide.Result & { requestId?: string }) => { + const { result, status } = message; + // Не наш ответ — игнорируем if (message.requestId !== requestId) return; // Отписываемся после получения this.worker.off("message", onMessage); - const { result, status } = message; - const baseAPI: RestServerSide.APIBase = { - name: platform.name, - url: platform.url, - color: platform.color - }; - - // Если получена ошибка - if (result instanceof Error) { - // Если платформа не отвечает, то отключаем ее! - if (/Connection Timeout/.test(result.message)) { - this.platforms.block.push(platform.name); + /** + * @description Слушаем статус ответа другого потока + * @private + */ + switch (status) { + // Если получен успешный ответ + case "success": { + Logger.log("DEBUG", `[Rest/API |${type}| GET - ${platform.name}]: ${payload}`); + const parseTrack = (item: TrackRaw.Data) => new Track(item, platform); + + // Если пришел список треков + if (Array.isArray(result)) { + return resolve(result.map(parseTrack) as APIRequests[T]); + } + + // Если пришел плейлист + else if (typeof result === "object" && "items" in result) { + return resolve({ ...result, items: result.items.map(parseTrack) } as any); + } + + // Если просто трек + return resolve(parseTrack(result) as APIRequests[T]); } - return resolve(result); - } - - // Если получен успешный ответ - else if (status === "success") { - const parseTrack = (item: TrackRaw.Data) => new Track(item, baseAPI); + // Если была получена ошибка + case "error": { + Logger.log("ERROR", result); - // Если пришел список треков - if (Array.isArray(result)) { - return resolve(result.map(parseTrack) as APIRequests[T]); + // Если платформа не отвечает, то отключаем ее! + if (/Connection Timeout/.test(result.message) || /Fail getting client ID/.test(result.message)) { + this.platforms.block.push(platform.name); + } + return resolve(result); } - // Если пришел плейлист - else if (typeof result === "object" && "items" in result) { - return resolve({ ...result, items: result.items.map(parseTrack) } as any); + // Если получен неожиданный ответ + default: { + Logger.log("WARN", `An unknown response was received from another thread!`); + return resolve(new Error(`Unknown response!!!`)) } - - // Если просто трек - return resolve(parseTrack(result) as APIRequests[T]); } - - return resolve(null); }; // Слушаем worker this.worker.on("message", onMessage); // Отправляем запрос - this.worker.postMessage({ platform: platform.name, payload, options, requestId }); + this.worker.postMessage({ platform: platform.name, payload, options, requestId, type }); + Logger.log("DEBUG", `[Rest/API |${type}| SEND - ${platform.name}]: ${payload}`); }); }; -} - - -/** ================= Decorators ================= */ -/** - * @author SNIPPIK - * @description Параметры запроса - */ -interface RestOptions { - readonly name: APIs_names; - readonly url: string; - readonly color: number; - readonly audio: boolean; - readonly auth?: string; - readonly filter: RegExp; -} - -/** - * @author SNIPPIK - * @description Декоратор создающий заголовок запроса - * @decorator - */ -export function DeclareRest(options: RestOptions) { - // Загружаем данные в класс - return (target: T) => - class extends target { - name = options.name; - url = options.url; - color = options.color; - audio = options.audio; - auth = options.auth; - filter = options.filter; - } -} - -/** - * @author SNIPPIK - * @description Дополнительные параметры - * @decorator - */ -export function OptionsRest(options: T) { - // Загружаем данные в класс - return (target: T) => - class extends target { - options = options; - } -} -/** ================= Decorators ================= */ - - -/** - * @description Названия всех доступных платформ - * @type APIs_names - */ -type APIs_names = "YOUTUBE" | "SPOTIFY" | "VK" | "YANDEX" | "SOUNDCLOUD" | "DEEZER"; - -/** - * @description Helper: all possible requests across platforms - * @type APIRequests - * @helper - */ -type APIRequests = { - track: Track - playlist: Track.list - album: Track[] - artist: Track[] - related: Track.list - search: Track[] -} - -/** - * @description Helper: all possible requests across platforms - * @type APIRequestsRaw - * @helper - */ -type APIRequestsRaw = { - track: TrackRaw.Data - playlist: TrackRaw.List - album: TrackRaw.List - artist: TrackRaw.Data[] - related: TrackRaw.List - search: TrackRaw.Data[] -} - -/** - * @description Тип параметров для каждого запроса - * @type ExecuteParams - * @helper - */ -type ExecuteParams = T extends "track" ? { audio: boolean } : T extends "playlist" | "album" | "artist" | "related" | "search" ? { limit: number } : never; - -/** - * @description Сырые типы данных для дальнейшего использования - * @namespace TrackRaw - * @helper - */ -namespace TrackRaw { - export interface Data { - readonly id: string; - title: string; - readonly url: string; - artist: { title: string; readonly url: string; image?: string } - image: string; - time: { total: string; split?: string } - audio?: string; - } - - export interface List { - readonly url: string; - readonly title: string; - items: Data[]; - image: string; - artist?: { title: string; readonly url: string; image?: string } - } -} - -/** ================= Client-Side ================= */ -/** - * @author SNIPPIK - * @description Данные для работы в основной системе бота - * @namespace RestClientSide - * @public - */ -export namespace RestClientSide { - /** - * @description Данные для валидного запроса параллельному процессу - * @interface ClientOptions - */ - export interface ClientOptions { - requestId: string - platform: RestServerSide.APIBase - payload: string - options?: { audio?: boolean; limit?: number } - } - - /** - * @description Класс для взаимодействия с конкретной платформой - * @class ClientRestRequest - * @private - */ - export class Request { - /** - * @description Выдаем название - * @return API.platform - * @public - */ - public get platform() { - return this._api.name; - }; - - /** - * @description Выдаем bool, Недоступна ли платформа - * @return boolean - * @public - */ - public get block() { - return db.api.platforms.block.includes(this._api.name); - }; - - /** - * @description Выдаем bool, есть ли доступ к платформе - * @return boolean - * @public - */ - public get auth() { - return this._api.auth !== null; - }; - - /** - * @description Выдаем bool, есть ли доступ к получению аудио у платформы - * @return boolean - * @public - */ - public get audio() { - return this._api.audio; - }; - - /** - * @description Выдаем int, цвет платформы - * @return number - * @public - */ - public get color() { - return this._api.color; - }; - - /** - * @description Ищем платформу из доступных - * @param _api - Данные платформы - * @public - */ - public constructor(private _api: RestServerSide.API) {}; - - /** - * @description Запрос в систему Rest/API, через систему worker - * @param payload - Данные для отправки - * @param options - Параметры для отправки - */ - public request(payload: string, options?: {audio: boolean}) { - const matchedRequest = this._api.requests.find((item) => { - if (item.name === payload) return true; - if (typeof payload === "string" && payload.startsWith("http")) { - return item["filter"]?.test(payload) ?? false; - } - return false; - }) || this._api.requests.find(item => item.name === "search"); - - return { - // Получение типа запроса - type: matchedRequest?.name as T, - - // Функция запроса на Worker для получения данных - request: () => db.api["request_worker"]( - { - platform: this._api, - payload: payload, - requestId: null, // Присваивается в request_worker - options, - } - ) - } - }; - } -} - -/** ================= Worker-Side ================= */ -/** - * @author SNIPPIK - * @description Данные для работы серверной части (Worker) - * @namespace RestServerSide - * @public - */ -export namespace RestServerSide { - /** - * @description Пример класса с типами - * @type APIs - */ - export type APIs = Record - - /** - * @description Данные для валидного запроса параллельном процессу - * @interface ServerOptions - */ - export type ServerOptions = RestClientSide.ClientOptions & { - platform: APIs_names; - data?: boolean - } - - /** - * @description Передаваемые данные из worker в основной поток - * @type Result - * @public - */ - export type Result = { - requestId: number; - status: "success"; - type: T; - result: APIRequestsRaw[T]; - } | { - requestId: number; - status: "error"; - result: Error; - } - - /** - * @description Создаем класс для итоговой платформы для взаимодействия с APIs - * @interface APIBase - * @public - */ - export interface APIBase { - /** - * @description Название платформы - */ - readonly name: APIs_names; - - /** - * @description Ссылка на платформу - */ - readonly url: string; - - /** - * @description Цвет платформы, в стиле discord - */ - readonly color: number; - } - - /** - * @description Создаем класс для итоговой платформы для взаимодействия с APIs - * @class API - * @public - */ - export class API implements APIBase { - /** - * @description Название платформы - */ - readonly name: APIs_names; - - /** - * @description Ссылка на платформу - */ - readonly url: string; - - /** - * @description Цвет платформы, в стиле discord - */ - readonly color: number; - - /** - * @description Может ли платформа получать аудио сама. Аудио получается через запрос к track - */ - readonly audio: boolean; - - /** - * @description Если ли данные для авторизации - * @default undefined - данные не требуются - */ - readonly auth?: string; - - /** - * @description Regexp для поиска платформы - */ - readonly filter: RegExp; - - /** - * @description Запросы к данных платформы - */ - readonly requests: (RequestDef<"track"> | RequestDef<"search"> | RequestDef<"artist"> | RequestDef<"related"> | RequestDef<"album"> | RequestDef<"playlist">)[]; - - /** - * @description Доп параметры - */ - readonly options: any; - } - - /** - * @description Доступные запросы для платформ - * @interface RequestDef - * @public - */ - export interface RequestDef { - name: T - filter?: RegExp - execute: (url: string, options: ExecuteParams) => Promise; - } - - /** - * @description Данные класса для работы с Rest/API - * @interface Data - * @public - */ - export interface Data { - /** - * @description Все загруженные платформы - * @protected - */ - supported: APIs; - - /** - * @description Платформы с данных для авторизации - * @protected - */ - authorization: APIs_names[]; - - /** - * @description Платформы с возможности получить аудио - * @warn По-умолчанию запрос идет к track - * @protected - */ - audio: APIs_names[]; - - /** - * @description Платформы с возможностью получать похожие треки - * @protected - */ - related: APIs_names[]; - - /** - * @description Заблокированные платформы - * @protected - */ - block: APIs_names[]; - } } \ No newline at end of file diff --git a/src/workers/RestAPIServerThread.ts b/src/handlers/rest/index.worker.ts similarity index 55% rename from src/workers/RestAPIServerThread.ts rename to src/handlers/rest/index.worker.ts index fb39e54d..7a33089d 100644 --- a/src/workers/RestAPIServerThread.ts +++ b/src/handlers/rest/index.worker.ts @@ -1,6 +1,7 @@ import { parentPort, workerData } from "node:worker_threads"; -import type { RestServerSide } from "#handler/rest"; -import { initDatabase } from "#app/db"; +import type { APIRequestsLimits } from "#handler/rest"; +import type { RestServerSide } from "./index.server"; +import { initSharedDatabase } from "#worker/db"; import { handler } from "#handler"; import { env } from "#app/env"; @@ -8,23 +9,17 @@ import { env } from "#app/env"; * @author SNIPPIK * @description Коллекция для взаимодействия с APIs * @class RestServer - * @extends handler + * @extends handler * @private */ class RestServer extends handler { /** * @description База с платформами - * @protected * @readonly + * @public */ - public readonly platforms: RestServerSide.Data & { - /** - * @description Поддерживаемые платформы в array формате, для экономии памяти - * @private - */ - array?: RestServerSide.API[] - } = { - supported: null, + public readonly platforms: RestServerSide.Data = { + supported: {} as RestServerSide.APIs, authorization: [], audio: [], related: [], @@ -34,31 +29,34 @@ class RestServer extends handler { /** * Лимиты на количество обрабатываемых элементов для различных типов запросов. * Значения читаются из переменных окружения. - * @type {Record} + * @readonly + * @public */ - public readonly limits: Record = ((): Record => { - const keys = ["playlist", "album", "search", "author"]; + public readonly limits = (() => { + const keys: APIRequestsLimits[] = ["playlist", "album", "search", "artist", "related"]; return keys.reduce((acc, key) => { - acc[key] = parseInt(env.get(`APIs.limit.${key}`)); + acc[key] = parseInt(env.get(`APIs.limit.${key}`, "10")); return acc; - }, {} as Record); + }, {} as Record); })(); /** * @description Получаем список всех доступных платформ + * @returns RestServerSide.API[] * @private */ - private get array(): RestServerSide.API[] { + private get array() { if (!this.platforms?.array) this.platforms.array = Object.values(this.platforms.supported).sort((a, b) => a.name.localeCompare(b.name)); return this.platforms.array; }; /** * @description Исключаем платформы из общего списка + * @returns RestServerSide.API[] * @public */ - public get allow(): RestServerSide.API[] { - return this.array.filter(api => api.auth !== null); + public get allow() { + return this.array.filter(api => api.auth !== null && !this.platforms.block.includes(api.name)); }; /** @@ -66,7 +64,7 @@ class RestServer extends handler { * @returns RestServerSide.API[] * @public */ - public get allowRelated(): RestServerSide.API[] { + public get allowRelated() { return this.array.filter(api => api.auth !== null && api.requests.some((apis) => apis.name === "related")); }; @@ -80,20 +78,6 @@ class RestServer extends handler { this.register(); }; - /** - * @description Получаем платформу - * @param name - Имя платформы - * @public - */ - public platform = (name: RestServerSide.API["name"] | string) => { - const platform = this.platforms.supported[name]; - - // Если есть такая платформа по имени - if (platform) return platform; - - return this.allow.find((api) => !!api.filter.exec(name) || name === api.name); - }; - /** * @description Функция загрузки api запросов * @returns void @@ -111,7 +95,7 @@ class RestServer extends handler { this.platforms.supported = { ...this.platforms.supported, [file.name]: file - } + }; } }; } @@ -129,69 +113,87 @@ let rest: RestServer; * @private */ if (parentPort && workerData.rest) { - initDatabase(null); + initSharedDatabase(); rest = new RestServer(); // Получаем ответ от основного потока - parentPort.on("message", (message: RestServerSide.ServerOptions): Promise | void => { + parentPort.on("message", async (message: RestServerSide.ServerOptions) => { // Если запрос к платформе - if (message.platform) return ExtractDataFromAPI(message); + if (message.platform) return fetchFromPlatform(message); // Если надо выдать данные о загруженных платформах - else if (message.data) return ExtractData(); + else if (message.data) return fetchPlatforms(); + + parentPort.postMessage({ + status: "error", + requestId: undefined, + result: Error("Dont support this request") + }); }); // Если возникнет непредвиденная ошибка process.on("unhandledRejection", (err) => { - parentPort.removeAllListeners(); - throw err; + parentPort?.postMessage({ status: "error", result: err }); }); } +/** + * @author SNIPPIK + * @description Удаление функций для SharedMemory + * @param obj - Данные запроса из Rest API + * @private + */ +function stripFunctions(obj: T) { + return Object.fromEntries(Object.entries(obj).filter(([_, v]) => typeof v !== "function")) as RestServerSide.Serializable; +} + /** * @author SNIPPIK * @description Получения json данных из платформ * @param api - Данные для успешного запроса * @returns Promise - * @function ExtractDataFromAPI + * @function fetchFromPlatform * @async */ -async function ExtractDataFromAPI(api: RestServerSide.ServerOptions) { - const { platform, payload, options, requestId } = api; +async function fetchFromPlatform(api: RestServerSide.ServerOptions) { + const { platform, payload, options, requestId, type } = api; try { - const readPlatform: RestServerSide.API = rest.platform( platform ); - const callback = readPlatform.requests.find((p) => { - // Если производится прямой запрос по названию - if (p.name === payload) return true; - - // Если указана ссылка - else if (payload.startsWith("http")) { - try { - return p["filter"]?.test(payload); - } catch { - return false; - } - } - - // Скорее всего надо произвести поиск - return p.name === "search"; - }); + const restPlatform = rest.platforms.supported[platform] as RestServerSide.API; + + // Если нет типа + if (!type) return parentPort.postMessage({requestId, status: "error", result: Error(`Unknown request type for payload: ${payload}`)}); + + const callback = restPlatform.requests.find((request) => request.name === type); // Если не найдена функция вызова if (!callback) return parentPort.postMessage({requestId, status: "error", result: Error(`Callback not found for platform: ${platform}`)}); + const result = await callback.execute(payload, { + audio: options?.audio !== undefined ? options.audio : true, + limit: rest.limits[callback.name] + }); + + // Если была получена ошибка + if (result instanceof Error) { + return parentPort.postMessage({ requestId, + status: "error", + result + }); + } + + // Если запрос успешен return parentPort.postMessage({ requestId, type: callback.name, status: "success", - result: await callback.execute(payload, { - audio: options?.audio !== undefined ? options.audio : true, - limit: rest.limits[callback.name] - }) + result + }); + } catch (err: any) { + parentPort.postMessage({ + status: "error", + requestId, + result: { name: err.name, message: err.message, stack: err.stack } }); - } catch (err) { - parentPort.postMessage({status: "error", result: err, requestId}); - throw new Error(`${err}`); } } @@ -199,26 +201,16 @@ async function ExtractDataFromAPI(api: RestServerSide.ServerOptions) { * @author SNIPPIK * @description Выдача найденных платформ без функций запроса * @returns Promise - * @function ExtractData + * @function fetchPlatforms * @async */ -async function ExtractData() { - const fakeReq = rest.allow.map(api => { - // Удаляем все поля-функции на верхнем уровне - const safeApi = Object.fromEntries( - Object.entries(api).filter(([_, v]) => typeof v !== "function") - ); - - // Обрабатываем requests - safeApi.requests = (api.requests ?? []).map(request => - Object.fromEntries( - Object.entries(request).filter(([_, v]) => typeof v !== "function") - ) - ); - - return safeApi; - }); +async function fetchPlatforms() { + const fakeReq = rest.allow.map(api => ({ + ...stripFunctions(api), + requests: (api.requests ?? []).map(stripFunctions) + })); + // Отдаем данные в другой поток parentPort?.postMessage({ supported: fakeReq, authorization: fakeReq.filter(api => api.auth !== null).map(api => api.name), diff --git a/src/handlers/rest/other/spotify.ts b/src/handlers/rest/other/spotify.ts index f1c1727a..43c68efd 100644 --- a/src/handlers/rest/other/spotify.ts +++ b/src/handlers/rest/other/spotify.ts @@ -1,7 +1,19 @@ import { DeclareRest, OptionsRest, RestServerSide } from "#handler/rest"; import { httpsClient, locale } from "#structures"; +import { sdb } from "#worker/db"; import { env } from "#app/env"; -import { db } from "#app/db"; + +/** + * @author SNIPPIK + * @description Взаимодействие с платформой Spotify, динамический плагин + * # Types + * - Track - Любое трек с платформы + * - Playlist - Любой открытый плейлист + * - Artist - Популярные треки автора с учетом лимита + * - Search - Поиск треков, пока не доступны плейлисты, альбомы, авторы + * @Specification Rest Spotify API + * @Audio Не доступно нативное получение + */ /** * @author SNIPPIK @@ -47,181 +59,188 @@ class RestSpotifyAPI extends RestServerSide.API { /** * @description Запрос данных о треке * @type "track" + * @private */ { name: "track", filter: /track\/[0-9z]+/i, - execute: (url, options) => { - const ID = /track\/[a-zA-Z0-9]+/.exec(url)?.pop()?.split("track\/")?.pop(); + execute: async (url, options) => { + const ID = this.getID(/track\/[a-zA-Z0-9]+/, url)?.split("track\/")?.pop(); - return new Promise(async (resolve) => { - //Если ID трека не удалось извлечь из ссылки - if (!ID) return resolve(locale.err("api.request.id.track")); + //Если ID трека не удалось извлечь из ссылки + if (!ID) return locale.err("api.request.id.track"); - // Интеграция с утилитой кеширования - const cache = db.cache.get(`${this.url}/${ID}`); + // Интеграция с утилитой кеширования + const cache = sdb.meta_saver?.get(`${this.url}/${ID}`); - // Если трек есть в кеше - if (cache) { - if (!options.audio) return resolve(cache); + // Если трек есть в кеше + if (cache) { + if (!options.audio) return cache; - // Если включена утилита кеширования аудио - else if (db.cache.audio) { - const check = db.cache.audio.status(`${this.url}/${ID}`); + // Если включена утилита кеширования аудио + else if (sdb.audio_saver) { + const check = sdb.audio_saver.status(`${this.url}/${ID}`); - // Если есть кеш аудио - if (check.status === "ended") { - cache.audio = check.path; - return resolve(cache); - } + // Если есть кеш аудио + if (check.status === "ended") { + cache.audio = check.path; + return cache; } } + } + + try { + // Создаем запрос + const api = await this.API(`tracks/${ID}`); - try { - // Создаем запрос - const api = await this.API(`tracks/${ID}`); - - // Если запрос выдал ошибку то - if (api instanceof Error) return resolve(api); - const track = this.track(api); - - // Если указано получение аудио - if (options.audio) { - // Если включена утилита кеширования - if (db.cache.audio) { - const check = db.cache.audio.status(`${this.url}/${ID}`); - - // Если есть кеш аудио - if (check.status === "ended") { - track.audio = check.path; - return resolve(track); - } + // Если запрос выдал ошибку то + if (api instanceof Error) return api; + const track = this.track(api); + + // Если указано получение аудио + if (options.audio) { + // Если включена утилита кеширования + if (sdb.audio_saver) { + const check = sdb.audio_saver.status(`${this.url}/${ID}`); + + // Если есть кеш аудио + if (check.status === "ended") { + track.audio = check.path; + return track; } } + } - setImmediate(() => { - // Сохраняем кеш в системе - if (!cache) db.cache.set(track, this.url); - }); + setImmediate(() => { + // Сохраняем кеш в системе + if (!cache) sdb.meta_saver.set(track, this.url); + }); - return resolve(track); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) - } - }); + return track; + } catch (e) { + return new Error(`[APIs]: ${e}`); + } } }, /** * @description Запрос данных об альбоме * @type "album" + * @private */ { name: "album", filter: /album\/[0-9z]+/i, - execute: (url, {limit}) => { - const ID = /album\/[a-zA-Z0-9]+/.exec(url)?.pop()?.split("album\/")?.pop(); - - return new Promise(async (resolve) => { - // Если ID альбома не удалось извлечь из ссылки - if (!ID) return resolve(locale.err( "api.request.id.album")); - - try { - // Создаем запрос - const api: Error | any = await this.API(`albums/${ID}?offset=0&limit=${limit}`); - - // Если запрос выдал ошибку то - if (api instanceof Error) return resolve(api); - - const tracks = api.tracks.items.map((track: any) => this.track(track, api.images)); - - return resolve({ url, title: api.name, image: api.images[0], items: tracks, artist: api?.["artists"][0] }); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) - } - }); + execute: async (url, {limit}) => { + const ID = this.getID(/album\/[a-zA-Z0-9]+/, url)?.split("album\/")?.pop(); + + // Если ID альбома не удалось извлечь из ссылки + if (!ID) return locale.err( "api.request.id.album"); + + try { + // Создаем запрос + const api: Error | any = await this.API(`albums/${ID}?offset=0&limit=${limit}`); + + // Если запрос выдал ошибку то + if (api instanceof Error) return api; + + // Подготавливаем все треки + const tracks = api.tracks.items.map((track: any) => this.track(track, api.images)); + return { + id: ID, + url: `https://open.spotify/album/${ID}`, + title: api.name, + image: api.images[0], + items: tracks, + artist: api?.["artists"][0] + }; + } catch (e) { + return new Error(`[APIs]: ${e}`); + } } }, /** * @description Запрос данных об плейлисте * @type "playlist" + * @private */ { name: "playlist", filter: /playlist\/[0-9z]+/i, - execute: (url, {limit}) => { - const ID = /playlist\/[a-zA-Z0-9]+/.exec(url)?.pop()?.split("playlist\/")?.pop(); - - return new Promise(async (resolve) => { - // Если ID плейлиста не удалось извлечь из ссылки - if (!ID) return resolve(locale.err( "api.request.id.playlist")); - - try { - // Создаем запрос - const api: Error | any = await this.API(`playlists/${ID}?offset=0&limit=${limit}`); - - // Если запрос выдал ошибку то - if (api instanceof Error) return resolve(api); - const tracks = api.tracks.items.map(({ track }) => this.track(track)); - - return resolve({ url, title: api.name, image: api.images[0], items: tracks }); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) - } - }); + execute: async (url, {limit}) => { + const ID = this.getID(/playlist\/[a-zA-Z0-9]+/, url)?.split("playlist\/")?.pop(); + + // Если ID плейлиста не удалось извлечь из ссылки + if (!ID) return locale.err( "api.request.id.playlist"); + + try { + // Создаем запрос + const api: Error | any = await this.API(`playlists/${ID}?offset=0&limit=${limit}`); + + // Если запрос выдал ошибку то + if (api instanceof Error) return api; + const tracks = api.tracks.items.map(({ track }) => this.track(track)); + + return { + url: `https://open.spotify/playlist/${ID}`, + title: api.name, + image: api.images[0], + items: tracks + }; + } catch (e) { + return new Error(`[APIs]: ${e}`); + } } }, /** * @description Запрос данных треков артиста * @type "author" + * @private */ { name: "artist", filter: /artist\/[0-9z]+/i, - execute: (url, {limit}) => { - const ID = /artist\/[a-zA-Z0-9]+/.exec(url)?.pop()?.split("artist\/")?.pop(); + execute: async (url, {limit}) => { + const ID = this.getID(/artist\/[a-zA-Z0-9]+/, url)?.split("artist\/")?.pop(); - return new Promise(async (resolve) => { - // Если ID автора не удалось извлечь из ссылки - if (!ID) return resolve(locale.err( "api.request.id.author")); + // Если ID автора не удалось извлечь из ссылки + if (!ID) return locale.err( "api.request.id.author"); - try { - // Создаем запрос - const api = await this.API(`artists/${ID}/top-tracks?market=ES&limit=${limit}`); + try { + // Создаем запрос + const api = await this.API(`artists/${ID}/top-tracks?market=ES&limit=${limit}`); - // Если запрос выдал ошибку то - if (api instanceof Error) return resolve(api); + // Если запрос выдал ошибку то + if (api instanceof Error) return api; - return resolve((api.tracks?.items ?? api.tracks).map(this.track)); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) - } - }); + return (api.tracks?.items ?? api.tracks).map(this.track); + } catch (e) { + return new Error(`[APIs]: ${e}`); + } } }, /** * @description Запрос данных по поиску * @type "search" + * @private */ { name: "search", - execute: (query, {limit}) => { - return new Promise(async (resolve) => { - try { - // Создаем запрос - const api: Error | any = await this.API(`search?q=${encodeURIComponent(query)}&type=track&limit=${limit}`); - - // Если запрос выдал ошибку то - if (api instanceof Error) return resolve(api); - - return resolve(api.tracks.items.map(this.track)); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) - } - }); + execute: async (query, {limit}) => { + try { + // Создаем запрос + const api: Error | any = await this.API(`search?q=${encodeURIComponent(query)}&type=track&limit=${limit}`); + + // Если запрос выдал ошибку то + if (api instanceof Error) return api; + + return api.tracks.items.map(this.track); + } catch (e) { + return new Error(`[APIs]: ${e}`); + } } } ] @@ -230,31 +249,11 @@ class RestSpotifyAPI extends RestServerSide.API { * @description Создаем запрос к SPOTIFY API и обновляем токен * @param method - Метод запроса из api * @protected - * @static */ protected API = (method: string): Promise => { return new Promise(async (resolve) => { - const getToken = async () => { - const token = await new httpsClient({ - url: `${this.options.account}/token`, - headers: { - "Authorization": `Basic ${Buffer.from(`${this.auth}`).toString('base64')}`, - "Content-Type": "application/x-www-form-urlencoded" - }, - body: "grant_type=client_credentials", - method: "POST" - }).toJson; - - // Если при получении токена была получена ошибка - if (token instanceof Error) return resolve(token); - - // Вносим данные авторизации - this.options.time = Date.now() + token["expires_in"]; - this.options.token = token["access_token"]; - } - // Нужно обновить токен - if (!this.options.token || this.options.time <= Date.now()) await getToken(); + if (!this.options.token || this.options.time <= Date.now()) await this.authorization(); new httpsClient({ url: `${this.options.api}/${method}`, @@ -275,12 +274,38 @@ class RestSpotifyAPI extends RestServerSide.API { }); }; + /** + * @description Авторизация на spotify + * @protected + */ + protected async authorization(): Promise { + const token = await new httpsClient({ + url: `${this.options.account}/token`, + headers: { + "Authorization": `Basic ${Buffer.from(`${this.auth}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded" + }, + body: "grant_type=client_credentials", + method: "POST" + }).toJson; + + // Если при получении токена была получена ошибка + if (token instanceof Error) { + return this.authorization(); + } + + // Вносим данные авторизации + this.options.time = Date.now() + token["expires_in"]; + this.options.token = token["access_token"]; + + return super.authorization(); + }; + /** * @description Собираем трек в готовый образ * @param track - Трек из Spotify API * @param images - Сторонние картинки * @protected - * @static */ protected track = (track: json, images?: any[]) => { const track_images = images?.length > 0 ? images : track?.album?.images || track?.images; @@ -293,7 +318,7 @@ class RestSpotifyAPI extends RestServerSide.API { title: track["artists"][0].name, url: track["artists"][0]["external_urls"]["spotify"] }, - time: { total: (track["duration_ms"] / 1000).toFixed(0) as any }, + time: { total: (track["duration_ms"] / 1000).toFixed(0) }, image: track_images.sort((item1: any, item2: any) => item1.width > item2.width)[0].url, audio: null }; diff --git a/src/handlers/rest/other/vk.ts b/src/handlers/rest/other/vk.ts index e109d870..17445530 100644 --- a/src/handlers/rest/other/vk.ts +++ b/src/handlers/rest/other/vk.ts @@ -1,8 +1,18 @@ import { DeclareRest, OptionsRest, RestServerSide } from "#handler/rest"; import { httpsClient, locale } from "#structures"; -import { Track } from "#core/queue"; +import type { Track } from "#core/queue"; +import { sdb } from "#worker/db"; import { env } from "#app/env"; -import { db } from "#app/db"; + +/** + * @author SNIPPIK + * @description Взаимодействие с платформой VK, динамический плагин + * # Types + * - Track - Любое трек с платформы + * - Search - Поиск треков, пока не доступны плейлисты, альбомы, авторы + * @Specification Rest VK API + * @Audio Доступно нативное получение только в RU регионе + */ /** * @author SNIPPIK @@ -34,68 +44,66 @@ class RestVKAPI extends RestServerSide.API { { name: "track", filter: /(audio)([0-9]+_[0-9]+_[a-zA-Z0-9]+|-[0-9]+_[a-zA-Z0-9]+)/i, - execute: (url, options) => { - const ID = /([0-9]+_[0-9]+_[a-zA-Z0-9]+|-[0-9]+_[a-zA-Z0-9]+)/i.exec(url).pop(); + execute: async (url, options) => { + const ID = this.getID(/([0-9]+_[0-9]+_[a-zA-Z0-9]+|-[0-9]+_[a-zA-Z0-9]+)/i, url); - return new Promise(async (resolve) => { - //Если ID трека не удалось извлечь из ссылки - if (!ID) return resolve(locale.err( "api.request.id.track")); + //Если ID трека не удалось извлечь из ссылки + if (!ID) return locale.err( "api.request.id.track"); - // Интеграция с утилитой кеширования - const cache = db.cache.get(`${this.url}/${ID}`); + // Интеграция с утилитой кеширования + const cache = sdb.meta_saver?.get(`${this.url}/${ID}`); - // Если трек есть в кеше - if (cache) { - if (!options.audio) return resolve(cache); + // Если трек есть в кеше + if (cache) { + if (!options.audio) return cache; - // Если включена утилита кеширования аудио - else if (db.cache.audio) { - const check = db.cache.audio.status(`${this.url}/${ID}`); + // Если включена утилита кеширования аудио + else if (sdb.audio_saver) { + const check = sdb.audio_saver.status(`${this.url}/${ID}`); - // Если есть кеш аудио - if (check.status === "ended") { - cache.audio = check.path; - return resolve(cache); - } + // Если есть кеш аудио + if (check.status === "ended") { + cache.audio = check.path; + return cache; } } + } - try { - // Создаем запрос - const api = await this.API("audio", "getById", `&audios=${ID}`); + try { + // Создаем запрос + const api = await this.API("audio", "getById", `&audios=${ID}`); - // Если запрос выдал ошибку то - if (api instanceof Error) return resolve(api); + // Если запрос выдал ошибку то + if (api instanceof Error) return api; - const track = this.track(api.response.pop(), url); + const track = this.track(api.response.pop(), url); - // Если указано получение аудио - if (options.audio) { - // Если включена утилита кеширования - if (db.cache.audio) { - const check = db.cache.audio.status(`${this.url}/${ID}`); + // Если указано получение аудио + if (options.audio) { + // Если включена утилита кеширования + if (sdb.audio_saver) { + const check = sdb.audio_saver.status(`${this.url}/${ID}`); - // Если есть кеш аудио - if (check.status === "ended") { - track.audio = check.path; - return resolve(track); - } + // Если есть кеш аудио + if (check.status === "ended") { + track.audio = check.path; + return track; } } + } - // Если нет ссылки на трек - if (!track.audio) return resolve(locale.err( "api.request.fail")); + // Если нет ссылки на трек + if (!track.audio) return locale.err( "api.request.fail"); - setImmediate(() => { - // Сохраняем кеш в системе - if (!cache) db.cache.set(track, this.url); - }); + setImmediate(() => { + // Сохраняем кеш в системе + if (!cache) sdb.meta_saver?.set(track, this.url); + }); - return resolve(track); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) - } - }); + return track; + } catch (e) { + return new Error(`[APIs]: ${e}`); + } } }, @@ -105,21 +113,17 @@ class RestVKAPI extends RestServerSide.API { */ { name: "search", - execute: (query, {limit}) => { - return new Promise(async (resolve) => { - try { - // Создаем запрос - const api = await this.API("audio", "search", `&q=${encodeURIComponent(query)}`); - - // Если запрос выдал ошибку то - if (api instanceof Error) return resolve(api); - const tracks = (api.response.items.splice(0, limit)).map(this.track); - - return resolve(tracks); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) - } - }); + execute: async (query, {limit}) => { + try { + // Создаем запрос + const api = await this.API("audio", "search", `&q=${encodeURIComponent(query)}`); + + // Если запрос выдал ошибку то + if (api instanceof Error) return api; + return (api.response.items.splice(0, limit)).map(this.track); + } catch (e) { + return new Error(`[APIs]: ${e}`); + } } } ]; @@ -130,7 +134,6 @@ class RestVKAPI extends RestServerSide.API { * @param type {string} Тип запроса * @param options {string} Параметры через & * @protected - * @static */ protected API = (method: "audio" | "execute" | "catalog", type: "getById" | "search" | "getPlaylistById", options: string): Promise => { return new Promise((resolve) => { @@ -155,7 +158,6 @@ class RestVKAPI extends RestServerSide.API { * @param track {any} Любой трек из VK * @param url - Ссылка на трек * @protected - * @static */ protected track = (track: json, url: string = null) => { const image = track?.album?.["thumb"]; @@ -175,7 +177,6 @@ class RestVKAPI extends RestServerSide.API { * @description Из полученных данных подготавливаем данные об авторе для ISong.track * @param user {any} Любой автор трека * @protected - * @static */ protected author = (user: any): Track.artist => { const url = `https://vk.com/audio?performer=1&q=${user.artist.replaceAll(" ", "").toLowerCase()}`; diff --git a/src/handlers/rest/other/yandex.ts b/src/handlers/rest/other/yandex.ts index 1cc572ab..f2def1c8 100644 --- a/src/handlers/rest/other/yandex.ts +++ b/src/handlers/rest/other/yandex.ts @@ -1,8 +1,21 @@ import { DeclareRest, OptionsRest, RestServerSide } from "#handler/rest"; import { httpsClient, locale } from "#structures"; import crypto from "node:crypto"; +import { sdb } from "#worker/db"; import { env } from "#app/env"; -import { db } from "#app/db"; + +/** + * @author SNIPPIK + * @description Взаимодействие с платформой Yandex, динамический плагин + * # Types + * - Track - Любое трек с платформы + * - Playlist - Любой открытый плейлист + * - Artist - Популярные треки автора с учетом лимита + * - Related - Похожее треки, работает через алгоритмы yandex + * - Search - Поиск треков, пока не доступны плейлисты, альбомы, авторы + * @Specification Rest Yandex API + * @Audio Доступно нативное получение + */ /** * @author SNIPPIK @@ -29,18 +42,7 @@ import { db } from "#app/db"; * @description Ключи для расшифровки ссылок * @protected */ - keys: ["kzqU4XhfCaY6B6JTHODeq5", "XGRlBW9FXlekgbPrRHuSiA"], - - /** - * @description Доступные заголовки - */ - agents: [ - // Windows Desktop - "YandexMusicDesktopAppWindows/5.13.2", - - // Phone Android - "YandexMusicAndroid/2025071" - ], + keys: ["p93jhgh689SBReK6ghtw62", "XGRlBW9FXlekgbPrRHuSiA"], }) class RestYandexAPI extends RestServerSide.API { readonly requests: RestServerSide.API["requests"] = [ @@ -51,27 +53,25 @@ class RestYandexAPI extends RestServerSide.API { { name: "related", filter: /(track\/[0-9]+)?(list=RD)/, - execute: (url) => { - const ID = /track\/[0-9]+/gi.exec(url)[0]?.split("track")?.at(1); + execute: async (url) => { + const ID = this.getID(/track\/[0-9]+/gi, url)?.split("track")?.at(1); - return new Promise(async (resolve) => { - // Если ID альбома не удалось извлечь из ссылки - if (!ID) return resolve(locale.err( "api.request.id.album")); + // Если ID альбома не удалось извлечь из ссылки + if (!ID) return locale.err( "api.request.id.album"); - try { - // Создаем запрос - const api = await this.API(`tracks/${ID}/similar`); + try { + // Создаем запрос + const api = await this.API(`tracks/${ID}/similar`); - // Если запрос выдал ошибку то - if (api instanceof Error) return resolve(api); - else if (!api["similarTracks"]?.length) return resolve(locale.err("api.request.fail.msg", ["0 tracks received"])); + // Если запрос выдал ошибку то + if (api instanceof Error) return api; + else if (!api["similarTracks"]?.length) return locale.err("api.request.fail.msg", ["0 tracks received"]); - const songs = api["similarTracks"].map(this.track); - return resolve({url, title: null, image: null, items: songs}); - } catch (e) { - return resolve(Error(`[APIs]: ${e}`)) - } - }); + const songs = api["similarTracks"].map(this.track); + return {url, title: null, image: null, items: songs}; + } catch (e) { + return Error(`[APIs]: ${e}`); + } } }, @@ -82,72 +82,70 @@ class RestYandexAPI extends RestServerSide.API { { name: "track", filter: /track\/[0-9]+/i, - execute: (url, options) => { - const ID = /track\/[0-9]+/gi.exec(url)[0]?.split("track")?.at(1); + execute: async (url, options) => { + const ID = this.getID(/track\/[0-9]+/gi, url)?.split("track")?.at(1); - return new Promise(async (resolve) => { - // Если ID трека не удалось извлечь из ссылки - if (!ID) return resolve(locale.err( "api.request.id.track")); + // Если ID трека не удалось извлечь из ссылки + if (!ID) return locale.err( "api.request.id.track"); - // Интеграция с утилитой кеширования - const cache = db.cache.get(`${this.url}/${ID}`); + // Интеграция с утилитой кеширования + const cache = sdb.meta_saver?.get(`${this.url}/${ID}`); - // Если трек есть в кеше - if (cache) { - if (!options.audio) return resolve(cache); + // Если трек есть в кеше + if (cache) { + if (!options.audio) return cache; - // Если включена утилита кеширования аудио - else if (db.cache.audio) { - const check = db.cache.audio.status(`${this.url}/${ID}`); + // Если включена утилита кеширования аудио + else if (sdb.audio_saver) { + const check = sdb.audio_saver.status(`${this.url}/${ID}`); - // Если есть кеш аудио - if (check.status === "ended") { - cache.audio = check.path; - return resolve(cache); - } + // Если есть кеш аудио + if (check.status === "ended") { + cache.audio = check.path; + return cache; } } + } - try { - // Делаем запрос - const api = await this.API(`tracks/${ID}`); + try { + // Делаем запрос + const api = await this.API(`tracks/${ID}`); - // Обрабатываем ошибки - if (api instanceof Error) return resolve(api); - else if (!api[0]) return resolve(locale.err( "api.request.fail")); + // Обрабатываем ошибки + if (api instanceof Error) return api; + else if (!api[0]) return locale.err( "api.request.fail"); - const track = this.track(api[0]); + const track = this.track(api[0]); - // Если указано получение аудио - if (options.audio) { - // Если включена утилита кеширования - if (db.cache.audio) { - const check = db.cache.audio.status(`${this.url}/${ID}`); + // Если указано получение аудио + if (options.audio) { + // Если включена утилита кеширования + if (sdb.audio_saver) { + const check = sdb.audio_saver.status(`${this.url}/${ID}`); - // Если есть кеш аудио - if (check.status === "ended") { - track.audio = check.path; - return resolve(track); - } + // Если есть кеш аудио + if (check.status === "ended") { + track.audio = check.path; + return track; } - - const link = await this.getAudio(ID); - - // Проверяем не получена ли ошибка при расшифровке ссылки на исходный файл - if (link instanceof Error) return resolve(link); - track["audio"] = link; } - setImmediate(() => { - // Сохраняем кеш в системе - if (!cache) db.cache.set(track, this.url); - }); + const link = await this.getAudio(ID); - return resolve(track); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) + // Проверяем не получена ли ошибка при расшифровке ссылки на исходный файл + if (link instanceof Error) return link; + track["audio"] = link; } - }); + + setImmediate(() => { + // Сохраняем кеш в системе + if (!cache) sdb.meta_saver.set(track, this.url); + }); + + return track; + } catch (e) { + return new Error(`[APIs]: ${e}`); + } } }, @@ -158,30 +156,28 @@ class RestYandexAPI extends RestServerSide.API { { name: "album", filter: /(album)\/[0-9]+/i, - execute: (url, {limit}) => { - const ID = /[0-9]+/i.exec(url)?.at(0)?.split("album")?.at(0); + execute: async (url, {limit}) => { + const ID = this.getID(/[0-9]+/i, url)?.split("album")?.at(0); - return new Promise(async (resolve) => { - // Если ID альбома не удалось извлечь из ссылки - if (!ID) return resolve(locale.err( "api.request.id.album")); + // Если ID альбома не удалось извлечь из ссылки + if (!ID) return locale.err( "api.request.id.album"); - try { - // Создаем запрос - const api = await this.API(`albums/${ID}/with-tracks`); + try { + // Создаем запрос + const api = await this.API(`albums/${ID}/with-tracks`); - // Если запрос выдал ошибку то - if (api instanceof Error) return resolve(api); - else if (!api?.["duplicates"]?.length && !api?.["volumes"]?.length) return resolve(locale.err("api.request.fail")); + // Если запрос выдал ошибку то + if (api instanceof Error) return api; + else if (!api?.["duplicates"]?.length && !api?.["volumes"]?.length) return locale.err("api.request.fail"); - const AlbumImage = this.parseImage({image: api?.["ogImage"] ?? api?.["coverUri"]}); - const tracks = api["volumes"]?.pop().splice(0, limit); - const songs = tracks.map(this.track); + const AlbumImage = this.parseImage({image: api?.["ogImage"] ?? api?.["coverUri"]}); + const tracks = api["volumes"]?.pop().splice(0, limit); + const songs = tracks.map(this.track); - return resolve({url, title: api.title, image: AlbumImage, items: songs}); - } catch (e) { - return resolve(Error(`[APIs]: ${e}`)) - } - }); + return {id: ID, url, title: api.title, image: AlbumImage, items: songs}; + } catch (e) { + return Error(`[APIs]: ${e}`); + } } }, @@ -191,36 +187,37 @@ class RestYandexAPI extends RestServerSide.API { */ { name: "playlist", - filter: /(playlists\/[a0-Z9.-]*)/i, - execute: (url, {limit}) => { - const ID = /(playlists\/[a0-Z9.-]*)/i.exec(url)[0].split("/")[1]; - - return new Promise(async (resolve) => { - if (!ID) return resolve(locale.err("api.request.id.playlist")); - - try { - // Создаем запрос - const api = await this.API(`playlist/${ID}`); - - // Если запрос выдал ошибку то - if (api instanceof Error) return resolve(api); - else if (api?.tracks?.length === 0) return resolve(locale.err("api.request.fail.msg", ["Not found tracks in playlist"])); - - const image = this.parseImage({image: api?.["ogImage"] ?? api?.["coverUri"]}); - const tracks: any[] = api.tracks?.splice(0, limit); - const songs = tracks.map(({track}) => this.track(track)); - - return resolve({ - url, title: api.title, image: image, items: songs, - artist: { - title: api.owner.name, - url: `https://music.yandex.ru/users/${ID[1]}` - } - }); - } catch (e) { - return resolve(Error(`[APIs]: ${e}`)) - } - }); + filter: /(playlists\/[0-9a-f-]+)/i, + execute: async (url, {limit}) => { + const ID = this.getID(/(playlists\/[0-9a-f-]+)/i, url).split("/")[1]; + + // Если ID альбома не удалось извлечь из ссылки + if (!ID) return locale.err("api.request.id.playlist"); + + try { + // Создаем запрос + const api = await this.API(`playlist/${ID}`); + + // Если запрос выдал ошибку то + if (api instanceof Error) return api; + else if (!api?.tracks) return locale.err("api.request.fail.msg", ["Not found playlist"]); + else if (api?.tracks?.length === 0) return locale.err("api.request.fail.msg", ["Not found tracks in playlist"]); + + const image = this.parseImage({image: api?.["ogImage"] ?? api?.["coverUri"]}); + const tracks: any[] = api.tracks?.splice(0, limit); + const songs = tracks.map(({track}) => this.track(track)); + + return { + url: `https://music.yandex.ru/playlists/${ID}`, + title: api.title, image: image, items: songs, + artist: { + title: api.owner.name, + url: `https://music.yandex.ru/users/${ID[1]}` + } + }; + } catch (e) { + return Error(`[APIs]: ${e}`); + } } }, @@ -231,26 +228,22 @@ class RestYandexAPI extends RestServerSide.API { { name: "artist", filter: /(artist)\/[0-9]+/i, - execute: (url, {limit}) => { - const ID = /(artist)\/[0-9]+/i.exec(url)?.at(0)?.split("artist")?.at(0); + execute: async (url, {limit}) => { + const ID = this.getID(/[0-9]+/i, url); - return new Promise(async (resolve) => { - // Если ID автора не удалось извлечь из ссылки - if (!ID) return resolve(locale.err("api.request.id.author")); + // Если ID автора не удалось извлечь из ссылки + if (!ID) return locale.err("api.request.id.author"); - try { - // Создаем запрос - const api = await this.API(`artists/${ID}/tracks`); + try { + // Создаем запрос + const api = await this.API(`artists/${ID}/tracks`); - // Если запрос выдал ошибку то - if (api instanceof Error) return resolve(api); - const tracks = api.tracks.splice(0, limit).map(this.track); - - return resolve(tracks); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) - } - }); + // Если запрос выдал ошибку то + if (api instanceof Error) return api; + return api.tracks.splice(0, limit).map(this.track); + } catch (e) { + return new Error(`[APIs]: ${e}`) + } } }, @@ -260,22 +253,19 @@ class RestYandexAPI extends RestServerSide.API { */ { name: "search", - execute: (query , {limit}) => { - return new Promise(async (resolve) => { - try { - // Создаем запрос - const api = await this.API(`search?type=all&text=${encodeURIComponent(query)}&page=0&nococrrect=false`); - - // Обрабатываем ошибки - if (api instanceof Error) return resolve(api); - else if (!api.tracks) return resolve([]); - - const tracks = api.tracks["results"].splice(0, limit).map(this.track); - return resolve(tracks); - } catch (e) { - return resolve(new Error(`[APIs]: ${e}`)) - } - }); + execute: async (query , {limit}) => { + try { + // Создаем запрос + const api = await this.API(`search?type=all&text=${encodeURIComponent(query)}&page=0&nococrrect=false`); + + // Обрабатываем ошибки + if (api instanceof Error) return api; + else if (!api.tracks) return []; + + return api.tracks["results"].splice(0, limit).map(this.track); + } catch (e) { + return new Error(`[APIs]: ${e}`) + } } } ]; @@ -284,15 +274,13 @@ class RestYandexAPI extends RestServerSide.API { * @description Делаем запрос на {data.api}/methods * @param method - Метод запроса из api * @protected - * @static */ protected API = (method: string): Promise => { return new Promise((resolve) => { new httpsClient({ url: `${this.options.api}/${method}`, headers: { - "Authorization": "OAuth " + this.auth, - "X-Yandex-Music-Client": method?.startsWith("get-file-info") ? this.options.agents[0] : this.options.agents[1] + "Authorization": "OAuth " + this.auth }, method: "GET", }).toJson.then((req) => { @@ -314,80 +302,66 @@ class RestYandexAPI extends RestServerSide.API { * @param ID - ID трека * @support MP3, Lossless * @protected - * @static */ - protected getAudio = (ID: string): Promise => { + protected getAudio = async (ID: string): Promise => { const trackId = ID.split("/")[1]; - return new Promise(async (resolve) => { - for (let i = 0; i < 3; i++) { - - try { /* Flac Audio handler */ - const timestamp = Math.floor(Date.now() / 1000); - const encoder = new TextEncoder(); - const keyData = encoder.encode(this.options.keys[0]); - const cryptoKey = await crypto.subtle.importKey("raw", keyData, {name: "HMAC", hash: {name: "SHA-256"}},false, ["sign"]); - const dataEncoded = encoder.encode(`${timestamp}${trackId}losslessflacaache-aacmp3raw`); - const signature = await crypto.subtle.sign("HMAC", cryptoKey, dataEncoded); - const sign = btoa(String.fromCharCode(...new Uint8Array(signature))); - const params = new URLSearchParams({ - ts: `${timestamp}`, - trackId: trackId, - quality: "lossless", - codecs: "flac,aac,he-aac,mp3", - transports: "raw", - - // Удаляем лишний символ с конца (=) - sign: sign.slice(0, -1) - }); + for (let i = 0; i <= 3; i++) { + // Если достигли максимума, возвращаем ошибку + if (i === 3) { + return locale.err("api.request.fail.msg", ["Fail getting audio url"]); + } - // Делаем запрос для получения аудио - const api = await this.API(`get-file-info?${params.toString()}`) as { downloadInfo: {url: string, trackId: string, realId: string} }; + try { + // Делаем запрос для получения аудио + const api = await new httpsClient({ + url: `https://api.music.yandex.net/tracks/${trackId}/download-info`, + headers: { + "Authorization": "OAuth " + this.auth + }, + method: "GET", + }).toJson; - // Если yandex пытается подсунуть рекламу вместо реального аудио - if (api.downloadInfo.trackId !== trackId || api.downloadInfo.realId !== trackId) continue; + // Если на этапе получение данных получена одна из ошибок + if (!api) return locale.err("api.request.fail.msg", ["Fail getting audio file, api as 0"]); + else if (api instanceof Error) return api; + else if (api?.result?.name === "track-download-info-error") return locale.err("api.request.fail.msg", ["Fail getting audio file, blocked from provider"]); + else if (api?.result?.length === 0) return locale.err("api.request.fail.msg", ["Fail getting audio file, api.size as 0"]); - return resolve(api.downloadInfo.url); - } catch (e) { /* MP3 Audio handler */ - try { - // Делаем запрос для получения аудио - const api = await this.API(`tracks/${trackId}/download-info`); + const url = api?.result.find((data: any) => data.codec !== "aac") as { downloadInfoUrl: string }; - // Если на этапе получение данных получена одна из ошибок - if (!api) return resolve(locale.err("api.request.fail.msg", ["Fail getting audio file, api as 0"])); - else if (api instanceof Error) return resolve(api); - else if (api.length === 0) return resolve(locale.err("api.request.fail.msg", ["Fail getting audio file, api.size as 0"])); + // Если нет ссылки на xml + if (!url) return locale.err("api.request.fail.msg", ["Fail getting audio url"]); - const url = api.find((data: any) => data.codec !== "aac") as { downloadInfoUrl: string }; + // Если yandex пытается подсунуть рекламу вместо реального аудио + else if (`${url.downloadInfoUrl.split(".").at(-1)!.split("/")[0]}` !== trackId) continue; - // Если нет ссылки на xml - if (!url) return resolve(locale.err("api.request.fail.msg", ["Fail getting audio url"])); + // Расшифровываем xml страницу на фрагменты + const xml = await new httpsClient({ + url: url["downloadInfoUrl"], + headers: { + "Authorization": "OAuth " + this.auth + }, + method: "GET", + }).toXML; - // Если yandex пытается подсунуть рекламу вместо реального аудио - else if (`${url.downloadInfoUrl.split(".").at(-1).split("/")[0]}` !== trackId) continue; + // Если произошла ошибка при получении xml + if (xml instanceof Error) return locale.err("api.request.fail.msg", ["Fail parsing xml page"]); - // Расшифровываем xml страницу на фрагменты - new httpsClient({url: url["downloadInfoUrl"], - headers: { - "X-Yandex-Music-Client": this.options.agents[1] - } - }).toXML.then((xml) => { - if (xml instanceof Error) return resolve(xml); - - const path = xml[1]; - const sign = crypto.createHash("md5").update(this.options.keys[1] + path.slice(1) + xml[4]).digest("hex"); - - return resolve(`https://${xml[0]}/get-mp3/${sign}/${xml[2]}${path}`); - }).catch((e) => { - return resolve(Error(e)); - }); - } catch (err) { - return resolve(Error(e as string)); - } - } + const path = xml[1]; + const sign = crypto.createHash("md5").update(this.options.keys[1] + path.slice(1) + xml[4]).digest("hex"); + + // Успех, возвращаем результат и прерываем цикл + return `https://${xml[0]}/get-mp3/${sign}/${xml[2]}${path}`; + } catch (mp3Error) { + // Если MP3 handler также бросил ошибку, выводим её и продолжаем цикл (i++) + console.error("MP3 Handler Failed. Retrying...", mp3Error); } - }); + } + + // Достичь этого return-а в рабочем цикле невозможно, но добавлен для соответствия сигнатуре Promise + return locale.err("api.request.fail.msg", ["Failed to retrieve audio after all retries."]); }; /** @@ -395,7 +369,6 @@ class RestYandexAPI extends RestServerSide.API { * @param image - Данные о картинке * @param size - Размер картинки * @protected - * @static */ protected parseImage = ({image, size = 1e3}: { image: string, size?: number }): string => { if (!image) return null; @@ -406,19 +379,18 @@ class RestYandexAPI extends RestServerSide.API { * @description Из полученных данных подготавливаем трек для Audio * @param track - Данные трека * @protected - * @static */ protected track = (track: any) => { const author = track["artists"]?.length ? track["artists"]?.pop() : track["artists"]; const album = track["albums"]?.length ? track["albums"][0] : track["albums"]; - const image = this.parseImage({image: album?.["ogImage"] ?? album?.["coverUri"] ?? track?.["ogImage"] ?? track?.["coverUri"]}) ?? null + const image = this.parseImage({image: album?.["ogImage"] ?? album?.["coverUri"] ?? track?.["ogImage"] ?? track?.["coverUri"]}) ?? null; return { id: `${album.id}_${track.id}`, title: `${track?.title ?? track?.name}` + (track.version ? ` - ${track.version}` : ""), image, url: `https://${this.url}/album/${album.id}/track/${track.id}`, - time: { total: (track["durationMs"] / 1000).toFixed(0) ?? "250" as any }, + time: { total: (track["durationMs"] / 1000).toFixed(0) ?? "250" }, artist: track.author ?? { title: author?.name, diff --git a/src/index.ts b/src/index.ts index f791e393..cbe57a8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { DiscordClient, ShardManager } from "#structures/discord"; +import { initSharedDatabase } from "#worker/db"; import { db, initDatabase } from "#app/db"; import { Logger } from "#structures"; import { env } from "#app/env"; @@ -11,24 +12,26 @@ void main(); * @description Запуск всего проекта в async режиме * @function main * @returns void or Promise + * @private */ function main() { const isManager = process.argv.includes("--ShardManager"); // Если включен менеджер осколков - if (isManager) return runShardManager(); + if (isManager) return execute_shardManager(); // Запускаем осколок - return runShard(); + return execute_shard(); } /** * @author SNIPPIK * @description Если требуется запустить менеджер осколков - * @function runShardManager + * @function execute_shardManager * @returns void + * @private */ -function runShardManager() { +function execute_shardManager() { Logger.log("WARN", `[Manager] has running ${Logger.color(36, `ShardManager...`)}`); new ShardManager(__filename, env.get("token.discord")); } @@ -36,11 +39,12 @@ function runShardManager() { /** * @author SNIPPIK * @description Если требуется запустить осколок - * @function runShard + * @function execute_shard * @returns Promise * @async + * @private */ -async function runShard() { +async function execute_shard() { Logger.log("WARN", `[Core] has running ${Logger.color(36, `shard`)}`); const client = new DiscordClient(); @@ -48,13 +52,11 @@ async function runShard() { // Инициализируем базу данных initDatabase(client); - - // Запускаем бота - await client.login(env.get("token.discord")); + initSharedDatabase(); // Загружаем API await db.api.startWorker(); - Logger.log("LOG", `[Core/${id}] Loaded ${Logger.color(34, `${db.api.allow.length} APIs`)}`); + Logger.log("LOG", `[Core/${id}] Loaded ${Logger.color(34, `${db.api.array.length} APIs`)}`); // Загружаем components db.components.register(); @@ -64,6 +66,11 @@ async function runShard() { db.middlewares.register(); Logger.log("LOG", `[Core/${id}] Loaded ${Logger.color(34, `${db.middlewares.size} middlewares`)}`); + + // Запускаем бота + await client.login(env.get("token.discord")); + + // Загружаем events db.events.register(client); Logger.log("LOG", `[Core/${id}] Loaded ${Logger.color(34, `${db.events.size} events`)}`); @@ -73,7 +80,7 @@ async function runShard() { Logger.log("LOG", `[Core/${id}] Loaded ${Logger.color(34, `${db.commands.public.length} public, ${db.commands.owner.length} dev commands`)}`); // Запускаем отслеживание событий процесса - initProcessEvents(client); + init_process_events(client); // Запускаем Garbage Collector setImmediate(() => { @@ -82,16 +89,50 @@ async function runShard() { global.gc(); } }); + + + // Тест постоянной нагрузки на event loop + /*setInterval(() => { + const startBlock = performance.now(); + while (performance.now() - startBlock < 100) {} + }, 60); + + setInterval(() => { + const startBlock = performance.now(); + while (performance.now() - startBlock < 100) {} + }, 80); + + setInterval(() => { + const startBlock = performance.now(); + while (performance.now() - startBlock < 100) {} + }, 120); + + setInterval(() => { + const startBlock = performance.now(); + while (performance.now() - startBlock < 100) {} + }, 100);*/ + +/* + // Тест временной нагрузки на event loop + let size = 1000; + setInterval(() => { + if (size === 0) return; + size--; + + const startBlock = performance.now(); + while (performance.now() - startBlock < 100) {} + }, 100);*/ } /** * @author SNIPPIK * @description Инициализирует события процесса (ошибки, сигналы) * @param client - Класс клиента - * @function initProcessEvents + * @function init_process_events * @returns void + * @private */ -function initProcessEvents(client: DiscordClient) { +function init_process_events(client: DiscordClient): void { // Необработанная ошибка (внутри синхронного кода) process.on("uncaughtException", (err) => { // Скорее всего дело в Discord.js @@ -113,7 +154,7 @@ function initProcessEvents(client: DiscordClient) { // Возможность завершить процесс корректно for (const event of ["SIGINT", "SIGTERM"]) { process.on(event, () => { - if (ProcessQueues(client)) return; + if (init_queue_destroyer(client)) return; Logger.log("WARN", `Received ${event}. Shutting down...`); process.exit(0); @@ -125,10 +166,11 @@ function initProcessEvents(client: DiscordClient) { * @author SNIPPIK * @description Функция проверяющая состояние очередей, для безопасного выключения * @param client - Класс клиента - * @function ProcessQueues - * @returns void + * @function init_queue_destroyer + * @returns boolean + * @private */ -function ProcessQueues(client: DiscordClient): boolean { +function init_queue_destroyer(client: DiscordClient): boolean { if (db.queues.size > 0) { // Отключаем все события от клиента, для предотвращения включения или создания еще очередей client.removeAllListeners(); @@ -139,7 +181,7 @@ function ProcessQueues(client: DiscordClient): boolean { // Если плееры играют и есть остаток от аудио if (timeout > 0) { // Ожидаем выключения музыки на других серверах - setTimeout(() => { process.exit(0); }, timeout + 1e3); + setTimeout(() => { process.exit(0); }, timeout + 1e3).ref(); Logger.log("WARN", `[Queues/${db.queues.size}] Wait other queues. Timeout to restart ${(timeout / 1e3).duration()}`); return true; diff --git a/src/structures/tools/Collection.ts b/src/structures/array/index.collection.ts similarity index 89% rename from src/structures/tools/Collection.ts rename to src/structures/array/index.collection.ts index 63fa638b..05a20231 100644 --- a/src/structures/tools/Collection.ts +++ b/src/structures/array/index.collection.ts @@ -5,12 +5,8 @@ * @public */ export class Collection { - /** - * @description База Map для взаимодействия с объектами через идентификатор - * @readonly - * @private - */ - private readonly _map = new Map(); + /** База Map для взаимодействия с объектами через идентификатор */ + private _map = new Map(); /** * @description Получаем случайный объект из MAP @@ -25,7 +21,7 @@ export class Collection { * @returns number * @public */ - public get size(): number { + public get size() { return this._map.size; }; @@ -86,9 +82,11 @@ export class Collection { /** * @description Удаление всего из set/map + * @returns void * @public */ - public clear = (): void => { + public clear = () => { this._map.clear(); + this._map = null; }; } \ No newline at end of file diff --git a/src/structures/tools/SetArray.ts b/src/structures/array/index.set.ts similarity index 50% rename from src/structures/tools/SetArray.ts rename to src/structures/array/index.set.ts index 47a61db4..c71b3c01 100644 --- a/src/structures/tools/SetArray.ts +++ b/src/structures/array/index.set.ts @@ -15,6 +15,46 @@ export class SetArray extends Set { return Array.from(this.values()); }; + /** + * @description Добавление задачи в базу + * @param task - Задача + * @public + */ + public add(task: T) { + if (this.has(task)) this.delete(task); + + super.add(task); + return this; + }; + + /** + * @description Удаляет элемент из массива + * @param item - объект задачи или item с next + * @returns true если элемент найден и удалён, иначе false + * @public + */ + public delete(item: T) { + if (!this.has(item)) { + this.delete(item); + return true; + } + + super.delete(item); + return true; + }; + + /** + * @description Получаем объект из списка + * @param item - оригинальный объект + * @public + */ + public get(item: T) { + const array = this.array; + const index = array.indexOf(item); + + return index > -1 ? array[index] : null; + }; + /** * @description Производим фильтрацию по функции * @param predicate - Функция поиска diff --git a/src/structures/array/index.ts b/src/structures/array/index.ts new file mode 100644 index 00000000..7fdf0bae --- /dev/null +++ b/src/structures/array/index.ts @@ -0,0 +1,2 @@ +export * from "./index.set"; +export * from "./index.collection"; \ No newline at end of file diff --git a/src/structures/discord/Client.ts b/src/structures/discord/index.client.ts similarity index 75% rename from src/structures/discord/Client.ts rename to src/structures/discord/index.client.ts index 2c386101..3e37a838 100644 --- a/src/structures/discord/Client.ts +++ b/src/structures/discord/index.client.ts @@ -1,6 +1,5 @@ import { Client, Options, Partials } from "discord.js"; import { ActivityType } from "discord-api-types/v10"; -import { VoiceAdapters } from "#core/voice/adapter"; import { version } from "package.json"; import { Logger } from "#structures"; import { env } from "#app/env"; @@ -20,7 +19,11 @@ export class DiscordClient extends Client { * @public */ public get shardID(): number { - return this.shard?.ids[0] ?? 0; + try { + return this.shard?.count - 1; + } catch { + return 0; + } }; /** @@ -30,11 +33,14 @@ export class DiscordClient extends Client { */ public constructor() { super({ - // Данный раздел не трогать, иначе вы нарушите лицензию BSD-3 presence: { - afk: false, status: "online", - activities: [{name: " 💫 Startup...", type: 4}] + activities: [ + { + name: " 💫 Startup...", + type: 4 + } + ] }, // Права бота @@ -44,7 +50,6 @@ export class DiscordClient extends Client { // Отправление сообщений "GuildMessages", - "DirectMessages", // Нужен для голосовой системы "GuildVoiceStates", @@ -65,6 +70,17 @@ export class DiscordClient extends Client { // Задаем параметры кеша makeCache: Options.cacheWithLimits({ ...Options.DefaultMakeCacheSettings, + ...Options.DefaultSweeperSettings, + MessageManager: { + keepOverLimit: (value) => value.createdTimestamp > (Date.now() + 60e3 * 10) + }, + GuildScheduledEventManager: 0, + GuildTextThreadManager: 0, + BaseGuildEmojiManager: 0, + ReactionManager: 0, + ReactionUserManager: 0, + EntitlementManager: 0, + StageInstanceManager: 0, GuildBanManager: 0, GuildForumThreadManager: 0, AutoModerationRuleManager: 0, @@ -76,26 +92,27 @@ export class DiscordClient extends Client { ThreadMemberManager: 0, }) }); + + // Ограничиваем кол-во событий this.setMaxListeners(10); this.ws.setMaxListeners(10); // Запускаем статусы после инициализации клиента - this.once("clientReady", this.IntervalStatus); + this.once("clientReady", this.initSwapStatus); }; /** * @description Функция создания и управления статусом * @returns void - * @readonly * @private */ - private readonly IntervalStatus = (): void => { + private initSwapStatus = (): void => { // Время обновления статуса const timeout = parseInt(env.get("client.presence.interval", "120")); const arrayUpdate = parseInt(env.get("client.presence.array.update", "3600")) * 1e3; const clientID = this.shardID; - let array = this.parseStatuses(); + let array = this.prepareStatuses(); let size = array.length - 1; let i = 0, lastDate = Date.now() + arrayUpdate ; @@ -106,11 +123,11 @@ export class DiscordClient extends Client { } // Интервал для обновления статуса - setInterval(async () => { + setInterval(() => { // Обновляем статусы if (lastDate < Date.now()) { // Обновляем статусы - array = this.parseStatuses(); + array = this.prepareStatuses(); // Обновляем время для следующего обновления lastDate = Date.now() + arrayUpdate; @@ -134,17 +151,16 @@ export class DiscordClient extends Client { /** * @description Функция подготавливающая статусы * @returns ActivityOptions[] - * @readonly * @private */ - private readonly parseStatuses = (): ActivityOptions[] => { + private prepareStatuses = (): ActivityOptions[] => { const statuses: ActivityOptions[] = []; const guilds = this.guilds.cache.size; const users = this.users.cache.size; // Получаем пользовательские статусы try { - const presence = (JSON.parse(`[${env.get("client.presence.array")}]`) as ActivityOptions[]); + const presence = (JSON.parse(`[${env.get("client.presence.array")}]`) as ActivityOptionsRaw[]); const envPresents = presence.map((status) => { const edited = status.name .replace(/{shard}/g, `${this.shardID + 1}`) @@ -155,7 +171,7 @@ export class DiscordClient extends Client { return { name: edited, - type: ActivityType[status.type] as any, + type: ActivityType[status.type], shardId: this.shardID } }); @@ -170,51 +186,11 @@ export class DiscordClient extends Client { }; } -/** - * @author SNIPPIK - * @description Класс реализации адаптера - * @class DJSVoice - * @extends VoiceAdapters - * @public - */ -export class DJSVoice extends VoiceAdapters { - public constructor(client: T) { - super(client); - - //@ts-ignore - client.ws.on("VOICE_SERVER_UPDATE", (data) => { - this.onVoiceServer(data); - }); - - //@ts-ignore - client.ws.on("VOICE_STATE_UPDATE", (data) => { - this.onVoiceStateUpdate(data); - }); - }; - - public voiceAdapterCreator = (guildID: string) => { - const id = this.client.shardID; - - return methods => { - this.adapters.set(guildID, methods); - - return { - sendPayload: (data) => { - this.client.ws.shards.get(id).send(data); - return true; - }, - destroy: () => { - this.adapters.delete(guildID); - } - }; - }; - }; -} - /** * @author SNIPPIK * @description Параметры показа статуса * @interface ActivityOptions + * @private */ interface ActivityOptions { name: string; @@ -222,4 +198,15 @@ interface ActivityOptions { url?: string; type?: ActivityType; shardId?: number | readonly number[]; +} + +/** + * @author SNIPPIK + * @description Данные статуса бота из env + * @interface ActivityOptionsRaw + * @private + */ +//@ts-ignore +interface ActivityOptionsRaw extends ActivityOptions { + type?: string; } \ No newline at end of file diff --git a/src/structures/discord/index.manager.ts b/src/structures/discord/index.manager.ts new file mode 100644 index 00000000..84810b4b --- /dev/null +++ b/src/structures/discord/index.manager.ts @@ -0,0 +1,55 @@ +import { ShardingManager } from "discord.js"; +import { Logger } from "#structures"; + +/** + * @author SNIPPIK + * @description Класс менеджера осколков + * @class ShardManager + * @extends ShardingManager + * @public + */ +export class ShardManager extends ShardingManager { + /** + * @description Создание менеджера осколков + * @param file - путь до файла запуска осколка + * @param token - токен бота + * @constructor + * @public + */ + public constructor(file: string, token: string) { + super(file, { + execArgv: [ + "-r", "tsconfig-paths/register", + "--expose-gc", + "--optimize_for_size", + "--experimental-require-module", + "--no-compilation-cache" + ], + token: token, + mode: "process", + respawn: true + }); + + // Слушаем событие для создания осколка + this.on("shardCreate", (shard) => { + shard.setMaxListeners(3); + shard.on("spawn", () => Logger.log("LOG", `[Manager/${shard.id}] shard ${Logger.color(36, `added to manager`)}`)); + shard.on("ready", () => Logger.log("LOG", `[Manager/${shard.id}] shard is ${Logger.color(36, `ready`)}`)); + shard.on("death", () => Logger.log("LOG", `[Manager/${shard.id}] shard is ${Logger.color(31, `killed`)}`)); + + // Запускаем Garbage Collector + setImmediate(() => { + if (global.gc) global.gc(); + }); + }); + this.setMaxListeners(1); + + // Создаем дубликат + this.spawn({ + amount: "auto", + delay: -1 + }) + // Перехватываем ошибку + .catch((err: Error) => Logger.log("ERROR", err)); + }; +} \ No newline at end of file diff --git a/src/structures/discord/index.ts b/src/structures/discord/index.ts index 775f1ff5..46d8d746 100644 --- a/src/structures/discord/index.ts +++ b/src/structures/discord/index.ts @@ -1,14 +1,9 @@ -import { - ChatInputCommandInteraction, - AutocompleteInteraction, - CacheType, - ButtonInteraction, Message -} from "discord.js"; -import { ShardingManager } from "discord.js"; -import { Logger } from "#structures"; -import { DiscordClient } from "#structures/discord/Client"; +import type { ChatInputCommandInteraction, AutocompleteInteraction, CacheType, ButtonInteraction, Message, AnySelectMenuInteraction } from "discord.js"; +import type { DiscordClient } from "#structures/discord/index.client"; -export * from "./Client"; +export * from "./index.manager"; +export * from "./index.client"; +export * from "./index.voice"; /** * @description Тип входящих данных для команд @@ -31,6 +26,13 @@ export type CompeteInteraction = AutocompleteInteraction; */ export type buttonInteraction = ButtonInteraction; +/** + * @description Тип входящих данных для циклической системы + * @type buttonInteraction + * @public + */ +export type SelectMenuInteract = AnySelectMenuInteraction; + /** * @description Тип входящих данных для циклической системы * @type buttonInteraction @@ -38,6 +40,13 @@ export type buttonInteraction = ButtonInteraction; */ export type CycleInteraction = Message; +/** + * @description Тип входящих данных для циклической системы + * @type MessageComponent + * @public + */ +export type MessageComponent = any; + /** * @author SNIPPIK * @description Все цвета для embed сообщений @@ -85,51 +94,8 @@ declare module "discord.js" { member: GuildMember; } + //@ts-ignore export interface GuildMemberManager { client: DiscordClient; } -} - -/** - * @author SNIPPIK - * @description Класс менеджера осколков - * @class ShardManager - * @extends ShardingManager - * @public - */ -export class ShardManager extends ShardingManager { - /** - * @description Создание менеджера осколков - * @param file - путь до файла запуска осколка - * @param token - токен бота - * @constructor - * @public - */ - public constructor(file: string, token: string) { - super(file, { - execArgv: ["-r", "tsconfig-paths/register", "--expose-gc", "--optimize_for_size"], - token: token, - mode: "process", - respawn: true, - totalShards: "auto", - shardList: "auto" - }); - - // Слушаем событие для создания осколка - this.on("shardCreate", async (shard) => { - shard.setMaxListeners(3); - shard.on("spawn", () => Logger.log("LOG", `[Manager/${shard.id}] shard ${Logger.color(36, `added to manager`)}`)); - shard.on("ready", () => Logger.log("LOG", `[Manager/${shard.id}] shard is ${Logger.color(36, `ready`)}`)); - shard.on("death", () => Logger.log("LOG", `[Manager/${shard.id}] shard is ${Logger.color(31, `killed`)}`)); - - // Запускаем Garbage Collector - setImmediate(() => { - if (global.gc) global.gc(); - }); - }); - this.setMaxListeners(1); - - // Создаем дубликат - this.spawn({amount: "auto", delay: -1}).catch((err: Error) => Logger.log("ERROR", err)); - }; } \ No newline at end of file diff --git a/src/structures/discord/index.voice.ts b/src/structures/discord/index.voice.ts new file mode 100644 index 00000000..b5019f42 --- /dev/null +++ b/src/structures/discord/index.voice.ts @@ -0,0 +1,58 @@ +import { VoiceAdapters, DiscordGatewayAdapterLibraryMethods } from "#core/voice/adapter"; +import type { DiscordClient } from "#structures/discord/index.client"; + +/** + * @author SNIPPIK + * @description Класс реализации адаптера + * @class DJSVoice + * @extends VoiceAdapters + * @public + */ +export class DJSVoice extends VoiceAdapters { + public constructor(client: T) { + super(client); + + //@ts-ignore + client.ws.on("VOICE_SERVER_UPDATE", this.onVoiceServer); + + //@ts-ignore + client.ws.on("VOICE_STATE_UPDATE", this.onVoiceStateUpdate); + }; + + /** + * @description Реализация смены статуса голосового канала + * @param channelId - ID голосового канала + * @param status - Название заголовка + * @public + */ + public status = (channelId: string, status: string = "") => { + this.client.rest.put(`/channels/${channelId}/voice-status`, { + body: { + status: status + } + }).catch(() => null); + }; + + /** + * @description Создаем прослойку адаптера голосового соединения + * @param guildID - ID сервера + * @public + */ + public voiceAdapterCreator = (guildID: string) => { + const id = this.client.shardID; + + return (methods: DiscordGatewayAdapterLibraryMethods) => { + this.adapters.set(guildID, methods); + + return { + sendPayload: (data: object) => { + this.client.ws.shards.get(id)?.send(data); + return true; + }, + destroy: () => { + this.adapters.delete(guildID); + } + }; + }; + }; +} \ No newline at end of file diff --git a/src/structures/index.ts b/src/structures/index.ts index 8a32645b..6bba80dc 100644 --- a/src/structures/index.ts +++ b/src/structures/index.ts @@ -1,6 +1,6 @@ - /** - * @description Все prototype объектов + * @author SNIPPIK + * @description Все prototype объектов, для модификации функций * @remark * Использовать с умом, если попадут не те данные то могут быть ошибки */ @@ -9,42 +9,106 @@ const prototypes: { type: any, name: string, value: any}[] = [ { type: String.prototype, name: "duration", value: function () { - // Если требуется преобразовать число из строки в число - if ((this as any).match(/^\d+$/)) return parseInt(this as any); + const str = String(this).trim(); + if (!str) return 0; + + // Формат "HH:MM:SS" или "MM:SS" + if (str.includes(":")) { + // Разбираем строку по ":" и конвертируем в числа + const parts = str.split(":").map(Number); + + // Если parts.length = 3 (HH:MM:SS), parts = [H, M, S] + // Если parts.length = 2 (MM:SS), parts = [M, S] + // Если parts.length = 1 (S), parts = [S] + + let seconds = 0; + + // Начинаем с конца и умножаем на 60 в соответствующей степени + for (let i = 0; i < parts.length; i++) { + const part = parts[parts.length - 1 - i]; // S, M, H + // (S * 60^0) + (M * 60^1) + (H * 60^2) + seconds += part * (60 ** i); + } + return seconds; + } - // Если надо разобрать строковое время в число - else if (!(this as any).match(":")) { - let hours = 0, minutes = 0, seconds = 0; + // Если строка содержит только цифры, преобразуем в целое число (считаем секундами) + if (/^\d+$/.test(str)) { + return parseInt(str, 10); + } + + // Формат "1h 30m 5s" (с использованием регулярных выражений) + let totalSeconds = 0; - const h = (this as any).match(/(\d+)\s*(?:hour|hours|hr|hrs)/i); - const m = (this as any).match(/(\d+)\s*(?:minute|minutes|min|mins)/i); - const s = (this as any).match(/(\d+)\s*(?:second|seconds|sec|secs)/i); + // Регулярное выражение для поиска H, M, S с их сокращениями + const regex = /(?:(\d+)\s*(?:hours?|hr|hrs|h))?\s*(?:(\d+)\s*(?:minutes?|min|mins|m))?\s*(?:(\d+)\s*(?:seconds?|sec|secs|s))?/i; + const match = str.match(regex); - if (h) hours = parseInt(h[1], 10); - if (m) minutes = parseInt(m[1], 10); - if (s) seconds = parseInt(s[1], 10); + if (match) { + const hours = parseInt(match[1] || '0', 10); + const minutes = parseInt(match[2] || '0', 10); + const seconds = parseInt(match[3] || '0', 10); - return seconds + (minutes * 60) + (hours * 3600); + totalSeconds += seconds; + totalSeconds += minutes * 60; + totalSeconds += hours * 3600; } - // Если указан формат HH:MM:SS - const time = this?.["split"](":").map(Number); - return time.length === 1 ? time[0] : time.reduce((acc: number, val: number) => acc * 60 + val); + return totalSeconds; } }, // Number { type: Number.prototype, name: "duration", - value: function () { - const t = Number(this), days = ~~(t / 86400), hours = ~~(t % 86400 / 3600), min = ~~(t % 3600 / 60), sec = ~~(t % 60); - return [days && days, (days || hours) && hours.toZero(), min.toZero(), sec.toZero()].filter(Boolean).join(":"); - } - }, - { - type: Number.prototype, name: "toZero", - value: function (size: number = 2) { - return String(this).padStart(size, "0"); + value: function (ms: boolean = false) { + const t = Number(this); + if (isNaN(t) || t < 0) return "00:00"; + + // Внутренняя функция для добавления ведущего нуля + const toZero = (val: number) => String(val).padStart(2, '0'); + + // Выделяем дни, часы, минуты, секунды + const days = Math.floor(t / 86400); // 86400 = 24 * 3600 + let remainder = t % 86400; + + const hours = Math.floor(remainder / 3600); + remainder %= 3600; + + const minutes = Math.floor(remainder / 60); + const seconds = Math.floor(remainder % 60); + + // Форматируем части + const parts: (string | number)[] = []; + + if (days > 0) parts.push(`${days}d`); + + // Часы показываем, только если есть дни ИЛИ если это самый большой элемент + // (например, "01:30:00" а не "30:00") + if (days > 0 || hours > 0) { + // Если есть дни, форматируем часы с нулем, иначе просто числом + parts.push(days > 0 ? toZero(hours) : hours); + } + + // Минуты и секунды обязательны + parts.push(toZero(minutes), toZero(seconds)); + + // Соединяем + let result = parts + .filter(Boolean) // Удаляем потенциальные нули + .join(":"); + + // Если надо указать миллисекунды + if (ms) { + // Добавляем миллисекунды (если число было дробным) + const milliseconds = Math.round((t - Math.floor(t)) * 1000); + if (milliseconds > 0) { + // Оставляем только 3 знака после запятой + result += `.${String(milliseconds).padStart(3, '0')}`; + } + } + + return result; } }, { @@ -52,28 +116,21 @@ const prototypes: { type: any, name: string, value: any}[] = [ value: function (min = 0) { return Math.floor(Math.random() * ((this as any) - min) + min); } - }, + } ]; /** + * @author SNIPPIK * @description Задаем функции для их использования в проекте + * @private */ for (const {type, name, value} of prototypes) { Object.defineProperty(type, name, { value, writable: true, configurable: true }); } -export * from "./logger"; -export * from "./locale"; -export * from "./tools/TypedEmitter"; -export * from "./tools/Assign"; -export * from "./tools/Collection"; -export * from "./tools/SetArray"; -export * from "./tools/Cycle"; -export * from "./tools/httpsClient"; -export * from "./tools/SimpleWorker"; - /** - * @description Декларируем для TS + * @author SNIPPIK + * @description Декларируем данные для работы с typescript * @global */ declare global { @@ -95,7 +152,7 @@ declare global { * @description Превращаем число в 00:00 * @returns string */ - duration(): string; + duration(ms?: boolean): string; /** * @prototype Number @@ -103,12 +160,14 @@ declare global { * @param min {number} Мин число */ random(min?: number): number; - - /** - * @prototype Number - * @description Функция превращающая число в строку с добавлением 0 - * @param size - Размер ряда, 2 = 00 - */ - toZero(size?: number): number; } -} \ No newline at end of file +} + +export * from "./array"; +export * from "./logger"; +export * from "./locale"; +export * from "./tools/TypedEmitter"; +export * from "./tools/Assign"; +export * from "./tools/Cycle"; +export * from "./tools/httpsClient"; +export * from "./tools/SimpleWorker"; \ No newline at end of file diff --git a/src/structures/locale/languages.json b/src/structures/locale/languages.json index 3bdb6876..94fe9e72 100644 --- a/src/structures/locale/languages.json +++ b/src/structures/locale/languages.json @@ -231,8 +231,8 @@ }, "player.button.lyrics.fail": { - "en-US": "The song lyrics were not found!", - "ru": "Текст песни не был найден!" + "en-US": "Provider: {ARGUMENT}\n - The song lyrics were not found!", + "ru": "Поставщик: {ARGUMENT}\n - Текст песни не был найден!" }, @@ -271,6 +271,11 @@ "ru": "{ARGUMENT} | Я покинул голосовой канал" }, + "voice.leave.fail": { + "en-US": "{ARGUMENT} | I can't leave the voice channel, I'm not even connected!", + "ru": "{ARGUMENT} | Я не могу покинуть голосовой канал, я даже не подключен!" + }, + "voice.join": { "en-US": "{ARGUMENT} | Join success", "ru": "{ARGUMENT} | Произведено подключение" @@ -362,8 +367,8 @@ }, "api.platform.error": { - "en-US": "**Critical Error:** {ARGUMENT}", - "ru": "**Критическая ошибка:** {ARGUMENT}" + "en-US": "**Critical Error:**\n{ARGUMENT}", + "ru": "**Критическая ошибка:**\n{ARGUMENT}" }, @@ -526,13 +531,13 @@ }, "command.filter.push.argument": { - "en-US": "An argument must be in the range from {ARGUMENT} to {ARGUMENT}!", - "ru": "Необходимо указать аргумент в диапазоне от {ARGUMENT} до {ARGUMENT}!" + "en-US": "An argument must be in the range from **{ARGUMENT}** to **{ARGUMENT}**!", + "ru": "Необходимо указать аргумент в диапазоне от **{ARGUMENT}** до **{ARGUMENT}**!" }, "command.filter.push.unsupported": { - "en-US": "This filter {ARGUMENT} is not compatible with {ARGUMENT}, it must be disabled!", - "ru": "Этот фильтр {ARGUMENT} не совместим с {ARGUMENT}, его необходимо необходимо отключить!" + "en-US": "This filter **{ARGUMENT}** is not compatible with **{ARGUMENT}**, it must be disabled!", + "ru": "Этот фильтр **{ARGUMENT}** не совместим с **{ARGUMENT}**, его необходимо отключить!" }, "command.filter.push.before": { diff --git a/src/structures/logger/index.ts b/src/structures/logger/index.ts index 15e33cf0..a3ac6dc2 100644 --- a/src/structures/logger/index.ts +++ b/src/structures/logger/index.ts @@ -1,4 +1,5 @@ import * as process from "node:process"; +import { inspect } from "node:util"; import { env } from "#app/env"; import path from "node:path"; import fs from "node:fs"; @@ -36,16 +37,18 @@ const db = { * @description Функция создания локального времени * @private */ -const createDate = (ms: boolean = false) => { +const createDate = () => { const local_date = new Date(); - return `${local_date.getDate().toZero()}.${(local_date.getMonth() + 1).toZero()}.${local_date.getFullYear()} ${local_date.getHours().toZero()}:${local_date.getMinutes().toZero()}` + (ms ? `.${local_date.getMilliseconds().toZero(4)}` : ""); + const DMY = `${local_date.getDate()}.${(local_date.getMonth() + 1)}.${local_date.getFullYear()}`; + const time = (local_date.getHours() * 3600 + local_date.getMinutes() * 60 + local_date.getSeconds() + local_date.getMilliseconds() / 1e3).duration(true); + return `${DMY} ` + time; } /** * @description Время запуска процесса * @private */ -const _timestamp = createDate(true); +let _timestamp = null; /** * @author SNIPPIK @@ -73,7 +76,7 @@ export class Logger { * @private * @static */ - private static _createFiles = env.get("cache.file"); + private static _createFiles = this.debug ? env.get("cache.file") : null; /** * @description Отправляем лог в консоль @@ -82,42 +85,45 @@ export class Logger { * @static */ public static log = (status: keyof typeof db.status, text: string | Error): void => { - const extStatus = db.status[status]; - - // Получаем память в мегабайтах с двумя знаками после запятой - const mem = process.memoryUsage(); - const memUsedMB = ((mem.heapUsed + mem.external + mem.arrayBuffers) / 1024 / 1024).toFixed(2); - const time = createDate(true); - - // Если пришел текст - if (typeof text === "string") { - // Сохраняем логи - this.saveLog(`[RAM ${memUsedMB} MB] ${time} | ${status} - ${text}`); - text = `${text}`.replace(/\[/, `\x1b[104m\x1b[30m|`).replace(/]/, "|\x1b[0m"); - } - - // Если вместо текста пришла ошибка - else if (text instanceof Error) { - text = `Uncaught Exception\n` + - `┌ Name: ${text.name}\n` + - `├ Message: ${text.message}\n` + - `├ Origin: ${text}\n` + - `└ Stack: ${text.stack}`; - - // Сохраняем логи - this.saveLog(`[RAM ${memUsedMB} MB] ${time} | ${status} - ${text}`); - } - - // Если объект - else if (typeof text === "object") { - text = JSON.stringify(text); - } - - // Игнорируем debug сообщения - if (status === "DEBUG" && !this.debug) return; - - // Отправляем лог - process.stdout.write(`\x1b[35m[RAM ${memUsedMB} MB]\x1b[0m \x1b[90m${time}\x1b[0m |\x1b[0m ${extStatus} ` + `${db.colors[status]} - ${text}\n`); + setImmediate(() => { + const extStatus = db.status[status]; + + // Получаем память в мегабайтах с двумя знаками после запятой + const mem = process.memoryUsage(); + const memUsedMB = ((mem.heapUsed + mem.external + mem.arrayBuffers) / 1024 / 1024).toFixed(2); + const time = createDate(); + + // Если пришел текст + if (typeof text === "string") { + // Сохраняем логи + this.saveLog(`[RAM ${memUsedMB} MB] ${time} | ${status} - ${text}`); + text = `${text}`.replace(/\[/, `\x1b[104m\x1b[30m|`).replace(/]/, "|\x1b[0m"); + } + + // Если вместо текста пришла ошибка + else if (text instanceof Error) { + text = `Uncaught Exception\n` + + `┌ Name: ${text.name}\n` + + `├ Message: ${text.message}\n` + + `└ Stack: ${text.stack}`; + + // Сохраняем логи + this.saveLog(`[RAM ${memUsedMB} MB] ${time} | ${status} - ${text}`); + } + + // Если объект + else if (typeof text === "object") { + text = inspect(text, {depth: 3, colors: false}); + } + + // Игнорируем debug сообщения + if (status === "DEBUG" && !this.debug) return; + + // Отправляем лог + process.stdout.write(`\x1b[35m[RAM ${memUsedMB} MB]\x1b[0m \x1b[90m${time}\x1b[0m |\x1b[0m ${extStatus} ` + `${db.colors[status]} - ${text}\n`); + + if (!_timestamp) _timestamp = time; + }); }; /** @@ -127,13 +133,17 @@ export class Logger { * @static */ private static saveLog = (text: string) => { - if (!this._createFiles) return; + try { + if (!this._createFiles) return; - // Если нет пути сохранения - else if (!fs.existsSync(this._path)) fs.mkdirSync(this._path); + // Если нет пути сохранения + else if (!fs.existsSync(this._path)) fs.mkdirSync(this._path); - // Сохраняем данные в файл - fs.appendFileSync(`${this._path}/${_timestamp}.txt`, text + "\n", "utf8"); + // Сохраняем данные в файл + fs.appendFileSync(`${this._path}/${_timestamp}.txt`, text + "\n", "utf8"); + } catch { + return; + } }; /** diff --git a/src/structures/tools/Assign.ts b/src/structures/tools/Assign.ts index cec570ca..e8c6499f 100644 --- a/src/structures/tools/Assign.ts +++ b/src/structures/tools/Assign.ts @@ -13,6 +13,6 @@ export abstract class Assign { * @protected */ protected constructor(options: T) { - Object.assign(this as this & T, options); + Object.assign(this, options); }; } \ No newline at end of file diff --git a/src/structures/tools/Cycle.ts b/src/structures/tools/Cycle.ts index 54e6f060..77fecdfd 100644 --- a/src/structures/tools/Cycle.ts +++ b/src/structures/tools/Cycle.ts @@ -1,67 +1,48 @@ -import { performance } from "perf_hooks"; -import { SetArray } from "#structures"; +import { performance } from "node:perf_hooks"; +import { SetArray } from "#structures/array"; /** * @author SNIPPIK * @description Базовый класс цикла - * @class BaseCycle + * @class DefaultCycleSystem * @extends SetArray * @abstract * @private */ -abstract class BaseCycle extends SetArray { - /** - * @description Последний записанное значение performance.now(), нужно для улавливания event loop lags - * @private - */ +abstract class DefaultCycleSystem extends SetArray { + /** Последний записанное значение performance.now(), нужно для улавливания event loop lags */ private performance: number = 0; - /** - * @description Последний записанное значение performance.now(), нужно для сглаживания лага - * @private - */ - private prevEventLoopLag: number = 0; - - /** - * @description Последний сохраненный временной интервал - * @private - */ + /** Последний сохраненный временной интервал */ private lastDelay: number = 0; - /** - * @description Следующее запланированное время запуска (в ms, с плавающей точкой) - * @private - */ + /** Следующее запланированное время запуска (в ms, с плавающей точкой) */ private startTime: number = 0; - /** - * @description Время для высчитывания - * @private - */ + /** Время для высчитывания */ private tickTime: number = 0; - /** - * @description Временное число отставания цикла в миллисекундах - * @private - */ - private drift: number = 0; + /** Таймер или функция ожидания */ + private timeout: NodeJS.Timeout | NodeJS.Immediate; /** - * @description Последний зафиксированный разбег во времени + * @description Время циклической системы изнутри * @returns number * @public */ - public get drifting(): number { - return this.drift + this.lastDelay; + public get insideTime(): number { + return this.startTime + this.tickTime; }; /** - * @description Время циклической системы изнутри + * @description Метод получения времени для обновления времени цикла + * @default Date.now * @returns number - * @public + * @protected */ - public get insideTime(): number { - return this.startTime + this.tickTime; + protected get time(): number { + const startTime = process.hrtime.bigint(); + return Number(startTime) / 1_000_000; }; /** @@ -79,26 +60,51 @@ abstract class BaseCycle extends SetArray { * @private */ private set delay(duration: number) { - // Получаем следующее время - const expectedTime = this.startTime + this.tickTime + duration; - - // Корректируем шаги, для точности цикла - const step = Math.max(1, (this.time - expectedTime) / duration); + // Ожидаемое время следующего запуска (без учета _driftStep/lag) + const expectedNext = this.startTime + this.tickTime + duration; + const now = this.time; + + // Сколько шагов "пропустили" по времени (если any) + let missedSteps = 1; + if (now > expectedNext) { + // (now - expectedNext) может быть > duration * N + const over = (now - expectedNext) / duration; + missedSteps = Math.max(missedSteps, over); + } - // Делаем шаг - const timeCorrection = step * duration; - this.tickTime += timeCorrection; - this.lastDelay = timeCorrection; + const correction = missedSteps * duration; + this.tickTime += correction; + this.lastDelay = correction; }; /** - * @description Метод получения времени для обновления времени цикла - * @default Date.now - * @returns number + * @description Высчитываем задержки event loop * @protected */ - protected get time(): number { - return Number(process.hrtime.bigint()) / 1e6; + protected get _calculateLags() { + const now = performance.now(); + + // Если это первый тик — просто инициализация + if (!this.performance) { + this.performance = now; + return 0; + } + + const delta = Math.max(0, now - this.performance - this.lastDelay); + this.performance = now; + + // Отдаем задержку Event loop + return delta; + }; + + /** + * @description Создаем класс и добавляем параметры + * @param options - Параметры для работы класса + * @constructor + * @public + */ + public constructor(public options: TaskCycleConfig | PromiseCycleConfig) { + super(); }; /** @@ -107,15 +113,11 @@ abstract class BaseCycle extends SetArray { * @public */ public add(item: T): this { - const existing = this.has(item); - - // Если добавляется уже существующий объект - if (existing) this.delete(item); - + if (this.has(item)) this.delete(item); super.add(item); - // Запускаем цикл, если добавлен первый объект - if (this.size === 1 && this.startTime === 0) { + // Запускаем цикл сразу после добавления первого элемента + if (this.size === 1 && !this.startTime) { this.startTime = this.time; setImmediate(this._stepCycle); } @@ -130,113 +132,67 @@ abstract class BaseCycle extends SetArray { */ public reset(): void { this.clear(); // Удаляем все объекты + this.reboot(); + }; + /** + * @description Подготовка данных цикла для повторного использования + * @private + */ + private reboot = () => { this.startTime = 0; this.tickTime = 0; this.lastDelay = 0; - // Чистимся от drift составляющих - this.drift = 0; - // Чистим performance.now this.performance = 0; - this.prevEventLoopLag = 0 - }; - - /** - * @description Выполняет шаг цикла с учётом точного времени следующего запуска - * @returns void - * @protected - * @abstract - */ - protected abstract _stepCycle: () => void; - - /** - * @description Проверяем время для запуска цикла повторно, без учета дрифта - * @returns void - * @protected - * @readonly - */ - protected _stepCheckTimeCycle = (duration: number): void => { - // Проверяем цикл на наличие объектов - if (this.size === 0) return this.reset(); - // Высчитываем время шага - this.delay = duration; - - // Запускаем таймер - return this._runTimeout(this.insideTime, this._stepCycle); + // Если есть таймер + if (this.timeout) this._clearTimeout(); }; /** * @description Проверяем время для запуска цикла повторно с учетом дрифта цикла * @returns void * @protected - * @readonly */ - protected _stepCheckTimeCycleDrift = (duration: number): void => { - if (this.size === 0) return this.reset(); + protected _runStepTimeout = (duration: number): void => { + // Проверяем цикл на наличие объектов + if (this.size === 0 || isNaN(duration)) return this.reset(); // Высчитываем время шага this.delay = duration; - // Коррекция event loop lag - const lags = this._calculateLags(this.lastDelay); - // Следующее время шага - const nextTargetTime = this.insideTime + this.drift - lags; - - // Запускаем шаг - this._runTimeout(nextTargetTime, () => { - const tickStart = this.time; - this._stepCycle(); - const tickEnd = this.time; + const nextTargetTime = (this.insideTime + this._calculateLags); - // Сглаживание дрейфа - this.drift = this._compensator(0.95, this.drift, tickEnd - tickStart); - }); - }; + /* TIMEOUT */ - /** - * @description Функция запуска timeout или immediate функции - * @param actualTime - Внутренне время с учетом прошлого тика - * @param callback - Функция для высчитывания - * @returns void - * @protected - * @readonly - */ - protected _runTimeout = (actualTime: number, callback: () => void): void => { - const delay = Math.max(0, actualTime - this.time); + // Время для выполнения шага + const delay = Math.max(0, nextTargetTime - this.time); + this._clearTimeout(); - (delay < 1 ? process.nextTick : setTimeout)(callback, delay); + this.timeout = setTimeout(this._stepCycle, delay); }; /** - * @description Высчитываем задержки event loop - * @param duration - Размер шага + * @description Удаляем таймер или Immediate * @protected - * @readonly */ - protected _calculateLags = (duration: number) => { - // Коррекция event loop lag - const performanceNow = performance.now(); - const driftEvent = this.performance ? Math.max(0, (performanceNow - this.performance) - duration) : 0; - this.performance = performanceNow; - - // Смягчение event loop lag - return this.prevEventLoopLag = this.prevEventLoopLag !== undefined ? this._compensator(0.95, this.prevEventLoopLag, driftEvent) : driftEvent; + protected _clearTimeout = () => { + if (!this.timeout) return; + if ('hasRef' in this.timeout) clearTimeout(this.timeout as NodeJS.Timeout); + else clearImmediate(this.timeout as NodeJS.Immediate); + this.timeout = null; }; /** - * @description Сглаживание дрифта времени, смягчает новый по сравнению со старым - * @param alpha - Значение для сглаживания - * @param old - Старое время - * @param current - Новое время - * @private + * @description Выполняет шаг цикла с учётом точного времени следующего запуска + * @returns void + * @protected + * @abstract */ - private _compensator = (alpha: number, old: number, current: number) => { - return alpha * old + (1 - alpha) * current; - }; + protected abstract _stepCycle: () => void; } /** @@ -246,16 +202,7 @@ abstract class BaseCycle extends SetArray { * @abstract * @public */ -export abstract class TaskCycle extends BaseCycle { - /** - * @description Создаем класс и добавляем параметры - * @param options - Параметры для работы класса - * @constructor - * @protected - */ - protected constructor(public readonly options: TaskCycleConfig) { - super(); - }; +export abstract class TaskCycle extends DefaultCycleSystem { /** * @description Добавляем элемент в очередь @@ -311,9 +258,7 @@ export abstract class TaskCycle extends BaseCycle { } } - // Запускаем цикл повторно - if (this.options.drift) return this._stepCheckTimeCycle(this.options.duration); - return this._stepCheckTimeCycleDrift(this.options.duration); + return this._runStepTimeout(this.options.duration); }; } @@ -324,16 +269,8 @@ export abstract class TaskCycle extends BaseCycle { * @abstract * @public */ -export abstract class PromiseCycle extends BaseCycle { - /** - * @description Создаем класс и добавляем параметры - * @param options - Параметры для работы класса - * @constructor - * @protected - */ - protected constructor(public readonly options: PromiseCycleConfig) { - super(); - }; +export abstract class PromiseCycle extends DefaultCycleSystem { + protected get time() { return Date.now(); }; /** * @description Добавляем элемент в очередь @@ -389,24 +326,23 @@ export abstract class PromiseCycle extends BaseCycle { } } - // Запускаем цикл повторно - if (this.options.drift) return this._stepCheckTimeCycle(30e3); - return this._stepCheckTimeCycleDrift(30e3); + return this._runStepTimeout(30e3); }; } /** * @author SNIPPIK - * @description Интерфейс для опций BaseCycle + * @description Интерфейс для опций DefaultCycleSystem + * @interface BaseCycleConfig * @private */ interface BaseCycleConfig { /** - * @description Допустим ли drift, если требуется учитывать дрифт для стабилизации цикла + * @description Время прогона цикла, через n времени будет запущен цикл по новой * @readonly * @public */ - readonly drift: boolean; + duration: number; /** * @description Как фильтровать объекты, вдруг объект еще не готов @@ -448,7 +384,8 @@ interface BaseCycleConfig { /** * @author SNIPPIK - * @description Интерфейс для опций SyncCycle + * @description Интерфейс для опций TaskCycle + * @interface TaskCycleConfig * @private */ interface TaskCycleConfig extends BaseCycleConfig { @@ -469,7 +406,8 @@ interface TaskCycleConfig extends BaseCycleConfig { /** * @author SNIPPIK - * @description Интерфейс для опций AsyncCycle + * @description Интерфейс для опций PromiseCycle + * @interface PromiseCycleConfig * @private */ interface PromiseCycleConfig extends BaseCycleConfig { diff --git a/src/structures/tools/SimpleWorker.ts b/src/structures/tools/SimpleWorker.ts index 7aadf552..fbdbd6eb 100644 --- a/src/structures/tools/SimpleWorker.ts +++ b/src/structures/tools/SimpleWorker.ts @@ -4,11 +4,14 @@ import path from "node:path"; /** * @author SNIPPIK - * @description Класс упрощающий работу с потоками + * @description Класс упрощающий работу с потоками, позволяет за несколько сек запускать и удалять потоки + * @class SimpleWorker + * @public */ export class SimpleWorker { /** * @description Создаем или заменяем поток + * @static * @public */ public static create({options, file, callback, postMessage, not_destroyed}: WorkerInput): Worker { @@ -29,7 +32,7 @@ export class SimpleWorker { delete require.cache[require.resolve(file)]; // Если поток должен остаться активным - if (!not_destroyed) this.destroy(worker); + if (!not_destroyed) this.destroy(worker).catch(console.error); }); return worker; @@ -38,6 +41,7 @@ export class SimpleWorker { /** * @description Уничтожаем поток * @param worker - Поток + * @static * @private */ private static destroy = async (worker: Worker) => { @@ -56,6 +60,7 @@ export class SimpleWorker { * @author SNIPPIK * @description Интерфейс для работы с потоком * @interface WorkerInput + * @private */ interface WorkerInput { file: string; diff --git a/src/structures/tools/TypedEmitter.ts b/src/structures/tools/TypedEmitter.ts index db0f3392..49fb6944 100644 --- a/src/structures/tools/TypedEmitter.ts +++ b/src/structures/tools/TypedEmitter.ts @@ -29,7 +29,7 @@ interface EventBucket { /** * @author SNIPPIK - * @description Типизированный EventEmitter построенный на Map + * @description Типизированный EventEmitter построенный на Object-Map системе, работает чуть быстрее чем vanilla EventEmitter * @template L - Интерфейс событий и их типов слушателей * @class TypedEmitter * @public @@ -37,10 +37,7 @@ interface EventBucket { * @usage Если требуется ответ в событиях once использовать async! */ export class TypedEmitter> { - /** - * @description Локальной список событий, функций в map - * @private - */ + /** Локальной список событий, функций в map */ private _set = new Map(); /** @@ -68,7 +65,7 @@ export class TypedEmitter> { */ public once>(event: E, listener: ListenerSignature[E]): this; public once(event: Exclude>, listener: DefaultListener): this; - public once(event: string, listener: (...args: any[]) => any): this { + public once(event: string, listener: (...args: L[]) => any): this { const arr = this._set.get(event) ?? []; arr.push({ listener, type: "once" }); this._set.set(event, arr); @@ -83,7 +80,7 @@ export class TypedEmitter> { * @public */ public emit>(event: E, ...args: Parameters[E]>): boolean; - public emit(event: Exclude>, ...args: any[]): boolean; + public emit(event: Exclude>, ...args: L[]): boolean; public emit(event: string, ...args: any[]): boolean { const arr = this._set?.get(event); if (!arr?.length) return false; @@ -98,11 +95,11 @@ export class TypedEmitter> { setImmediate(() => { throw err; }); }).finally(() => { // Если разовая функция - if (run.type === "once") this.off(event, run.listener as any); + if (run.type === "once") this.off(event, run.listener as ListenerSignature[string]); }); } else { // Если разовая функция - if (run.type === "once") this.off(event, run.listener as any); + if (run.type === "once") this.off(event, run.listener as ListenerSignature[string]); } } return true; @@ -117,7 +114,7 @@ export class TypedEmitter> { */ public off>(event: E, listener: ListenerSignature[E]): this; public off(event: Exclude>, listener: DefaultListener): this; - public off(event: string, listener: (...args: any[]) => any): this { + public off(event: string, listener: DefaultListener): this { const arr = this._set?.get(event); if (!arr) return this; this._set.set(event, arr.filter(x => x.listener !== listener)); @@ -131,13 +128,18 @@ export class TypedEmitter> { * @returns this * @public */ - public removeListener>(event: E, listener: ListenerSignature[E]): this; - public removeListener(event: Exclude>, listener: DefaultListener): this; + public removeListener>(event: E, listener?: ListenerSignature[E]): this; + public removeListener(event: Exclude>, listener?: DefaultListener): this; public removeListener(event: string, listener: (...args: any[]) => any): this { - this._set.delete(event); + if (!listener) { + this._set.delete(event); + return this; + } + + const arr = this._set.get(event); + if (!arr) return this; - // Если есть событие с таким именем - if (listener) listener(); + this._set.set(event, arr.filter(l => l.listener !== listener)); return this; }; diff --git a/src/structures/tools/httpsClient.ts b/src/structures/tools/httpsClient.ts index d71937f2..ead2c158 100644 --- a/src/structures/tools/httpsClient.ts +++ b/src/structures/tools/httpsClient.ts @@ -1,12 +1,12 @@ import { BrotliDecompress, createBrotliDecompress, createDeflate, createGunzip, Deflate, Gunzip } from "node:zlib"; import { request as httpsRequest, RequestOptions } from "node:https"; import { IncomingMessage, request as httpRequest } from "node:http"; -import { Logger } from "#structures/logger"; /** * @author SNIPPIK * @description Данные поступающие при head запросе * @interface httpsClient_head + * @public */ export interface httpsClient_head { // Статус код @@ -44,8 +44,10 @@ abstract class Request { // Пользовательский User-Agent userAgent?: string | boolean; } & RequestOptions = { - timeout: 3e3, - headers: {}, + timeout: 5e3, + headers: { + "Accept-Encoding": "gzip, deflate, br" + }, maxVersion: "TLSv1.3" }; @@ -66,47 +68,56 @@ abstract class Request { */ public get request(): Promise { return new Promise((resolve) => { - const request = this.protocol(this.data, (res) => { - - // Если есть редирект куда-то - if (res.headers?.location) { - if ((res.statusCode >= 300 && res.statusCode < 400)) { - this.data.path = res.headers.location; - return resolve(this.request); + // Клонируем данные, чтобы избежать изменения опций при параллельных запросах + const options = { ...this.data }; + + const req = this.protocol(options, (res) => { + + // Более строгая проверка редиректа. + // Возврат resolve(this.request) запускает новый запрос, что правильно для автоматического редиректа. + if (res.headers.location && (res.statusCode >= 300 && res.statusCode < 400)) { + // Создаем новый объект данных на основе старого + новый путь + const newUrl = res.headers.location; + + // Повторный парсинг URL для корректного обновления всех полей (hostname, protocol, path, port) + try { + const parsedUrl = new URL(newUrl); + this.data.hostname = parsedUrl.hostname; + this.data.protocol = parsedUrl.protocol; + this.data.path = parsedUrl.pathname + parsedUrl.search; + this.data.port = parsedUrl.port; + } catch (e) { + // Если редирект на некорректный URL, возвращаем ошибку + return resolve(Error(`[httpsClient]: Invalid redirect URL: ${newUrl}`)); } + + // Возвращаем промис нового запроса, чтобы продолжить цепочку + return this.request.then(resolve).catch(resolve); } return resolve(res); }); - // Если запрос POST, отправляем ответ на сервер - if (this.data.method === "POST" && this.data.body) request.write(this.data.body); - - /** - * @description Если превышено время ожидания - */ - request.once("timeout", () => { - Logger.log("DEBUG", `${this.data}`); - - return resolve(Error(`[httpsClient]: Connection Timeout Exceeded ${this.data.url}:443`)); - }); + // Обработка POST/PUT/PATCH (если есть body) + if (options.body) { + // Если body — строка, можно установить заголовок Content-Length + if (typeof options.body === "string") { + req.setHeader("Content-Length", Buffer.byteLength(options.body).toString()); + req.write(options.body); + } + } - /** - * @description Если получена ошибка - */ - request.once("error", (err) => { - return resolve(Error(`[httpsClient]: Connection Error: ${err}`)); + req.once("timeout", () => { + req.destroy(); // Уничтожаем запрос при таймауте + return resolve(Error(`[httpsClient]: Connection Timeout Exceeded ${options.hostname}:${options.port || 443}`)); }); - /** - * @description Если запрос завершен - */ - request.once("end", () => { - request.removeAllListeners(); - this.data = null; + req.once("error", (err) => { + req.destroy(); // Уничтожаем запрос при ошибке + return resolve(Error(`[httpsClient]: Connection Error: ${err.message}`)); }); - request.end(); + req.end(); }); }; @@ -117,35 +128,47 @@ abstract class Request { * @public */ public constructor(options: httpsClient["data"]) { - // Если ссылка является ссылкой - if (options.url.startsWith("http")) { - const { hostname, pathname, search, port, protocol } = URL.parse(options.url); + let parsedUrl: URL | undefined; + + // Проверяем, является ли это корректным URL, используя try/catch с URL + try { + parsedUrl = new URL(options.url); + } catch (e) { + // Если URL не корректен, можно выбросить ошибку + console.error(`[httpsClient]: Invalid URL provided: ${options.url}`); + } - // Создаем стандартные настройки - this.data = { ...this.data, port, hostname, path: pathname + search, protocol } + // Применяем стандартные настройки и настройки из URL + if (parsedUrl) { + this.data = { + ...this.data, + hostname: parsedUrl.hostname, + protocol: parsedUrl.protocol, + path: parsedUrl.pathname + parsedUrl.search, + port: parsedUrl.port || (parsedUrl.protocol === "https:" ? 443 : 80), + }; } - // Надо ли генерировать user-agent - if (options?.userAgent !== undefined) { - // Если указан свой user-agent - if (typeof options?.userAgent === "string") { - this.data.headers = { ...this.data.headers, - "User-Agent": options.userAgent - }; - - // Генерируем новый - } else { - const revision = `${(140).random(130)}.0`; - const OS = ["(X11; Linux x86_64;", "(Windows NT 10.0; Win64; x64;"]; - - this.data.headers = { ...this.data.headers, - "User-Agent": `Mozilla/5.0 ${OS[(OS.length - 1).random(0)]} rv:${revision}) Gecko/20100101 Firefox/${revision}` - }; + // Устанавливаем User-Agent + if (options.userAgent !== undefined) { + let ua: string; + + if (typeof options.userAgent === "string") ua = options.userAgent; + else { + // Генерируем новый User-Agent + const revision = Math.floor(Math.random() * 2) + 140; // Генерация числа около 140 + const OS = ["X11; Linux x86_64", "Windows NT 10.0; Win64; x64", "X11; Linux i686"]; + const randomOS = OS[Math.floor(Math.random() * OS.length)]; + + ua = `Mozilla/5.0 (${randomOS}; rv:${revision}.0) Gecko/20100101 Firefox/${revision}.0`; } + + this.data.headers = { ...this.data.headers, "User-Agent": ua }; } - options.url = null; - this.data = { ...this.data, ...options }; + // Чистое объединение опций: сначала удаляем, потом объединяем. + const { url, userAgent, ...restOptions } = options; + this.data = { ...this.data, ...restOptions }; }; } @@ -162,6 +185,8 @@ export class httpsClient extends Request { * @public */ public get toHead(): Promise { + this.data.method = "HEAD"; + return new Promise((resolve) => { this.request.then((response) => { @@ -175,9 +200,9 @@ export class httpsClient extends Request { } return resolve({ - statusCode: response.statusCode === 400 && response.statusMessage === "Bad Request" ? 200 : response.statusCode, + statusCode: response.statusCode, statusMessage: response.statusMessage, - headers: response.headers, + headers: response.headers as Record }); }); }); @@ -194,23 +219,17 @@ export class httpsClient extends Request { if (res instanceof Error) return resolve(res); const encoding = res.headers["content-encoding"]; - let decoder: BrotliDecompress | Gunzip | Deflate | IncomingMessage = res, data = ""; + let decoder: BrotliDecompress | Gunzip | Deflate | IncomingMessage = res; if (encoding === "br") decoder = res.pipe(createBrotliDecompress() as any); else if (encoding === "gzip") decoder = res.pipe(createGunzip() as any); else if (encoding === "deflate") decoder = res.pipe(createDeflate() as any); + const chunks: string[] = []; decoder.setEncoding("utf-8") - .on("data", (c) => data += c) - .once("end", () => { - setImmediate(() => { - data = null; - decoder.removeAllListeners(); - decoder.destroy(); - }); - - return resolve(data); - }); + .on("data", (c: string) => chunks.push(c)) + .once("end", () => resolve(chunks.join(""))) + .once("error", (err) => resolve(Error(`[httpsClient]: Decoding Error: ${err.message}`))); }).catch((err) => { return resolve(err); }); @@ -227,6 +246,11 @@ export class httpsClient extends Request { if (body instanceof Error) return body; try { + // Добавляем проверку на пустой/короткий body + if (typeof body !== 'string' || body.trim().length === 0) { + return Error(`Empty response body from ${this.data.hostname}`); + } + return JSON.parse(body); } catch { return Error(`Invalid json response body at ${this.data.hostname}`); @@ -244,21 +268,24 @@ export class httpsClient extends Request { try { const body = await this.toString; - // Если при получении страници произошла ошибка - if (body instanceof Error) return new Error("Not found XML data!"); + // Если при получении страниц произошла ошибка + if (body instanceof Error) return resolve(body); + // Более строгий и эффективный RegExp для извлечения текста между тегами + // Регулярное выражение: /<[^<>]+>([^<>]+)<\/[^<>]+>/g const items = body.match(/<[^<>]+>([^<>]+)<\/[^<>]+>/gi); // Если нет данных xml в странице if (!items) return resolve([]); + // ⚡️ Ускорение: Используем map с try/catch для обработки ошибок парсинга const filtered = items .map(tag => tag.replace(/<\/?[^<>]+>/gi, "").trim()) - .filter(text => text !== ""); + .filter(text => text.length > 0); // Проверка на пустую строку через length return resolve(filtered); } catch (error) { - return new Error("Unexpected error occurred"); + return resolve(Error(`[httpsClient]: Unexpected error occurred during XML parsing: ${error}`)); } }); }; @@ -268,6 +295,7 @@ export class httpsClient extends Request { * @author SNIPPIK * @description Парсинг статус-кода и возврат ошибки * @class httpsStatusCode + * @public */ export class httpsStatusCode { /** @@ -293,4 +321,4 @@ export class httpsStatusCode { // Если неизвестный статус код return Error(`[${statusCode}]: ${statusMessage}`); }; -} +} \ No newline at end of file diff --git a/src/workers/YouTubeSignatureExtractor.ts b/src/workers/YouTubeSignatureExtractor.ts deleted file mode 100644 index 51dff73a..00000000 --- a/src/workers/YouTubeSignatureExtractor.ts +++ /dev/null @@ -1,431 +0,0 @@ -import { isMainThread, parentPort } from "node:worker_threads"; -import { httpsClient } from "#structures"; -import querystring from "node:querystring"; -import { Script } from "node:vm"; - - -/** - * @author SNIPPIK - * @description Если запускается фрагмент кода в другом процессе - */ -if (!isMainThread) { - // Разовое событие - parentPort.once("message", async (message) => { - const formats = await YouTubeSignatureExtractor.decipherFormats(message.formats, message.html); - return parentPort.postMessage(formats[0]); - }); -} - -/** - * @author SNIPPIK - * @description Ищем имена в строке - * @param pattern - Как искать имена - * @param text - Строка где будем искать - */ -const mRegex = (pattern: string | RegExp, text: string): string | null => { - const match = text.match(pattern); - if (!match || match.length < 2) return null; - return match[1].replace(/\$/i, "\\$"); -}; - - -/** - * @author SNIPPIK - * @description Поиск вспомогательных данных - * @param body - Страница - */ -const extractTceFunc = (body: string) => { - try { - const matcher = body.match(NEW_TCE_GLOBAL_VARS_REGEXP); - if (!matcher?.groups?.varname || !matcher.groups.code) return null; - - return { - name: matcher.groups.varname, - code: matcher.groups.code - }; - } catch (error) { - console.error("extractTceFunc error:", error); - return null; - } -}; - - -/** - * @author SNIPPIK - * @description Расшифровщик ссылок на исходный файл для youtube - * @class Youtube_decoder - * @private - */ -class YouTubeSignatureExtractor { - /** - * @author SNIPPIK - * @description Функции для расшифровки - */ - private static extractors: { name: string, callback: (body: string, name: string, code: number) => any }[] = [ - /** - * @description Получаем функцию с данными - */ - { - name: "extractDecipherFunction", - callback: (body, _, code) => { - try { - const callerFunc = `${DECIPHER_FUNC_NAME}(${DECIPHER_ARGUMENT});`; - - // --- Попытка взять TCE-вариант (новая схема YouTube) --- - const sigFunc = body.match(TCE_SIGN_FUNCTION_REGEXP); - const sigActions = body.match(TCE_SIGN_FUNCTION_ACTION_REGEXP); - - if (sigFunc && sigActions && code) return `var ${DECIPHER_FUNC_NAME}=${sigFunc[0]}${sigActions[0]}${code};\n${callerFunc}`; - - // --- Классический helper --- - const helperMatch = body.match(HELPER_REGEXP); - if (!helperMatch) return null; - - const [helperObject, , actionBody] = helperMatch; - - // Поиск ключей операций - const keys = [ - mRegex(REVERSE_PATTERN, actionBody), - mRegex(SLICE_PATTERN, actionBody), - mRegex(SPLICE_PATTERN, actionBody), - mRegex(SWAP_PATTERN, actionBody), - ].filter(Boolean); - - if (keys.length === 0) return null; - - // --- Функция-дешифратор --- - let decipherFunc = body.match(DECIPHER_REGEXP)?.[0]; - let tceVars = ""; - - if (!decipherFunc) { - const tceFunc = body.match(FUNCTION_TCE_REGEXP); - if (!tceFunc) return null; - decipherFunc = tceFunc[0]; - - // Если TCE — берем глобальные переменные - const tceVarsMatch = body.match(TCE_GLOBAL_VARS_REGEXP); - if (tceVarsMatch) tceVars = `${tceVarsMatch[1]};\n`; - } - - return `${tceVars}${helperObject}\nvar ${DECIPHER_FUNC_NAME}=${decipherFunc};\n${callerFunc}`; - } catch (e) { - console.error("Error in extractDecipherFunc:", e); - return null; - } - } - }, - - /** - * @description Получаем данные n кода - для ускоренной загрузки с серверов - */ - { - name: "extractNTransformFunction", - callback: (body, name, code) => { - try { - const caller = `${N_TRANSFORM_FUNC_NAME}(${N_ARGUMENT});`; - - // Попытка найти прямую TCE-функцию - const tceMatch = body.match(TCE_N_FUNCTION_REGEXP); - if (tceMatch && name && code) { - let func = tceMatch[0]; - const escapedName = name.replace("$", "\\$"); - const shortCircuit = new RegExp( - `;\\s*if\\s*\\(\\s*typeof\\s+[\\w$]+\\s*===?\\s*(?:\"undefined\"|'undefined'|${escapedName}\\[\\d+\\])\\s*\\)\\s*return\\s+\\w+;` - ); - func = func.replace(shortCircuit, ";"); - return `var ${N_TRANSFORM_FUNC_NAME}=${func}${code};\n${caller}`; - } - - // Альтернатива: стандартный или TCE-формат - const nMatch = body.match(N_TRANSFORM_REGEXP) ?? body.match(N_TRANSFORM_TCE_REGEXP); - if (!nMatch) return null; - - let func = nMatch[0]; - let tceVars = ""; - if (!body.match(N_TRANSFORM_REGEXP)) { - const tceVarsMatch = body.match(TCE_GLOBAL_VARS_REGEXP); - tceVars = tceVarsMatch ? tceVarsMatch[1] + ";\n" : ""; - } - - const param = func.match(/function\s*\(\s*(\w+)\s*\)/)?.[1]; - if (!param) return null; - - func = func.replace(new RegExp(`if\\s*\\(typeof\\s*[^\\s()]+\\s*===?.*?\\)return ${param}\\s*;?`, "g"), ""); - - return `${tceVars}var ${N_TRANSFORM_FUNC_NAME}=${func};\n${caller}`; - } catch (e) { - console.error("Error in extractNTransformFunc:", e); - return null; - } - } - } - ]; - - /** - * @description Применяем преобразования decipher и n параметров ко всем URL-адресам формата. - * @param formats - Все форматы аудио или видео - * @param html5player - Ссылка на плеер - */ - public static decipherFormats = async (formats: YouTubeFormat[], html5player: string): Promise => { - // Получаем страницу плеера - const body = await new httpsClient({url: html5player}).toString; - - // Если при получении страницы плеера произошла ошибка - if (body instanceof Error) return formats; - - const { name, code } = extractTceFunc(body); - const [ decipher, nTransform ] = [this.extractDecipher(body, name, code), this.extractNTransform(body, name, code)]; - - for (let item of formats) this.getting_url(item, {decipher, nTransform}); - return formats; - }; - - /** - * @description Применить расшифровку и n-преобразование к индивидуальному формату - * @param format - Аудио или видео формат на youtube - * @param script - Скрипт для выполнения на виртуальной машине - * @private - */ - private static getting_url = (format: YouTubeFormat, {decipher, nTransform}: YouTubeChanter): void => { - if (!format) return; - - const rawUrl = format.url || format.signatureCipher || format.cipher; - if (!rawUrl) return; - - const decodeURL = (url: string) => { - try { - return new URL(decodeURIComponent(url)); - } catch { - return null; - } - }; - const applyDecipher = (url: string) => { - if (!decipher) return url; - - const args = querystring.parse(url); - if (!args.s) return args.url as string; - - try { - const context = { [DECIPHER_ARGUMENT]: decodeURIComponent(args.s as string) }; - const components = decodeURL(args.url as string); - if (!components) return args.url as string; - - const deciphered = decipher.runInNewContext({ ...context, console }, { breakOnSigint: true, timeout: 5e3 }); - components.searchParams.set((args.sp as string) || DECIPHER_ARGUMENT, deciphered); - return components.toString(); - } catch { - return args.url as string; - } - }; - const applyNTransform = (url: string) => { - if (!nTransform) return url; - - const components = decodeURL(url); - if (!components) return url; - - const nParam = components.searchParams.get("n"); - if (!nParam) return url; - - try { - const transformed = nTransform.runInNewContext({ [N_ARGUMENT]: nParam, console }, { breakOnSigint: true, timeout: 5e3 }); - if (transformed) components.searchParams.set("n", transformed); - return components.toString(); - } catch { - return url; - } - }; - - try { - const initialUrl = rawUrl === format.url ? rawUrl : applyDecipher(rawUrl); - format.url = applyNTransform(initialUrl); - - delete format.signatureCipher; - delete format.cipher; - } catch (err) { - throw err; - } - }; - - /** - * @description Извлекает функции расшифровки N типа - * @param body - Страница плеера - * @param name - Имя функции - * @param code - Данные функции - * @private - */ - private static extractNTransform = (body: string, name: string, code: string) => { - const nTransformFunc = this.extraction([this.extractors[1].callback], body, name, code); - if (!nTransformFunc) return null; - return nTransformFunc; - }; - - /** - * @description Извлекает функции расшифровки сигнатур и преобразования n параметров из файла html5 player. - * @param body - Страница плеера - * @param name - Имя функции - * @param code - Данные функции - * @private - */ - private static extractDecipher = (body: string, name: string, code: string) => { - const decipherFunc = this.extraction([this.extractors[0].callback], body, name, code); - if (!decipherFunc) return null; - return decipherFunc; - }; - - /** - * @description Получаем функции для расшифровки - * @param extractFunctions - Функция расшифровки - * @param body - Станица youtube - * @param name - Имя функции - * @param code - Данные функции - * @param postProcess - Если есть возможность обработать сторонний код - * @private - */ - private static extraction = (extractFunctions: Function[], body: string, name: string, code: string, postProcess = null) => { - for (const extractFunction of extractFunctions) { - try { - // Если есть функция - const func = extractFunction(body, name, code); - - // Если нет функции - if (!func) continue; - - // Выполняем виртуальный код - return new Script(postProcess ? postProcess(func) : func); - } catch {} - } - - return null; - }; -} - - -/** - * @author SNIPPIK - * @description Общий стандарт аудио или видео json объекта - * @interface YouTubeFormat - */ -interface YouTubeFormat { - url: string; - signatureCipher?: string; - cipher?: string - sp?: string; - s?: string; - mimeType?: string; - bitrate?: number; - acodec?: string; - fps?: number; -} - -/** - * @author SNIPPIK - * @description Варианты расшифровки url - * @interface YouTubeChanter - */ -interface YouTubeChanter { - decipher?: Script; - nTransform?: Script; -} - -const DECIPHER_ARGUMENT = "sig"; -const N_ARGUMENT = "ncode"; -const DECIPHER_FUNC_NAME = "DecipherFunc"; -const N_TRANSFORM_FUNC_NAME = "NTransformFunc"; - -const VARIABLE_PART = "[a-zA-Z_\\$][a-zA-Z_0-9\\$]*"; -const VARIABLE_PART_DEFINE = "\\\"?" + VARIABLE_PART + "\\\"?"; -const BEFORE_ACCESS = "(?:\\[\\\"|\\.)"; -const AFTER_ACCESS = "(?:\\\"\\]|)"; -const VARIABLE_PART_ACCESS = BEFORE_ACCESS + VARIABLE_PART + AFTER_ACCESS; -const REVERSE_PART = ":function\\(\\w\\)\\{(?:return )?\\w\\.reverse\\(\\)\\}"; -const SLICE_PART = ":function\\(\\w,\\w\\)\\{return \\w\\.slice\\(\\w\\)\\}"; -const SPLICE_PART = ":function\\(\\w,\\w\\)\\{\\w\\.splice\\(0,\\w\\)\\}"; -const SWAP_PART = ":function\\(\\w,\\w\\)\\{" + - "var \\w=\\w\\[0\\];\\w\\[0\\]=\\w\\[\\w%\\w\\.length\\];\\w\\[\\w(?:%\\w.length|)\\]=\\w(?:;return \\w)?\\}"; - -const PATTERN_PREFIX = "(?:^|,)\\\"?(" + VARIABLE_PART + ")\\\"?"; -const REVERSE_PATTERN = new RegExp(PATTERN_PREFIX + REVERSE_PART, "m"); -const SLICE_PATTERN = new RegExp(PATTERN_PREFIX + SLICE_PART, "m"); -const SPLICE_PATTERN = new RegExp(PATTERN_PREFIX + SPLICE_PART, "m"); -const SWAP_PATTERN = new RegExp(PATTERN_PREFIX + SWAP_PART, "m"); - -const DECIPHER_REGEXP = new RegExp( - "function(?: " + VARIABLE_PART + ")?\\(([a-zA-Z])\\)\\{" + - "\\1=\\1\\.split\\(\"\"\\);\\s*" + - "((?:(?:\\1=)?" + VARIABLE_PART + VARIABLE_PART_ACCESS + "\\(\\1,\\d+\\);)+)" + - "return \\1\\.join\\(\"\"\\)" + - "\\}", "s"); - -const HELPER_REGEXP = new RegExp( - "var (" + VARIABLE_PART + ")=\\{((?:(?:" + - VARIABLE_PART_DEFINE + REVERSE_PART + "|" + - VARIABLE_PART_DEFINE + SLICE_PART + "|" + - VARIABLE_PART_DEFINE + SPLICE_PART + "|" + - VARIABLE_PART_DEFINE + SWAP_PART + - "),?\\n?)+)\\};", "s"); - -const FUNCTION_TCE_REGEXP = new RegExp( - "function(?:\\s+[a-zA-Z_\\$][a-zA-Z0-9_\\$]*)?\\(\\w\\)\\{" + - "\\w=\\w\\.split\\((?:\"\"|[a-zA-Z0-9_$]*\\[\\d+])\\);" + - "\\s*((?:(?:\\w=)?[a-zA-Z_\\$][a-zA-Z0-9_\\$]*(?:\\[\\\"|\\.)[a-zA-Z_\\$][a-zA-Z0-9_\\$]*(?:\\\"\\]|)\\(\\w,\\d+\\);)+)" + - "return \\w\\.join\\((?:\"\"|[a-zA-Z0-9_$]*\\[\\d+])\\)}", "s"); - -const N_TRANSFORM_REGEXP = new RegExp( - "function\\(\\s*(\\w+)\\s*\\)\\s*\\{" + - "var\\s*(\\w+)=(?:\\1\\.split\\(.*?\\)|String\\.prototype\\.split\\.call\\(\\1,.*?\\))," + - "\\s*(\\w+)=(\\[.*?]);\\s*\\3\\[\\d+]" + - "(.*?try)(\\{.*?})catch\\(\\s*(\\w+)\\s*\\)\\s*\\{" + - '\\s*return"[\\w-]+([A-z0-9-]+)"\\s*\\+\\s*\\1\\s*}' + - '\\s*return\\s*(\\2\\.join\\(""\\)|Array\\.prototype\\.join\\.call\\(\\2,.*?\\))};', "s"); - -const N_TRANSFORM_TCE_REGEXP = new RegExp( - "function\\(\\s*(\\w+)\\s*\\)\\s*\\{" + - "\\s*var\\s*(\\w+)=\\1\\.split\\(\\1\\.slice\\(0,0\\)\\),\\s*(\\w+)=\\[.*?];" + - ".*?catch\\(\\s*(\\w+)\\s*\\)\\s*\\{" + - "\\s*return(?:\"[^\"]+\"|\\s*[a-zA-Z_0-9$]*\\[\\d+])\\s*\\+\\s*\\1\\s*}" + - "\\s*return\\s*\\2\\.join\\((?:\"\"|[a-zA-Z_0-9$]*\\[\\d+])\\)};", "s"); - -const TCE_GLOBAL_VARS_REGEXP = new RegExp( - "(?:^|[;,])\\s*(var\\s+([\\w$]+)\\s*=\\s*" + - "(?:" + - "([\"'])(?:\\\\.|[^\\\\])*?\\3" + - "\\s*\\.\\s*split\\((" + - "([\"'])(?:\\\\.|[^\\\\])*?\\5" + - "\\))" + - "|" + - "\\[\\s*(?:([\"'])(?:\\\\.|[^\\\\])*?\\6\\s*,?\\s*)+\\]" + - "))(?=\\s*[,;])", "s"); - -const NEW_TCE_GLOBAL_VARS_REGEXP = new RegExp( - "('use\\s*strict';)?" + - "(?var\\s*" + - "(?[a-zA-Z0-9_$]+)\\s*=\\s*" + - "(?" + - "(?:\"[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*\"|'[^'\\\\]*(?:\\\\.[^'\\\\]*)*')" + - "\\.split\\(" + - "(?:\"[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*\"|'[^'\\\\]*(?:\\\\.[^'\\\\]*)*')" + - "\\)" + - "|" + - "\\[" + - "(?:(?:\"[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*\"|'[^'\\\\]*(?:\\\\.[^'\\\\]*)*')" + - "\\s*,?\\s*)*" + - "\\]" + - "|" + - "\"[^\"]*\"\\.split\\(\"[^\"]*\"\\)" + - ")" + - ")", "m"); - -const TCE_SIGN_FUNCTION_REGEXP = new RegExp("function\\(\\s*([a-zA-Z0-9$])\\s*\\)\\s*\\{" + - "\\s*\\1\\s*=\\s*\\1\\[(\\w+)\\[\\d+\\]\\]\\(\\2\\[\\d+\\]\\);" + - "([a-zA-Z0-9$]+)\\[\\2\\[\\d+\\]\\]\\(\\s*\\1\\s*,\\s*\\d+\\s*\\);" + - "\\s*\\3\\[\\2\\[\\d+\\]\\]\\(\\s*\\1\\s*,\\s*\\d+\\s*\\);" + - ".*?return\\s*\\1\\[\\2\\[\\d+\\]\\]\\(\\2\\[\\d+\\]\\)\\};", "s"); - -const VARIABLE_PART_OBJECT_DECLARATION = "[\"']?[a-zA-Z_\\$][a-zA-Z_0-9\\$]*[\"']?" -const TCE_SIGN_FUNCTION_ACTION_REGEXP = new RegExp( -"var\\s+([$A-Za-z0-9_]+)\\s*=\\s*\\{" + -"\\s*" + VARIABLE_PART_OBJECT_DECLARATION + "\\s*:\\s*function\\s*\\([^)]*\\)\\s*\\{[^{}]*(?:\\{[^{}]*}[^{}]*)*}\\s*," + -"\\s*" + VARIABLE_PART_OBJECT_DECLARATION + "\\s*:\\s*function\\s*\\([^)]*\\)\\s*\\{[^{}]*(?:\\{[^{}]*}[^{}]*)*}\\s*," + -"\\s*" + VARIABLE_PART_OBJECT_DECLARATION + "\\s*:\\s*function\\s*\\([^)]*\\)\\s*\\{[^{}]*(?:\\{[^{}]*}[^{}]*)*}\\s*};", "s"); - -const TCE_N_FUNCTION_REGEXP = new RegExp("function\\s*\\((\\w+)\\)\\s*\\{var\\s*\\w+\\s*=\\s*\\1\\[\\w+\\[\\d+\\]\\]\\(\\w+\\[\\d+\\]\\)\\s*,\\s*\\w+\\s*=\\s*\\[.*?\\]\\;.*?catch\\s*\\(\\s*(\\w+)\\s*\\)\\s*\\{return\\s*\\w+\\[\\d+\\]\\s*\\+\\s*\\1\\}\\s*return\\s*\\w+\\[\\w+\\[\\d+\\]\\]\\(\\w+\\[\\d+\\]\\)\\}\\s*\\;", "gs"); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 4ab1c9be..5977099d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,32 +1,38 @@ { "compilerOptions": { - "module": "CommonJS", - "target": "ESNext", - "moduleResolution": "node", + /* --- Основные настройки сборки --- */ + "target": "ES2022", /* Собираем в современный JS (Node 18+ поддерживает ES2022 полностью) */ + "module": "NodeNext", /* Важно для поддержки imports (#core/...) и Top-level await */ + "moduleResolution": "NodeNext", /* Современный алгоритм разрешения модулей Node.js */ + "lib": ["ESNext"], /* Включаем новейшие фичи языка */ - "rootDir": "./", "baseUrl": "./", "outDir": "./build", - "removeComments": true, - "alwaysStrict": true, "sourceMap": false, - "resolveJsonModule": true, - "forceConsistentCasingInFileNames": true, + /* --- Строгость (Type Checking) --- */ "strict": true, - "esModuleInterop": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "preserveConstEnums": true, - "noImplicitAny": false, + "alwaysStrict": true, "strictNullChecks": false, "strictFunctionTypes": true, + "noImplicitThis": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, + "noImplicitAny": false, + + /* --- Совместимость и производительность --- */ + "esModuleInterop": true, "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "removeComments": true, + + /* --- Декораторы --- */ + "experimentalDecorators": true, + "emitDecoratorMetadata": true, "paths": { "#core/*": ["./src/core/*"], @@ -39,8 +45,10 @@ "#structures": ["./src/structures/index"], "#app": ["./src/index"], - "#app/db": ["./src/database"], - "#app/env": ["./src/environment"] + "#app/db": ["./src/database/index"], + "#app/env": ["./src/environment"], + + "#worker/db": ["./src/database/index.worker"] } },