From f331b10358d0ff81c2987114895b5a17317b3be6 Mon Sep 17 00:00:00 2001 From: Umiko Date: Sun, 13 Apr 2025 14:44:48 +0700 Subject: [PATCH] first commit. --- .dockerignore | 4 + .flake8 | 4 + .gitattributes | 6 + .github/workflows/build-nightly.yml | 25 + .gitignore | 140 ++ .vscode/launch.json | 15 + .vscode/settings.json | 16 + .yapfignore | 2 + Dockerfile | 51 + LICENSE | 21 + README.md | 76 + TTMediaBot.py | 64 + TTMediaBot.sh | 7 + bot/TeamTalk/__init__.py | 386 +++++ bot/TeamTalk/structs.py | 336 ++++ bot/TeamTalk/thread.py | 214 +++ bot/__init__.py | 150 ++ bot/app_vars.py | 54 + bot/cache.py | 74 + bot/commands/__init__.py | 267 +++ bot/commands/admin_commands.py | 889 ++++++++++ bot/commands/command.py | 32 + bot/commands/task_processor.py | 30 + bot/commands/user_commands.py | 629 +++++++ bot/config/__init__.py | 65 + bot/config/models.py | 127 ++ bot/connectors/__init__.py | 1 + bot/connectors/tt_player_connector.py | 110 ++ bot/errors.py | 78 + bot/logger.py | 59 + bot/migrators/__init__.py | 1 + bot/migrators/cache_migrator.py | 42 + bot/migrators/config_migrator.py | 37 + bot/modules/__init__.py | 23 + bot/modules/shortener.py | 24 + bot/modules/streamer.py | 74 + bot/modules/task_scheduler.py | 32 + bot/modules/uploader.py | 82 + bot/player/__init__.py | 283 ++++ bot/player/enums.py | 23 + bot/player/track.py | 111 ++ bot/services/__init__.py | 85 + bot/services/vk.py | 150 ++ bot/services/yam.py | 150 ++ bot/services/yt.py | 191 +++ bot/sound_devices.py | 51 + bot/translator.py | 32 + bot/utils.py | 28 + changelog.txt | 159 ++ config_default.json | 80 + development-requirements.txt | 8 + downloader.py | 11 + locale/id/LC_MESSAGES/TTMediaBot.po | 984 +++++++++++ mpv.py | 2215 +++++++++++++++++++++++++ requirements.txt | 13 + srb | 7 + systemd/user/TTMediaBot.service | 12 + systemd/user/pulseaudio.service | 13 + systemd/user/pulseaudio.socket | 10 + tools/compile_locales.py | 49 + tools/libmpv_win_downloader.py | 95 ++ tools/ttsdk_downloader.py | 117 ++ tools/vk_auth.py | 134 ++ tools/yam_auth.py | 76 + typestubs/mpv.pyi | 65 + typestubs/patoolib.pyi | 9 + typestubs/version/__init__.pyi | 35 + typestubs/vk_api/__init__.py | 45 + typestubs/vk_api/exceptions.py | 6 + typestubs/youtubesearchpython.pyi | 7 + typestubs/yt_dlp.pyi | 24 + 71 files changed, 9525 insertions(+) create mode 100644 .dockerignore create mode 100644 .flake8 create mode 100644 .gitattributes create mode 100644 .github/workflows/build-nightly.yml create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .yapfignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 TTMediaBot.py create mode 100644 TTMediaBot.sh create mode 100644 bot/TeamTalk/__init__.py create mode 100644 bot/TeamTalk/structs.py create mode 100644 bot/TeamTalk/thread.py create mode 100644 bot/__init__.py create mode 100644 bot/app_vars.py create mode 100644 bot/cache.py create mode 100644 bot/commands/__init__.py create mode 100644 bot/commands/admin_commands.py create mode 100644 bot/commands/command.py create mode 100644 bot/commands/task_processor.py create mode 100644 bot/commands/user_commands.py create mode 100644 bot/config/__init__.py create mode 100644 bot/config/models.py create mode 100644 bot/connectors/__init__.py create mode 100644 bot/connectors/tt_player_connector.py create mode 100644 bot/errors.py create mode 100644 bot/logger.py create mode 100644 bot/migrators/__init__.py create mode 100644 bot/migrators/cache_migrator.py create mode 100644 bot/migrators/config_migrator.py create mode 100644 bot/modules/__init__.py create mode 100644 bot/modules/shortener.py create mode 100644 bot/modules/streamer.py create mode 100644 bot/modules/task_scheduler.py create mode 100644 bot/modules/uploader.py create mode 100644 bot/player/__init__.py create mode 100644 bot/player/enums.py create mode 100644 bot/player/track.py create mode 100644 bot/services/__init__.py create mode 100644 bot/services/vk.py create mode 100644 bot/services/yam.py create mode 100644 bot/services/yt.py create mode 100644 bot/sound_devices.py create mode 100644 bot/translator.py create mode 100644 bot/utils.py create mode 100644 changelog.txt create mode 100644 config_default.json create mode 100644 development-requirements.txt create mode 100644 downloader.py create mode 100644 locale/id/LC_MESSAGES/TTMediaBot.po create mode 100644 mpv.py create mode 100644 requirements.txt create mode 100644 srb create mode 100644 systemd/user/TTMediaBot.service create mode 100644 systemd/user/pulseaudio.service create mode 100644 systemd/user/pulseaudio.socket create mode 100644 tools/compile_locales.py create mode 100644 tools/libmpv_win_downloader.py create mode 100644 tools/ttsdk_downloader.py create mode 100644 tools/vk_auth.py create mode 100644 tools/yam_auth.py create mode 100644 typestubs/mpv.pyi create mode 100644 typestubs/patoolib.pyi create mode 100644 typestubs/version/__init__.pyi create mode 100644 typestubs/vk_api/__init__.py create mode 100644 typestubs/vk_api/exceptions.py create mode 100644 typestubs/youtubesearchpython.pyi create mode 100644 typestubs/yt_dlp.pyi diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b70d117 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git/ +.gitattributes +.gitignore + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..59f8848 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +exclude = TeamTalkPy,mpv.py +inline-quotes = " +max-line-length = 88 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c88de16 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Set default behaviour, in case users don't have core.autocrlf set. +* text=auto + +# Exclude files from exporting. +.gitattributes export-ignore +.gitignore export-ignore diff --git a/.github/workflows/build-nightly.yml b/.github/workflows/build-nightly.yml new file mode 100644 index 0000000..52f0e63 --- /dev/null +++ b/.github/workflows/build-nightly.yml @@ -0,0 +1,25 @@ +name: Build-nightly + +on: + push: + branches: ["master"] + workflow_dispatch: + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Build and push + uses: docker/build-push-action@v2 + with: + push: true + tags: gumerovamir/ttmediabot:nightly diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63d0236 --- /dev/null +++ b/.gitignore @@ -0,0 +1,140 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +*.dll +*.so +TeamTalkPy/ +config.json +*config.json +*.dat +*.log +.*env/ +TTSDK_license.txt +event_handlers.py diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..2ea8854 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Launch Project in Integrated Terminal", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/TTMediaBot.py", + "console": "integratedTerminal" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3f25df5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "python.linting.enabled": true, + "python.linting.flake8Enabled": true, + "python.analysis.useLibraryCodeForTypes": true, + "python.analysis.typeCheckingMode": "strict", + "python.analysis.stubPath": "typestubs", + "python.formatting.provider": "black", + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", + "editor.formatOnType": true, + "files.exclude": { + "**/__pycache__": true, + ".*env": true + } +} \ No newline at end of file diff --git a/.yapfignore b/.yapfignore new file mode 100644 index 0000000..d92cb5e --- /dev/null +++ b/.yapfignore @@ -0,0 +1,2 @@ +TeamTalkPy +mpv.py \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e73e510 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +FROM python:3.11-slim-bullseye AS build + +# Install dependencies +RUN apt update && apt upgrade -y && apt install -y --no-install-recommends gettext p7zip-full python3-httpx libmpv-dev curl git-core && apt clean && rm -rf /var/lib/apt/lists/* + +# Create build user and working directory +RUN useradd -d /bot --system build +RUN mkdir /bot && chown build:build /bot +USER build + +# Download and extract PandoraBox.zip +WORKDIR /bot +RUN curl -L -o PandoraBox.zip "https://www.dropbox.com/scl/fi/w59od6p43v474cdqfllt1/PandoraBox.zip?rlkey=sghktp7rbuxknbz9b3v9lqfii&dl=1" +RUN 7z x PandoraBox.zip -o/bot/repo + +# Install Python dependencies and prepare bot +WORKDIR /bot/repo +RUN pip install --no-cache-dir --upgrade -r requirements.txt +RUN python tools/ttsdk_downloader.py && python tools/compile_locales.py + +# Replace yt.py with a custom version +RUN curl -L -o /bot/repo/bot/services/yt.py "https://www.dropbox.com/scl/fi/eq1fdfp2k7whfbhklwazn/yt.py?rlkey=emggs6flhwpoje6sagmjpeljn&dl=1" + +# Set permissions for all files in /bot/repo +RUN chmod -R 0755 /bot/repo + +FROM python:3.11-slim-bullseye + +# Install runtime dependencies +RUN apt update && apt upgrade -y && apt install -y --no-install-recommends libmpv-dev pulseaudio && apt clean && rm -rf /var/lib/apt/lists/* + +# Install yt-dlp with pip +RUN pip install --no-cache-dir yt-dlp + +# Create runtime user +RUN useradd --system -ms /bin/bash ttbot +USER ttbot + +# Set working directory and copy files +WORKDIR /home/ttbot +COPY --from=build --chown=ttbot /bot/repo . +COPY --from=build --chown=ttbot /bot/.local ./.local + +# Set permissions for all files in /home/ttbot +RUN chmod -R 0755 /home/ttbot + +# Remove .git directory +RUN rm -rf .git + +# Command to run the bot +CMD ["sh", "-c", "rm -rf /tmp/* && pulseaudio --daemon --exit-idle-time=-1 && sleep 2 && ./TTMediaBot.sh -c data/config.json --cache data/TTMediaBotCache.dat --log data/TTMediaBot.log"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4701b7e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Gumerov Amir Eduardovich + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..aadcfe3 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# TTMediaBot +A media streaming bot for TeamTalk. + +## Installation and usage +### Requirements +* To use the bot, you need to install Python 3.7 or later; +* The bot requires the TeamTalk SDK component to be downloaded using the integrated tool from the command line. In order to download and extract the mentioned component, on Linux, you need to install p7zip or p7zip-full, or if you want to run the bot on Windows, 7Zip must be installed; +* If you are going to use Linux as your main system for a bot, you will need pulseaudio and libmpv to route and play audio, but if you re using Windows, PulseAudio is not available, so you will need a virtual audio cable driver, such as VBCable and of course, the mpv player library must also be installed. on Windows this library can be installed using an integrated tool. On Debian-based systems the required package is libmpv1. + +### Installation +* Download TTMediaBot; +* install all python requirements from requirements.txt, using the "pip3 install -r requirements.txt" or just "pip install -r requirements.txt" command, without quotes;; +* Run ttsdk_downloader.py from the tools folder; +* If you're using Windows run libmpv_win_downloader.py from the tools folder; +* Copy or rename config_default.json to config.json; +* Fill in all required fields in config.json (Config description will be there later); +* On Linux run TTMediaBot.sh --devices to list all available devices and their numbers; +* On Windows run TTMediaBot.py --devices to list all available devices with their numbers; +* Edit config.json (change device numbers appropriately for your purposes); + +### Usage +* On Linux run ./TTMediaBot.sh; +* On Windows run python TTMediaBot.py directly. + +### Running in Docker +You can also run the bot in a Docker container. +First of all, You should build an image from the provided Dockerfile: +```sh +docker build -t ttmediabot . +``` +Note: The first run could take some time. + +Then you can run the Docker container with the following command: +```sh +docker run --rm --name ttmb_1 -dv :/home/ttbot/data ttmediabot +``` + here means a directory where config.json file will be stored. It should not contain any other unrelated data. +The cache and log files will be stored in the specified directory. + +## Startup options +* --devices - Shows the list of all available input and output audio devices; +* -c PATH - Sets the path to the configuration file. + +## Config file options +* language - Sets the bot's interface language. Warning! to select a language you need an appropriate language folder inside the "locale" folder; +* sound devices - Here you have to enter audio device numbers. Devices should be connected to each other (like Virtual audio cable or pulseaudio); +* player - This section sets the configuration for the player such as default volume, maximum volume, etc; +* teamtalk - here are main options for bot to connect and login to your TeamTalk server; +* Services - Here you should configure available services for music search and playback; +* logger - Here you can configure various logging related options. + +## Pulse audio or VB cable settings +### Linux variant +* Install pulseaudio. +* type $pulseaudio --start +* Next command creates null sink and this sink can be monitored by default pulse input device. +$pacmd load-module module-null-sink +* then run ./TTMediaBot.sh --devices and check its numbers. +output should be null audio output, input should be pulse. +* put this numbers to your config.json. + +### windows variant +* install VB-cable, run "TTMediaBot.py --devices" and check numbers of VB-cable devices +* put this numbers to your config.json. + +## Some notes about the Windows variant +* When listing input and output devices in the Windows variant of TTMediaBot, please note, that the input device will be doubled, i.e., if the output device is line 1 with number 3, the input device for line 1 will be listed twice, at number 5 and, for example, at number 7. +* The correct number will be the last one as input, that is, if we selected the output as line 1 with the number 3, the input device would be line 1 with number 7 of the two options, number 5 and number 7. +* The same method applies to all numbers and all Input / Outputs. + +# support us +* yoomoney: https://yoomoney.ru/to/4100117354062028 + +# contacts +* telegram channel: https://t.me/TTMediaBot_chat +* E-mail: TTMediaBot@gmail.com diff --git a/TTMediaBot.py b/TTMediaBot.py new file mode 100644 index 0000000..d44bcce --- /dev/null +++ b/TTMediaBot.py @@ -0,0 +1,64 @@ +from typing import Optional + +from os import path + +from argparse import ArgumentParser + +from bot import Bot, app_vars +from bot.config import save_default_file +from bot.sound_devices import SoundDeviceManager + +parser = ArgumentParser() +parser.add_argument( + "-c", + "--config", + help="Path to the configuration file", + default=path.join(app_vars.directory, "config.json"), +) +parser.add_argument("-C", "--cache", help="Path to the cache file", default=None) +parser.add_argument("-l", "--log", help="Path to the log file", default=None) +parser.add_argument( + "--devices", help="Show available devices and exit", action="store_true" +) +parser.add_argument( + "--default-config", + help='Save default config to "config_default.json" and exit', + action="store_true", +) +args = parser.parse_args() + + +def main( + config: str = args.config, + cache: Optional[str] = args.cache, + log: Optional[str] = args.log, + devices: bool = args.devices, + default_config: bool = args.default_config, +) -> None: + if devices: + bot = Bot(None, None, None) + echo_sound_devices(bot.sound_device_manager) + elif default_config: + save_default_file() + print("Successfully dumped to config_default.json") + else: + bot = Bot(config, cache, log) + bot.initialize() + try: + bot.run() + except KeyboardInterrupt: + bot.close() + + +def echo_sound_devices(sound_device_manager: SoundDeviceManager): + print("Output devices:") + for i, device in enumerate(sound_device_manager.output_devices): + print("\t{index}: {name}".format(index=i, name=device.name)) + print() + print("Input devices:") + for i, device in enumerate(sound_device_manager.input_devices): + print("\t{index}: {name}".format(index=i, name=device.name)) + + +if __name__ == "__main__": + main() diff --git a/TTMediaBot.sh b/TTMediaBot.sh new file mode 100644 index 0000000..80ea11f --- /dev/null +++ b/TTMediaBot.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +PROGNAME=TTMediaBot.py +PROGDIR=$(dirname "$(readlink -f $0)") +LD_LIBRARY_PATH=$PROGDIR/TeamTalk_DLL:$PROGDIR:$LD_LIBRARY_PATH +export LD_LIBRARY_PATH +python3 "$PROGDIR/$PROGNAME" "$@" diff --git a/bot/TeamTalk/__init__.py b/bot/TeamTalk/__init__.py new file mode 100644 index 0000000..321f4af --- /dev/null +++ b/bot/TeamTalk/__init__.py @@ -0,0 +1,386 @@ +from __future__ import annotations +import logging +import os +import re +import sys +from typing import AnyStr, List, TYPE_CHECKING, Optional, Union +from queue import Queue + +from bot import app_vars +from bot.sound_devices import SoundDevice, SoundDeviceType + + +if sys.platform == "win32": + if sys.version_info.major == 3 and sys.version_info.minor >= 8: + os.add_dll_directory(app_vars.directory) + else: + os.chdir(app_vars.directory) + +from bot.TeamTalk.thread import TeamTalkThread +from bot.TeamTalk.structs import * + +import TeamTalkPy + + +re_line_endings = re.compile("[\\r\\n]") + +if TYPE_CHECKING: + from bot import Bot + + +def _str(data: AnyStr) -> AnyStr: + if isinstance(data, str): + if os.supports_bytes_environ: + return bytes(data, "utf-8") + else: + return data + else: + return str(data, "utf-8") + + +def split(text: str, max_length: int = app_vars.max_message_length) -> List[str]: + if len(text) <= max_length: + lines = [text] + else: + lines = [""] + for line in text.split("\n"): + if len(line) <= max_length: + if len(lines[-1]) > 0 and len(lines[-1]) + len(line) + 1 <= max_length: + lines[-1] += "\n" + line + elif len(lines) == 1 and len(lines[0]) == 0: + lines[0] = line + else: + lines.append(line) + else: + words = [""] + for word in line.split(" "): + if len(word) <= max_length: + if ( + len(words[-1]) > 0 + and len(words[-1]) + len(word) + 1 <= max_length + ): + words[-1] += " " + word + elif len(words) == 1 and len(words[0]) == 0: + words[0] == word + else: + words.append(word) + else: + chunk = word + for _ in range(0, int(len(chunk) / max_length) + 1): + words.append(chunk[0:max_length]) + chunk = chunk[max_length::] + lines += words + return lines + + +class TeamTalk: + def __init__(self, bot: Bot) -> None: + self.config = bot.config.teamtalk + self.translator = bot.translator + TeamTalkPy.setLicense( + _str(self.config.license_name), _str(self.config.license_key) + ) + self.tt = TeamTalkPy.TeamTalk() + self.state = State.NOT_CONNECTED + self.is_voice_transmission_enabled = False + self.nickname = self.config.nickname + self.gender = UserStatusMode.__members__[self.config.gender.upper()] + self.status = self.default_status + self.errors_queue: Queue[Error] = Queue() + self.event_success_queue: Queue[Event] = Queue() + self.message_queue: Queue[Message] = Queue() + self.myself_event_queue: Queue[Event] = Queue() + self.uploaded_files_queue: Queue[File] = Queue() + self.thread = TeamTalkThread(bot, self) + self.reconnect = False + self.reconnect_attempt = 0 + self.user_account: UserAccount + + def initialize(self) -> None: + logging.debug("Initializing TeamTalk") + self.thread.start() + self.connect() + logging.debug("TeamTalk initialized") + + def close(self) -> None: + logging.debug("Closing teamtalk") + self.thread.close() + self.disconnect() + self.state = State.NOT_CONNECTED + self.tt.closeTeamTalk() + logging.debug("Teamtalk closed") + + def connect(self) -> None: + self.state = State.CONNECTING + self.tt.connect( + _str(self.config.hostname), + self.config.tcp_port, + self.config.udp_port, + 0, + 0, + self.config.encrypted, + ) + + def disconnect(self) -> None: + self.tt.disconnect() + self.state = State.NOT_CONNECTED + + def login(self) -> None: + self.tt.doLogin( + _str(self.config.nickname), + _str(self.config.username), + _str(self.config.password), + _str(app_vars.client_name), + ) + + def join(self) -> None: + if isinstance(self.config.channel, int): + channel_id = int(self.config.channel) + else: + channel_id = self.tt.getChannelIDFromPath(_str(self.config.channel)) + if channel_id == 0: + channel_id = 1 + self.tt.doJoinChannelByID(channel_id, _str(self.config.channel_password)) + + @property + def default_status(self) -> str: + if self.config.status: + return self.config.status + else: + return self.translator.translate('PM "start" to get short help!') + + def send_message( + self, text: str, user: Optional[User] = None, type: int = 1 + ) -> None: + for string in split(text): + message = TeamTalkPy.TextMessage() + message.nFromUserID = self.tt.getMyUserID() + message.nMsgType = type + message.szMessage = _str(string) + if type == 1: + if isinstance(user, int): + message.nToUserID = user + else: + message.nToUserID = user.id + elif type == 2: + message.nChannelID = self.tt.getMyChannelID() + self.tt.doTextMessage(message) + + def send_file(self, channel: Union[int, str], file_path: str): + if isinstance(channel, int): + channel_id = channel + else: + channel_id = self.tt.getChannelIDFromPath(_str(channel)) + if channel_id == 0: + raise ValueError() + return self.tt.doSendFile(channel_id, _str(file_path)) + + def delete_file(self, channel: Union[int, str], file_id: int) -> int: + if isinstance(channel, int): + channel_id = channel + else: + channel_id = self.tt.getChannelIDFromPath(_str(channel)) + if channel_id == 0 or file_id == 0: + raise ValueError() + return self.tt.doDeleteFile(channel_id, file_id) + + def join_channel(self, channel: Union[str, int], password: str) -> int: + if isinstance(channel, int): + channel_id = channel + else: + channel_id = self.tt.getChannelIDFromPath(_str(channel)) + if channel_id == 0: + raise ValueError() + return self.tt.doJoinChannelByID(channel_id, _str(password)) + + def change_nickname(self, nickname: str) -> None: + self.tt.doChangeNickname(_str(nickname)) + + def change_status_text(self, text: str) -> None: + if text: + self.status = split(text)[0] + else: + self.status = split(self.default_status)[0] + self.tt.doChangeStatus(self.gender.value, _str(self.status)) + + def change_gender(self, gender: str) -> None: + self.gender = UserStatusMode.__members__[gender.upper()] + self.tt.doChangeStatus(self.gender.value, _str(self.status)) + + def get_channel(self, channel_id: int) -> Channel: + channel = self.tt.getChannel(channel_id) + return self.get_channel_from_obj(channel) + + def get_channel_from_obj(self, obj: TeamTalkPy.Channel) -> Channel: + try: + return Channel( + obj.nChannelID, + obj.szName, + obj.szTopic, + obj.nMaxUsers, + ChannelType(obj.uChannelType), + ) + except ValueError: + return Channel(0, "", "", 0, ChannelType.Default) + + @property + def flags(self) -> Flags: + return Flags(self.tt.getFlags()) + + def get_error(self, error_no: int, cmdid: int) -> Error: + try: + error_type = ErrorType(error_no) + except ValueError: + error_type = ErrorType(0) + return Error(_str(self.tt.getErrorMessage(error_no)), error_type, cmdid) + + def get_message(self, msg: TeamTalkPy.TextMessage) -> Message: + try: + return Message( + re.sub(re_line_endings, "", _str(msg.szMessage)), + self.get_user(msg.nFromUserID), + self.get_channel(msg.nChannelID), + MessageType(msg.nMsgType), + ) + except ValueError: + return Message("", self.get_user(1), self.get_channel(1), MessageType.User) + + def get_file(self, file: TeamTalkPy.RemoteFile) -> File: + return File( + file.nFileID, + _str(file.szFileName), + self.get_channel(file.nChannelID), + file.nFileSize, + _str(file.szUsername), + ) + + @property + def user(self) -> User: + user = self.get_user(self.tt.getMyUserID()) + user.user_account = self.user_account + return user + + @property + def channel(self) -> Channel: + return self.get_channel(self.tt.getMyChannelID()) + + def get_user(self, id: int) -> User: + user = self.tt.getUser(id) + gender = UserStatusMode(user.nStatusMode) + return User( + user.nUserID, + _str(user.szNickname), + _str(user.szUsername), + _str(user.szStatusMsg), + gender, + UserState(user.uUserState), + self.get_channel(user.nChannelID), + _str(user.szClientName), + user.uVersion, + self.get_user_account(_str(user.szUsername)), + UserType(user.uUserType), + True + if _str(user.szUsername) in self.config.users.admins or user.uUserType == 2 + else False, + _str(user.szUsername) in self.config.users.banned_users, + ) + + def get_user_account(self, username: str) -> UserAccount: + return UserAccount(username, "", "", UserType.Null, UserRight.Null, "/") + + def get_user_account_by_tt_obj(self, obj: TeamTalkPy.UserAccount) -> UserAccount: + return UserAccount( + _str(obj.szUsername), + _str(obj.szPassword), + _str(obj.szNote), + UserType(obj.uUserType), + UserRight(obj.uUserRights), + _str(obj.szInitChannel), + ) + + def get_event(self, obj: TeamTalkPy.TTMessage) -> Event: + try: + channel = self.get_channel_from_obj(obj.channel) + except (UnicodeDecodeError, ValueError): + channel = Channel(1, "", "", 0, ChannelType.Default) + try: + error = self.get_error(obj.clienterrormsg.nErrorNo, obj.nSource) + except (UnicodeDecodeError, ValueError): + error = Error("", ErrorType.Success, 1) + try: + file = self.get_file(obj.remotefile) + except (UnicodeDecodeError, ValueError): + file = File(1, "", channel, 0, "") + try: + user_account = self.get_user_account_by_tt_obj(obj.useraccount) + except (UnicodeDecodeError, ValueError): + user_account = UserAccount("", "", "", UserType.Null, UserRight.Null, "") + try: + user = self.get_user(obj.user.nUserID) + except (UnicodeDecodeError, ValueError): + user = User( + 1, + "", + "", + "", + UserStatusMode.M, + UserState.Null, + channel, + "", + 1, + user_account, + UserType.Null, + False, + False, + ) + try: + message = self.get_message(obj.textmessage) + except (UnicodeDecodeError, ValueError): + message = Message("", user, channel, MessageType.NONE) + return Event( + EventType(obj.nClientEvent), + obj.nSource, + channel, + error, + file, + message, + user, + user_account, + ) + + def get_input_devices(self) -> List[SoundDevice]: + devices: List[SoundDevice] = [] + device_list = [i for i in self.tt.getSoundDevices()] + for device in device_list: + if sys.platform == "win32": + if ( + device.nSoundSystem == TeamTalkPy.SoundSystem.SOUNDSYSTEM_WASAPI + and device.nMaxOutputChannels == 0 + ): + devices.append( + SoundDevice( + _str(device.szDeviceName), + device.nDeviceID, + SoundDeviceType.Input, + ) + ) + else: + devices.append( + SoundDevice( + _str(device.szDeviceName), + device.nDeviceID, + SoundDeviceType.Input, + ) + ) + return devices + + def set_input_device(self, id: int) -> None: + self.tt.initSoundInputDevice(id) + + def enable_voice_transmission(self) -> None: + self.tt.enableVoiceTransmission(True) + self.is_voice_transmission_enabled = True + + def disable_voice_transmission(self) -> None: + self.tt.enableVoiceTransmission(False) + self.is_voice_transmission_enabled = False diff --git a/bot/TeamTalk/structs.py b/bot/TeamTalk/structs.py new file mode 100644 index 0000000..85532b5 --- /dev/null +++ b/bot/TeamTalk/structs.py @@ -0,0 +1,336 @@ +from enum import Enum, Flag + +import TeamTalkPy + + +class State(Enum): + NOT_CONNECTED = 0 + CONNECTING = 1 + RECONNECTING = 2 + CONNECTED = 3 + + +class Flags(Flag): + CLOSED = TeamTalkPy.ClientFlags.CLIENT_CLOSED + SND_INPUT_READY = TeamTalkPy.ClientFlags.CLIENT_SNDINPUT_READY + SND_OUTPUT_READY = TeamTalkPy.ClientFlags.CLIENT_SNDOUTPUT_READY + SND_INOUTPUT_DUPLEX = TeamTalkPy.ClientFlags.CLIENT_SNDINOUTPUT_DUPLEX + SND_INPUT_VOICE_ACTIVATED = TeamTalkPy.ClientFlags.CLIENT_SNDINPUT_VOICEACTIVATED + SND_INPUT_VOICE_ACTIVE = TeamTalkPy.ClientFlags.CLIENT_SNDINPUT_VOICEACTIVE + SND_OUTPUT_MUTE = TeamTalkPy.ClientFlags.CLIENT_SNDOUTPUT_MUTE + SND_OUTPUT_AUTO_3D_POSITION = TeamTalkPy.ClientFlags.CLIENT_SNDOUTPUT_AUTO3DPOSITION + VIDEO_CAPTURE_READY = TeamTalkPy.ClientFlags.CLIENT_VIDEOCAPTURE_READY + TX_VOICE = TeamTalkPy.ClientFlags.CLIENT_TX_VOICE + TX_VIDEO_CAPTURE = TeamTalkPy.ClientFlags.CLIENT_TX_VIDEOCAPTURE + TX_DESKTOP = TeamTalkPy.ClientFlags.CLIENT_TX_DESKTOP + DESKTOP_ACTIVE = TeamTalkPy.ClientFlags.CLIENT_DESKTOP_ACTIVE + MUX_AUDIO_FILE = TeamTalkPy.ClientFlags.CLIENT_MUX_AUDIOFILE + CONNECTING = TeamTalkPy.ClientFlags.CLIENT_CONNECTING + CONNECTED = TeamTalkPy.ClientFlags.CLIENT_CONNECTED + CONNECTION = TeamTalkPy.ClientFlags.CLIENT_CONNECTION = ( + TeamTalkPy.ClientFlags.CLIENT_CONNECTING + or TeamTalkPy.ClientFlags.CLIENT_CONNECTED + ) + AUTHORIZED = TeamTalkPy.ClientFlags.CLIENT_AUTHORIZED + STREAM_AUDIO = TeamTalkPy.ClientFlags.CLIENT_STREAM_AUDIO + STREAM_VIDEO = TeamTalkPy.ClientFlags.CLIENT_STREAM_VIDEO + + +class ChannelType(Flag): + ClassRoom = TeamTalkPy.ChannelType.CHANNEL_CLASSROOM + Default = TeamTalkPy.ChannelType.CHANNEL_DEFAULT + Hidden = TeamTalkPy.ChannelType.CHANNEL_HIDDEN + NoRecording = TeamTalkPy.ChannelType.CHANNEL_NO_RECORDING + NoVoiceActivation = TeamTalkPy.ChannelType.CHANNEL_NO_VOICEACTIVATION + OperatorRecnvOnly = TeamTalkPy.ChannelType.CHANNEL_OPERATOR_RECVONLY + Permanent = TeamTalkPy.ChannelType.CHANNEL_PERMANENT + SoloTransmit = TeamTalkPy.ChannelType.CHANNEL_SOLO_TRANSMIT + + +class Channel: + def __init__( + self, id: int, name: str, topic: str, max_users: int, type: ChannelType + ) -> None: + self.id = id + self.name = name + self.topic = topic + self.max_users = max_users + self.type = type + + +class ErrorType(Enum): + Success = TeamTalkPy.ClientError.CMDERR_SUCCESS + SyntaxError = TeamTalkPy.ClientError.CMDERR_SYNTAX_ERROR + UnknownCommand = TeamTalkPy.ClientError.CMDERR_UNKNOWN_COMMAND + MissingParameter = TeamTalkPy.ClientError.CMDERR_MISSING_PARAMETER + IncompatibleProtocols = TeamTalkPy.ClientError.CMDERR_INCOMPATIBLE_PROTOCOLS + UnknownAudioCodec = TeamTalkPy.ClientError.CMDERR_UNKNOWN_AUDIOCODEC + InvalidUsername = TeamTalkPy.ClientError.CMDERR_INVALID_USERNAME + IncorrectChannelPassword = TeamTalkPy.ClientError.CMDERR_INCORRECT_CHANNEL_PASSWORD + InvalidAccount = TeamTalkPy.ClientError.CMDERR_INVALID_ACCOUNT + MaxServerUsersExceeded = TeamTalkPy.ClientError.CMDERR_MAX_SERVER_USERS_EXCEEDED + MaxChannelUsersExceeded = TeamTalkPy.ClientError.CMDERR_MAX_CHANNEL_USERS_EXCEEDED + ServerBanned = TeamTalkPy.ClientError.CMDERR_SERVER_BANNED + NotAuthorised = TeamTalkPy.ClientError.CMDERR_NOT_AUTHORIZED + MaxDiskusageExceeded = TeamTalkPy.ClientError.CMDERR_MAX_DISKUSAGE_EXCEEDED + IncorrectOperatorPassword = TeamTalkPy.ClientError.CMDERR_INCORRECT_OP_PASSWORD + AudioCodecBitrateLimitExceeded = ( + TeamTalkPy.ClientError.CMDERR_AUDIOCODEC_BITRATE_LIMIT_EXCEEDED + ) + MaxLoginsPerIpAddressExceeded = ( + TeamTalkPy.ClientError.CMDERR_MAX_LOGINS_PER_IPADDRESS_EXCEEDED + ) + MaxChannelsExceeded = TeamTalkPy.ClientError.CMDERR_MAX_CHANNELS_EXCEEDED + CommandFlood = TeamTalkPy.ClientError.CMDERR_COMMAND_FLOOD + ChannelBanned = TeamTalkPy.ClientError.CMDERR_CHANNEL_BANNED + NotLoggedin = TeamTalkPy.ClientError.CMDERR_NOT_LOGGEDIN + AlreadyLoggedin = TeamTalkPy.ClientError.CMDERR_ALREADY_LOGGEDIN + NotInChannel = TeamTalkPy.ClientError.CMDERR_NOT_IN_CHANNEL + AlreadyInChannel = TeamTalkPy.ClientError.CMDERR_ALREADY_IN_CHANNEL + ChannelAlreadyExists = TeamTalkPy.ClientError.CMDERR_CHANNEL_ALREADY_EXISTS + ChannelNotFound = TeamTalkPy.ClientError.CMDERR_CHANNEL_NOT_FOUND + UserNotFound = TeamTalkPy.ClientError.CMDERR_USER_NOT_FOUND + BanNotFound = TeamTalkPy.ClientError.CMDERR_BAN_NOT_FOUND + FileTransferNotFound = TeamTalkPy.ClientError.CMDERR_FILETRANSFER_NOT_FOUND + OpenFileFailed = TeamTalkPy.ClientError.CMDERR_OPENFILE_FAILED + AccountNotFound = TeamTalkPy.ClientError.CMDERR_ACCOUNT_NOT_FOUND + FileNotFound = TeamTalkPy.ClientError.CMDERR_FILE_NOT_FOUND + FileAlreadyExists = TeamTalkPy.ClientError.CMDERR_FILE_ALREADY_EXISTS + FileSharingDisabled = TeamTalkPy.ClientError.CMDERR_FILESHARING_DISABLED + ChannelHasUsers = TeamTalkPy.ClientError.CMDERR_CHANNEL_HAS_USERS + LoginServiceUnavailable = TeamTalkPy.ClientError.CMDERR_LOGINSERVICE_UNAVAILABLE + ChannelCannotBeHidden = TeamTalkPy.ClientError.CMDERR_CHANNEL_CANNOT_BE_HIDDEN + SndInputFailure = TeamTalkPy.ClientError.INTERR_SNDINPUT_FAILURE + SndOutputFailure = TeamTalkPy.ClientError.INTERR_SNDOUTPUT_FAILURE + AudioCodecInitFailed = TeamTalkPy.ClientError.INTERR_AUDIOCODEC_INIT_FAILED + SpeexDSPInitFailed = TeamTalkPy.ClientError.INTERR_SPEEXDSP_INIT_FAILED + TTMesageQueueOverflow = TeamTalkPy.ClientError.INTERR_TTMESSAGE_QUEUE_OVERFLOW + SndEffectFailure = TeamTalkPy.ClientError.INTERR_SNDEFFECT_FAILURE + + +class Error: + def __init__(self, message: str, type: ErrorType, command_id: int) -> None: + self.message = message + self.type = type + self.command_id = command_id + + +class UserType(Enum): + Null = 0 + Default = 1 + Admin = 2 + + +class UserState(Flag): + Null = TeamTalkPy.UserState.USERSTATE_NONE + Voice = TeamTalkPy.UserState.USERSTATE_VOICE + MuteVoice = TeamTalkPy.UserState.USERSTATE_MUTE_VOICE + MuteMediaFile = TeamTalkPy.UserState.USERSTATE_MUTE_MEDIAFILE + Desktop = TeamTalkPy.UserState.USERSTATE_DESKTOP + VideoCapture = TeamTalkPy.UserState.USERSTATE_VIDEOCAPTURE + AudioFile = TeamTalkPy.UserState.USERSTATE_MEDIAFILE_AUDIO + VideoFile = TeamTalkPy.UserState.USERSTATE_MEDIAFILE_VIDEO + MediaFile = TeamTalkPy.UserState.USERSTATE_MEDIAFILE + + +class UserStatusMode(Flag): + Available = 0 + Away = 1 + Question = 2 + VideoTx = 512 + Desktop = 1024 + StreamMediaFile = 2048 + M = Available + F = 256 + N = 4096 + + +class UserRight(Flag): + Null = TeamTalkPy.UserRight.USERRIGHT_NONE + MultiLogin = TeamTalkPy.UserRight.USERRIGHT_MULTI_LOGIN + ViewAllUsers = TeamTalkPy.UserRight.USERRIGHT_VIEW_ALL_USERS + CreateTemporaryChannel = TeamTalkPy.UserRight.USERRIGHT_CREATE_TEMPORARY_CHANNEL + ModifyChannels = TeamTalkPy.UserRight.USERRIGHT_MODIFY_CHANNELS + BroadcastTextMessage = TeamTalkPy.UserRight.USERRIGHT_TEXTMESSAGE_BROADCAST + KickUsers = TeamTalkPy.UserRight.USERRIGHT_KICK_USERS + BanUsers = TeamTalkPy.UserRight.USERRIGHT_BAN_USERS + MoveUsers = TeamTalkPy.UserRight.USERRIGHT_MOVE_USERS + OperatorEnable = TeamTalkPy.UserRight.USERRIGHT_OPERATOR_ENABLE + UploadFiles = TeamTalkPy.UserRight.USERRIGHT_UPLOAD_FILES + DownloadFiles = TeamTalkPy.UserRight.USERRIGHT_DOWNLOAD_FILES + UpdateServerProperties = TeamTalkPy.UserRight.USERRIGHT_UPDATE_SERVERPROPERTIES + TransmitVoice = TeamTalkPy.UserRight.USERRIGHT_TRANSMIT_VOICE + TransmitVideoCapture = TeamTalkPy.UserRight.USERRIGHT_TRANSMIT_VIDEOCAPTURE + TransmitDesktop = TeamTalkPy.UserRight.USERRIGHT_TRANSMIT_DESKTOP + TransmitDesktopInput = TeamTalkPy.UserRight.USERRIGHT_TRANSMIT_DESKTOPINPUT + TransmitMediaFileAudio = TeamTalkPy.UserRight.USERRIGHT_TRANSMIT_MEDIAFILE_AUDIO + TransmitMediaFileVideo = TeamTalkPy.UserRight.USERRIGHT_TRANSMIT_MEDIAFILE_VIDEO + TransmitMediaFile = TeamTalkPy.UserRight.USERRIGHT_TRANSMIT_MEDIAFILE + LockedNickname = TeamTalkPy.UserRight.USERRIGHT_LOCKED_NICKNAME + LockedStatus = TeamTalkPy.UserRight.USERRIGHT_LOCKED_STATUS + RecordVoice = TeamTalkPy.UserRight.USERRIGHT_RECORD_VOICE + ViewHiddenChannels = TeamTalkPy.UserRight.USERRIGHT_VIEW_HIDDEN_CHANNELS + + +class UserAccount: + def __init__( + self, + username: str, + password: str, + note: str, + type: UserType, + rights: UserRight, + init_channel: str, + ) -> None: + self.username = username + self.password = password + self.note = note + self.type = type + self.rights = rights + self.init_channel = init_channel + + +class User: + def __init__( + self, + id: int, + nickname: str, + username: str, + status: str, + gender: UserStatusMode, + state: UserState, + channel: Channel, + client_name: str, + version: int, + user_account: UserAccount, + type: UserType, + is_admin: bool, + is_banned: bool, + ) -> None: + self.id = id + self.nickname = nickname + self.username = username + self.channel = channel + self.status = status + self.gender = gender + self.state = state + self.client_name = client_name + self.version = version + self.user_account = user_account + self.type = type + self.is_admin = is_admin + self.is_banned = is_banned + + +class MessageType(Enum): + NONE = 0 + User = TeamTalkPy.TextMsgType.MSGTYPE_USER + Channel = TeamTalkPy.TextMsgType.MSGTYPE_CHANNEL + Broadcast = TeamTalkPy.TextMsgType.MSGTYPE_BROADCAST + Custom = TeamTalkPy.TextMsgType.MSGTYPE_CUSTOM + + +class Message: + def __init__( + self, text: str, user: User, channel: Channel, type: MessageType + ) -> None: + self.text = text + self.channel = channel + self.user = user + self.type = type + + +class File: + def __init__( + self, id: int, name: str, channel: Channel, size: int, username: str + ) -> None: + self.id = id + self.name = name + self.channel = channel + self.size = size + self.username = username + + +class EventType(Enum): + NONE = TeamTalkPy.ClientEvent.CLIENTEVENT_NONE + CON_SUCCESS = TeamTalkPy.ClientEvent.CLIENTEVENT_CON_SUCCESS + CON_FAILED = TeamTalkPy.ClientEvent.CLIENTEVENT_CON_FAILED + CON_LOST = TeamTalkPy.ClientEvent.CLIENTEVENT_CON_LOST + CON_MAX_PAYLOAD_UPDATED = TeamTalkPy.ClientEvent.CLIENTEVENT_CON_MAX_PAYLOAD_UPDATED + PROCESSING = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_PROCESSING + ERROR = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_ERROR + SUCCESS = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_SUCCESS + MYSELF_LOGGEDIN = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_MYSELF_LOGGEDIN + MYSELF_LOGGEDOUT = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_MYSELF_LOGGEDOUT + MYSELF_KICKED = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_MYSELF_KICKED + USER_LOGGEDIN = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_USER_LOGGEDIN + USER_LOGGEDOUT = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_USER_LOGGEDOUT + USER_UPDATE = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_USER_UPDATE + USER_JOINED = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_USER_JOINED + USER_LEFT = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_USER_LEFT + USER_TEXT_MESSAGE = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_USER_TEXTMSG + CHANNEL_NEW = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_CHANNEL_NEW + CHANNEL_UPDATE = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_CHANNEL_UPDATE + CHANNEL_REMOVE = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_CHANNEL_REMOVE + SERVER_UPDATE = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_SERVER_UPDATE + SERVER_STATISTICS = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_SERVERSTATISTICS + FILE_NEW = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_FILE_NEW + FILE_REMOVE = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_FILE_REMOVE + USER_ACCOUNT = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_USERACCOUNT + BANNED_USER = TeamTalkPy.ClientEvent.CLIENTEVENT_CMD_BANNEDUSER + STATE_CHANGE = TeamTalkPy.ClientEvent.CLIENTEVENT_USER_STATECHANGE + USER_VIDEO_CAPTURE = TeamTalkPy.ClientEvent.CLIENTEVENT_USER_VIDEOCAPTURE + USER_MEDIAFILE_VIDEO = TeamTalkPy.ClientEvent.CLIENTEVENT_USER_MEDIAFILE_VIDEO + USER_DESKTOP_WINDOW = TeamTalkPy.ClientEvent.CLIENTEVENT_USER_DESKTOPWINDOW + USER_DESKTOP_CURSOR = TeamTalkPy.ClientEvent.CLIENTEVENT_USER_DESKTOPCURSOR + USER_DESKTOP_INPUT = TeamTalkPy.ClientEvent.CLIENTEVENT_USER_DESKTOPINPUT + USER_RECORD_MEDIAFILE = TeamTalkPy.ClientEvent.CLIENTEVENT_USER_RECORD_MEDIAFILE + USER_AUDIO_BLOCK = TeamTalkPy.ClientEvent.CLIENTEVENT_USER_AUDIOBLOCK + INTERNAL_ERROR = TeamTalkPy.ClientEvent.CLIENTEVENT_INTERNAL_ERROR + VOICE_ACTIVATION = TeamTalkPy.ClientEvent.CLIENTEVENT_VOICE_ACTIVATION + HOTKEY = TeamTalkPy.ClientEvent.CLIENTEVENT_HOTKEY + HOTKEY_TEST = TeamTalkPy.ClientEvent.CLIENTEVENT_HOTKEY_TEST + FILE_TRANSFER = TeamTalkPy.ClientEvent.CLIENTEVENT_FILETRANSFER + DESKTOP_WINDOW_TRANSFER = TeamTalkPy.ClientEvent.CLIENTEVENT_DESKTOPWINDOW_TRANSFER + STREAM_MEDIAFILE = TeamTalkPy.ClientEvent.CLIENTEVENT_STREAM_MEDIAFILE + LOCAL_MEDIAFILE = TeamTalkPy.ClientEvent.CLIENTEVENT_LOCAL_MEDIAFILE + AUDIO_INPUT = TeamTalkPy.ClientEvent.CLIENTEVENT_AUDIOINPUT + USER_FIRST_STREAM_VOICE_PACKET = ( + TeamTalkPy.ClientEvent.CLIENTEVENT_USER_FIRSTVOICESTREAMPACKET + ) + + +class Event: + def __init__( + self, + event_type: EventType, + source: int, + channel: Channel, + error: Error, + file: File, + message: Message, + user: User, + user_account: UserAccount, + ): + self.event_type = event_type + self.source = source + # ("ttType", INT32), + # ("uReserved", UINT32), + self.channel = channel + self.error = error + # desktop_input + # ("filetransfer", FileTransfer), + # ("mediafileinfo", MediaFileInfo), + self.file = file + # ("serverproperties", ServerProperties), + # ("serverstatistics", ServerStatistics), + self.message: Message = message + self.user = user + self.user_account = user_account + # ("banneduser", BannedUser), + # ("bActive", BOOL), + # ("nBytesRemain", INT32), + # ("nStreamID", INT32), + # ("nPayloadSize", INT32), + # ("nStreamType", INT32), + # ("audioinputprogress", AudioInputProgress), diff --git a/bot/TeamTalk/thread.py b/bot/TeamTalk/thread.py new file mode 100644 index 0000000..d82ee8d --- /dev/null +++ b/bot/TeamTalk/thread.py @@ -0,0 +1,214 @@ +from __future__ import annotations +from importlib.machinery import SourceFileLoader +import logging +import os +from threading import Thread +import time +from typing import Any, Callable, Optional, Tuple, TYPE_CHECKING +from types import ModuleType + + +import types +import sys + +from bot.TeamTalk.structs import * + +if TYPE_CHECKING: + from bot import Bot + from bot.TeamTalk import TeamTalk + + +class TeamTalkThread(Thread): + def __init__(self, bot: Bot, ttclient: TeamTalk): + Thread.__init__(self, daemon=True) + self.name = "TeamTalkThread" + self.bot = bot + self.config = ttclient.config + self.ttclient = ttclient + + def run(self) -> None: + if self.config.event_handling.load_event_handlers: + self.event_handlers = self.import_event_handlers() + self._close = False + while not self._close: + event = self.ttclient.get_event(self.ttclient.tt.getMessage()) + if event.event_type == EventType.NONE: + continue + elif ( + event.event_type == EventType.ERROR + and self.ttclient.state == State.CONNECTED + ): + self.ttclient.errors_queue.put(event.error) + elif ( + event.event_type == EventType.SUCCESS + and self.ttclient.state == State.CONNECTED + ): + self.ttclient.event_success_queue.put(event) + elif ( + event.event_type == EventType.USER_TEXT_MESSAGE + and event.message.type == MessageType.User + ): + self.ttclient.message_queue.put(event.message) + elif ( + event.event_type == EventType.FILE_NEW + and event.file.username == self.config.username + and event.file.channel.id == self.ttclient.channel.id + ): + self.ttclient.uploaded_files_queue.put(event.file) + elif ( + event.event_type == EventType.CON_FAILED + or event.event_type == EventType.CON_LOST + or event.event_type == EventType.MYSELF_KICKED + ): + if event.event_type == EventType.CON_FAILED: + logging.warning("Connection failed") + elif event.event_type == EventType.CON_LOST: + logging.warning("Server lost") + else: + logging.warning("Kicked") + self.ttclient.disconnect() + if ( + self.ttclient.reconnect + and self.ttclient.reconnect_attempt + < self.config.reconnection_attempts + or self.config.reconnection_attempts < 0 + ): + self.ttclient.disconnect() + time.sleep(self.config.reconnection_timeout) + self.ttclient.connect() + self.ttclient.reconnect_attempt += 1 + else: + logging.error("Connection error") + sys.exit(1) + elif event.event_type == EventType.CON_SUCCESS: + self.ttclient.reconnect_attempt = 0 + self.ttclient.login() + elif event.event_type == EventType.ERROR: + if self.ttclient.flags & Flags.AUTHORIZED == Flags(0): + logging.warning("Login failed") + if ( + self.ttclient.reconnect + and self.ttclient.reconnect_attempt + < self.config.reconnection_attempts + or self.config.reconnection_attempts < 0 + ): + time.sleep(self.config.reconnection_timeout) + self.ttclient.login() + else: + logging.error("Login error") + sys.exit(1) + else: + logging.warning("Failed to join channel") + if ( + self.ttclient.reconnect + and self.ttclient.reconnect_attempt + < self.config.reconnection_attempts + or self.config.reconnection_attempts < 0 + ): + time.sleep(self.config.reconnection_timeout) + self.ttclient.join() + else: + logging.error("Error joining channel") + sys.exit(1) + elif event.event_type == EventType.MYSELF_LOGGEDIN: + self.ttclient.user_account = event.user_account + self.ttclient.reconnect_attempt = 0 + self.ttclient.join() + elif ( + event.event_type == EventType.SUCCESS + and self.ttclient.state == State.CONNECTING + ): + self.ttclient.reconnect_attempt = 0 + self.ttclient.reconnect = True + self.ttclient.state = State.CONNECTED + self.ttclient.change_status_text(self.ttclient.status) + if self.config.event_handling.load_event_handlers: + self.run_event_handler(event) + + def close(self) -> None: + self._close = True + + def get_function_name_by_event_type(self, event_type: EventType) -> str: + return f"on_{event_type.name.lower()}" + + def import_event_handlers(self) -> ModuleType: + try: + if ( + os.path.isfile(self.config.event_handling.event_handlers_file_name) + and os.path.splitext( + self.config.event_handling.event_handlers_file_name + )[1] + == ".py" + ): + module = SourceFileLoader( + os.path.splitext( + self.config.event_handling.event_handlers_file_name + )[0], + self.config.event_handling.event_handlers_file_name, + ).load_module() + elif os.path.isdir( + self.config.event_handling.event_handlers_file_name + ) and "__init__.py" in os.listdir( + self.config.event_handling.event_handlers_file_name + ): + module = SourceFileLoader( + self.config.event_handling.event_handlers_file_name, + self.config.event_handling.event_handlers_file_name + + "/__init__.py", + ).load_module() + else: + logging.error( + "Incorrect path to event handlers. An empty module will be used" + ) + module = types.ModuleType("event_handlers") + except Exception as e: + logging.error( + "Can't load specified event handlers. Error: {}. An empty module will be used.".format( + e + ) + ) + module = types.ModuleType("event_handlers") + return module + + def parse_event(self, event: Event) -> Tuple[Any, ...]: + if event.event_type in ( + EventType.USER_UPDATE, + EventType.USER_JOINED, + EventType.USER_LOGGEDIN, + EventType.USER_LOGGEDOUT, + ): + return (event.user,) + elif event.event_type == EventType.USER_LEFT: + return (event.source, event.user) + elif event.event_type == EventType.USER_TEXT_MESSAGE: + return (event.message,) + elif event.event_type in ( + EventType.CHANNEL_NEW, + EventType.CHANNEL_UPDATE, + EventType.CHANNEL_REMOVE, + ): + return (event.channel,) + elif event.event_type in ( + EventType.FILE_NEW, + EventType.FILE_REMOVE, + ): + return (event.file,) + else: + return (1, 2) + + def run_event_handler(self, event: Event) -> None: + try: + event_handler: Optional[Callable[..., None]] = getattr( + self.event_handlers, + self.get_function_name_by_event_type(event.event_type), + None, + ) + if not event_handler: + return + try: + event_handler(*self.parse_event(event), self.bot) + except Exception as e: + print("Error in event handling {}".format(e)) + except AttributeError: + self.event_handlers = self.import_event_handlers() + self.run_event_handler(event) diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..5941ed7 --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1,150 @@ +import os +import logging +import queue +import sys +import time +from typing import Optional, List, Tuple, TYPE_CHECKING +if TYPE_CHECKING: + from bot.config.models import CronEntryModel + +from pydantic import ValidationError +from crontab import CronTab + +from bot import ( + TeamTalk, + cache, + commands, + config, + connectors, + logger, + modules, + player, + services, + sound_devices, + translator, + app_vars, +) +from bot.utils import sort_cron_tasks + + +class Bot: + def __init__( + self, + config_file_name: Optional[str], + cache_file_name: Optional[str] = None, + log_file_name: Optional[str] = None, + ) -> None: + try: + self.config_manager = config.ConfigManager(config_file_name) + except ValidationError as e: + for error in e.errors(): + print( + "Error in config:", + ".".join([str(i) for i in error["loc"]]), + error["msg"], + ) + sys.exit(1) + except PermissionError: + sys.exit( + "The configuration file cannot be accessed due to a permission error or is already used by another instance of the bot" + ) + self.config = self.config_manager.config + self.translator = translator.Translator(self.config.general.language) + try: + if cache_file_name: + self.cache_manager = cache.CacheManager(cache_file_name) + else: + cache_file_name = self.config.general.cache_file_name + if not os.path.isdir( + os.path.join(*os.path.split(cache_file_name)[0:-1]) + ): + cache_file_name = os.path.join( + self.config_manager.config_dir, cache_file_name + ) + self.cache_manager = cache.CacheManager(cache_file_name) + except PermissionError: + sys.exit( + "The cache file cannot be accessed due to a permission error or is already used by another instance of the bot" + ) + self.cache = self.cache_manager.cache + self.log_file_name = log_file_name + self.player = player.Player(self) + self.periodic_player = player.Player(self) + self.ttclient = TeamTalk.TeamTalk(self) + self.tt_player_connector = connectors.TTPlayerConnector(self) + self.periodic_tt_player_connector = connectors.MinimalTTPlayerConnector(self) + self.sound_device_manager = sound_devices.SoundDeviceManager(self) + self.service_manager = services.ServiceManager(self) + self.module_manager = modules.ModuleManager(self) + self.command_processor = commands.CommandProcessor(self) + self.scheduled_command_processor = commands.ScheduledCommandProcessor(self) + self.cron_patterns: List[Tuple[CronTab, CronEntryModel]] = [] + + def initialize(self): + if self.config.logger.log: + logger.initialize_logger(self) + logging.debug("Initializing") + self.sound_device_manager.initialize() + self.ttclient.initialize() + self.player.initialize() + self.periodic_player.initialize() + self.service_manager.initialize() + if self.config.schedule.enabled: + # parse all cron patterns into CronTab instances and store + for entry in self.config.schedule.patterns: + logging.debug( + f"Parsing cron pattern '{entry.pattern}' and appending to list" + ) + e = CronTab(entry.pattern) + self.cron_patterns.append((e, entry)) + logging.debug("Initialized") + + def run(self): + logging.debug("Starting") + counter = 0 + counter_total = int(1 / app_vars.loop_timeout) + self.player.run() + self.periodic_player.run() + self.tt_player_connector.start() + self.periodic_tt_player_connector.start() + self.command_processor.run() + self.scheduled_command_processor.run() + logging.info("Started") + self._close = False + while not self._close: + if self.config.schedule.enabled and counter == counter_total: + tasks = sort_cron_tasks(self.cron_patterns) + for ct, entry in tasks: + if ct.next() <= 1: + # run the associated command here + logging.debug( + f"Running command '{entry.command}' for cron pattern '{entry.pattern}" + ) + # call the scheduled command processor with the cron entry from config + self.scheduled_command_processor(entry) + try: + message = self.ttclient.message_queue.get_nowait() + logging.info( + "New message {text} from {username}".format( + text=message.text, username=message.user.username + ) + ) + self.command_processor(message) + except queue.Empty: + pass + if counter > counter_total: + counter = 0 + counter += 1 + time.sleep(app_vars.loop_timeout) + + def close(self) -> None: + logging.debug("Closing bot") + self.player.close() + self.periodic_player.close() + self.ttclient.close() + self.tt_player_connector.close() + self.periodic_tt_player_connector.close() + self.config_manager.close() + self.cache_manager.close() + self._close = True + logging.info("Bot closed") diff --git a/bot/app_vars.py b/bot/app_vars.py new file mode 100644 index 0000000..80de2d6 --- /dev/null +++ b/bot/app_vars.py @@ -0,0 +1,54 @@ +from __future__ import annotations +import os +from typing import Callable, TYPE_CHECKING + +if TYPE_CHECKING: + from bot.translator import Translator + +app_name = "PandoraBox" +app_version = "2.3.5" +client_name = app_name + "-V" + app_version +about_text: Callable[[Translator], str] = lambda translator: translator.translate( + """\ +A media streaming bot for TeamTalk. +Authors: Amir Gumerov, Vladislav Kopylov, Beqa Gozalishvili, Kirill Belousov. +Home page: https://github.com/gumerov-amir/TTMediaBot +License: MIT License\ +""" +) +start_bottt: Callable[[Translator], str] = lambda translator: translator.translate( + """\ +Hello there! +I'm PandoraBox, your go-to companion for discovering amazing songs and audio through YouTube, Yandex Music, and VK. +Hosted on Pandora's server, I'm all set to bring audio magic to your TeamTalk experience. +To get started, simply send me a private message with a specific command. +Here's how you can interact with me: +- help: Receive a handy guide to all the commands you can use, delivered straight to your private message. +- contacts: Get contact information sent directly to your private message. +- log: Check out the change log file. +If you encounter any issues or want to chat with us, feel free to reach out. +Thank you for choosing our service, and have a fantastic day!\ +""" +) +contacts_bot: Callable[[Translator], str] = lambda translator: translator.translate( + """\ +If you encounter any issues with this bot, please reach out to our dedicated technicians: + +- Muhammad: + - WhatsApp: https://api.whatsapp.com/send?phone=6282156978782 + - Telegram: https://t.me/muha_aku + +- Rexya: + - WhatsApp: https://api.whatsapp.com/send?phone=6288222553434 + - Email: rexya@infiartt.com + +Join the TTMediaBot Official Group on Telegram: https://t.me/TTMediaBot_chat\ +""" +) +fallback_service = "yt" +loop_timeout = 0.01 +max_message_length = 512 +recents_max_lenth = 64 +tt_event_timeout = 2 + +directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/bot/cache.py b/bot/cache.py new file mode 100644 index 0000000..152403b --- /dev/null +++ b/bot/cache.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import pickle +from collections import deque +from typing import Any, Dict, List, TYPE_CHECKING + +from bot import app_vars +from bot.migrators import cache_migrator + +import portalocker + +if TYPE_CHECKING: + from bot.player.track import Track + + +cache_data_type = Dict[str, Any] + + +class Cache: + def __init__(self, cache_data: cache_data_type): + self.cache_version = cache_data["cache_version"] if "cache_version" in cache_data else CacheManager.version + self.recents: deque[Track] = ( + cache_data["recents"] + if "recents" in cache_data + else deque(maxlen=app_vars.recents_max_lenth) + ) + self.favorites: Dict[str, List[Track]] = ( + cache_data["favorites"] if "favorites" in cache_data else {} + ) + + @property + def data(self): + return {"cache_version": self.cache_version, "recents": self.recents, "favorites": self.favorites} + + +class CacheManager: + version = 1 + + def __init__(self, file_name: str) -> None: + self.file_name = file_name + try: + self.data = cache_migrator.migrate(self, self._load()) + self.cache = Cache(self.data) + except FileNotFoundError: + self.cache = Cache({}) + self._dump(self.cache.data) + self._lock() + + def _dump(self, data: cache_data_type): + with open(self.file_name, "wb") as f: + pickle.dump(data, f) + + def _load(self) -> cache_data_type: + with open(self.file_name, "rb") as f: + return pickle.load(f) + + def _lock(self): + self.file_locker = portalocker.Lock( + self.file_name, + timeout=0, + flags=portalocker.LOCK_EX | portalocker.LOCK_NB, + ) + try: + self.file_locker.acquire() + except portalocker.exceptions.LockException: + raise PermissionError() + + def close(self): + self.file_locker.release() + + def save(self): + self.file_locker.release() + self._dump(self.cache.data) + self.file_locker.acquire() diff --git a/bot/commands/__init__.py b/bot/commands/__init__.py new file mode 100644 index 0000000..24982df --- /dev/null +++ b/bot/commands/__init__.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import logging +import re +from threading import Thread +from typing import Any, List, TYPE_CHECKING, Tuple + +from bot import app_vars, errors +from bot.TeamTalk.structs import Message, User, UserType +from bot.commands import admin_commands, user_commands +from bot.commands.task_processor import TaskProcessor + +re_command = re.compile("[a-z]+") +re_arg_split = re.compile(r"(? None: + command_thread = Thread(target=self._run, args=(message,)) + command_thread.start() + + def _run(self, message: Message) -> None: + try: + command_name, arg = self.parse_command(message.text) + if self.check_access(message.user, command_name): + command_class = self.get_command(command_name, message.user) + command = command_class(self) + self.current_command_id = id(command) + result = command(arg, message.user) + if result: + self.ttclient.send_message( + result, + message.user, + ) # here was command.ttclient later + except errors.InvalidArgumentError: + self.ttclient.send_message( + self.help(command_name, message.user), + message.user, + ) + except errors.AccessDeniedError as e: + self.ttclient.send_message(str(e), message.user) + except (errors.ParseCommandError, errors.UnknownCommandError): + self.ttclient.send_message( + self.translator.translate('Unknown command. Send "start" for help.'), + message.user, + ) + except Exception as e: + logging.error("", exc_info=True) + self.ttclient.send_message( + self.translator.translate("Error: {}").format(str(e)), + message.user, + ) + + def check_access(self, user: User, command: str) -> bool: + if ( + not user.is_admin and user.type != UserType.Admin + ) or app_vars.app_name in user.client_name: + if app_vars.app_name in user.client_name: + raise errors.AccessDeniedError("") + elif user.is_banned: + raise errors.AccessDeniedError( + self.translator.translate("You are banned"), + ) + elif user.channel.id != self.ttclient.channel.id: + raise errors.AccessDeniedError( + self.translator.translate("You are not in bot's channel"), + ) + elif self.locked: + raise errors.AccessDeniedError( + self.translator.translate("Bot is locked"), + ) + elif command in self.config.general.blocked_commands: + raise errors.AccessDeniedError( + self.translator.translate("This command is blocked"), + ) + else: + return True + else: + return True + + def get_command(self, command: str, user: Optional[User]) -> Any: + if command in self.commands_dict: + return self.commands_dict[command] + elif ( + (user is not None) + and (user.is_admin or user.type == UserType.Admin) + and command in self.admin_commands_dict + ): + return self.admin_commands_dict[command] + else: + raise errors.UnknownCommandError() + + def help(self, arg: str, user: User) -> str: + if arg: + if arg in self.commands_dict: + return "{} {}".format(arg, self.commands_dict[arg](self).help) + elif user.is_admin and arg in self.admin_commands_dict: + return "{} {}".format(arg, self.admin_commands_dict[arg](self).help) + else: + return self.translator.translate("Unknown command") + else: + help_strings: List[str] = [] + for i in list(self.commands_dict): + help_strings.append(self.help(i, user)) + if user.is_admin: + for i in list(self.admin_commands_dict): + help_strings.append(self.help(i, user)) + return "\n".join(help_strings) + + def parse_command(self, text: str) -> Tuple[str, str]: + text = text.strip() + try: + command = re.findall(re_command, text.split(" ")[0].lower())[0] + except IndexError: + raise errors.ParseCommandError() + arg = " ".join(text.split(" ")[1::]) + return command, arg + + def split_arg(self, arg: str) -> List[str]: + args = re.split(re_arg_split, arg) + for i, arg in enumerate(args): + args[i] = args[i].strip().replace("\\|", "|") + return args + + +class ScheduledCommandProcessor(CommandProcessor): + """Command processor, specifically tailored for scheduled tasks. Takes a CronEntry instead of a message.""" + + def __init__(self, bot: Bot): + super().__init__(bot) + + def __call__(self, task: CronEntryModel) -> None: + command_thread = Thread(target=self._run, args=(task,)) + command_thread.start() + + def _run(self, task: CronEntryModel) -> None: + """Takes a cron task and runs it. Doesn't provide user to the command, doesn't check access (as there is no user to check). Logs errors and reports them to the channel also if that is enabled. Cron tasks can run admin commands.""" + try: + command_name, arg = self.parse_command(task.command) + if ( + command_name in self.commands_dict + or command_name in self.admin_commands_dict + ): + command_class = self.get_command(command_name, None) + command = command_class(self) + self.current_command_id = id(command) + result = command(arg, None) + if result: + logger.info( + f"Successfully ran cron command '{task.command}; result: {result}" + ) + self.ttclient.send_message( + result, + message.user, + ) + except errors.InvalidArgumentError: + log.error( + f"Invalid argument for scheduled command '{task.command}'; cron pattern: {task.pattern}" + ) + if self.config.general.send_channel_messages: + self.ttclient.send_message( + self.translator.translate( + f"Scheduled command '{task.command}' failed: invalid argument" + ), + type=2, + ) + except errors.AccessDeniedError as e: + log.error( + f"Got access denied while running scheduled task '{task.command}'; cron pattern: {task.pattern}" + ) + except (errors.ParseCommandError, errors.UnknownCommandError): + if self.config.general.send_channel_messages: + self.ttclient.send_message( + self.translator.translate( + f"Unknown scheduled command: '{task.command}'." + ), + type=2, + ) + except Exception as e: + logging.error( + f"Error running scheduled command '{task.command}", exc_info=True + ) + if self.config.general.send_channel_messages: + self.ttclient.send_message( + self.translator.translate( + f"Error running scheduled command '{task.command}: {e}" + ), + type=2, + ) diff --git a/bot/commands/admin_commands.py b/bot/commands/admin_commands.py new file mode 100644 index 0000000..2ed2f8c --- /dev/null +++ b/bot/commands/admin_commands.py @@ -0,0 +1,889 @@ +from __future__ import annotations +import logging +import os +import subprocess +import sys +import time +from typing import Optional, TYPE_CHECKING +from queue import Empty +import humanize + +from crontab import CronTab +from bot.commands.command import Command +from bot.config.models import CronEntryModel +from bot.player.enums import State +from bot import app_vars, errors + +if TYPE_CHECKING: + from bot.TeamTalk.structs import User + + +class BlockCommandCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "+/-COMMAND Block or unblock command. +COMMAND add command to the block list. -COMMAND remove from it. Without a command shows the block list." + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + arg = arg.lower() + if len(arg) >= 1 and arg[1:] not in self.command_processor.commands_dict: + raise errors.InvalidArgumentError() + if not arg: + return ( + ", ".join(self.config.general.blocked_commands) + if self.config.general.blocked_commands + else self.translator.translate("the list is empty") + ) + if arg[0] == "+": + if arg[1::] not in self.config.general.blocked_commands: + self.config.general.blocked_commands.append(arg[1::]) + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "command \"{command}\" was blocked." + ).format(command=arg[1::]), + user, + ) + self.config_manager.save() + else: + self.translator.translate("This command is already added") + elif arg[0] == "-": + if arg[1::] in self.config.general.blocked_commands: + del self.config.general.blocked_commands[ + self.config.general.blocked_commands.index(arg[1::]) + ] + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "command \"{command}\" was unblocked." + ).format(command=arg[1::]), + user, + ) + self.config_manager.save() + else: + return self.translator.translate("This command is not blocked") + else: + raise errors.InvalidArgumentError() + + +class ChangeGenderCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "GENDER Changes bot's gender. n neutral, m male, f female" + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + try: + self.ttclient.change_gender(arg) + self.config.teamtalk.gender = arg + except KeyError: + raise errors.InvalidArgumentError() + self.config_manager.save() + + +class ChangeLanguageCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("LANGUAGE change player language") + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + try: + self.translator.set_locale(arg) + self.config.general.language = arg + self.ttclient.change_status_text("") + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "the language has been changed to {language}." + ).format(language=arg), + user, + ) + self.config_manager.save() + except errors.LocaleNotFoundError: + return self.translator.translate("Incorrect language") + else: + return self.translator.translate( + "Current language: {current_language}. Available languages: {available_languages}." + ).format( + current_language=self.translator.get_locale(), + available_languages=", ".join(self.translator.get_locales()), + ) + + +class ChangeNicknameCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("NICKNAME Changes bot's nickname") + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + self.ttclient.change_nickname(arg) + self.config.teamtalk.nickname = arg + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "nickname change to {newname}." + ).format(newname=arg), + user, + ) + else: + self.ttclient.change_nickname(app_vars.client_name) + self.config.teamtalk.nickname = app_vars.client_name + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "nickname set to default client ({newname})." + ).format(newname=app_vars.client_name), + user, + ) + self.config_manager.save() + + +class ClearCacheCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "r/f Clears bot's cache. r clears recents, f clears favorites, without an option clears the entire cache" + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if not arg: + self.cache.recents.clear() + self.cache.favorites.clear() + self.cache_manager.save() + return self.translator.translate("Cache cleared") + elif arg == "r": + self.cache.recents.clear() + self.cache_manager.save() + return self.translator.translate("Recents cleared") + elif arg == "f": + self.cache.favorites.clear() + self.cache_manager.save() + return self.translator.translate("Favorites cleared") + + +class ShowDefaultServerCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("Read config file and show default Server, Port, Username, Password, Channel, Channel password.") + + def __call__(self, arg: str, user: User) -> Optional[str]: + return self.translator.translate( + "Server: {hostname}\n" + "TCP Port: {tcpport}\n" + "UDP Port: {udpport}\n" + "Username: {username}\n" + "Password: {password}\n" + "Channel: {channel}\n" + "Channel password: {channel_password}" + ).format( + hostname=self.config.teamtalk.hostname, + tcpport=self.config.teamtalk.tcp_port, + udpport=self.config.teamtalk.udp_port, + username=self.config.teamtalk.username, + password=self.config.teamtalk.password, + channel=self.config.teamtalk.channel, + channel_password=self.config.teamtalk.channel_password, + ) + + +class DefaultServerCommand(Command): + @property + def help(self): + return self.translator.translate( + 'Change default Server information.' + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + self.config.teamtalk.hostname = arg + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "Default Server change to {newhostname}." + ).format(newhostname=arg), + user, + ) + self.config_manager.save() + else: + return self.translator.translate("Default server can not be blank, Please specify default Server information!") + + +class DefaultTCPPortCommand(Command): + @property + def help(self): + return self.translator.translate( + 'Change default TCP port information.' + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + try: + tcpport = int(arg) + self.config.teamtalk.tcp_port = int(arg) + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "Default TCP port change to {newtcp_port}." + ).format(newtcp_port=int(arg)), + user, + ) + self.config_manager.save() + except ValueError: + raise errors.InvalidArgumentError + else: + return self.translator.translate("Default TCP port can not be blank, Please specify default TCP port!") + + +class DefaultUDPPortCommand(Command): + @property + def help(self): + return self.translator.translate( + 'Change default UDP port information.' + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + try: + udpport = int(arg) + self.config.teamtalk.udp_port = int(arg) + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "Default TCP port change to {newudp_port}." + ).format(newudp_port=int(arg)), + user, + ) + self.config_manager.save() + except ValueError: + raise errors.InvalidArgumentError + else: + return self.translator.translate("Default UDP port can not be blank, Please specify default UDP port!") + + +class DefaultUsernameCommand(Command): + @property + def help(self): + return self.translator.translate( + 'Change default Username information. Without any arguments, set default Username to blank.' + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + self.config.teamtalk.username = arg + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "Default Username change to {newusername}." + ).format(newusername=arg), + user, + ) + self.config_manager.save() + else: + self.config.teamtalk.username = "" + self.config_manager.save() + return self.translator.translate("Default username set to blank.") + + +class DefaultPasswordCommand(Command): + @property + def help(self): + return self.translator.translate( + 'Change default Password information. Without any arguments, set default password to blank.' + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + self.config.teamtalk.password = arg + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "Default Password change to {newpassword}." + ).format(newpassword=arg), + user, + ) + self.config_manager.save() + else: + self.config.teamtalk.password = "" + self.config_manager.save() + return self.translator.translate("Default Password set to blank.") + + +class DefaultChannelCommand(Command): + @property + def help(self): + return self.translator.translate( + 'Change default Channel information. Without any arguments, set default Channel to current channel.' + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + self.config.teamtalk.channel = arg + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "Default Channel change to {newchannel}, if your channel has a password, please set it manually!" + ).format(newchannel=arg), + user, + ) + self.config_manager.save() + else: + channel_id = str(self.ttclient.channel.id) + self.config.teamtalk.channel = channel_id + self.config_manager.save() + return self.translator.translate("Default Channel set to current channel, if your channel has a password, please set it manually!") + + +class DefaultChannelPasswordCommand(Command): + @property + def help(self): + return self.translator.translate( + 'Change default Channel password information. Without any arguments, set default Channel password to blank..' + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + self.config.teamtalk.channel_password = arg + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "Default Channel password change to {newchannel_password}." + ).format(newchannel_password=arg), + user, + ) + self.config_manager.save() + else: + self.config.teamtalk.channel_password = "" + self.config_manager.save() + return self.translator.translate("Default Channel password set to blank.") + + +class GetChannelIDCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("Get current channel ID, will be useful for several things.") + + def __call__(self, arg: str, user: User) -> Optional[str]: + return str(self.ttclient.channel.id) + + +class JoinChannelCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + 'Join channel. first argument is channel name or id, second argument is password, split argument " | ", if password is undefined, don\'t type second argument' + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + args = self.command_processor.split_arg(arg) + if not arg: + channel = self.config.teamtalk.channel + password = self.config.teamtalk.channel_password + elif len(args) == 2: + channel = args[0] + password = args[1] + else: + channel = arg + password = "" + if isinstance(channel, str) and channel.isdigit(): + channel = int(channel) + try: + cmd = self.ttclient.join_channel(channel, password) + except ValueError: + return self.translator.translate("This channel does not exist") + while True: + try: + event = self.ttclient.event_success_queue.get_nowait() + if event.source == cmd: + break + else: + self.ttclient.event_success_queue.put(event) + except Empty: + pass + try: + error = self.ttclient.errors_queue.get_nowait() + if error.command_id == cmd: + return self.translator.translate( + "Error joining channel: {error}".format(error=error.message) + ) + else: + self.ttclient.errors_queue.put(error) + except Empty: + pass + time.sleep(app_vars.loop_timeout) + + +""" class CreateChannelCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + 'Create channel. first argument is channel name, second argument is parent channel ID, third argument is the channel topic, fourth argument is the channel password. Split arguments " | ", if password or topic is undefined, don\'t type fourth or third argument.' + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + args = self.command_processor.split_arg(arg) + if len(args) == 4: + channel_name = args[0] + parent_channel_id = int(args[1]) + channel_topic = args[2] + channel_password = args[3] + elif len(args) == 3: + channel_name = args[0] + parent_channel_id = int(args[1]) + channel_topic = args[2] + channel_password = "" + elif len(args) == 2: + channel_name = args[0] + parent_channel_id = int(args[1]) + channel_topic = "" + channel_password = "" + else: + return self.help + """ + + +""" class TaskSchedulerCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("Task scheduler") + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg[0] == "+": + self._add(arg[1::]) + + def _add(self, arg: str) -> None: + args = arg.split("|") + timestamp = self._get_timestamp(args[0]) + task = [] + for arg in args[1::]: + try: + command, arg = self.parse_command(message.text) + if self.check_access(message.user, command): + command = self.get_command(command, message.user) + task.append((command, arg)) + except errors.AccessDeniedError as e: + return e + except (errors.ParseCommandError, errors.UnknownCommandError): + return self.translator.translate("Unknown command. Send \"start\" for help.") + except errors.InvalidArgumentError: + return self.help(command, message.user) + if timestamp in self.module_manager.task_scheduler.tasks: + self.module_manager.task_scheduler[timestamp].append(task) + else: + self.module_manager.task_scheduler.tasks[timestamp] = [task,] + + + def _get_timestamp(self, t): + return int(datetime.combine(datetime.today(), datetime.strptime(t, self.config["general"]["time_format"]).time()).timestamp()) + """ + + +class VoiceTransmissionCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("Enables or disables voice transmission") + + def __call__(self, arg: str, user: User) -> Optional[str]: + if not self.ttclient.is_voice_transmission_enabled: + self.ttclient.enable_voice_transmission() + if self.player.state == State.Stopped: + self.ttclient.change_status_text( + self.translator.translate("Voice transmission enabled") + ) + return self.translator.translate("Voice transmission enabled") + else: + self.ttclient.disable_voice_transmission() + if self.player.state == State.Stopped: + self.ttclient.change_status_text("") + return self.translator.translate("Voice transmission disabled") + + +class LockCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("Locks or unlocks the bot") + + def __call__(self, arg: str, user: User) -> Optional[str]: + self.command_processor.locked = not self.command_processor.locked + if self.command_processor.locked: + self.config.general.bot_lock = True + self.run_async( + self.ttclient.send_message, + self.translator.translate("Bot is now locked by administrator."), + user, + ) + else: + self.config.general.bot_lock = False + self.run_async( + self.ttclient.send_message, + self.translator.translate("Bot is now unlocked by administrator."), + user, + ) + self.config_manager.save() + return None + + def on_startup(self) -> None: + self.command_processor.locked = self.config.general.bot_lock + + + +class ChangeStatusCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("STATUS Changes bot's status") + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + self.ttclient.change_status_text(arg) + self.config.teamtalk.status = self.ttclient.status + self.run_async( + self.ttclient.send_message, + self.translator.translate("Status changed."), + user, + ) + else: + self.config.teamtalk.status = "" + self.ttclient.change_status_text("") + self.run_async( + self.ttclient.send_message, + self.translator.translate("Status change to default."), + user, + ) + self.config_manager.save() + + +class EventHandlingCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("Enables or disables event handling") + + def __call__(self, arg: str, user: User) -> Optional[str]: + self.config.teamtalk.event_handling.load_event_handlers = ( + not self.config.teamtalk.event_handling.load_event_handlers + ) + return ( + self.translator.translate("Event handling is enabled") + if self.config.teamtalk.event_handling.load_event_handlers + else self.translator.translate("Event handling is disabled") + ) + + +class SendChannelMessages(Command): + @property + def help(self) -> str: + return self.translator.translate( + "Send your message to current channel. Without any argument, turn or off Channel messages." + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + if self.config.general.send_channel_messages: + self.ttclient.send_message( + self .translator.translate("{message}.\nSender: {nickname}.").format( + message=arg, nickname=user.nickname), + type=2 + ) + elif not self.config.general.send_channel_messages: + return self.translator.translate( + "Please enable channel messages first" + ) + else: + self.config.general.send_channel_messages = (not self.config.general.send_channel_messages) + return (self.translator.translate("Channel messages enabled") + if self.config.general.send_channel_messages + else self.translator.translate("Channel messages disabled") + ) + self.config_manager.save() + + +class SendBroadcastMessages(Command): + @property + def help(self) -> str: + return self.translator.translate( + "Send broadcast message to all users. Without any argument, turn on or off Broadcast messages" + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + if self.config.general.send_broadcast_messages: + self.ttclient.send_message( + self .translator.translate("{message}.\nSender: {nickname}.").format( + message=arg, nickname=user.nickname), + type=3 + ) + elif not self.config.general.send_broadcast_messages: + return self.translator.translate( + "Please enable broadcast messages first" + ) + else: + self.config.general.send_broadcast_messages = (not self.config.general.send_broadcast_messages) + return (self.translator.translate("Broadcast messages enabled") + if self.config.general.send_broadcast_messages + else self.translator.translate("Broadcast messages disabled") + ) + self.config_manager.save() + + +class SaveConfigCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("Saves bot's configuration") + + def __call__(self, arg: str, user: User) -> Optional[str]: + self.config_manager.save() + return self.translator.translate("Configuration saved") + + +class AdminUsersCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "+/-USERNAME Manage the list of administrators. +USERNAME add a user. -USERNAME remove it. Without an option show the list." + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + if arg[0] == "+": + self.config.teamtalk.users.admins.append(arg[1::]) + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "{username} is now admin in this player." + ).format(username=arg[1::]), + user, + ) + self.config_manager.save() + elif arg[0] == "-": + try: + del self.config.teamtalk.users.admins[ + self.config.teamtalk.users.admins.index(arg[1::]) + ] + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "{username} no longer admin in this player." + ).format(username=arg[1::]), + user, + ) + self.config_manager.save() + except ValueError: + return self.translator.translate( + "this user is not in the admin list" + ) + else: + admins = self.config.teamtalk.users.admins.copy() + if len(admins) > 0: + if "" in admins: + admins[admins.index("")] = "" + return ", ".join(admins) + else: + return self.translator.translate("the list is empty") + + +class BannedUsersCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "+/-USERNAME Manage the list of banned users. +USERNAME add a user. -USERNAME remove it. Without an option show the list." + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + if arg[0] == "+": + self.config.teamtalk.users.banned_users.append(arg[1::]) + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "{username} now banned" + ).format(username=arg[1::], nickname=user.nickname), + user, + ) + self.config_manager.save() + elif arg[0] == "-": + try: + del self.config.teamtalk.users.banned_users[ + self.config.teamtalk.users.banned_users.index(arg[1::]) + ] + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "{username} now unbanned." + ).format(username=arg[1::]), + user, + ) + self.config_manager.save() + except ValueError: + return self.translator.translate("this user is not banned") + else: + banned_users = self.config.teamtalk.users.banned_users.copy() + if len(banned_users) > 0: + if "" in banned_users: + banned_users[banned_users.index("")] = "" + return ", ".join(banned_users) + else: + return self.translator.translate("the list is empty") + + +class QuitCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("Quits the bot") + + def __call__(self, arg: str, user: User) -> Optional[str]: + self.config_manager.save() + if self.player.state != State.Stopped: + volume = int(0) + if 0 <= volume <= self.config.player.max_volume: + self.player.set_volume(int(0)) + self._bot.close() + + +class RestartCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("Restarts the bot") + + def __call__(self, arg: str, user: User) -> Optional[str]: + self.config_manager.save() + if self.player.state != State.Stopped: + volume = int(0) + if 0 <= volume <= self.config.player.max_volume: + self.player.set_volume(int(0)) + self._bot.close() + args = sys.argv + if sys.platform == "win32": + subprocess.run([sys.executable] + args) + else: + args.insert(0, sys.executable) + os.execv(sys.executable, args) + + +class SchedulerCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "Controls the cron scheduler, allowing admins to toggle it and add / remove scheduled commands" + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg == "toggle" or arg == "t": + return self.toggle() + elif arg.startswith("add "): + # pass in the string after our command: + add_argstr = arg[4:] + return self.add(add_argstr) + elif arg.startswith("rm "): + return self.remove(arg[3:]) + elif arg.strip() == "list" or arg.strip() == "ls": + return self.list_tasks() + elif arg.strip() in ["help", "?", "h"]: + return """Unknown cr subcommand. Options: + toggle (shorten to t) - turn the scheduler on or off. + add - add a cron task. + Format is cron expression|command with arguments. + Remove - remove a task. + list or ls - list all scheduled tasks.""" + else: + s: str = ( + self.translator.translate( + "Scheduled tasks are enabled (disable with 'cr toggle')" + ) + if self.config.schedule.enabled + else self.translator.translate( + "Scheduled tasks are disabled; enable with 'cr toggle'" + ) + ) + s += "\n" + self.list_tasks() + s += self.translator.translate( + "Commands: \ncr add cron expression|command\ncr rm #\ncr ls\ncr toggle" + ) + return s + + def toggle(self): + self.config.schedule.enabled = not self.config.schedule.enabled + if self.config.schedule.enabled: + self.reparse_patterns() + return ( + self.translator.translate("Scheduler enabled.") + if self.config.schedule.enabled + else self.translator.translate("Scheduler disabled") + ) + + def reparse_patterns(self): + self._bot.cron_patterns = [] + for entry in self.config.schedule.patterns: + logging.debug( + f"Parsing cron pattern '{entry.pattern}' and appending to list" + ) + e = CronTab(entry.pattern) + self._bot.cron_patterns.append((e, entry)) + + def add(self, argstr: str) -> str: + # Our arg should be a cron expression, | and the command. + help_text = self.translator.translate( + "Incorrect format. Cron expression | command you want to run with arguments after" + ) + if "|" not in argstr: + return help_text + args = argstr.split("|") + if len(args) != 2: + return help_text + cronexpr = args[0].strip() + cmd = args[1].strip() + try: + ct: CronTab = CronTab(cronexpr) + entry = CronEntryModel(pattern=cronexpr, command=cmd) + self.config.schedule.patterns.append(entry) + self.reparse_patterns() + return self.translator.translate("Task scheduled.") + except ValueError: + return self.translator.translate( + "Not a valid cron expression. Pleaes use cr expression-help for more details." + ) + + def remove(self, arg: str) -> str: + if arg == "": + return self.list_tasks() + try: + task_index = int(arg) + task_index -= 1 + if task_index > len(self.config.schedule.patterns) or task_index < 0: + return self.translator.translate( + "Task number out of range - should be 1 to {}".format(len(l)) + ) + task = self.config.schedule.patterns.pop(task_index) + self.reparse_patterns() + return self.translator.translate( + f"Removed task #{task_index+1}, {task.pattern}: {task.command}" + ) + except ValueError: + return self.translator.translate("Invalid task number") + + def list_tasks(self) -> str: + if len(self.config.schedule.patterns) == 0: + return self.translator.translate("There are no scheduled tasks") + lines: list[str] = [] + for idx, task in enumerate(self.config.schedule.patterns): + lines.append("Task #{}".format(idx + 1)) + lines.append(" Cron pattern: {}".format(task.pattern)) + lines.append(" command: {}".format(task.command)) + c = CronTab(task.pattern) + lines.append( + "Pattern ran: {} ago;".format( + humanize.precisedelta(c.previous(default_utc=False)) + ) + ) + lines.append( + "Next run in {}.".format( + humanize.precisedelta(c.next(default_utc=False)) + ) + ) + s: str = "" + for l in lines: + s += l + "\n" + + return s + return lines diff --git a/bot/commands/command.py b/bot/commands/command.py new file mode 100644 index 0000000..e8e1c7f --- /dev/null +++ b/bot/commands/command.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Any, TYPE_CHECKING, Callable + +from bot.commands.task_processor import Task + +if TYPE_CHECKING: + from bot.commands import CommandProcessor + + +class Command: + def __init__(self, command_processor: CommandProcessor): + self._bot = command_processor.bot + self.cache = command_processor.cache + self.cache_manager = command_processor.cache_manager + self.command_processor = command_processor + self.config = command_processor.config + self.config_manager = command_processor.config_manager + self.module_manager = command_processor.module_manager + self.player = command_processor.player + self.periodic_player = command_processor.periodic_player + self.service_manager = command_processor.service_manager + self._task_processor = command_processor.task_processor + self.ttclient = command_processor.ttclient + self.translator = command_processor.translator + + @property + def help(self) -> str: + return self.translator.translate("help text not found") + + def run_async(self, func: Callable[..., None], *args: Any, **kwargs: Any) -> None: + self._task_processor.task_queue.put(Task(id(self), func, args, kwargs)) diff --git a/bot/commands/task_processor.py b/bot/commands/task_processor.py new file mode 100644 index 0000000..b2589ba --- /dev/null +++ b/bot/commands/task_processor.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from threading import Thread +from queue import Queue +from typing import TYPE_CHECKING, Any, Callable + +if TYPE_CHECKING: + from bot.commands import CommandProcessor + + +class Task: + def __init__( + self, command_id: int, function: Callable[..., None], args: Any, kwargs: Any + ) -> None: + self.command_id = command_id + self.function = function + self.args = args + self.kwargs = kwargs + + +class TaskProcessor(Thread): + def __init__(self, command_processor: CommandProcessor) -> None: + super().__init__(daemon=True) + self.command_processor = command_processor + self.task_queue: Queue[Task] = Queue() + + def run(self) -> None: + while True: + task = self.task_queue.get() + if task.command_id == self.command_processor.current_command_id: + task.function(*task.args, **task.kwargs) diff --git a/bot/commands/user_commands.py b/bot/commands/user_commands.py new file mode 100644 index 0000000..e601c87 --- /dev/null +++ b/bot/commands/user_commands.py @@ -0,0 +1,629 @@ +from __future__ import annotations +from typing import List, Optional, TYPE_CHECKING +import os + +from bot.commands.command import Command +from bot.player.enums import Mode, State, TrackType +from bot.TeamTalk.structs import User, UserRight +from bot import errors, app_vars + +if TYPE_CHECKING: + from bot.TeamTalk.structs import User + + +class HelpCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("Shows command help") + + def __call__(self, arg: str, user: User) -> Optional[str]: + return self.command_processor.help(arg, user) + + +class AboutCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("Shows information about the bot") + + def __call__(self, arg: str, user: User) -> Optional[str]: + return app_vars.client_name + "\n" + app_vars.about_text(self.translator) + + +class StartBottt(Command): + @property + def help(self) -> str: + return self.translator.translate("Shows greetings") + + def __call__(self, arg: str, user: User) -> Optional[str]: + return app_vars.start_bottt(self.translator) + + +class ContactsBot(Command): + @property + def help(self) -> str: + return self.translator.translate("Shows contact information") + + def __call__(self, arg: str, user: User) -> Optional[str]: + return app_vars.contacts_bot(self.translator) + + +class PlayPauseCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "QUERY Plays tracks found for the query. If no query is given, plays or pauses current track" + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + self.run_async( + self.ttclient.send_message, + self.translator.translate("Searching..."), + user, + ) + try: + track_list = self.service_manager.service.search(arg) + if self.config.general.send_channel_messages: + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "{nickname} requested {request}" + ).format(nickname=user.nickname, request=arg), + type=2, + ) + self.run_async(self.player.play, track_list) + return self.translator.translate("Playing {}").format( + track_list[0].name + ) + except errors.NothingFoundError: + return self.translator.translate("Nothing is found for your query") + except errors.ServiceError: + return self.translator.translate( + "The selected service is currently unavailable" + ) + else: + if self.player.state == State.Playing: + self.run_async(self.player.pause) + elif self.player.state == State.Paused: + self.run_async(self.player.play) + + +class PlayUrlCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("URL Plays a stream from a given URL") + + def __call__(self, arg: str, user: Optional[User]) -> Optional[str]: + if arg: + try: + tracks = self.module_manager.streamer.get(arg, user.is_admin if user is not None else True) + if user is not None and self.config.general.send_channel_messages: + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "{nickname} requested playing from a URL" + ).format(nickname=user.nickname), + type=2, + ) + self.run_async(self.player.play, tracks) + except errors.IncorrectProtocolError: + return self.translator.translate("Incorrect protocol") + except errors.ServiceError: + return self.translator.translate("Cannot process stream URL") + except errors.PathNotFoundError: + return self.translator.translate("The path cannot be found") + else: + raise errors.InvalidArgumentError + + +class StopCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("Stops playback") + + def __call__(self, arg: str, user: User) -> Optional[str]: + if self.player.state != State.Stopped: + volume = int(0) + if 0 <= volume <= self.config.player.max_volume: + self.player.set_volume(int(0)) + self.player.stop() + self.player.set_volume(self.config.player.default_volume) + if self.config.general.send_channel_messages: + self.ttclient.send_message( + self.translator.translate("{nickname} stopped playback").format( + nickname=user.nickname + ), + type=2, + ) + else: + return self.translator.translate("Nothing is playing") + + +class VolumeCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "VALUE Set the volume between 0 and {max_volume}. If no value is specified, show the current volume level." + ).format(max_volume=self.config.player.max_volume) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + try: + volume = int(arg) + if 0 <= volume <= self.config.player.max_volume: + self.player.set_volume(int(arg)) + self.config.player.default_volume = int(arg) + self.config_manager.save() + if self.config.general.send_channel_messages: + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "volume set to {volume}% by {nickname}" + ).format(volume=int(arg), nickname=user.nickname), + type=2, + ) + else: + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "volume is now {volume}%" + ).format(volume=int(arg)), + user, + ) + else: + raise ValueError + except ValueError: + raise errors.InvalidArgumentError + else: + return self.translator.translate("current volume is {volume}%").format(volume=self.player.volume) + + +class SeekBackCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "STEP Seeks current track backward. the default step is {seek_step} seconds" + ).format(seek_step=self.config.player.seek_step) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if self.player.state == State.Stopped: + return self.translator.translate("Nothing is playing") + if arg: + try: + self.player.seek_back(float(arg)) + except ValueError: + raise errors.InvalidArgumentError + else: + self.player.seek_back() + + +class SeekForwardCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "STEP Seeks current track forward. the default step is {seek_step} seconds" + ).format(seek_step=self.config.player.seek_step) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if self.player.state == State.Stopped: + return self.translator.translate("Nothing is playing") + if arg: + try: + self.player.seek_forward(float(arg)) + except ValueError: + raise errors.InvalidArgumentError + else: + self.player.seek_forward() + + +class NextTrackCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("Plays next track") + + def __call__(self, arg: str, user: User) -> Optional[str]: + try: + self.player.next() + return self.translator.translate("Playing {}").format( + self.player.track.name + ) + except errors.NoNextTrackError: + return self.translator.translate("No next track") + except errors.NothingIsPlayingError: + return self.translator.translate("Nothing is playing") + + +class PreviousTrackCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("Plays previous track") + + def __call__(self, arg: str, user: User) -> Optional[str]: + try: + self.player.previous() + return self.translator.translate("Playing {}").format( + self.player.track.name + ) + except errors.NoPreviousTrackError: + return self.translator.translate("No previous track") + except errors.NothingIsPlayingError: + return self.translator.translate("Nothing is playing") + + +class ModeCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "MODE Sets the playback mode. If no mode is specified, the current mode and a list of modes are displayed" + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + self.mode_names = { + Mode.SingleTrack: self.translator.translate("Single Track"), + Mode.RepeatTrack: self.translator.translate("Repeat Track"), + Mode.TrackList: self.translator.translate("Track list"), + Mode.RepeatTrackList: self.translator.translate("Repeat track list"), + Mode.Random: self.translator.translate("Random"), + } + mode_help = self.translator.translate( + "Current mode: {current_mode}\n{modes}" + ).format( + current_mode=self.mode_names[self.player.mode], + modes="\n".join( + [ + "{value} {name}".format(name=self.mode_names[i], value=i.value) + for i in Mode.__members__.values() + ] + ), + ) + if arg: + try: + mode = Mode(arg.lower()) + if mode == Mode.Random: + self.player.shuffle(True) + if self.player.mode == Mode.Random and mode != Mode.Random: + self.player.shuffle(False) + self.player.mode = Mode(mode) + return self.translator.translate("Current mode: {mode}").format( + mode=self.mode_names[self.player.mode] + ) + except ValueError: + return "Incorrect mode\n" + mode_help + else: + return mode_help + + +class ServiceCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "SERVICE Selects the service to play from, sv SERVICE h returns additional help. If no service is specified, the current service and a list of available services are displayed" + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + args = arg.split(" ") + if args[0]: + service_name = args[0].lower() + if service_name not in self.service_manager.services: + return self.translator.translate("Unknown service.\n{}").format( + self.service_help + ) + service = self.service_manager.services[service_name] + if len(args) == 1: + if not service.hidden and service.is_enabled: + self.service_manager.service = service + if service.warning_message: + return self.translator.translate( + "Current service: {}\nWarning: {}" + ).format(service.name, service.warning_message) + return self.translator.translate("Current service: {}").format( + service.name + ) + elif not service.is_enabled: + if service.error_message: + return self.translator.translate( + "Error: {error}\n{service} is disabled".format( + error=service.error_message, + service=service.name, + ) + ) + else: + return self.translator.translate( + "{service} is disabled".format(service=service.name) + ) + elif len(args) >= 1: + if service.help: + return service.help + else: + return self.translator.translate( + "This service has no additional help" + ) + else: + return self.service_help + + @property + def service_help(self): + services: List[str] = [] + for i in self.service_manager.services: + service = self.service_manager.services[i] + if not service.is_enabled: + if service.error_message: + services.append( + "{} (Error: {})".format(service.name, service.error_message) + ) + else: + services.append("{} (Error)".format(service.name)) + elif service.warning_message: + services.append( + self.translator.translate("{} (Warning: {})").format( + service.name, service.warning_message + ) + ) + else: + services.append(service.name) + help = self.translator.translate( + "Current service: {current_service}\nAvailable:\n{available_services}\nsend sv SERVICE h for additional help" + ).format( + current_service=self.service_manager.service.name, + available_services="\n".join(services), + ) + return help + + +class SelectTrackCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "NUMBER Selects track by number from the list of current results" + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + try: + number = int(arg) + if number > 0: + index = number - 1 + elif number < 0: + index = number + else: + return self.translator.translate("Incorrect number") + self.player.play_by_index(index) + return self.translator.translate("Playing {} {}").format( + arg, self.player.track.name + ) + except errors.IncorrectTrackIndexError: + return self.translator.translate("Out of list") + except errors.NothingIsPlayingError: + return self.translator.translate("Nothing is playing") + except ValueError: + raise errors.InvalidArgumentError + else: + if self.player.state != State.Stopped: + return self.translator.translate("Playing {} {}").format( + self.player.track_index + 1, self.player.track.name + ) + else: + return self.translator.translate("Nothing is playing") + + +class SpeedCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "SPEED Sets playback speed from 0.25 to 4. If no speed is given, shows current speed" + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if not arg: + return self.translator.translate("Current rate: {}").format( + str(self.player.get_speed()) + ) + else: + try: + self.player.set_speed(float(arg)) + except ValueError: + raise errors.InvalidArgumentError() + + +class FavoritesCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "+/-NUMBER Manages favorite tracks. + adds the current track to favorites. - removes a track requested from favorites. If a number is specified after +/-, adds/removes a track with that number" + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if user.username == "": + return self.translator.translate( + "This command is not available for guest users" + ) + if arg: + if arg[0] == "+": + return self._add(user) + elif arg[0] == "-": + return self._del(arg, user) + else: + return self._play(arg, user) + else: + return self._list(user) + + def _add(self, user: User) -> str: + if self.player.state != State.Stopped: + if user.username in self.cache.favorites: + self.cache.favorites[user.username].append(self.player.track.get_raw()) + else: + self.cache.favorites[user.username] = [self.player.track.get_raw()] + self.cache_manager.save() + return self.translator.translate("Added") + else: + return self.translator.translate("Nothing is playing") + + def _del(self, arg: str, user: User) -> str: + if (self.player.state != State.Stopped and len(arg) == 1) or len(arg) > 1: + try: + if len(arg) == 1: + self.cache.favorites[user.username].remove(self.player.track) + else: + del self.cache.favorites[user.username][int(arg[1::]) - 1] + self.cache_manager.save() + return self.translator.translate("Deleted") + except IndexError: + return self.translator.translate("Out of list") + except ValueError: + if not arg[1::].isdigit: + return self.help + return self.translator.translate("This track is not in favorites") + else: + return self.translator.translate("Nothing is playing") + + def _list(self, user: User) -> str: + track_names: List[str] = [] + try: + for number, track in enumerate(self.cache.favorites[user.username]): + track_names.append( + "{number}: {track_name}".format( + number=number + 1, + track_name=track.name if track.name else track.url, + ) + ) + except KeyError: + pass + if len(track_names) > 0: + return "\n".join(track_names) + else: + return self.translator.translate("The list is empty") + + def _play(self, arg: str, user: User) -> Optional[str]: + try: + self.player.play( + self.cache.favorites[user.username], start_track_index=int(arg) - 1 + ) + except ValueError: + raise errors.InvalidArgumentError() + except IndexError: + return self.translator.translate("Out of list") + except KeyError: + return self.translator.translate("The list is empty") + + +class GetLinkCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("Gets a direct link to the current track") + + def __call__(self, arg: str, user: User) -> Optional[str]: + if self.player.state != State.Stopped: + url = self.player.track.original_url + if url: + shortener = self.module_manager.shortener + return shortener.get(url) if shortener else url + else: + return self.translator.translate("URL is not available") + else: + return self.translator.translate("Nothing is playing") + + +class RecentsCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "NUMBER Plays a track with the given number from a list of recent tracks. Without a number shows recent tracks" + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + try: + self.player.play( + list(reversed(list(self.cache.recents))), + start_track_index=int(arg) - 1, + ) + except ValueError: + raise errors.InvalidArgumentError() + except IndexError: + return self.translator.translate("Out of list") + else: + track_names: List[str] = [] + for number, track in enumerate(reversed(self.cache.recents)): + if track.name: + track_names.append(f"{number + 1}: {track.name}") + else: + track_names.append(f"{number + 1}: {track.url}") + return ( + "\n".join(track_names) + if track_names + else self.translator.translate("The list is empty") + ) + + +class DownloadCommand(Command): + @property + def help(self) -> str: + return self.translator.translate( + "Downloads the current track and uploads it to the channel." + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if not ( + self.ttclient.user.user_account.rights & UserRight.UploadFiles + == UserRight.UploadFiles + ): + raise PermissionError( + self.translator.translate("Cannot upload file to channel") + ) + if self.player.state != State.Stopped: + track = self.player.track + if track.url and ( + track.type == TrackType.Default or track.type == TrackType.Local + ): + self.module_manager.uploader(self.player.track, user) + return self.translator.translate("Downloading...") + else: + return self.translator.translate("Live streams cannot be downloaded") + else: + return self.translator.translate("Nothing is playing") + +class ChangeLogCommand(Command): + @property + def help(self) -> str: + return self.translator.translate("Show the change log.") + + def __call__(self, arg: str, user: User) -> Optional[str]: + filename = "changelog.txt" + if os.path.exists(filename): + if os.path.getsize(filename ) == 0: + return self.translator.translate("Change log is not available. Contact the administrator.") + else: + f = open(filename, "r") + f = f.read() + for line in f: + return self.translator.translate(f) + f.close() + else: + return self.translator.translate("Change log is not available. Contact the administrator.") + + +class DefaultSeekStepCommand(Command): + @property + def help(self): + return self.translator.translate( + 'Change default Seek step for player.' + ) + + def __call__(self, arg: str, user: User) -> Optional[str]: + if arg: + try: + seekstep = float(arg) + self.config.player.seek_step = float(arg) + self.run_async( + self.ttclient.send_message, + self.translator.translate( + "Default Seek step change to {newseek_step} second." + ).format(newseek_step=float(arg)), + user, + ) + self.config_manager.save() + except ValueError: + raise errors.InvalidArgumentError + else: + return self.translator.translate("Default Seek step can not be blank, Please specify default Seek step!") + diff --git a/bot/config/__init__.py b/bot/config/__init__.py new file mode 100644 index 0000000..a47f458 --- /dev/null +++ b/bot/config/__init__.py @@ -0,0 +1,65 @@ +import json +import os +import sys +from typing import Any, Dict, Optional + +from bot import app_vars, utils +from bot.config.models import ConfigModel +from bot.migrators import config_migrator + +import portalocker + +config_data_type = Dict[str, Any] + + +def save_default_file() -> None: + with open(utils.get_abs_path("config_default.json"), "w") as f: + json.dump(ConfigModel().dict(), f, indent=4, ensure_ascii=False) + + +class ConfigManager: + version = 1 + + def __init__(self, file_name: Optional[str]) -> None: + if file_name: + if os.path.isfile(file_name): + self.file_name = os.path.abspath(file_name) + config_dict = config_migrator.migrate(self, self._load()) + self.config_dir = os.path.dirname(self.file_name) + self._lock() + else: + sys.exit("Incorrect configuration file path") + else: + self.config_dir = app_vars.directory + config_dict = {} + self.config: ConfigModel = ConfigModel(**config_dict) + + def _dump(self, data: config_data_type): + with open(self.file_name, "w", encoding="UTF-8") as f: + json.dump(data, f, indent=4, ensure_ascii=False) + + def _load(self): + with open(self.file_name, "r", encoding="UTF-8") as f: + try: + return json.load(f) + except json.decoder.JSONDecodeError as e: + sys.exit("Syntax error in configuration file: " + str(e)) + + def _lock(self): + self.file_locker = portalocker.Lock( + self.file_name, + timeout=0, + flags=portalocker.LOCK_EX | portalocker.LOCK_NB, + ) + try: + self.file_locker.acquire() + except portalocker.exceptions.LockException: + raise PermissionError() + + def close(self): + self.file_locker.release() + + def save(self): + self.file_locker.release() + self._dump(self.config.dict()) + self.file_locker.acquire() diff --git a/bot/config/models.py b/bot/config/models.py new file mode 100644 index 0000000..def91b0 --- /dev/null +++ b/bot/config/models.py @@ -0,0 +1,127 @@ +from typing import Any, Dict, List, Union, Optional + +from pydantic import BaseModel, validator +from crontab import CronTab + + +class GeneralModel(BaseModel): + language: str = "en" + send_channel_messages: bool = True + send_broadcast_messages: bool = False + bot_lock: bool = False + cache_file_name: str = "TTMediaBotCache.dat" + blocked_commands: List[str] = [] + delete_uploaded_files_after: int = 300 + time_format: str = r"%H:%M" + + +class SoundDevicesModel(BaseModel): + output_device: int = 0 + input_device: int = 0 + + +class PlayerModel(BaseModel): + default_volume: int = 50 + max_volume: int = 100 + volume_fading: bool = True + volume_fading_interval: float = 0.025 + seek_step: int = 5 + player_options: Dict[str, Any] = {} + + +class TeamTalkUserModel(BaseModel): + admins: List[str] = ["admin"] + banned_users: List[str] = [] + + +class EventHandlingModel(BaseModel): + load_event_handlers: bool = False + event_handlers_file_name: str = "event_handlers.py" + + +class TeamTalkModel(BaseModel): + hostname: str = "localhost" + tcp_port: int = 10333 + udp_port: int = 10333 + encrypted: bool = False + nickname: str = "TTMediaBot" + status: str = "" + gender: str = "n" + username: str = "" + password: str = "" + channel: Union[int, str] = "" + channel_password: str = "" + license_name: str = "" + license_key: str = "" + reconnection_attempts: int = -1 + reconnection_timeout: int = 10 + users: TeamTalkUserModel = TeamTalkUserModel() + event_handling: EventHandlingModel = EventHandlingModel() + + +class VkModel(BaseModel): + enabled: bool = True + token: str = "" + + +class YtModel(BaseModel): + enabled: bool = True + + +class YamModel(BaseModel): + enabled: bool = True + token: str = "" + + +class ServicesModel(BaseModel): + default_service: str = "vk" + vk: VkModel = VkModel() + yam: YamModel = YamModel() + yt: YtModel = YtModel() + + +class LoggerModel(BaseModel): + log: bool = True + level: str = "INFO" + format: str = "%(levelname)s [%(asctime)s]: %(message)s in %(threadName)s file: %(filename)s line %(lineno)d function %(funcName)s" + mode: Union[int, str] = "FILE" + file_name: str = "TTMediaBot.log" + max_file_size: int = 0 + backup_count: int = 0 + + +class ShorteningModel(BaseModel): + shorten_links: bool = False + service: str = "clckru" + service_params: Dict[str, Any] = {} + + +class CronEntryModel(BaseModel, arbitrary_types_allowed=True): + # Text cron pattern; will be parsed with the crontab library and a configuration error raised if a pattern fails to parse + pattern: str = "" + # What to run when this cron entry matches + command: str = "" + + @validator('pattern') + def cron_pattern_must_be_valid(cls, pattern): + if pattern != "": + # Add a try here later, with a message about which cron entry is invalid (pattern and command in output if I don't add name) + _entry = CronTab(pattern) + return pattern + + +class SchedulesModel(BaseModel): + enabled: bool = True + patterns: List[CronEntryModel] = [] + + +class ConfigModel(BaseModel): + config_version: int = 0 + general: GeneralModel = GeneralModel() + sound_devices: SoundDevicesModel = SoundDevicesModel() + player: PlayerModel = PlayerModel() + teamtalk: TeamTalkModel = TeamTalkModel() + services: ServicesModel = ServicesModel() + schedule: SchedulesModel = SchedulesModel() + logger: LoggerModel = LoggerModel() + shortening: ShorteningModel = ShorteningModel() diff --git a/bot/connectors/__init__.py b/bot/connectors/__init__.py new file mode 100644 index 0000000..c6232b5 --- /dev/null +++ b/bot/connectors/__init__.py @@ -0,0 +1 @@ +from .tt_player_connector import TTPlayerConnector, MinimalTTPlayerConnector diff --git a/bot/connectors/tt_player_connector.py b/bot/connectors/tt_player_connector.py new file mode 100644 index 0000000..b1bb925 --- /dev/null +++ b/bot/connectors/tt_player_connector.py @@ -0,0 +1,110 @@ +from __future__ import annotations +import logging +from threading import Thread +import time +from typing import TYPE_CHECKING + +from bot.player import State +from bot import app_vars + +if TYPE_CHECKING: + from bot import Bot + + +class TTPlayerConnector(Thread): + def __init__(self, bot: Bot): + super().__init__(daemon=True) + self.name = "TTPlayerConnector" + self.player = bot.player + self.ttclient = bot.ttclient + self.translator = bot.translator + + def run(self): + last_player_state = State.Stopped + last_track_meta = {"name": None, "url": None} + self._close = False + while not self._close: + try: + if self.player.state != last_player_state: + last_player_state = self.player.state + if self.player.state == State.Playing: + self.ttclient.enable_voice_transmission() + last_track_meta = self.player.track.get_meta() + if self.player.track.name: + self.ttclient.change_status_text( + self.translator.translate( + "Playing: {track_name}" + ).format(track_name=self.player.track.name) + ) + else: + self.ttclient.change_status_text( + self.translator.translate( + "Playing: {stream_url}" + ).format(stream_url=self.player.track.url) + ) + elif self.player.state == State.Stopped: + self.ttclient.disable_voice_transmission() + self.ttclient.change_status_text("") + elif self.player.state == State.Paused: + self.ttclient.disable_voice_transmission() + if self.player.track.name: + self.ttclient.change_status_text( + self.translator.translate( + "Paused: {track_name}" + ).format(track_name=self.player.track.name) + ) + else: + self.ttclient.change_status_text( + self.translator.translate( + "Paused: {stream_url}" + ).format(stream_url=self.player.track.url) + ) + if ( + self.player.track.get_meta() != last_track_meta + and last_player_state != State.Stopped + ): + last_track_meta = self.player.track.get_meta() + self.ttclient.change_status_text( + "{state}: {name}".format( + state=self.ttclient.status.split(":")[0], + name=self.player.track.name, + ) + ) + except Exception: + logging.error("", exc_info=True) + time.sleep(app_vars.loop_timeout) + + def close(self): + self._close = True + + +class MinimalTTPlayerConnector(Thread): + """TT player connector, controls setting status and toggling audio transmission. This minimal varient doesn't change status text (as its for periodic / other announcements. +One other consideration is that this connector checks to see if the regular player is playing before stopping transmission when it's starting or stopping playback, and it doesn't keep track of (ha) track metadata. + """ + def __init__(self, bot: Bot): + super().__init__(daemon=True) + self.name = "MinimalTTPlayerConnector" + self.player = bot.player + self.periodic_player = bot.periodic_player + self.ttclient = bot.ttclient + self.translator = bot.translator + + def run(self): + last_player_state = State.Stopped + self._close = False + while not self._close: + try: + if self.periodic_player.state != last_player_state: + last_player_state = self.periodic_player.state + if self.periodic_player.state == State.Playing and self.player.state != State.Playing: + self.ttclient.enable_voice_transmission() + elif self.periodic_player.state != State.Playing and self.player.state != State.Playing: + self.ttclient.disable_voice_transmission() + except Exception: + logging.error("", exc_info=True) + time.sleep(app_vars.loop_timeout) + + def close(self): + self._close = True + diff --git a/bot/errors.py b/bot/errors.py new file mode 100644 index 0000000..fff71dc --- /dev/null +++ b/bot/errors.py @@ -0,0 +1,78 @@ +class ParseCommandError(Exception): + pass + + +class AccessDeniedError(Exception): + pass + + +class UnknownCommandError(Exception): + pass + + +class InvalidArgumentError(Exception): + pass + + +class ServiceNotFoundError(Exception): + pass + + +class ServiceIsDisabledError(Exception): + pass + + +class ServiceError(Exception): + pass + + +class NothingFoundError(Exception): + pass + + +class NoNextTrackError(Exception): + pass + + +class NoPreviousTrackError(Exception): + pass + + +class IncorrectProtocolError(Exception): + pass + + +class PathNotFoundError(Exception): + pass + + +class IncorrectTrackIndexError(Exception): + pass + + +class NothingIsPlayingError(Exception): + pass + + +class IncorrectPositionError(Exception): + pass + + +class TTEventError(Exception): + pass + + +class ConnectionError(Exception): + pass + + +class LoginError(Exception): + pass + + +class LocaleNotFoundError(Exception): + pass + + +class JoinChannelError(Exception): + pass diff --git a/bot/logger.py b/bot/logger.py new file mode 100644 index 0000000..3c282ec --- /dev/null +++ b/bot/logger.py @@ -0,0 +1,59 @@ +from __future__ import annotations +from enum import Flag +import logging +from logging.handlers import RotatingFileHandler +import os +import sys +from typing import TYPE_CHECKING, Any, List + +from bot import app_vars + +if TYPE_CHECKING: + from bot import Bot + + +class Mode(Flag): + STDOUT = 1 + FILE = 2 + STDOUT_AND_FILE = STDOUT | FILE + + +def initialize_logger(bot: Bot) -> None: + config = bot.config.logger + logging.addLevelName(5, "PLAYER_DEBUG") + level = logging.getLevelName(config.level) + formatter = logging.Formatter(config.format) + handlers: List[Any] = [] + try: + mode = ( + Mode(config.mode) + if isinstance(config.mode, int) + else Mode.__members__[config.mode] + ) + except KeyError: + sys.exit("Invalid log mode name") + if mode & Mode.FILE == Mode.FILE: + if bot.log_file_name: + file_name = bot.log_file_name + else: + file_name = config.file_name + if os.path.isdir(os.path.join(*os.path.split(file_name)[0:-1])): + file = file_name + else: + file = os.path.join(bot.config_manager.config_dir, file_name) + rotating_file_handler = RotatingFileHandler( + filename=file, + mode="a", + maxBytes=config.max_file_size * 1024, + backupCount=config.backup_count, + encoding="UTF-8", + ) + rotating_file_handler.setFormatter(formatter) + rotating_file_handler.setLevel(level) + handlers.append(rotating_file_handler) + if mode & Mode.STDOUT == Mode.STDOUT: + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setFormatter(formatter) + stream_handler.setLevel(level) + handlers.append(stream_handler) + logging.basicConfig(level=level, format=config.format, handlers=handlers) diff --git a/bot/migrators/__init__.py b/bot/migrators/__init__.py new file mode 100644 index 0000000..6dde5c1 --- /dev/null +++ b/bot/migrators/__init__.py @@ -0,0 +1 @@ +from bot.migrators import cache_migrator, config_migrator diff --git a/bot/migrators/cache_migrator.py b/bot/migrators/cache_migrator.py new file mode 100644 index 0000000..692e110 --- /dev/null +++ b/bot/migrators/cache_migrator.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from bot.cache import CacheManager, cache_data_type + + +def to_v1(cache_data: cache_data_type) -> cache_data_type: + return update_version(cache_data, 1) + + +migrate_functs = {1: to_v1} + + +def migrate( + cache_manager: CacheManager, + cache_data: cache_data_type, +) -> cache_data_type: + if "cache_version" not in cache_data: + cache_data = update_version(cache_data, 0) + elif ( + not isinstance(cache_data["cache_version"], int) + or cache_data["cache_version"] > cache_manager.version + ): + sys.exit("Error: invalid cache_version value") + elif cache_data["cache_version"] == cache_manager.version: + return cache_data + else: + for ver in migrate_functs: + if ver > cache_data["cache_version"]: + cache_data = migrate_functs[ver](cache_data) + cache_manager._dump(cache_data) + return cache_data + + +def update_version(cache_data: cache_data_type, version: int) -> cache_data_type: + _cache_data = {"cache_version": version} + _cache_data.update(cache_data) + return _cache_data diff --git a/bot/migrators/config_migrator.py b/bot/migrators/config_migrator.py new file mode 100644 index 0000000..b30c782 --- /dev/null +++ b/bot/migrators/config_migrator.py @@ -0,0 +1,37 @@ +import sys + +from bot.config import ConfigManager, config_data_type + + +def to_v1(config_data: config_data_type) -> config_data_type: + return update_version(config_data, 1) + + +migrate_functs = {1: to_v1} + + +def migrate( + config_manager: ConfigManager, + config_data: config_data_type, +) -> config_data_type: + if "config_version" not in config_data: + update_version(config_data, 0) + elif ( + not isinstance(config_data["config_version"], int) + or config_data["config_version"] > config_manager.version + ): + sys.exit("Error: invalid config_version value") + elif config_data["config_version"] == config_manager.version: + return config_data + else: + for ver in migrate_functs: + if ver > config_data["config_version"]: + config_data = migrate_functs[ver](config_data) + config_manager._dump(config_data) + return config_data + + +def update_version(config_data: config_data_type, version: int) -> config_data_type: + _config_data = {"config_version": version} + _config_data.update(config_data) + return _config_data diff --git a/bot/modules/__init__.py b/bot/modules/__init__.py new file mode 100644 index 0000000..34cd3c7 --- /dev/null +++ b/bot/modules/__init__.py @@ -0,0 +1,23 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +from bot.modules.uploader import Uploader +from bot.modules.shortener import Shortener +from bot.modules.streamer import Streamer + +# from bot.modules.task_scheduler import TaskScheduler + +if TYPE_CHECKING: + from bot import Bot + + +class ModuleManager: + def __init__(self, bot: Bot): + self.shortener = ( + Shortener(bot.config.shortening) + if bot.config.shortening.shorten_links + else None + ) + self.streamer = Streamer(bot) + # self.task_scheduler = TaskScheduler(bot) + self.uploader = Uploader(bot) diff --git a/bot/modules/shortener.py b/bot/modules/shortener.py new file mode 100644 index 0000000..c7e9ba4 --- /dev/null +++ b/bot/modules/shortener.py @@ -0,0 +1,24 @@ +import logging + +import pyshorteners + +from bot.config.models import ShorteningModel + + +class Shortener: + def __init__(self, config: ShorteningModel) -> None: + self.shorten_links = config.shorten_links + self.shortener = pyshorteners.Shortener(**config.service_params) + if config.service not in self.shortener.available_shorteners: + logging.error("Unknown shortener service, this feature will be disabled") + self.shorten_links = False + self.shorten_service = getattr(self.shortener, config.service, None) + + def get(self, url: str) -> str: + try: + if self.shorten_links: + return self.shorten_service.short(url) + except Exception: + logging.error("", exc_info=True) + self.shorten_links = False + return url diff --git a/bot/modules/streamer.py b/bot/modules/streamer.py new file mode 100644 index 0000000..d779fbe --- /dev/null +++ b/bot/modules/streamer.py @@ -0,0 +1,74 @@ +from __future__ import annotations +import os +from typing import TYPE_CHECKING, List +from urllib.parse import urlparse + +from bot import errors +from bot.player.enums import TrackType +from bot.player.track import Track + +if TYPE_CHECKING: + from bot import Bot + + +class Streamer: + def __init__(self, bot: Bot): + self.allowed_schemes: List[str] = ["http", "https", "rtmp", "rtsp"] + self.config = bot.config + self.service_manager = bot.service_manager + + def get(self, url: str, is_admin: bool) -> List[Track]: + parsed_url = urlparse(url) + if parsed_url.scheme in self.allowed_schemes: + track = Track(url=url, type=TrackType.Direct) + fetched_data = [track] + for service in self.service_manager.services.values(): + try: + if ( + parsed_url.hostname in service.hostnames + or service.name == self.service_manager.fallback_service + ): + fetched_data = service.get(url) + break + except errors.ServiceError: + continue + except Exception: + if service.name == self.service_manager.fallback_service: + return [ + track, + ] + if len(fetched_data) == 1 and fetched_data[0].url.startswith( + str(track.url) + ): + return [ + track, + ] + else: + return fetched_data + elif is_admin: + if os.path.isfile(url): + track = Track( + url=url, + name=os.path.split(url)[-1], + format=os.path.splitext(url)[1], + type=TrackType.Local, + ) + return [ + track, + ] + elif os.path.isdir(url): + tracks: List[Track] = [] + for path, _, files in os.walk(url): + for file in sorted(files): + url = os.path.join(path, file) + name = os.path.split(url)[-1] + format = os.path.splitext(url)[1] + track = Track( + url=url, name=name, format=format, type=TrackType.Local + ) + tracks.append(track) + return tracks + else: + raise errors.PathNotFoundError("") + else: + raise errors.IncorrectProtocolError("") diff --git a/bot/modules/task_scheduler.py b/bot/modules/task_scheduler.py new file mode 100644 index 0000000..50e1fd8 --- /dev/null +++ b/bot/modules/task_scheduler.py @@ -0,0 +1,32 @@ +""" +from __future__ import annotations +import threading +import time +from typing import TYPE_CHECKING + +from bot import app_vars + +if TYPE_CHECKING: + from bot import Bot + + +class TaskScheduler(threading.Thread): + def __init__(self, bot: Bot): + super().__init__(daemon=True) + self.name = "SchedulerThread" + self.tasks = {} + # self.user = User(0, "", "", 0, 0, "", is_admin=True, is_banned=False) + + def run(self): + while True: + for t in self.tasks: + if self.get_time() >= t: + task = self.tasks[t] + task[0](task[1], self.user) + time.sleep(app_vars.loop_timeout) + + def get_time(self): + return int(round(time.time())) + + +""" diff --git a/bot/modules/uploader.py b/bot/modules/uploader.py new file mode 100644 index 0000000..6f6b3ed --- /dev/null +++ b/bot/modules/uploader.py @@ -0,0 +1,82 @@ +from __future__ import annotations +import threading +import time +import os +import tempfile +from typing import TYPE_CHECKING +from queue import Empty + + +from bot.player.track import Track +from bot.player.enums import TrackType +from bot.TeamTalk.structs import ErrorType, User +from bot import app_vars + +if TYPE_CHECKING: + from bot import Bot + + +class Uploader: + def __init__(self, bot: Bot): + self.config = bot.config + self.ttclient = bot.ttclient + self.translator = bot.translator + + def __call__(self, track: Track, user: User) -> None: + thread = threading.Thread( + target=self.run, + daemon=True, + args=( + track, + user, + ), + ) + thread.start() + + def run(self, track: Track, user: User) -> None: + error_exit = False + if track.type == TrackType.Default: + temp_dir = tempfile.TemporaryDirectory() + file_path = track.download(temp_dir.name) + else: + file_path = track.url + command_id = self.ttclient.send_file(self.ttclient.channel.id, file_path) + file_name = os.path.basename(file_path) + while True: + try: + file = self.ttclient.uploaded_files_queue.get_nowait() + if file.name == file_name: + break + else: + self.ttclient.uploaded_files_queue.put(file) + except Empty: + pass + try: + error = self.ttclient.errors_queue.get_nowait() + if ( + error.command_id == command_id + and error.type == ErrorType.MaxDiskusageExceeded + ): + self.ttclient.send_message( + self.translator.translate("Error: {}").format( + "Max diskusage exceeded" + ), + user, + ) + error_exit = True + else: + self.ttclient.errors_queue.put(error) + except Empty: + pass + time.sleep(app_vars.loop_timeout) + time.sleep(app_vars.loop_timeout) + if track.type == TrackType.Default: + temp_dir.cleanup() + if error_exit: + return + if self.config.general.delete_uploaded_files_after > 0: + timeout = self.config.general.delete_uploaded_files_after + else: + return + time.sleep(timeout) + self.ttclient.delete_file(file.channel.id, file.id) diff --git a/bot/player/__init__.py b/bot/player/__init__.py new file mode 100644 index 0000000..c1307d8 --- /dev/null +++ b/bot/player/__init__.py @@ -0,0 +1,283 @@ +from __future__ import annotations +import html +import logging +import time +from typing import Any, Dict, Callable, List, Optional, TYPE_CHECKING +import random + +import mpv + +from bot import errors +from bot.player.enums import Mode, State, TrackType +from bot.player.track import Track +from bot.sound_devices import SoundDevice, SoundDeviceType + + +if TYPE_CHECKING: + from bot import Bot + + +class Player: + def __init__(self, bot: Bot): + self.config = bot.config.player + self.cache = bot.cache + self.cache_manager = bot.cache_manager + mpv_options = { + "demuxer_lavf_o": "http_persistent=false", + "demuxer_max_back_bytes": 1048576, + "demuxer_max_bytes": 2097152, + "video": False, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36", + "ytdl": False, + } + mpv_options.update(self.config.player_options) + try: + self._player = mpv.MPV(**mpv_options, log_handler=self.log_handler) + except AttributeError: + del mpv_options["demuxer_max_back_bytes"] + self._player = mpv.MPV(**mpv_options, log_handler=self.log_handler) + self._log_level = 5 + self.track_list: List[Track] = [] + self.track: Track = Track() + self.track_index: int = -1 + self.state = State.Stopped + self.mode = Mode.TrackList + self.volume = self.config.default_volume + + def initialize(self) -> None: + logging.debug("Initializing player") + logging.debug("Player initialized") + + def run(self) -> None: + logging.debug("Registering player callbacks") + self.register_event_callback("end-file", self.on_end_file) + self._player.observe_property("metadata", self.on_metadata_update) + self._player.observe_property("media-title", self.on_metadata_update) + logging.debug("Player callbacks registered") + + def close(self) -> None: + logging.debug("Closing player") + if self.state != State.Stopped: + self.stop() + self._player.terminate() + logging.debug("Player closed") + + def play( + self, + tracks: Optional[List[Track]] = None, + start_track_index: Optional[int] = None, + ) -> None: + if tracks != None: + self.track_list = tracks + if not start_track_index and self.mode == Mode.Random: + self.shuffle(True) + self.track_index = self._index_list[0] + self.track = self.track_list[self.track_index] + else: + self.track_index = start_track_index if start_track_index else 0 + self.track = tracks[self.track_index] + self._play(self.track.url) + else: + self._player.pause = False + self._player.volume = self.volume + self.state = State.Playing + + def pause(self) -> None: + self.state = State.Paused + self._player.pause = True + + def stop(self) -> None: + self.state = State.Stopped + self._player.stop() + self.track_list = [] + self.track = Track() + self.track_index = -1 + + def _play(self, arg: str, save_to_recents: bool = True) -> None: + if save_to_recents: + try: + if self.cache.recents[-1] != self.track_list[self.track_index]: + self.cache.recents.append( + self.track_list[self.track_index].get_raw() + ) + except: + self.cache.recents.append(self.track_list[self.track_index].get_raw()) + self.cache_manager.save() + self._player.pause = False + self._player.play(arg) + + def next(self) -> None: + track_index = self.track_index + if len(self.track_list) > 0: + if self.mode == Mode.Random: + try: + track_index = self._index_list[ + self._index_list.index(self.track_index) + 1 + ] + except IndexError: + track_index = 0 + else: + track_index += 1 + else: + track_index = 0 + try: + self.play_by_index(track_index) + except errors.IncorrectTrackIndexError: + if self.mode == Mode.RepeatTrackList: + self.play_by_index(0) + else: + raise errors.NoNextTrackError() + + def previous(self) -> None: + track_index = self.track_index + if len(self.track_list) > 0: + if self.mode == Mode.Random: + try: + track_index = self._index_list[ + self._index_list.index(self.track_index) - 1 + ] + except IndexError: + track_index = len(self.track_list) - 1 + else: + if track_index == 0 and self.mode != Mode.RepeatTrackList: + raise errors.NoPreviousTrackError + else: + track_index -= 1 + else: + track_index = 0 + try: + self.play_by_index(track_index) + except errors.IncorrectTrackIndexError: + if self.mode == Mode.RepeatTrackList: + self.play_by_index(len(self.track_list) - 1) + else: + raise errors.NoPreviousTrackError + + def play_by_index(self, index: int) -> None: + if index < len(self.track_list) and index >= (0 - len(self.track_list)): + self.track = self.track_list[index] + self.track_index = self.track_list.index(self.track) + self._play(self.track.url) + self.state = State.Playing + else: + raise errors.IncorrectTrackIndexError() + + def set_volume(self, volume: int) -> None: + volume = volume if volume <= self.config.max_volume else self.config.max_volume + self.volume = volume + if self.config.volume_fading: + n = 1 if self._player.volume < volume else -1 + for i in range(int(self._player.volume), volume, n): + self._player.volume = i + time.sleep(self.config.volume_fading_interval) + else: + self._player.volume = volume + + def get_speed(self) -> float: + return self._player.speed + + def set_speed(self, arg: float) -> None: + if arg < 0.25 or arg > 4: + raise ValueError() + self._player.speed = arg + + def seek_back(self, step: Optional[float] = None) -> None: + step = step if step else self.config.seek_step + if step <= 0: + raise ValueError() + try: + self._player.seek(-step, reference="relative") + except SystemError: + self.stop() + + def seek_forward(self, step: Optional[float] = None) -> None: + step = step if step else self.config.seek_step + if step <= 0: + raise ValueError() + try: + self._player.seek(step, reference="relative") + except SystemError: + self.stop() + + def get_duration(self) -> float: + return self._player.duration + + """def get_position(self) -> float: + return self._player.time_pos + + def set_position(self, arg: float) -> None: + if arg < 0: + raise errors.IncorrectPositionError() + self._player.seek(arg, reference="absolute")""" + + def get_output_devices(self) -> List[SoundDevice]: + devices: List[SoundDevice] = [] + for device in self._player.audio_device_list: + devices.append( + SoundDevice( + device["description"], device["name"], SoundDeviceType.Output + ) + ) + return devices + + def set_output_device(self, id: str) -> None: + self._player.audio_device = id + + def shuffle(self, enable: bool) -> None: + if enable: + self._index_list = [i for i in range(0, len(self.track_list))] + random.shuffle(self._index_list) + else: + del self._index_list + + def register_event_callback( + self, callback_name: str, callback_func: Callable[[mpv.MpvEvent], None] + ) -> None: + self._player.event_callback(callback_name)(callback_func) + + def log_handler(self, level: str, component: str, message: str) -> None: + logging.log(self._log_level, "{}: {}: {}".format(level, component, message)) + + def _parse_metadata(self, metadata: Dict[str, Any]) -> str: + stream_names = ["icy-name"] + stream_name = None + title = None + artist = None + for i in metadata: + if i in stream_names: + stream_name = html.unescape(metadata[i]) + if "title" in i: + title = html.unescape(metadata[i]) + if "artist" in i: + artist = html.unescape(metadata[i]) + chunks: List[str] = [] + chunks.append(artist) if artist else ... + chunks.append(title) if title else ... + chunks.append(stream_name) if stream_name else ... + return " - ".join(chunks) + + def on_end_file(self, event: mpv.MpvEvent) -> None: + if self.state == State.Playing and self._player.idle_active: + if self.mode == Mode.SingleTrack or self.track.type == TrackType.Direct: + self.stop() + elif self.mode == Mode.RepeatTrack: + self.play_by_index(self.track_index) + else: + try: + self.next() + except errors.NoNextTrackError: + self.stop() + + def on_metadata_update(self, name: str, value: Any) -> None: + if self.state == State.Playing and ( + self.track.type == TrackType.Direct or self.track.type == TrackType.Local + ): + metadata = self._player.metadata + try: + new_name = self._parse_metadata(metadata) + if not new_name: + new_name = html.unescape(self._player.media_title) + except TypeError: + new_name = html.unescape(self._player.media_title) + if self.track.name != new_name and new_name: + self.track.name = new_name diff --git a/bot/player/enums.py b/bot/player/enums.py new file mode 100644 index 0000000..0dc4f10 --- /dev/null +++ b/bot/player/enums.py @@ -0,0 +1,23 @@ +from enum import Enum + + +class State(Enum): + Stopped = "Stopped" + Playing = "Playing" + Paused = "Paused" + + +class Mode(Enum): + SingleTrack = "st" + RepeatTrack = "rt" + TrackList = "tl" + RepeatTrackList = "rtl" + Random = "rnd" + + +class TrackType(Enum): + Default = 0 + Live = 1 + Local = 2 + Direct = 3 + Dynamic = 4 diff --git a/bot/player/track.py b/bot/player/track.py new file mode 100644 index 0000000..ee43f9f --- /dev/null +++ b/bot/player/track.py @@ -0,0 +1,111 @@ +from __future__ import annotations +import copy +import os +from threading import Lock +from typing import Any, Dict, Optional, TYPE_CHECKING + +from bot.player.enums import TrackType +from bot import utils + +if TYPE_CHECKING: + from bot.services import Service + + +class Track: + format: str + type: TrackType + + def __init__( + self, + service: str = "", + url: str = "", + name: str = "", + format: str = "", + extra_info: Optional[Dict[str, Any]] = None, + type: TrackType = TrackType.Default, + ) -> None: + self.service = service + self.url = url + self.name = name + self.format = format + self.extra_info = extra_info + self.type = type + self._lock = Lock() + self._is_fetched = False + + def download(self, directory: str) -> str: + service: Service = get_service_by_name(self.service) + file_name = self.name + "." + self.format + file_name = utils.clean_file_name(file_name) + file_path = os.path.join(directory, file_name) + service.download(self, file_path) + return file_path + + def _fetch_stream_data(self): + if self.type != TrackType.Dynamic or self._is_fetched: + return + self._original_track = copy.deepcopy(self) + service: Service = get_service_by_name(self.service) + track = service.get(self._url, extra_info=self.extra_info, process=True)[0] + self.url = track.url + self.name = track.name + self._original_track.name = track.name + self.format = track.format + self.type = track.type + self.extra_info = track.extra_info + self._is_fetched = True + + @property + def url(self) -> str: + with self._lock: + self._fetch_stream_data() + return self._url + + @url.setter + def url(self, value: str) -> None: + self._url = value + + @property + def original_url(self) -> str: + if self.extra_info and "webpage_url" in self.extra_info: + return self.extra_info["webpage_url"] + return self._url # fallback ke URL biasa + + + @property + def name(self) -> str: + with self._lock: + if not self._name: + self._fetch_stream_data() + return self._name + + @name.setter + def name(self, value: str) -> None: + self._name = value + + def get_meta(self) -> Dict[str, Any]: + try: + return {"name": self.name, "url": self.url} + except: + return {"name": None, "url": ""} + + def get_raw(self) -> Track: + raw = copy.deepcopy(self._original_track) if hasattr(self, "_original_track") else copy.deepcopy(self) + if raw.extra_info and "webpage_url" in raw.extra_info: + raw.url = raw.extra_info["webpage_url"] + return raw + + def __bool__(self): + if self.service or self.url: + return True + else: + return False + + def __getstate__(self) -> Dict[str, Any]: + state: Dict[str, Any] = self.__dict__.copy() + del state["_lock"] + return state + + def __setstate__(self, state: Dict[str, Any]): + self.__dict__.update(state) + self._lock = Lock() diff --git a/bot/services/__init__.py b/bot/services/__init__.py new file mode 100644 index 0000000..0f84549 --- /dev/null +++ b/bot/services/__init__.py @@ -0,0 +1,85 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +import logging +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +import downloader + +from bot import app_vars, errors + +if TYPE_CHECKING: + from bot import Bot + from bot.player.track import Track + + +class Service(ABC): + name: str + is_enabled: bool + hidden: bool + hostnames: List[str] + error_message: str + warning_message: str + help: str + + def download(self, track: Track, file_path: str) -> None: + downloader.download_file(track.url, file_path) + + @abstractmethod + def get( + self, + url: str, + extra_info: Optional[Dict[str, Any]] = None, + process: bool = False, + ) -> List[Track]: + ... + + @abstractmethod + def initialize(self) -> None: + ... + + @abstractmethod + def search(self, query: str) -> List[Track]: + ... + + +from bot.services.vk import VkService +from bot.services.yam import YamService +from bot.services.yt import YtService + + +class ServiceManager: + def __init__(self, bot: Bot) -> None: + self.config = bot.config.services + self.services: Dict[str, Service] = { + "vk": VkService(bot, self.config.vk), + "yam": YamService(bot, self.config.yam), + "yt": YtService(bot, self.config.yt), + } + self.service: Service = self.services[self.config.default_service] + self.fallback_service = app_vars.fallback_service + import builtins + + builtins.__dict__["get_service_by_name"] = self.get_service_by_name + + def initialize(self) -> None: + logging.debug("Initializing services") + for service in self.services.values(): + if not service.is_enabled: + continue + try: + service.initialize() + except errors.ServiceError as e: + service.is_enabled = False + service.error_message = str(e) + if self.service == service: + self.service = self.services[self.fallback_service] + logging.debug("Services initialized") + + def get_service_by_name(self, name: str) -> Service: + try: + service = self.services[name] + if not service.is_enabled: + raise errors.ServiceIsDisabledError(service.error_message) + return service + except KeyError as e: + raise errors.ServiceNotFoundError(str(e)) diff --git a/bot/services/vk.py b/bot/services/vk.py new file mode 100644 index 0000000..97e91da --- /dev/null +++ b/bot/services/vk.py @@ -0,0 +1,150 @@ +from __future__ import annotations +import logging +from typing import Any, Dict, List, Optional, TYPE_CHECKING +from urllib.parse import urlparse + +if TYPE_CHECKING: + from bot import Bot + +import mpv +import requests +import vk_api + +from bot.config.models import VkModel +from bot.player.track import Track +from bot.services import Service as _Service +from bot import errors + + +class VkService(_Service): + def __init__(self, bot: Bot, config: VkModel) -> None: + self.bot = bot + self.config = config + self.name = "vk" + self.hostnames = [ + "vk.com", + "www.vk.com", + "vkontakte.ru", + "www.vkontakte.ru", + "m.vk.com", + "m.vkontakte.ru", + ] + self.is_enabled = config.enabled + self.error_message = "" + self.warning_message = "" + self.help = "" + self.format = "mp3" + self.hidden = False + + def download(self, track: Track, file_path: str) -> None: + if ".m3u8" not in track.url: + super().download(track, file_path) + return + _mpv = mpv.MPV( + **{ + "demuxer_lavf_o": "http_persistent=false", + "ao": "null", + "ao_null_untimed": True, + } + ) + _mpv.play(track.url) + _mpv.record_file = file_path + while not _mpv.idle_active: + pass + _mpv.terminate() + + def initialize(self) -> None: + http = requests.Session() + http.headers.update( + { + "User-agent": "VKAndroidApp/6.2-5091 (Android 9; SDK 28; samsungexynos7870; samsung j6lte; 720x1450)" + } + ) + self._session = vk_api.VkApi( + token=self.config.token, session=http, api_version="5.89" + ) + self.api = self._session.get_api() + try: + self.api.account.getInfo() + except ( + vk_api.exceptions.ApiHttpError, + vk_api.exceptions.ApiError, + requests.exceptions.ConnectionError, + ) as e: + logging.error(e) + raise errors.ServiceError(e) + + def get( + self, + url: str, + extra_info: Optional[Dict[str, Any]] = None, + process: bool = False, + ) -> List[Track]: + parsed_url = urlparse(url) + path = parsed_url.path[1::] + if path.startswith("video-"): + raise errors.ServiceError() + try: + if "music/" in path: + id = path.split("/")[-1] + ids = id.split("_") + o_id = ids[0] + p_id = ids[1] + audios = self.api.audio.get(owner_id=int(o_id), album_id=int(p_id)) + elif "audio" in path: + audios = { + "count": 1, + "items": self.api.audio.getById(audios=[path[5::]]), + } + else: + object_info = self.api.utils.resolveScreenName(screen_name=path) + if object_info["type"] == "group": + id = -object_info["object_id"] + else: + id = object_info["object_id"] + audios = self.api.audio.get(owner_id=id, count=6000) + if "count" in audios and audios["count"] > 0: + tracks: List[Track] = [] + for audio in audios["items"]: + if "url" not in audio or not audio["url"]: + continue + track = Track( + service=self.name, + url=audio["url"], + name="{} - {}".format(audio["artist"], audio["title"]), + format=self.format, + extra_info={"webpage_url": f"https://vk.com/audio{audio['owner_id']}_{audio['id']}"} + ) + tracks.append(track) + if tracks: + return tracks + else: + raise errors.NothingFoundError() + else: + raise errors.NothingFoundError + except NotImplementedError as e: + print("vk get error") + print(e) + raise NotImplementedError() + + def search(self, query: str) -> List[Track]: + results = self.api.audio.search(q=query, count=300, sort=0) + if "count" in results and results["count"] > 0: + tracks: List[Track] = [] + for track in results["items"]: + if "url" not in track or not track["url"]: + continue + track = Track( + service=self.name, + url=track["url"], + name="{} - {}".format(track["artist"], track["title"]), + format=self.format, + extra_info={"webpage_url": f"https://vk.com/audio{track['owner_id']}_{track['id']}"} + ) + tracks.append(track) + if tracks: + return tracks + else: + raise errors.NothingFoundError() + else: + raise errors.NothingFoundError() diff --git a/bot/services/yam.py b/bot/services/yam.py new file mode 100644 index 0000000..8841cef --- /dev/null +++ b/bot/services/yam.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import logging +import time +from typing import Any, Dict, List, Optional, TYPE_CHECKING +from urllib.parse import urlparse + +if TYPE_CHECKING: + from bot import Bot + +from yandex_music import Client +from yandex_music.exceptions import UnauthorizedError, NetworkError + +from bot.config.models import YamModel +from bot.player.enums import TrackType +from bot.player.track import Track +from bot.services import Service +from bot import errors + + +class YamService(Service): + def __init__(self, bot: Bot, config: YamModel): + self.bot = bot + self.config = config + self.name = "yam" + self.hostnames = ["music.yandex.ru"] + self.is_enabled = self.config.enabled + self.error_message = "" + self.warning_message = "" + self.help = "" + self.hidden = False + self.format = ".mp3" + + def initialize(self): + self.api = Client(token=self.config.token) + try: + self.api.init() + except (UnauthorizedError, NetworkError) as e: + logging.error(e) + raise errors.ServiceError(e) + if not self.api.account_status().account.uid: + self.warning_message = self.bot.translator.translate( + "Token is not provided" + ) + elif not self.api.account_status().plus["has_plus"]: + self.warning_message = self.bot.translator.translate( + "You don't have Yandex Plus" + ) + + def get( + self, + url: str, + extra_info: Optional[Dict[str, Any]] = None, + process: bool = False, + ) -> List[Track]: + if not process: + parsed_data = urlparse(url) + path = parsed_data.path + if "/album/" in path and "/track/" in path: + split_path = path.split("/") + real_id = split_path[4] + ":" + split_path[2] + return self.get(None, extra_info={"track_id": real_id}, process=True) + elif "/album/" in path: + tracks = [] + album = self.api.albums_with_tracks(path.split("/")[2]) + if len(album.volumes) == 0 or len(album.volumes[0]) == 0: + raise errors.ServiceError() + for volume in album.volumes: + for track in volume: + tracks.append( + Track( + service=self.name, + extra_info={"track_id": track.track_id}, + type=TrackType.Dynamic, + ) + ) + return tracks + if "/artist/" in path: + tracks = [] + artist_tracks = self.api.artists_tracks(path.split("/")[2]).tracks + if len(artist_tracks) == 0: + raise errors.ServiceError() + for track in artist_tracks: + tracks.append( + Track( + service=self.name, + extra_info={"track_id": track.track_id}, + type=TrackType.Dynamic, + ) + ) + return tracks + elif "users" in path and "playlist" in path: + tracks = [] + split_path = path.split("/") + user_id = split_path[2] + kind = split_path[4] + playlist = self.api.users_playlists(kind=kind, user_id=user_id) + if playlist.track_count == 0: + raise errors.ServiceError() + for track in playlist.tracks: + tracks.append( + Track( + service=self.name, + extra_info={"track_id": track.track_id}, + type=TrackType.Dynamic, + ) + ) + return tracks + else: + track = self.api.tracks(extra_info["track_id"])[0] + return [ + Track( + service=self.name, + name="{} - {}".format( + " & ".join(track.artists_name()), track.title + ), + url=track.get_download_info(get_direct_links=True)[0].direct_link, + type=TrackType.Default, + format=self.format, + ) + ] + + def search(self, query: str) -> List[Track]: + tracks: List[Track] = [] + found_tracks = self.api.search(text=query, nocorrect=True, type_="all").tracks + if found_tracks: + for track in found_tracks.results: + tracks.append( + Track( + service=self.name, + type=TrackType.Dynamic, + extra_info={"track_id": track.track_id}, + ) + ) + found_podcast_episodes = self.api.search( + text=query, nocorrect=True, type_="podcast_episode" + ).podcast_episodes + if found_podcast_episodes: + for podcast_episode in found_podcast_episodes.results: + tracks.append( + Track( + service=self.name, + type=TrackType.Dynamic, + extra_info={"track_id": podcast_episode.track_id}, + ) + ) + if tracks: + return tracks + else: + raise errors.NothingFoundError("") diff --git a/bot/services/yt.py b/bot/services/yt.py new file mode 100644 index 0000000..e75c0cd --- /dev/null +++ b/bot/services/yt.py @@ -0,0 +1,191 @@ +from __future__ import annotations +import logging +import time +from typing import Any, Dict, List, Optional, TYPE_CHECKING +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +if TYPE_CHECKING: + from bot import Bot + +from yt_dlp import YoutubeDL +from yt_dlp.downloader import get_suitable_downloader +from bot.config.models import YtModel +from bot.player.enums import TrackType +from bot.player.track import Track +from bot.services import Service as _Service +from bot import errors + +class YtService(_Service): + def __init__(self, bot: Bot, config: YtModel): + self.bot = bot + self.config = config + self.name = "yt" + self.hostnames = [] + self.is_enabled = self.config.enabled + self.error_message = "" + self.warning_message = "" + self.help = "" + self.hidden = False + + self._last_search = time.time() + self._search_interval = 1.0 + self._search_results_cache = {} + self._track_cache = {} + self._cache_timeout = 300 + + API_KEY = 'AIzaSyAnXGFy067AjtuuySsldXi17ysOEQW_ssw' + try: + self.youtube_api = build("youtube", "v3", developerKey=API_KEY) + except Exception as e: + logging.error(f"Failed to initialize YouTube API: {str(e)}") + self.is_enabled = False + self.error_message = "YouTube API initialization failed" + + def initialize(self): + self._ydl_config = { + "skip_download": True, + "format": "m4a/bestaudio/best[protocol!=m3u8_native]/best", + "socket_timeout": 5, + "logger": logging.getLogger("root"), + "cookiefile": "/home/ttbot/data/cookies.txt" + } + + def download(self, track: Track, file_path: str) -> None: + try: + info = track.extra_info + if not info: + super().download(track, file_path) + return + + with YoutubeDL(self._ydl_config) as ydl: + dl = get_suitable_downloader(info)(ydl, self._ydl_config) + dl.download(file_path, info) + except Exception as e: + logging.error(f"Download error: {str(e)}", exc_info=True) + raise errors.ServiceError("Download failed") + + def get( + self, + url: str, + extra_info: Optional[Dict[str, Any]] = None, + process: bool = False, + ) -> List[Track]: + if not (url or extra_info): + raise errors.InvalidArgumentError() + + cache_key = url or extra_info.get('webpage_url', '') if extra_info else '' + if cache_key in self._track_cache and not process: + cache_time, track = self._track_cache[cache_key] + if time.time() - cache_time < self._cache_timeout: + return [track] + + try: + with YoutubeDL(self._ydl_config) as ydl: + if process and extra_info: + info = ydl.extract_info(extra_info.get('webpage_url', url), process=False) + elif not extra_info: + info = ydl.extract_info(url, process=False) + else: + info = extra_info + + info_type = info.get("_type") + + if info_type == "url" and not info.get("ie_key"): + return self.get(info["url"], process=False) + + elif info_type == "playlist": + tracks: List[Track] = [] + for entry in info["entries"]: + data = self.get("", extra_info=entry, process=False) + tracks.extend(data) + return tracks + + if not process: + track = Track( + service=self.name, + url=info.get('webpage_url', url) or url, + name=info.get('title', ''), + extra_info=info, + format=info.get('ext', ''), + type=TrackType.Dynamic + ) + + self._track_cache[cache_key] = (time.time(), track) + return [track] + + stream = ydl.process_ie_result(info, download=False) + + if "url" not in stream: + raise errors.ServiceError("No URL found in stream info") + + title = stream.get("title", "") + if stream.get("uploader"): + title += f" - {stream['uploader']}" + + track_type = TrackType.Live if stream.get("is_live") else TrackType.Default + + track = Track( + service=self.name, + url=stream["url"], + name=title, + format=stream.get("ext", ""), + type=track_type, + extra_info=stream + ) + + self._track_cache[cache_key] = (time.time(), track) + return [track] + + except Exception as e: + logging.error(f"Error getting track info: {str(e)}", exc_info=True) + raise errors.ServiceError(f"Failed to get track info: {str(e)}") + + def search(self, query: str) -> List[Track]: + current_time = time.time() + + if query in self._search_results_cache: + cache_time, results = self._search_results_cache[query] + if current_time - cache_time < self._cache_timeout: + return results.copy() + + time_since_last = current_time - self._last_search + if time_since_last < self._search_interval: + time.sleep(self._search_interval - time_since_last) + + try: + search_response = self.youtube_api.search().list( + q=query, + part="snippet", + maxResults=25, + type="video" + ).execute() + + tracks = [] + for search_result in search_response.get("items", []): + if 'videoId' in search_result.get('id', {}): + video_url = f"https://www.youtube.com/watch?v={search_result['id']['videoId']}" + track = Track( + service=self.name, + url=video_url, + name=search_result["snippet"]["title"], + type=TrackType.Dynamic, + extra_info={"webpage_url": video_url, "title": search_result["snippet"]["title"]} + ) + + self._track_cache[video_url] = (current_time, track.get_raw()) + tracks.append(track) + + self._search_results_cache[query] = (current_time, tracks.copy()) + self._last_search = current_time + + if tracks: + return tracks + raise errors.NothingFoundError("No videos found") + + except HttpError as e: + logging.error(f"YouTube API error: {str(e)}", exc_info=True) + raise errors.NothingFoundError(f"YouTube search failed: {str(e)}") + except Exception as e: + logging.error(f"Unexpected error in search: {str(e)}", exc_info=True) + raise errors.ServiceError(f"Search failed: {str(e)}") \ No newline at end of file diff --git a/bot/sound_devices.py b/bot/sound_devices.py new file mode 100644 index 0000000..c0fe87a --- /dev/null +++ b/bot/sound_devices.py @@ -0,0 +1,51 @@ +from __future__ import annotations +from enum import Enum +import logging +import sys +from typing import TYPE_CHECKING, Union + +if TYPE_CHECKING: + from bot import Bot + + +class SoundDevice: + def __init__(self, name: str, id: Union[int, str], type: SoundDeviceType) -> None: + self.name = name + self.id = id + self.type = type + + +class SoundDeviceType(Enum): + Output = 0 + Input = 1 + + +class SoundDeviceManager: + def __init__(self, bot: Bot) -> None: + self.config = bot.config + self.output_device_index = self.config.sound_devices.output_device + self.input_device_index = self.config.sound_devices.input_device + self.player = bot.player + self.ttclient = bot.ttclient + self.output_devices = self.player.get_output_devices() + self.input_devices = self.ttclient.get_input_devices() + + def initialize(self) -> None: + logging.debug("Initializing sound devices") + try: + self.player.set_output_device( + str(self.output_devices[self.output_device_index].id) + ) + except IndexError: + error = "Incorrect output device index: " + str(self.output_device_index) + logging.error(error) + sys.exit(error) + try: + self.ttclient.set_input_device( + int(self.input_devices[self.input_device_index].id) + ) + except IndexError: + error = "Incorrect input device index: " + str(self.input_device_index) + logging.error(error) + sys.exit(error) + logging.debug("Sound devices initialized") diff --git a/bot/translator.py b/bot/translator.py new file mode 100644 index 0000000..c90504c --- /dev/null +++ b/bot/translator.py @@ -0,0 +1,32 @@ +import gettext +import os +from typing import List + +from bot import app_vars, errors + + +class Translator: + def __init__(self, language: str) -> None: + self._locale = "en" + self.set_locale(language) + + def get_locale(self) -> str: + return self._locale + + def get_locales(self) -> List[str]: + return ["en"] + os.listdir(os.path.join(app_vars.directory, "locale")) + + def set_locale(self, locale: str) -> None: + if locale in self.get_locales() or locale == "en": + self._locale = locale + self.translation = gettext.translation( + "TTMediaBot", + os.path.join(app_vars.directory, "locale"), + languages=[locale], + fallback=True, + ) + else: + raise errors.LocaleNotFoundError() + + def translate(self, message: str) -> str: + return self.translation.gettext(message) diff --git a/bot/utils.py b/bot/utils.py new file mode 100644 index 0000000..ddc3378 --- /dev/null +++ b/bot/utils.py @@ -0,0 +1,28 @@ +import os +from typing import List, Tuple +from crontab import CronTab + +from bot import app_vars +from bot.config.models import CronEntryModel + + +def clean_file_name(file_name: str) -> str: + for char in ["\\", "/", "%", "*", "?", ":", '"', "|"] + [ + chr(i) for i in range(1, 32) + ]: + file_name = file_name.replace(char, "_") + file_name = file_name.strip() + return file_name + + +def get_abs_path(file_name: str) -> str: + return os.path.join(app_vars.directory, file_name) + + +def sort_cron_tasks( + tasks: List[Tuple[CronTab, CronEntryModel]] +) -> List[CronEntryModel]: + """Given a list of CronTask instances, return the same list, sorted by ascending next run time.""" + # sort by item[0].next(), a function on CronTab instance + sorted_tasks = sorted(tasks, key=lambda t: t[0].next(default_utc=False)) + return sorted_tasks diff --git a/changelog.txt b/changelog.txt new file mode 100644 index 0000000..96cdea2 --- /dev/null +++ b/changelog.txt @@ -0,0 +1,159 @@ +This change log is written to find out the changes that have been made by Pandora, and the source code still refers to TTMediaBot. + +07-04-2025 + +1. Some of the default TTMediabot languages were deleted. Things that are irrelevant because Pandorabox has changed a lot of things to be done. Currently there are only English and Indonesian languages. +2. Improve Indonesian Language, in reality, it must be re -brought up. 177 items? How wasteful is! +3. The default dockerfile from TTMediaBot must be kicked and replaced with a more stable one, instead of letting Pulseaudio jammed. Thank you to friends in the TTMediaBot Telegram Group, you are amazing! +4. Improfify YouTube Search and Youtube DLP. + +05-04-2025 + +1. Get link command (gl) is now fix. Stop the temporary url that makes your screen full of insignificant spamming URLs, kick out! +2. Recent and Favorite Command (R) (F) should be able to store well. Don't worry about ASMR Audio or other pleasure audio, now hugged tightly. +3. Don't forget to buy us coffee and snacks, Starbucks! + +29-12-2024 + +1. Fix pydantic error, fix mvp for stabilization and for windows. + +16-06-2023 + +1. add the Scheduler Command to perform certain tasks using crontab. Thanks to someone who made it, I can't remember, but one of the members of the TTMediaBot group on telegram. +2. Fixed on connector players. Enjoy! +3. bump Version to 2.3.3. + +30-05-2023 + +1. align the code with TTMediaBot. +2. add new command (cdid) to get channel ID, this will be useful to get channel ID instead of channel path to apply to config file. +3. The translate files still broke, i'm lazy :P. If someone wants to help me, catch me on contact page! +4. The download file from youtube it's should be faster now! + + +19-04-2022 + +1. Fix Indonesian translation for some strings not work. +2. now possible to start stop enable or disable services without logging in to the current user. Just copy file srb to /usr/sbin and type chnmod +x /usr/sbin/srb. if you want to restart service TTMediaBot with user root for example, you can type srb restart then answer all question. + + +15-04-2022 + +1. Indonesian translate should work fine now. +2. changed the bot version, adapted to the TTMediaBot source code. +3. Improve server security from SSH attacks. + +Merge source code from TTMediaBot; +1. migrator functionality. +2. services strings translation fix. +3. localisation fixes. +4. fixed compile_locales.py. +5. trying to fix localisation system. +6. Update russian translation has been finished. +7. migrate improve, TTSDK version 5.8 is latest, some fixes. +8. yandex music playlist support. (Still can't be use because stuck on authorization). +9. str fix. +10. Yam: Gracefully handle network errors.. +11. Remove useless typestubs.. + + +06-04-2022 + +1. When stop player, restart bot and quit bot, player volume must be returned to 0. This will take effect while player is playing, this will depend on volume_fading in the config file. + + +02-04-2022 + +1. Fixing on the help section, on send message to channel. +2. added "sbf" command to change default seek step for player. +3. fixing typo month on the log. + + +01-04-2022 + +1. Removed the "bm" command. Combine enable/disable broadcast messages with the "bmsg" command. +2. Removed the "cm" command. Combine enable/disable channel messages with the "cmsg" command. +3. Removed "dcs" command, "dss" command, "dcc" command and "dsc" command. +4. Added "sds" command for showing server information like server host, TCP/UDP port, username, password, channel and channel password. +5. Added "cds" command to change default server host. +6. Added "cdt" and "cdd" commands to change default TCP and UDP port. +7. Added "cdu" and "cdup" commands to change default username and password. +8. Added "cdc" "cdcp" command to change default channel and channel password. +9. Now all Pandora users can view the change log with the "log" command. It is hoped that it will make it easier for users to know the changes. + + +31-03-2022 + +Merge source code from TTMediaBot; +1. changed authors list. +2. libmpv_win_downloader: Fix architecture selection.. +3. fixed displaying input devices list. +4. requirements fix. +5. improved compile_locales.py, cleaned up development-requirements.txt. +6. Added yandex music service, but it can not play playlists, improved service_manager. (In pandora this service can't be use, because error can't login to yandex music). + + +15-02-2022 + +1. Auto save configuration has been added for some commands. +2. If the user restarts or quits, the bot will save the configuration automatically to the config file. Don't worry about losing your configuration! +3. Added "dcs" command. Change default server host, TCP/UDP port, username and password with one command. +4. Added "dcc" command. Change default channel and channel password with one command. +5. added "dss" and "dsc" command. shows default server information and default channel information. +6. If you set the volume, it will now be automatically saved to the config file. This is so that the bot restarts or lost connection, the bot will not return to the default volume configuration. +7. Modify the "cn" command. Now if the user sends the "cn" command without any arguments, the bot nickname will be returned to the client bot name. +8. Modify the "cs" command. If the user sends the "cs" command without any arguments, the bot will return to the default status. + + +18-01-2022 + +Merge source code from TTMediaBot; +1. Fix reconnection logic. +2. Fix status loss after reconnecting. +3. Support newer Windows builds of libmpv. +4. mpv.py: fix NameError while exiting with newer mpv versions. +5. Fix broken metadata updating on libmpv-2. We need to use observed properties, because the metadata-update event has been removed in mpv API v2. +6. Replace legacy urlretrieve with a custom requests-based download function. This should solve all certificate-related problems. Also, make a few cosmetic code changes. + + +02-12-2021 + +Merge source code from TTMediaBot; +1. Vk_auth: Fix wording. + + +26-11-2021 + +Merge source code from TTMediaBot; +1. Shortener module now will be loded if corresponding option is enabled. +2. update readme. +3. vk.com/audio links suport, changed vk_auth vk_api version. + + +14-11-2021 + +Merge source code from TTMediaBot; +1. vk tracks downloading fix. +2. trying fix vk. +3. Some fixes. + + +29-10-2021 + +Merge source code from TTMediaBot; +1. Changed the most importent function + + +26-09-2021 + +1. Added the "bm" command. allows bots to enable/disable broadcast messages. +2. Added "bmsg" command. Allows broadcast messages to be sent via bots, useful for iPhone and android users. (will working with admin rights) +3. Added "cmsg" command. Allows users to send messages to channels using bots. (will working with admin rights) + +11-09-2021 + +1. Added the "start" command. To make this bot user friendly, add a greeting in the status and at the beginning of the command, instead of the "h" command. +2. Added the "contacts" command. Make it easy for all users to contact the administrator. +3. Change the "h" command. Now the "help" command will bring up all the commands that can be used in this bot. +4. Instead of "a" command to see About this bot, change it to "about" command. +5. Fix typo Mit license to MIT license in the section about this bot, also fix the placement of "\" in the code section about this bot. diff --git a/config_default.json b/config_default.json new file mode 100644 index 0000000..16b84e7 --- /dev/null +++ b/config_default.json @@ -0,0 +1,80 @@ +{ + "config_version": 0, + "general": { + "language": "en", + "send_channel_messages": false, + "send_broadcast_messages": true, + "bot_lock": false, + "cache_file_name": "TTMediaBotCache.dat", + "blocked_commands": [], + "delete_uploaded_files_after": 300, + "time_format": "%H:%M" + }, + "sound_devices": { + "output_device": 1, + "input_device": 0 + }, + "player": { + "default_volume": 50, + "max_volume": 100, + "volume_fading": true, + "volume_fading_interval": 0.08, + "seek_step": 5, + "player_options": {} + }, + "teamtalk": { + "hostname": "tt.infiartt.com", + "tcp_port": 1945, + "udp_port": 1945, + "encrypted": false, + "nickname": "🎶 player", + "status": "", + "gender": "n", + "username": "", + "password": "", + "channel": "", + "channel_password": "", + "license_name": "", + "license_key": "", + "reconnection_attempts": -1, + "reconnection_timeout": 10, + "users": { + "admins": [ + "admin" + ], + "banned_users": [] + }, + "event_handling": { + "load_event_handlers": false, + "event_handlers_file_name": "event_handlers.py" + } + }, + "services": { + "default_service": "yt", + "vk": { + "enabled": true, + "token": "30638cf75e342a89fc8edca415f22e584015c7991abea763447d958d5533ef75ffa812cd2d21209df4d04" + }, + "yam": { + "enabled": false, + "token": "" + }, + "yt": { + "enabled": true + } + }, + "logger": { + "log": true, + "level": "INFO", + "format": "%(levelname)s [%(asctime)s]: %(message)s in %(threadName)s file: %(filename)s line %(lineno)d function %(funcName)s", + "mode": "FILE", + "file_name": "TTMediaBot.log", + "max_file_size": 0, + "backup_count": 0 + }, + "shortening": { + "shorten_links": false, + "service": "clckru", + "service_params": {} + } +} \ No newline at end of file diff --git a/development-requirements.txt b/development-requirements.txt new file mode 100644 index 0000000..38b7673 --- /dev/null +++ b/development-requirements.txt @@ -0,0 +1,8 @@ +black +flake8 +flake8-black +flake8-bandit +flake8-commas +flake8-eradicate +flake8-import-order +flake8-quotes diff --git a/downloader.py b/downloader.py new file mode 100644 index 0000000..1b76f9b --- /dev/null +++ b/downloader.py @@ -0,0 +1,11 @@ +import requests +import shutil + +def download_file(url: str, file_path: str) -> None: + headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"} + with requests.get(url, headers=headers, stream=True) as r: + try: + with open(file_path, "wb") as f: + shutil.copyfileobj(r.raw, f) + except Exception as e: + print(f"An error occurred while downloading the file: {e}") diff --git a/locale/id/LC_MESSAGES/TTMediaBot.po b/locale/id/LC_MESSAGES/TTMediaBot.po new file mode 100644 index 0000000..136d4d3 --- /dev/null +++ b/locale/id/LC_MESSAGES/TTMediaBot.po @@ -0,0 +1,984 @@ +# Indonesian (Indonesia) translations for TTMediaBot. +# Copyright (C) 2025 TTMediaBot-team +# This file is distributed under the same license as the TTMediaBot project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: TTMediaBot VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-04-07 19:17+0700\n" +"PO-Revision-Date: 2025-04-07 19:13+0700\n" +"Last-Translator: \n" +"Language: id_ID\n" +"Language-Team: Pandora's Team\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.14.0\n" + +#: D:/tanah/PandoraBox/bot/app_vars.py:11 +msgid "" +"A media streaming bot for TeamTalk.\n" +"Authors: Amir Gumerov, Vladislav Kopylov, Beqa Gozalishvili, Kirill " +"Belousov.\n" +"Home page: https://github.com/gumerov-amir/TTMediaBot\n" +"License: MIT License" +msgstr "" +"Bot media streaming untuk TeamTalk.\n" +"Penulis: Amir Gumerov, Vladislav Kopylov, Beqa Gozalishvili, Kirill " +"Belousov.\n" +"Home page: https://github.com/gumerov-amir/TTMediaBot\n" +"License: MIT License" + +#: D:/tanah/PandoraBox/bot/app_vars.py:19 +msgid "" +"Hello there!\n" +"I'm PandoraBox, your go-to companion for discovering amazing songs and " +"audio through YouTube, Yandex Music, and VK.\n" +"Hosted on Pandora's server, I'm all set to bring audio magic to your " +"TeamTalk experience.\n" +"To get started, simply send me a private message with a specific command." +"\n" +"Here's how you can interact with me:\n" +"- help: Receive a handy guide to all the commands you can use, delivered " +"straight to your private message.\n" +"- contacts: Get contact information sent directly to your private " +"message.\n" +"- log: Check out the change log file.\n" +"If you encounter any issues or want to chat with us, feel free to reach " +"out.\n" +"Thank you for choosing our service, and have a fantastic day!" +msgstr "" +"Halo!\n" +"Saya PandoraBox, teman Anda untuk menemukan lagu dan audio yang luar " +"biasa melalui YouTube, Yandex Music, dan VK.\n" +"Di -host di server Pandora, saya siap untuk membawa audio keren ke " +"TeamTalk Anda.\n" +"Untuk memulai, cukup kirimkan pesan pribadi dengan perintah tertentu.\n" +"Inilah cara Anda dapat berinteraksi dengan saya:\n" +"- help: Menerima panduan praktis untuk semua perintah yang dapat Anda " +"gunakan, dikirim langsung ke pesan pribadi Anda.\n" +"- contacts: Dapatkan informasi kontak yang dikirim langsung ke pesan " +"pribadi Anda.\n" +"- log: Lihat file log perubahan.\n" +"Jika Anda menghadapi masalah atau ingin mengobrol dengan kami, jangan " +"ragu untuk menghubungi kami.\n" +"Terima kasih telah memilih layanan kami, dan semoga harimu fantastis!" + +#: D:/tanah/PandoraBox/bot/app_vars.py:33 +msgid "" +"If you encounter any issues with this bot, please reach out to our " +"dedicated technicians:\n" +"\n" +"- Muhammad:\n" +" - WhatsApp: https://api.whatsapp.com/send?phone=6282156978782\n" +" - Telegram: https://t.me/muha_aku\n" +"\n" +"- Rexya:\n" +" - WhatsApp: https://api.whatsapp.com/send?phone=6288222553434\n" +" - Email: rexya@infiartt.com\n" +"\n" +"Join the TTMediaBot Official Group on Telegram: " +"https://t.me/TTMediaBot_chat" +msgstr "" +"Jika Anda menghadapi masalah apa pun dengan bot ini, silakan hubungi " +"teknisi kami:\n" +"\n" +"- Muhammad:\n" +" - WhatsApp: https://api.whatsapp.com/send?phone=6282156978782\n" +" - Telegram: https://t.me/muha_aku\n" +"\n" +"- Rexya:\n" +" - WhatsApp: https://api.whatsapp.com/send?phone=6288222553434\n" +" - Email: rexya@infiartt.com\n" +"\n" +"Gabung dengan Grup Resmi TTMediaBot di Telegram: " +"https://t.me/TTMediaBot_chat" + +#: D:/tanah/PandoraBox/bot/TeamTalk/__init__.py:150 +msgid "PM \"start\" to get short help!" +msgstr "PM \"start\" untuk mendapatkan bantuan singkat!" + +#: D:/tanah/PandoraBox/bot/commands/__init__.py:122 +msgid "Unknown command. Send \"start\" for help." +msgstr "Perintah tidak diketahui. Kirim \"Start\" untuk bantuan." + +#: D:/tanah/PandoraBox/bot/commands/__init__.py:128 +#: D:/tanah/PandoraBox/bot/modules/uploader.py:61 +msgid "Error: {}" +msgstr "Kesalahan: {}" + +#: D:/tanah/PandoraBox/bot/commands/__init__.py:140 +msgid "You are banned" +msgstr "Anda dilarang" + +#: D:/tanah/PandoraBox/bot/commands/__init__.py:144 +msgid "You are not in bot's channel" +msgstr "Anda tidak berada di saluran bot" + +#: D:/tanah/PandoraBox/bot/commands/__init__.py:148 +msgid "Bot is locked" +msgstr "Bot terkunci" + +#: D:/tanah/PandoraBox/bot/commands/__init__.py:152 +msgid "This command is blocked" +msgstr "Perintah ini diblokir" + +#: D:/tanah/PandoraBox/bot/commands/__init__.py:178 +msgid "Unknown command" +msgstr "Perintah tidak diketahui" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:24 +msgid "" +"+/-COMMAND Block or unblock command. +COMMAND add command to the block " +"list. -COMMAND remove from it. Without a command shows the block list." +msgstr "" +"+/- lakukan blokir atau unblokir perintah. +Perintah Tambahkan perintah " +"ke daftar blokir. -perintah hapus perintah dari daftar blokir. Tanpa " +"tanda apapun tunjukan daftar blokir." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:36 +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:683 +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:727 +msgid "the list is empty" +msgstr "Daftar kosong" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:43 +msgid "command \"{command}\" was blocked." +msgstr "Perintah \"{command}\" diblokir." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:50 +msgid "This command is already added" +msgstr "Perintah ini sudah ditambahkan" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:58 +msgid "command \"{command}\" was unblocked." +msgstr "Perintah \"{command}\" tidak diblokir." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:65 +msgid "This command is not blocked" +msgstr "Perintah ini tidak diblokir" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:73 +msgid "GENDER Changes bot's gender. n neutral, m male, f female" +msgstr "Rubah Jenis Kelamin Bot. n netral, m laki-laki, f perempuan" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:89 +msgid "LANGUAGE change player language" +msgstr "Ubah bahasa pemain" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:99 +msgid "the language has been changed to {language}." +msgstr "Bahasa telah di ubah ke {language}." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:106 +msgid "Incorrect language" +msgstr "Bahasa tidak tersedia" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:108 +msgid "" +"Current language: {current_language}. Available languages: " +"{available_languages}." +msgstr "" +"Bahasa saat ini: {current_language}. Bahasa yang tersedia: " +"{available_languages}." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:119 +msgid "NICKNAME Changes bot's nickname" +msgstr "Ubah nama panggilan bot" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:127 +msgid "nickname change to {newname}." +msgstr "Nama panggilan telah di ubah ke {newname}." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:137 +msgid "nickname set to default client ({newname})." +msgstr "nama panggilan diatur ke klien default ({newname})." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:148 +msgid "" +"r/f Clears bot's cache. r clears recents, f clears favorites, without an " +"option clears the entire cache" +msgstr "" +"R/F bersihkan cache terbaru dan favorit Bot. r membersihkan terbaru, f " +"membersihkan favorit, tanpa tanda apapun hapus seluruh cache " + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:157 +msgid "Cache cleared" +msgstr "Cache dihapus" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:161 +msgid "Recents cleared" +msgstr "Terbaru dibersihkan" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:165 +msgid "Favorites cleared" +msgstr "Favorit dibersihkan" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:171 +msgid "" +"Read config file and show default Server, Port, Username, Password, " +"Channel, Channel password." +msgstr "" +"Baca file konfigurasi dan tampilkan server default, port, nama pengguna, " +"kata sandi, saluran, kata sandi saluran." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:174 +msgid "" +"Server: {hostname}\n" +"TCP Port: {tcpport}\n" +"UDP Port: {udpport}\n" +"Username: {username}\n" +"Password: {password}\n" +"Channel: {channel}\n" +"Channel password: {channel_password}" +msgstr "" +"Server: {hostname}\n" +"TCP Port: {tcpport}\n" +"UDP Port: {udpport}\n" +"Nama pengguna: {username}\n" +"Kata sandi: {password}\n" +"Saluran: {channel}\n" +"Kata sandi saluran: {channel_password}" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:196 +msgid "Change default Server information." +msgstr "Ubah Informasi Server Default." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:205 +msgid "Default Server change to {newhostname}." +msgstr "Server di ubah ke {newhostname}." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:212 +msgid "" +"Default server can not be blank, Please specify default Server " +"information!" +msgstr "" +"Server default tidak dapat kosong, harap tentukan informasi server " +"default!" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:218 +msgid "Change default TCP port information." +msgstr "Ubah informasi TCP port default." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:229 +msgid "Default TCP port change to {newtcp_port}." +msgstr "TCP Port di ubah ke {newtcp_port}." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:238 +msgid "Default TCP port can not be blank, Please specify default TCP port!" +msgstr "Port TCP default tidak dapat kosong, tentukan port TCP default!" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:244 +msgid "Change default UDP port information." +msgstr "Ubah informasi UDP port default." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:255 +msgid "Default TCP port change to {newudp_port}." +msgstr "UDP Port di ubah ke {newudp_port}." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:264 +msgid "Default UDP port can not be blank, Please specify default UDP port!" +msgstr "Port UDP default tidak dapat kosong, tentukan port UDP default!" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:270 +msgid "" +"Change default Username information. Without any arguments, set default " +"Username to blank." +msgstr "" +"Ubah informasi nama pengguna. Tanpa argumen apa pun, atur nama pengguna " +"default menjadi kosong." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:279 +msgid "Default Username change to {newusername}." +msgstr "Nama pengguna di ubah ke {newusername}." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:288 +msgid "Default username set to blank." +msgstr "Nama pengguna diatur ke kosong." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:294 +msgid "" +"Change default Password information. Without any arguments, set default " +"password to blank." +msgstr "" +"Ubah informasi kata sandi. Tanpa argumen apa pun, atur kata sandi default" +" ke kosong." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:303 +msgid "Default Password change to {newpassword}." +msgstr "Kata sandi di ubah ke {newpassword}." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:312 +msgid "Default Password set to blank." +msgstr "Kata sandi diatur ke kosong." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:318 +msgid "" +"Change default Channel information. Without any arguments, set default " +"Channel to current channel." +msgstr "" +"Ubah informasi saluran. Tanpa argumen apa pun, atur saluran default ke " +"saluran saat ini." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:327 +msgid "" +"Default Channel change to {newchannel}, if your channel has a password, " +"please set it manually!" +msgstr "" +"Saluran di ubah ke {newchannel}, Jika saluran anda memiliki kata sandi, " +"silakan atur secara manual!" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:337 +msgid "" +"Default Channel set to current channel, if your channel has a password, " +"please set it manually!" +msgstr "" +"Saluran diatur ke saluran saat ini, jika saluran Anda memiliki kata " +"sandi, silakan atur secara manual!" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:343 +msgid "" +"Change default Channel password information. Without any arguments, set " +"default Channel password to blank.." +msgstr "" +"Ubah informasi kata sandi saluran. Tanpa argumen apa pun, atur kata sandi" +" saluran default menjadi kosong.." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:352 +msgid "Default Channel password change to {newchannel_password}." +msgstr "Kata sandi saluran di ubah ke {newchannel_password}." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:361 +msgid "Default Channel password set to blank." +msgstr "Kata sandi saluran diatur ke kosong." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:367 +msgid "Get current channel ID, will be useful for several things." +msgstr "Dapatkan ID saluran saat ini, akan berguna untuk beberapa hal." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:376 +msgid "" +"Join channel. first argument is channel name or id, second argument is " +"password, split argument \" | \", if password is undefined, don't type " +"second argument" +msgstr "" +"Gabung dengan saluran. Argumen pertama adalah nama atau ID saluran, " +"argumen kedua adalah kata sandi. Setiap argumen dipisahkan dengan tanda " +"\"|\", jika kata sandi tidak ditentukan, jangan ketik argumen kedua" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:396 +msgid "This channel does not exist" +msgstr "Saluran ini tidak ada" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:410 +msgid "Error joining channel: {error}" +msgstr "Kesalahan bergabung dengan saluran: {error}" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:487 +msgid "Enables or disables voice transmission" +msgstr "Aktifkan atau nonaktifkan transmisi suara" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:494 +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:496 +msgid "Voice transmission enabled" +msgstr "Transmisi suara diaktifkan" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:501 +msgid "Voice transmission disabled" +msgstr "Transmisi suara dinonaktifkan" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:507 +msgid "Locks or unlocks the bot" +msgstr "Kunci atau buka kunci bot" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:515 +msgid "Bot is now locked by administrator." +msgstr "Bot dikunci oleh administrator." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:522 +msgid "Bot is now unlocked by administrator." +msgstr "Bot dibuka oleh administrator." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:536 +msgid "STATUS Changes bot's status" +msgstr "ubah status bot" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:544 +msgid "Status changed." +msgstr "Status diubah." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:552 +msgid "Status change to default." +msgstr "Status di ubah ke default." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:561 +msgid "Enables or disables event handling" +msgstr "Aktifkan atau nonaktifkan penanganan acara" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:568 +msgid "Event handling is enabled" +msgstr "Penanganan acara diaktifkan" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:570 +msgid "Event handling is disabled" +msgstr "Penanganan acara dinonaktifkan" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:577 +msgid "" +"Send your message to current channel. Without any argument, turn or off " +"Channel messages." +msgstr "" +"Kirim pesan Anda ke saluran saat ini. Tanpa argumen apapun, aktifkan atau" +" nonaktifkan pesan saluran." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:585 +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:613 +msgid "" +"{message}.\n" +"Sender: {nickname}." +msgstr "" +"{message}.\n" +"pengirim: {nickname}." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:590 +msgid "Please enable channel messages first" +msgstr "silahkan aktifkan pesan saluran terlebih dahulu" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:595 +msgid "Channel messages enabled" +msgstr "Pesan saluran diaktifkan" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:597 +msgid "Channel messages disabled" +msgstr "Pesan saluran dinonaktifkan" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:605 +msgid "" +"Send broadcast message to all users. Without any argument, turn on or off" +" Broadcast messages" +msgstr "" +"Kirim pesan siaran ke semua pengguna. Tanpa argumen apa pun, aktifkan " +"atau nonaktifkan pesan siaran" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:618 +msgid "Please enable broadcast messages first" +msgstr "Silahkan aktifkan pesan siaran terlebih dahulu" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:623 +msgid "Broadcast messages enabled" +msgstr "Pesan siaran diaktifkan" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:625 +msgid "Broadcast messages disabled" +msgstr "Pesan siaran dinonaktifkan" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:633 +msgid "Saves bot's configuration" +msgstr "Simpan konfigurasi bot" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:637 +msgid "Configuration saved" +msgstr "Konfigurasi disimpan" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:643 +msgid "" +"+/-USERNAME Manage the list of administrators. +USERNAME add a user. " +"-USERNAME remove it. Without an option show the list." +msgstr "" +"+/- Nama pengguna mengelola daftar administrator. +Nama pengguna " +"Tambahkan pengguna. -Nama pengguna hapus nama pengguna. Tanpa argumen " +"apapun, tunjukkan daftar administrator." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:653 +msgid "{username} is now admin in this player." +msgstr "{username} sekarang admin di player ini." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:666 +msgid "{username} no longer admin in this player." +msgstr "{username} tidak lagi admin di player ini." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:673 +msgid "this user is not in the admin list" +msgstr "Pengguna ini tidak ada dalam daftar admin" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:689 +msgid "" +"+/-USERNAME Manage the list of banned users. +USERNAME add a user. " +"-USERNAME remove it. Without an option show the list." +msgstr "" +"+/- Nama pengguna mengelola daftar pengguna yang dilarang. +Nama pengguna" +" Tambahkan pengguna. -Nama pengguna hapus pengguna. Tanpa argumen apapun," +" tunjukkan daftar pengguna yang dilarang." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:699 +msgid "{username} now banned" +msgstr "{username} sekarang dilarang" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:712 +msgid "{username} now unbanned." +msgstr "{username} sekarang tidak dilarang." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:719 +msgid "this user is not banned" +msgstr "Pengguna ini tidak dilarang" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:733 +msgid "Quits the bot" +msgstr "Tutup bot" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:747 +msgid "Restarts the bot" +msgstr "Mulai ulang bot" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:767 +msgid "" +"Controls the cron scheduler, allowing admins to toggle it and add / " +"remove scheduled commands" +msgstr "" +"Mengontrol cron scheduler, memungkinkan admin untuk mengubahnya dan " +"menambahkan / menghapus perintah yang dijadwalkan" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:791 +msgid "Scheduled tasks are enabled (disable with 'cr toggle')" +msgstr "Tugas Terjadwal diaktifkan (Nonaktifkan dengan 'CR Toggle')" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:795 +msgid "Scheduled tasks are disabled; enable with 'cr toggle'" +msgstr "Tugas Terjadwal dinonaktifkan (aktifkan dengan 'CR Toggle')" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:800 +msgid "" +"Commands: \n" +"cr add cron expression|command\n" +"cr rm #\n" +"cr ls\n" +"cr toggle" +msgstr "" +"Perintah: \n" +"cr tambahkan cron expression|Perintah\n" +"cr rm #\n" +"cr ls\n" +"cr toggle" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:810 +msgid "Scheduler enabled." +msgstr "Penjadwal diaktifkan." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:812 +msgid "Scheduler disabled" +msgstr "Penjadwal dinonaktifkan" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:826 +msgid "" +"Incorrect format. Cron expression | command you want to run with " +"arguments after" +msgstr "" +"Format yang salah. Ekspresi cron | Perintah yang ingin Anda jalankan " +"dengan argumen setelahnya" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:841 +msgid "Task scheduled." +msgstr "Tugas dijadwalkan." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:843 +msgid "" +"Not a valid cron expression. Pleaes use cr expression-help for more " +"details." +msgstr "" +"Bukan ekspresi cron yang valid. Pleaes menggunakan CR Expression-Help " +"untuk detail lebih lanjut." + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:855 +msgid "Task number out of range - should be 1 to {}" +msgstr "Nomor tugas di luar jangkauan - harus 1 hingga {}" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:863 +msgid "Invalid task number" +msgstr "Nomor tugas tidak valid" + +#: D:/tanah/PandoraBox/bot/commands/admin_commands.py:867 +msgid "There are no scheduled tasks" +msgstr "Tidak ada tugas yang dijadwalkan" + +#: D:/tanah/PandoraBox/bot/commands/command.py:29 +msgid "help text not found" +msgstr "Bantuan teks tidak ditemukan" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:17 +msgid "Shows command help" +msgstr "Tunjukan daftar Perintah" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:26 +msgid "Shows information about the bot" +msgstr "Tunjukan informasi tentang bot" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:35 +msgid "Shows greetings" +msgstr "salam" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:44 +msgid "Shows contact information" +msgstr "Tunjukan informasi kontak" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:53 +msgid "" +"QUERY Plays tracks found for the query. If no query is given, plays or " +"pauses current track" +msgstr "" +"Putar trek yang ditemukan dari daftar pencarian. Jika tidak ada daftar " +"pencarian, putar atau jeda trek saat ini" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:61 +msgid "Searching..." +msgstr "Mencari ..." + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:69 +msgid "{nickname} requested {request}" +msgstr "{nickname} Meminta memutarkan {request}" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:75 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:227 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:244 +msgid "Playing {}" +msgstr "Memutar {}" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:79 +msgid "Nothing is found for your query" +msgstr "Tidak ada yang ditemukan dari pencarian Anda" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:81 +msgid "The selected service is currently unavailable" +msgstr "Layanan yang dipilih saat ini tidak tersedia" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:94 +msgid "URL Plays a stream from a given URL" +msgstr "URL putar streaming dari URL yang diberikan" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:103 +msgid "{nickname} requested playing from a URL" +msgstr "{nickname} memutar streaming dari URL" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:110 +msgid "Incorrect protocol" +msgstr "kesalahan Protokol" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:112 +msgid "Cannot process stream URL" +msgstr "Tidak dapat memproses URL streaming" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:114 +msgid "The path cannot be found" +msgstr "jalur tidak dapat ditemukan" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:122 +msgid "Stops playback" +msgstr "Hentikan pemutaran" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:133 +msgid "{nickname} stopped playback" +msgstr "{nickname} menghentikan pemutaran" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:139 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:190 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:209 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:233 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:250 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:397 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:406 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:459 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:477 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:523 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:583 +msgid "Nothing is playing" +msgstr "Tidak ada yang diputar" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:145 +msgid "" +"VALUE Set the volume between 0 and {max_volume}. If no value is " +"specified, show the current volume level." +msgstr "" +"Atur volume antara 0 dan {max_volume}. Jika tidak ada nilai yang " +"ditentukan, tunjukkan level volume saat ini." + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:160 +msgid "volume set to {volume}% by {nickname}" +msgstr "Volume diatur ke {volume}% oleh {nickname}" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:168 +msgid "volume is now {volume}%" +msgstr "Volume {volume}%" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:178 +msgid "current volume is {volume}%" +msgstr "Volume saat ini {volume}%" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:184 +msgid "STEP Seeks current track backward. the default step is {seek_step} seconds" +msgstr "Mundur {seek_step} detik" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:203 +msgid "STEP Seeks current track forward. the default step is {seek_step} seconds" +msgstr "maju {seek_step} detik" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:222 +msgid "Plays next track" +msgstr "putar trek berikutnya" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:231 +msgid "No next track" +msgstr "Tidak ada trek berikutnya" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:239 +msgid "Plays previous track" +msgstr "putar trek sebelumnya" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:248 +msgid "No previous track" +msgstr "Tidak ada trek sebelumnya" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:256 +msgid "" +"MODE Sets the playback mode. If no mode is specified, the current mode " +"and a list of modes are displayed" +msgstr "" +"Atur Mode Playback. Jika tidak ada mode yang ditentukan, mode saat ini " +"dan daftar mode ditampilkan" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:262 +msgid "Single Track" +msgstr "Trek tunggal" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:263 +msgid "Repeat Track" +msgstr "Ulangi trek" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:264 +msgid "Track list" +msgstr "daftar trek" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:265 +msgid "Repeat track list" +msgstr "Ulangi daftar trek" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:266 +msgid "Random" +msgstr "Acak" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:268 +msgid "" +"Current mode: {current_mode}\n" +"{modes}" +msgstr "" +"Mode saat ini: {current_mode}\n" +"{modes}" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:287 +msgid "Current mode: {mode}" +msgstr "Mode saat ini: {mode}" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:299 +msgid "" +"SERVICE Selects the service to play from, sv SERVICE h returns additional" +" help. If no service is specified, the current service and a list of " +"available services are displayed" +msgstr "" +"pilih layanan yang akan dimainkan, sv SERVICE h akan menampilkan bantuan " +"tambahan. Jika tidak ada layanan yang ditentukan, layanan saat ini dan " +"daftar layanan yang tersedia ditampilkan" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:308 +msgid "" +"Unknown service.\n" +"{}" +msgstr "" +"Layanan tidak diketahui.\n" +"{}" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:316 +msgid "" +"Current service: {}\n" +"Warning: {}" +msgstr "" +"Layanan saat ini: {}\n" +"Peringatan: {}" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:319 +msgid "Current service: {}" +msgstr "Layanan saat ini: {}" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:325 +msgid "" +"Error: {error}\n" +"{service} is disabled" +msgstr "" +"Kesalahan: {error}\n" +"{service} dinonaktifkan" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:332 +msgid "{service} is disabled" +msgstr "{service} dinonaktifkan" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:338 +msgid "This service has no additional help" +msgstr "Layanan ini tidak memiliki bantuan tambahan" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:358 +msgid "{} (Warning: {})" +msgstr "{} (Peringatan: {})" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:364 +msgid "" +"Current service: {current_service}\n" +"Available:\n" +"{available_services}\n" +"send sv SERVICE h for additional help" +msgstr "" +"Layanan saat ini: {current_service}\n" +"Tersedia:\n" +"{available_services}\n" +"kirim sv SERVICE h untuk bantuan tambahan" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:376 +msgid "NUMBER Selects track by number from the list of current results" +msgstr "Pilih trek dengan angka dari daftar hasil saat ini" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:389 +msgid "Incorrect number" +msgstr "Angka salah" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:391 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:402 +msgid "Playing {} {}" +msgstr "Memutar {} {}" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:395 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:471 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:504 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:543 +msgid "Out of list" +msgstr "Di luar daftar" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:412 +msgid "" +"SPEED Sets playback speed from 0.25 to 4. If no speed is given, shows " +"current speed" +msgstr "" +"Atur kecepatan pemutaran dari 0.25 ke 4. Jika tidak ada kecepatan yang " +"diberikan, Tunjukan kecepatan saat ini" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:418 +msgid "Current rate: {}" +msgstr "Tingkat saat ini: {}" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:431 +msgid "" +"+/-NUMBER Manages favorite tracks. + adds the current track to favorites." +" - removes a track requested from favorites. If a number is specified " +"after +/-, adds/removes a track with that number" +msgstr "" +"+/- Nomor mengelola trek favorit. + Menambahkan trek saat ini ke favorit." +" - Menghapus trek yang diminta dari favorit. Jika angka ditentukan " +"setelah +/-, tambahkan/hapus trek dengan angka yang sudah ditentukan" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:437 +msgid "This command is not available for guest users" +msgstr "Perintah ini tidak tersedia untuk pengguna tamu" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:457 +msgid "Added" +msgstr "Ditambahkan" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:469 +msgid "Deleted" +msgstr "Dihapus" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:475 +msgid "This track is not in favorites" +msgstr "Trek ini tidak ada dalam favorit" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:494 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:506 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:554 +msgid "The list is empty" +msgstr "Daftar kosong" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:512 +msgid "Gets a direct link to the current track" +msgstr "Dapatkan tautan langsung ke trek saat ini" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:521 +msgid "URL is not available" +msgstr "URL tidak tersedia" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:529 +msgid "" +"NUMBER Plays a track with the given number from a list of recent tracks." +" Without a number shows recent tracks" +msgstr "" +"Nomor putar trek dengan nomor yang sesuai dengan daftar trek terbaru. " +"Tanpa nomor tunjukan trek terbaru" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:561 +msgid "Downloads the current track and uploads it to the channel." +msgstr "Unduh trek saat ini dan unggah ke saluran." + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:571 +msgid "Cannot upload file to channel" +msgstr "Tidak dapat mengunggah file ke saluran" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:579 +msgid "Downloading..." +msgstr "Mengunduh ..." + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:581 +msgid "Live streams cannot be downloaded" +msgstr "Live streams tidak dapat diunduh" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:588 +msgid "Show the change log." +msgstr "Tunjukkan log perubahan." + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:594 +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:602 +msgid "Change log is not available. Contact the administrator." +msgstr "Log perubahan tidak tersedia. hubungi administrator." + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:608 +msgid "Change default Seek step for player." +msgstr "Ubah aturan detik maju/mundur pada player" + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:619 +msgid "Default Seek step change to {newseek_step} second." +msgstr "Maju/mundur di ubah ke {newseek_step} detik." + +#: D:/tanah/PandoraBox/bot/commands/user_commands.py:628 +msgid "Default Seek step can not be blank, Please specify default Seek step!" +msgstr "Maju/mundur tidak dapat kosong, tentukan nilainya!" + +#: D:/tanah/PandoraBox/bot/connectors/tt_player_connector.py:35 +msgid "Playing: {track_name}" +msgstr "Memutar: {track_name}" + +#: D:/tanah/PandoraBox/bot/connectors/tt_player_connector.py:41 +msgid "Playing: {stream_url}" +msgstr "Memutar: {stream_url}" + +#: D:/tanah/PandoraBox/bot/connectors/tt_player_connector.py:52 +msgid "Paused: {track_name}" +msgstr "digeda: {track_name}" + +#: D:/tanah/PandoraBox/bot/connectors/tt_player_connector.py:58 +msgid "Paused: {stream_url}" +msgstr "dijeda: {stream_url}" + +#: D:/tanah/PandoraBox/bot/services/yam.py:42 +msgid "Token is not provided" +msgstr "Token tidak disediakan" + +#: D:/tanah/PandoraBox/bot/services/yam.py:46 +msgid "You don't have Yandex Plus" +msgstr "Anda tidak memiliki Yandex Plus" + diff --git a/mpv.py b/mpv.py new file mode 100644 index 0000000..097812e --- /dev/null +++ b/mpv.py @@ -0,0 +1,2215 @@ +# -*- coding: utf-8 -*- +# vim: ts=4 sw=4 et +# +# Python MPV library module +# Copyright (C) 2017-2020 Sebastian Götte +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# + +from ctypes import * +import ctypes.util +import threading +import os +import sys +from warnings import warn +from functools import partial, wraps +from contextlib import contextmanager +import collections +import re +import traceback + +if os.name == "nt": + if sys.version_info.major == 3 and sys.version_info.minor >= 8: + os.add_dll_directory(os.getcwd()) + dll = "mpv.dll" + backend = cdll.LoadLibrary(dll) + fs_enc = "utf-8" +else: + import locale + + lc, enc = locale.getlocale(locale.LC_NUMERIC) + # libmpv requires LC_NUMERIC to be set to "C". Since messing with global variables everyone else relies upon is + # still better than segfaulting, we are setting LC_NUMERIC to "C". + locale.setlocale(locale.LC_NUMERIC, "C") + + sofile = ctypes.util.find_library("mpv") + if sofile is None: + raise OSError( + "Cannot find libmpv in the usual places. Depending on your distro, you may try installing an " + "mpv-devel or mpv-libs package. If you have libmpv around but this script can't find it, consult " + "the documentation for ctypes.util.find_library which this script uses to look up the library " + "filename." + ) + backend = CDLL(sofile) + fs_enc = sys.getfilesystemencoding() + + +class ShutdownError(SystemError): + pass + + +class MpvHandle(c_void_p): + pass + + +class MpvRenderCtxHandle(c_void_p): + pass + + +class MpvOpenGLCbContext(c_void_p): + pass + + +class PropertyUnavailableError(AttributeError): + pass + + +class ErrorCode(object): + """For documentation on these, see mpv's libmpv/client.h.""" + + SUCCESS = 0 + EVENT_QUEUE_FULL = -1 + NOMEM = -2 + UNINITIALIZED = -3 + INVALID_PARAMETER = -4 + OPTION_NOT_FOUND = -5 + OPTION_FORMAT = -6 + OPTION_ERROR = -7 + PROPERTY_NOT_FOUND = -8 + PROPERTY_FORMAT = -9 + PROPERTY_UNAVAILABLE = -10 + PROPERTY_ERROR = -11 + COMMAND = -12 + LOADING_FAILED = -13 + AO_INIT_FAILED = -14 + VO_INIT_FAILED = -15 + NOTHING_TO_PLAY = -16 + UNKNOWN_FORMAT = -17 + UNSUPPORTED = -18 + NOT_IMPLEMENTED = -19 + GENERIC = -20 + + EXCEPTION_DICT = { + 0: None, + -1: lambda *a: MemoryError("mpv event queue full", *a), + -2: lambda *a: MemoryError("mpv cannot allocate memory", *a), + -3: lambda *a: ValueError("Uninitialized mpv handle used", *a), + -4: lambda *a: ValueError("Invalid value for mpv parameter", *a), + -5: lambda *a: AttributeError("mpv option does not exist", *a), + -6: lambda *a: TypeError("Tried to set mpv option using wrong format", *a), + -7: lambda *a: ValueError("Invalid value for mpv option", *a), + -8: lambda *a: AttributeError("mpv property does not exist", *a), + # Currently (mpv 0.18.1) there is a bug causing a PROPERTY_FORMAT error to be returned instead of + # INVALID_PARAMETER when setting a property-mapped option to an invalid value. + -9: lambda *a: TypeError( + "Tried to get/set mpv property using wrong format, or passed invalid value", + *a + ), + -10: lambda *a: PropertyUnavailableError("mpv property is not available", *a), + -11: lambda *a: RuntimeError( + "Generic error getting or setting mpv property", *a + ), + -12: lambda *a: SystemError("Error running mpv command", *a), + -14: lambda *a: RuntimeError("Initializing the audio output failed", *a), + -15: lambda *a: RuntimeError("Initializing the video output failed"), + -16: lambda *a: RuntimeError( + "There was no audio or video data to play. This also happens if the file " + "was recognized, but did not contain any audio or video streams, or no " + "streams were selected." + ), + -17: lambda *a: RuntimeError( + "When trying to load the file, the file format could not be determined, " + "or the file was too broken to open it" + ), + -18: lambda *a: ValueError( + "Generic error for signaling that certain system requirements are not fulfilled" + ), + -19: lambda *a: NotImplementedError( + "The API function which was called is a stub only" + ), + -20: lambda *a: RuntimeError("Unspecified error"), + } + + @staticmethod + def default_error_handler(ec, *args): + return ValueError(_mpv_error_string(ec).decode("utf-8"), ec, *args) + + @classmethod + def raise_for_ec(kls, ec, func, *args): + ec = 0 if ec > 0 else ec + ex = kls.EXCEPTION_DICT.get(ec, kls.default_error_handler) + if ex: + raise ex(ec, *args) + + +MpvGlGetProcAddressFn = CFUNCTYPE(c_void_p, c_void_p, c_char_p) + + +class MpvOpenGLInitParams(Structure): + _fields_ = [ + ("get_proc_address", MpvGlGetProcAddressFn), + ("get_proc_address_ctx", c_void_p), + ("extra_exts", c_void_p), + ] + + def __init__(self, get_proc_address): + self.get_proc_address = get_proc_address + self.get_proc_address_ctx = None + self.extra_exts = None + + +class MpvOpenGLFBO(Structure): + _fields_ = [("fbo", c_int), ("w", c_int), ("h", c_int), ("internal_format", c_int)] + + def __init__(self, w, h, fbo=0, internal_format=0): + self.w, self.h = w, h + self.fbo = fbo + self.internal_format = internal_format + + +class MpvRenderFrameInfo(Structure): + _fields_ = [("flags", c_int64), ("target_time", c_int64)] + + def as_dict(self): + return {"flags": self.flags, "target_time": self.target_time} + + +class MpvOpenGLDRMParams(Structure): + _fields_ = [ + ("fd", c_int), + ("crtc_id", c_int), + ("connector_id", c_int), + ("atomic_request_ptr", c_void_p), + ("render_fd", c_int), + ] + + +class MpvOpenGLDRMDrawSurfaceSize(Structure): + _fields_ = [("width", c_int), ("height", c_int)] + + +class MpvOpenGLDRMParamsV2(Structure): + _fields_ = [ + ("fd", c_int), + ("crtc_id", c_int), + ("connector_id", c_int), + ("atomic_request_ptr", c_void_p), + ("render_fd", c_int), + ] + + def __init__(self, crtc_id, connector_id, atomic_request_ptr, fd=-1, render_fd=-1): + self.crtc_id, self.connector_id = crtc_id, connector_id + self.atomic_request_ptr = atomic_request_ptr + self.fd, self.render_fd = fd, render_fd + + +class MpvRenderParam(Structure): + _fields_ = [("type_id", c_int), ("data", c_void_p)] + + # maps human-readable type name to (type_id, argtype) tuple. + # The type IDs come from libmpv/render.h + TYPES = { + "invalid": (0, None), + "api_type": (1, str), + "opengl_init_params": (2, MpvOpenGLInitParams), + "opengl_fbo": (3, MpvOpenGLFBO), + "flip_y": (4, bool), + "depth": (5, int), + "icc_profile": (6, bytes), + "ambient_light": (7, int), + "x11_display": (8, c_void_p), + "wl_display": (9, c_void_p), + "advanced_control": (10, bool), + "next_frame_info": (11, MpvRenderFrameInfo), + "block_for_target_time": (12, bool), + "skip_rendering": (13, bool), + "drm_display": (14, MpvOpenGLDRMParams), + "drm_draw_surface_size": (15, MpvOpenGLDRMDrawSurfaceSize), + "drm_display_v2": (16, MpvOpenGLDRMParamsV2), + } + + def __init__(self, name, value=None): + if name not in self.TYPES: + raise ValueError('unknown render param type "{}"'.format(name)) + self.type_id, cons = self.TYPES[name] + if cons is None: + self.value = None + self.data = c_void_p() + elif cons is str: + self.value = value + self.data = cast(c_char_p(value.encode("utf-8")), c_void_p) + elif cons is bytes: + self.value = MpvByteArray(value) + self.data = cast(pointer(self.value), c_void_p) + elif cons is bool: + self.value = c_int(int(bool(value))) + self.data = cast(pointer(self.value), c_void_p) + elif cons is c_void_p: + self.value = value + self.data = cast(self.value, c_void_p) + else: + self.value = cons(**value) + self.data = cast(pointer(self.value), c_void_p) + + +def kwargs_to_render_param_array(kwargs): + t = MpvRenderParam * (len(kwargs) + 1) + return t(*kwargs.items(), ("invalid", None)) + + +class MpvFormat(c_int): + NONE = 0 + STRING = 1 + OSD_STRING = 2 + FLAG = 3 + INT64 = 4 + DOUBLE = 5 + NODE = 6 + NODE_ARRAY = 7 + NODE_MAP = 8 + BYTE_ARRAY = 9 + + def __eq__(self, other): + return self is other or self.value == other or self.value == int(other) + + def __repr__(self): + return [ + "NONE", + "STRING", + "OSD_STRING", + "FLAG", + "INT64", + "DOUBLE", + "NODE", + "NODE_ARRAY", + "NODE_MAP", + "BYTE_ARRAY", + ][self.value] + + def __hash__(self): + return self.value + + +class MpvEventID(c_int): + NONE = 0 + SHUTDOWN = 1 + LOG_MESSAGE = 2 + GET_PROPERTY_REPLY = 3 + SET_PROPERTY_REPLY = 4 + COMMAND_REPLY = 5 + START_FILE = 6 + END_FILE = 7 + FILE_LOADED = 8 + TRACKS_CHANGED = 9 + TRACK_SWITCHED = 10 + IDLE = 11 + PAUSE = 12 + UNPAUSE = 13 + TICK = 14 + SCRIPT_INPUT_DISPATCH = 15 + CLIENT_MESSAGE = 16 + VIDEO_RECONFIG = 17 + AUDIO_RECONFIG = 18 + METADATA_UPDATE = 19 + SEEK = 20 + PLAYBACK_RESTART = 21 + PROPERTY_CHANGE = 22 + CHAPTER_CHANGE = 23 + + ANY = ( + SHUTDOWN, + LOG_MESSAGE, + GET_PROPERTY_REPLY, + SET_PROPERTY_REPLY, + COMMAND_REPLY, + START_FILE, + END_FILE, + FILE_LOADED, + TRACKS_CHANGED, + TRACK_SWITCHED, + IDLE, + PAUSE, + UNPAUSE, + TICK, + SCRIPT_INPUT_DISPATCH, + CLIENT_MESSAGE, + VIDEO_RECONFIG, + AUDIO_RECONFIG, + METADATA_UPDATE, + SEEK, + PLAYBACK_RESTART, + PROPERTY_CHANGE, + CHAPTER_CHANGE, + ) + + def __repr__(self): + return [ + "NONE", + "SHUTDOWN", + "LOG_MESSAGE", + "GET_PROPERTY_REPLY", + "SET_PROPERTY_REPLY", + "COMMAND_REPLY", + "START_FILE", + "END_FILE", + "FILE_LOADED", + "TRACKS_CHANGED", + "TRACK_SWITCHED", + "IDLE", + "PAUSE", + "UNPAUSE", + "TICK", + "SCRIPT_INPUT_DISPATCH", + "CLIENT_MESSAGE", + "VIDEO_RECONFIG", + "AUDIO_RECONFIG", + "METADATA_UPDATE", + "SEEK", + "PLAYBACK_RESTART", + "PROPERTY_CHANGE", + "CHAPTER_CHANGE", + ][self.value] + + @classmethod + def from_str(kls, s): + return getattr(kls, s.upper().replace("-", "_")) + + +identity_decoder = lambda b: b +strict_decoder = lambda b: b.decode("utf-8") + + +def lazy_decoder(b): + try: + return b.decode("utf-8") + except UnicodeDecodeError: + return b + + +class MpvNodeList(Structure): + def array_value(self, decoder=identity_decoder): + return [self.values[i].node_value(decoder) for i in range(self.num)] + + def dict_value(self, decoder=identity_decoder): + return { + self.keys[i].decode("utf-8"): self.values[i].node_value(decoder) + for i in range(self.num) + } + + +class MpvByteArray(Structure): + _fields_ = [("data", c_void_p), ("size", c_size_t)] + + def __init__(self, value): + self._value = value + self.data = cast(c_char_p(value), c_void_p) + self.size = len(value) + + def bytes_value(self): + return cast(self.data, POINTER(c_char))[: self.size] + + +class MpvNode(Structure): + def node_value(self, decoder=identity_decoder): + return MpvNode.node_cast_value(self.val, self.format.value, decoder) + + @staticmethod + def node_cast_value(v, fmt=MpvFormat.NODE, decoder=identity_decoder): + if fmt == MpvFormat.NONE: + return None + elif fmt == MpvFormat.STRING: + return decoder(v.string) + elif fmt == MpvFormat.OSD_STRING: + return v.string.decode("utf-8") + elif fmt == MpvFormat.FLAG: + return bool(v.flag) + elif fmt == MpvFormat.INT64: + return v.int64 + elif fmt == MpvFormat.DOUBLE: + return v.double + else: + if not v.node: # Check for null pointer + return None + if fmt == MpvFormat.NODE: + return v.node.contents.node_value(decoder) + elif fmt == MpvFormat.NODE_ARRAY: + return v.list.contents.array_value(decoder) + elif fmt == MpvFormat.NODE_MAP: + return v.map.contents.dict_value(decoder) + elif fmt == MpvFormat.BYTE_ARRAY: + return v.byte_array.contents.bytes_value() + else: + raise TypeError( + "Unknown MPV node format {}. Please submit a bug report.".format( + fmt + ) + ) + + +class MpvNodeUnion(Union): + _fields_ = [ + ("string", c_char_p), + ("flag", c_int), + ("int64", c_int64), + ("double", c_double), + ("node", POINTER(MpvNode)), + ("list", POINTER(MpvNodeList)), + ("map", POINTER(MpvNodeList)), + ("byte_array", POINTER(MpvByteArray)), + ] + + +MpvNode._fields_ = [("val", MpvNodeUnion), ("format", MpvFormat)] + +MpvNodeList._fields_ = [ + ("num", c_int), + ("values", POINTER(MpvNode)), + ("keys", POINTER(c_char_p)), +] + + +class MpvSubApi(c_int): + MPV_SUB_API_OPENGL_CB = 1 + + +class MpvEvent(Structure): + _fields_ = [ + ("event_id", MpvEventID), + ("error", c_int), + ("reply_userdata", c_ulonglong), + ("data", c_void_p), + ] + + def as_dict(self, decoder=identity_decoder): + dtype = { + MpvEventID.END_FILE: MpvEventEndFile, + MpvEventID.PROPERTY_CHANGE: MpvEventProperty, + MpvEventID.GET_PROPERTY_REPLY: MpvEventProperty, + MpvEventID.LOG_MESSAGE: MpvEventLogMessage, + MpvEventID.SCRIPT_INPUT_DISPATCH: MpvEventScriptInputDispatch, + MpvEventID.CLIENT_MESSAGE: MpvEventClientMessage, + }.get(self.event_id.value, None) + return { + "event_id": self.event_id.value, + "error": self.error, + "reply_userdata": self.reply_userdata, + "event": cast(self.data, POINTER(dtype)).contents.as_dict(decoder=decoder) + if dtype + else None, + } + + +class MpvEventProperty(Structure): + _fields_ = [("name", c_char_p), ("format", MpvFormat), ("data", MpvNodeUnion)] + + def as_dict(self, decoder=identity_decoder): + value = MpvNode.node_cast_value(self.data, self.format.value, decoder) + return { + "name": self.name.decode("utf-8"), + "format": self.format, + "data": self.data, + "value": value, + } + + +class MpvEventLogMessage(Structure): + _fields_ = [("prefix", c_char_p), ("level", c_char_p), ("text", c_char_p)] + + def as_dict(self, decoder=identity_decoder): + return { + "prefix": self.prefix.decode("utf-8"), + "level": self.level.decode("utf-8"), + "text": decoder(self.text).rstrip(), + } + + +class MpvEventEndFile(Structure): + _fields_ = [("reason", c_int), ("error", c_int)] + + EOF = 0 + RESTARTED = 1 + ABORTED = 2 + QUIT = 3 + ERROR = 4 + REDIRECT = 5 + + # For backwards-compatibility + @property + def value(self): + return self.reason + + def as_dict(self, decoder=identity_decoder): + return {"reason": self.reason, "error": self.error} + + +class MpvEventScriptInputDispatch(Structure): + _fields_ = [("arg0", c_int), ("type", c_char_p)] + + def as_dict(self, decoder=identity_decoder): + pass # TODO + + +class MpvEventClientMessage(Structure): + _fields_ = [("num_args", c_int), ("args", POINTER(c_char_p))] + + def as_dict(self, decoder=identity_decoder): + return {"args": [self.args[i].decode("utf-8") for i in range(self.num_args)]} + + +StreamReadFn = CFUNCTYPE(c_int64, c_void_p, POINTER(c_char), c_uint64) +StreamSeekFn = CFUNCTYPE(c_int64, c_void_p, c_int64) +StreamSizeFn = CFUNCTYPE(c_int64, c_void_p) +StreamCloseFn = CFUNCTYPE(None, c_void_p) +StreamCancelFn = CFUNCTYPE(None, c_void_p) + + +class StreamCallbackInfo(Structure): + _fields_ = [ + ("cookie", c_void_p), + ("read", StreamReadFn), + ("seek", StreamSeekFn), + ("size", StreamSizeFn), + ("close", StreamCloseFn), + ] + + +# ('cancel', StreamCancelFn)] + +StreamOpenFn = CFUNCTYPE(c_int, c_void_p, c_char_p, POINTER(StreamCallbackInfo)) + +WakeupCallback = CFUNCTYPE(None, c_void_p) + +RenderUpdateFn = CFUNCTYPE(None, c_void_p) + +OpenGlCbUpdateFn = CFUNCTYPE(None, c_void_p) +OpenGlCbGetProcAddrFn = CFUNCTYPE(c_void_p, c_void_p, c_char_p) + + +def _handle_func(name, args, restype, errcheck, ctx=MpvHandle, deprecated=False): + if name.startswith("mpv_render_context_"): + return + func = getattr(backend, name) + func.argtypes = [ctx] + args if ctx else args + if restype is not None: + func.restype = restype + if errcheck is not None: + func.errcheck = errcheck + if deprecated: + + @wraps(func) + def wrapper(*args, **kwargs): + if not wrapper.warned: # Only warn on first invocation to prevent spamming + warn( + "Backend C api has been deprecated: " + name, + DeprecationWarning, + stacklevel=2, + ) + wrapper.warned = True + return func(*args, **kwargs) + + wrapper.warned = False + + globals()["_" + name] = wrapper + else: + globals()["_" + name] = func + + +def bytes_free_errcheck(res, func, *args): + notnull_errcheck(res, func, *args) + rv = cast(res, c_void_p).value + _mpv_free(res) + return rv + + +def notnull_errcheck(res, func, *args): + if res is None: + raise RuntimeError( + "Underspecified error in MPV when calling {} with args {!r}: NULL pointer returned." + "Please consult your local debugger.".format(func.__name__, args) + ) + return res + + +ec_errcheck = ErrorCode.raise_for_ec + + +def _handle_gl_func(name, args=[], restype=None, deprecated=False): + _handle_func( + name, + args, + restype, + errcheck=None, + ctx=MpvOpenGLCbContext, + deprecated=deprecated, + ) + + +backend.mpv_client_api_version.restype = c_ulong + + +def _mpv_client_api_version(): + ver = backend.mpv_client_api_version() + return ver >> 16, ver & 0xFFFF + + +backend.mpv_free.argtypes = [c_void_p] +_mpv_free = backend.mpv_free + +backend.mpv_free_node_contents.argtypes = [c_void_p] +_mpv_free_node_contents = backend.mpv_free_node_contents + +backend.mpv_create.restype = MpvHandle +_mpv_create = backend.mpv_create + +_handle_func("mpv_create_client", [c_char_p], MpvHandle, notnull_errcheck) +_handle_func("mpv_client_name", [], c_char_p, errcheck=None) +_handle_func("mpv_initialize", [], c_int, ec_errcheck) +try: + _handle_func("mpv_detach_destroy", [], None, errcheck=None) +except AttributeError: + _handle_func("mpv_destroy", [], None, errcheck=None) +_handle_func("mpv_terminate_destroy", [], None, errcheck=None) +_handle_func("mpv_load_config_file", [c_char_p], c_int, ec_errcheck) +_handle_func("mpv_get_time_us", [], c_ulonglong, errcheck=None) + +_handle_func("mpv_set_option", [c_char_p, MpvFormat, c_void_p], c_int, ec_errcheck) +_handle_func("mpv_set_option_string", [c_char_p, c_char_p], c_int, ec_errcheck) + +_handle_func("mpv_command", [POINTER(c_char_p)], c_int, ec_errcheck) +_handle_func("mpv_command_string", [c_char_p, c_char_p], c_int, ec_errcheck) +_handle_func("mpv_command_async", [c_ulonglong, POINTER(c_char_p)], c_int, ec_errcheck) +_handle_func( + "mpv_command_node", [POINTER(MpvNode), POINTER(MpvNode)], c_int, ec_errcheck +) +_handle_func("mpv_command_async", [c_ulonglong, POINTER(MpvNode)], c_int, ec_errcheck) + +_handle_func("mpv_set_property", [c_char_p, MpvFormat, c_void_p], c_int, ec_errcheck) +_handle_func("mpv_set_property_string", [c_char_p, c_char_p], c_int, ec_errcheck) +_handle_func( + "mpv_set_property_async", + [c_ulonglong, c_char_p, MpvFormat, c_void_p], + c_int, + ec_errcheck, +) +_handle_func("mpv_get_property", [c_char_p, MpvFormat, c_void_p], c_int, ec_errcheck) +_handle_func("mpv_get_property_string", [c_char_p], c_void_p, bytes_free_errcheck) +_handle_func("mpv_get_property_osd_string", [c_char_p], c_void_p, bytes_free_errcheck) +_handle_func( + "mpv_get_property_async", [c_ulonglong, c_char_p, MpvFormat], c_int, ec_errcheck +) +_handle_func( + "mpv_observe_property", [c_ulonglong, c_char_p, MpvFormat], c_int, ec_errcheck +) +_handle_func("mpv_unobserve_property", [c_ulonglong], c_int, ec_errcheck) + +_handle_func("mpv_event_name", [c_int], c_char_p, errcheck=None, ctx=None) +_handle_func("mpv_error_string", [c_int], c_char_p, errcheck=None, ctx=None) + +_handle_func("mpv_request_event", [MpvEventID, c_int], c_int, ec_errcheck) +_handle_func("mpv_request_log_messages", [c_char_p], c_int, ec_errcheck) +_handle_func("mpv_wait_event", [c_double], POINTER(MpvEvent), errcheck=None) +_handle_func("mpv_wakeup", [], None, errcheck=None) +_handle_func("mpv_set_wakeup_callback", [WakeupCallback, c_void_p], None, errcheck=None) +_handle_func("mpv_get_wakeup_pipe", [], c_int, errcheck=None) + +_handle_func( + "mpv_stream_cb_add_ro", [c_char_p, c_void_p, StreamOpenFn], c_int, ec_errcheck +) + +_handle_func( + "mpv_render_context_create", + [MpvRenderCtxHandle, MpvHandle, POINTER(MpvRenderParam)], + c_int, + ec_errcheck, + ctx=None, +) +_handle_func( + "mpv_render_context_set_parameter", + [MpvRenderParam], + c_int, + ec_errcheck, + ctx=MpvRenderCtxHandle, +) +_handle_func( + "mpv_render_context_get_info", + [MpvRenderParam], + c_int, + ec_errcheck, + ctx=MpvRenderCtxHandle, +) +_handle_func( + "mpv_render_context_set_update_callback", + [RenderUpdateFn, c_void_p], + None, + errcheck=None, + ctx=MpvRenderCtxHandle, +) +_handle_func( + "mpv_render_context_update", [], c_int64, errcheck=None, ctx=MpvRenderCtxHandle +) +_handle_func( + "mpv_render_context_render", + [POINTER(MpvRenderParam)], + c_int, + ec_errcheck, + ctx=MpvRenderCtxHandle, +) +_handle_func( + "mpv_render_context_report_swap", [], None, errcheck=None, ctx=MpvRenderCtxHandle +) +_handle_func("mpv_render_context_free", [], None, errcheck=None, ctx=MpvRenderCtxHandle) + + +# Deprecated in v0.29.0 and may disappear eventually +if hasattr(backend, "mpv_get_sub_api"): + _handle_func( + "mpv_get_sub_api", [MpvSubApi], c_void_p, notnull_errcheck, deprecated=True + ) + + _handle_gl_func( + "mpv_opengl_cb_set_update_callback", + [OpenGlCbUpdateFn, c_void_p], + deprecated=True, + ) + _handle_gl_func( + "mpv_opengl_cb_init_gl", + [c_char_p, OpenGlCbGetProcAddrFn, c_void_p], + c_int, + deprecated=True, + ) + _handle_gl_func("mpv_opengl_cb_draw", [c_int, c_int, c_int], c_int, deprecated=True) + _handle_gl_func("mpv_opengl_cb_render", [c_int, c_int], c_int, deprecated=True) + _handle_gl_func("mpv_opengl_cb_report_flip", [c_ulonglong], c_int, deprecated=True) + _handle_gl_func("mpv_opengl_cb_uninit_gl", [], c_int, deprecated=True) + + +def _mpv_coax_proptype(value, proptype=str): + """Intelligently coax the given python value into something that can be understood as a proptype property.""" + if type(value) is bytes: + return value + elif type(value) is bool: + return b"yes" if value else b"no" + elif proptype in (str, int, float): + return str(proptype(value)).encode("utf-8") + else: + raise TypeError( + "Cannot coax value of type {} into property type {}".format( + type(value), proptype + ) + ) + + +def _make_node_str_list(l): + """Take a list of python objects and make a MPV string node array from it. + + As an example, the python list ``l = [ "foo", 23, false ]`` will result in the following MPV node object:: + + struct mpv_node { + .format = MPV_NODE_ARRAY, + .u.list = *(struct mpv_node_array){ + .num = len(l), + .keys = NULL, + .values = struct mpv_node[len(l)] { + { .format = MPV_NODE_STRING, .u.string = l[0] }, + { .format = MPV_NODE_STRING, .u.string = l[1] }, + ... + } + } + } + """ + char_ps = [c_char_p(_mpv_coax_proptype(e, str)) for e in l] + node_list = MpvNodeList( + num=len(l), + keys=None, + values=(MpvNode * len(l))( + *[ + MpvNode(format=MpvFormat.STRING, val=MpvNodeUnion(string=p)) + for p in char_ps + ] + ), + ) + node = MpvNode( + format=MpvFormat.NODE_ARRAY, val=MpvNodeUnion(list=pointer(node_list)) + ) + return char_ps, node_list, node, cast(pointer(node), c_void_p) + + +def _event_generator(handle): + while True: + event = _mpv_wait_event(handle, -1).contents + if event.event_id.value == MpvEventID.NONE: + raise StopIteration() + yield event + + +_py_to_mpv = lambda name: name.replace("_", "-") +_mpv_to_py = lambda name: name.replace("-", "_") + +_drop_nones = lambda *args: [arg for arg in args if arg is not None] + + +class _Proxy: + def __init__(self, mpv): + super().__setattr__("mpv", mpv) + + +class _PropertyProxy(_Proxy): + def __dir__(self): + return super().__dir__() + [ + name.replace("-", "_") for name in self.mpv.property_list + ] + + +class _FileLocalProxy(_Proxy): + def __getitem__(self, name): + return self.mpv.__getitem__(name, file_local=True) + + def __setitem__(self, name, value): + return self.mpv.__setitem__(name, value, file_local=True) + + def __iter__(self): + return iter(self.mpv) + + +class _OSDPropertyProxy(_PropertyProxy): + def __getattr__(self, name): + return self.mpv._get_property(_py_to_mpv(name), fmt=MpvFormat.OSD_STRING) + + def __setattr__(self, _name, _value): + raise AttributeError( + "OSD properties are read-only. Please use the regular property API for writing." + ) + + +class _DecoderPropertyProxy(_PropertyProxy): + def __init__(self, mpv, decoder): + super().__init__(mpv) + super().__setattr__("_decoder", decoder) + + def __getattr__(self, name): + return self.mpv._get_property(_py_to_mpv(name), decoder=self._decoder) + + def __setattr__(self, name, value): + setattr(self.mpv, _py_to_mpv(name), value) + + +class GeneratorStream: + """Transform a python generator into an mpv-compatible stream object. This only supports size() and read(), and + does not support seek(), close() or cancel(). + """ + + def __init__(self, generator_fun, size=None): + self._generator_fun = generator_fun + self.size = size + + def seek(self, offset): + self._read_iter = iter(self._generator_fun()) + self._read_chunk = b"" + return 0 # We only support seeking to the first byte atm + # implementation in case seeking to arbitrary offsets would be necessary + # while offset > 0: + # offset -= len(self.read(offset)) + # return offset + + def read(self, size): + if not self._read_chunk: + try: + self._read_chunk += next(self._read_iter) + except StopIteration: + return b"" + rv, self._read_chunk = self._read_chunk[:size], self._read_chunk[size:] + return rv + + def close(self): + self._read_iter = iter([]) # make next read() call return EOF + + def cancel(self): + self._read_iter = iter([]) # make next read() call return EOF + # TODO? + + +class ImageOverlay: + def __init__(self, m, overlay_id, img=None, pos=(0, 0)): + self.m = m + self.overlay_id = overlay_id + self.pos = pos + self._size = None + if img is not None: + self.update(img) + + def update(self, img=None, pos=None): + from PIL import Image + + if img is not None: + self.img = img + img = self.img + + w, h = img.size + stride = w * 4 + + if pos is not None: + self.pos = pos + x, y = self.pos + + # Pre-multiply alpha channel + bg = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + out = Image.alpha_composite(bg, img) + + # Copy image to ctypes buffer + if img.size != self._size: + self._buf = create_string_buffer(w * h * 4) + self._size = img.size + + ctypes.memmove(self._buf, out.tobytes("raw", "BGRA"), w * h * 4) + source = "&" + str(addressof(self._buf)) + + self.m.overlay_add(self.overlay_id, x, y, source, 0, "bgra", w, h, stride) + + def remove(self): + self.m.remove_overlay(self.overlay_id) + + +class FileOverlay: + def __init__( + self, m, overlay_id, filename=None, size=None, stride=None, pos=(0, 0) + ): + self.m = m + self.overlay_id = overlay_id + self.pos = pos + self.size = size + self.stride = stride + if filename is not None: + self.update(filename) + + def update(self, filename=None, size=None, stride=None, pos=None): + if filename is not None: + self.filename = filename + + if pos is not None: + self.pos = pos + + if size is not None: + self.size = size + + if stride is not None: + self.stride = stride + + x, y = self.pos + w, h = self.size + stride = self.stride or 4 * w + + self.m.overlay_add( + self, self.overlay_id, x, y, self.filename, 0, "bgra", w, h, stride + ) + + def remove(self): + self.m.remove_overlay(self.overlay_id) + + +class MPV(object): + """See man mpv(1) for the details of the implemented commands. All mpv properties can be accessed as + ``my_mpv.some_property`` and all mpv options can be accessed as ``my_mpv['some-option']``. + + By default, properties are returned as decoded ``str`` and an error is thrown if the value does not contain valid + utf-8. To get a decoded ``str`` if possibly but ``bytes`` instead of an error if not, use + ``my_mpv.lazy.some_property``. To always get raw ``bytes``, use ``my_mpv.raw.some_property``. To access a + property's decoded OSD value, use ``my_mpv.osd.some_property``. + + To get API information on an option, use ``my_mpv.option_info('option-name')``. To get API information on a + property, use ``my_mpv.properties['property-name']``. Take care to use mpv's dashed-names instead of the + underscore_names exposed on the python object. + + To make your program not barf hard the first time its used on a weird file system **always** access properties + containing file names or file tags through ``MPV.raw``.""" + + def __init__( + self, + *extra_mpv_flags, + log_handler=None, + start_event_thread=True, + loglevel=None, + **extra_mpv_opts + ): + """Create an MPV instance. + + Extra arguments and extra keyword arguments will be passed to mpv as options. + """ + + self.handle = _mpv_create() + self._event_thread = None + self._core_shutdown = False + + _mpv_set_option_string(self.handle, b"audio-display", b"no") + istr = lambda o: ("yes" if o else "no") if type(o) is bool else str(o) + try: + for flag in extra_mpv_flags: + _mpv_set_option_string(self.handle, flag.encode("utf-8"), b"") + for k, v in extra_mpv_opts.items(): + _mpv_set_option_string( + self.handle, + k.replace("_", "-").encode("utf-8"), + istr(v).encode("utf-8"), + ) + finally: + _mpv_initialize(self.handle) + + self.osd = _OSDPropertyProxy(self) + self.file_local = _FileLocalProxy(self) + self.raw = _DecoderPropertyProxy(self, identity_decoder) + self.strict = _DecoderPropertyProxy(self, strict_decoder) + self.lazy = _DecoderPropertyProxy(self, lazy_decoder) + + self._event_callbacks = [] + self._event_handler_lock = threading.Lock() + self._property_handlers = collections.defaultdict(lambda: []) + self._quit_handlers = set() + self._message_handlers = {} + self._key_binding_handlers = {} + self._event_handle = _mpv_create_client(self.handle, b"py_event_handler") + self._log_handler = log_handler + self._stream_protocol_cbs = {} + self._stream_protocol_frontends = collections.defaultdict(lambda: {}) + self.register_stream_protocol("python", self._python_stream_open) + self._python_streams = {} + self._python_stream_catchall = None + self.overlay_ids = set() + self.overlays = {} + if loglevel is not None or log_handler is not None: + self.set_loglevel(loglevel or "terminal-default") + if start_event_thread: + self._event_thread = threading.Thread( + target=self._loop, name="MPVEventHandlerThread" + ) + self._event_thread.setDaemon(True) + self._event_thread.start() + else: + self._event_thread = None + + def _loop(self): + for event in _event_generator(self._event_handle): + try: + devent = event.as_dict(decoder=lazy_decoder) # copy data from ctypes + eid = devent["event_id"] + + with self._event_handler_lock: + if eid == MpvEventID.SHUTDOWN: + self._core_shutdown = True + + for callback in self._event_callbacks: + callback(devent) + + if eid == MpvEventID.PROPERTY_CHANGE: + pc = devent["event"] + name, value, _fmt = pc["name"], pc["value"], pc["format"] + for handler in self._property_handlers[name]: + handler(name, value) + + if eid == MpvEventID.LOG_MESSAGE and self._log_handler is not None: + ev = devent["event"] + self._log_handler(ev["level"], ev["prefix"], ev["text"]) + + if eid == MpvEventID.CLIENT_MESSAGE: + # {'event': {'args': ['key-binding', 'foo', 'u-', 'g']}, 'reply_userdata': 0, 'error': 0, 'event_id': 16} + target, *args = devent["event"]["args"] + if target in self._message_handlers: + self._message_handlers[target](*args) + + if eid == MpvEventID.SHUTDOWN: + try: + _mpv_detach_destroy(self._event_handle) + except NameError: + _mpv_destroy(self._event_handle) + return + + except Exception as e: + print("Exception inside python-mpv event loop:", file=sys.stderr) + traceback.print_exc() + + @property + def core_shutdown(self): + """Property indicating whether the core has been shut down. Possible causes for this are e.g. the `quit` command + or a user closing the mpv window.""" + return self._core_shutdown + + def check_core_alive(self): + """This method can be used as a sanity check to tests whether the core is still alive at the time it is + called.""" + if self._core_shutdown: + raise ShutdownError("libmpv core has been shutdown") + + def wait_until_paused(self): + """Waits until playback of the current title is paused or done. Raises a ShutdownError if the core is shutdown while + waiting.""" + self.wait_for_property("core-idle") + + def wait_for_playback(self): + """Waits until playback of the current title is finished. Raises a ShutdownError if the core is shutdown while + waiting. + """ + self.wait_for_event("end_file") + + def wait_until_playing(self): + """Waits until playback of the current title has started. Raises a ShutdownError if the core is shutdown while + waiting.""" + self.wait_for_property("core-idle", lambda idle: not idle) + + def wait_for_property(self, name, cond=lambda val: val, level_sensitive=True): + """Waits until ``cond`` evaluates to a truthy value on the named property. This can be used to wait for + properties such as ``idle_active`` indicating the player is done with regular playback and just idling around. + Raises a ShutdownError when the core is shutdown while waiting. + """ + with self.prepare_and_wait_for_property(name, cond, level_sensitive): + pass + + def wait_for_shutdown(self): + """Wait for core to shutdown (e.g. through quit() or terminate()).""" + sema = threading.Semaphore(value=0) + + @self.event_callback("shutdown") + def shutdown_handler(event): + sema.release() + + sema.acquire() + shutdown_handler.unregister_mpv_events() + + @contextmanager + def prepare_and_wait_for_property( + self, name, cond=lambda val: val, level_sensitive=True + ): + """Context manager that waits until ``cond`` evaluates to a truthy value on the named property. See + prepare_and_wait_for_event for usage. + Raises a ShutdownError when the core is shutdown while waiting. + """ + sema = threading.Semaphore(value=0) + + def observer(name, val): + if cond(val): + sema.release() + + self.observe_property(name, observer) + + @self.event_callback("shutdown") + def shutdown_handler(event): + sema.release() + + yield + if not level_sensitive or not cond(getattr(self, name.replace("-", "_"))): + sema.acquire() + + self.check_core_alive() + + shutdown_handler.unregister_mpv_events() + self.unobserve_property(name, observer) + + def wait_for_event(self, *event_types, cond=lambda evt: True): + """Waits for the indicated event(s). If cond is given, waits until cond(event) is true. Raises a ShutdownError + if the core is shutdown while waiting. This also happens when 'shutdown' is in event_types. + """ + with self.prepare_and_wait_for_event(*event_types, cond=cond): + pass + + @contextmanager + def prepare_and_wait_for_event(self, *event_types, cond=lambda evt: True): + """Context manager that waits for the indicated event(s) like wait_for_event after running. If cond is given, + waits until cond(event) is true. Raises a ShutdownError if the core is shutdown while waiting. This also happens + when 'shutdown' is in event_types. + + Compared to wait_for_event this handles the case where a thread waits for an event it itself causes in a + thread-safe way. An example from the testsuite is: + + with self.m.prepare_and_wait_for_event('client_message'): + self.m.keypress(key) + + Using just wait_for_event it would be impossible to ensure the event is caught since it may already have been + handled in the interval between keypress(...) running and a subsequent wait_for_event(...) call. + """ + sema = threading.Semaphore(value=0) + + @self.event_callback("shutdown") + def shutdown_handler(event): + sema.release() + + @self.event_callback(*event_types) + def target_handler(evt): + if cond(evt): + sema.release() + + yield + sema.acquire() + + self.check_core_alive() + + shutdown_handler.unregister_mpv_events() + target_handler.unregister_mpv_events() + + def __del__(self): + if self.handle: + self.terminate() + + def terminate(self): + """Properly terminates this player instance. Preferably use this instead of relying on python's garbage + collector to cause this to be called from the object's destructor. + + This method will detach the main libmpv handle and wait for mpv to shut down and the event thread to finish. + """ + self.handle, handle = None, self.handle + if threading.current_thread() is self._event_thread: + raise UserWarning( + "terminate() should not be called from event thread (e.g. from a callback function). If " + "you want to terminate mpv from here, please call quit() instead, then sync the main thread " + "against the event thread using e.g. wait_for_shutdown(), then terminate() from the main thread. " + "This call has been transformed into a call to quit()." + ) + self.quit() + else: + _mpv_terminate_destroy(handle) + if self._event_thread: + self._event_thread.join() + + def set_loglevel(self, level): + """Set MPV's log level. This adjusts which output will be sent to this object's log handlers. If you just want + mpv's regular terminal output, you don't need to adjust this but just need to pass a log handler to the MPV + constructur such as ``MPV(log_handler=print)``. + + Valid log levels are "no", "fatal", "error", "warn", "info", "v" "debug" and "trace". For details see your mpv's + client.h header file. + """ + _mpv_request_log_messages(self._event_handle, level.encode("utf-8")) + + def command(self, name, *args): + """Execute a raw command.""" + args = ( + [name.encode("utf-8")] + + [ + (arg if type(arg) is bytes else str(arg).encode("utf-8")) + for arg in args + if arg is not None + ] + + [None] + ) + _mpv_command(self.handle, (c_char_p * len(args))(*args)) + + def node_command(self, name, *args, decoder=strict_decoder): + _1, _2, _3, pointer = _make_node_str_list([name, *args]) + out = cast(create_string_buffer(sizeof(MpvNode)), POINTER(MpvNode)) + ppointer = cast(pointer, POINTER(MpvNode)) + _mpv_command_node(self.handle, ppointer, out) + rv = out.contents.node_value(decoder=decoder) + _mpv_free_node_contents(out) + return rv + + def seek(self, amount, reference="relative", precision="default-precise"): + """Mapped mpv seek command, see man mpv(1).""" + self.command("seek", amount, reference, precision) + + def revert_seek(self): + """Mapped mpv revert_seek command, see man mpv(1).""" + self.command("revert_seek") + + def frame_step(self): + """Mapped mpv frame-step command, see man mpv(1).""" + self.command("frame-step") + + def frame_back_step(self): + """Mapped mpv frame_back_step command, see man mpv(1).""" + self.command("frame_back_step") + + def property_add(self, name, value=1): + """Add the given value to the property's value. On overflow or underflow, clamp the property to the maximum. If + ``value`` is omitted, assume ``1``. + """ + self.command("add", name, value) + + def property_multiply(self, name, factor): + """Multiply the value of a property with a numeric factor.""" + self.command("multiply", name, factor) + + def cycle(self, name, direction="up"): + """Cycle the given property. ``up`` and ``down`` set the cycle direction. On overflow, set the property back to + the minimum, on underflow set it to the maximum. If ``up`` or ``down`` is omitted, assume ``up``. + """ + self.command("cycle", name, direction) + + def screenshot(self, includes="subtitles", mode="single"): + """Mapped mpv screenshot command, see man mpv(1).""" + self.command("screenshot", includes, mode) + + def screenshot_to_file(self, filename, includes="subtitles"): + """Mapped mpv screenshot_to_file command, see man mpv(1).""" + self.command("screenshot_to_file", filename.encode(fs_enc), includes) + + def screenshot_raw(self, includes="subtitles"): + """Mapped mpv screenshot_raw command, see man mpv(1). Returns a pillow Image object.""" + from PIL import Image + + res = self.node_command("screenshot-raw", includes) + if res["format"] != "bgr0": + raise ValueError( + 'Screenshot in unknown format "{}". Currently, only bgr0 is supported.'.format( + res["format"] + ) + ) + img = Image.frombytes("RGBA", (res["stride"] // 4, res["h"]), res["data"]) + b, g, r, a = img.split() + return Image.merge("RGB", (r, g, b)) + + def allocate_overlay_id(self): + free_ids = set(range(64)) - self.overlay_ids + if not free_ids: + raise IndexError("All overlay IDs are in use") + next_id, *_ = sorted(free_ids) + self.overlay_ids.add(next_id) + return next_id + + def free_overlay_id(self, overlay_id): + self.overlay_ids.remove(overlay_id) + + def create_file_overlay(self, filename=None, size=None, stride=None, pos=(0, 0)): + overlay_id = self.allocate_overlay_id() + overlay = FileOverlay(self, overlay_id, filename, size, stride, pos) + self.overlays[overlay_id] = overlay + return overlay + + def create_image_overlay(self, img=None, pos=(0, 0)): + overlay_id = self.allocate_overlay_id() + overlay = ImageOverlay(self, overlay_id, img, pos) + self.overlays[overlay_id] = overlay + return overlay + + def remove_overlay(self, overlay_id): + self.overlay_remove(overlay_id) + self.free_overlay_id(overlay_id) + del self.overlays[overlay_id] + + def playlist_next(self, mode="weak"): + """Mapped mpv playlist_next command, see man mpv(1).""" + self.command("playlist_next", mode) + + def playlist_prev(self, mode="weak"): + """Mapped mpv playlist_prev command, see man mpv(1).""" + self.command("playlist_prev", mode) + + def playlist_play_index(self, idx): + """Mapped mpv playlist-play-index command, see man mpv(1).""" + self.command("playlist-play-index", idx) + + @staticmethod + def _encode_options(options): + return ",".join( + "{}={}".format(_py_to_mpv(str(key)), str(val)) + for key, val in options.items() + ) + + def loadfile(self, filename, mode="replace", **options): + """Mapped mpv loadfile command, see man mpv(1).""" + self.command( + "loadfile", filename.encode(fs_enc), mode, MPV._encode_options(options) + ) + + def loadlist(self, playlist, mode="replace"): + """Mapped mpv loadlist command, see man mpv(1).""" + self.command("loadlist", playlist.encode(fs_enc), mode) + + def playlist_clear(self): + """Mapped mpv playlist_clear command, see man mpv(1).""" + self.command("playlist_clear") + + def playlist_remove(self, index="current"): + """Mapped mpv playlist_remove command, see man mpv(1).""" + self.command("playlist_remove", index) + + def playlist_move(self, index1, index2): + """Mapped mpv playlist_move command, see man mpv(1).""" + self.command("playlist_move", index1, index2) + + def playlist_shuffle(self): + """Mapped mpv playlist-shuffle command, see man mpv(1).""" + self.command("playlist-shuffle") + + def playlist_unshuffle(self): + """Mapped mpv playlist-unshuffle command, see man mpv(1).""" + self.command("playlist-unshuffle") + + def run(self, command, *args): + """Mapped mpv run command, see man mpv(1).""" + self.command("run", command, *args) + + def quit(self, code=None): + """Mapped mpv quit command, see man mpv(1).""" + self.command("quit", code) + + def quit_watch_later(self, code=None): + """Mapped mpv quit_watch_later command, see man mpv(1).""" + self.command("quit_watch_later", code) + + def stop(self, keep_playlist=False): + """Mapped mpv stop command, see man mpv(1).""" + if keep_playlist: + self.command("stop", "keep-playlist") + else: + self.command("stop") + + def audio_add(self, url, flags="select", title=None, lang=None): + """Mapped mpv audio_add command, see man mpv(1).""" + self.command("audio_add", url.encode(fs_enc), *_drop_nones(flags, title, lang)) + + def audio_remove(self, audio_id=None): + """Mapped mpv audio_remove command, see man mpv(1).""" + self.command("audio_remove", audio_id) + + def audio_reload(self, audio_id=None): + """Mapped mpv audio_reload command, see man mpv(1).""" + self.command("audio_reload", audio_id) + + def video_add(self, url, flags="select", title=None, lang=None): + """Mapped mpv video_add command, see man mpv(1).""" + self.command("video_add", url.encode(fs_enc), *_drop_nones(flags, title, lang)) + + def video_remove(self, video_id=None): + """Mapped mpv video_remove command, see man mpv(1).""" + self.command("video_remove", video_id) + + def video_reload(self, video_id=None): + """Mapped mpv video_reload command, see man mpv(1).""" + self.command("video_reload", video_id) + + def sub_add(self, url, flags="select", title=None, lang=None): + """Mapped mpv sub_add command, see man mpv(1).""" + self.command("sub_add", url.encode(fs_enc), *_drop_nones(flags, title, lang)) + + def sub_remove(self, sub_id=None): + """Mapped mpv sub_remove command, see man mpv(1).""" + self.command("sub_remove", sub_id) + + def sub_reload(self, sub_id=None): + """Mapped mpv sub_reload command, see man mpv(1).""" + self.command("sub_reload", sub_id) + + def sub_step(self, skip): + """Mapped mpv sub_step command, see man mpv(1).""" + self.command("sub_step", skip) + + def sub_seek(self, skip): + """Mapped mpv sub_seek command, see man mpv(1).""" + self.command("sub_seek", skip) + + def toggle_osd(self): + """Mapped mpv osd command, see man mpv(1).""" + self.command("osd") + + def print_text(self, text): + """Mapped mpv print-text command, see man mpv(1).""" + self.command("print-text", text) + + def show_text(self, string, duration="-1", level=None): + """Mapped mpv show_text command, see man mpv(1).""" + self.command("show_text", string, duration, level) + + def expand_text(self, text): + """Mapped mpv expand-text command, see man mpv(1).""" + return self.node_command("expand-text", text) + + def expand_path(self, path): + """Mapped mpv expand-path command, see man mpv(1).""" + return self.node_command("expand-path", path) + + def show_progress(self): + """Mapped mpv show_progress command, see man mpv(1).""" + self.command("show_progress") + + def rescan_external_files(self, mode="reselect"): + """Mapped mpv rescan-external-files command, see man mpv(1).""" + self.command("rescan-external-files", mode) + + def discnav(self, command): + """Mapped mpv discnav command, see man mpv(1).""" + self.command("discnav", command) + + def mouse(x, y, button=None, mode="single"): + """Mapped mpv mouse command, see man mpv(1).""" + if button is None: + self.command("mouse", x, y, mode) + else: + self.command("mouse", x, y, button, mode) + + def keypress(self, name): + """Mapped mpv keypress command, see man mpv(1).""" + self.command("keypress", name) + + def keydown(self, name): + """Mapped mpv keydown command, see man mpv(1).""" + self.command("keydown", name) + + def keyup(self, name=None): + """Mapped mpv keyup command, see man mpv(1).""" + if name is None: + self.command("keyup") + else: + self.command("keyup", name) + + def keybind(self, name, command): + """Mapped mpv keybind command, see man mpv(1).""" + self.command("keybind", name, command) + + def write_watch_later_config(self): + """Mapped mpv write_watch_later_config command, see man mpv(1).""" + self.command("write_watch_later_config") + + def overlay_add(self, overlay_id, x, y, file_or_fd, offset, fmt, w, h, stride): + """Mapped mpv overlay_add command, see man mpv(1).""" + self.command( + "overlay_add", overlay_id, x, y, file_or_fd, offset, fmt, w, h, stride + ) + + def overlay_remove(self, overlay_id): + """Mapped mpv overlay_remove command, see man mpv(1).""" + self.command("overlay_remove", overlay_id) + + def script_message(self, *args): + """Mapped mpv script_message command, see man mpv(1).""" + self.command("script_message", *args) + + def script_message_to(self, target, *args): + """Mapped mpv script_message_to command, see man mpv(1).""" + self.command("script_message_to", target, *args) + + def observe_property(self, name, handler): + """Register an observer on the named property. An observer is a function that is called with the new property + value every time the property's value is changed. The basic function signature is ``fun(property_name, + new_value)`` with new_value being the decoded property value as a python object. This function can be used as a + function decorator if no handler is given. + + To unregister the observer, call either of ``mpv.unobserve_property(name, handler)``, + ``mpv.unobserve_all_properties(handler)`` or the handler's ``unregister_mpv_properties`` attribute:: + + @player.observe_property('volume') + def my_handler(new_volume, *): + print("It's loud!", volume) + + my_handler.unregister_mpv_properties() + + exit_handler is a function taking no arguments that is called when the underlying mpv handle is terminated (e.g. + from calling MPV.terminate() or issuing a "quit" input command). + """ + self._property_handlers[name].append(handler) + _mpv_observe_property( + self._event_handle, + hash(name) & 0xFFFFFFFFFFFFFFFF, + name.encode("utf-8"), + MpvFormat.NODE, + ) + + def property_observer(self, name): + """Function decorator to register a property observer. See ``MPV.observe_property`` for details.""" + + def wrapper(fun): + self.observe_property(name, fun) + fun.unobserve_mpv_properties = lambda: self.unobserve_property(name, fun) + return fun + + return wrapper + + def unobserve_property(self, name, handler): + """Unregister a property observer. This requires both the observed property's name and the handler function that + was originally registered as one handler could be registered for several properties. To unregister a handler + from *all* observed properties see ``unobserve_all_properties``. + """ + self._property_handlers[name].remove(handler) + if not self._property_handlers[name]: + _mpv_unobserve_property(self._event_handle, hash(name) & 0xFFFFFFFFFFFFFFFF) + + def unobserve_all_properties(self, handler): + """Unregister a property observer from *all* observed properties.""" + for name in self._property_handlers: + self.unobserve_property(name, handler) + + def register_message_handler(self, target, handler=None): + """Register a mpv script message handler. This can be used to communicate with embedded lua scripts. Pass the + script message target name this handler should be listening to and the handler function. + + WARNING: Only one handler can be registered at a time for any given target. + + To unregister the message handler, call its ``unregister_mpv_messages`` function:: + + player = mpv.MPV() + @player.message_handler('foo') + def my_handler(some, args): + print(args) + + my_handler.unregister_mpv_messages() + """ + self._register_message_handler_internal(target, handler) + + def _register_message_handler_internal(self, target, handler): + self._message_handlers[target] = handler + + def unregister_message_handler(self, target_or_handler): + """Unregister a mpv script message handler for the given script message target name. + + You can also call the ``unregister_mpv_messages`` function attribute set on the handler function when it is + registered. + """ + if isinstance(target_or_handler, str): + del self._message_handlers[target_or_handler] + else: + for key, val in self._message_handlers.items(): + if val == target_or_handler: + del self._message_handlers[key] + + def message_handler(self, target): + """Decorator to register a mpv script message handler. + + WARNING: Only one handler can be registered at a time for any given target. + + To unregister the message handler, call its ``unregister_mpv_messages`` function:: + + player = mpv.MPV() + @player.message_handler('foo') + def my_handler(some, args): + print(args) + + my_handler.unregister_mpv_messages() + """ + + def register(handler): + self._register_message_handler_internal(target, handler) + handler.unregister_mpv_messages = lambda: self.unregister_message_handler( + handler + ) + return handler + + return register + + def register_event_callback(self, callback): + """Register a blanket event callback receiving all event types. + + To unregister the event callback, call its ``unregister_mpv_events`` function:: + + player = mpv.MPV() + @player.event_callback('shutdown') + def my_handler(event): + print('It ded.') + + my_handler.unregister_mpv_events() + """ + self._event_callbacks.append(callback) + + def unregister_event_callback(self, callback): + """Unregister an event callback.""" + self._event_callbacks.remove(callback) + + def event_callback(self, *event_types): + """Function decorator to register a blanket event callback for the given event types. Event types can be given + as str (e.g. 'start-file'), integer or MpvEventID object. + + WARNING: Due to the way this is filtering events, this decorator cannot be chained with itself. + + To unregister the event callback, call its ``unregister_mpv_events`` function:: + + player = mpv.MPV() + @player.event_callback('shutdown') + def my_handler(event): + print('It ded.') + + my_handler.unregister_mpv_events() + """ + + def register(callback): + with self._event_handler_lock: + self.check_core_alive() + types = [ + MpvEventID.from_str(t) if isinstance(t, str) else t + for t in event_types + ] or MpvEventID.ANY + + @wraps(callback) + def wrapper(event, *args, **kwargs): + if event["event_id"] in types: + callback(event, *args, **kwargs) + + self._event_callbacks.append(wrapper) + wrapper.unregister_mpv_events = partial( + self.unregister_event_callback, wrapper + ) + return wrapper + + return register + + @staticmethod + def _binding_name(callback_or_cmd): + return "py_kb_{:016x}".format(hash(callback_or_cmd) & 0xFFFFFFFFFFFFFFFF) + + def on_key_press(self, keydef, mode="force"): + """Function decorator to register a simplified key binding. The callback is called whenever the key given is + *pressed*. + + To unregister the callback function, you can call its ``unregister_mpv_key_bindings`` attribute:: + + player = mpv.MPV() + @player.on_key_press('Q') + def binding(): + print('blep') + + binding.unregister_mpv_key_bindings() + + WARNING: For a single keydef only a single callback/command can be registered at the same time. If you register + a binding multiple times older bindings will be overwritten and there is a possibility of references leaking. So + don't do that. + + The BIG FAT WARNING regarding untrusted keydefs from the key_binding method applies here as well. + """ + + def register(fun): + @self.key_binding(keydef, mode) + @wraps(fun) + def wrapper(state="p-", name=None, char=None): + if state[0] in ("d", "p"): + fun() + + return wrapper + + return register + + def key_binding(self, keydef, mode="force"): + """Function decorator to register a low-level key binding. + + The callback function signature is ``fun(key_state, key_name)`` where ``key_state`` is either ``'U'`` for "key + up" or ``'D'`` for "key down". + + The keydef format is: ``[Shift+][Ctrl+][Alt+][Meta+]`` where ```` is either the literal character the + key produces (ASCII or Unicode character), or a symbolic name (as printed by ``mpv --input-keylist``). + + To unregister the callback function, you can call its ``unregister_mpv_key_bindings`` attribute:: + + player = mpv.MPV() + @player.key_binding('Q') + def binding(state, name, char): + print('blep') + + binding.unregister_mpv_key_bindings() + + WARNING: For a single keydef only a single callback/command can be registered at the same time. If you register + a binding multiple times older bindings will be overwritten and there is a possibility of references leaking. So + don't do that. + + BIG FAT WARNING: mpv's key binding mechanism is pretty powerful. This means, you essentially get arbitrary code + exectution through key bindings. This interface makes some limited effort to sanitize the keydef given in the + first parameter, but YOU SHOULD NOT RELY ON THIS IN FOR SECURITY. If your input comes from config files, this is + completely fine--but, if you are about to pass untrusted input into this parameter, better double-check whether + this is secure in your case. + """ + + def register(fun): + fun.mpv_key_bindings = getattr(fun, "mpv_key_bindings", []) + [keydef] + + def unregister_all(): + for keydef in fun.mpv_key_bindings: + self.unregister_key_binding(keydef) + + fun.unregister_mpv_key_bindings = unregister_all + + self.register_key_binding(keydef, fun, mode) + return fun + + return register + + def register_key_binding(self, keydef, callback_or_cmd, mode="force"): + """Register a key binding. This takes an mpv keydef and either a string containing a mpv command or a python + callback function. See ``MPV.key_binding`` for details. + """ + if not re.match(r"(Shift+)?(Ctrl+)?(Alt+)?(Meta+)?(.|\w+)", keydef): + raise ValueError( + "Invalid keydef. Expected format: [Shift+][Ctrl+][Alt+][Meta+]\n" + " is either the literal character the key produces (ASCII or Unicode character), or a " + "symbolic name (as printed by --input-keylist" + ) + binding_name = MPV._binding_name(keydef) + if callable(callback_or_cmd): + self._key_binding_handlers[binding_name] = callback_or_cmd + self.register_message_handler( + "key-binding", self._handle_key_binding_message + ) + self.command( + "define-section", + binding_name, + "{} script-binding py_event_handler/{}".format(keydef, binding_name), + mode, + ) + elif isinstance(callback_or_cmd, str): + self.command( + "define-section", + binding_name, + "{} {}".format(keydef, callback_or_cmd), + mode, + ) + else: + raise TypeError( + "register_key_binding expects either an str with an mpv command or a python callable." + ) + self.command( + "enable-section", binding_name, "allow-hide-cursor+allow-vo-dragging" + ) + + def _handle_key_binding_message( + self, binding_name, key_state, key_name=None, key_char=None + ): + self._key_binding_handlers[binding_name](key_state, key_name, key_char) + + def unregister_key_binding(self, keydef): + """Unregister a key binding by keydef.""" + binding_name = MPV._binding_name(keydef) + self.command("disable-section", binding_name) + self.command("define-section", binding_name, "") + if binding_name in self._key_binding_handlers: + del self._key_binding_handlers[binding_name] + if not self._key_binding_handlers: + self.unregister_message_handler("key-binding") + + def register_stream_protocol(self, proto, open_fn=None): + """Register a custom stream protocol as documented in libmpv/stream_cb.h: + https://github.com/mpv-player/mpv/blob/master/libmpv/stream_cb.h + + proto is the protocol scheme, e.g. "foo" for "foo://" urls. + + This function can either be used with two parameters or it can be used as a decorator on the target + function. + + open_fn is a function taking an URI string and returning an mpv stream object. + open_fn may raise a ValueError to signal libmpv the URI could not be opened. + + The mpv stream protocol is as follows: + class Stream: + @property + def size(self): + return None # unknown size + return size # int with size in bytes + + def read(self, size): + ... + return read # non-empty bytes object with input + return b'' # empty byte object signals permanent EOF + + def seek(self, pos): + return new_offset # integer with new byte offset. The new offset may be before the requested offset + in case an exact seek is inconvenient. + + def close(self): + ... + + # def cancel(self): (future API versions only) + # Abort a running read() or seek() operation + # ... + + """ + + def decorator(open_fn): + @StreamOpenFn + def open_backend(_userdata, uri, cb_info): + try: + frontend = open_fn(uri.decode("utf-8")) + except ValueError: + return ErrorCode.LOADING_FAILED + + def read_backend(_userdata, buf, bufsize): + data = frontend.read(bufsize) + for i in range(len(data)): + buf[i] = data[i] + return len(data) + + cb_info.contents.cookie = None + read = cb_info.contents.read = StreamReadFn(read_backend) + close = cb_info.contents.close = StreamCloseFn( + lambda _userdata: frontend.close() + ) + + seek, size, cancel = None, None, None + if hasattr(frontend, "seek"): + seek = cb_info.contents.seek = StreamSeekFn( + lambda _userdata, offx: frontend.seek(offx) + ) + if hasattr(frontend, "size") and frontend.size is not None: + size = cb_info.contents.size = StreamSizeFn( + lambda _userdata: frontend.size + ) + + # Future API versions only + # if hasattr(frontend, 'cancel'): + # cb_info.contents.cancel = StreamCancelFn(lambda _userdata: frontend.cancel()) + + # keep frontend and callbacks in memory forever (TODO) + frontend._registered_callbacks = [read, close, seek, size, cancel] + self._stream_protocol_frontends[proto][uri] = frontend + return 0 + + if proto in self._stream_protocol_cbs: + raise KeyError("Stream protocol already registered") + self._stream_protocol_cbs[proto] = [open_backend] + _mpv_stream_cb_add_ro( + self.handle, proto.encode("utf-8"), c_void_p(), open_backend + ) + + return open_fn + + if open_fn is not None: + decorator(open_fn) + return decorator + + # Convenience functions + def play(self, filename): + """Play a path or URL (requires ``ytdl`` option to be set).""" + self.loadfile(filename) + + @property + def playlist_filenames(self): + """Return all playlist item file names/URLs as a list of strs.""" + return [element["filename"] for element in self.playlist] + + def playlist_append(self, filename, **options): + """Append a path or URL to the playlist. This does not start playing the file automatically. To do that, use + ``MPV.loadfile(filename, 'append-play')``.""" + self.loadfile(filename, "append", **options) + + # "Python stream" logic. This is some porcelain for directly playing data from python generators. + + def _python_stream_open(self, uri): + """Internal handler for python:// protocol streams registered through @python_stream(...) and + @python_stream_catchall + """ + (name,) = re.fullmatch("python://(.*)", uri).groups() + + if name in self._python_streams: + generator_fun, size = self._python_streams[name] + else: + if self._python_stream_catchall is not None: + generator_fun, size = self._python_stream_catchall(name) + else: + raise ValueError( + "Python stream name not found and no catch-all defined" + ) + + return GeneratorStream(generator_fun, size) + + def python_stream(self, name=None, size=None): + """Register a generator for the python stream with the given name. + + name is the name, i.e. the part after the "python://" in the URI, that this generator is registered as. + size is the total number of bytes in the stream (if known). + + Any given name can only be registered once. The catch-all can also only be registered once. To unregister a + stream, call the .unregister function set on the callback. + + The generator signals EOF by returning, manually raising StopIteration or by yielding b'', an empty bytes + object. + + The generator may be called multiple times if libmpv seeks or loops. + + See also: @mpv.python_stream_catchall + + @mpv.python_stream('foobar') + def reader(): + for chunk in chunks: + yield chunk + mpv.play('python://foobar') + mpv.wait_for_playback() + reader.unregister() + """ + + def register(cb): + if name in self._python_streams: + raise KeyError( + 'Python stream name "{}" is already registered'.format(name) + ) + self._python_streams[name] = (cb, size) + + def unregister(): + if ( + name not in self._python_streams + or self._python_streams[name][0] is not cb + ): # This is just a basic sanity check + raise RuntimeError("Python stream has already been unregistered") + del self._python_streams[name] + + cb.unregister = unregister + return cb + + return register + + def python_stream_catchall(self, cb): + """Register a catch-all python stream to be called when no name matches can be found. Use this decorator on a + function that takes a name argument and returns a (generator, size) tuple (with size being None if unknown). + + An invalid URI can be signalled to libmpv by raising a ValueError inside the callback. + + See also: @mpv.python_stream(name, size) + + @mpv.python_stream_catchall + def catchall(name): + if not name.startswith('foo'): + raise ValueError('Unknown Name') + + def foo_reader(): + with open(name, 'rb') as f: + while True: + chunk = f.read(1024) + if not chunk: + break + yield chunk + return foo_reader, None + mpv.play('python://foo23') + mpv.wait_for_playback() + catchall.unregister() + """ + if self._python_stream_catchall is not None: + raise KeyError("A catch-all python stream is already registered") + + self._python_stream_catchall = cb + + def unregister(): + if self._python_stream_catchall is not cb: + raise RuntimeError( + "This catch-all python stream has already been unregistered" + ) + self._python_stream_catchall = None + + cb.unregister = unregister + return cb + + # Property accessors + def _get_property(self, name, decoder=strict_decoder, fmt=MpvFormat.NODE): + self.check_core_alive() + out = create_string_buffer(sizeof(MpvNode)) + try: + cval = _mpv_get_property(self.handle, name.encode("utf-8"), fmt, out) + + if fmt is MpvFormat.OSD_STRING: + return cast(out, POINTER(c_char_p)).contents.value.decode("utf-8") + elif fmt is MpvFormat.NODE: + rv = cast(out, POINTER(MpvNode)).contents.node_value(decoder=decoder) + _mpv_free_node_contents(out) + return rv + else: + raise TypeError( + "_get_property only supports NODE and OSD_STRING formats." + ) + except PropertyUnavailableError as ex: + return None + + def _set_property(self, name, value): + self.check_core_alive() + ename = name.encode("utf-8") + if isinstance(value, (list, set, dict)): + _1, _2, _3, pointer = _make_node_str_list(value) + _mpv_set_property(self.handle, ename, MpvFormat.NODE, pointer) + else: + _mpv_set_property_string(self.handle, ename, _mpv_coax_proptype(value)) + + def __getattr__(self, name): + return self._get_property(_py_to_mpv(name), lazy_decoder) + + def __setattr__(self, name, value): + try: + if name != "handle" and not name.startswith("_"): + self._set_property(_py_to_mpv(name), value) + else: + super().__setattr__(name, value) + except AttributeError: + super().__setattr__(name, value) + + def __dir__(self): + return super().__dir__() + [ + name.replace("-", "_") for name in self.property_list + ] + + @property + def properties(self): + return {name: self.option_info(name) for name in self.property_list} + + # Dict-like option access + def __getitem__(self, name, file_local=False): + """Get an option value.""" + prefix = "file-local-options/" if file_local else "options/" + return self._get_property(prefix + name, lazy_decoder) + + def __setitem__(self, name, value, file_local=False): + """Set an option value.""" + prefix = "file-local-options/" if file_local else "options/" + return self._set_property(prefix + name, value) + + def __iter__(self): + """Iterate over all option names.""" + return iter(self.options) + + def option_info(self, name): + """Get information on the given option.""" + try: + return self._get_property("option-info/" + name) + except AttributeError: + return None + + +class MpvRenderContext: + def __init__(self, mpv, api_type, **kwargs): + self._mpv = mpv + kwargs["api_type"] = api_type + + buf = cast( + create_string_buffer(sizeof(MpvRenderCtxHandle)), + POINTER(MpvRenderCtxHandle), + ) + _mpv_render_context_create( + buf, mpv.handle, kwargs_to_render_param_array(kwargs) + ) + self._handle = buf.contents + + def free(self): + _mpv_render_context_free(self._handle) + + def __setattr__(self, name, value): + if name.startswith("_"): + super().__setattr__(name, value) + + elif name == "update_cb": + func = value if value else (lambda: None) + self._update_cb = value + self._update_fn_wrapper = RenderUpdateFn(lambda _userdata: func()) + _mpv_render_context_set_update_callback( + self._handle, self._update_fn_wrapper, None + ) + + else: + param = MpvRenderParam(name, value) + _mpv_render_context_set_parameter(self._handle, param) + + def __getattr__(self, name): + if name == "update_cb": + return self._update_cb + + elif name == "handle": + return self._handle + + param = MpvRenderParam(name) + data_type = type(param.data.contents) + buf = cast(create_string_buffer(sizeof(data_type)), POINTER(data_type)) + param.data = buf + _mpv_render_context_get_info(self._handle, param) + return buf.contents.as_dict() + + def update(self): + """Calls mpv_render_context_update and returns the MPV_RENDER_UPDATE_FRAME flag (see render.h)""" + return bool(_mpv_render_context_update(self._handle) & 1) + + def render(self, **kwargs): + _mpv_render_context_render(self._handle, kwargs_to_render_param_array(kwargs)) + + def report_swap(self): + _mpv_render_context_report_swap(self._handle) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..291aeea --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +babel +beautifulsoup4 +patool +portalocker +pydantic +pyshorteners +requests +vk-api +yandex_music +google-api-python-client +yt-dlp +crontab +humanize == 4.6 diff --git a/srb b/srb new file mode 100644 index 0000000..607bbc8 --- /dev/null +++ b/srb @@ -0,0 +1,7 @@ +echo "Enter the username for the service that you want to $1." +read user +echo "Enter the service name that will perform the above action." +read service + +sudo -u ${user} XDG_RUNTIME_DIR="/run/user/$(id -u ${user})" systemctl --user $1 ${service} + diff --git a/systemd/user/TTMediaBot.service b/systemd/user/TTMediaBot.service new file mode 100644 index 0000000..49461a5 --- /dev/null +++ b/systemd/user/TTMediaBot.service @@ -0,0 +1,12 @@ +[Unit] +Description=TTMediaBot +Requires=pulseaudio.service + +[Service] +Type=simple +WorkingDirectory=/home/ttInfiArtt/TTMediaBot +ExecStart=/home/ttInfiArtt/TTMediaBot/TTMediaBot.sh -c /home/ttInfiArtt/TTMediaBot/config.json +Restart=on-failure + +[Install] +WantedBy=default.target diff --git a/systemd/user/pulseaudio.service b/systemd/user/pulseaudio.service new file mode 100644 index 0000000..8438e1a --- /dev/null +++ b/systemd/user/pulseaudio.service @@ -0,0 +1,13 @@ +[Unit] +Description=Pulseaudio Sound Service +Requires=pulseaudio.socket + +[Service] +Type=notify +ExecStart=/usr/bin/pulseaudio --verbose --daemonize=no +ExecStartPost=/usr/bin/pactl load-module module-null-sink +Restart=on-failure + +[Install] +Also=pulseaudio.socket +WantedBy=default.target diff --git a/systemd/user/pulseaudio.socket b/systemd/user/pulseaudio.socket new file mode 100644 index 0000000..7dadba0 --- /dev/null +++ b/systemd/user/pulseaudio.socket @@ -0,0 +1,10 @@ +[Unit] +Description=Pulseaudio Sound System + +[Socket] +Priority=6 +Backlog=5 +ListenStream=%t/pulse/native + +[Install] +WantedBy=sockets.target diff --git a/tools/compile_locales.py b/tools/compile_locales.py new file mode 100644 index 0000000..c11393a --- /dev/null +++ b/tools/compile_locales.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +import os +import sys +import subprocess + + +cd = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +locale_path = os.path.join(cd, "locale") +pot_file_path = os.path.join(locale_path, "TTMediaBot.pot") +source_paths = [os.path.join(cd, "bot"), os.path.join(cd, "TTMediaBot.py")] +babel_prefix = "{} -m babel.messages.frontend".format(sys.executable) +locale_domain = "TTMediaBot" + + +def extract(): + code = subprocess.call( + f"{babel_prefix} extract {' '.join(source_paths)} -o {pot_file_path} --keywords=translate -c translators: --copyright-holder=TTMediaBot-team --project=TTMediaBot", + shell=True, + ) + if code: + sys.exit("Bable is not installed. please install all the requirements") + + +def update(): + code = subprocess.call( + f"{babel_prefix} update -i {pot_file_path} -d {locale_path} -D {locale_domain} --update-header-comment --previous", + shell=True, + ) + if code: + sys.exit(code) + + +def compile(): + code = subprocess.call( + f"{babel_prefix} compile -d {locale_path} -D {locale_domain}", shell=True + ) + if code: + sys.exit(code) + + +def main(): + extract() + update() + compile() + + +if __name__ == "__main__": + main() diff --git a/tools/libmpv_win_downloader.py b/tools/libmpv_win_downloader.py new file mode 100644 index 0000000..edc5cdc --- /dev/null +++ b/tools/libmpv_win_downloader.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 + +import bs4 +import patoolib +import requests + +import os +import platform +import re +import shutil +import sys + +path = os.path.dirname(os.path.realpath(__file__)) +path = os.path.dirname(path) +sys.path.append(path) +import downloader + + +url = "https://sourceforge.net/projects/mpv-player-windows/files/libmpv/" + + +def download(): + try: + headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'} + r = requests.get(url, headers=headers) + r.raise_for_status() # raise an error if there was a problem with the request + except requests.exceptions.RequestException as e: + print(f"Error: {e}") + return + + page = bs4.BeautifulSoup(r.text, features="html.parser") + table = page.find("table") + + if platform.architecture()[0][0:2] == "64": + l_ver = table.find("a", href=True, title=re.compile("x86_64")).get("title") + else: + l_ver = table.find("a", href=True, title=re.compile("i686")).get("title") + download_url = l_ver.replace("Click to download ", "https://excellmedia.dl.sourceforge.net/project/mpv-player-windows/libmpv/") + try: + downloader.download_file(download_url, os.path.join(os.getcwd(), "libmpv.7z")) + except Exception as e: + print(f"Error downloading file: {e}") + + +def extract(): + try: + os.mkdir(os.path.join(os.getcwd(), "libmpv")) + except FileExistsError: + shutil.rmtree(os.path.join(os.getcwd(), "libmpv")) + os.mkdir(os.path.join(os.getcwd(), "libmpv")) + try: + patoolib.extract_archive( + os.path.join(os.getcwd(), "libmpv.7z"), + outdir=os.path.join(os.getcwd(), "libmpv"), + ) + except Exception as e: + print(f"Error extracting file: {e}") + return + +def move_file(): + try: + source = os.path.join(os.getcwd(), "libmpv", "libmpv-2.dll") + dest = os.path.join(os.getcwd(), os.pardir) if os.path.basename(os.getcwd()) == "tools" else os.getcwd() + if not os.path.exists(source): + raise FileNotFoundError("The file libmpv-2.dll does not exist") + elif os.path.exists(os.path.join(dest, "libmpv-2.dll")): + os.remove(os.path.join(dest, "libmpv-2.dll")) + shutil.move(source, os.path.join(dest, "libmpv-2.dll")) + except (FileNotFoundError, FileExistsError, Exception) as e: + print(f"Error moving file: {e}") + +def clean(): + os.remove(os.path.join(os.getcwd(), "libmpv.7z")) + shutil.rmtree(os.path.join(os.getcwd(), "libmpv")) + +def install(): + if sys.platform != "win32": + sys.exit("This script should be run only on Windows") + print("Installing libmpv for Windows...") + print("Downloading latest libmpv version...") + download() + print("Downloaded") + print("extracting...") + extract() + print("extracted") + print("moving...") + move_file() + print("moved") + print("cleaning...") + clean() + print("cleaned.") + print("Installed, exiting.") + +if __name__ == "__main__": + install() diff --git a/tools/ttsdk_downloader.py b/tools/ttsdk_downloader.py new file mode 100644 index 0000000..652fb78 --- /dev/null +++ b/tools/ttsdk_downloader.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 + +import bs4 +import patoolib +import requests + +import os +import platform +import shutil +import sys + +path = os.path.dirname(os.path.realpath(__file__)) +path = os.path.dirname(path) +sys.path.append(path) +import downloader + + +url = "https://bearware.dk/teamtalksdk" + + + +def get_url_suffix_from_platform() -> str: + machine = platform.machine() + if sys.platform == "win32": + architecture = platform.architecture() + if machine == "AMD64" or machine == "x86": + if architecture[0] == "64bit": + return "win64" + else: + return "win32" + else: + sys.exit("Native Windows on ARM is not supported") + elif sys.platform == "darwin": + sys.exit("Darwin is not supported") + else: + if machine == "AMD64" or machine == "x86_64": + return "ubuntu18_x86_64" + elif "arm" in machine: + return "raspbian_armhf" + else: + sys.exit("Your architecture is not supported") + + +def download() -> None: + headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'} + r = requests.get(url, headers=headers) + page = bs4.BeautifulSoup(r.text, features="html.parser") + # The last tested version series is v5.8x + versions = page.find_all("li") + version = [i for i in versions if "5.8" in i.text][-1].a.get("href")[0:-1] + download_url = ( + url + + "/" + + version + + "/" + + "tt5sdk_{v}_{p}.7z".format(v=version, p=get_url_suffix_from_platform()) + ) + print("Downloading from " + download_url) + downloader.download_file(download_url, os.path.join(os.getcwd(), "ttsdk.7z")) + + +def extract() -> None: + try: + os.mkdir(os.path.join(os.getcwd(), "ttsdk")) + except FileExistsError: + shutil.rmtree(os.path.join(os.getcwd(), "ttsdk")) + os.mkdir(os.path.join(os.getcwd(), "ttsdk")) + patoolib.extract_archive( + os.path.join(os.getcwd(), "ttsdk.7z"), outdir=os.path.join(os.getcwd(), "ttsdk") + ) + +def move() -> None: + path = os.path.join(os.getcwd(), "ttsdk", os.listdir(os.path.join(os.getcwd(), "ttsdk"))[0]) + libraries = ["TeamTalk_DLL", "TeamTalkPy"] + dest_dir = os.path.join(os.getcwd(), os.pardir) if os.path.basename(os.getcwd()) == "tools" else os.getcwd() + for library in libraries: + try: + os.rename( + os.path.join(path, "Library", library), os.path.join(dest_dir, library) + ) + except OSError: + shutil.rmtree(os.path.join(dest_dir, library)) + os.rename( + os.path.join(path, "Library", library), os.path.join(dest_dir, library) + ) + try: + os.rename( + os.path.join(path, "License.txt"), os.path.join(dest_dir, "TTSDK_license.txt") + ) + except FileExistsError: + os.remove(os.path.join(dest_dir, "TTSDK_license.txt")) + os.rename( + os.path.join(path, "License.txt"), os.path.join(dest_dir, "TTSDK_license.txt") + ) + + +def clean() -> None: + os.remove(os.path.join(os.getcwd(), "ttsdk.7z")) + shutil.rmtree(os.path.join(os.getcwd(), "ttsdk")) + + +def install() -> None: + print("Installing TeamTalk sdk components") + print("Downloading latest sdk version") + download() + print("Downloaded. extracting") + extract() + print("Extracted. moving") + move() + print("moved. cleaning") + clean() + print("cleaned.") + print("Installed") + + +if __name__ == "__main__": + install() diff --git a/tools/vk_auth.py b/tools/vk_auth.py new file mode 100644 index 0000000..177bfee --- /dev/null +++ b/tools/vk_auth.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 + +import requests + +from getpass import getpass +import json +import os + + +class AuthenticationError(Exception): + pass + + +class PhoneValidationError(Exception): + pass + + +class TokenValidationError(Exception): + pass + + +client_id = "2274003" +client_secret = "hHbZxrka2uZ6jB1inYsH" +api_ver = "5.89" +scope = "all" +user_agent = "VKAndroidApp/6.2-5091 (Android 9; SDK 28; samsungexynos7870; samsung j6lte; 720x1450)" +api_url = "https://api.vk.com/method/" +receipt = "fkdoOMX_yqQ:APA91bHbLn41RMJmAbuFjqLg5K-QW7si9KajBGCDJxcpzbuvEcPIk9rwx5HWa1yo1pTzpaKL50mXiWvtqApBzymO2sRKlyRiWqqzjMTXUyA5HnRJZyXWWGPX8GkFxQQ4bLrDCcnb93pn" + + +def request_auth(login: str, password: str, scope: str = "", code: str = "") -> str: + if not (login or password): + raise ValueError + url = ( + "https://oauth.vk.com/token?grant_type=password&client_id=" + + client_id + + "&client_secret=" + + client_secret + + "&username=" + + login + + "&password=" + + password + + "&v=" + + api_ver + + "&2fa_supported=1&force_sms=1" + ) + if scope: + url += "&scope=" + scope + if code: + url += "&code=" + code + headers = {"User-Agent": user_agent} + r = requests.get(url, headers=headers) + if r.status_code == 200 and "access_token" in r.text: + res = r.json() + access_token = res["access_token"] + return access_token + elif "need_validation" in r.text: + res = r.json() + sid = res["validation_sid"] + code = handle_2fa(sid) + access_token = request_auth(login, password, scope=scope, code=code) + return access_token + else: + raise AuthenticationError(r.text) + + +def handle_2fa(sid: str) -> str: + if not sid: + raise ValueError("No sid is given") + url = api_url + "auth.validatePhone?sid=" + sid + "&v=" + api_ver + headers = {"User-Agent": user_agent} + r = requests.get(url, headers=headers) + if r.status_code == 200: + print("Two factor authentication is required") + code = "" + while not code: + code = input("SMS code: ") + if len(code) != 6 or not code.isdigit(): + print("SMS code must be a string of 6 digits") + continue + return code + else: + raise PhoneValidationError(r.text) + + +def validate_token(token: str) -> str: + if not (token): + raise ValueError("Required argument is missing") + url = api_url + "auth.refreshToken?access_token=" + token + "&receipt=" + receipt + "&v=" + api_ver + headers = {"User-Agent": user_agent} + r = requests.get(url, headers=headers) + if r.status_code == 200 and "token" in r.text: + res = r.json() + received_token = res["response"]["token"] + if not received_token: + raise TokenValidationError(r.text) + else: + return received_token + else: + raise TokenValidationError(r.text) + + +def main(): + login = "" + password = "" + try: + print("VK Authentication Helper for TTMediaBot") + print() + print("Enter your VK credentials to continue") + while not login: + login = input("Phone, email or login: ") + while not password: + password = getpass("Password: ") + token = request_auth(login, password, scope=scope) + validated_token = validate_token(token) + y_or_n = input("Do you want to save the token to the configuration file? y/n") + if y_or_n == "y": + config_file = input("Configuration file path: ") + with open(config_file, "r") as f: + data = json.load(f) + data["services"]["vk"]["token"] = validated_token + with open(config_file, "w") as f: + json.dump(data, f, indent=4, ensure_ascii=False) + print("Your token has been successfully saved to the configuration file") + else: + print("Your VK token:") + print(validated_token) + except Exception as e: + print(e) + input("Press enter to continue") + + +if __name__ == "__main__": + main() diff --git a/tools/yam_auth.py b/tools/yam_auth.py new file mode 100644 index 0000000..cb71a4f --- /dev/null +++ b/tools/yam_auth.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 + +from getpass import getpass +import json +import os + +import requests + +CLIENT_ID = "23cabbbdc6cd418abb4b39c32c41195d" +CLIENT_SECRET = "53bc75238f0c4d08a118e51fe9203300" +USER_AGENT = "Yandex-Music-API" +HEADERS = { + "X-Yandex-Music-Client": "YandexMusicAndroid/23020251", + "USER_AGENT": USER_AGENT, +} +url = "https://oauth.yandex.ru/token" + + +def get_token( + username, password, grant_type="password", x_captcha_answer=None, x_captcha_key=None +): + data = { + "grant_type": grant_type, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "username": username, + "password": password, + } + if x_captcha_answer and x_captcha_key: + data.update( + {"x_captcha_answer": x_captcha_answer, "x_captcha_key": x_captcha_key} + ) + try: + resp = requests.request("post", url, data=data, headers=HEADERS) + except requests.RequestException as e: + raise NetworkError(e) + if not (200 <= resp.status_code <= 299): + raise SystemError("Error") + json_data = json.loads(resp.content.decode("utf-8")) + return json_data["access_token"] + + +def main(): + login = "" + password = "" + try: + print("Yandex music Authentication Helper for TTMediaBot") + print() + print("Enter your Yandex credentials to continue") + while not login: + login = input("email or login: ") + while not password: + password = getpass("Password: ") + token = get_token(login, password) + y_or_n = input("Do you want to save the token to the configuration file? y/n") + if y_or_n == "y": + config_file = input("Configuration file path: ") + with open(config_file, "r") as f: + data = json.load(f) + try: + data["services"]["yam"]["token"] = token + except KeyError: + data["services"]["yam"] = {"enabled": True, "token": token} + with open(config_file, "w") as f: + json.dump(data, f, indent=4, ensure_ascii=False) + print("Your token has been successfully saved to the configuration file") + else: + print("Your yandex music token:") + print(token) + except Exception as e: + print(e) + input("Press enter to continue") + + +if __name__ == "__main__": + main() diff --git a/typestubs/mpv.pyi b/typestubs/mpv.pyi new file mode 100644 index 0000000..e1a6782 --- /dev/null +++ b/typestubs/mpv.pyi @@ -0,0 +1,65 @@ +from ctypes import * +from typing import Any, Callable, Dict, List, Optional + +class MpvEventID(c_int): + NONE: int + SHUTDOWN: int + LOG_MESSAGE: int + GET_PROPERTY_REPLY: int + SET_PROPERTY_REPLY: int + COMMAND_REPLY: int + START_FILE: int + END_FILE: int + FILE_LOADED: int + TRACKS_CHANGED: int + TRACK_SWITCHED: int + IDLE: int + PAUSE: int + UNPAUSE: int + TICK: int + SCRIPT_INPUT_DISPATCH: int + CLIENT_MESSAGE: int + VIDEO_RECONFIG: int + AUDIO_RECONFIG: int + METADATA_UPDATE: int + SEEK: int + PLAYBACK_RESTART: int + PROPERTY_CHANGE: int + CHAPTER_CHANGE: int + +class MpvEvent(Structure): + _fields_ = [ + ("event_id", MpvEventID), + ("error", c_int), + ("reply_userdata", c_ulonglong), + ("data", c_void_p), + ] + +class MPV: + def __init__( + self, + *extra_mpv_flags: Any, + log_handler: Optional[Callable[[str, str, str], None]] = ..., + start_event_thread: bool = ..., + loglevel: Optional[str] = ..., + **extra_mpv_opts: Any + ) -> None: ... + audio_device: str + audio_device_list: List[Dict[str, Any]] + idle_active: bool + duration: float + def event_callback( + self, *event_types: str + ) -> Callable[[Callable[[MpvEvent], None]], None]: ... + media_title: str + metadata: Dict[str, Any] + pause: bool + def seek( + self, amount: float, reference: str = ..., precision: str = ... + ) -> None: ... + speed: float + def stop(self, keep_playlist: bool = ...) -> None: ... + stream_record: str + def play(self, filename: str) -> None: ... + def terminate(self) -> None: ... + volume: int diff --git a/typestubs/patoolib.pyi b/typestubs/patoolib.pyi new file mode 100644 index 0000000..bbdc0a9 --- /dev/null +++ b/typestubs/patoolib.pyi @@ -0,0 +1,9 @@ +from typing import Optional + +def extract_archive( + archive: str, + verbosity: int = ..., + outdir: Optional[str] = ..., + program: Optional[str] = ..., + interactive: bool = ..., +) -> None: ... diff --git a/typestubs/version/__init__.pyi b/typestubs/version/__init__.pyi new file mode 100644 index 0000000..4b4b8c5 --- /dev/null +++ b/typestubs/version/__init__.pyi @@ -0,0 +1,35 @@ +""" +This type stub file was generated by pyright. +""" + +class _Comparable: + """Implements rich comparison if __lt__ and __eq__ are provided.""" + def __gt__(self, other: _Comparable: _Comparable) -> bool: + ... + + def __le__(self, other: _Comparable) -> bool: + ... + + def __ne__(self, other: _Comparable) -> bool: + ... + + def __ge__(self, other: _Comparable) -> bool: + ... + + +class Version(_Comparable): + def __init__(self, version: str) -> None: + ... + + def __str__(self) -> str: + ... + + def __repr__(self): # -> str: + ... + + def __lt__(self, other: Version) -> bool: + ... + + def __eq__(self, other: Version) -> bool: + ... + diff --git a/typestubs/vk_api/__init__.py b/typestubs/vk_api/__init__.py new file mode 100644 index 0000000..441bd38 --- /dev/null +++ b/typestubs/vk_api/__init__.py @@ -0,0 +1,45 @@ +from typing import Any, Dict + +import requests + + +class Audio: + def get( + self, + owner_id: int = 1, + album_id: int = 1, + count: int = ..., + ) -> Dict[str, Any]: + ... + + def search(self, q: str = "", count: int = 100, sort: int = 0) -> Dict[str, Any]: + ... + + +class Account: + def getInfo(self) -> Dict[str, Any]: + ... + + +class Utils: + def resolveScreenName(self, screen_name: str = "") -> Dict[str, Any]: + ... + + +class Api: + account: Account + audio: Audio + utils: Utils + + +class VkApi: + def __init__( + self, + token: str = "", + session: requests.Session = ..., + api_version: str = "", + ) -> None: + ... + + def get_api(self) -> Api: + ... diff --git a/typestubs/vk_api/exceptions.py b/typestubs/vk_api/exceptions.py new file mode 100644 index 0000000..64a2059 --- /dev/null +++ b/typestubs/vk_api/exceptions.py @@ -0,0 +1,6 @@ +class ApiHttpError(Exception): + pass + + +class ApiError(Exception): + pass diff --git a/typestubs/youtubesearchpython.pyi b/typestubs/youtubesearchpython.pyi new file mode 100644 index 0000000..85ce8c7 --- /dev/null +++ b/typestubs/youtubesearchpython.pyi @@ -0,0 +1,7 @@ +from typing import Any, Dict + +class VideosSearch: + def __init__( + self, query: str, limit: int = ..., language: str = ..., region: str = ... + ) -> None: ... + def result(self, mode: int = ...) -> Dict[str, Any]: ... diff --git a/typestubs/yt_dlp.pyi b/typestubs/yt_dlp.pyi new file mode 100644 index 0000000..0abcad5 --- /dev/null +++ b/typestubs/yt_dlp.pyi @@ -0,0 +1,24 @@ +from __future__ import annotations +from typing import Any, Dict, Optional + +class YoutubeDL: + def __init__( + self, params: Optional[Dict[str, Any]] = ..., auto_init: bool = ... + ) -> None: ... + def __enter__(self) -> YoutubeDL: ... + def __exit__(self) -> None: ... + def extract_info( + self, + url: Optional[str], + download: bool = ..., + ie_key: Optional[str] = ..., + extra_info: Optional[Dict[str, Any]] = ..., + process: bool = ..., + force_generic_extractor: bool = ..., + ) -> Dict[str, Any]: ... + def process_ie_result( + self, + ie_result: Dict[str, Any], + download: bool = ..., + extra_info: Optional[Dict[str, Any]] = ..., + ) -> Dict[str, Any]: ...