Repository: azlux/botamusique Branch: master Commit: 2760a14f0100 Files: 66 Total size: 904.8 KB Directory structure: gitextract_qyloy__i/ ├── .dockerignore ├── .drone.yml ├── .github/ │ ├── FUNDING.yml │ └── ISSUE_TEMPLATE/ │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .gitmodules ├── Dockerfile ├── Dockerfile.local ├── LICENSE ├── README.md ├── command.py ├── configuration.default.ini ├── configuration.example.ini ├── constants.py ├── database.py ├── entrypoint.sh ├── interface.py ├── lang/ │ ├── de_DE.json │ ├── en_US.json │ ├── es_ES.json │ ├── fr_FR.json │ ├── it_IT.json │ ├── ja_JP.json │ ├── nl_NL.json │ ├── pt_BR.json │ └── zh_CN.json ├── media/ │ ├── README.md │ ├── __init__.py │ ├── cache.py │ ├── file.py │ ├── item.py │ ├── playlist.py │ ├── radio.py │ ├── url.py │ └── url_from_playlist.py ├── mumbleBot.py ├── requirements.txt ├── scripts/ │ ├── commit_new_translation.sh │ ├── sync_translation.py │ ├── translate_templates.py │ └── update_translation_to_server.sh ├── static/ │ └── .gitignore ├── update.sh ├── util.py ├── variables.py └── web/ ├── .editorconfig ├── .eslintrc.json ├── .gitattributes ├── .gitignore ├── babel.config.json ├── js/ │ ├── app.mjs │ ├── lib/ │ │ ├── text.mjs │ │ ├── theme.mjs │ │ ├── type.mjs │ │ └── util.mjs │ └── main.mjs ├── package-lock.json ├── package.json ├── sass/ │ ├── app-dark.scss │ ├── app.scss │ └── main.scss ├── templates/ │ ├── index.template.html │ └── need_token.template.html ├── vscode.eslintrc.json └── webpack.config.cjs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .git/ ================================================ FILE: .drone.yml ================================================ kind: pipeline type: docker name: deployement-local volumes: - name: repo host: path: /media/raid5/data/packages/repos/apt/botamusique/ steps: - name: build-web image: node:16 commands: - (cd web && npm install && npm run build) when: event: - push - tag - name: translate-html image: python:3 commands: - pip3 install jinja2 - ./scripts/translate_templates.py --lang-dir lang/ --template-dir web/templates/ when: event: - push - tag - name: deploy-testing image: debian commands: - apt-get -qq update && apt-get -qq install git > /dev/null - sed -i 's/target_version = git/target_version = testing/' configuration.default.ini - git fetch --tags - version=$(git describe --tags) - echo "current git commit is $version" - echo $version > /mnt/botamusique/testing-version - sed -i "s/version = 'git'/version = '$version'/" mumbleBot.py - rm -rf .git* - rm -rf web - mkdir /tmp/botamusique - cp -r . /tmp/botamusique/ - tar -czf /mnt/botamusique/sources-testing.tar.gz -C /tmp botamusique volumes: - name: repo path: /mnt/botamusique/ when: branch: - master event: - push - name: deploy-stable image: debian commands: - apt-get -qq update && apt-get -qq install jq curl git pandoc python3-requests > /dev/null - sed -i 's/target_version = git/target_version = stable/' configuration.default.ini - git fetch --tags - version=$(git describe --abbrev=0 --tags) - echo "version is $version" - echo $version > /mnt/botamusique/version - sed -i "s/version = 'git'/version = '$version'/" mumbleBot.py - curl --silent "https://api.github.com/repos/azlux/botamusique/releases/latest" | jq -r '.body' | pandoc --from gfm --to html - --output - > /mnt/botamusique/changelog - rm -rf .git* - rm -rf web - mkdir /tmp/botamusique - cp -r . /tmp/botamusique/ - tar -czf /mnt/botamusique/sources-stable.tar.gz -C /tmp botamusique volumes: - name: repo path: /mnt/botamusique/ when: event: - tag node: location: local trigger: event: exclude: - cron --- kind: pipeline type: docker name: deployement-docker steps: - name: build-web image: node:16 commands: - (cd web && npm install && npm run build) when: event: - push - tag - name: translate-html image: python:3 commands: - pip3 install jinja2 - ./scripts/translate_templates.py --lang-dir lang/ --template-dir web/templates/ when: event: - push - tag - name: config-testing image: debian commands: - sed -i 's/target_version = git/target_version = testing/' configuration.default.ini when: branch: - master event: - push - name: docker-testing image: thegeeklab/drone-docker-buildx privileged: true settings: repo: azlux/botamusique platforms: linux/amd64,linux/arm64,linux/arm/v7 username: from_secret: docker_username password: from_secret: docker_password tags: testing when: branch: - master event: - push - name: config-stable image: debian commands: - sed -i 's/target_version = git/target_version = stable/' configuration.default.ini when: event: - tag - name: docker-stable image: thegeeklab/drone-docker-buildx privileged: true settings: repo: azlux/botamusique platforms: linux/amd64,linux/arm64,linux/arm/v7 username: from_secret: docker_username password: from_secret: docker_password tags: latest when: event: - tag node: location: external trigger: event: exclude: - cron --- kind: pipeline type: docker name: translation-traduora steps: - name: fetch-translation image: debian environment: TRADUORA_R_CLIENT: from_secret: TRADUORA_R_CLIENT TRADUORA_R_SECRET: from_secret: TRADUORA_R_SECRET GITHUB_API: from_secret: GITHUB_API commands: - apt update && apt install -y git python3-requests hub - PUSH=true SOURCE_DIR=$(pwd) ./scripts/commit_new_translation.sh node: location: external trigger: event: - cron cron: - auto-fetch-lang --- kind: pipeline type: docker name: translation-git steps: - name: push-translation image: debian environment: TRADUORA_R_CLIENT: from_secret: TRADUORA_R_CLIENT TRADUORA_R_SECRET: from_secret: TRADUORA_R_SECRET TRADUORA_W_CLIENT: from_secret: TRADUORA_W_CLIENT TRADUORA_W_SECRET: from_secret: TRADUORA_W_SECRET GITHUB_API: from_secret: GITHUB_API commands: - apt update && apt install -y git python3-requests hub - SOURCE_DIR=$(pwd) ./scripts/update_translation_to_server.sh when: branch: - master event: - push node: location: external trigger: event: exclude: - cron ================================================ FILE: .github/FUNDING.yml ================================================ open_collective: botamusique ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve --- **Describe the bug** A clear and concise description of what the bug is. **Affected version** The exact version you're using (git commit id). You should **always** only report bugs which you can reproduce on the latest version (`uif` branch), however **always** state the current commit id here (in case there are new commits between your report and us looking at it) **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .gitignore ================================================ # Created by .ignore support plugin (hsz.mobi) ### Python template # 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/ *.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/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # 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/ configuration.ini .vscode/settings.json 2019-07-27 22_09_08-radiobrowser.py - botamusique - Visual Studio Code.png .DS_Store *.pem music_folder/ tmp/ *.db templates/*.html # Pycharm .idea/ ================================================ FILE: .gitmodules ================================================ ================================================ FILE: Dockerfile ================================================ ARG ARCH= FROM python:3.11-slim-bullseye AS python-builder ENV DEBIAN_FRONTEND=noninteractive WORKDIR /botamusique RUN apt-get update \ && apt-get install --no-install-recommends -y gcc g++ ffmpeg libjpeg-dev libmagic-dev opus-tools zlib1g-dev \ && rm -rf /var/lib/apt/lists/* COPY . /botamusique RUN python3 -m venv venv \ && venv/bin/pip install wheel \ && venv/bin/pip install -r requirements.txt FROM python:3.11-slim-bullseye ENV DEBIAN_FRONTEND noninteractive EXPOSE 8181 RUN apt update && \ apt install --no-install-recommends -y opus-tools ffmpeg libmagic-dev curl tar && \ rm -rf /var/lib/apt/lists/* COPY --from=python-builder /botamusique /botamusique WORKDIR /botamusique RUN chmod +x entrypoint.sh ENTRYPOINT [ "/botamusique/entrypoint.sh" ] CMD ["venv/bin/python", "mumbleBot.py"] ================================================ FILE: Dockerfile.local ================================================ ARG ARCH= FROM ${ARCH}python:3-slim-bullseye AS source ARG VERSION=master ENV DEBIAN_FRONTEND=noninteractive WORKDIR /botamusique RUN apt-get update && apt-get install -y git RUN git clone --recurse-submodules https://github.com/azlux/botamusique.git . && git checkout $VERSION FROM ${ARCH}python:3-slim-bullseye AS python-builder ENV DEBIAN_FRONTEND=noninteractive WORKDIR /botamusique RUN apt-get update \ && apt-get install -y gcc ffmpeg libjpeg-dev libmagic-dev opus-tools zlib1g-dev \ && rm -rf /var/lib/apt/lists/* COPY --from=source /botamusique . RUN python3 -m venv venv \ && venv/bin/pip install wheel \ && venv/bin/pip install -r requirements.txt FROM ${ARCH}node:14-bullseye-slim AS node-builder ENV DEBIAN_FRONTEND=noninteractive WORKDIR /botamusique/web COPY --from=source /botamusique/web . RUN npm install RUN npm run build FROM ${ARCH}python:3-slim-bullseye AS template-builder ENV DEBIAN_FRONTEND=noninteractive WORKDIR /botamusique COPY --from=python-builder /botamusique . COPY --from=node-builder /botamusique/templates templates RUN venv/bin/python scripts/translate_templates.py --lang-dir /botamusique/lang --template-dir /botamusique/web/templates FROM ${ARCH}python:3-slim-bullseye ENV DEBIAN_FRONTEND=noninteractive EXPOSE 8181 WORKDIR /botamusique RUN apt-get update \ && apt-get install -y ffmpeg libmagic-dev opus-tools zlib1g \ && rm -rf /var/lib/apt/lists/* COPY --from=python-builder /botamusique . COPY --from=node-builder /botamusique/static static COPY --from=template-builder /botamusique/templates templates RUN chmod +x entrypoint.sh ENTRYPOINT [ "/botamusique/entrypoint.sh" ] CMD ["/botamusique/venv/bin/python", "/botamusique/mumbleBot.py"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2016 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. ================================================ FILE: README.md ================================================ # Important Announcement Hello everyone, First, let's look at the problems: 1. I don't use mumble anymore, working on a bot you don't use produces a leak of testing and motivation. 2. I don't code like before, my hobbies have changed, I maintain stuff I still use, but no real coding anymore. 3. Botamusique is monolitique I've been trying to make a POC to change the monolitique part, to have a fully modulable bot, with asyncio and and feature/backend as plugins. But asyncio was blocking for me, especially to make the bot with fastapi, discord api / pymumble. It's 2 async loop and I don't have the knowledge to make it work. To be transparent, botamusique was the biggest project I've done, one of the funniest. Thanks @TerryGeng for joining the adventure. I don't think I will be looking for a maintainer, the monolithic part of this project is not something that needs to be maintained. **This projet will be archived.** BUT If someone want to rewrite a bot, I'm ready to help with the projet : what to do, Errors to avoid, Design/architecture help (but no code). I think **_8 years_** on this projet (have start with [this small projet](https://github.com/azlux/MumbleRadioPlayer/commit/56ca276c5519fcb0e1af043beb043202e65c2cca)) can help someone. It was really funny, thank all, for your support ! See you in space cowboy. -- Azlux -----
botamusique

botamusique

Botamusique is a [Mumble](https://www.mumble.info/) music bot. Predicted functionalities will be those people would expect from any classic music player. [![Build Status](https://ci.azlux.fr/api/badges/azlux/botamusique/status.svg)](https://ci.azlux.fr/azlux/botamusique) ## Features 1. **Support multiple music sources:** - Music files in local folders (which can be uploaded through the web interface). - Youtube/Soundcloud URLs and playlists (everything supported by youtube-dl). - Radio stations from URL and http://www.radio-browser.info API. 2. **Modern and powerful web remote control interface.** Powered by Flask. Which supports: - Playlist management. - Music library management, including uploading, browsing all files and edit tags, etc. 3. **Powerful command system.** Commands and words the bot says are fully customizable. Support partial-match for commands. 4. **Ducking.** The bot would automatically lower its volume if people are talking. 5. **Stereo sound.** After Mumble 1.4.0, stereo output support has been added. Our bot is designed to work nicely with it naturally. 6. **Multilingual support.** A list of supported languages can be found below. ## Screenshots ![botamusique in Mumble channel](https://user-images.githubusercontent.com/2306637/75210917-68fbf680-57bd-11ea-9cf8-c0871edff13f.jpg) ![botamusique web interface](https://user-images.githubusercontent.com/2306637/77822763-b4911f80-7130-11ea-9bc5-83c36c995ab9.png) ----- ## Quick Start Guide 1. [Installation](#installation) 1. [Configuration](#configuration) 1. [Run the bot](#run-the-bot) 1. [Operate the bot](#operate-the-bot) 1. [Update](#update) 1. [Known issues](#known-issues) 1. [Contributors](#contributors) ## Installation ### Dependencies 1. Install python. We require a python version of 3.6 or higher. 1. Install [Opus Codec](https://www.opus-codec.org/) (which should be already installed if you installed Mumble or Murmur, or you may try to install `opus-tools` with your package manager). 1. Install ffmpeg. If ffmpeg isn't in your package manager, you may need to find another source. I personally use [this repository](http://repozytorium.mati75.eu/) on my raspberry. ### Docker See https://github.com/azlux/botamusique/wiki/Docker-install Both stable and nightly (developing) builds are available! ### Manual install **Stable release (recommended)** This is current stable version, with auto-update support. To install the stable release, run these lines in your terminal: ``` curl -Lo botamusique.tar.gz http://packages.azlux.fr/botamusique/sources-stable.tar.gz tar -xzf botamusique.tar.gz cd botamusique python3 -m venv venv venv/bin/pip install wheel venv/bin/pip install -r requirements.txt ``` **Nightly build (developing version)**
Click to expand! This build reflects any newest change in the master branch, with auto-update support baked in. This version follow all commits into the master branch. ``` curl -Lo botamusique.tar.gz http://packages.azlux.fr/botamusique/sources-testing.tar.gz tar -xzf botamusique.tar.gz cd botamusique python3 -m venv venv venv/bin/pip install wheel venv/bin/pip install -r requirements.txt ```
**Build from source code**
Click to expand! You can checkout the master branch of our repo and compile everything by yourself. We will test new features in the master branch, maybe sometimes post some hotfixes. Please be noted that the builtin auto-update support doesn't track this version. If you have no idea what these descriptions mean to you, we recommend you install the stable version above. ``` git clone https://github.com/azlux/botamusique.git cd botamusique python3 -m venv venv venv/bin/pip install wheel venv/bin/pip install -r requirements.txt (cd web && npm install && npm run build) venv/bin/python3 ./scripts/translate_templates.py --lang-dir lang/ --template-dir web/templates/ ```
## Configuration Please copy `configuration.example.ini` into `configuration.ini`, follow the instructions in that file and uncomment options you would like to modify. Not all sections are needed. You may just keep the options that matter to you. For example, if you only would like to set `host`, all you need you is keep ``` [server] host=xxxxxx ``` in your `configuration.ini`. Please DO NOT MODIFY `configuration.default.ini`, since if the bot realizes one option is undefined in `configuration.ini`, it will look into `configuration.default.ini` for the default value of that option. This file will be constantly overridden in each update. We list some basic settings for you to quickly get things working. ### Basic settings 1. Usually, the first thing is to set the Murmur server you'd like the bot to connect to. You may also specify which channel the bot stays, and tokens used by the bot. ``` [server] host = 127.0.0.1 port = 64738 ``` 2. You need to specify a folder that stores your music files. The bot will look for music and upload files into that folder. You also need to specify a temporary folder to store music file downloads from URLs. ``` [bot] music_folder = music_folder/ tmp_folder = /tmp/ ``` 3. **Web interface is disabled by default** for performance and security reasons. It is extremely powerful, so we encourage you to have a try. To enable it, set ``` [webinterface] enabled = True ``` Default binding address is ``` listening_addr = 127.0.0.1 listening_port = 8181 ``` You can access the web interface through http://127.0.0.1:8181 if you keep it unchanged. Note: Listening to address `127.0.0.1` will only accept requests from localhost. _If you would like to connect from the public internet, you need to set it to `0.0.0.0`, and set up username and password to impose access control._ In addition, if the bot is behind a router, you should also properly set forwarding rules in you NAT configuration to forward requests to the bot. 4. The default language is English, but you can change it in `[bot]` section: ``` [bot] language=en_US ``` Available translations can be found inside `lang/` folder. Currently, options are - `en_US`, English - `es_ES`, Spanish - `fr_FR`, French - `it_IT`, Italian - `ja_JP`, Japanese - `zh_CN`, Chinese 5. Generate a certificate (Optional, but recommended) By default, murmur server uses certificates to identify users. Without a valid certificate, you wouldn't able to register the bot into your Murmur server. Some server even refused users without a certificate. Therefore, it is recommended to generate a certificate for the bot. If you have a certificate (for say, `botmusique.pem` in the folder of the bot), you can specify its location in ``` [server] certificate=botamusique.pem ``` If you don't have a certificate, you may generate one by: `openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout botamusique.pem -out botamusique.pem -subj "/CN=botamusique"` ### Sections explained - `server`: configuration about the server. Will be overridden by the `./mumbleBot.py` parameters. - `bot`: basic configuration of the bot, eg. name, comment, folder, default volume, etc. - `webinterface`: basic configuration about the web interface. - `commands`: you can customize the command you want for each action (eg. put `help = helpme` , the bot will respond to `!helpme`) - `radio`: a list of default radio (eg. play a jazz radio with the command `!radio jazz`) - `debug`: option to activate ffmpeg or pymumble debug output. ## Run the bot If you have set up everything in your `configuration.ini`, you can `venv/bin/python mumbleBot.py --config configuration.ini` Or you can `venv/bin/python mumbleBot.py -s HOST -u BOTNAME -P PASSWORD -p PORT -c CHANNEL -C /path/to/botamusique.pem` If you want information about auto-starting and auto-restarting of the bot, you can check out the wiki page [Run botamusique as a daemon In the background](https://github.com/azlux/botamusique/wiki/Run-botamusique-as-a-daemon-In-the-background). **For the detailed manual of using botamusique, please see the [wiki](https://github.com/azlux/botamusique/wiki).** ## Operate the bot You can control the bot by both commands sent by text message and the web interface. By default, all commands start with `!`. You can type `!help` in the text message to see the full list of commands supported, or see the examples on the [wiki page](https://github.com/azlux/botamusique/wiki/Command-Help-and-Examples). The web interface can be used if you'd like an intuitive way of interacting with the bot. Through it is fairly straightforward, a walk-through can be found on the [wiki page](https://github.com/azlux/botamusique/wiki/Web-interface-walk-through). ## Update If you enable `auto_check_update`, the bot will check for updates every time it starts. If you are using the recommended install, you can send `!update` to the bot (command by default). If you are using git, you need to update manually: ``` git pull --all git submodule update venv/bin/pip install --upgrade -r requirements.txt ``` ## Known issues 1. During installation, you may encounter the following error: ``` ImportError: libtiff.so.5: cannot open shared object file: No such file or directory ``` You need to install a missing library: `apt install libtiff5` 2. In the beginning, you may encounter the following error even if you have installed all requirements: ``` Exception: Could not find opus library. Make sure it is installed. ``` You need to install the opus codec (not embedded in all system): `apt install libopus0` 3. MacOS Users may encounter the following error: ``` ImportError: failed to find libmagic. Check your installation ``` This is caused by missing `libmagic` binaries and can be solved by ```bash brew install libmagic ``` One may also install `python-magic-bin` instead of `python-magic`. 5. If you have a large amount of music files (>1000), it may take some time for the bot to boot, since it will build up the cache for the music library on booting. You may want to disable this auto-scanning by setting ``refresh_cache_on_startup=False`` in `[bot]` section and control the scanning manually by ``!rescan`` command and the *Rescan Files* button on the web interface. 6. Alpine Linux requires some extra dependencies during the installation (in order to compile Pillow): ``` python3-dev musl-lib libmagic jpeg-dev zlib-dev gcc ``` For more information, see [#122](https://github.com/azlux/botamusique/issues/122). ## _I need help!_ If you ran into some problems in using the bot, or discovered bugs and want to talk to us, you may - Start a new issue, - Ask in the Matrix channel of Mumble [#mumble:matrix.org](https://matrix.to/#/#mumble:matrix.org) (we are usually there to help). ## Contributors If you want to help us develop, you're welcome to fork and submit pull requests (fixes and new features). We are looking for people helping us translating the bot. If you'd like to add a new language or fix errors in existed translations, feel free to catch us in the IRC channel #mumble, or just email us! The following people joined as collaborators for a faster development, big thanks to them: - @TerryGeng - @mertkutay Feel free to ask me if you want to help actively without using pull requests. ================================================ FILE: command.py ================================================ # coding=utf-8 import logging import secrets import datetime import json import re import pymumble_py3 as pymumble from constants import tr_cli as tr from constants import commands import interface import util import variables as var from pyradios import RadioBrowser from database import SettingsDatabase, MusicDatabase, Condition import media.playlist from media.item import item_id_generators, dict_to_item, dicts_to_items, ValidationFailedError from media.cache import get_cached_wrapper_from_scrap, get_cached_wrapper_by_id, get_cached_wrappers_by_tags, \ get_cached_wrapper, get_cached_wrappers, get_cached_wrapper_from_dict, get_cached_wrappers_from_dicts from media.url_from_playlist import get_playlist_info log = logging.getLogger("bot") def register_all_commands(bot): bot.register_command(commands('add_from_shortlist'), cmd_shortlist) bot.register_command(commands('add_tag'), cmd_add_tag) bot.register_command(commands('change_user_password'), cmd_user_password, no_partial_match=True) bot.register_command(commands('clear'), cmd_clear) bot.register_command(commands('current_music'), cmd_current_music) bot.register_command(commands('delete_from_library'), cmd_delete_from_library) bot.register_command(commands('ducking'), cmd_ducking) bot.register_command(commands('ducking_threshold'), cmd_ducking_threshold) bot.register_command(commands('ducking_volume'), cmd_ducking_volume) bot.register_command(commands('find_tagged'), cmd_find_tagged) bot.register_command(commands('help'), cmd_help, no_partial_match=False, access_outside_channel=True) bot.register_command(commands('joinme'), cmd_joinme, access_outside_channel=True) bot.register_command(commands('last'), cmd_last) bot.register_command(commands('list_file'), cmd_list_file) bot.register_command(commands('mode'), cmd_mode) bot.register_command(commands('pause'), cmd_pause) bot.register_command(commands('play'), cmd_play) bot.register_command(commands('play_file'), cmd_play_file) bot.register_command(commands('play_file_match'), cmd_play_file_match) bot.register_command(commands('play_playlist'), cmd_play_playlist) bot.register_command(commands('play_radio'), cmd_play_radio) bot.register_command(commands('play_tag'), cmd_play_tags) bot.register_command(commands('play_url'), cmd_play_url) bot.register_command(commands('queue'), cmd_queue) bot.register_command(commands('random'), cmd_random) bot.register_command(commands('rb_play'), cmd_rb_play) bot.register_command(commands('rb_query'), cmd_rb_query) bot.register_command(commands('remove'), cmd_remove) bot.register_command(commands('remove_tag'), cmd_remove_tag) bot.register_command(commands('repeat'), cmd_repeat) bot.register_command(commands('requests_webinterface_access'), cmd_web_access) bot.register_command(commands('rescan'), cmd_refresh_cache, no_partial_match=True) bot.register_command(commands('search'), cmd_search_library) bot.register_command(commands('skip'), cmd_skip) bot.register_command(commands('stop'), cmd_stop) bot.register_command(commands('stop_and_getout'), cmd_stop_and_getout) bot.register_command(commands('version'), cmd_version, no_partial_match=True) bot.register_command(commands('volume'), cmd_volume) bot.register_command(commands('yt_play'), cmd_yt_play) bot.register_command(commands('yt_search'), cmd_yt_search) # admin command bot.register_command(commands('add_webinterface_user'), cmd_web_user_add, admin=True) bot.register_command(commands('drop_database'), cmd_drop_database, no_partial_match=True, admin=True) bot.register_command(commands('kill'), cmd_kill, admin=True) bot.register_command(commands('list_webinterface_user'), cmd_web_user_list, admin=True) bot.register_command(commands('remove_webinterface_user'), cmd_web_user_remove, admin=True) bot.register_command(commands('max_volume'), cmd_max_volume, admin=True) bot.register_command(commands('update'), cmd_update, no_partial_match=True, admin=True) bot.register_command(commands('url_ban'), cmd_url_ban, no_partial_match=True, admin=True) bot.register_command(commands('url_ban_list'), cmd_url_ban_list, no_partial_match=True, admin=True) bot.register_command(commands('url_unban'), cmd_url_unban, no_partial_match=True, admin=True) bot.register_command(commands('url_unwhitelist'), cmd_url_unwhitelist, no_partial_match=True, admin=True) bot.register_command(commands('url_whitelist'), cmd_url_whitelist, no_partial_match=True, admin=True) bot.register_command(commands('url_whitelist_list'), cmd_url_whitelist_list, no_partial_match=True, admin=True) bot.register_command(commands('user_ban'), cmd_user_ban, no_partial_match=True, admin=True) bot.register_command(commands('user_unban'), cmd_user_unban, no_partial_match=True, admin=True) # Just for debug use bot.register_command('rtrms', cmd_real_time_rms, True) # bot.register_command('loop', cmd_loop_state, True) # bot.register_command('item', cmd_item, True) def send_multi_lines(bot, lines, text, linebreak="
"): global log msg = "" br = "" for newline in lines: msg += br br = linebreak if bot.mumble.get_max_message_length() \ and (len(msg) + len(newline)) > (bot.mumble.get_max_message_length() - 4): # 4 == len("
") bot.send_msg(msg, text) msg = "" msg += newline bot.send_msg(msg, text) def send_multi_lines_in_channel(bot, lines, linebreak="
"): global log msg = "" br = "" for newline in lines: msg += br br = linebreak if bot.mumble.get_max_message_length() \ and (len(msg) + len(newline)) > (bot.mumble.get_max_message_length() - 4): # 4 == len("
") bot.send_channel_msg(msg) msg = "" msg += newline bot.send_channel_msg(msg) def send_item_added_message(bot, wrapper, index, text): if index == var.playlist.current_index + 1: bot.send_msg(tr('file_added', item=wrapper.format_song_string()) + tr('position_in_the_queue', position=tr('next_to_play')), text) elif index == len(var.playlist) - 1: bot.send_msg(tr('file_added', item=wrapper.format_song_string()) + tr('position_in_the_queue', position=tr('last_song_on_the_queue')), text) else: bot.send_msg(tr('file_added', item=wrapper.format_song_string()) + tr('position_in_the_queue', position=f"{index + 1}/{len(var.playlist)}."), text) # ---------------- Variables ----------------- ITEMS_PER_PAGE = 50 song_shortlist = [] # ---------------- Commands ------------------ def cmd_joinme(bot, user, text, command, parameter): global log bot.mumble.users.myself.move_in( bot.mumble.users[text.actor]['channel_id'], token=parameter) def cmd_user_ban(bot, user, text, command, parameter): global log if parameter: var.db.set("user_ban", parameter, None) bot.send_msg(tr("user_ban_success", user=parameter), text) else: ban_list = "" bot.send_msg(tr("user_ban_list", list=ban_list), text) def cmd_user_unban(bot, user, text, command, parameter): global log if parameter and var.db.has_option("user_ban", parameter): var.db.remove_option("user_ban", parameter) bot.send_msg(tr("user_unban_success", user=parameter), text) def cmd_url_ban(bot, user, text, command, parameter): global log url = util.get_url_from_input(parameter) if url: _id = item_id_generators['url'](url=url) var.cache.free_and_delete(_id) var.playlist.remove_by_id(_id) else: if var.playlist.current_item() and var.playlist.current_item().type == 'url': item = var.playlist.current_item().item() url = item.url var.cache.free_and_delete(item.id) var.playlist.remove_by_id(item.id) else: bot.send_msg(tr('bad_parameter', command=command), text) return # Remove from the whitelist first if var.db.has_option('url_whitelist', url): var.db.remove_option("url_whitelist", url) bot.send_msg(tr("url_unwhitelist_success", url=url), text) if not var.db.has_option('url_ban', url): var.db.set("url_ban", url, None) bot.send_msg(tr("url_ban_success", url=url), text) def cmd_url_ban_list(bot, user, text, command, parameter): ban_list = "" bot.send_msg(tr("url_ban_list", list=ban_list), text) def cmd_url_unban(bot, user, text, command, parameter): url = util.get_url_from_input(parameter) if url: var.db.remove_option("url_ban", url) bot.send_msg(tr("url_unban_success", url=url), text) else: bot.send_msg(tr('bad_parameter', command=command), text) def cmd_url_whitelist(bot, user, text, command, parameter): url = util.get_url_from_input(parameter) if url: # Unban first if var.db.has_option('url_ban', url): var.db.remove_option("url_ban", url) bot.send_msg(tr("url_unban_success"), text) # Then add to whitelist if not var.db.has_option('url_whitelist', url): var.db.set("url_whitelist", url, None) bot.send_msg(tr("url_whitelist_success", url=url), text) else: bot.send_msg(tr('bad_parameter', command=command), text) def cmd_url_whitelist_list(bot, user, text, command, parameter): ban_list = "" bot.send_msg(tr("url_whitelist_list", list=ban_list), text) def cmd_url_unwhitelist(bot, user, text, command, parameter): url = util.get_url_from_input(parameter) if url: var.db.remove_option("url_whitelist", url) bot.send_msg(tr("url_unwhitelist_success"), text) else: bot.send_msg(tr('bad_parameter', command=command), text) def cmd_play(bot, user, text, command, parameter): global log params = parameter.split() index = -1 start_at = 0 if len(params) > 0: if params[0].isdigit() and 1 <= int(params[0]) <= len(var.playlist): index = int(params[0]) else: bot.send_msg(tr('invalid_index', index=parameter), text) return if len(params) > 1: try: start_at = util.parse_time(params[1]) except ValueError: bot.send_msg(tr('bad_parameter', command=command), text) return if len(var.playlist) > 0: if index != -1: bot.play(int(index) - 1, start_at) elif bot.is_pause: bot.resume() else: bot.send_msg(var.playlist.current_item().format_current_playing(), text) else: bot.is_pause = False bot.send_msg(tr('queue_empty'), text) def cmd_pause(bot, user, text, command, parameter): global log bot.pause() bot.send_channel_msg(tr('paused')) def cmd_play_file(bot, user, text, command, parameter, do_not_refresh_cache=False): global log, song_shortlist # assume parameter is a path music_wrappers = get_cached_wrappers_from_dicts(var.music_db.query_music(Condition().and_equal('path', parameter)), user) if music_wrappers: var.playlist.append(music_wrappers[0]) log.info("cmd: add to playlist: " + music_wrappers[0].format_debug_string()) send_item_added_message(bot, music_wrappers[0], len(var.playlist) - 1, text) return # assume parameter is a folder music_wrappers = get_cached_wrappers_from_dicts(var.music_db.query_music(Condition() .and_equal('type', 'file') .and_like('path', parameter + '%')), user) if music_wrappers: msgs = [tr('multiple_file_added')] for music_wrapper in music_wrappers: log.info("cmd: add to playlist: " + music_wrapper.format_debug_string()) msgs.append("{:s} ({:s})".format(music_wrapper.item().title, music_wrapper.item().path)) var.playlist.extend(music_wrappers) send_multi_lines_in_channel(bot, msgs) return # try to do a partial match matches = var.music_db.query_music(Condition() .and_equal('type', 'file') .and_like('path', '%' + parameter + '%', case_sensitive=False)) if len(matches) == 1: music_wrapper = get_cached_wrapper_from_dict(matches[0], user) var.playlist.append(music_wrapper) log.info("cmd: add to playlist: " + music_wrapper.format_debug_string()) send_item_added_message(bot, music_wrapper, len(var.playlist) - 1, text) return elif len(matches) > 1: song_shortlist = matches msgs = [tr('multiple_matches')] for index, match in enumerate(matches): msgs.append("{:d} - {:s} ({:s})".format( index + 1, match['title'], match['path'])) msgs.append(tr("shortlist_instruction")) send_multi_lines(bot, msgs, text) return if do_not_refresh_cache: bot.send_msg(tr("no_file"), text) else: var.cache.build_dir_cache() cmd_play_file(bot, user, text, command, parameter, do_not_refresh_cache=True) def cmd_play_file_match(bot, user, text, command, parameter, do_not_refresh_cache=False): global log if parameter: file_dicts = var.music_db.query_music(Condition().and_equal('type', 'file')) msgs = [tr('multiple_file_added') + "") var.playlist.extend(music_wrappers) send_multi_lines_in_channel(bot, msgs, "") else: if do_not_refresh_cache: bot.send_msg(tr("no_file"), text) else: var.cache.build_dir_cache() cmd_play_file_match(bot, user, text, command, parameter, do_not_refresh_cache=True) except re.error as e: msg = tr('wrong_pattern', error=str(e)) bot.send_msg(msg, text) else: bot.send_msg(tr('bad_parameter', command=command), text) def cmd_play_url(bot, user, text, command, parameter): global log url = util.get_url_from_input(parameter) if url: music_wrapper = get_cached_wrapper_from_scrap(type='url', url=url, user=user) var.playlist.append(music_wrapper) log.info("cmd: add to playlist: " + music_wrapper.format_debug_string()) send_item_added_message(bot, music_wrapper, len(var.playlist) - 1, text) if len(var.playlist) == 2: # If I am the second item on the playlist. (I am the next one!) bot.async_download_next() else: bot.send_msg(tr('bad_parameter', command=command), text) def cmd_play_playlist(bot, user, text, command, parameter): global log offset = 0 # if you want to start the playlist at a specific index try: offset = int(parameter.split(" ")[-1]) except ValueError: pass url = util.get_url_from_input(parameter) if url: log.debug(f"cmd: fetching media info from playlist url {url}") items = get_playlist_info(url=url, start_index=offset, user=user) if len(items) > 0: items = var.playlist.extend(list(map(lambda item: get_cached_wrapper_from_scrap(**item), items))) for music in items: log.info("cmd: add to playlist: " + music.format_debug_string()) else: bot.send_msg(tr("playlist_fetching_failed"), text) else: bot.send_msg(tr('bad_parameter', command=command), text) def cmd_play_radio(bot, user, text, command, parameter): global log if not parameter: all_radio = var.config.items('radio') msg = tr('preconfigurated_radio') for i in all_radio: comment = "" if len(i[1].split(maxsplit=1)) == 2: comment = " - " + i[1].split(maxsplit=1)[1] msg += "
" + i[0] + comment bot.send_msg(msg, text) else: if var.config.has_option('radio', parameter): parameter = var.config.get('radio', parameter) parameter = parameter.split()[0] url = util.get_url_from_input(parameter) if url: music_wrapper = get_cached_wrapper_from_scrap(type='radio', url=url, user=user) var.playlist.append(music_wrapper) log.info("cmd: add to playlist: " + music_wrapper.format_debug_string()) send_item_added_message(bot, music_wrapper, len(var.playlist) - 1, text) else: bot.send_msg(tr('bad_url'), text) def cmd_rb_query(bot, user, text, command, parameter): global log log.info('cmd: Querying radio stations') if not parameter: log.debug('rbquery without parameter') msg = tr('rb_query_empty') bot.send_msg(msg, text) else: log.debug('cmd: Found query parameter: ' + parameter) rb = RadioBrowser() rb_stations = rb.search(name=parameter, name_exact=False) msg = tr('rb_query_result') msg += '\n' if not rb_stations: log.debug(f"cmd: No matches found for rbquery {parameter}") bot.send_msg(f"Radio-Browser found no matches for {parameter}", text) else: for s in rb_stations: station_id = s['stationuuid'] station_name = s['name'] country = s['countrycode'] codec = s['codec'] bitrate = s['bitrate'] genre = s['tags'] msg += f"" msg += '
!rbplay IDStation NameGenreCodec/BitrateCountry
{station_id}{station_name}{genre}{codec}/{bitrate}{country}
' # Full message as html table if len(msg) <= 5000: bot.send_msg(msg, text) # Shorten message if message too long (stage I) else: log.debug('Result too long stage I') msg = tr('rb_query_result') + ' (shortened L1)' msg += '\n' for s in rb_stations: station_id = s['stationuuid'] station_name = s['name'] msg += f'' msg += '
!rbplay IDStation Name
{station_id}{station_name}
' if len(msg) <= 5000: bot.send_msg(msg, text) # Shorten message if message too long (stage II) else: log.debug('Result too long stage II') msg = tr('rb_query_result') + ' (shortened L2)' msg += '!rbplay ID - Station Name' for s in rb_stations: station_id = s['stationuuid'] station_name = s['name'][:12] msg += f'{station_id} - {station_name}' if len(msg) <= 5000: bot.send_msg(msg, text) # Message still too long else: bot.send_msg('Query result too long to post (> 5000 characters), please try another query.', text) def cmd_rb_play(bot, user, text, command, parameter): global log log.debug('cmd: Play a station by ID') if not parameter: log.debug('rbplay without parameter') msg = tr('rb_play_empty') bot.send_msg(msg, text) else: log.debug('cmd: Retreiving url for station ID ' + parameter) rb = RadioBrowser() rstation = rb.station_by_uuid(parameter) stationname = rstation[0]['name'] country = rstation[0]['countrycode'] codec = rstation[0]['codec'] bitrate = rstation[0]['bitrate'] genre = rstation[0]['tags'] homepage = rstation[0]['homepage'] url = rstation[0]['url'] msg = 'Radio station added to playlist:' msg += '' + \ f"
IDStation NameGenreCodec/BitrateCountryHomepage
{parameter}{stationname}{genre}{codec}/{bitrate}{country}{homepage}
" log.debug(f'cmd: Added station to playlist {stationname}') bot.send_msg(msg, text) if url != "-1": log.info('cmd: Found url: ' + url) music_wrapper = get_cached_wrapper_from_scrap(type='radio', url=url, name=stationname, user=user) var.playlist.append(music_wrapper) log.info("cmd: add to playlist: " + music_wrapper.format_debug_string()) bot.async_download_next() else: log.info('cmd: No playable url found.') msg += "No playable url found for this station, please try another station." bot.send_msg(msg, text) yt_last_result = [] yt_last_page = 0 # TODO: if we keep adding global variables, we need to consider sealing all commands up into classes. def cmd_yt_search(bot, user, text, command, parameter): global log, yt_last_result, yt_last_page, song_shortlist item_per_page = 5 if parameter: # if next page if parameter.startswith("-n"): yt_last_page += 1 if len(yt_last_result) > yt_last_page * item_per_page: song_shortlist = [{'type': 'url', 'url': "https://www.youtube.com/watch?v=" + result[0], 'title': result[1] } for result in yt_last_result[yt_last_page * item_per_page: (yt_last_page * item_per_page) + item_per_page]] msg = _yt_format_result(yt_last_result, yt_last_page * item_per_page, item_per_page) bot.send_msg(tr('yt_result', result_table=msg), text) else: bot.send_msg(tr('yt_no_more'), text) # if query else: results = util.youtube_search(parameter) if results: yt_last_result = results yt_last_page = 0 song_shortlist = [{'type': 'url', 'url': "https://www.youtube.com/watch?v=" + result[0]} for result in results[0: item_per_page]] msg = _yt_format_result(results, 0, item_per_page) bot.send_msg(tr('yt_result', result_table=msg), text) else: bot.send_msg(tr('yt_query_error'), text) else: bot.send_msg(tr('bad_parameter', command=command), text) def _yt_format_result(results, start, count): msg = '' for index, item in enumerate(results[start:start + count]): msg += ''.format( index=index + 1, title=item[1], uploader=item[2]) msg += '
IndexTitleUploader
{index:d}{title}{uploader}
' return msg def cmd_yt_play(bot, user, text, command, parameter): global log, yt_last_result, yt_last_page if parameter: results = util.youtube_search(parameter) if results: yt_last_result = results yt_last_page = 0 url = "https://www.youtube.com/watch?v=" + yt_last_result[0][0] cmd_play_url(bot, user, text, command, url) else: bot.send_msg(tr('yt_query_error'), text) else: bot.send_msg(tr('bad_parameter', command=command), text) def cmd_help(bot, user, text, command, parameter): global log bot.send_msg(tr('help'), text) if bot.is_admin(user): bot.send_msg(tr('admin_help'), text) def cmd_stop(bot, user, text, command, parameter): global log if var.config.getboolean("bot", "clear_when_stop_in_oneshot") \ and var.playlist.mode == 'one-shot': cmd_clear(bot, user, text, command, parameter) else: bot.stop() bot.send_msg(tr('stopped'), text) def cmd_clear(bot, user, text, command, parameter): global log bot.clear() bot.send_msg(tr('cleared'), text) def cmd_kill(bot, user, text, command, parameter): global log bot.pause() bot.exit = True def cmd_update(bot, user, text, command, parameter): global log if bot.is_admin(user): bot.mumble.users[text.actor].send_text_message( tr('start_updating')) msg = util.update(bot.version) bot.mumble.users[text.actor].send_text_message(msg) else: bot.mumble.users[text.actor].send_text_message( tr('not_admin')) def cmd_stop_and_getout(bot, user, text, command, parameter): global log bot.stop() if var.playlist.mode == "one-shot": var.playlist.clear() bot.join_channel() def cmd_volume(bot, user, text, command, parameter): global log # The volume is a percentage max_vol = min(int(var.config.getfloat('bot', 'max_volume') * 100), 100.0) if var.db.has_option('bot', 'max_volume'): max_vol = float(var.db.get('bot', 'max_volume')) * 100.0 if parameter and parameter.isdigit() and 0 <= int(parameter) <= 100: if int(parameter) <= max_vol: vol = int(parameter) bot.send_msg(tr('change_volume', volume=int(parameter), user=bot.mumble.users[text.actor]['name']), text) else: vol = max_vol bot.send_msg(tr('max_volume', max=int(vol)), text) bot.volume_helper.set_volume(float(vol) / 100.0) var.db.set('bot', 'volume', str(float(vol) / 100.0)) log.info(f'cmd: volume set to {float(vol) / 100.0}') else: bot.send_msg(tr('current_volume', volume=int(bot.volume_helper.plain_volume_set * 100)), text) def cmd_max_volume(bot, user, text, command, parameter): global log if parameter and parameter.isdigit() and 0 <= int(parameter) <= 100: max_vol = float(parameter) / 100.0 var.db.set('bot', 'max_volume', float(parameter) / 100.0) bot.send_msg(tr('change_max_volume', max=parameter, user=bot.mumble.users[text.actor]['name']), text) if int(bot.volume_helper.plain_volume_set) > max_vol: bot.volume_helper.set_volume(max_vol) log.info(f'cmd: max volume set to {max_vol}') else: max_vol = var.config.getfloat('bot', 'max_volume') * 100.0 if var.db.has_option('bot', 'max_volume'): max_vol = var.db.getfloat('bot', 'max_volume') * 100.0 bot.send_msg(tr('current_max_volume', max=int(max_vol)), text) def cmd_ducking(bot, user, text, command, parameter): global log if parameter == "" or parameter == "on": bot.is_ducking = True var.db.set('bot', 'ducking', True) bot.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_SOUNDRECEIVED, bot.ducking_sound_received) bot.mumble.set_receive_sound(True) log.info('cmd: ducking is on') msg = "Ducking on." bot.send_msg(msg, text) elif parameter == "off": bot.is_ducking = False bot.mumble.set_receive_sound(False) var.db.set('bot', 'ducking', False) msg = "Ducking off." log.info('cmd: ducking is off') bot.send_msg(msg, text) def cmd_ducking_threshold(bot, user, text, command, parameter): global log if parameter and parameter.isdigit(): bot.ducking_threshold = int(parameter) var.db.set('bot', 'ducking_threshold', str(bot.ducking_threshold)) msg = f"Ducking threshold set to {bot.ducking_threshold}." bot.send_msg(msg, text) else: msg = f"Current ducking threshold is {bot.ducking_threshold}." bot.send_msg(msg, text) def cmd_ducking_volume(bot, user, text, command, parameter): global log # The volume is a percentage if parameter and parameter.isdigit() and 0 <= int(parameter) <= 100: bot.volume_helper.set_ducking_volume(float(parameter) / 100.0) bot.send_msg(tr('change_ducking_volume', volume=parameter, user=bot.mumble.users[text.actor]['name']), text) var.db.set('bot', 'ducking_volume', float(parameter) / 100.0) log.info(f'cmd: volume on ducking set to {parameter}') else: bot.send_msg(tr('current_ducking_volume', volume=int(bot.volume_helper.plain_ducking_volume_set * 100)), text) def cmd_current_music(bot, user, text, command, parameter): global log if len(var.playlist) > 0: bot.send_msg(var.playlist.current_item().format_current_playing(), text) else: bot.send_msg(tr('not_playing'), text) def cmd_skip(bot, user, text, command, parameter): global log if not bot.is_pause: bot.interrupt() else: var.playlist.next() bot.wait_for_ready = True if len(var.playlist) == 0: bot.send_msg(tr('queue_empty'), text) def cmd_last(bot, user, text, command, parameter): global log if len(var.playlist) > 0: bot.interrupt() var.playlist.point_to(len(var.playlist) - 1 - 1) else: bot.send_msg(tr('queue_empty'), text) def cmd_remove(bot, user, text, command, parameter): global log # Allow to remove specific music into the queue with a number if parameter and parameter.isdigit() and 0 < int(parameter) <= len(var.playlist): index = int(parameter) - 1 if index == var.playlist.current_index: removed = var.playlist[index] bot.send_msg(tr('removing_item', item=removed.format_title()), text) log.info("cmd: delete from playlist: " + removed.format_debug_string()) var.playlist.remove(index) if index < len(var.playlist): if not bot.is_pause: bot.interrupt() var.playlist.current_index -= 1 # then the bot will move to next item else: # if item deleted is the last item of the queue var.playlist.current_index -= 1 if not bot.is_pause: bot.interrupt() else: var.playlist.remove(index) else: bot.send_msg(tr('bad_parameter', command=command), text) def cmd_list_file(bot, user, text, command, parameter): global song_shortlist files = var.music_db.query_music(Condition() .and_equal('type', 'file') .order_by('path')) song_shortlist = files msgs = [tr("multiple_file_found") + "") if count > ITEMS_PER_PAGE: msgs.append(tr("records_omitted")) msgs.append(tr("shortlist_instruction")) send_multi_lines(bot, msgs, text, "") else: bot.send_msg(tr("no_file"), text) except re.error as e: msg = tr('wrong_pattern', error=str(e)) bot.send_msg(msg, text) def cmd_queue(bot, user, text, command, parameter): global log if len(var.playlist) == 0: msg = tr('queue_empty') bot.send_msg(msg, text) else: msgs = [tr('queue_contents')] for i, music in enumerate(var.playlist): tags = '' if len(music.item().tags) > 0: tags = "{}".format(", ".join(music.item().tags)) if i == var.playlist.current_index: newline = "{} ({}) {} {}".format(i + 1, music.display_type(), music.format_title(), tags) else: newline = '{} ({}) {} {}'.format(i + 1, music.display_type(), music.format_title(), tags) msgs.append(newline) send_multi_lines(bot, msgs, text) def cmd_random(bot, user, text, command, parameter): global log bot.interrupt() var.playlist.randomize() def cmd_repeat(bot, user, text, command, parameter): global log repeat = 1 if parameter and parameter.isdigit(): repeat = int(parameter) music = var.playlist.current_item() if music: for _ in range(repeat): var.playlist.insert( var.playlist.current_index + 1, music ) log.info("bot: add to playlist: " + music.format_debug_string()) bot.send_channel_msg(tr("repeat", song=music.format_song_string(), n=str(repeat))) else: bot.send_msg(tr("queue_empty"), text) def cmd_mode(bot, user, text, command, parameter): global log if not parameter: bot.send_msg(tr("current_mode", mode=var.playlist.mode), text) return if parameter not in ["one-shot", "repeat", "random", "autoplay"]: bot.send_msg(tr('unknown_mode', mode=parameter), text) else: var.db.set('playlist', 'playback_mode', parameter) var.playlist = media.playlist.get_playlist(parameter, var.playlist) log.info(f"command: playback mode changed to {parameter}.") bot.send_msg(tr("change_mode", mode=var.playlist.mode, user=bot.mumble.users[text.actor]['name']), text) if parameter == "random": bot.interrupt() def cmd_play_tags(bot, user, text, command, parameter): if not parameter: bot.send_msg(tr('bad_parameter', command=command), text) return msgs = [tr('multiple_file_added') + "") var.playlist.extend(music_wrappers) send_multi_lines_in_channel(bot, msgs, "") else: bot.send_msg(tr("no_file"), text) def cmd_add_tag(bot, user, text, command, parameter): global log params = parameter.split(" ", 1) index = 0 tags = [] if len(params) == 2 and params[0].isdigit(): index = params[0] tags = list(map(lambda t: t.strip(), params[1].split(","))) elif len(params) == 2 and params[0] == "*": index = "*" tags = list(map(lambda t: t.strip(), params[1].split(","))) else: index = str(var.playlist.current_index + 1) tags = list(map(lambda t: t.strip(), parameter.split(","))) if tags[0]: if index.isdigit() and 1 <= int(index) <= len(var.playlist): var.playlist[int(index) - 1].add_tags(tags) log.info(f"cmd: add tags {', '.join(tags)} to song {var.playlist[int(index) - 1].format_debug_string()}") bot.send_msg(tr("added_tags", tags=", ".join(tags), song=var.playlist[int(index) - 1].format_title()), text) return elif index == "*": for item in var.playlist: item.add_tags(tags) log.info(f"cmd: add tags {', '.join(tags)} to song {item.format_debug_string()}") bot.send_msg(tr("added_tags_to_all", tags=", ".join(tags)), text) return bot.send_msg(tr('bad_parameter', command=command), text) def cmd_remove_tag(bot, user, text, command, parameter): global log params = parameter.split(" ", 1) index = 0 tags = [] if len(params) == 2 and params[0].isdigit(): index = params[0] tags = list(map(lambda t: t.strip(), params[1].split(","))) elif len(params) == 2 and params[0] == "*": index = "*" tags = list(map(lambda t: t.strip(), params[1].split(","))) else: index = str(var.playlist.current_index + 1) tags = list(map(lambda t: t.strip(), parameter.split(","))) if tags[0]: if index.isdigit() and 1 <= int(index) <= len(var.playlist): if tags[0] != "*": var.playlist[int(index) - 1].remove_tags(tags) log.info(f"cmd: remove tags {', '.join(tags)} from song {var.playlist[int(index) - 1].format_debug_string()}") bot.send_msg(tr("removed_tags", tags=", ".join(tags), song=var.playlist[int(index) - 1].format_title()), text) return else: var.playlist[int(index) - 1].clear_tags() log.info(f"cmd: clear tags from song {var.playlist[int(index) - 1].format_debug_string()}") bot.send_msg(tr("cleared_tags", song=var.playlist[int(index) - 1].format_title()), text) return elif index == "*": if tags[0] != "*": for item in var.playlist: item.remove_tags(tags) log.info(f"cmd: remove tags {', '.join(tags)} from song {item.format_debug_string()}") bot.send_msg(tr("removed_tags_from_all", tags=", ".join(tags)), text) return else: for item in var.playlist: item.clear_tags() log.info(f"cmd: clear tags from song {item.format_debug_string()}") bot.send_msg(tr("cleared_tags_from_all"), text) return bot.send_msg(tr('bad_parameter', command=command), text) def cmd_find_tagged(bot, user, text, command, parameter): global song_shortlist if not parameter: bot.send_msg(tr('bad_parameter', command=command), text) return msgs = [tr('multiple_file_found') + "") if count > ITEMS_PER_PAGE: msgs.append(tr("records_omitted")) msgs.append(tr("shortlist_instruction")) send_multi_lines(bot, msgs, text, "") else: bot.send_msg(tr("no_file"), text) def cmd_search_library(bot, user, text, command, parameter): global song_shortlist if not parameter: bot.send_msg(tr('bad_parameter', command=command), text) return msgs = [tr('multiple_file_found') + "") if count > ITEMS_PER_PAGE: msgs.append(tr("records_omitted")) msgs.append(tr("shortlist_instruction")) send_multi_lines(bot, msgs, text, "") else: bot.send_msg(tr("no_file"), text) else: bot.send_msg(tr("no_file"), text) def cmd_shortlist(bot, user, text, command, parameter): global song_shortlist, log if parameter.strip() == "*": msgs = [tr('multiple_file_added') + "") send_multi_lines_in_channel(bot, msgs, "") return try: indexes = [int(i) for i in parameter.split(" ")] except ValueError: bot.send_msg(tr('bad_parameter', command=command), text) return if len(indexes) > 1: msgs = [tr('multiple_file_added') + "") send_multi_lines_in_channel(bot, msgs, "") return elif len(indexes) == 1: index = indexes[0] if 1 <= index <= len(song_shortlist): kwargs = song_shortlist[index - 1] kwargs['user'] = user music_wrapper = get_cached_wrapper_from_scrap(**kwargs) var.playlist.append(music_wrapper) log.info("cmd: add to playlist: " + music_wrapper.format_debug_string()) send_item_added_message(bot, music_wrapper, len(var.playlist) - 1, text) return bot.send_msg(tr('bad_parameter', command=command), text) def cmd_delete_from_library(bot, user, text, command, parameter): global song_shortlist, log if not var.config.getboolean("bot", "delete_allowed"): bot.mumble.users[text.actor].send_text_message(tr('not_admin')) return try: indexes = [int(i) for i in parameter.split(" ")] except ValueError: bot.send_msg(tr('bad_parameter', command=command), text) return if len(indexes) > 1: msgs = [tr('multiple_file_added') + "") send_multi_lines_in_channel(bot, msgs, "") return elif len(indexes) == 1: index = indexes[0] if 1 <= index <= len(song_shortlist): music_dict = song_shortlist[index - 1] if 'id' in music_dict: music_wrapper = get_cached_wrapper_by_id(music_dict['id'], user) bot.send_msg(tr('file_deleted', item=music_wrapper.format_song_string()), text) log.info("cmd: remove from library: " + music_wrapper.format_debug_string()) var.playlist.remove_by_id(music_dict['id']) var.cache.free_and_delete(music_dict['id']) return bot.send_msg(tr('bad_parameter', command=command), text) def cmd_drop_database(bot, user, text, command, parameter): global log if bot.is_admin(user): var.db.drop_table() var.db = SettingsDatabase(var.settings_db_path) var.music_db.drop_table() var.music_db = MusicDatabase(var.settings_db_path) log.info("command: database dropped.") bot.send_msg(tr('database_dropped'), text) else: bot.mumble.users[text.actor].send_text_message(tr('not_admin')) def cmd_refresh_cache(bot, user, text, command, parameter): global log if bot.is_admin(user): var.cache.build_dir_cache() log.info("command: Local file cache refreshed.") bot.send_msg(tr('cache_refreshed'), text) else: bot.mumble.users[text.actor].send_text_message(tr('not_admin')) def cmd_web_access(bot, user, text, command, parameter): auth_method = var.config.get("webinterface", "auth_method") if auth_method == 'token': interface.banned_ip = [] interface.bad_access_count = {} user_info = var.db.get("user", user, fallback='{}') user_dict = json.loads(user_info) if 'token' in user_dict: var.db.remove_option("web_token", user_dict['token']) token = secrets.token_urlsafe(5) user_dict['token'] = token user_dict['token_created'] = str(datetime.datetime.now()) user_dict['last_ip'] = '' var.db.set("web_token", token, user) var.db.set("user", user, json.dumps(user_dict)) access_address = var.config.get("webinterface", "access_address") + "/?token=" + token else: access_address = var.config.get("webinterface", "access_address") bot.send_msg(tr('webpage_address', address=access_address), text) def cmd_user_password(bot, user, text, command, parameter): if not parameter: bot.send_msg(tr('bad_parameter', command=command), text) return user_info = var.db.get("user", user, fallback='{}') user_dict = json.loads(user_info) user_dict['password'], user_dict['salt'] = util.get_salted_password_hash(parameter) var.db.set("user", user, json.dumps(user_dict)) bot.send_msg(tr('user_password_set'), text) def cmd_web_user_add(bot, user, text, command, parameter): if not parameter: bot.send_msg(tr('bad_parameter', command=command), text) return auth_method = var.config.get("webinterface", "auth_method") if auth_method == 'password': web_users = json.loads(var.db.get("privilege", "web_access", fallback='[]')) if parameter not in web_users: web_users.append(parameter) var.db.set("privilege", "web_access", json.dumps(web_users)) bot.send_msg(tr('web_user_list', users=", ".join(web_users)), text) else: bot.send_msg(tr('command_disabled', command=command), text) def cmd_web_user_remove(bot, user, text, command, parameter): if not parameter: bot.send_msg(tr('bad_parameter', command=command), text) return auth_method = var.config.get("webinterface", "auth_method") if auth_method == 'password': web_users = json.loads(var.db.get("privilege", "web_access", fallback='[]')) if parameter in web_users: web_users.remove(parameter) var.db.set("privilege", "web_access", json.dumps(web_users)) bot.send_msg(tr('web_user_list', users=", ".join(web_users)), text) else: bot.send_msg(tr('command_disabled', command=command), text) def cmd_web_user_list(bot, user, text, command, parameter): auth_method = var.config.get("webinterface", "auth_method") if auth_method == 'password': web_users = json.loads(var.db.get("privilege", "web_access", fallback='[]')) bot.send_msg(tr('web_user_list', users=", ".join(web_users)), text) else: bot.send_msg(tr('command_disabled', command=command), text) def cmd_version(bot, user, text, command, parameter): bot.send_msg(tr('report_version', version=bot.get_version()), text) # Just for debug use def cmd_real_time_rms(bot, user, text, command, parameter): bot._display_rms = not bot._display_rms def cmd_loop_state(bot, user, text, command, parameter): print(bot._loop_status) def cmd_item(bot, user, text, command, parameter): var.playlist._debug_print() ================================================ FILE: configuration.default.ini ================================================ # ======================================================== # botamusique Default Configuration File # Version 6 # ======================================================== # WARNING: # ****************************** # ** DO NOT MODIFY THIS FILE. ** # ****************************** # # Please create your own configuration file, and # ONLY ADD ITEMS YOU WANT TO MODIFY into it. Other # items will be loaded from this file automatically. # DO NOT DIRECTLY COPY THIS FILE. # # That is because this file will be overridden # during updates. New options will be added and # old options (like [strings]) will be updated. # ======================================================== [server] certificate = channel = host = 127.0.0.1 password = port = 64738 tokens = [bot] admin = allow_other_channel_message = False allow_private_message = True announce_current_music = True auto_check_update = True autoplay_length = 5 avatar = bandwidth = 96000 clear_when_stop_in_oneshot = False comment = "Hi, I'm here to play radio, local music or youtube/soundcloud music. Have fun!" database_path = delete_allowed = True download_attempts = 2 ducking = False ducking_threshold = 3000 ducking_volume = 0.05 ignored_files = Thumbs.db ignored_folders = tmp language = en_US logfile = max_track_duration = 60 max_track_playlist = 20 max_volume = 1.0 music_database_path = music.db music_folder = music_folder/ pip3_path = venv/bin/pip playback_mode = one-shot redirect_stderr = True refresh_cache_on_startup = True save_music_library = True save_playlist = True stereo = True target_version = git tmp_folder = /tmp/ tmp_folder_max_size = 10 username = botamusique volume = 0.8 when_nobody_in_channel = nothing when_nobody_in_channel_ignore = [webinterface] access_address = http://127.0.0.1:8181 auth_method = none enabled = False flask_secret = ChangeThisPassword is_web_proxified = True listening_addr = 127.0.0.1 listening_port = 8181 max_attempts = 10 max_upload_file_size = 30M password = upload_enabled = True user = web_logfile = [debug] ffmpeg = False mumble_connection = False redirect_ffmpeg_log = False youtube_dl = False [radio] ponyville = http://192.99.131.205:8000/stream.mp3 "Here a command of !radio comment" luna = http://radio.ponyvillelive.com:8002/stream "calm and orchestra music" radiobrony = http://62.210.138.34:8000/live "Brony music of a friend" jazz = http://jazz-wr04.ice.infomaniak.ch/jazz-wr04-128.mp3 "Jazz Yeah !" [youtube_dl] cookie_file = source_address = user_agent = [commands] add_from_shortlist = shortlist, sl add_tag = addtag add_webinterface_user = webuseradd change_user_password = password clear = clear command_symbol = !:! current_music = np, now delete_from_library = delete drop_database = dropdatabase ducking = duck ducking_threshold = duckthres ducking_volume = duckv find_tagged = findtagged, ft help = help joinme = joinme kill = kill last = last list_file = listfile list_webinterface_user = webuserlist max_volume = maxvolume mode = mode pause = pause play = p, play play_file = file, f play_file_match = filematch, fm play_playlist = playlist play_radio = radio play_tag = tag play_url = url queue = queue random = random rb_play = rbplay rb_query = rbquery remove = rm remove_tag = untag remove_webinterface_user = webuserdel repeat = repeat requests_webinterface_access = web rescan = rescan search = search skip = skip split_username_at_space = False stop = stop stop_and_getout = oust update = update url_ban = urlban url_ban_list = urlbanlist url_unban = urlunban url_unwhitelist = urlunwhitelist, urlunw url_whitelist = urlwhitelist, urlw url_whitelist_list = urlwhitelistlist, urlwls user_ban = userban user_unban = userunban version = version volume = volume yt_play = yplay yt_search = ysearch ================================================ FILE: configuration.example.ini ================================================ # ======================================================== # botamusique example configuration file # Version 6 # ======================================================== # Rename this file to configuration.ini after editing. # Uncomment lines you'd like to change, and carefully # follow the instructions. # ======================================================== # The [server] section tells the bot how to connect to your Murmur server. # This section will be overridden by command line arguments. [server] host = 127.0.0.1 port = 64738 #password = #channel = #tokens = token1,token2 #certificate = # The [bot] section stores some basic settings for the bot. [bot] # 'username': The bot's username. # 'comment': Comment displayed on the bot's profile. # 'avatar': Path to an image used for the bot's avatar (PNG recommended, 128 KB max). #username = botamusique #comment = "Hi, I'm here to play radio, local music or youtube/soundcloud music. Have fun!" #avatar = # 'language': Language to use; available languages can be found inside # the lang/ folder. #language=en_US # 'music_folder': Folder that stores your local songs. #music_folder = music_folder/ # 'database_path': The path of the database, which stores things like your # volume set by the !volume command, your playback mode and your playlist, # banned URLs, etc. # This option will be overridden by command line arguments. # 'music_database_path': The path of the database that stores the music library. # Can be disabled by setting 'save_music_library = False' #database_path=settings.db #music_database_path=music.db # 'admin': List of users allowed to kill the bot, or ban URLs. # Separated by ';'. #admin = User1;User2; # 'stereo': Enable stereo stream transmission, supported since Mumble 1.4.0. # If this is not enabled, the bot will downgrade stereo sound into mono. #stereo = True # 'volume': The default volume, a number from 0 to 1. # This option will be overridden by the value set in the database. #volume = 0.1 # 'bandwidth': The number of bits per second used by the bot when streaming audio. # Enabling this option will allow you to set it higher than the default value. # If the given value exceeds the server's bitrate, the bitrate used by the bot # will match the server's. #bandwidth = 200000 # 'playback_mode': The playback mode of the bot. It should be one of the below: # one-shot: remove item once it has finished playing # repeat: repeat the playlist # random: randomize the order of the playlist # autoplay: randomly pick a track from the music library # This option will be overridden by the value set in the database. # 'autoplay_length': How many songs to fill the playlist with in autoplay mode. # 'clear_when_stop_in_oneshot': Whether to clear the playlist when stopping the # bot in one-shot mode. #playback_mode = one-shot #autoplay_length = 5 #clear_when_stop_in_oneshot = False # 'target_version': version to fetch when updating: # stable: use the curl command to get stable releases # testing: follow git master branch using the git command #target_version = stable # 'tmp_folder': Folder that music will be downloaded into. # 'tmp_folder_max_size': Maximum size of tmp_folder in MB, or 0 to not cache # at all, or -1 for unlimited size # 'ignored_files', 'ignored_folders': Files and folders to ignore during scanning. #tmp_folder = /tmp/ #tmp_folder_max_size = 10 #ignored_folders = tmp #ignored_files = Thumbs.db # 'download_attempts': How many times to attempt a download. #download_attempts = 2 # 'auto_check_update': Whether to check for updates every time the bot starts, # and post the changelog after an update was applied. #auto_check_update = True #pip3_path = venv/bin/pip # 'logfile': File to write log messages to. # 'redirect_stderr': Whether to capture outputs from standard error and write # it into the log file. Useful for capturing an exception message when the # bot crashes. #logfile = #redirect_stderr = False #announce_current_music = True #allow_other_channel_message = False #allow_private_message = True # 'delete_allowed': Whether to allow admins to delete a file from the library # stored on disk. Works for both command and web interfaces. #delete_allowed = True # 'save_music_library': Whether to save music metadata to the database. #save_music_library = True # 'refresh_cache_on_startup': Whether to refresh the music directory's cache when # starting up. Metadata from each file will not be refreshed. If this is False, # the cache from last time will be used. #refresh_cache_on_startup = True # 'save_playlist': Whether to save the current playlist before quitting, so that # it may be reloaded next time. To use this, save_music_library must be True. #save_playlist = True # 'max_volume': Maximum volume users are allowed to set. # Number between 0.0 - 1.0. #max_volume = 0.8 # 'max_track_playlist': The maximum amount of tracks allowed in a playlist. #max_track_playlist = 20 # 'max_track_duration': Maximum track duration in minutes. #max_track_duration = 60 # 'ducking': Whether to lower music volume when someone is talking. #ducking = False #ducking_volume = 0.05 #ducking_threshold = 3000 # 'when_nobody_in_channel': Behaviour of the bot when nobody is in the channel. # Has to be one of: # pause: pause the current track # pause_resume: pause the current track and resume it once someone joins # stop: stop the bot, clearing its playlist # Or you can leave it empty to take no action. #when_nobody_in_channel = # 'when_nobody_in_channel_ignore': List of users that should be ignored. # This is typically used when other bots are present in the channel. #when_nobody_in_channel_ignore = # 'youtube_query_cookie': Sometimes YouTube will block the bot's request and ask # the bot to complete a captcha to verify the request is made by a human. This # can be solved if the bot has a valid cookie. If the bot complains "unable to # query youtube", you should provide a value here. #youtube_query_cookie = {"CONSENT": "paste your CONSENT cookie value here"} # The [webinterface] section stores settings related to the web interface. [webinterface] # 'enabled': Whether to enable the web interface to allow managing your playlist, # uploading tracks, etc. # The web interface is disabled by default for security and performance reasons. # 'access_address': URL provided to users when the public URL for the # web interface is requested. #enabled = False #listening_addr = 127.0.0.1 #listening_port = 8181 #is_web_proxified = True #access_address = http://127.0.0.1:8181 # 'web_logfile': If this is provided, web server access logs are written to this file. #web_logfile = # 'auth_method': Method used to authenticate users accessing the web interface. # One of 'none', 'password' or 'token'. If this is set to 'token', a unique token # is used for authentication. # 'max_attempts': Amount of incorrect login attempts needed before being banned. # Regenerating a token or rebooting the bot will reset this number. #auth_method = token #max_attempts = 10 # 'user', 'password': If auth_method is set to 'password', you'll need to set # the default username and password, which is set by these two options. # You can add more users using the '!webadduser' command. #user = botamusique #password = mumble # 'flask_secret': To use a token, Flask needs a password to encrypt/sign cookies. # This is absolutely necessary if auth_method is 'token'! #flask_secret = ChangeThisPassword # 'upload_enabled': Whether to enable the upload function of the web interface. # If this is False, only admins can upload files. # 'maximum_upload_file_size': Maximum file size allowed for uploads. # Can be specified in B, KB, MB, GB, or TB. #upload_enabled = True #max_upload_file_size = 30MB # The [debug] section contains settings to enable debugging messaages. [debug] # 'ffmpeg': Whether to display debug messages from ffmpeg. # 'mumble_connection': Whether to display debug messages for the # connection to the Mumble server (from the pymumble library). # 'youtube_dl': Whether to display debug messages from youtube-dl. #ffmpeg = False #mumble_connection = False #youtube_dl = False # The [radio] section contains a list of default radio stations. [radio] # List of radio stations you want to have by default, one entry per line. #jazz = http://jazz-wr04.ice.infomaniak.ch/jazz-wr04-128.mp3 "Jazz Yeah !" # The optional [youtube_dl] section contains options to customize youtube-dl [youtube_dl] # 'source_address': Set to '::' to force ipv6, "0.0.0.0" to force ipv4, # or else put the IP address you want to use here. # 'cookie_file': Path of the cookie file to use, useful if you are being rate limited: # # 'user_agent': Set the User-Agent header when making requests to youtube.com. # source_address = '::' # cookie_file = /tmp/youtube-dl-cookie # user-agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:87.0) Gecko/20100101 Firefox/87.0" # The [commands] section contains settings related to user commands sent via # Mumble text messages. [commands] # 'command_symbol': List of characters recognized as a command prefix. # 'split_username_at_space': Whether usernames should be split by a space, # in case you use these kinds of Mumo plugins: # #split_username_at_space = False #command_symbol = !:! # You may also customize commands recognized by the bot. For a full list of commands, # see configuration.default.ini. Copy options you want to edit into this file. #play_file = file, f #play_file_match = filematch, fm ================================================ FILE: constants.py ================================================ import os import json import variables as var default_lang_dict = {} lang_dict = {} def load_lang(lang): global lang_dict, default_lang_dict root_dir = os.path.dirname(__file__) with open(os.path.join(root_dir, "lang/en_US.json"), "r") as f: default_lang_dict = json.load(f) with open(os.path.join(root_dir, f"lang/{lang}.json"), "r") as f: lang_dict = json.load(f) def tr_cli(option, *argv, **kwargs): try: if option in lang_dict['cli'] and lang_dict['cli'][option]: string = lang_dict['cli'][option] else: string = default_lang_dict['cli'][option] except KeyError: raise KeyError("Missed strings in language file: '{string}'. ".format(string=option)) return _tr(string, *argv, **kwargs) def tr_web(option, *argv, **kwargs): try: if option in lang_dict['web'] and lang_dict['web'][option]: string = lang_dict['web'][option] else: string = default_lang_dict['web'][option] except KeyError: raise KeyError("Missed strings in language file: '{string}'. ".format(string=option)) return _tr(string, *argv, **kwargs) def _tr(string, *argv, **kwargs): if argv or kwargs: try: formatted = string.format(*argv, **kwargs) return formatted except KeyError as e: raise KeyError( "Missed/Unexpected placeholder {{{placeholder}}} in string " "'{string}'. ".format(placeholder=str(e).strip("'"), string=string)) except TypeError: raise KeyError( "Missed placeholder in string '{string}'. ".format(string=string)) else: return string def commands(command): try: string = var.config.get("commands", command) return string except KeyError: raise KeyError("Missed command in configuration file: '{string}'. ".format(string=command)) ================================================ FILE: database.py ================================================ import os import re import sqlite3 import json import datetime import time import logging log = logging.getLogger("bot") class DatabaseError(Exception): pass class Condition: def __init__(self): self.filler = [] self._sql = "" self._limit = 0 self._offset = 0 self._order_by = "" self._desc = "" self.has_regex = False pass def sql(self, conn: sqlite3.Connection = None): sql = self._sql if not self._sql: sql = "1" if self._order_by: sql += f" ORDER BY {self._order_by}" if self._desc: sql += " DESC" if self._limit: sql += f" LIMIT {self._limit}" if self._offset: sql += f" OFFSET {self._offset}" if self.has_regex and conn: conn.create_function("REGEXP", 2, self._regexp) return sql @staticmethod def _regexp(expr, item): if not item: return False reg = re.compile(expr) return reg.search(item) is not None def or_equal(self, column, equals_to, case_sensitive=True): if not case_sensitive: column = f"LOWER({column})" equals_to = equals_to.lower() if self._sql: self._sql += f" OR {column}=?" else: self._sql += f"{column}=?" self.filler.append(equals_to) return self def and_equal(self, column, equals_to, case_sensitive=True): if not case_sensitive: column = f"LOWER({column})" equals_to = equals_to.lower() if self._sql: self._sql += f" AND {column}=?" else: self._sql += f"{column}=?" self.filler.append(equals_to) return self def or_like(self, column, equals_to, case_sensitive=True): if not case_sensitive: column = f"LOWER({column})" equals_to = equals_to.lower() if self._sql: self._sql += f" OR {column} LIKE ?" else: self._sql += f"{column} LIKE ?" self.filler.append(equals_to) return self def and_like(self, column, equals_to, case_sensitive=True): if not case_sensitive: column = f"LOWER({column})" equals_to = equals_to.lower() if self._sql: self._sql += f" AND {column} LIKE ?" else: self._sql += f"{column} LIKE ?" self.filler.append(equals_to) return self def and_regexp(self, column, regex): self.has_regex = True if self._sql: self._sql += f" AND {column} REGEXP ?" else: self._sql += f"{column} REGEXP ?" self.filler.append(regex) return self def or_regexp(self, column, regex): self.has_regex = True if self._sql: self._sql += f" OR {column} REGEXP ?" else: self._sql += f"{column} REGEXP ?" self.filler.append(regex) return self def or_sub_condition(self, sub_condition): if sub_condition.has_regex: self.has_regex = True self.filler.extend(sub_condition.filler) if self._sql: self._sql += f" OR ({sub_condition.sql(None)})" else: self._sql += f"({sub_condition.sql(None)})" return self def or_not_sub_condition(self, sub_condition): if sub_condition.has_regex: self.has_regex = True self.filler.extend(sub_condition.filler) if self._sql: self._sql += f" OR NOT ({sub_condition.sql(None)})" else: self._sql += f"NOT ({sub_condition.sql(None)})" return self def and_sub_condition(self, sub_condition): if sub_condition.has_regex: self.has_regex = True self.filler.extend(sub_condition.filler) if self._sql: self._sql += f" AND ({sub_condition.sql(None)})" else: self._sql += f"({sub_condition.sql(None)})" return self def and_not_sub_condition(self, sub_condition): if sub_condition.has_regex: self.has_regex = True self.filler.extend(sub_condition.filler) if self._sql: self._sql += f" AND NOT({sub_condition.sql(None)})" else: self._sql += f"NOT ({sub_condition.sql(None)})" return self def limit(self, limit): self._limit = limit return self def offset(self, offset): self._offset = offset return self def order_by(self, order_by, desc=False): self._order_by = order_by self._desc = desc return self SETTING_DB_VERSION = 2 MUSIC_DB_VERSION = 4 class SettingsDatabase: def __init__(self, db_path): self.db_path = db_path def get(self, section, option, **kwargs): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() result = cursor.execute("SELECT value FROM botamusique WHERE section=? AND option=?", (section, option)).fetchall() conn.close() if len(result) > 0: return result[0][0] else: if 'fallback' in kwargs: return kwargs['fallback'] else: raise DatabaseError("Item not found") def getboolean(self, section, option, **kwargs): return bool(int(self.get(section, option, **kwargs))) def getfloat(self, section, option, **kwargs): return float(self.get(section, option, **kwargs)) def getint(self, section, option, **kwargs): return int(self.get(section, option, **kwargs)) def set(self, section, option, value): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute("INSERT OR REPLACE INTO botamusique (section, option, value) " "VALUES (?, ?, ?)", (section, option, value)) conn.commit() conn.close() def has_option(self, section, option): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() result = cursor.execute("SELECT value FROM botamusique WHERE section=? AND option=?", (section, option)).fetchall() conn.close() if len(result) > 0: return True else: return False def remove_option(self, section, option): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute("DELETE FROM botamusique WHERE section=? AND option=?", (section, option)) conn.commit() conn.close() def remove_section(self, section): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute("DELETE FROM botamusique WHERE section=?", (section,)) conn.commit() conn.close() def items(self, section): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() results = cursor.execute("SELECT option, value FROM botamusique WHERE section=?", (section,)).fetchall() conn.close() if len(results) > 0: return list(map(lambda v: (v[0], v[1]), results)) else: return [] def drop_table(self): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute("DROP TABLE botamusique") conn.close() class MusicDatabase: def __init__(self, db_path): self.db_path = db_path def insert_music(self, music_dict, _conn=None): conn = sqlite3.connect(self.db_path) if _conn is None else _conn cursor = conn.cursor() id = music_dict['id'] title = music_dict['title'] type = music_dict['type'] path = music_dict['path'] if 'path' in music_dict else '' keywords = music_dict['keywords'] tags_list = list(dict.fromkeys(music_dict['tags'])) tags = '' if tags_list: tags = ",".join(tags_list) + "," del music_dict['id'] del music_dict['title'] del music_dict['type'] del music_dict['tags'] if 'path' in music_dict: del music_dict['path'] del music_dict['keywords'] existed = cursor.execute("SELECT 1 FROM music WHERE id=?", (id,)).fetchall() if len(existed) == 0: cursor.execute( "INSERT INTO music (id, type, title, metadata, tags, path, keywords) VALUES (?, ?, ?, ?, ?, ?, ?)", (id, type, title, json.dumps(music_dict), tags, path, keywords)) else: cursor.execute("UPDATE music SET type=:type, title=:title, metadata=:metadata, tags=:tags, " "path=:path, keywords=:keywords WHERE id=:id", {'id': id, 'type': type, 'title': title, 'metadata': json.dumps(music_dict), 'tags': tags, 'path': path, 'keywords': keywords}) if not _conn: conn.commit() conn.close() def query_music_ids(self, condition: Condition): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() results = cursor.execute("SELECT id FROM music WHERE id != 'info' AND %s" % condition.sql(conn), condition.filler).fetchall() conn.close() return list(map(lambda i: i[0], results)) def query_all_paths(self): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() results = cursor.execute("SELECT path FROM music WHERE id != 'info' AND type = 'file'").fetchall() conn.close() paths = [] for result in results: if result and result[0]: paths.append(result[0]) return paths def query_all_tags(self): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() results = cursor.execute("SELECT tags FROM music WHERE id != 'info'").fetchall() tags = [] for result in results: for tag in result[0].strip(",").split(","): if tag and tag not in tags: tags.append(tag) conn.close() return tags def query_music_count(self, condition: Condition): filler = condition.filler conn = sqlite3.connect(self.db_path) condition_str = condition.sql(conn) cursor = conn.cursor() results = cursor.execute("SELECT COUNT(*) FROM music " "WHERE id != 'info' AND %s" % condition_str, filler).fetchall() conn.close() return results[0][0] def query_music(self, condition: Condition, _conn=None): filler = condition.filler conn = sqlite3.connect(self.db_path) if _conn is None else _conn condition_str = condition.sql(conn) cursor = conn.cursor() results = cursor.execute("SELECT id, type, title, metadata, tags, path, keywords FROM music " "WHERE id != 'info' AND %s" % condition_str, filler).fetchall() if not _conn: conn.close() return self._result_to_dict(results) def _query_music_by_plain_sql_cond(self, sql_cond, _conn=None): conn = sqlite3.connect(self.db_path) if _conn is None else _conn cursor = conn.cursor() results = cursor.execute("SELECT id, type, title, metadata, tags, path, keywords FROM music " "WHERE id != 'info' AND %s" % sql_cond).fetchall() if not _conn: conn.close() return self._result_to_dict(results) def query_music_by_id(self, _id, _conn=None): results = self.query_music(Condition().and_equal("id", _id), _conn) if results: return results[0] else: return None def query_music_by_keywords(self, keywords, _conn=None): condition = Condition() for keyword in keywords: condition.and_like("title", f"%{keyword}%", case_sensitive=False) return self.query_music(condition, _conn) def query_music_by_tags(self, tags, _conn=None): condition = Condition() for tag in tags: condition.and_like("tags", f"%{tag},%", case_sensitive=False) return self.query_music(condition, _conn) def manage_special_tags(self): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute("UPDATE music SET tags=REPLACE(tags, 'recent added,', '') WHERE tags LIKE '%recent added,%' " "AND create_at <= DATETIME('now', '-1 day') AND id != 'info'") cursor.execute("UPDATE music SET tags=tags||'recent added,' WHERE tags NOT LIKE '%recent added,%' " "AND create_at > DATETIME('now', '-1 day') AND id != 'info'") conn.commit() conn.close() def query_tags(self, condition: Condition): # TODO: Can we keep a index of tags? conn = sqlite3.connect(self.db_path) cursor = conn.cursor() results = cursor.execute("SELECT id, tags FROM music " "WHERE id != 'info' AND %s" % condition.sql(conn), condition.filler).fetchall() conn.close() lookup = {} if len(results) > 0: for result in results: id = result[0] tags = result[1].strip(",").split(",") lookup[id] = tags if tags[0] else [] return lookup def query_random_music(self, count, condition: Condition = None): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() results = [] if condition is None: condition = Condition().and_not_sub_condition(Condition().and_equal('id', 'info')) results = cursor.execute("SELECT id, type, title, metadata, tags, path, keywords FROM music " "WHERE id IN (SELECT id FROM music WHERE %s ORDER BY RANDOM() LIMIT ?) " "ORDER BY RANDOM()" % condition.sql(conn), condition.filler + [count]).fetchall() conn.close() return self._result_to_dict(results) def _result_to_dict(self, results): if len(results) > 0: music_dicts = [] for result in results: music_dict = json.loads(result[3]) music_dict['type'] = result[1] music_dict['title'] = result[2] music_dict['id'] = result[0] music_dict['tags'] = result[4].strip(",").split(",") if result[4] else [] music_dict['path'] = result[5] music_dict['keywords'] = result[6] music_dicts.append(music_dict) return music_dicts else: return [] def delete_music(self, condition: Condition): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute("DELETE FROM music " "WHERE %s" % condition.sql(conn), condition.filler) conn.commit() conn.close() def drop_table(self): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute("DROP TABLE music") conn.close() class DatabaseMigration: def __init__(self, settings_db: SettingsDatabase, music_db: MusicDatabase): self.settings_db = settings_db self.music_db = music_db self.settings_table_migrate_func = {0: self.settings_table_migrate_from_0_to_1, 1: self.settings_table_migrate_from_1_to_2} self.music_table_migrate_func = {0: self.music_table_migrate_from_0_to_1, 1: self.music_table_migrate_from_1_to_2, 2: self.music_table_migrate_from_2_to_4, 3: self.music_table_migrate_from_2_to_4 } def migrate(self): self.settings_database_migrate() self.music_database_migrate() def settings_database_migrate(self): conn = sqlite3.connect(self.settings_db.db_path) cursor = conn.cursor() if self.has_table('botamusique', conn): current_version = 0 ver = cursor.execute("SELECT value FROM botamusique WHERE section='bot' " "AND option='db_version'").fetchone() if ver: current_version = int(ver[0]) if current_version == SETTING_DB_VERSION: conn.close() return else: log.info( f"database: migrating from settings table version {current_version} to {SETTING_DB_VERSION}...") while current_version < SETTING_DB_VERSION: log.debug(f"database: migrate step {current_version}/{SETTING_DB_VERSION - 1}") current_version = self.settings_table_migrate_func[current_version](conn) log.info(f"database: migration done.") cursor.execute("UPDATE botamusique SET value=? " "WHERE section='bot' AND option='db_version'", (SETTING_DB_VERSION,)) else: log.info(f"database: no settings table found. Creating settings table version {SETTING_DB_VERSION}.") self.create_settings_table_version_2(conn) conn.commit() conn.close() def music_database_migrate(self): conn = sqlite3.connect(self.music_db.db_path) cursor = conn.cursor() if self.has_table('music', conn): current_version = 0 ver = cursor.execute("SELECT title FROM music WHERE id='info'").fetchone() if ver: current_version = int(ver[0]) if current_version == MUSIC_DB_VERSION: conn.close() return else: log.info(f"database: migrating from music table version {current_version} to {MUSIC_DB_VERSION}...") while current_version < MUSIC_DB_VERSION: log.debug(f"database: migrate step {current_version}/{MUSIC_DB_VERSION - 1}") current_version = self.music_table_migrate_func[current_version](conn) log.info(f"database: migration done.") cursor.execute("UPDATE music SET title=? " "WHERE id='info'", (MUSIC_DB_VERSION,)) else: log.info(f"database: no music table found. Creating music table version {MUSIC_DB_VERSION}.") self.create_music_table_version_4(conn) conn.commit() conn.close() def has_table(self, table, conn): cursor = conn.cursor() tables = cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?;", (table,)).fetchall() if len(tables) == 0: return False return True def create_settings_table_version_2(self, conn): cursor = conn.cursor() cursor.execute("CREATE TABLE IF NOT EXISTS botamusique (" "section TEXT, " "option TEXT, " "value TEXT, " "UNIQUE(section, option))") cursor.execute("INSERT INTO botamusique (section, option, value) " "VALUES (?, ?, ?)", ("bot", "db_version", 2)) conn.commit() return 1 def create_music_table_version_1(self, conn): cursor = conn.cursor() cursor.execute("CREATE TABLE music (" "id TEXT PRIMARY KEY, " "type TEXT, " "title TEXT, " "keywords TEXT, " "metadata TEXT, " "tags TEXT, " "path TEXT, " "create_at DATETIME DEFAULT CURRENT_TIMESTAMP" ")") cursor.execute("INSERT INTO music (id, title) " "VALUES ('info', ?)", (MUSIC_DB_VERSION,)) conn.commit() def create_music_table_version_4(self, conn): self.create_music_table_version_1(conn) def settings_table_migrate_from_0_to_1(self, conn): cursor = conn.cursor() cursor.execute("DROP TABLE botamusique") conn.commit() self.create_settings_table_version_2(conn) return 2 # return new version number def settings_table_migrate_from_1_to_2(self, conn): cursor = conn.cursor() # move music database into a separated file if self.has_table('music', conn) and not os.path.exists(self.music_db.db_path): log.info(f"database: move music db into separated file.") cursor.execute(f"ATTACH DATABASE '{self.music_db.db_path}' AS music_db") cursor.execute(f"SELECT sql FROM sqlite_master " f"WHERE type='table' AND name='music'") sql_create_table = cursor.fetchone()[0] sql_create_table = sql_create_table.replace("music", "music_db.music") cursor.execute(sql_create_table) cursor.execute("INSERT INTO music_db.music SELECT * FROM music") conn.commit() cursor.execute("DETACH DATABASE music_db") cursor.execute("DROP TABLE music") cursor.execute("UPDATE botamusique SET value=2 " "WHERE section='bot' AND option='db_version'") return 2 # return new version number def music_table_migrate_from_0_to_1(self, conn): cursor = conn.cursor() cursor.execute("ALTER TABLE music RENAME TO music_old") conn.commit() self.create_music_table_version_1(conn) cursor.execute("INSERT INTO music (id, type, title, metadata, tags)" "SELECT id, type, title, metadata, tags FROM music_old") cursor.execute("DROP TABLE music_old") conn.commit() return 1 # return new version number def music_table_migrate_from_1_to_2(self, conn): items_to_update = self.music_db.query_music(Condition(), conn) for item in items_to_update: item['keywords'] = item['title'] if 'artist' in item: item['keywords'] += ' ' + item['artist'] tags = [] for tag in item['tags']: if tag: tags.append(tag) item['tags'] = tags self.music_db.insert_music(item) conn.commit() return 2 # return new version number def music_table_migrate_from_2_to_4(self, conn): items_to_update = self.music_db.query_music(Condition(), conn) for item in items_to_update: if 'duration' not in item: item['duration'] = 0 if item['type'] == 'url' or item['type'] == "url_from_playlist": item['duration'] = item['duration'] * 60 self.music_db.insert_music(item) conn.commit() return 4 # return new version number ================================================ FILE: entrypoint.sh ================================================ #!/usr/bin/env bash command=( "${@}" ) if [ "$1" == "bash" ] || [ "$1" == "sh" ]; then exec "${@}" fi if [ -n "$BAM_DB" ]; then command+=( "--db" "$BAM_DB" ) fi if [ -n "$BAM_MUSIC_DB" ]; then command+=( "--music-db" "$BAM_MUSIC_DB" ) fi if [ -n "$BAM_MUMBLE_SERVER" ]; then command+=( "--server" "$BAM_MUMBLE_SERVER") fi if [ -n "$BAM_MUMBLE_PASSWORD" ]; then command+=( "--password" "$BAM_MUMBLE_PASSWORD" ) fi if [ -n "$BAM_MUMBLE_PORT" ]; then command+=( "--port" "$BAM_MUMBLE_PORT" ) fi if [ -n "$BAM_USER" ]; then command+=( "--user" "$BAM_USER" ) fi if [ -n "$BAM_TOKENS" ]; then command+=( "--tokens" "$BAM_TOKENS" ) fi if [ -n "$BAM_CHANNEL" ]; then command+=( "--channel" "$BAM_CHANNEL" ) fi if [ -n "$BAM_CERTIFICATE" ]; then command+=( "--cert" "$BAM_CERTIFICATE" ) fi if [ -n "$BAM_VERBOSE" ]; then command+=( "--verbose" ) fi if [ -n "$BAM_BANDWIDTH" ]; then command+=( "--bandwidth" "$BAM_BANDWIDTH") fi if [ -n "$BAM_CONFIG_file" ]; then if [ ! -f "$BAM_CONFIG_file" ]; then cp "/botamusique/configuration.example.ini" "$BAM_CONFIG_file" fi command+=( "--config" "$BAM_CONFIG_file" ) else if [ ! -f "/botamusique/configuration.ini" ]; then cp "/botamusique/configuration.example.ini" "/botamusique/configuration.ini" fi command+=( "--config" "/botamusique/configuration.ini" ) fi exec "${command[@]}" ================================================ FILE: interface.py ================================================ #!/usr/bin/python3 import sqlite3 from functools import wraps from flask import Flask, render_template, request, redirect, send_file, Response, jsonify, abort, session from werkzeug.utils import secure_filename import variables as var import util import math import os import os.path import errno from typing import Type import media import json from media.item import dicts_to_items, dict_to_item, BaseItem from media.file import FileItem from media.url import URLItem from media.url_from_playlist import PlaylistURLItem from media.radio import RadioItem from media.cache import get_cached_wrapper_from_scrap, get_cached_wrapper_by_id, get_cached_wrappers_by_tags, \ get_cached_wrapper from database import MusicDatabase, Condition import logging import time class ReverseProxied(object): """Wrap the application in this middleware and configure the front-end server to add these headers, to let you quietly bind this to a URL other than / and to an HTTP scheme that is different than what is used locally. In nginx: location /myprefix { proxy_pass http://192.168.0.1:5001; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Scheme $scheme; proxy_set_header X-Script-Name /myprefix; } :param app: the WSGI application """ def __init__(self, app): self.app = app def __call__(self, environ, start_response): script_name = environ.get('HTTP_X_SCRIPT_NAME', '') if script_name: environ['SCRIPT_NAME'] = script_name path_info = environ['PATH_INFO'] if path_info.startswith(script_name): environ['PATH_INFO'] = path_info[len(script_name):] scheme = environ.get('HTTP_X_SCHEME', '') if scheme: environ['wsgi.url_scheme'] = scheme real_ip = environ.get('HTTP_X_REAL_IP', '') if real_ip: environ['REMOTE_ADDR'] = real_ip return self.app(environ, start_response) root_dir = os.path.dirname(__file__) web = Flask(__name__, template_folder=os.path.join(root_dir, "web/templates")) #web.config['TEMPLATES_AUTO_RELOAD'] = True log = logging.getLogger("bot") user = 'Remote Control' def init_proxy(): global web if var.is_proxified: web.wsgi_app = ReverseProxied(web.wsgi_app) # https://stackoverflow.com/questions/29725217/password-protect-one-webpage-in-flask-app def check_auth(username, password): """This function is called to check if a username / password combination is valid. """ if username == var.config.get("webinterface", "user") and password == var.config.get("webinterface", "password"): return True web_users = json.loads(var.db.get("privilege", "web_access", fallback='[]')) if username in web_users: user_dict = json.loads(var.db.get("user", username, fallback='{}')) if 'password' in user_dict and 'salt' in user_dict and \ util.verify_password(password, user_dict['password'], user_dict['salt']): return True return False def authenticate(): """Sends a 401 response that enables basic auth""" global log return Response('Could not verify your access level for that URL.\n' 'You have to login with proper credentials', 401, {'WWW-Authenticate': 'Basic realm="Login Required"'}) bad_access_count = {} banned_ip = [] def requires_auth(f): @wraps(f) def decorated(*args, **kwargs): global log, user, bad_access_count, banned_ip if request.remote_addr in banned_ip: abort(403) auth_method = var.config.get("webinterface", "auth_method") if auth_method == 'password': auth = request.authorization if auth: user = auth.username if not check_auth(auth.username, auth.password): if request.remote_addr in bad_access_count: bad_access_count[request.remote_addr] += 1 log.info(f"web: failed login attempt, user: {auth.username}, from ip {request.remote_addr}." f"{bad_access_count[request.remote_addr]} attempts.") if bad_access_count[request.remote_addr] > var.config.getint("webinterface", "max_attempts", fallback=10): banned_ip.append(request.remote_addr) log.info(f"web: access banned for {request.remote_addr}") else: bad_access_count[request.remote_addr] = 1 log.info(f"web: failed login attempt, user: {auth.username}, from ip {request.remote_addr}.") return authenticate() else: return authenticate() if auth_method == 'token': if 'user' in session and 'token' not in request.args: user = session['user'] return f(*args, **kwargs) elif 'token' in request.args: token = request.args.get('token') token_user = var.db.get("web_token", token, fallback=None) if token_user is not None: user = token_user user_info = var.db.get("user", user, fallback=None) user_dict = json.loads(user_info) user_dict['IP'] = request.remote_addr var.db.set("user", user, json.dumps(user_dict)) log.debug( f"web: new user access, token validated for the user: {token_user}, from ip {request.remote_addr}.") session['token'] = token session['user'] = token_user return f(*args, **kwargs) if request.remote_addr in bad_access_count: bad_access_count[request.remote_addr] += 1 log.info(f"web: bad token from ip {request.remote_addr}, " f"{bad_access_count[request.remote_addr]} attempts.") if bad_access_count[request.remote_addr] > var.config.getint("webinterface", "max_attempts"): banned_ip.append(request.remote_addr) log.info(f"web: access banned for {request.remote_addr}") else: bad_access_count[request.remote_addr] = 1 log.info(f"web: bad token from ip {request.remote_addr}.") return render_template(f'need_token.{var.language}.html', name=var.config.get('bot', 'username'), command=f"{var.config.get('commands', 'command_symbol')[0]}" f"{var.config.get('commands', 'requests_webinterface_access')}") return f(*args, **kwargs) return decorated def tag_color(tag): num = hash(tag) % 8 if num == 0: return "primary" elif num == 1: return "secondary" elif num == 2: return "success" elif num == 3: return "danger" elif num == 4: return "warning" elif num == 5: return "info" elif num == 6: return "light" elif num == 7: return "dark" def build_tags_color_lookup(): color_lookup = {} for tag in var.music_db.query_all_tags(): color_lookup[tag] = tag_color(tag) return color_lookup def get_all_dirs(): dirs = ["."] paths = var.music_db.query_all_paths() for path in paths: pos = 0 while True: pos = path.find("/", pos + 1) if pos == -1: break folder = path[:pos] if folder not in dirs: dirs.append(folder) return dirs @web.route("/", methods=['GET']) @requires_auth def index(): return open(os.path.join(root_dir, f"web/templates/index.{var.language}.html"), "r").read() @web.route("/playlist", methods=['GET']) @requires_auth def playlist(): if len(var.playlist) == 0: return jsonify({ 'items': [], 'current_index': -1, 'length': 0, 'start_from': 0 }) DEFAULT_DISPLAY_COUNT = 11 _from = 0 _to = 10 if 'range_from' in request.args and 'range_to' in request.args: _from = int(request.args['range_from']) _to = int(request.args['range_to']) else: if var.playlist.current_index - int(DEFAULT_DISPLAY_COUNT / 2) > 0: _from = var.playlist.current_index - int(DEFAULT_DISPLAY_COUNT / 2) _to = _from - 1 + DEFAULT_DISPLAY_COUNT tags_color_lookup = build_tags_color_lookup() # TODO: cached this? items = [] for index, item_wrapper in enumerate(var.playlist[_from: _to + 1]): tag_tuples = [] for tag in item_wrapper.item().tags: tag_tuples.append([tag, tags_color_lookup[tag]]) item: Type[BaseItem] = item_wrapper.item() title = item.format_title() artist = "??" path = "" duration = 0 if isinstance(item, FileItem): path = item.path if item.artist: artist = item.artist duration = item.duration elif isinstance(item, URLItem): path = f" {item.url}" duration = item.duration elif isinstance(item, PlaylistURLItem): path = f" {item.url}" artist = f" {item.playlist_title}" duration = item.duration elif isinstance(item, RadioItem): path = f" {item.url}" thumb = "" if item.type != 'radio' and item.thumbnail: thumb = f"data:image/PNG;base64,{item.thumbnail}" else: thumb = "static/image/unknown-album.png" items.append({ 'index': _from + index, 'id': item.id, 'type': item.display_type(), 'path': path, 'title': title, 'artist': artist, 'thumbnail': thumb, 'tags': tag_tuples, 'duration': duration }) return jsonify({ 'items': items, 'current_index': var.playlist.current_index, 'length': len(var.playlist), 'start_from': _from }) def status(): if len(var.playlist) > 0: return jsonify({'ver': var.playlist.version, 'current_index': var.playlist.current_index, 'empty': False, 'play': not var.bot.is_pause, 'mode': var.playlist.mode, 'volume': var.bot.volume_helper.plain_volume_set, 'playhead': var.bot.playhead }) else: return jsonify({'ver': var.playlist.version, 'current_index': var.playlist.current_index, 'empty': True, 'play': not var.bot.is_pause, 'mode': var.playlist.mode, 'volume': var.bot.volume_helper.plain_volume_set, 'playhead': 0 }) @web.route("/post", methods=['POST']) @requires_auth def post(): global log payload = request.get_json() if request.is_json else request.form if payload: log.debug("web: Post request from %s: %s" % (request.remote_addr, str(payload))) if 'add_item_at_once' in payload: music_wrapper = get_cached_wrapper_by_id(payload['add_item_at_once'], user) if music_wrapper: var.playlist.insert(var.playlist.current_index + 1, music_wrapper) log.info('web: add to playlist(next): ' + music_wrapper.format_debug_string()) if not var.bot.is_pause: var.bot.interrupt() else: var.bot.is_pause = False else: abort(404) if 'add_item_bottom' in payload: music_wrapper = get_cached_wrapper_by_id(payload['add_item_bottom'], user) if music_wrapper: var.playlist.append(music_wrapper) log.info('web: add to playlist(bottom): ' + music_wrapper.format_debug_string()) else: abort(404) elif 'add_item_next' in payload: music_wrapper = get_cached_wrapper_by_id(payload['add_item_next'], user) if music_wrapper: var.playlist.insert(var.playlist.current_index + 1, music_wrapper) log.info('web: add to playlist(next): ' + music_wrapper.format_debug_string()) else: abort(404) elif 'add_url' in payload: music_wrapper = get_cached_wrapper_from_scrap(type='url', url=payload['add_url'], user=user) var.playlist.append(music_wrapper) log.info("web: add to playlist: " + music_wrapper.format_debug_string()) if len(var.playlist) == 2: # If I am the second item on the playlist. (I am the next one!) var.bot.async_download_next() elif 'add_radio' in payload: url = payload['add_radio'] music_wrapper = get_cached_wrapper_from_scrap(type='radio', url=url, user=user) var.playlist.append(music_wrapper) log.info("cmd: add to playlist: " + music_wrapper.format_debug_string()) elif 'delete_music' in payload: music_wrapper = var.playlist[int(payload['delete_music'])] log.info("web: delete from playlist: " + music_wrapper.format_debug_string()) if len(var.playlist) >= int(payload['delete_music']): index = int(payload['delete_music']) if index == var.playlist.current_index: var.playlist.remove(index) if index < len(var.playlist): if not var.bot.is_pause: var.bot.interrupt() var.playlist.current_index -= 1 # then the bot will move to next item else: # if item deleted is the last item of the queue var.playlist.current_index -= 1 if not var.bot.is_pause: var.bot.interrupt() else: var.playlist.remove(index) elif 'play_music' in payload: music_wrapper = var.playlist[int(payload['play_music'])] log.info("web: jump to: " + music_wrapper.format_debug_string()) if len(var.playlist) >= int(payload['play_music']): var.bot.play(int(payload['play_music'])) time.sleep(0.1) elif 'move_playhead' in payload: if float(payload['move_playhead']) < var.playlist.current_item().item().duration: log.info(f"web: move playhead to {float(payload['move_playhead'])} s.") var.bot.play(var.playlist.current_index, float(payload['move_playhead'])) elif 'delete_item_from_library' in payload: _id = payload['delete_item_from_library'] var.playlist.remove_by_id(_id) item = var.cache.get_item_by_id(_id) if os.path.isfile(item.uri()): log.info("web: delete file " + item.uri()) os.remove(item.uri()) var.cache.free_and_delete(_id) time.sleep(0.1) elif 'add_tag' in payload: music_wrappers = get_cached_wrappers_by_tags([payload['add_tag']], user) for music_wrapper in music_wrappers: log.info("cmd: add to playlist: " + music_wrapper.format_debug_string()) var.playlist.extend(music_wrappers) elif 'action' in payload: action = payload['action'] if action == "random": if var.playlist.mode != "random": var.playlist = media.playlist.get_playlist("random", var.playlist) else: var.playlist.randomize() var.bot.interrupt() var.db.set('playlist', 'playback_mode', "random") log.info("web: playback mode changed to random.") if action == "one-shot": var.playlist = media.playlist.get_playlist("one-shot", var.playlist) var.db.set('playlist', 'playback_mode', "one-shot") log.info("web: playback mode changed to one-shot.") if action == "repeat": var.playlist = media.playlist.get_playlist("repeat", var.playlist) var.db.set('playlist', 'playback_mode', "repeat") log.info("web: playback mode changed to repeat.") if action == "autoplay": var.playlist = media.playlist.get_playlist("autoplay", var.playlist) var.db.set('playlist', 'playback_mode', "autoplay") log.info("web: playback mode changed to autoplay.") if action == "rescan": var.cache.build_dir_cache() var.music_db.manage_special_tags() log.info("web: Local file cache refreshed.") elif action == "stop": if var.config.getboolean("bot", "clear_when_stop_in_oneshot") \ and var.playlist.mode == 'one-shot': var.bot.clear() else: var.bot.stop() elif action == "next": if not var.bot.is_pause: var.bot.interrupt() else: var.playlist.next() var.bot.wait_for_ready = True elif action == "pause": var.bot.pause() elif action == "resume": var.bot.resume() elif action == "clear": var.bot.clear() elif action == "volume_up": if var.bot.volume_helper.plain_volume_set + 0.03 < 1.0: var.bot.volume_helper.set_volume(var.bot.volume_helper.plain_volume_set + 0.03) else: var.bot.volume_helper.set_volume(1.0) var.db.set('bot', 'volume', str(var.bot.volume_helper.plain_volume_set)) log.info("web: volume up to %d" % (var.bot.volume_helper.plain_volume_set * 100)) elif action == "volume_down": if var.bot.volume_helper.plain_volume_set - 0.03 > 0: var.bot.volume_helper.set_volume(var.bot.unconverted_volume - 0.03) else: var.bot.volume_helper.set_volume(1.0) var.db.set('bot', 'volume', str(var.bot.volume_helper.plain_volume_set)) log.info("web: volume down to %d" % (var.bot.volume_helper.plain_volume_set * 100)) elif action == "volume_set_value": if 'new_volume' in payload: if float(payload['new_volume']) > 1: var.bot.volume_helper.set_volume(1.0) elif float(payload['new_volume']) < 0: var.bot.volume_helper.set_volume(0) else: # value for new volume is between 0 and 1, round to two decimal digits var.bot.volume_helper.set_volume(round(float(payload['new_volume']), 2)) var.db.set('bot', 'volume', str(var.bot.volume_helper.plain_volume_set)) log.info("web: volume set to %d" % (var.bot.volume_helper.plain_volume_set * 100)) return status() def build_library_query_condition(form): try: condition = Condition() types = form['type'].split(",") sub_cond = Condition() for type in types: sub_cond.or_equal("type", type) condition.and_sub_condition(sub_cond) if form['type'] == 'file': folder = form['dir'] if folder == ".": folder = "" if not folder.endswith('/') and folder: folder += '/' condition.and_like('path', folder + '%') tags = form['tags'].split(",") for tag in tags: if tag: condition.and_like("tags", f"%{tag},%", case_sensitive=False) _keywords = form['keywords'].split(" ") keywords = [] for kw in _keywords: if kw: keywords.append(kw) for keyword in keywords: condition.and_like("keywords", f"%{keyword}%", case_sensitive=False) condition.order_by('create_at', desc=True) return condition except KeyError: abort(400) @web.route("/library/info", methods=['GET']) @requires_auth def library_info(): global log while var.cache.dir_lock.locked(): time.sleep(0.1) tags = var.music_db.query_all_tags() max_upload_file_size = util.parse_file_size(var.config.get("webinterface", "max_upload_file_size")) return jsonify(dict( dirs=get_all_dirs(), upload_enabled=var.config.getboolean("webinterface", "upload_enabled") or var.bot.is_admin(user), delete_allowed=var.config.getboolean("bot", "delete_allowed") or var.bot.is_admin(user), tags=tags, max_upload_file_size=max_upload_file_size )) @web.route("/library", methods=['POST']) @requires_auth def library(): global log ITEM_PER_PAGE = 10 payload = request.form if request.form else request.json if payload: log.debug("web: Post request from %s: %s" % (request.remote_addr, str(payload))) if payload['action'] in ['add', 'query', 'delete']: condition = build_library_query_condition(payload) total_count = 0 try: total_count = var.music_db.query_music_count(condition) except sqlite3.OperationalError: pass if not total_count: return jsonify({ 'items': [], 'total_pages': 0, 'active_page': 0 }) if payload['action'] == 'add': items = dicts_to_items(var.music_db.query_music(condition)) music_wrappers = [] for item in items: music_wrapper = get_cached_wrapper(item, user) music_wrappers.append(music_wrapper) log.info("cmd: add to playlist: " + music_wrapper.format_debug_string()) var.playlist.extend(music_wrappers) return redirect("./", code=302) elif payload['action'] == 'delete': if var.config.getboolean("bot", "delete_allowed"): items = dicts_to_items(var.music_db.query_music(condition)) for item in items: var.playlist.remove_by_id(item.id) item = var.cache.get_item_by_id(item.id) if os.path.isfile(item.uri()): log.info("web: delete file " + item.uri()) os.remove(item.uri()) var.cache.free_and_delete(item.id) if len(os.listdir(var.music_folder + payload['dir'])) == 0: os.rmdir(var.music_folder + payload['dir']) time.sleep(0.1) return redirect("./", code=302) else: abort(403) else: page_count = math.ceil(total_count / ITEM_PER_PAGE) current_page = int(payload['page']) if 'page' in payload else 1 if current_page <= page_count: condition.offset((current_page - 1) * ITEM_PER_PAGE) else: current_page = 1 condition.limit(ITEM_PER_PAGE) items = dicts_to_items(var.music_db.query_music(condition)) results = [] for item in items: result = {'id': item.id, 'title': item.title, 'type': item.display_type(), 'tags': [(tag, tag_color(tag)) for tag in item.tags]} if item.type != 'radio' and item.thumbnail: result['thumb'] = f"data:image/PNG;base64,{item.thumbnail}" else: result['thumb'] = "static/image/unknown-album.png" if item.type == 'file': result['path'] = item.path result['artist'] = item.artist else: result['path'] = item.url result['artist'] = "??" results.append(result) return jsonify({ 'items': results, 'total_pages': page_count, 'active_page': current_page }) elif payload['action'] == 'edit_tags': tags = list(dict.fromkeys(payload['tags'].split(","))) # remove duplicated items if payload['id'] in var.cache: music_wrapper = get_cached_wrapper_by_id(payload['id'], user) music_wrapper.clear_tags() music_wrapper.add_tags(tags) var.playlist.version += 1 else: item = var.music_db.query_music_by_id(payload['id']) item['tags'] = tags var.music_db.insert_music(item) return redirect("./", code=302) else: abort(400) @web.route('/upload', methods=["POST"]) @requires_auth def upload(): global log if not var.config.getboolean("webinterface", "upload_enabled"): abort(403) file = request.files['file'] if not file: abort(400) filename = file.filename if filename == '': abort(400) targetdir = request.form['targetdir'].strip() if targetdir == '': targetdir = 'uploads/' elif '../' in targetdir: abort(403) log.info('web: Uploading file from %s:' % request.remote_addr) log.info('web: - filename: ' + filename) log.info('web: - targetdir: ' + targetdir) log.info('web: - mimetype: ' + file.mimetype) if "audio" in file.mimetype or "video" in file.mimetype: storagepath = os.path.abspath(os.path.join(var.music_folder, targetdir)) if not storagepath.startswith(os.path.abspath(var.music_folder)): abort(403) try: os.makedirs(storagepath) except OSError as ee: if ee.errno != errno.EEXIST: log.error(f'web: failed to create directory {storagepath}') abort(500) filepath = os.path.join(storagepath, filename) log.info('web: - file saved at: ' + filepath) if os.path.exists(filepath): return 'File existed!', 409 file.save(filepath) else: log.error(f'web: unsupported file type {file.mimetype}! File was not saved.') return 'Unsupported media type!', 415 return '', 200 @web.route('/download', methods=["GET"]) @requires_auth def download(): global log if 'id' in request.args and request.args['id']: item = dicts_to_items(var.music_db.query_music( Condition().and_equal('id', request.args['id'])))[0] requested_file = item.uri() log.info('web: Download of file %s requested from %s:' % (requested_file, request.remote_addr)) try: return send_file(requested_file, as_attachment=True) except Exception as e: log.exception(e) abort(404) else: condition = build_library_query_condition(request.args) items = dicts_to_items(var.music_db.query_music(condition)) zipfile = util.zipdir([item.uri() for item in items]) try: return send_file(zipfile, as_attachment=True) except Exception as e: log.exception(e) abort(404) return abort(400) if __name__ == '__main__': web.run(port=8181, host="127.0.0.1") ================================================ FILE: lang/de_DE.json ================================================ { "cli": { "added_tags": "{song} wurde mit {tags} verschlagwortet.", "added_tags_to_all": "Alle Lieder in der Playlist wurden mit {tags} verschlagwortet.", "admin_help": "

Adminbefehle

\nBot\n\nWebinterface\n", "auto_paused": "Sende !play, um die Wiedergabe fortzusetzen!", "bad_command": "{command}: Befehl nicht verfügbar. Sende !help, um dir alle möglichen Befehle anzuzeigen.", "bad_parameter": "{command}: Ungültiges Argument.", "bad_url": "URL nicht verfügbar.", "cache_refreshed": "Cache erneuert!", "change_ducking_volume": "Lautstärkeabsenkung wurde von {user} auf {volume} gesetzt.", "change_max_volume": "", "change_mode": "Wiedergabemodus wurde von {user} auf {mode} gesetzt.", "change_volume": "Lautstärke wurde von {user} auf {volume} gesetzt.", "cleared": "Playlist wurde geleert.", "cleared_tags": "Alle Tags wurden von {song} entfernt.", "cleared_tags_from_all": "Alle Tags wurden von allen Songs in der Playlist entfernt.", "command_disabled": "{command}: Befehl deaktiviert!", "current_ducking_volume": "Aktuelle Lautstärkeabsenkung: {volume}.", "current_max_volume": "", "current_mode": "Aktueller Wiedergabemodus: {mode}", "current_volume": "Aktuelle Lautstärke: {volume}.", "database_dropped": "Datenbank gelöscht. Alle Einträge wurde gelöscht.", "download_in_progress": "{item} wird heruntergeladen ...", "error_executing_command": "{command}: Befehl fehlgeschlagen: {error}.", "file": "Datei", "file_added": "{item} wurde hinzugefügt.", "file_deleted": "{item} wurde aus der Bibliothek gelöscht.", "file_item": "{artist} - {title} wurde von {user} hinzugefügt. ", "file_missed": "Datei {file} nicht gefunden. Das Element wurde aus der Playlist entfernt.", "help": "", "invalid_index": "{index} ist ein ungültiger Index. Sende !queue, um die aktuelle Playlist anzuzeigen.", "last_song_on_the_queue": "Letztes Lied in der Wiedergabeliste.", "max_volume": "", "multiple_file_added": "Mehrere Elemente wurden hinzugefügt:", "multiple_file_deleted": "Mehrere Elemente wurden aus der Bibliothek gelöscht:", "multiple_file_found": "Gefunden:", "multiple_matches": "Datei wurde nicht gefunden! Meintest du:", "new_version_found": "

Update verfügbar!

Version {new_version} von botamusique ist verfügbar!
\n

Changelog

\n{changelog}
Sende !update, um das Update zu starten!", "next_to_play": "Nächster Song.", "no_file": "Datei nicht gefunden.", "not_admin": "Du bist kein Administrator!", "not_in_my_channel": "Du bist nicht in meinem Kanal!", "not_playing": "Aktuell läuft keine Wiedergabe.", "now_playing": "{item} wird wiedergegeben.", "page_instruction": "Seite {current}/{total}. Nutze !{command} {{page}}, um zu navigieren.", "paused": "Wiedergabe pausiert.", "playlist_fetching_failed": "Playlist konnte nicht geladen werden!", "pm_not_allowed": "Private Nachrichten sind nicht erlaubt.", "position_in_the_queue": "Aktuelle Position der Wiedergabeliste: {position}", "preconfigurated_radio": "Folgende Radiosender wurden vorkonfiguriert und sind verfügbar:", "queue_contents": "Elemente in der Wiedergabeliste:", "queue_empty": "Wiedergabeliste ist leer!", "radio": "Radiosender", "radio_item": "", "rb_play_empty": "", "rb_query_result": "", "records_omitted": "", "removed_tags": "", "removed_tags_from_all": "", "removing_item": "", "repeat": "", "report_version": "", "shortlist_instruction": "Sende !sl {indexes}, um das gewünscht Element abzuspielen.", "start_updating": "", "stopped": "", "too_long": "", "unable_download": "", "unable_play": "", "unknown_mode": "", "update_successful": "", "url": "", "url_ban": "", "url_ban_list": "", "url_ban_success": "", "url_from_playlist": "", "url_from_playlist_item": "", "url_item": "", "url_unban_success": "", "url_unwhitelist_success": "", "url_whitelist_list": "", "url_whitelist_success": "", "user_ban": "", "user_ban_list": "", "user_ban_success": "", "user_password_set": "", "user_unban_success": "", "web_user_list": "", "webpage_address": "", "which_command": "", "wrong_pattern": "", "yt_no_more": "", "yt_query_error": "", "yt_result": "Ergebnis der YouTube-Suche:\n{result_table}\nSende !sl {{indexes}}, um das gewünscht Element abzuspielen.\n!ytquery -n, um die nächste Seite aufzurufen." }, "web": { "action": "", "add": "", "add_all": "", "add_radio": "", "add_radio_url": "", "add_to_bottom": "", "add_to_bottom_of_current_playlist": "", "add_to_playlist_next": "", "add_url": "", "add_youtube_or_soundcloud_url": "", "are_you_really_sure": "", "aria_botamusique_logo": "", "aria_default_cover": "", "aria_empty_box": "", "aria_remove_this_song": "", "aria_skip_current_song": "", "aria_skip_to_next_track": "", "aria_spinner": "", "aria_warning_of_deletion": "", "autoplay": "", "browse_music_file": "", "cancel": "", "cancel_upload_warning": "", "change_playback_mode": "", "choose_file": "", "clear_playlist": "", "close": "", "delete_all": "", "delete_all_files": "", "delete_file_warning": "", "directory": "", "download_all": "", "download_song_from_library": "", "edit_submit": "", "edit_tags_for": "", "expand_playlist": "", "file": "", "filters": "", "index": "#", "keywords": "", "keywords_placeholder": "", "mini_player_title": "", "music_library": "", "next_to_play": "", "no_tag": "", "oneshot": "", "open_volume_controls": "", "page_title": "", "pause": "", "play": "", "playlist_controls": "", "radio": "", "radio_url_placeholder": "", "random": "", "remove_song_from_library": "", "repeat": "", "rescan_files": "", "skip_track": "", "submit": "", "tags": "", "tags_to_add": "", "title": "", "token": "", "token_required": "", "token_required_message": "", "type": "", "upload_file": "", "upload_submit": "", "upload_to": "", "uploaded_finished": "", "uploading_files": "", "url": "", "url_path": "", "url_placeholder": "", "volume_slider": "" } } ================================================ FILE: lang/en_US.json ================================================ { "cli": { "added_tags": "Added tags {tags} to {song}.", "added_tags_to_all": "Added tags {tags} to songs on the playlist.", "admin_help": "

Admin command

\nBot\n\nWeb Interface\n", "auto_paused": "Use !play to resume music!", "bad_command": "{command}: command not found.", "bad_parameter": "{command}: invalid parameter.", "bad_url": "Bad URL requested.", "cache_refreshed": "Cache refreshed!", "change_ducking_volume": "Volume on ducking set to {volume} by {user}.", "change_max_volume": "Max volume set to {max} by {user}", "change_mode": "Playback mode set to {mode} by {user}.", "change_volume": "Volume set to {volume} by {user}.", "cleared": "Playlist emptied.", "cleared_tags": "Removed all tags from {song}.", "cleared_tags_from_all": "Removed all tags from songs on the playlist.", "command_disabled": "{command}: command disabled!", "current_ducking_volume": "Volume on ducking: {volume}.", "current_max_volume": "Current max volume: {max}.", "current_mode": "Current playback mode is {mode}.", "current_volume": "Current volume: {volume}.", "database_dropped": "Database dropped. All records have gone.", "download_in_progress": "Download of {item} in progress...", "error_executing_command": "{command}: Command failed with error: {error}.", "file": "File", "file_added": "Added {item}. ", "file_deleted": "Deleted {item} from the library.", "file_item": "{artist} - {title} added by {user}", "file_missed": "Music file '{file}' missed! This item has been removed from the playlist.", "help": "

Commands

\nControl\n\nPlaylist\n\nMusic Library\n\nOther\n", "invalid_index": "Invalid index {index}. Use !queue to see the playlist.", "last_song_on_the_queue": "Last one on the queue.", "max_volume": "Volume exceeds max volume of {max}. Setting volume to max.", "multiple_file_added": "Multiple items added:", "multiple_file_deleted": "Multiple items deleted from the library:", "multiple_file_found": "Found:", "multiple_matches": "File not found! Possible candidates:", "new_version_found": "

Update Available!

Version {new_version} of botamusique is available!
\n

Changelog

{changelog}
Send !update to update!", "next_to_play": "Next song.", "no_file": "File not found.", "not_admin": "You are not an admin!", "not_in_my_channel": "You're not in my channel!", "not_playing": "Nothing is playing right now.", "now_playing": "Playing {item}", "page_instruction": "Page {current}/{total}. Use !{command} {{page}} to navigate.", "paused": "Music paused.", "playlist_fetching_failed": "Unable to fetch the playlist!", "pm_not_allowed": "Private message aren't allowed.", "position_in_the_queue": "Position: {position}", "preconfigurated_radio": "Preconfigurated Radio available:", "queue_contents": "Items on the playlist:", "queue_empty": "Playlist is empty!", "radio": "Radio", "radio_item": "{title} from {name} added by {user}", "rb_play_empty": "Please specify a radio station ID!", "rb_query_result": "This is the result of your query, send !rbplay {ID} to play a station:", "records_omitted": "...", "removed_tags": "Removed tags {tags} from {song}.", "removed_tags_from_all": "Removed tags {tags} from songs on the playlist.", "removing_item": "Removed entry {item} from playlist.", "repeat": "Repeat {song} for {n} times.", "report_version": "The current version of botamusique is {version}.", "shortlist_instruction": "Use !sl {indexes} to play the item you want.", "start_updating": "Start updating...", "stopped": "Music stopped.", "too_long": "{song} is too long ({duration} > {max_duration}), removed from playlist!", "unable_download": "Unable to download {item}. Removed from the library.", "unable_play": "Unable to play {item}. Removed from the library.", "unknown_mode": "Unknown playback mode '{mode}'. It should be one of one-shot, repeat, random.", "update_successful": "

botamusique v{version} Installed!


\n

Changelog

{changelog}
Visit our github repo for more details!", "url": "URL", "url_ban": "The URL {url} is banned! Removed from playlist!", "url_ban_list": "List of banned URL:
{list}", "url_ban_success": "The following URL is banned: {url}.", "url_from_playlist": "URL", "url_from_playlist_item": "{title} from playlist {playlist} added by {user}", "url_item": "{title} added by {user}", "url_unban_success": "The following URL is unbanned: {url}.", "url_unwhitelist_success": "The following URL is un-whitelisted: {url}.", "url_whitelist_list": "List of whitelisted URL:
{list}", "url_whitelist_success": "The following URL is whitelisted: {url}.", "user_ban": "You are banned, not allowed to do that!", "user_ban_list": "List of banned user:
{list}", "user_ban_success": "User {user} is banned.", "user_password_set": "Your password has been updated.", "user_unban_success": "User {user} is unbanned.", "web_user_list": "Following users have the privilege to access the web interface:
{users}", "webpage_address": "Your own address to access the web interface is {address}", "which_command": "Do you mean
{commands}", "wrong_pattern": "Invalid regex: {error}.", "yt_no_more": "No more results!", "yt_query_error": "Unable to query youtube!", "yt_result": "Youtube query result: {result_table} Use !sl {{indexes}} to play the item you want.
\n!ytquery -n for the next page." }, "web": { "action": "Action", "add": "Add", "add_all": "Add All", "add_radio": "Add Radio", "add_radio_url": "Add Radio URL", "add_to_bottom": "Add to bottom", "add_to_bottom_of_current_playlist": "Add to bottom of current playlist", "add_to_playlist_next": "Add to playlist right after current song", "add_url": "Add URL", "add_youtube_or_soundcloud_url": "Add Youtube or Soundcloud URL", "are_you_really_sure": "Are you really sure?", "aria_botamusique_logo": "Botamusique Logo: a fox with two headphones, enjoying the music", "aria_default_cover": "A black square with two eighth notes beamed together.", "aria_empty_box": "A drawing of an empty box.", "aria_remove_this_song": "Remove this song from the current playlist", "aria_skip_current_song": "Skip current song and play this song right now", "aria_skip_to_next_track": "Skip to next track", "aria_spinner": "A loading spinner", "aria_warning_of_deletion": "Warning about deletion of files.", "autoplay": "Autoplay", "browse_music_file": "Browse Music file", "cancel": "Cancel", "cancel_upload_warning": "Are you really sure?
Click again to abort uploading.", "change_playback_mode": "Change Playback Mode", "choose_file": "Choose file", "clear_playlist": "Clear Playlist", "close": "Close", "delete_all": "Delete All", "delete_all_files": "Delete All Listed Files", "delete_file_warning": "All files listed here, include files on other pages, will be deleted from your hard-drive.\n Is that what you want?", "directory": "Directory", "download_all": "Download All", "download_song_from_library": "Download song from library", "edit_submit": "Edit!", "edit_tags_for": "Edit tags for", "expand_playlist": "See item on the playlist.", "file": "File", "filters": "Filters", "index": "#", "keywords": "Keywords", "keywords_placeholder": "Keywords...", "mini_player_title": "Now Playing...", "music_library": "Music Library", "next_to_play": "Next to play", "no_tag": "No tag", "oneshot": "One-shot", "open_volume_controls": "Open Volume Controls", "page_title": "botamusique Web Interface", "pause": "Pause", "play": "Play", "playlist_controls": "Playlist controls", "radio": "Radio", "radio_url_placeholder": "Radio URL...", "random": "Random", "remove_song_from_library": "Remove song from library", "repeat": "Repeat", "rescan_files": "Rescan Files", "skip_track": "Skip Track", "submit": "Submit", "tags": "Tags", "tags_to_add": "Tags to add", "title": "Title", "token": "Token", "token_required": "Token Required", "token_required_message": "You are accessing the web interface of {{ name }}.\nA token is needed to grant you access.
\nPlease send \"{{ command }}\" to the bot in mumble to acquire one.", "type": "Type", "upload_file": "Upload File", "upload_submit": "Upload!", "upload_to": "Upload To", "uploaded_finished": "Uploaded finished!", "uploading_files": "Uploading files...", "url": "URL", "url_path": "Url/Path", "url_placeholder": "URL...", "volume_slider": "Volume Slider" } } ================================================ FILE: lang/es_ES.json ================================================ { "cli": { "added_tags": "Etiquetas {tags} fueron añadidas a {song}.", "added_tags_to_all": "Etiquetas {tags} fueron añadidas a las canciones en la lista de reproducción.", "admin_help": "

Comandos de administrador

\nBot\n
    \n
  • !kill - matar al bot
  • \n
  • !update - actualizar al bot
  • \n
  • !userban {user} - banear a un usuario
  • \n
  • !userunban {user} - desbanear a un usuario
  • \n
  • !urlbanlist - listar url baneadas
  • \n
  • !urlban [{url}] - banear {url} (o por defecto, la url del ítem actual) y eliminar esta url de la biblioteca.
  • \n
  • !urlunban {url} - desbanear {url}
  • \n
  • !rescan {url} - reconstruir caché local de ficheros de música
  • \n
  • !dropdatabase - borrar toda la base de datos. Esto eliminará toda su configuración y su biblioteca musical.
  • \n
\nInterfaz Web\n
    \n
  • !webuserlist - lista todos los usuarios que tienen permiso de acceder a la interfaz web, si el modo de autenticación es 'contraseña'.
  • \n
  • !webuseradd {nickname} - otorga al usuario con {nickname} acceso a la interfaz web, si el modo de autenticación es 'contraseña'.
  • \n
  • !webuserdel {nickname} - revoca el acceso a la interfaz web para {nickname}, si el modo de autenticación es 'contraseña'.
  • \n
", "auto_paused": "Usa !play para continuar la reproducción!", "bad_command": "{command}: comando no encontrado.", "bad_parameter": "{command}: parámetro inválido.", "bad_url": "Se solicitó una URL mal formada. ", "cache_refreshed": "Caché fue actualizada!", "change_ducking_volume": "Volumen en agache ajustado a {volume} por {user}.", "change_max_volume": "", "change_mode": "Modo de reproducción ajustado a {mode} por {user}.", "change_volume": "Volumen ajustado a {volume} por {user}.", "cleared": "Lista de reproducción ha sido vaciada.", "cleared_tags": "Eliminadas todas las etiquetas de {song}.", "cleared_tags_from_all": "Eliminadas todas las etiquetas de las canciones en la lista de reproducción.", "command_disabled": "{command}: comando desactivado!", "current_ducking_volume": "Volumen en agache: {volume}.", "current_max_volume": "", "current_mode": "Modo actual de reproducción es {mode}.", "current_volume": "Volumen actual: {volume}.", "database_dropped": "Base de datos descartada. Todos los registros se han ido.", "download_in_progress": "Descarga de {item} en progreso...", "error_executing_command": "{command}: Comando falló, con el siguiente error: {error}.", "file": "Fichero", "file_added": "Añadido {item}.", "file_deleted": "{item} fue eliminado de la biblioteca.", "file_item": "{artist} - {title} añadido por {user}", "file_missed": "Fichero de música '{file}' no encontrado! Este ítem ha sido eliminado de la lista de reproducción.", "help": "

Comandos

\nControl\n
    \n
  • !web - obtener la URL de la interfaz web, en caso de estar activada.
  • \n
  • !play (or !p) [{n}] [{empezar_desde}] - continuar desde pausa / empezar a reproducir (desde la n-ésima canción, si n es introducido)
  • \n
  • !pause - pausar
  • \n
  • !stop - parar la reproducción
  • \n
  • !skip - saltar a la siguiente canción
  • \n
  • !last - saltar a la última canción
  • \n
  • !volume {volumen} - obtener o cambiar el volumen (de 0 a 100)
  • \n
  • !mode [{modo}] - obtener o ajustar el modo de reproducción. {modo} debiera ser o bien one-shot (eliminar el ítem de la lista una vez reproducido), repeat (repetir la lista de reproducción una vez terminada), random (aleatorizar la reproducción), o autoplay (reproducir una muestra aleatoria de canciones de la biblioteca musical).
  • \n
  • !duck on/off - activar o desactivar funcionalidad de agache
  • \n
  • !duckv - ajustar el volumen del bot para cuando se está en modo de agache
  • \n
  • !duckthres - ajustar el nivel de volumen de habla que activa el agache (3000 por defecto)
  • \n
  • !oust - parar la reproducción e ir al canal por defecto del bot
  • \n
\nLista de Reproducción\n
    \n
  • !now (o !np) - mostrar la canción actual
  • \n
  • !queue - mostrar ítems actualmente en la lista de reproducción
  • \n
  • !tag {etiquetas} - añadir todos los ítems con etiquetas {etiquetas}. Éstas deben ir separadas por coma (\",\").
  • \n
  • !file (or !f) {ruta/carpeta/palabra clave} - añadir un único fichero a la lista de reproducción a partir de su ruta o una palabra clave en su ruta.
  • \n
  • !filematch (o !fm) {patrón} - añade todos los ficheros que calzan con la expresión regular {patrón}.
  • \n
  • !url {url} - añade música de Youtube o de SoundCloud
  • \n
  • !playlist {url} [{offset}] - añade todos los ítems en una lista de reproducción de Youtube o de Soundcloud, y empieza desde el primer ítem después del {offset} entregado
  • \n
  • !radio {url} - agrega una radio {url} a la lista de reproducción
  • \n
  • !rbquery {palabra clave} - envía una query a http://www.radio-browser.info para una estación de radio
  • \n
  • !rbplay {id} - reproduce una estación de radio con {id} (por ejemplo, !rbplay 96746)
  • \n
  • !ysearch {palabras clave} - busca en youtube. Use !ysearch -n para avanzar la página.
  • \n
  • !yplay {palabras clave} - añade el primer resultado de la búsqueda de {palabras clave} en Youtube a la lista de reproducción.
  • \n
  • !shortlist (o !sl) {n/*} - añade el {n}-ésimo elemento (o todos los elementos si se entrega *) en la lista corta.
  • \n
  • !rm {n} - elimina la n-ésima canción en la lista de reproducción
  • \n
  • !repeat [{n}] - repite la canción actual {n} veces (1 por defecto).
  • \n
  • !random - baraja la lista de reproducción.
  • \n
\nBiblioteca Musical\n
    \n
  • !search {palabras clave} - encuentra elemento con {palabras clave} en la biblioteca musical. Palabras clave separadas por espacios
  • \n
  • !listfile [{patrón}] - muestra la lista de ficheros disponibles (cuyas rutas calzan con la expresión regular {patrón}, si éste es entregado)
  • \n
  • !addtag [{n}] {etiquetas} - añade {etiquetas} a la {n}-ésima canción (canción actual si {n} es omitida) en la lista de reproducción. Etiquetas separadas por comas (\",\").
  • \n
  • !addtag * {etiquetas} - añade {etiquetas} a todos los elementos en la lista de reproducción.
  • \n
  • !untag [{n/*}] {etiquetas}/* - elimina {etiquetas}/todas las etiquetas de la {n}-ésima canción (canción actual si {n} es omitida) en la lista de reproducción.
  • \n
  • !findtagged (o !ft) {etiquetas} - encuentra elemento con {etiquetas} en la biblioteca musical.
  • \n
  • !delete {n} - elimina {n}-ésimo elemento en la lista corta, de la biblioteca musical.
  • \n
\nOtros\n
    \n
  • !joinme {token} - unirse a tu propio canal con {token}.
  • \n
  • !password {contraseña} - cambia la contraseña que usa para acceder a la interfaz web.
  • \n
", "invalid_index": "Índice {index} inválido. Use '!queue' para ver la lista de reproducción.", "last_song_on_the_queue": "Última en la cola.", "max_volume": "", "multiple_file_added": "Múltiples elementos añadidos:", "multiple_file_deleted": "Múltiples elementos fueron eliminados de la biblioteca:", "multiple_file_found": "Encontrado:", "multiple_matches": "Fichero no encontrado! Posibles candidatos:", "new_version_found": "

Actualización disponible!

La versión {new_version} de botamusique está disponible!
\n

Lista de cambios:

{changelog}
Envía !update para actualizar este bot!", "next_to_play": "Siguiente canción.", "no_file": "Fichero no encontrado.", "not_admin": "Usted no es un administrador!", "not_in_my_channel": "Tú no estás en mi canal!", "not_playing": "Nada se está reproduciendo ahora mismo.", "now_playing": "Reproduciendo {item}", "page_instruction": "Página {current}/{total}. Use !{command} {{page}} para navegar.", "paused": "Música pausada.", "playlist_fetching_failed": "No fue posible obtener la lista de reproducción!", "pm_not_allowed": "Mensajes privados no están permitidos.", "position_in_the_queue": "Posición: {position}", "preconfigurated_radio": "Radio pre-configurada disponible:", "queue_contents": "Elementos en la lista de reproducción:", "queue_empty": "Lista de reproducción está vacía!", "radio": "Radio", "radio_item": "{title} de {name} añadido por {user}", "rb_play_empty": "Por favor especifique el ID de una estación de radio!", "rb_query_result": "Este es el resultado de su consulta, envíe !rbplay {ID} para reproducir una estación:", "records_omitted": "...", "removed_tags": "Eliminadas las etiquetas {tags} de {song}.", "removed_tags_from_all": "Eliminadas las etiquetas {tags} de las canciones en la lista de reproducción.", "removing_item": "Eliminado {item} de la lista de reproducción.", "repeat": "Repetir {song} {n} veces.", "report_version": "La versión actual de botamusique es {version}.", "shortlist_instruction": "Use !sl {índices} para reproducir los elementos que usted desea.", "start_updating": "Empezando la actualización...", "stopped": "Música fue detenida.", "too_long": "{song} es muy larga ({duration} > {max_duration}). Eliminada de la lista de reproducción!", "unable_download": "No fue posible descargar {item}. Eliminado de la biblioteca.", "unable_play": "No fue posible reproducir {item}. Eliminado de la biblioteca.", "unknown_mode": "Modo de reproducción '{mode}' desconocido. Debiera ser o bien one-shot, repeat o random.", "update_successful": "

botamusique v{version} instalado!


\n

Lista de cambios

{changelog}
Visite nuestro repositorio en Github para más detalles!", "url": "URL", "url_ban": "URL {url} está baneada! Eliminada de la lista de reproducción!", "url_ban_list": "", "url_ban_success": "", "url_from_playlist": "URL", "url_from_playlist_item": "{title} de lista de reproducción {playlist} añadido por {user}", "url_item": "{title} añadido por {user}", "url_unban_success": "", "url_unwhitelist_success": "", "url_whitelist_list": "", "url_whitelist_success": "", "user_ban": "Tú estás baneado. No tienes permitido hacer eso!", "user_ban_list": "", "user_ban_success": "", "user_password_set": "Su contraseña ha sido actualizada.", "user_unban_success": "", "web_user_list": "Los siguientes usuarios tienen el privilegio de acceder a la interfaz web:
{users}", "webpage_address": "Tu dirección web para acceder a la interfaz es {address}", "which_command": "Quieres decir
{commands}", "wrong_pattern": "Expresión regular inválida: {error}", "yt_no_more": "No hay más resultados!", "yt_query_error": "Fue imposible consultar a youtube!", "yt_result": "Resultado de la consulta a youtube: {result_table} Use !sl {{índices}} para reproducir el elemento que usted desea.
\n!ytquery -n para la siguiente página." }, "web": { "action": "Acción", "add": "Añadir", "add_all": "Añadir todas", "add_radio": "Añadir Radio", "add_radio_url": "Añadir URL de radio", "add_to_bottom": "Añadir al final", "add_to_bottom_of_current_playlist": "Añadir al final de la lista de reproducción actual", "add_to_playlist_next": "Añadir a la lista de reproducción justo después de la canción actual", "add_url": "Añadir URL", "add_youtube_or_soundcloud_url": "Añadir URL de Youtube o de Soundcloud", "are_you_really_sure": "¿Está usted realmente seguro?", "aria_botamusique_logo": "El logo de Botamusique: un zorro con dos audífonos, disfrutando de la música", "aria_default_cover": "Un cuadrado negro, con dos corcheas unidas entre sí.", "aria_empty_box": "El dibujo de una caja vacía.", "aria_remove_this_song": "Sacar esta canción de la lista de reproducción actual", "aria_skip_current_song": "Saltar la canción actual y reproducir esta canción ahora mismo", "aria_skip_to_next_track": "Saltar a la siguiente canción", "aria_spinner": "Una curva siguiendo la forma de un círculo, para indicar que el elemento está cargándose todavía.", "aria_warning_of_deletion": "Advertencia acerca de la eliminación de ficheros.", "autoplay": "Reproducción automática", "browse_music_file": "Explorar fichero de música", "cancel": "Cancelar", "cancel_upload_warning": "¿Está realmente seguro?
Haga click de nuevo para abortar la subida.", "change_playback_mode": "Cambiar Modo de Reproducción.", "choose_file": "Elija un fichero", "clear_playlist": "Vaciar la lista de reproducción", "close": "Cerrar", "delete_all": "Borrar todo", "delete_all_files": "Eliminar todos los ficheros listados", "delete_file_warning": "Todos los archivos listados aquí, incluyendo ficheros en otras páginas, serán eliminados de su disco duro.\n ¿Es eso lo que usted desea?", "directory": "Directorio", "download_all": "Descargar todo", "download_song_from_library": "Descargar canción desde la biblioteca", "edit_submit": "Editar!", "edit_tags_for": "Editar etiquetas para", "expand_playlist": "Ver elemento en la lista de reproducción.", "file": "Fichero", "filters": "Filtros", "index": "#", "keywords": "Palabras clave", "keywords_placeholder": "Palabras clave...", "mini_player_title": "Ahora reproduciendo...", "music_library": "Biblioteca musical", "next_to_play": "Siguiente canción a reproducir", "no_tag": "Sin etiquetas", "oneshot": "One-shot", "open_volume_controls": "Abrir controles de volumen", "page_title": "Interfaz web de botamusique", "pause": "Pausar", "play": "Reanudar", "playlist_controls": "Controles de la lista de reproducción", "radio": "Radio", "radio_url_placeholder": "URL de radio...", "random": "Aleatorio", "remove_song_from_library": "Eliminar canción de la biblioteca", "repeat": "Repetir", "rescan_files": "Volver a escanear ficheros", "skip_track": "Saltar canción", "submit": "Enviar", "tags": "Etiquetas", "tags_to_add": "Etiquetas a añadir", "title": "Título", "token": "Token", "token_required": "Se requiere una token", "token_required_message": "Tú estás accediendo a la interfaz web de {{ name }}.\nUna token es necesaria para otorgarte acceso.
\nPor favor, envíe \"{{ command }}\" al bot en mumble para obtener una.", "type": "Tipo", "upload_file": "Subir Fichero", "upload_submit": "Subir!", "upload_to": "Subir a", "uploaded_finished": "Subida terminada!", "uploading_files": "Subiendo ficheros...", "url": "URL", "url_path": "Url/Ruta", "url_placeholder": "URL...", "volume_slider": "Control deslizante de volumen" } } ================================================ FILE: lang/fr_FR.json ================================================ { "cli": { "added_tags": "Tags {tags} ajoutés à {song}.", "added_tags_to_all": "Tags {tags} ajoutés aux musiques de la playlist.", "admin_help": "

Commandes Admin

\nBot\n
    \n
  • !kill - tuer le bot
  • \n
  • !update - update the bot
  • \n
  • !userban {user} - bannir un utilisateur
  • \n
  • !userunban {user} - unban a user
  • \n
  • !urlbanlist - liste url interdite
  • \n
  • !urlban [{url}] - interdire {url} (ou l'url de l'élément courant par défaut) et supprimer cette url de la bibliothèque.
  • \n
  • !urlunban {url} - unban {url}
  • \n
  • !rescan {url} - reconstruction du cache des fichiers musicaux locaux
  • \n
  • !dropdatabase - effacez toute la base de données, vous perdrez tous les paramètres et la bibliothèque musicale.
  • \n
\nInterface Web\n
    \n
  • !webuserlist - liste de tous les utilisateurs qui ont la permission d'accéder à l'interface web, si le mode d'authentification est 'password'.
  • \n
  • !webuseradd {nick name} - accorder à l'utilisateur avec {nick name} l'accès à l'interface web, si le mode d'authentification est 'password'.
  • \n
  • !webuserdel {nick name} - révoquer l'accès à l'interface web de {nick name}, si le mode d'authentification est 'password'.
  • \n
", "auto_paused": "!play pour reprendre la lecture!", "bad_command": "{{command}}: commande non trouvé.", "bad_parameter": "{command}: commande invalide.", "bad_url": "Mauvaise URL demandé", "cache_refreshed": "Cache actualisé!", "change_ducking_volume": "Volume sur le ducking réglé sur {volume} par {user}.", "change_max_volume": "Volume max configuré à {max} par {user}", "change_mode": "Mode de lecture réglé sur {mode} par {user}.", "change_volume": "Volume réglé sur {volume} par {user}.", "cleared": "Playlist vidée.", "cleared_tags": "Suppression de tous les tag de {song}.", "cleared_tags_from_all": "Suppression de tous les tags des chansons de la playlist.", "command_disabled": "{command} : commande désactivée !", "current_ducking_volume": "Volume de ducking: {volume}.", "current_max_volume": "Volume max actuel : {max}", "current_mode": "Le mode de lecture actuel est {mode}.", "current_volume": "Volume actuel : {volume}.", "database_dropped": "La base de données a été supprimée. Tous les enregistrements ont disparu.", "download_in_progress": "Téléchargement de {item} en cours...", "error_executing_command": "{command} : La commande a échoué avec l'erreur : {error}.", "file": "Fichier", "file_added": "{item} ajouté.", "file_deleted": "{item} supprimé de la bibliothèque.", "file_item": "{artist} - {title} ajouté par {user}", "file_missed": "Fichier audio '{file}' introuvable! Cet élément a été supprimé de la playlist.", "help": "

Commandes

\nControl\n
    \n
  • !web - obtenir l'URL de l'interface web, si elle est activée.
  • \n
  • !play (ou !p) [{num}] [{start_from}] - reprise de la pause / début de la lecture (à partir de la n° X s'il est donné)
  • \n
  • !pause - pause
  • \n
  • !stop - arrêtez de jouer
  • \n
  • !skip - passer à la chanson suivante
  • \n
  • !last - passer à la dernière chanson
  • \n
  • !volume {volume} - obtenir ou modifier le volume (de 0 à 100)
  • \n
  • !mode [{mode}] - obtenir ou définir le mode de lecture, {mode} doit être l'un de one-shot (supprimer l'élément une fois joué), repeat (boucle de la liste de lecture), ramdom (liste de lecture aléatoire),\nautoplay (prendre au hasard dans la bibliothèque musicale).
  • \n
  • !duck on/off - activer ou désactiver la fonction d'esquive
  • \n
  • !duckv {volume} - définit le volume du bot lorsque le ducking est activé
  • \n
  • !duckthres - définir le seuil de volume pour activer le ducking (3000 par défaut)
  • \n
  • !oust - arrêtez de jouer et passez sur le canal par défaut
  • \n
\nPlayist\n
    \n
  • !now (ou !np) - afficher la chanson actuelle
  • \n
  • !queue - afficher les éléments de la playlist
  • \n
  • !tag {balises} - ajouter tous les éléments avec les tags {tags}, les balises séparées par \",\".
  • \n
  • !file (ou !f) {chemin/dossier/mot-clé} - ajoute un seul fichier à la playlist par son chemin ou un mot-clé.
  • \n
  • !filematch (ou !fm) {pattern} - ajouter tous les fichiers qui correspondent à la regex {pattern}
  • \n
  • !url {url} - ajouter de la musique Youtube ou SoundCloud
  • \n
  • !playlist {url} [{offset}] - ajouter tous les éléments d'une liste de lecture Youtube ou SoundCloud, et commencer par le {offset}-ième élément
  • \n
  • !radio {url} - ajouter une radio {url} à la playlist
  • \n
  • !rbquery {keyword} - interroger http://www.radio-browser.info pour une station de radio
  • \n
  • !rbplay {id} - jouer une station de radio avec {id} (ex. !rbplay 96746)
  • \n
  • !ysearch {keywords} - requête youtube. Utilisez !ysearch -n pour aller à la page d'après.
  • \n
  • !yplay {keywords} - ajouter le premier résultat de recherche de {keyword} dans la playlist.
  • \n
  • !shortlist (ou !sl) {index/*} - ajouter {index}-ième élément (ou tous les éléments si * est donné) de la liste.
  • \n
  • !rm {num} - supprimer le num-ième morceau de la playlist
  • \n
  • !repeat [{num}] - répéter la chanson actuelle {num} (1 par défaut) times.
  • \n
  • !random - randomiser la playlist.
  • \n
\nBibliothèque musicale\n
    \n
  • !search {keywords} - trouver un élément avec {mots-clés} dans la bibliothèque musicale, mots-clés séparés par un espace.
  • \n
  • !listfile [{pattern}] - affiche la liste des fichiers disponibles (dont les chemins correspondent au motif de regex si {pattern} est donné)
  • \n
  • !addtag [{index}] {tags} - ajouter {tags} à {index} (current song if {index} n'existe pas) de la playliste, tags séparer par \",\".
  • \n
  • !addtag * {tags} - ajouter des {tags} à tous les éléments de la playlist.
  • \n
  • !untag [{index/*}] {tags}/* - supprimer {tags}/toutes les tags de {index}-th(current song if {index} is oitted) item on the playlist.
  • \n
  • !findtagged (ou !ft) {tags} - trouver un élément avec des {balises} dans la bibliothèque.
  • \n
  • !delete {index} - supprimer le {index}-ième élément de la liste de la bibliothèque.
  • \n
\nAutre\n
    \n
  • !joinme {token} - rejoins votre propre channel mumble avec {token}.
  • \n
  • !password {password} - changer votre mot de passe, utilisé pour accéder à l'interface web.
  • \n
", "invalid_index": "Index non valide {index}. Utilisez '!queue' pour voir la playlist.", "last_song_on_the_queue": "Dernier de la file d'attente.", "max_volume": "Le volume dépasse le maximum {max}. Réglage du volume sur le max.", "multiple_file_added": "Ajout de plusieurs éléments :", "multiple_file_deleted": "Plusieurs éléments ont été supprimés de la bibliothèque :", "multiple_file_found": "Trouvé :", "multiple_matches": "Fichier non trouvé ! Candidats possibles :", "new_version_found": "

Mise à jour disponible!

La version {new_version} de botamusique est disponible !
\n

Changelog

{changelog}
Envoyer !update pour mettre à jour !", "next_to_play": "Chanson suivante.", "no_file": "Fichier non trouvé.", "not_admin": "Vous n'êtes pas un admin !", "not_in_my_channel": "Vous n'êtes pas dans mon canal, commande refusé !", "not_playing": "Rien n'est joué en ce moment.", "now_playing": "En cours de lecture {item}", "page_instruction": "Page {current}/{total}. Utilisez !{command} {{page}} pour naviguer.", "paused": "Music en pause.", "playlist_fetching_failed": "Impossible d'obtenir la playlist !", "pm_not_allowed": "Les messages privés ne sont pas autorisés.", "position_in_the_queue": "Position: {position}", "preconfigurated_radio": "Radio préconfigurées disponible :", "queue_contents": "Éléments de la playlist :", "queue_empty": "La playlist est vide !", "radio": "Radio", "radio_item": "{title} from {name} ajouté par {user}", "rb_play_empty": "Veuillez préciser l'ID de la station de radio !", "rb_query_result": "Résultat de votre requête, envoyez !rbplay 'ID' pour jouer une station :", "records_omitted": "...", "removed_tags": "Suppression des tags {tags} de {song}.", "removed_tags_from_all": "Suppression des tags {tags} des chansons de la playlist.", "removing_item": "Entrée {item} suprimée de la playlist.", "repeat": "Répète {song} {n} fois.", "report_version": "La version actuelle de botamusique est {version}{/b}.", "shortlist_instruction": "Utilisez !sl {indexes} pour jouer l'élément que vous voulez.", "start_updating": "Début de la mise à jour...", "stopped": "Musique arrêté.", "too_long": "{song} est trop long ({duration} > {max_duration}), supprimé de la playlist !", "unable_download": "Impossible de télécharger {item}. Retiré de la bibliothèque.", "unable_play": "Impossible de jouer {item}. Retiré de la bibliothèque.", "unknown_mode": "Mode de lecture \"{mode}\" inconnu. Il devrait s'agir d'un des modes suivants : one-shot, repeat, random.", "update_successful": "

botamusique v{version} Installé !


\n

Changelog

{changelog}
Visitez notre repo github pour plus de détails !", "url": "URL", "url_ban": "URL {url} est interdite !", "url_ban_list": "Liste des URL bannies:
{list=", "url_ban_success": "L'URL suivante est interdite: {url}", "url_from_playlist": "URL", "url_from_playlist_item": "{title} depuis la playlist {playlist} ajouté par {user}", "url_item": "{title} ajouté par {user}", "url_unban_success": "L'URL suivante est débloquée : {url}.", "url_unwhitelist_success": "L'URL suivante n'est pas sur liste blanche : {url}.", "url_whitelist_list": "Liste des URL sur liste blanche:
{list}", "url_whitelist_success": "L'URL suivante est sur la liste blanche : {url}.", "user_ban": "Vous êtes banni, vous n'avez donc pas le droit de faire cela !", "user_ban_list": "Liste des utilisateurs bannis:
{list}", "user_ban_success": "L'utilisateur {user} est banni.", "user_password_set": "Votre mot de passe a été mis à jour.", "user_unban_success": "L'utilisateur {user} n'est plus banni.", "web_user_list": "Les utilisateurs suivants ont l'autorisation d'accéder à l'interface web :
{users}", "webpage_address": "Votre propre adresse pour accéder à l'interface web est {address}", "which_command": "Voulez-vous dire
{commands}", "wrong_pattern": "regex invalide: {error}.", "yt_no_more": "Plus de résultats !", "yt_query_error": "Impossible d'interroger youtube !", "yt_result": "Résultat de la requête Youtube : {result_table} Utilisez !sl {{indexes}} pour jouer l'entrée que vous voulez.
\n!ytquery -n pour la page suivante." }, "web": { "action": "Action", "add": "Ajouter", "add_all": "Ajouter tout", "add_radio": "Ajouter une Radio", "add_radio_url": "Ajouter l'URL d'une Radio", "add_to_bottom": "Ajouter à la fin", "add_to_bottom_of_current_playlist": "Ajouter à la fin de la playlist actuelle", "add_to_playlist_next": "Ajouter à la playlist juste après la chanson en cours", "add_url": "Ajouter l'URL", "add_youtube_or_soundcloud_url": "Ajouter une URL Youtube ou Soundcloud", "are_you_really_sure": "En êtes-vous vraiment sûr ?", "aria_botamusique_logo": "Logo Botamusique : un renard avec deux écouteurs, appréciant la musique", "aria_default_cover": "Un carré noir avec deux croches qui se rejoignent.", "aria_empty_box": "Un dessin d'une boîte vide.", "aria_remove_this_song": "Supprimer cette chanson de la playlist actuelle", "aria_skip_current_song": "Passer la chanson actuelle et jouer cette chanson maintenant", "aria_skip_to_next_track": "Passer à la piste suivante", "aria_spinner": "Une roue de chargement", "aria_warning_of_deletion": "Avertissement concernant la suppression de fichiers.", "autoplay": "Autoplay", "browse_music_file": "Parcourir le dossier de musique", "cancel": "Annuler", "cancel_upload_warning": "Etes-vous vraiment sûr ?
Cliquez à nouveau pour interrompre le téléchargement.", "change_playback_mode": "Changer de mode de lecture", "choose_file": "Choisissez un fichier", "clear_playlist": "Vider la playlist", "close": "Fermer", "delete_all": "Supprimer tous", "delete_all_files": "Supprimer tous les fichiers répertoriés", "delete_file_warning": "Tous les fichiers énumérés ici, y compris les fichiers des autres pages, seront supprimés de votre disque dur.\n C'est ce que vous voulez ?", "directory": "Répertoire", "download_all": "Télécharger tout", "download_song_from_library": "Télécharger une chanson de la bibliothèque", "edit_submit": "Editer !", "edit_tags_for": "Modifier les tags pour", "expand_playlist": "Voir le point sur la playlist.", "file": "Dossier", "filters": "Filtres", "index": "#", "keywords": "Mots-clés", "keywords_placeholder": "Mots-clés...", "mini_player_title": "En train de jouer...", "music_library": "Bibliothèque musicale", "next_to_play": "Suivant à jouer", "no_tag": "Pas de tag", "oneshot": "One-shot", "open_volume_controls": "Ouvrir le contrôle de volume", "page_title": "Interface Web botamusique", "pause": "Pause", "play": "Jouer", "playlist_controls": "Contrôle des playlists", "radio": "Radio", "radio_url_placeholder": "URL de la radio...", "random": "Aléatoire", "remove_song_from_library": "Retirer une chanson de la bibliothèque", "repeat": "Répéter", "rescan_files": "Re-scanner les fichiers", "skip_track": "Passer la piste", "submit": "Envoyer", "tags": "Tags", "tags_to_add": "Tags à ajouter", "title": "Titre", "token": "Token", "token_required": "Token requis", "token_required_message": "Vous accédez à l'interface web de {{ name }}.\nUn jeton est nécessaire pour vous permettre d'y accéder.
\nVeuillez envoyer \"{{ command }}\" au bot sur mumble pour en acquérir un.", "type": "Type", "upload_file": "Télécharger un fichier", "upload_submit": "Téléchargez !", "upload_to": "Télécharger vers", "uploaded_finished": "Téléchargement terminé !", "uploading_files": "Téléchargement de fichiers...", "url": "URL", "url_path": "Url/Path", "url_placeholder": "URL...", "volume_slider": "Curseur de volume" } } ================================================ FILE: lang/it_IT.json ================================================ { "cli": { "added_tags": "Tag {tags} aggiunti a {song}.", "added_tags_to_all": "I tag {tags} sono stati aggiunti ai brani nella playlist.", "admin_help": "

Comandi amministratore

\nBot\n
    \n
  • !kill - Termina il bot.
  • \n
  • !update - Aggiorna il bot.
  • \n
  • !userban {user} - Banna utente.
  • \n
  • !userunban {user} - Sbanna utente.
  • \n
  • !urlbanlist - Elenco URL vietati.
  • \n
  • !urlban [{url}] - Banna {url} (o URL dell'elemento corrente come impostazione predefinita) e rimuovi questo URL dalla libreria.
  • \n
  • !urlunban {url} - Sbanna {url}.
  • \n
  • !rescan {url} - Ricostruisce la cache dei file musicali locali.
  • \n
  • !dropdatabase - Cancella l'intero database, perderai tutte le impostazioni e la libreria musicale.
  • \n
\nInterfaccia Web\n
    \n
  • !webuserlist - Elenca tutti gli utenti che hanno il permesso di accedere all'interfaccia web, se la modalità di autenticazione è 'password'.
  • \n
  • !webuseradd {nick name} - Concedi all'utente con {nick name} l'accesso all'interfaccia web, se la modalità di autenticazione è 'password'.
  • \n
  • !webuserdel {nick name} - Revoca l'accesso all'interfaccia web di {nick name}, se la modalità di autenticazione è 'password'.
  • \n
\"", "auto_paused": "Usa !play per riprendere la musica!", "bad_command": "{command}: comando non trovato.", "bad_parameter": "{command}: parametro non valido.", "bad_url": "È stato richiesto un URL non valido.", "cache_refreshed": "Cache aggiornata!", "change_ducking_volume": "Volume del ducking impostato a {volume} da {user}.", "change_max_volume": "", "change_mode": "Modalità di riproduzione impostata su {mode} da {user}.", "change_volume": "Volume impostato a {volume} da {user}.", "cleared": "Playlist svuotata.", "cleared_tags": "Rimossi tutti i tag da {song}.", "cleared_tags_from_all": "Rimossi tutti i tag dai brani nella playlist.", "command_disabled": "{command}: comando disabilitato!", "current_ducking_volume": "Volume ducking attuale: {volume}.", "current_max_volume": "", "current_mode": "Modalità di riproduzione corrente: {mode}.", "current_volume": "Volume attuale: {volume}.", "database_dropped": "Database eliminato. Tutti i dati sono andati.", "download_in_progress": "Scaricamento di {item} in corso...", "error_executing_command": "{command}: Comando non riuscito con errore: {error}.", "file": "File", "file_added": "{item} aggiunto.", "file_deleted": "{item} eliminato dalla libreria.", "file_item": "{artist} - {title} aggiunto da {user}", "file_missed": "File musicale \"{file}\" mancante! Questo elemento è stato rimosso dalla playlist.", "help": "

Comandi

\nControllo\n
    \n
  • !web - ottenere l'URL dell'interfaccia web, se abilitata.
  • \n
  • !play (or !p) [{num}] [{start_from}] - Riprende dalla pausa / avvia la riproduzione (dal numero {num} se fornito).
  • \n
  • !pause - Pausa.
  • \n
  • !stop - Arresta riproduzione.
  • \n
  • !skip - Passa al brano successivo.
  • \n
  • !last - Passa all'ultimo brano.
  • \n
  • !volume {volume} - Ottenere o modificare il volume (da 0 a 100).
  • \n
  • !mode [{mode}] - Ottenere o impostare la modalità di riproduzione, {mode} dovrebbe essere one-shot (rimuove l'elemento una volta riprodotto), repeat (ripete la playlist dopo il completamento), random (riproduzione casuale della playlist), autoplay (riproduce brani casuali dalla libreria musicale).
  • \n
  • !duck on/off - Abilitare o disabilitare la funzione ducking.
  • \n
  • !duckv {volume} - Imposta il volume del bot quando il ducking è attivato.
  • \n
  • !duckthres - Imposta la soglia del volume per attivare il ducking (3000 per impostazione predefinita).
  • \n
  • !oust - Interrompe la riproduzione e vai al canale predefinito.
  • \n
\nPlaylist\n
    \n
  • !now (or !np) - Visualizza il brano corrente.
  • \n
  • !queue - Visualizza gli elementi nella playlist.
  • \n
  • !tag {tags} - Aggiungi tutti gli elementi con i tag {tags}, tag separati da \",\".
  • \n
  • !file (or !f) {path/folder/keyword} - Aggiungi un singolo file alla playlist tramite il percorso o la parola chiave nel percorso.
  • \n
  • !filematch (or !fm) {pattern} - Aggiungi tutti i file che corrispondono all'espressione regolare {pattern}.
  • \n
  • !url {url} - Aggiungi musica da YouTube o SoundCloud.
  • \n
  • !playlist {url} [{offset}] - Aggiungi tutti gli elementi da una playlist di YouTube o SoundCloud e inizia con l'elemento {offset}.
  • \n
  • !radio {url} - Aggiungi una radio {url} alla playlist.
  • \n
  • !rbquery {keyword} - Interroga http://www.radio-browser.info per una stazione radio.
  • \n
  • !rbplay {id} - Riproduce una stazione radio con {id} (es. !rbplay 96746).
  • \n
  • !ysearch {keywords} - Interroga YouTube. Usa !ysearch -n per andare alla pagina successiva.
  • \n
  • !yplay {keywords} - Aggiungi il primo risultato di ricerca per {keyword} alla playlist.
  • \n
  • !shortlist (or !sl) {indexes/*} - Aggiungi {index}-esimo elemento (o tutti gli elementi se * è dato) alla lista.
  • \n
  • !rm {num} - Rimuove il brano {num} dalla playlist.
  • \n
  • !repeat [{num}] - Ripete il brano corrente {num} volte (1 per impostazione predefinita).
  • \n
  • !random - Playlist in riproduzione casuale.
  • \n
\nLibreria Musicale\n
    \n
  • !search {keywords} - Trova l'elemento con {keywords} nella libreria musicale, parole chiave separate da spazio.
  • \n
  • !listfile [{pattern}] - Mostra l'elenco dei file disponibili (i cui percorsi corrispondono all'espressione regolare {pattern}, se fornito).
  • \n
  • !addtag [{index}] {tags} - Aggiunge {tag} a {index} (brano corrente se {index} è omesso) della playlist, tag separati da \",\".
  • \n
  • !addtag * {tags} - Aggiunge {tags} a tutti gli elementi sulla playlist.
  • \n
  • !untag [{index/*}] {tags}/* - Rimuove {tags}/tutti i tag dall'elemento {index} (brano corrente se {index} è omesso) nella playlist.
  • \n
  • !findtagged (or !ft) {tags} - Trova l'elemento con {tags} nella libreria musicale.
  • \n
  • !delete {index} - Rimuove {index} elemento dall'elenco della libreria musicale.
  • \n
\nAltro\n
    \n
  • !joinme {token} - Unisciti al tuo canale Mumble con {token}.
  • \n
  • !password {password} - Cambia la password, utilizzata per accedere all'interfaccia web.
  • \n
\",", "invalid_index": "Indice {index} non valido. Usa !queue per vedere la playlist.", "last_song_on_the_queue": "Ultimo in coda.", "max_volume": "", "multiple_file_added": "Più elementi aggiunti:", "multiple_file_deleted": "Più elementi eliminati dalla libreria:", "multiple_file_found": "Trovati:", "multiple_matches": "File non trovato! Possibili candidati:", "new_version_found": "

Aggiornamento disponibile!

Versione {new_version} di botamusique trovata!
\\n

Changelog

{changelog}
Invia !update per aggiornare!", "next_to_play": "Brano successivo.", "no_file": "File non trovato.", "not_admin": "Non sei un amministratore!", "not_in_my_channel": "Non sei nel mio canale!", "not_playing": "Niente in riproduzione in questo momento.", "now_playing": "{item} in riproduzione", "page_instruction": "Pagina {corrente}/{totale}. Usa !{command} {{page}} per navigare.", "paused": "Musica in pausa.", "playlist_fetching_failed": "Impossibile recuperare la playlist!", "pm_not_allowed": "Messaggi privati non consentiti.", "position_in_the_queue": "Posizione: {position}", "preconfigurated_radio": "Radio preconfigurate disponibili:", "queue_contents": "Elementi nella playlist:", "queue_empty": "La playlist è vuota!", "radio": "Radio", "radio_item": "{title} di {name} aggiunto da {user}", "rb_play_empty": "Si prega di specificare l'ID di una stazione radio!", "rb_query_result": "Questo è il risultato della tua ricerca, invia !rbplay {ID} per riprodurre una stazione:", "records_omitted": "...", "removed_tags": "Tag {tags} rimossi da {song}.", "removed_tags_from_all": "Tag {tags} rimossi dai brani nella playlist.", "removing_item": "Voce {item} rimossa dalla playlist.", "repeat": "Ripeti {song} per {n} volte.", "report_version": "La versione attuale di Botamusique è {version}.", "shortlist_instruction": "Usa !sl {indexes} per riprodurre l'elemento desiderato.", "start_updating": "Inizio aggiornamento...", "stopped": "Riproduzione interrotta.", "too_long": "{song} è troppo lunga ({duration} > {max_duration}), rimossa dalla playlist!", "unable_download": "Impossibile scaricare {item}. Rimosso dalla libreria.", "unable_play": "Impossibile riprodurre {item}. Rimosso dalla libreria.", "unknown_mode": "Modalità di riproduzione '{mode}' sconosciuta. Dovrebbe essere one-shot, ripeti, casuale.", "update_successful": "

botamusique v{version} installato!


\n

Changelog

{changelog}
Visita la nostra repository GitHub per ulteriori dettagli!", "url": "URL", "url_ban": "URL {url} è vietato!", "url_ban_list": "", "url_ban_success": "", "url_from_playlist": "URL", "url_from_playlist_item": "{title} dalla playlist {playlist} aggiunto da {user}", "url_item": "{title} aggiunto da {user}", "url_unban_success": "", "url_unwhitelist_success": "", "url_whitelist_list": "", "url_whitelist_success": "", "user_ban": "Sei bannato, non ti è permesso farlo!", "user_ban_list": "", "user_ban_success": "", "user_password_set": "La tua password è stata aggiornata.", "user_unban_success": "", "web_user_list": "I seguenti utenti hanno il privilegio di accedere all'interfaccia web:
{users}", "webpage_address": "Il tuo indirizzo per accedere all'interfaccia web è {address}", "which_command": "Intendi
{commands}", "wrong_pattern": "Espressione regolare non valida: {error}.", "yt_no_more": "Nessun altro risultato!", "yt_query_error": "Impossibile consultare YouTube!", "yt_result": "Risultato ricerca YouTube: {result_table} Usa !sl {{indexes}} per riprodurre l'elemento desiderato.
\\n!ytquery -n per la pagina successiva." }, "web": { "action": "Azione", "add": "Aggiungi", "add_all": "Aggiungi tutto", "add_radio": "Aggiungi Radio", "add_radio_url": "Aggiungi URL Radio", "add_to_bottom": "Aggiungi in fondo", "add_to_bottom_of_current_playlist": "Aggiungi in fondo alla playlist corrente", "add_to_playlist_next": "Aggiungi alla playlist subito dopo il brano corrente", "add_url": "Aggiungi URL", "add_youtube_or_soundcloud_url": "Aggiungi URL di YouTube o SoundCloud", "are_you_really_sure": "Sei davvero sicuro?", "aria_botamusique_logo": "Botamusique Logo: una volpe con due cuffie, che si gode la musica", "aria_default_cover": "Un quadrato nero con due ottave unite insieme.", "aria_empty_box": "Il disegno di una scatola vuota.", "aria_remove_this_song": "Rimuovi questo brano dalla playlist corrente", "aria_skip_current_song": "Salta il brano corrente e riproduci ora questo brano", "aria_skip_to_next_track": "Passa alla traccia successiva", "aria_spinner": "Una ruota di caricamento", "aria_warning_of_deletion": "Avviso sulla cancellazione dei file.", "autoplay": "Riproduzione automatica", "browse_music_file": "Sfoglia file musicali", "cancel": "Annulla", "cancel_upload_warning": "Sei davvero sicuro?
Fare di nuovo clic per interrompere il caricamento.", "change_playback_mode": "Cambia modalità di riproduzione", "choose_file": "Scegli il file", "clear_playlist": "Cancella playlist", "close": "Chiudi", "delete_all": "Cancella tutto", "delete_all_files": "Elimina tutti i file elencati", "delete_file_warning": "Tutti i file elencati qui, inclusi i file in altre pagine, verranno eliminati dal disco rigido.\n È questo che vuoi?", "directory": "Directory", "download_all": "Scarica tutto", "download_song_from_library": "Scarica il brano dalla libreria", "edit_submit": "Modifica!", "edit_tags_for": "Modifica tag per", "expand_playlist": "Vedi elemento nella playlist.", "file": "File", "filters": "Filtri", "index": "#", "keywords": "Parole chiave", "keywords_placeholder": "Parole chiave...", "mini_player_title": "In riproduzione...", "music_library": "Libreria musicale", "next_to_play": "Brano seguente", "no_tag": "Nessun tag", "oneshot": "One-shot", "open_volume_controls": "Apri i controlli del volume", "page_title": "Interfaccia Web di botamusique", "pause": "Pausa", "play": "Play", "playlist_controls": "Controlli playlist", "radio": "Radio", "radio_url_placeholder": "URL Radio...", "random": "Casuale", "remove_song_from_library": "Rimuovi brano dalla libreria", "repeat": "Ripeti", "rescan_files": "Riesegui la scansione dei file", "skip_track": "Salta traccia", "submit": "Invia", "tags": "Tag", "tags_to_add": "Tag da aggiungere", "title": "Titolo", "token": "Token", "token_required": "Token richiesto", "token_required_message": "Stai accedendo all'interfaccia web di {{ name }}.\nÈ necessario un token per concederti l'accesso.
\nPer favore invia \\\"{{ command }}\\\" al bot in mumble per acquisirne uno.", "type": "Genere", "upload_file": "Carica file", "upload_submit": "Carica!", "upload_to": "Carica in", "uploaded_finished": "Caricamento terminato!", "uploading_files": "Caricamento file...", "url": "URL", "url_path": "Url/Percorso", "url_placeholder": "URL...", "volume_slider": "Cursore del volume" } } ================================================ FILE: lang/ja_JP.json ================================================ { "cli": { "added_tags": "{song}{tags}というタグを追加しました。", "added_tags_to_all": "再生リストの曲に{tags}というタグを追加しました。", "admin_help": "

管理者コマンド

\nBot\n
    \n
  • !kill - botを終了する。
  • \n
  • !update - 自動更新する。
  • \n
  • !userban {user} - このユーザーを禁止する。
  • \n
  • !userunban {user} - このユーザーの禁止を解除する。
  • \n
  • !urlbanlist - 禁止さらたユーザーリスト
  • \n
  • !urlban [{url}] - {url} (デフォルトは今の曲のURL)を禁止する。ライブラリに削除する。
  • \n
  • !urlunban {url} - このURLの禁止を解除する。
  • \n
  • !rescan {url} - 本機の音楽フォルダをスキャン直す。
  • \n
  • !dropdatabase - 全部設定とライブラリを消去する。
  • \n
\nWeb Interface\n
    \n
  • !webuserlist - list all users that have the permission of accessing the web interface, if auth mode is 'password'.
  • \n
  • !webuseradd {nick name} - grant the user with {nick name} the access to the web interface, if auth mode is 'password'.
  • \n
  • !webuserdel {nick name} - revoke the access to the web interface of {nick name}, if auth mode is 'password'.
  • \n
", "auto_paused": "音楽を再開するには、!play を送信してください。", "bad_command": "{command}: コマンドが見つかりません。", "bad_parameter": "{command}: パラメータが不正です。", "bad_url": "URLが不正です。", "cache_refreshed": "キャッシュが更新されました。", "change_ducking_volume": "{user}は「ダッキング」が触発する時の音量を{volume}に設定しました。", "change_max_volume": "", "change_mode": "{user}がプレイモードを{mode}に設定しました。", "change_volume": "{user}が音量を{volume}に設定しました。", "cleared": "再生リストがクリアされました。", "cleared_tags": "{song}のタグが全部クリアされました。", "cleared_tags_from_all": "再生リスト内の全ての曲のタグがクリアされました。", "command_disabled": "{command}: この命令は利用できません。", "current_ducking_volume": "「ダッキング」が触発する時の音量:{volume}。", "current_max_volume": "", "current_mode": "現在のプレイモードは{mode}です。", "current_volume": "現在の音量は{volume}です。", "database_dropped": "データベースがクリアされました。", "download_in_progress": "今は{item}をダウンロード中…", "error_executing_command": "{command}: コマンドが失敗しまいました,エラーは {error}。", "file": "ファイル", "file_added": "新しい曲が追加しました:{item}。", "file_deleted": "{item}がライブラリから削除されました。", "file_item": "{artist} - {title}{user}によって追加しました。", "file_missed": "'{file}' が見つかりません!プレイリストから削除します。", "help": "

コマンドの使い方


\n\nbotを操縦する\n\n
    \n
  • !web - ウェブインターフェースのアドレスを取得する。
  • \n
  • !play (= !p) [{num}] [{start_from}] - 再生を再開する・第{num}番目を再生する。
  • \n
  • !pause - 一時停止。
  • \n
  • !stop - 再生停止。
  • \n
  • !skip - 次の曲にスキップする。
  • \n
  • !last - 最後の曲にスキップする。
  • \n
  • !volume {volume} - 音量を取得・設定する(0〜100)。
  • \n
  • !mode [{mode}] - 再生モードを設定する。 {mode} はone-shotrepeatrandom、 \nautoplay 四つ中の一つです。
  • \n
  • !duck on/off - 「ダッキング」を起動する(人が喋る時自動的に音量を下げる)。
  • \n
  • !duckv {volume} - 「ダッキング」の音量を取得・設定する(0〜100)。
  • \n
  • !duckthres - 「ダッキング」を触発ために必要なオーディオ信号の閾値を設定する(デフォルトは3000)。
  • \n
  • !oust - 再生を停止する、そして最初のチャネルに戻る。
  • \n

\n\n再生リスト
\n\n
    \n
  • !now (= !np) - 今放送中の曲のインフォを取得する。
  • \n
  • !queue - 再生リストを表示する。
  • \n
  • !tag {tags} - ライブラリの中にタグ「{tags}」がある曲を再生リストに追加する。
  • \n
  • !file (= !f) {path/folder/keyword} - 本機にある音楽フェイル・フォルダを追加する。
  • \n
  • !filematch (or !fm) {pattern} - ファイルパスが正規表現パターン「{pattern}」にマッチされる曲を追加する。
  • \n
  • !url {url} - Youtube/SoundCloudリンクを追加する。
  • \n
  • !playlist {url} [{offset}] - Youtube/SoundCloud再生リストを追加する。
  • \n
  • !radio {url} - アドレス「{url}」のウェブラジオを追加する。
  • \n
  • !rbquery {keyword} - http://www.radio-browser.infoからウェブラジオを検索する。
  • \n
  • !rbplay {id} - ID「{id}」のウェブラジオを追加する (例: !rbplay 96746)。
  • \n
  • !ysearch {keywords} - Youtubeを検索する。 ペイジをめぐるため !ysearch -n を使ってください。
  • \n
  • !yplay {keywords} - Youtubeを検索する。第一番目の曲を直接に再生リストに追加する。
  • \n
  • !shortlist (or !sl) {indexes/*} - 候補リストの第{indexes}番目の曲を追加する(もし「*」を使ったら、候補リストにある全ての曲を追加する)。
  • \n
  • !rm {num} - 再生リストにある第{num}番目の曲を削除する。
  • \n
  • !repeat [{num}] - 今の曲を{num}回リピートする(デフォルトは一回リピートする)。
  • \n
  • !random - 再生リストの順序をランダム化にする。
  • \n

\n\nライブリ
\n\n
    \n
  • !search {keywords} - ライブリの中に「{keywords}」が出る曲を検索する。
  • \n
  • !listfile [{pattern}] - ファイルパスが正規表現パターン「{pattern}」にマッチされる曲を表示する。
  • \n
  • !addtag [{index}] {tags} - タグ「{tags}」を第{index}番目の曲に追加する(もし{index}が提供されなかったら、今の曲に追加する)。複数のタグが「,」で区切る。
  • \n
  • !addtag * {tags} - タグ「{tags}」を再生リストにある全部曲に追加する。
  • \n
  • !untag [{index/*}] {tags}/* - 第{index}番目の曲(全ての曲、もし「*」を使ったら)からタグ「{tags}」を削除する(全部のタグ、もし「*」を使ったら)。
  • \n
  • !findtagged (or !ft) {tags} - ライブリに{tags}が含む曲を検索する。
  • \n
  • !delete {index} - ライブリ(ハードドライブ)に候補リストの第{index}番目曲を削除する。
  • \n

\n\n他のコマンド
\n\n
    \n
  • !joinme [{token}] - あなたがいるチャネルに入る。
  • \n
  • !password {password} - あなたのウェブインタフェーイスのパスワードを変更する。
  • \n
", "invalid_index": "インデックス{index}が不正です。再生リストを見るために、!queueを送信してください。", "last_song_on_the_queue": "最後の曲。", "max_volume": "", "multiple_file_added": "以下の曲が追加しました:", "multiple_file_deleted": "以下の曲がライブラリから削除されました:", "multiple_file_found": "以下の曲が見つかりました:", "multiple_matches": "ファイルが見つかりませんでした。もしかして:", "new_version_found": "

新バージョン発見!

botamusique {new_version} 可用!
\n

更新履歴

{changelog}
!updateを送信してこのバージョンにアップデートします。", "next_to_play": "次の曲。", "no_file": "ファイルが見つかりません。", "not_admin": "あなたは管理員ではありません。", "not_in_my_channel": "あなたは私のチャネルにいません。", "not_playing": "何も再生していません。", "now_playing": "再生中:{item}", "page_instruction": "第{current}/{total}頁。 !{command} {{page}}を送信してページをめぐります。", "paused": "音楽は一時停止しました。", "playlist_fetching_failed": "再生リストを取得できません。", "pm_not_allowed": "プライベートメッセージが受け取りません。", "position_in_the_queue": "位置:", "preconfigurated_radio": "デフォルトのウェブラジオは:", "queue_contents": "再生リストにある曲は:", "queue_empty": "再生リストは空です。", "radio": "ラジオ", "radio_item": "{title}{name}から)。{user}に追加されました。", "rb_play_empty": "ラジオIDを提供してください。", "rb_query_result": "検索の結果( !rbplay {ID} を送信して再生する)", "records_omitted": "…", "removed_tags": "{song}からタグ「 {tags}」を削除しました。", "removed_tags_from_all": "再生リストの全ての曲にタグ「{tags} 」を削除しました。", "removing_item": "再生リストに「{item}」を削除しました。", "repeat": "「{song}」を{n}回リピートするになります。", "report_version": "現在のbotamusiqueバージョンは{version}です。", "shortlist_instruction": "!sl {indexes}を使ってこのリストの曲を再生する。", "start_updating": "更新しています…", "stopped": "再生停止。", "too_long": "「{song}」が長さ制限を超えました({duration} > {max_duration})。削除されました。", "unable_download": "「{item}」がダウンロードできません。削除されました。", "unable_play": "「{item}」が再生できません。削除されました。", "unknown_mode": "不正な再生モード「{mode}」。 one-shot, repeat, random, autoplayの中の一つを使ってください。", "update_successful": "

botamusique v{version} インストール完成!


\n

更新履歴

{changelog}
このプロジェクトの githubページ をご覧ください!", "url": "URL", "url_ban": "URL {url} が禁止されています。", "url_ban_list": "", "url_ban_success": "", "url_from_playlist": "URL", "url_from_playlist_item": "{title}、({playlist}から)、 {user} に追加されました。", "url_item": "{title} {user} に追加されました。", "url_unban_success": "", "url_unwhitelist_success": "", "url_whitelist_list": "", "url_whitelist_success": "", "user_ban": "あなたはブラックリストに載っています。命令が拒否されました。", "user_ban_list": "", "user_ban_success": "", "user_password_set": "パスワードが更新されました。", "user_unban_success": "", "web_user_list": "以下のユーザーはウェブインターフェースを訪問する権利を持っています:
{users}", "webpage_address": "ウェブインターフェースのアドレスは{address}。", "which_command": "もしかして 
{commands}", "wrong_pattern": "不正な正規表現パターン:{error}。", "yt_no_more": "これ以上のエントリがありません。", "yt_query_error": "Youtubeを訪問できません!", "yt_result": "Youtube検索結果: {result_table} !sl {{indexes}}を使って再生します。
\n!ytquery -nを使ってページをめぐります。" }, "web": { "action": "動作", "add": "追加する", "add_all": "全部追加", "add_radio": "ラジオを追加する", "add_radio_url": "ラジオURL", "add_to_bottom": "最後尾に追加する。", "add_to_bottom_of_current_playlist": "再施リストの最後尾に追加する。", "add_to_playlist_next": "次の曲に追加する。", "add_url": "URLを追加する", "add_youtube_or_soundcloud_url": "Youtube・Soundcloud URLを追加する", "are_you_really_sure": "本当ですが?", "aria_botamusique_logo": "BotamusiqueのLogo", "aria_default_cover": "デフォルトアルバムカバー。", "aria_empty_box": "空。", "aria_remove_this_song": "再生リストからこの曲を削除する。", "aria_skip_current_song": "いますぐこの曲を再生する。", "aria_skip_to_next_track": "次の曲を再生する。", "aria_spinner": "ローディング中", "aria_warning_of_deletion": "ファイル削除警告", "autoplay": "自動再生", "browse_music_file": "音楽ファイルを閲覧する", "cancel": "キャンセル", "cancel_upload_warning": "本当ですが?
もしアップ本当にロードをキャンセルすることに決まったら、もう一度このバトンを押してください。", "change_playback_mode": "再生モードを変更する", "choose_file": "ファイルを選ぶ", "clear_playlist": "クリアする", "close": "閉める", "delete_all": "全部削除", "delete_all_files": "以上のファイルを全て削除する", "delete_file_warning": "続行すると、以上表示されたファイル(他のページにあるファイルも含む)をハードドライブから消去されます。本当にそうしますか?", "directory": "フォルダ", "download_all": "全部ダウンロード", "download_song_from_library": "ライブラリから音楽ファイルをダウンロードする", "edit_submit": "変更", "edit_tags_for": "タグを編集する", "expand_playlist": "第 番目の曲を表示する。", "file": "ファイル", "filters": "検索", "index": "#", "keywords": "キーワード", "keywords_placeholder": "キーワード…", "mini_player_title": "放送中…", "music_library": "ライブラリ", "next_to_play": "次の曲に追加する", "no_tag": "空", "oneshot": "順番に再生", "open_volume_controls": "音量スライダーを表示する", "page_title": "botamusiqueウェブインタフェイス", "pause": "一時停止", "play": "再生する", "playlist_controls": "再生管理", "radio": "ラジオ", "radio_url_placeholder": "ラジオURL…", "random": "シャッフル再生", "remove_song_from_library": "ライブラリから削除する", "repeat": "全曲リピート", "rescan_files": "フォルダをスキャン直す", "skip_track": "今の曲をスッキプする", "submit": "送信", "tags": "タグ", "tags_to_add": "追加するタグ", "title": "タイトル", "token": "トークン", "token_required": "トークンが必要です", "token_required_message": "このページは{{ name }}のウェブインタフェイスです。\n設定によって、ログオンするにはトークンが必要になります。
\n \"{{ command }}\" を送信してトークンを取得してください。", "type": "種類", "upload_file": "アップロード", "upload_submit": "アップロード", "upload_to": "フォルダ", "uploaded_finished": "アップロード完了", "uploading_files": "アップロード中…", "url": "URL", "url_path": "URL・パス", "url_placeholder": "URL…", "volume_slider": "音量スライダー" } } ================================================ FILE: lang/nl_NL.json ================================================ { "cli": { "added_tags": "", "added_tags_to_all": "", "admin_help": "", "auto_paused": "", "bad_command": "", "bad_parameter": "", "bad_url": "", "cache_refreshed": "", "change_ducking_volume": "", "change_max_volume": "", "change_mode": "", "change_volume": "", "cleared": "", "cleared_tags": "", "cleared_tags_from_all": "", "command_disabled": "", "current_ducking_volume": "", "current_max_volume": "", "current_mode": "", "current_volume": "Huidig volume: {volume}.", "database_dropped": "", "download_in_progress": "", "error_executing_command": "", "file": "Bestand", "file_added": "Toegevoegd {item}.", "file_deleted": "", "file_item": "", "file_missed": "", "help": "", "invalid_index": "", "last_song_on_the_queue": "", "max_volume": "", "multiple_file_added": "", "multiple_file_deleted": "", "multiple_file_found": "", "multiple_matches": "", "new_version_found": "", "next_to_play": "", "no_file": "", "not_admin": "", "not_in_my_channel": "", "not_playing": "", "now_playing": "", "page_instruction": "", "paused": "", "playlist_fetching_failed": "", "pm_not_allowed": "", "position_in_the_queue": "", "preconfigurated_radio": "", "queue_contents": "", "queue_empty": "", "radio": "", "radio_item": "", "rb_play_empty": "", "rb_query_result": "", "records_omitted": "", "removed_tags": "", "removed_tags_from_all": "", "removing_item": "", "repeat": "", "report_version": "", "shortlist_instruction": "", "start_updating": "", "stopped": "", "too_long": "", "unable_download": "", "unable_play": "", "unknown_mode": "", "update_successful": "", "url": "", "url_ban": "", "url_ban_list": "", "url_ban_success": "", "url_from_playlist": "", "url_from_playlist_item": "", "url_item": "", "url_unban_success": "", "url_unwhitelist_success": "", "url_whitelist_list": "", "url_whitelist_success": "", "user_ban": "", "user_ban_list": "", "user_ban_success": "", "user_password_set": "", "user_unban_success": "", "web_user_list": "", "webpage_address": "", "which_command": "", "wrong_pattern": "", "yt_no_more": "", "yt_query_error": "", "yt_result": "" }, "web": { "action": "", "add": "", "add_all": "", "add_radio": "", "add_radio_url": "", "add_to_bottom": "", "add_to_bottom_of_current_playlist": "", "add_to_playlist_next": "", "add_url": "", "add_youtube_or_soundcloud_url": "", "are_you_really_sure": "", "aria_botamusique_logo": "", "aria_default_cover": "", "aria_empty_box": "", "aria_remove_this_song": "", "aria_skip_current_song": "", "aria_skip_to_next_track": "", "aria_spinner": "", "aria_warning_of_deletion": "", "autoplay": "", "browse_music_file": "", "cancel": "", "cancel_upload_warning": "", "change_playback_mode": "", "choose_file": "", "clear_playlist": "", "close": "", "delete_all": "", "delete_all_files": "", "delete_file_warning": "", "directory": "", "download_all": "", "download_song_from_library": "", "edit_submit": "", "edit_tags_for": "", "expand_playlist": "", "file": "", "filters": "", "index": "", "keywords": "", "keywords_placeholder": "", "mini_player_title": "", "music_library": "", "next_to_play": "", "no_tag": "", "oneshot": "", "open_volume_controls": "", "page_title": "", "pause": "", "play": "", "playlist_controls": "", "radio": "", "radio_url_placeholder": "", "random": "", "remove_song_from_library": "", "repeat": "", "rescan_files": "", "skip_track": "", "submit": "", "tags": "", "tags_to_add": "", "title": "", "token": "", "token_required": "", "token_required_message": "", "type": "", "upload_file": "", "upload_submit": "", "upload_to": "", "uploaded_finished": "", "uploading_files": "", "url": "", "url_path": "", "url_placeholder": "", "volume_slider": "" } } ================================================ FILE: lang/pt_BR.json ================================================ { "cli": { "added_tags": "As etiquetas {tags} foram adicionadas em {song}.", "added_tags_to_all": "As etiquetas {tags} foram adicionadas nas músicas da lista de reprodução.", "admin_help": "

Comandos de administrador

\nRobô\n
    \n
  • !kill - matar o robô
  • \n
  • !update - atualizar o robô
  • \n
  • !userban {usuário} - banir um usuário
  • \n
  • !userunban {usuário} - remover usuário da lista de usuários banidos
  • \n
  • !urlbanlist - exibir lista de endereços banidos
  • \n
  • !urlban [{endereço}] - banir {endereço} (ou o endereço do item atual, por padrão) e remover este endereço da biblioteca.
  • \n
  • !urlunban {endereço - remover {endereço} da lista de endereços banidos
  • \n
  • !rescan {endereço} - reconstruir cache de arquivos de música local
  • \n
  • !dropdatabase - limpar o banco de dados inteiro, você perderá todas as configurações e a biblioteca de música.
  • \n
\nInterface web\n
    \n
  • !webuserlist - exibir lista de todos os usuários que têm permissão para acessar a interface web, se o modo de autenticação for 'password'.
  • \n
  • !webuseradd {apelido} - dar acesso à interface web para {apelido}, se o modo de autenticação for 'password'.
  • \n
  • !webuserdel {apelido} - revogar o acesso à interface web de {apelido}, caso o modo de autenticação for 'password'.
  • \n
", "auto_paused": "Use !play para retomar a reprodução de música!", "bad_command": "{command}: comando não encontrado.", "bad_parameter": "{command}: parâmetro inválido.", "bad_url": "Um endereço malformado foi pedido.", "cache_refreshed": "Cache atualizado!", "change_ducking_volume": "O volume de atenuação foi definido para {volume} por {user}.", "change_max_volume": "O volume máximo foi definido para {max} por {user}.", "change_mode": "O modo de reprodução foi definido para {mode} por {user}.", "change_volume": "O volume foi definido para {volume} por {user}.", "cleared": "A lista de reprodução foi esvaziada.", "cleared_tags": "Todas as etiquetas foram removidas de {song}.", "cleared_tags_from_all": "Todas as etiquetas das músicas na lista de reprodução foram removidas.", "command_disabled": "{command}: comando desabilitado!", "current_ducking_volume": "Volume de atenuação: {volume}.", "current_max_volume": "Volume máximo atual: {max}.", "current_mode": "O modo de reprodução é {mode}.", "current_volume": "Volume atual: {volume}.", "database_dropped": "O banco de dados foi esvaziado.", "download_in_progress": "A descarga de {item} está em progresso...", "error_executing_command": "{command}: O comando falhou com um erro: {error}.", "file": "Arquivo", "file_added": "{item} adicionado.", "file_deleted": "{item} foi apagado da biblioteca.", "file_item": "{artist} - {title} adicionado por {user}", "file_missed": "O arquivo de música '{file}' foi perdido! Este item foi removido da lista de reprodução.", "help": "

Comandos

\nControle\n
    \n
  • !web - exibe o endereço da interface web, caso habilitado.
  • \n
  • !play (ou !p) [{num}] [{iniciar_de}] - resume/inicia a reprodução (a partir da música na posição {num}, caso especificado)
  • \n
  • !pause - pausa
  • \n
  • !stop - interrompe a reprodução
  • \n
  • !skip - pula para a próxima música
  • \n
  • !last - pula para a última música
  • \n
  • !volume {volume} - exibe ou altera o volume (de 0 a 100)
  • \n
  • !mode [{modo}] - exibe ou define o modo de reprodução, {modo} deve ser um dos seguintes: one-shot (remover o item assim que ele for reproduzido, repeat (repetir a lista de reprodução), ou random (tornar a lista de reprodução em ordem aleatória),\nautoplay (escolher algo da biblioteca de música aleatoriamente).
  • \n
  • !duck on/off - habilita ou desabilita a função de atenuação
  • \n
  • !duckv {volume} - define o volume do robô quando a atenuação está ativada
  • \n
  • !duckthres - define o nível de volume que ativa a atenuação (3000 por padrão)
  • \n
  • !oust - interrompe a reprodução e vai para o canal padrão
  • \n
\nLista de reprodução\n
    \n
  • !now (ou !np) - exibe a música atual
  • \n
  • !queue - exibe os itens na lista de reprodução
  • \n
  • !tag {etiquetas} - adiciona todos os itens com as etiquetas {etiquetas}, etiquetas separadas com \",\".
  • \n
  • !file (ou !f) {caminho/pasta/palavra-chave} - adiciona um único arquivo à lista de reprodução pelo seu caminho ou palavra-chave em seu caminho.
  • \n
  • !filematch (ou !fm) {padrão} - adiciona todos os arquivos que combinarem com a expressão regular {padrão}
  • \n
  • !url {url} - adicionar música do YouTube ou SoundCloud
  • \n
  • !playlist {endereço} [{deslocamento}] - adiciona todos os itens em uma lista de reprodução do YouTube ou SoundCloud, a partir do item na posição {deslocamento}
  • \n
  • !radio {endereço} - adiciona a rádio {endereço} no final da lista de reprodução
  • \n
  • !rbquery {palavra_chave} - busca por uma estação de rádio em http://www.radio-browser.info
  • \n
  • !rbplay {id} - reproduz uma estação de rádio com {id} (por ex.: !rbplay 96746)
  • \n
  • !ysearch {palavras_chave} - busca no YouTube. Use !ysearch -n para trocar de página.
  • \n
  • !yplay {palavras_chave} - adiciona o primeiro resultado da busca de {palavras_chave} na lista de reprodução.
  • \n
  • !shortlist (ou !sl) {índices/*} - adiciona o item na posição {índices} (ou todos caso * seja especificado) na lista curta.
  • \n
  • !rm {num} - remove a música na posição {num} da lista de reprodução
  • \n
  • !repeat [{num}] - repete a música atual {num} (1 por padrão) vezes.
  • \n
  • !random - torna a lista de reprodução em ordem aleatória.
  • \n
\nBiblioteca de música\n
    \n
  • !search {palavras_chave} - busca pelo item com {palavras_chave} na biblioteca de música, palavras-chave separadas por espaço.
  • \n
  • !listfile [{padrão}] - exibe a lista de arquivos disponíveis (os quais caminhos combinam com o padrão de expressão regular caso {padrão} seja especificado)
  • \n
  • !addtag [{índice}] {etiquetas} - adiciona {etiquetas} para a música da lista de reprodução na posição {índice} (ou a música atual caso {índice} seja omitido), etiquetas separadas por \",\".
  • \n
  • !addtag * {etiquetas} - adiciona {etiquetas} para todos os itens na lista de reprodução.
  • \n
  • !untag [{índice/*}] {etiquetas}/* - remove {etiquetas}/todas as etiquetas da música da lista de reprodução na posição {índice} (ou a música atual caso {índice} seja omitido).
  • \n
  • !findtagged (ou !ft) {etiquetas} - busca por um item com {etiquetas} na biblioteca de música.
  • \n
  • !delete {índice} - apaga da biblioteca de música o item da lista curta na posição {índice}.
  • \n
\nOutro\n
    \n
  • !joinme {token} - entra no seu próprio canal com {token}.
  • \n
  • !password {senha} - altera sua senha, usada para acessar a interface web.
  • \n
", "invalid_index": "O índice {index} é inválido. Use !queue para visualizar a lista de reprodução.", "last_song_on_the_queue": "Último na fila.", "max_volume": "O volume excede o volume máximo {max}. O volume foi definido para o máximo.", "multiple_file_added": "Múltiplos itens adicionados:", "multiple_file_deleted": "Múltiplos itens foram apagados da biblioteca:", "multiple_file_found": "Encontrado:", "multiple_matches": "Arquivo não encontrado! Possíveis resultados:", "new_version_found": "

Atualização disponível!

A versão {new_version} do botamusique está disponível!
\n

Registro de mudanças

{changelog}
Envie !update para atualizar!", "next_to_play": "Próxima música.", "no_file": "Arquivo não encontrado.", "not_admin": "Você não é um administrador!", "not_in_my_channel": "Você não está no meu canal!", "not_playing": "Nada está sendo reproduzido neste momento.", "now_playing": "Reproduzindo {item}", "page_instruction": "Página {current}/{total}. Use !{command} {{page}} para navegar.", "paused": "Música pausada.", "playlist_fetching_failed": "Não foi possível receber a lista de reprodução!", "pm_not_allowed": "Mensagens privadas não são permitidas.", "position_in_the_queue": "Posição: {position}", "preconfigurated_radio": "Estações de rádio pré-configuradas disponíveis:", "queue_contents": "Itens na lista de reprodução:", "queue_empty": "A lista de reprodução está vazia!", "radio": "Rádio", "radio_item": "{title} de {name} foi adicionado por {user}", "rb_play_empty": "Por favor especifique a identificação de uma estação de rádio!", "rb_query_result": "Este é o resultado da sua busca, envie !rbplay {ID} para reproduzir uma estação:", "records_omitted": "…", "removed_tags": "As etiquetas {tags} foram removidas de {song}.", "removed_tags_from_all": "As etiquetas {tags} foram removidas das músicas na lista de reprodução.", "removing_item": "O item {item} na lista de reprodução foi removido.", "repeat": "Repetir {song} {n} vezes.", "report_version": "A versão atual do botamusique é {version}.", "shortlist_instruction": "Use !sl {índices} para reproduzir o item que você deseja.", "start_updating": "Iniciando a atualização...", "stopped": "Música parada.", "too_long": "{song} é muito longo ({duration} > {max_duration}). Removido da lista de reprodução!", "unable_download": "Falha ao baixar {item}. Removido da biblioteca.", "unable_play": "Falha ao reproduzir {item}. Removido da biblioteca.", "unknown_mode": "O modo de reprodução '{mode}' é desconhecido. Ele deve ser um dos seguintes: one-shot, repeat, random.", "update_successful": "

botamusique v{version} instalado!


\n

Registro de mudanças

{changelog}
Visite nosso repositório no GitHub para mais detalhes!", "url": "Endereço", "url_ban": "O endereço {url} está banido! Removido da lista de reprodução!", "url_ban_list": "Lista de endereços banidos:
{list}", "url_ban_success": "O seguinte endereço está banido: {url}.", "url_from_playlist": "Endereço", "url_from_playlist_item": "{title} da lista de reprodução {playlist} adicionado por {user}", "url_item": "{title} adicionado por {user}", "url_unban_success": "O seguinte endereço foi removido da lista de endereços banidos: {url}.", "url_unwhitelist_success": "O seguinte endereço foi removido da lista branca: {url}.", "url_whitelist_list": "Lista de endereços na lista branca:
{list}", "url_whitelist_success": "O seguinte endereço foi adicionado à lista branca: {url}.", "user_ban": "Você está banido. Você não tem permissão para fazer isto!", "user_ban_list": "Lista de usuários banidos:
{list}", "user_ban_success": "O usuário {user} foi banido.", "user_password_set": "A sua senha foi atualizada.", "user_unban_success": "O usuário {user} foi removido da lista de usuários banidos.", "web_user_list": "Os seguintes usuários possuem privilégio para acessar a interface web:
{users}", "webpage_address": "O seu próprio endereço para acessar a interface web é {address}", "which_command": "Você quis dizer
{commands}", "wrong_pattern": "Expressão regular inválida: {error}.", "yt_no_more": "Não há mais resultados!", "yt_query_error": "Não foi possível buscar no YouTube!", "yt_result": "Resultado da busca no YouTube: {result_table} Use !sl {{índices}} para reproduzir o item que você deseja.
\n!ytquery -n para exibir a próxima página." }, "web": { "action": "Ação", "add": "Adicionar", "add_all": "Adicionar todos", "add_radio": "Adicionar rádio", "add_radio_url": "Adicionar endereço de rádio", "add_to_bottom": "Adicionar no fim", "add_to_bottom_of_current_playlist": "Adicionar no fim da lista de reprodução atual", "add_to_playlist_next": "Adicionar para a lista de reprodução após a música atual", "add_url": "Adicionar lista de reprodução", "add_youtube_or_soundcloud_url": "Adicionar endereço do YouTube ou SoundCloud", "are_you_really_sure": "Você realmente tem certeza?", "aria_botamusique_logo": "Logo do botamusique: uma raposa escutando música com fones de ouvido", "aria_default_cover": "Um quadrado preto com duas oitavas disparadas juntas", "aria_empty_box": "Um desenho de uma caixa vazia.", "aria_remove_this_song": "Remover esta música da lista de reprodução atual", "aria_skip_current_song": "Pular música atual e reproduzir esta música agora", "aria_skip_to_next_track": "Pular para a próxima trilha", "aria_spinner": "Um ícone de carregamento girando", "aria_warning_of_deletion": "Aviso sobre a remoção de arquivos.", "autoplay": "Reproduzir automaticamente", "browse_music_file": "Procurar arquivo de música", "cancel": "Cancelar", "cancel_upload_warning": "Você realmente tem certeza?
Clique novamente para abortar o envio.", "change_playback_mode": "Alterar modo de reprodução", "choose_file": "Escolher arquivo", "clear_playlist": "Limpar lista de reprodução", "close": "Fechar", "delete_all": "Apagar todos", "delete_all_files": "Apagar todos os arquivos listados", "delete_file_warning": "Todos os arquivos listados aqui, incluindo os arquivos em outras páginas, serão apagados do seu disco.\n É isso o que você quer?", "directory": "Diretório", "download_all": "Baixar todos", "download_song_from_library": "Baixar música da biblioteca", "edit_submit": "Editar!", "edit_tags_for": "Editar etiquetas de", "expand_playlist": "Ver o item na lista de reprodução.", "file": "Arquivo", "filters": "Filtros", "index": "Nº", "keywords": "Palavras-chave", "keywords_placeholder": "Palavras-chave...", "mini_player_title": "Reproduzindo...", "music_library": "Biblioteca de música", "next_to_play": "Próximo a reproduzir", "no_tag": "Nenhuma etiqueta", "oneshot": "Reprodução única", "open_volume_controls": "Abrir controles de volume", "page_title": "Interface web botamusique", "pause": "Pausar", "play": "Reproduzir", "playlist_controls": "Controles de lista de reprodução", "radio": "Rádio", "radio_url_placeholder": "Endereço de rádio...", "random": "Aleatório", "remove_song_from_library": "Remover música da biblioteca", "repeat": "Repetir", "rescan_files": "Escanear arquivos novamente", "skip_track": "Pular trilha", "submit": "Enviar", "tags": "Etiquetas", "tags_to_add": "Etiquetas para adicionar", "title": "Título", "token": "Token", "token_required": "Token necessário", "token_required_message": "Você está acessando a interface web de {{ name }}.\nUm token é necessário para autorizar o seu acesso.
\nPor favor, envie \"{{ command }}\" para o robô no Mumble para recebê-lo.", "type": "Tipo", "upload_file": "Enviar arquivo", "upload_submit": "Enviar!", "upload_to": "Enviar para", "uploaded_finished": "Envio concluído!", "uploading_files": "Enviando arquivos...", "url": "Endereço", "url_path": "Endereço/Caminho", "url_placeholder": "Endereço...", "volume_slider": "Controle de volume" } } ================================================ FILE: lang/zh_CN.json ================================================ { "cli": { "added_tags": "已将标签 {tags} 添加到 {song}。", "added_tags_to_all": "已将标签 {tags} 添加到播放列表的所有曲目中。", "admin_help": "

管理员命令

\n机器人管理\n
    \n
  • !kill - 退出。
  • \n
  • !update - 自动更新至新版本。
  • \n
  • !userban {user} - 封禁用户。
  • \n
  • !userunban {user} - 解除封禁。
  • \n
  • !urlbanlist - 列出全部封禁的用户。
  • \n
  • !urlban [{url}] - 封禁链接 {url} (若未指定,则默认为当前播放曲目的URL) 并将它从数据库中移除。
  • \n
  • !urlunban {url} - 解除封禁链接 {url}。
  • \n
  • !rescan {url} - 更新本地音乐库。
  • \n
  • !dropdatabase - 清除数据库(包括设置和音乐库)。本操作不可逆,请务必事先考虑清楚。
  • \n
\n网络控制界面\n
    \n
  • !webuserlist - (若当前认证模式为 'password')列出所有具有网络控制界面访问权限的用户。
  • \n
  • !webuseradd {name} - (若当前认证模式为 'password')授权名为 {name} 的用户访问网络控制界面。
  • \n
  • !webuserdel {name} - (若当前认证模式为 'password')撤销名为 {name} 的用户的访问权限。
  • \n
", "auto_paused": "已暂停。若要继续播放,请发送 !play !", "bad_command": "{{command}}: 未知命令。请发送!help获取命令列表。", "bad_parameter": "{command}: 无效参数!", "bad_url": "URL地址无效!", "cache_refreshed": "缓存已刷新。", "change_ducking_volume": "{user}将“闪避”时的音量设置为 {volume}。", "change_max_volume": "", "change_mode": "{user}将播放列表模式被设置为{mode} 。", "change_volume": "{user}将音量设置为{volume}。", "cleared": "播放列表已清空。", "cleared_tags": "已移除{song}上的所有标签。", "cleared_tags_from_all": "已移除播放列表内所有曲目的标签。", "command_disabled": "{command}: 该命令不可用!", "current_ducking_volume": "“闪避”时的音量为:{volume}。", "current_max_volume": "", "current_mode": "当前的播放模式为{mode}。", "current_volume": "当前音量为{volume}。", "database_dropped": "数据库已经清空。", "download_in_progress": "正在下载{item}……", "error_executing_command": "{command}: 命令失败,错误为 {error}。", "file": "文件", "file_added": "新曲目被添加:{item}。", "file_deleted": "{item}已从库中移除。", "file_item": "{artist} - {title},由{user}添加。", "file_missed": "文件 '{file}' 丢失!已将其移出播放列表。", "help": "

命令帮助

\n\n播放控制\n\n
    \n
  • !web - 获取网页控制界面的地址(如果启用了的话)。
  • \n
  • !play (或 !p) [{num}] [{start_from}] - 继续播放/开始播放第{num}首曲目。
  • \n
  • !pause - 暂停播放。
  • \n
  • !stop - 停止播放。
  • \n
  • !skip - 跳到下一首曲目。
  • \n
  • !last - 跳到播放列表上的最后一首曲目。
  • \n
  • !volume {volume} - 获取或设置音量(从0到100)。
  • \n
  • !mode [{mode}] - 设置播放模式。 {mode} 可以使 one-shot (顺序播放), repeat (循环播放), random (随机播放)或\nautoplay (自动播放)四种之一.
  • \n
  • !duck on/off - 开启或关闭“闪避”功能。开启后,在别人说话时,音乐的音量会自动减小。
  • \n
  • !duckv {volume} - 获取或设置“闪避”时的音量。
  • \n
  • !duckthres - 设置“闪避”被激活所需音频信号强度的阈值(默认是3000)。
  • \n
  • !oust - 停止播放,并回到默认频道。
  • \n
\n播放列表\n\n
    \n
  • !now (或 !np) - 显示当前曲目信息。
  • \n
  • !queue - 显示播放列表。
  • \n
  • !tag {tags} - 将音乐库中所有包含{tags}标签的曲目添加到播放列表中。
  • \n
  • !file (或 !f) {path/folder/keyword} - 添加某一本地音频文件或某个目录中的全部文件到播放列表中。
  • \n
  • !filematch (or !fm) {pattern} - 将文件名满足正则表达式{pattern}的全部文件添加到播放列表中。
  • \n
  • !url {url} - 添加Youtube或SoundCloud链接。
  • \n
  • !playlist {url} [{offset}] - 添加Youtube或SoundCloud播放列表。
  • \n
  • !radio {url} - 将地址为{url}的电台加入播放列表。
  • \n
  • !rbquery {keyword} - 从http://www.radio-browser.info中搜索某一电台。
  • \n
  • !rbplay {id} - 播放ID为{id}的电台 (如 !rbplay 96746)。
  • \n
  • !ysearch {keywords} - 搜索Youtube。 使用 !ysearch -n 翻页.
  • \n
  • !yplay {keywords} - 搜索Youtube,将第一条搜索结果直接加入播放列表。
  • \n
  • !shortlist (or !sl) {indexes/*} - 添加候选列表中的第{indexes}条曲目(或者是全部曲目,如果该参数为“*”)到播放列表中。
  • \n
  • !rm {num} - 删除播放列表上的第{num}首曲目。
  • \n
  • !repeat [{num}] - 重复当前曲目{num}遍(默认重复一遍)。
  • \n
  • !random - 随机打乱播放列表顺序。
  • \n
\n\n音乐库\n\n
    \n
  • !search {keywords} - 在音乐库中搜索包含关键词{keywords}的曲目,关键词以空格分割。
  • \n
  • !listfile [{pattern}] - 列出路径符合正则表达式{pattern}的文件。
  • \n
  • !addtag [{index}] {tags} - 将标签{tags}添加到第{index}首曲目(如果{index}被省略则默认为当前曲目)。多个标签以“,”分割。
  • \n
  • !addtag * {tags} - 将标签{tags}添加到播放列表上的所有曲目。
  • \n
  • !untag [{index/*}] {tags}/* - 从第{index}首曲目(或当前曲目,若{index}被省略;或全部曲目,若该参数为“*”)上删除标签{tags}(或全部标签)。
  • \n
  • !findtagged (or !ft) {tags} - 在音乐库中查找包含标签{tags}的曲目。
  • \n
  • !delete {index} - 从音乐库中删除候选列表上的第{index}首曲目。
  • \n
\n\n其他\n\n
    \n
  • !joinme [{token}] - 加入你所在的频道。
  • \n
  • !password {password} - 更改你用于访问网页控制界面的密码。
  • \n
", "invalid_index": "无效的序号 {index}。 使用 '!queue' 查看播放列表。", "last_song_on_the_queue": "最后一首。", "max_volume": "", "multiple_file_added": "以下曲目已被添加:", "multiple_file_deleted": "以下曲目已被移出库:", "multiple_file_found": "搜索到:", "multiple_matches": "文件未找到!你是不是指:", "new_version_found": "

发现新版本!

botamusique {new_version} 可用!
\n

更新日志

{changelog}
使用 !update自动更新至该版本。", "next_to_play": "下一首。", "no_file": "文件未找到。", "not_admin": "你不是管理员!", "not_in_my_channel": "你不在我的频道里!", "not_playing": "无播放中的曲目。", "now_playing": "正在播放:{item}", "page_instruction": "第{current}/{total}页。发送!{command} {{page}}翻页。", "paused": "暂停播放。", "playlist_fetching_failed": "无法获取播放列表!", "pm_not_allowed": "不接受私信。", "position_in_the_queue": "位置:", "preconfigurated_radio": "预设的电台如下:", "queue_contents": "播放列表中的曲目:", "queue_empty": "播放列表为空!", "radio": "电台", "radio_item": "{title}来自 {name}。 {user} 添加。", "rb_play_empty": "请指定一个电台ID!", "rb_query_result": "搜索结果如下。发送 !rbplay {ID} 播放。", "records_omitted": "……", "removed_tags": "已将标签 {tags}{song}上移除。", "removed_tags_from_all": "已将标签 {tags} 从播放列表的曲目中移除。", "removing_item": "已将 {item} 从播放列表中移除。", "repeat": "重复{song} {n}次。", "report_version": "当前的botamusique版本为{version}。", "shortlist_instruction": "使用!sl {indexes}播放列表中的曲目。", "start_updating": "开始更新……", "stopped": "音乐停止。", "too_long": "{song}超出长度限制({duration} > {max_duration})!已被移出播放列表。", "unable_download": "无法下载{item}。已移出播放列表。", "unable_play": "无法播放{item}。已移出播放列表。", "unknown_mode": "未知播放模式\"{mode}\"。播放模式应为 one-shot, repeat, random中的一个。", "update_successful": "

botamusique v{version} 安装完毕!


\n

更新日志

{changelog}
请访问我们的 github页面 获取更多信息!", "url": "URL", "url_ban": "链接{url}被列入黑名单了!", "url_ban_list": "", "url_ban_success": "", "url_from_playlist": "URL", "url_from_playlist_item": "{title},来自播放列表 {playlist},由 {user} 添加。", "url_item": "{title} {user} 添加。", "url_unban_success": "", "url_unwhitelist_success": "", "url_whitelist_list": "", "url_whitelist_success": "", "user_ban": "你被列入黑名单了!无法操作!", "user_ban_list": "", "user_ban_success": "", "user_password_set": "密码已经被更新。", "user_unban_success": "", "web_user_list": "下列用户具有访问网络控制界面的权限:
{users}", "webpage_address": "网页控制界面的地址是{address}。", "which_command": "你是不是指
{commands}", "wrong_pattern": "错误的正则表达式:{error}.", "yt_no_more": "没有更多条目了!", "yt_query_error": "无法访问Youtube!", "yt_result": "Youtube查询结果: {result_table} 使用 !sl {{indexes}} 播放列表中的曲目。
\n使用!ytquery -n翻页。" }, "web": { "action": "操作", "add": "添加", "add_all": "添加全部", "add_radio": "添加电台", "add_radio_url": "电台URL", "add_to_bottom": "添加到最后", "add_to_bottom_of_current_playlist": "添加到播放列表的末尾", "add_to_playlist_next": "添加到当前曲目的下一首", "add_url": "添加URL", "add_youtube_or_soundcloud_url": "添加Youtube或Soundcloud URL", "are_you_really_sure": "你真的确定吗?", "aria_botamusique_logo": "Botamusique的Logo", "aria_default_cover": "默认专辑封面图片:黑色背景上的两个音符。", "aria_empty_box": "空。", "aria_remove_this_song": "从当前播放列表中移除该曲目。", "aria_skip_current_song": "立刻播放该曲目。", "aria_skip_to_next_track": "播放下一曲目。", "aria_spinner": "加载中", "aria_warning_of_deletion": "删除文件警告", "autoplay": "自动播放", "browse_music_file": "浏览音乐文件", "cancel": "取消", "cancel_upload_warning": "你真的确定吗?
若要取消上传,请再次点击该按钮。", "change_playback_mode": "更改播放模式", "choose_file": "选择文件", "clear_playlist": "清空播放列表", "close": "关闭", "delete_all": "删除全部", "delete_all_files": "删除全部列出的文件", "delete_file_warning": "全部列出的文件(包括其他页面上的文件)将被从硬盘上删除。你确定要这样做吗?", "directory": "目录", "download_all": "下载全部", "download_song_from_library": "从库中下载曲目文件", "edit_submit": "编辑!", "edit_tags_for": "修改标签:", "expand_playlist": "查看第 首曲目。", "file": "文件", "filters": "筛选", "index": "#", "keywords": "关键词", "keywords_placeholder": "关键词……", "mini_player_title": "正在播放……", "music_library": "音乐库", "next_to_play": "添加到当前曲目后", "no_tag": "无标签", "oneshot": "顺序播放", "open_volume_controls": "打开音量控制条", "page_title": "botamusique控制面板", "pause": "暂停", "play": "播放", "playlist_controls": "播放控制", "radio": "电台", "radio_url_placeholder": "电台URL……", "random": "随机播放", "remove_song_from_library": "从库中移除曲目", "repeat": "列表循环", "rescan_files": "重新扫描目录", "skip_track": "跳过当前曲目", "submit": "提交", "tags": "标签", "tags_to_add": "欲添加的标签", "title": "标题", "token": "令牌", "token_required": "需要登录令牌", "token_required_message": "你现在在访问{{ name }}的网络控制面板。\n根据设置,你需要一个令牌才能登录。
\n请发送 \"{{ command }}\" 以获取你的登录令牌。", "type": "类型", "upload_file": "上传音乐文件", "upload_submit": "上传!", "upload_to": "上传到", "uploaded_finished": "上传完毕!", "uploading_files": "上传中……", "url": "URL", "url_path": "URL/路径", "url_placeholder": "URL……", "volume_slider": "音量控制条" } } ================================================ FILE: media/README.md ================================================ ``` +----------------------------------------------------------+ | <-| URLItem <-- URLFromPlaylistItem | | BaseItem <-| FileItem | | <-| RadioItem | ++---------------------------------------------------------+ ``` ================================================ FILE: media/__init__.py ================================================ ================================================ FILE: media/cache.py ================================================ import logging import os import json import threading from media.item import item_builders, item_id_generators, dict_to_item import media.file import media.url import media.url_from_playlist import media.radio from database import MusicDatabase, Condition import variables as var import util class ItemNotCachedError(Exception): pass class MusicCache(dict): def __init__(self, db: MusicDatabase): super().__init__() self.db = db self.log = logging.getLogger("bot") self.dir_lock = threading.Lock() def get_item_by_id(self, id): if id in self: return self[id] # if not cached, query the database item = self.fetch(id) if item is not None: self[id] = item self.log.debug("library: music found in database: %s" % item.format_debug_string()) return item else: return None # print(id) # raise KeyError("Unable to fetch item from the database! Please try to refresh the cache by !recache.") def get_item(self, **kwargs): # kwargs should provide type and id, and parameters to build the item if not existed in the library. # if cached if 'id' in kwargs: id = kwargs['id'] else: id = item_id_generators[kwargs['type']](**kwargs) if id in self: return self[id] # if not cached, query the database item = self.fetch(id) if item is not None: self[id] = item self.log.debug("library: music found in database: %s" % item.format_debug_string()) return item # if not in the database, build one self[id] = item_builders[kwargs['type']](**kwargs) # newly built item will not be saved immediately return self[id] def get_items_by_tags(self, tags): music_dicts = self.db.query_music_by_tags(tags) items = [] if music_dicts: for music_dict in music_dicts: id = music_dict['id'] self[id] = dict_to_item(music_dict) items.append(self[id]) return items def fetch(self, id): music_dict = self.db.query_music_by_id(id) if music_dict: self[id] = dict_to_item(music_dict) return self[id] else: return None def save(self, id): self.log.debug("library: music save into database: %s" % self[id].format_debug_string()) self.db.insert_music(self[id].to_dict()) self.db.manage_special_tags() def free_and_delete(self, id): item = self.get_item_by_id(id) if item: self.log.debug("library: DELETE item from the database: %s" % item.format_debug_string()) if item.type == 'url': if os.path.exists(item.path): os.remove(item.path) if item.id in self: del self[item.id] self.db.delete_music(Condition().and_equal("id", item.id)) def free(self, id): if id in self: self.log.debug("library: cache freed for item: %s" % self[id].format_debug_string()) del self[id] def free_all(self): self.log.debug("library: all cache freed") self.clear() def build_dir_cache(self): self.dir_lock.acquire() self.log.info("library: rebuild directory cache") files = util.get_recursive_file_list_sorted(var.music_folder) # remove deleted files results = self.db.query_music(Condition().or_equal('type', 'file')) for result in results: if result['path'] not in files: self.log.debug("library: music file missed: %s, delete from library." % result['path']) self.db.delete_music(Condition().and_equal('id', result['id'])) else: files.remove(result['path']) for file in files: results = self.db.query_music(Condition().and_equal('path', file)) if not results: item = item_builders['file'](path=file) self.log.debug("library: music save into database: %s" % item.format_debug_string()) self.db.insert_music(item.to_dict()) self.db.manage_special_tags() self.dir_lock.release() class CachedItemWrapper: def __init__(self, lib, id, type, user): self.lib = lib self.id = id self.user = user self.type = type self.log = logging.getLogger("bot") self.version = 0 def item(self): if self.id in self.lib: return self.lib[self.id] else: raise ItemNotCachedError(f"Uncached item of id {self.id}, type {self.type}.") def to_dict(self): dict = self.item().to_dict() dict['user'] = self.user return dict def validate(self): ret = self.item().validate() if ret and self.item().version > self.version: self.version = self.item().version self.lib.save(self.id) return ret def prepare(self): ret = self.item().prepare() if ret and self.item().version > self.version: self.version = self.item().version self.lib.save(self.id) return ret def uri(self): return self.item().uri() def add_tags(self, tags): self.item().add_tags(tags) if self.item().version > self.version: self.version = self.item().version self.lib.save(self.id) def remove_tags(self, tags): self.item().remove_tags(tags) if self.item().version > self.version: self.version = self.item().version self.lib.save(self.id) def clear_tags(self): self.item().clear_tags() if self.item().version > self.version: self.version = self.item().version self.lib.save(self.id) def is_ready(self): return self.item().is_ready() def is_failed(self): return self.item().is_failed() def format_current_playing(self): return self.item().format_current_playing(self.user) def format_song_string(self): return self.item().format_song_string(self.user) def format_title(self): return self.item().format_title() def format_debug_string(self): return self.item().format_debug_string() def display_type(self): return self.item().display_type() # Remember!!! Get wrapper functions will automatically add items into the cache! def get_cached_wrapper(item, user): if item: var.cache[item.id] = item return CachedItemWrapper(var.cache, item.id, item.type, user) return None def get_cached_wrappers(items, user): wrappers = [] for item in items: if item: wrappers.append(get_cached_wrapper(item, user)) return wrappers def get_cached_wrapper_from_scrap(**kwargs): item = var.cache.get_item(**kwargs) if 'user' not in kwargs: raise KeyError("Which user added this song?") return CachedItemWrapper(var.cache, item.id, kwargs['type'], kwargs['user']) def get_cached_wrapper_from_dict(dict_from_db, user): if dict_from_db: item = dict_to_item(dict_from_db) return get_cached_wrapper(item, user) return None def get_cached_wrappers_from_dicts(dicts_from_db, user): items = [] for dict_from_db in dicts_from_db: if dict_from_db: items.append(get_cached_wrapper_from_dict(dict_from_db, user)) return items def get_cached_wrapper_by_id(id, user): item = var.cache.get_item_by_id(id) if item: return CachedItemWrapper(var.cache, item.id, item.type, user) def get_cached_wrappers_by_tags(tags, user): items = var.cache.get_items_by_tags(tags) ret = [] for item in items: ret.append(CachedItemWrapper(var.cache, item.id, item.type, user)) return ret ================================================ FILE: media/file.py ================================================ import os import re from io import BytesIO import base64 import hashlib import mutagen from PIL import Image import util import variables as var from media.item import BaseItem, item_builders, item_loaders, item_id_generators, ValidationFailedError from constants import tr_cli as tr ''' type : file id path title artist duration thumbnail user ''' def file_item_builder(**kwargs): return FileItem(kwargs['path']) def file_item_loader(_dict): return FileItem("", _dict) def file_item_id_generator(**kwargs): return hashlib.md5(kwargs['path'].encode()).hexdigest() item_builders['file'] = file_item_builder item_loaders['file'] = file_item_loader item_id_generators['file'] = file_item_id_generator class FileItem(BaseItem): def __init__(self, path, from_dict=None): if not from_dict: super().__init__() self.path = path self.title = "" self.artist = "" self.thumbnail = None self.id = hashlib.md5(path.encode()).hexdigest() if os.path.exists(self.uri()): self._get_info_from_tag() self.ready = "yes" self.duration = util.get_media_duration(self.uri()) self.keywords = self.title + " " + self.artist else: super().__init__(from_dict) self.artist = from_dict['artist'] self.thumbnail = from_dict['thumbnail'] try: self.validate() except ValidationFailedError: self.ready = "failed" self.type = "file" def uri(self): return var.music_folder + self.path if self.path[0] != "/" else self.path def is_ready(self): return True def validate(self): if not os.path.exists(self.uri()): self.log.info( "file: music file missed for %s" % self.format_debug_string()) raise ValidationFailedError(tr('file_missed', file=self.path)) if self.duration == 0: self.duration = util.get_media_duration(self.uri()) self.version += 1 # 0 -> 1, notify the wrapper to save me self.ready = "yes" return True def _get_info_from_tag(self): path, file_name_ext = os.path.split(self.uri()) file_name, ext = os.path.splitext(file_name_ext) assert path is not None and file_name is not None try: im = None path_thumbnail = os.path.join(path, file_name + ".jpg") if os.path.isfile(path_thumbnail): im = Image.open(path_thumbnail) else: path_thumbnail = os.path.join(path, "cover.jpg") if os.path.isfile(path_thumbnail): im = Image.open(path_thumbnail) if ext == ".mp3": # title: TIT2 # artist: TPE1, TPE2 # album: TALB # cover artwork: APIC: tags = mutagen.File(self.uri()) if 'TIT2' in tags: self.title = tags['TIT2'].text[0] if 'TPE1' in tags: # artist self.artist = tags['TPE1'].text[0] if im is None: if "APIC:" in tags: im = Image.open(BytesIO(tags["APIC:"].data)) elif ext == ".m4a" or ext == ".m4b" or ext == ".mp4" or ext == ".m4p": # title: ©nam (\xa9nam) # artist: ©ART # album: ©alb # cover artwork: covr tags = mutagen.File(self.uri()) if '©nam' in tags: self.title = tags['©nam'][0] if '©ART' in tags: # artist self.artist = tags['©ART'][0] if im is None: if "covr" in tags: im = Image.open(BytesIO(tags["covr"][0])) elif ext == ".opus": # title: 'title' # artist: 'artist' # album: 'album' # cover artwork: 'metadata_block_picture', and then: ## | ## | ## v ## Decode string as base64 binary ## | ## v ## Open that binary as a mutagen.flac.Picture ## | ## v ## Extract binary image data tags = mutagen.File(self.uri()) if 'title' in tags: self.title = tags['title'][0] if 'artist' in tags: self.artist = tags['artist'][0] if im is None: if 'metadata_block_picture' in tags: pic_as_base64 = tags['metadata_block_picture'][0] as_flac_picture = mutagen.flac.Picture(base64.b64decode(pic_as_base64)) im = Image.open(BytesIO(as_flac_picture.data)) elif ext == ".flac": # title: 'title' # artist: 'artist' # album: 'album' # cover artwork: tags.pictures tags = mutagen.File(self.uri()) if 'title' in tags: self.title = tags['title'][0] if 'artist' in tags: self.artist = tags['artist'][0] if im is None: for flac_picture in tags.pictures: if flac_picture.type == 3: im = Image.open(BytesIO(flac_picture.data)) if im: self.thumbnail = self._prepare_thumbnail(im) except: pass if not self.title: self.title = file_name @staticmethod def _prepare_thumbnail(im): im.thumbnail((100, 100), Image.LANCZOS) buffer = BytesIO() im = im.convert('RGB') im.save(buffer, format="JPEG") return base64.b64encode(buffer.getvalue()).decode('utf-8') def to_dict(self): dict = super().to_dict() dict['type'] = 'file' dict['path'] = self.path dict['title'] = self.title dict['artist'] = self.artist dict['thumbnail'] = self.thumbnail return dict def format_debug_string(self): return "[file] {descrip} ({path})".format( descrip=self.format_title(), path=self.path ) def format_song_string(self, user): return tr("file_item", title=self.title, artist=self.artist if self.artist else '??', user=user ) def format_current_playing(self, user): display = tr("now_playing", item=self.format_song_string(user)) if self.thumbnail: thumbnail_html = '' display += "
" + thumbnail_html return display def format_title(self): title = self.title if self.title else self.path if self.artist: return self.artist + " - " + title else: return title def display_type(self): return tr("file") ================================================ FILE: media/item.py ================================================ import logging item_builders = {} item_loaders = {} item_id_generators = {} def example_builder(**kwargs): return BaseItem() def example_loader(_dict): return BaseItem(from_dict=_dict) def example_id_generator(**kwargs): return "" item_builders['base'] = example_builder item_loaders['base'] = example_loader item_id_generators['base'] = example_id_generator def dicts_to_items(music_dicts): items = [] for music_dict in music_dicts: type = music_dict['type'] items.append(item_loaders[type](music_dict)) return items def dict_to_item(music_dict): type = music_dict['type'] return item_loaders[type](music_dict) class ValidationFailedError(Exception): def __init__(self, msg = None): self.msg = msg class PreparationFailedError(Exception): def __init__(self, msg = None): self.msg = msg class BaseItem: def __init__(self, from_dict=None): self.log = logging.getLogger("bot") self.type = "base" self.title = "" self.path = "" self.tags = [] self.keywords = "" self.duration = 0 self.version = 0 # if version increase, wrapper will re-save this item if from_dict is None: self.id = "" self.ready = "pending" # pending - is_valid() -> validated - prepare() -> yes, failed else: self.id = from_dict['id'] self.ready = from_dict['ready'] self.tags = from_dict['tags'] self.title = from_dict['title'] self.path = from_dict['path'] self.keywords = from_dict['keywords'] self.duration = from_dict['duration'] def is_ready(self): return True if self.ready == "yes" else False def is_failed(self): return True if self.ready == "failed" else False def validate(self): raise ValidationFailedError(None) def uri(self): raise def prepare(self): return True def add_tags(self, tags): for tag in tags: if tag and tag not in self.tags: self.tags.append(tag) self.version += 1 def remove_tags(self, tags): for tag in tags: if tag in self.tags: self.tags.remove(tag) self.version += 1 def clear_tags(self): if len(self.tags) > 0: self.tags = [] self.version += 1 def format_song_string(self, user): return self.id def format_current_playing(self, user): return self.id def format_title(self): return self.title def format_debug_string(self): return self.id def display_type(self): return "" def to_dict(self): return {"type": self.type, "id": self.id, "ready": self.ready, "title": self.title, "path": self.path, "tags": self.tags, "keywords": self.keywords, "duration": self.duration} ================================================ FILE: media/playlist.py ================================================ import json import threading import logging import random import time import variables as var from media.cache import (CachedItemWrapper, ItemNotCachedError, get_cached_wrapper_from_dict, get_cached_wrapper_by_id) from database import Condition from media.item import ValidationFailedError, PreparationFailedError def get_playlist(mode, _list=None, _index=None): index = -1 if _list and _index is None: index = _list.current_index if _list is None: if mode == "one-shot": return OneshotPlaylist() elif mode == "repeat": return RepeatPlaylist() elif mode == "random": return RandomPlaylist() elif mode == "autoplay": return AutoPlaylist() else: if mode == "one-shot": return OneshotPlaylist().from_list(_list, index) elif mode == "repeat": return RepeatPlaylist().from_list(_list, index) elif mode == "random": return RandomPlaylist().from_list(_list, index) elif mode == "autoplay": return AutoPlaylist().from_list(_list, index) raise class BasePlaylist(list): def __init__(self): super().__init__() self.current_index = -1 self.version = 0 # increase by one after each change self.mode = "base" # "repeat", "random" self.pending_items = [] self.log = logging.getLogger("bot") self.validating_thread_lock = threading.Lock() self.playlist_lock = threading.RLock() def is_empty(self): return True if len(self) == 0 else False def from_list(self, _list, current_index): self.version += 1 super().clear() self.extend(_list) self.current_index = current_index return self def append(self, item: CachedItemWrapper): with self.playlist_lock: self.version += 1 super().append(item) self.pending_items.append(item) self.async_validate() return item def insert(self, index, item): with self.playlist_lock: self.version += 1 if index == -1: index = self.current_index super().insert(index, item) if index <= self.current_index: self.current_index += 1 self.pending_items.append(item) self.async_validate() return item def extend(self, items): with self.playlist_lock: self.version += 1 super().extend(items) self.pending_items.extend(items) self.async_validate() return items def next(self): with self.playlist_lock: if len(self) == 0: return False if self.current_index < len(self) - 1: self.current_index += 1 return self[self.current_index] else: return False def point_to(self, index): with self.playlist_lock: if -1 <= index < len(self): self.current_index = index def find(self, id): with self.playlist_lock: for index, wrapper in enumerate(self): if wrapper.item.id == id: return index return None def __delitem__(self, key): return self.remove(key) def remove(self, index): with self.playlist_lock: self.version += 1 if index > len(self) - 1: return False removed = self[index] super().__delitem__(index) if self.current_index > index: self.current_index -= 1 # reference counter counter = 0 for wrapper in self: if wrapper.id == removed.id: counter += 1 if counter == 0: var.cache.free(removed.id) return removed def remove_by_id(self, id): to_be_removed = [] for index, wrapper in enumerate(self): if wrapper.id == id: to_be_removed.append(index) if to_be_removed: self.version += 1 for index in to_be_removed: self.remove(index) def current_item(self): with self.playlist_lock: if len(self) == 0: return False return self[self.current_index] def next_index(self): with self.playlist_lock: if self.current_index < len(self) - 1: return self.current_index + 1 return False def next_item(self): with self.playlist_lock: if self.current_index < len(self) - 1: return self[self.current_index + 1] return False def randomize(self): with self.playlist_lock: # current_index will lose track after shuffling, thus we take current music out before shuffling # current = self.current_item() # del self[self.current_index] random.shuffle(self) # self.insert(0, current) self.current_index = -1 self.version += 1 def clear(self): with self.playlist_lock: self.version += 1 self.current_index = -1 super().clear() var.cache.free_all() def save(self): with self.playlist_lock: var.db.remove_section("playlist_item") assert self.current_index is not None var.db.set("playlist", "current_index", self.current_index) for index, music in enumerate(self): var.db.set("playlist_item", str(index), json.dumps({'id': music.id, 'user': music.user})) def load(self): current_index = var.db.getint("playlist", "current_index", fallback=-1) if current_index == -1: return items = var.db.items("playlist_item") if items: music_wrappers = [] items.sort(key=lambda v: int(v[0])) for item in items: item = json.loads(item[1]) music_wrapper = get_cached_wrapper_by_id(item['id'], item['user']) if music_wrapper: music_wrappers.append(music_wrapper) self.from_list(music_wrappers, current_index) def _debug_print(self): print("===== Playlist(%d) =====" % self.current_index) for index, item_wrapper in enumerate(self): if index == self.current_index: print("-> %d %s" % (index, item_wrapper.format_debug_string())) else: print("%d %s" % (index, item_wrapper.format_debug_string())) print("===== End =====") def async_validate(self): if not self.validating_thread_lock.locked(): time.sleep(0.1) # Just avoid validation finishes too fast and delete songs while something is reading it. th = threading.Thread(target=self._check_valid, name="Validating") th.daemon = True th.start() def _check_valid(self): self.log.debug("playlist: start validating...") self.validating_thread_lock.acquire() while len(self.pending_items) > 0: item = self.pending_items.pop() try: item.item() except ItemNotCachedError: # In some very subtle case, items are removed and freed from # the playlist and the cache, before validation even starts, # causes, freed items remain in pending_items. # Simply ignore these items here. continue self.log.debug("playlist: validating %s" % item.format_debug_string()) ver = item.version try: item.validate() except ValidationFailedError as e: self.log.debug("playlist: validating failed.") if var.bot: var.bot.send_channel_msg(e.msg) self.remove_by_id(item.id) var.cache.free_and_delete(item.id) continue if item.version > ver: self.version += 1 self.log.debug("playlist: validating finished.") self.validating_thread_lock.release() class OneshotPlaylist(BasePlaylist): def __init__(self): super().__init__() self.mode = "one-shot" self.current_index = -1 def current_item(self): with self.playlist_lock: if len(self) == 0: self.current_index = -1 return False if self.current_index == -1: self.current_index = 0 return self[self.current_index] def from_list(self, _list, current_index): with self.playlist_lock: if len(_list) > 0: if current_index > -1: for i in range(current_index): _list.pop(0) return super().from_list(_list, 0) return super().from_list(_list, -1) return self def next(self): with self.playlist_lock: if len(self) > 0: self.version += 1 if self.current_index != -1: super().__delitem__(self.current_index) if len(self) == 0: return False else: self.current_index = 0 return self[0] else: self.current_index = -1 return False def next_index(self): if len(self) > 1: return 1 else: return False def next_item(self): if len(self) > 1: return self[1] else: return False def point_to(self, index): with self.playlist_lock: self.version += 1 self.current_index = -1 for i in range(index): super().__delitem__(0) class RepeatPlaylist(BasePlaylist): def __init__(self): super().__init__() self.mode = "repeat" def next(self): with self.playlist_lock: if len(self) == 0: return False if self.current_index < len(self) - 1: self.current_index += 1 return self[self.current_index] else: self.current_index = 0 return self[0] def next_index(self): with self.playlist_lock: if self.current_index < len(self) - 1: return self.current_index + 1 else: return 0 def next_item(self): if len(self) == 0: return False return self[self.next_index()] class RandomPlaylist(BasePlaylist): def __init__(self): super().__init__() self.mode = "random" def from_list(self, _list, current_index): self.version += 1 random.shuffle(_list) return super().from_list(_list, -1) def next(self): with self.playlist_lock: if len(self) == 0: return False if self.current_index < len(self) - 1: self.current_index += 1 return self[self.current_index] else: self.version += 1 self.randomize() self.current_index = 0 return self[0] class AutoPlaylist(OneshotPlaylist): def __init__(self): super().__init__() self.mode = "autoplay" def refresh(self): dicts = var.music_db.query_random_music(var.config.getint("bot", "autoplay_length"), Condition().and_not_sub_condition( Condition().and_like('tags', "%don't autoplay,%"))) if dicts: _list = [get_cached_wrapper_from_dict(_dict, "AutoPlay") for _dict in dicts] self.from_list(_list, -1) # def from_list(self, _list, current_index): # self.version += 1 # self.refresh() # return self def clear(self): super().clear() self.refresh() def next(self): if len(self) == 0: self.refresh() return super().next() ================================================ FILE: media/radio.py ================================================ import re import logging import struct import requests import traceback import hashlib from media.item import BaseItem from media.item import item_builders, item_loaders, item_id_generators from constants import tr_cli as tr log = logging.getLogger("bot") def get_radio_server_description(url): global log log.debug("radio: fetching radio server description") p = re.compile('(https?://[^/]*)', re.IGNORECASE) res = re.search(p, url) base_url = res.group(1) url_icecast = base_url + '/status-json.xsl' url_shoutcast = base_url + '/stats?json=1' try: response = requests.head(url_shoutcast, timeout=3) if not response.headers.get('content-type', '').startswith(("audio/", "video/")): response = requests.get(url_shoutcast, timeout=10) data = response.json() title_server = data['servertitle'] return title_server # logging.info("TITLE FOUND SHOUTCAST: " + title_server) except (requests.exceptions.ConnectionError, requests.exceptions.HTTPError, requests.exceptions.ReadTimeout, requests.exceptions.Timeout): error_traceback = traceback.format_exc() error = error_traceback.rstrip().split("\n")[-1] log.debug("radio: unsuccessful attempts on fetching radio description (shoutcast): " + error) except ValueError: return url try: response = requests.head(url_shoutcast, timeout=3) if not response.headers.get('content-type', '').startswith(("audio/", "video/")): response = requests.get(url_icecast, timeout=10) data = response.json() source = data['icestats']['source'] if type(source) is list: source = source[0] title_server = source['server_name'] if 'server_description' in source: title_server += ' - ' + source['server_description'] # logging.info("TITLE FOUND ICECAST: " + title_server) return title_server except (requests.exceptions.ConnectionError, requests.exceptions.HTTPError, requests.exceptions.ReadTimeout, requests.exceptions.Timeout): error_traceback = traceback.format_exc() error = error_traceback.rstrip().split("\n")[-1] log.debug("radio: unsuccessful attempts on fetching radio description (icecast): " + error) return url def get_radio_title(url): global log log.debug("radio: fetching radio server description") try: r = requests.get(url, headers={'Icy-MetaData': '1'}, stream=True, timeout=10) icy_metaint_header = int(r.headers['icy-metaint']) r.raw.read(icy_metaint_header) metadata_length = struct.unpack('B', r.raw.read(1))[0] * 16 # length byte metadata = r.raw.read(metadata_length).rstrip(b'\0') logging.info(metadata) # extract title from the metadata m = re.search(br"StreamTitle='([^']*)';", metadata) if m: title = m.group(1) if title: return title.decode() except (requests.exceptions.ConnectionError, requests.exceptions.HTTPError, requests.exceptions.ReadTimeout, requests.exceptions.Timeout, KeyError): log.debug("radio: unsuccessful attempts on fetching radio title (icy)") return url def radio_item_builder(**kwargs): if 'name' in kwargs: return RadioItem(kwargs['url'], kwargs['name']) else: return RadioItem(kwargs['url'], '') def radio_item_loader(_dict): return RadioItem("", "", _dict) def radio_item_id_generator(**kwargs): return hashlib.md5(kwargs['url'].encode()).hexdigest() item_builders['radio'] = radio_item_builder item_loaders['radio'] = radio_item_loader item_id_generators['radio'] = radio_item_id_generator class RadioItem(BaseItem): def __init__(self, url, name="", from_dict=None): if from_dict is None: super().__init__() self.url = url if not name: self.title = get_radio_server_description(self.url) # The title of the radio station else: self.title = name self.id = hashlib.md5(url.encode()).hexdigest() else: super().__init__(from_dict) self.url = from_dict['url'] self.title = from_dict['title'] self.type = "radio" def validate(self): self.version += 1 # 0 -> 1, notify the wrapper to save me when validate() is visited the first time return True def is_ready(self): return True def uri(self): return self.url def to_dict(self): dict = super().to_dict() dict['url'] = self.url dict['title'] = self.title return dict def format_debug_string(self): return "[radio] {name} ({url})".format( name=self.title, url=self.url ) def format_song_string(self, user): return tr("radio_item", url=self.url, title=get_radio_title(self.url), # the title of current song name=self.title, # the title of radio station user=user ) def format_current_playing(self, user): return tr("now_playing", item=self.format_song_string(user)) def format_title(self): return self.title if self.title else self.url def display_type(self): return tr("radio") ================================================ FILE: media/url.py ================================================ import threading import logging import os import hashlib import traceback from PIL import Image import yt_dlp as youtube_dl import glob from io import BytesIO import base64 import util from constants import tr_cli as tr import media import variables as var from media.item import BaseItem, item_builders, item_loaders, item_id_generators, ValidationFailedError, \ PreparationFailedError from util import format_time log = logging.getLogger("bot") def url_item_builder(**kwargs): return URLItem(kwargs['url']) def url_item_loader(_dict): return URLItem("", _dict) def url_item_id_generator(**kwargs): return hashlib.md5(kwargs['url'].encode()).hexdigest() item_builders['url'] = url_item_builder item_loaders['url'] = url_item_loader item_id_generators['url'] = url_item_id_generator class URLItem(BaseItem): def __init__(self, url, from_dict=None): self.validating_lock = threading.Lock() if from_dict is None: super().__init__() self.url = url if url[-1] != "/" else url[:-1] self.title = "" self.duration = 0 self.id = hashlib.md5(url.encode()).hexdigest() self.path = var.tmp_folder + self.id self.thumbnail = "" self.keywords = "" else: super().__init__(from_dict) self.url = from_dict['url'] self.duration = from_dict['duration'] self.path = from_dict['path'] self.title = from_dict['title'] self.thumbnail = from_dict['thumbnail'] self.downloading = False self.type = "url" def uri(self): return self.path def is_ready(self): if self.downloading or self.ready != 'yes': return False if self.ready == 'yes' and not os.path.exists(self.path): self.log.info( "url: music file missed for %s" % self.format_debug_string()) self.ready = 'validated' return False return True def validate(self): try: self.validating_lock.acquire() if self.ready in ['yes', 'validated']: return True # if self.ready == 'failed': # self.validating_lock.release() # return False # if os.path.exists(self.path): self.ready = "yes" return True # Check if this url is banned if var.db.has_option('url_ban', self.url): raise ValidationFailedError(tr('url_ban', url=self.url)) # avoid multiple process validating in the meantime info = self._get_info_from_url() if not info: return False # Check if the song is too long and is not whitelisted max_duration = var.config.getint('bot', 'max_track_duration') * 60 if max_duration and \ not var.db.has_option('url_whitelist', self.url) and \ self.duration > max_duration: log.info( "url: " + self.url + " has a duration of " + str(self.duration / 60) + " min -- too long") raise ValidationFailedError(tr('too_long', song=self.format_title(), duration=format_time(self.duration), max_duration=format_time(max_duration))) else: self.ready = "validated" self.version += 1 # notify wrapper to save me return True finally: self.validating_lock.release() # Run in a other thread def prepare(self): if not self.downloading: assert self.ready == 'validated' return self._download() else: assert self.ready == 'yes' return True def _get_info_from_url(self): self.log.info("url: fetching metadata of url %s " % self.url) ydl_opts = { 'noplaylist': True } cookie = var.config.get('youtube_dl', 'cookie_file') if cookie: ydl_opts['cookiefile'] = var.config.get('youtube_dl', 'cookie_file') user_agent = var.config.get('youtube_dl', 'user_agent') if user_agent: youtube_dl.utils.std_headers['User-Agent'] = var.config.get('youtube_dl', 'user_agent')\ succeed = False with youtube_dl.YoutubeDL(ydl_opts) as ydl: attempts = var.config.getint('bot', 'download_attempts') for i in range(attempts): try: info = ydl.extract_info(self.url, download=False) self.duration = info['duration'] self.title = info['title'].strip() self.keywords = self.title succeed = True return True except youtube_dl.utils.DownloadError: pass except KeyError: # info has no 'duration' break if not succeed: self.ready = 'failed' self.log.error("url: error while fetching info from the URL") raise ValidationFailedError(tr('unable_download', item=self.format_title())) def _download(self): util.clear_tmp_folder(var.tmp_folder, var.config.getint('bot', 'tmp_folder_max_size')) self.downloading = True base_path = var.tmp_folder + self.id save_path = base_path # Download only if music is not existed self.ready = "preparing" self.log.info("bot: downloading url (%s) %s " % (self.title, self.url)) ydl_opts = { 'format': 'bestaudio/best', 'outtmpl': base_path, 'noplaylist': True, 'writethumbnail': True, 'updatetime': False, 'verbose': var.config.getboolean('debug', 'youtube_dl'), 'postprocessors': [{ 'key': 'FFmpegThumbnailsConvertor', 'format': 'jpg', 'when': 'before_dl' }] } cookie = var.config.get('youtube_dl', 'cookie_file') if cookie: ydl_opts['cookiefile'] = var.config.get('youtube_dl', 'cookie_file') user_agent = var.config.get('youtube_dl', 'user_agent') if user_agent: youtube_dl.utils.std_headers['User-Agent'] = var.config.get('youtube_dl', 'user_agent') with youtube_dl.YoutubeDL(ydl_opts) as ydl: attempts = var.config.getint('bot', 'download_attempts') download_succeed = False for i in range(attempts): self.log.info("bot: download attempts %d / %d" % (i + 1, attempts)) try: ydl.extract_info(self.url) download_succeed = True break except: error_traceback = traceback.format_exc().split("During")[0] error = error_traceback.rstrip().split("\n")[-1] self.log.error("bot: download failed with error:\n %s" % error) if download_succeed: self.path = save_path self.ready = "yes" self.log.info( "bot: finished downloading url (%s) %s, saved to %s." % (self.title, self.url, self.path)) self.downloading = False self._read_thumbnail_from_file(base_path + ".jpg") self.version += 1 # notify wrapper to save me return True else: for f in glob.glob(base_path + "*"): os.remove(f) self.ready = "failed" self.downloading = False raise PreparationFailedError(tr('unable_download', item=self.format_title())) def _read_thumbnail_from_file(self, path_thumbnail): if os.path.isfile(path_thumbnail): im = Image.open(path_thumbnail) self.thumbnail = self._prepare_thumbnail(im) def _prepare_thumbnail(self, im): im.thumbnail((100, 100), Image.LANCZOS) buffer = BytesIO() im = im.convert('RGB') im.save(buffer, format="JPEG") return base64.b64encode(buffer.getvalue()).decode('utf-8') def to_dict(self): dict = super().to_dict() dict['type'] = 'url' dict['url'] = self.url dict['duration'] = self.duration dict['path'] = self.path dict['title'] = self.title dict['thumbnail'] = self.thumbnail return dict def format_debug_string(self): return "[url] {title} ({url})".format( title=self.title, url=self.url ) def format_song_string(self, user): if self.ready in ['validated', 'yes']: return tr("url_item", title=self.title if self.title else "??", url=self.url, user=user) return self.url def format_current_playing(self, user): display = tr("now_playing", item=self.format_song_string(user)) if self.thumbnail: thumbnail_html = '' display += "
" + thumbnail_html return display def format_title(self): return self.title if self.title else self.url def display_type(self): return tr("url") ================================================ FILE: media/url_from_playlist.py ================================================ import logging import yt_dlp as youtube_dl from constants import tr_cli as tr import variables as var from media.item import item_builders, item_loaders, item_id_generators from media.url import URLItem, url_item_id_generator log = logging.getLogger("bot") def get_playlist_info(url, start_index=0, user=""): ydl_opts = { 'extract_flat': 'in_playlist', 'verbose': var.config.getboolean('debug', 'youtube_dl') } cookie = var.config.get('youtube_dl', 'cookie_file') if cookie: ydl_opts['cookiefile'] = var.config.get('youtube_dl', 'cookie_file') user_agent = var.config.get('youtube_dl', 'user_agent') if user_agent: youtube_dl.utils.std_headers['User-Agent'] = var.config.get('youtube_dl', 'user_agent') with youtube_dl.YoutubeDL(ydl_opts) as ydl: attempts = var.config.getint('bot', 'download_attempts') for i in range(attempts): items = [] try: info = ydl.extract_info(url, download=False) # # if url is not a playlist but a video # if 'entries' not in info and 'webpage_url' in info: # music = {'type': 'url', # 'title': info['title'], # 'url': info['webpage_url'], # 'user': user, # 'ready': 'validation'} # items.append(music) # return items playlist_title = info['title'] for j in range(start_index, min(len(info['entries']), start_index + var.config.getint('bot', 'max_track_playlist'))): # Unknow String if No title into the json title = info['entries'][j]['title'] if 'title' in info['entries'][j] else "Unknown Title" # Add youtube url if the url in the json isn't a full url item_url = info['entries'][j]['url'] if info['entries'][j]['url'][0:4] == 'http' \ else "https://www.youtube.com/watch?v=" + info['entries'][j]['url'] print(info['entries'][j]) music = { "type": "url_from_playlist", "url": item_url, "title": title, "playlist_url": url, "playlist_title": playlist_title, "user": user } items.append(music) except Exception as ex: log.exception(ex, exc_info=True) continue return items def playlist_url_item_builder(**kwargs): return PlaylistURLItem(kwargs['url'], kwargs['title'], kwargs['playlist_url'], kwargs['playlist_title']) def playlist_url_item_loader(_dict): return PlaylistURLItem("", "", "", "", _dict) item_builders['url_from_playlist'] = playlist_url_item_builder item_loaders['url_from_playlist'] = playlist_url_item_loader item_id_generators['url_from_playlist'] = url_item_id_generator class PlaylistURLItem(URLItem): def __init__(self, url, title, playlist_url, playlist_title, from_dict=None): if from_dict is None: super().__init__(url) self.title = title self.playlist_url = playlist_url self.playlist_title = playlist_title else: super().__init__("", from_dict) self.playlist_title = from_dict['playlist_title'] self.playlist_url = from_dict['playlist_url'] self.type = "url_from_playlist" def to_dict(self): tmp_dict = super().to_dict() tmp_dict['playlist_url'] = self.playlist_url tmp_dict['playlist_title'] = self.playlist_title return tmp_dict def format_debug_string(self): return "[url] {title} ({url}) from playlist {playlist}".format( title=self.title, url=self.url, playlist=self.playlist_title ) def format_song_string(self, user): return tr("url_from_playlist_item", title=self.title, url=self.url, playlist_url=self.playlist_url, playlist=self.playlist_title, user=user) def format_current_playing(self, user): display = tr("now_playing", item=self.format_song_string(user)) if self.thumbnail: thumbnail_html = '' display += "
" + thumbnail_html return display def display_type(self): return tr("url_from_playlist") ================================================ FILE: mumbleBot.py ================================================ #!/usr/bin/env python3 # -*- coding: utf-8 -*- import re import threading import time import sys import math import signal import configparser import audioop import subprocess as sp import argparse import os import os.path import pymumble_py3 as pymumble import pymumble_py3.constants import variables as var import logging import logging.handlers import traceback import struct from packaging import version import util import command import constants import media.playlist from constants import tr_cli as tr from database import SettingsDatabase, MusicDatabase, DatabaseMigration from media.item import ValidationFailedError, PreparationFailedError from media.cache import MusicCache class MumbleBot: version = 'git' def __init__(self, args): self.log = logging.getLogger("bot") self.log.info(f"bot: botamusique version {self.get_version()}, starting...") signal.signal(signal.SIGINT, self.ctrl_caught) self.cmd_handle = {} self.stereo = var.config.getboolean('bot', 'stereo') if args.channel: self.channel = args.channel else: self.channel = var.config.get("server", "channel") var.user = args.user var.is_proxified = var.config.getboolean( "webinterface", "is_web_proxified") # Flags to indicate the bot is exiting (Ctrl-C, or !kill) self.exit = False self.nb_exit = 0 # Related to ffmpeg thread self.thread = None self.thread_stderr = None self.read_pcm_size = 0 self.pcm_buffer_size = 0 self.last_ffmpeg_err = "" # Play/pause status self.is_pause = False self.pause_at_id = "" self.playhead = -1 # current position in a song. self.song_start_at = -1 self.wait_for_ready = False # flag for the loop are waiting for download to complete in the other thread # self.on_interrupting = False if args.host: host = args.host else: host = var.config.get("server", "host") if args.port: port = args.port else: port = var.config.getint("server", "port") if args.password: password = args.password else: password = var.config.get("server", "password") if args.channel: self.channel = args.channel else: self.channel = var.config.get("server", "channel") if args.certificate: certificate = args.certificate else: certificate = util.solve_filepath(var.config.get("server", "certificate")) if args.tokens: tokens = args.tokens else: tokens = var.config.get("server", "tokens") tokens = tokens.split(',') if args.user: self.username = args.user else: self.username = var.config.get("bot", "username") if args.bandwidth: self.bandwidth = args.bandwidth else: self.bandwidth = var.config.getint("bot", "bandwidth") self.mumble = pymumble.Mumble(host, user=self.username, port=port, password=password, tokens=tokens, stereo=self.stereo, debug=var.config.getboolean('debug', 'mumble_connection'), certfile=certificate) self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_TEXTMESSAGERECEIVED, self.message_received) self.mumble.set_codec_profile("audio") self.mumble.start() # start the mumble thread self.mumble.is_ready() # wait for the connection if self.mumble.connected >= pymumble.constants.PYMUMBLE_CONN_STATE_FAILED: exit() self.set_comment() self.set_avatar() self.mumble.users.myself.unmute() # by sure the user is not muted self.join_channel() self.mumble.set_bandwidth(self.bandwidth) bots = var.config.get("bot", "when_nobody_in_channel_ignore",fallback="") self.bots = set(bots.split(',')) self._user_in_channel = self.get_user_count_in_channel() # ====== Volume ====== self.volume_helper = util.VolumeHelper() max_vol = var.config.getfloat('bot', 'max_volume') if var.db.has_option('bot', 'max_volume'): max_vol = var.db.getfloat('bot', 'max_volume') _volume = var.config.getfloat('bot', 'volume') if var.db.has_option('bot', 'volume'): _volume = var.db.getfloat('bot', 'volume') _volume = min(_volume, max_vol) self.volume_helper.set_volume(_volume) self.is_ducking = False self.on_ducking = False self.ducking_release = time.time() self.last_volume_cycle_time = time.time() self._ducking_volume = 0 _ducking_volume = var.config.getfloat("bot", "ducking_volume") _ducking_volume = var.db.getfloat("bot", "ducking_volume", fallback=_ducking_volume) self.volume_helper.set_ducking_volume(_ducking_volume) self.ducking_threshold = var.config.getfloat("bot", "ducking_threshold") self.ducking_threshold = var.db.getfloat("bot", "ducking_threshold", fallback=self.ducking_threshold) if not var.db.has_option("bot", "ducking") and var.config.getboolean("bot", "ducking") \ or var.config.getboolean("bot", "ducking"): self.is_ducking = True self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_SOUNDRECEIVED, self.ducking_sound_received) self.mumble.set_receive_sound(True) assert var.config.get("bot", "when_nobody_in_channel") in ['pause', 'pause_resume', 'stop', 'nothing', ''], \ "Unknown action for when_nobody_in_channel" if var.config.get("bot", "when_nobody_in_channel") in ['pause', 'pause_resume', 'stop']: user_change_callback = \ lambda user, action: threading.Thread(target=self.users_changed, args=(user, action), daemon=True).start() self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_USERREMOVED, user_change_callback) self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_USERUPDATED, user_change_callback) # Debug use self._loop_status = 'Idle' self._display_rms = False self._max_rms = 0 self.redirect_ffmpeg_log = var.config.getboolean('debug', 'redirect_ffmpeg_log') if var.config.getboolean("bot", "auto_check_update"): def check_update(): nonlocal self new_version, changelog = util.check_update(self.get_version()) if new_version: self.send_channel_msg(tr('new_version_found', new_version=new_version, changelog=changelog)) th = threading.Thread(target=check_update, name="UpdateThread") th.daemon = True th.start() last_startup_version = var.db.get("bot", "version", fallback=None) try: if not last_startup_version or version.parse(last_startup_version) < version.parse(self.version): var.db.set("bot", "version", self.version) if var.config.getboolean("bot", "auto_check_update"): changelog = util.fetch_changelog() self.send_channel_msg(tr("update_successful", version=self.version, changelog=changelog)) except version.InvalidVersion: var.db.set("bot", "version", self.version) # Set the CTRL+C shortcut def ctrl_caught(self, signal, frame): self.log.info( "\nSIGINT caught, quitting, {} more to kill".format(2 - self.nb_exit)) if var.config.getboolean('bot', 'save_playlist') \ and var.config.get("bot", "save_music_library"): self.log.info("bot: save playlist into database") var.playlist.save() if self.nb_exit > 1: self.log.info("Forced Quit") sys.exit(0) self.nb_exit += 1 self.exit = True def get_version(self): if self.version != "git": return self.version else: return util.get_snapshot_version() def register_command(self, cmd, handle, no_partial_match=False, access_outside_channel=False, admin=False): cmds = cmd.split(",") for command in cmds: command = command.strip() if command: self.cmd_handle[command] = {'handle': handle, 'partial_match': not no_partial_match, 'access_outside_channel': access_outside_channel, 'admin': admin} self.log.debug("bot: command added: " + command) def set_comment(self): self.mumble.users.myself.comment(var.config.get('bot', 'comment')) def set_avatar(self): avatar_path = var.config.get('bot', 'avatar') if avatar_path: with open(avatar_path, 'rb') as avatar_file: self.mumble.users.myself.texture(avatar_file.read()) else: self.mumble.users.myself.texture(b'') def join_channel(self): if self.channel: if '/' in self.channel: self.mumble.channels.find_by_tree(self.channel.split('/')).move_in() else: self.mumble.channels.find_by_name(self.channel).move_in() # ======================= # Message # ======================= # All text send to the chat is analysed by this function def message_received(self, text): raw_message = text.message.strip() message = re.sub(r'<.*?>', '', raw_message) if text.actor == 0: # Some server will send a welcome message to the bot once connected. # It doesn't have a valid "actor". Simply ignore it here. return user = self.mumble.users[text.actor]['name'] if var.config.getboolean('commands', 'split_username_at_space'): # in can you use https://github.com/Natenom/mumblemoderator-module-collection/tree/master/os-suffixes , # you want to split the username user = user.split()[0] command_symbols = var.config.get('commands', 'command_symbol') match = re.match(fr'^[{re.escape(command_symbols)}](?P\S+)(?:\s(?P.*))?', message) if match: command = match.group("command").lower() argument = match.group("argument") or "" if not command: return self.log.info(f'bot: received command "{command}" with arguments "{argument}" from {user}') # Anti stupid guy function if not self.is_admin(user) and not var.config.getboolean('bot', 'allow_private_message') and text.session: self.mumble.users[text.actor].send_text_message( tr('pm_not_allowed')) return for i in var.db.items("user_ban"): if user.lower() == i[0]: self.mumble.users[text.actor].send_text_message( tr('user_ban')) return if not self.is_admin(user) and argument: input_url = util.get_url_from_input(argument) if input_url and var.db.has_option('url_ban', input_url): self.mumble.users[text.actor].send_text_message( tr('url_ban')) return command_exc = "" try: if command in self.cmd_handle: command_exc = command else: # try partial match cmds = self.cmd_handle.keys() matches = [] for cmd in cmds: if cmd.startswith(command) and self.cmd_handle[cmd]['partial_match']: matches.append(cmd) if len(matches) == 1: self.log.info("bot: {:s} matches {:s}".format(command, matches[0])) command_exc = matches[0] elif len(matches) > 1: self.mumble.users[text.actor].send_text_message( tr('which_command', commands="
".join(matches))) return else: self.mumble.users[text.actor].send_text_message( tr('bad_command', command=command)) return if self.cmd_handle[command_exc]['admin'] and not self.is_admin(user): self.mumble.users[text.actor].send_text_message(tr('not_admin')) return if not self.cmd_handle[command_exc]['access_outside_channel'] \ and not self.is_admin(user) \ and not var.config.getboolean('bot', 'allow_other_channel_message') \ and self.mumble.users[text.actor]['channel_id'] != self.mumble.users.myself['channel_id']: self.mumble.users[text.actor].send_text_message( tr('not_in_my_channel')) return self.cmd_handle[command_exc]['handle'](self, user, text, command_exc, argument) except: error_traceback = traceback.format_exc() error = error_traceback.rstrip().split("\n")[-1] self.log.error(f"bot: command {command_exc} failed with error: {error_traceback}\n") self.send_msg(tr('error_executing_command', command=command_exc, error=error), text) def send_msg(self, msg, text): msg = msg.encode('utf-8', 'ignore').decode('utf-8') # text if the object message, contain information if direct message or channel message self.mumble.users[text.actor].send_text_message(msg) def send_channel_msg(self, msg): msg = msg.encode('utf-8', 'ignore').decode('utf-8') own_channel = self.mumble.channels[self.mumble.users.myself['channel_id']] own_channel.send_text_message(msg) @staticmethod def is_admin(user): list_admin = var.config.get('bot', 'admin').rstrip().split(';') if user in list_admin: return True else: return False # ======================= # Other Mumble Events # ======================= def get_user_count_in_channel(self): # Get the channel, based on the channel id own_channel = self.mumble.channels[self.mumble.users.myself['channel_id']] # Build set of unique usernames users = set([user.get_property("name") for user in own_channel.get_users()]) # Exclude all bots from the set of usernames users = users.difference(self.bots) # Return the number of elements in the set, as the final user count return len(users) def users_changed(self, user, message): # only check if there is one more user currently in the channel # else when the music is paused and somebody joins, music would start playing again user_count = self.get_user_count_in_channel() if user_count > self._user_in_channel and user_count == 2: if var.config.get("bot", "when_nobody_in_channel") == "pause_resume": self.resume() elif var.config.get("bot", "when_nobody_in_channel") == "pause" and self.is_pause: self.send_channel_msg(tr("auto_paused")) elif user_count == 1 and len(var.playlist) != 0: # if the bot is the only user left in the channel and the playlist isn't empty if var.config.get("bot", "when_nobody_in_channel") == "stop": self.log.info('bot: No user in my channel. Stop music now.') self.clear() else: self.log.info('bot: No user in my channel. Pause music now.') self.pause() self._user_in_channel = user_count # ======================= # Launch and Download # ======================= def launch_music(self, music_wrapper, start_from=0): assert music_wrapper.is_ready() uri = music_wrapper.uri() self.log.info("bot: play music " + music_wrapper.format_debug_string()) if var.config.getboolean('bot', 'announce_current_music'): self.send_channel_msg(music_wrapper.format_current_playing()) if var.config.getboolean('debug', 'ffmpeg'): ffmpeg_debug = "debug" else: ffmpeg_debug = "warning" channels = 2 if self.stereo else 1 self.pcm_buffer_size = 960 * channels command = ("ffmpeg", '-v', ffmpeg_debug, '-nostdin', '-i', uri, '-ss', f"{start_from:f}", '-ac', str(channels), '-f', 's16le', '-ar', '48000', '-') self.log.debug("bot: execute ffmpeg command: " + " ".join(command)) # The ffmpeg process is a thread # prepare pipe for catching stderr of ffmpeg if self.redirect_ffmpeg_log: pipe_rd, pipe_wd = util.pipe_no_wait() # Let the pipe work in non-blocking mode self.thread_stderr = os.fdopen(pipe_rd) else: pipe_rd, pipe_wd = None, None self.thread = sp.Popen(command, stdout=sp.PIPE, stderr=pipe_wd, bufsize=self.pcm_buffer_size) def async_download_next(self): # Function start if the next music isn't ready # Do nothing in case the next music is already downloaded self.log.debug("bot: Async download next asked ") while var.playlist.next_item(): # usually, all validation will be done when adding to the list. # however, for performance consideration, youtube playlist won't be validate when added. # the validation has to be done here. next = var.playlist.next_item() try: if not next.is_ready(): self.async_download(next) break except ValidationFailedError as e: self.send_channel_msg(e.msg) var.playlist.remove_by_id(next.id) var.cache.free_and_delete(next.id) def async_download(self, item): th = threading.Thread( target=self._download, name="Prepare-" + item.id[:7], args=(item,)) self.log.info(f"bot: start preparing item in thread: {item.format_debug_string()}") th.daemon = True th.start() return th def start_download(self, item): if not item.is_ready(): self.log.info("bot: current music isn't ready, start downloading.") self.async_download(item) self.send_channel_msg( tr('download_in_progress', item=item.format_title())) def _download(self, item): ver = item.version try: item.validate() if item.is_ready(): return True except ValidationFailedError as e: self.send_channel_msg(e.msg) var.playlist.remove_by_id(item.id) var.cache.free_and_delete(item.id) return False try: item.prepare() if item.version > ver: var.playlist.version += 1 return True except PreparationFailedError as e: self.send_channel_msg(e.msg) return False # ======================= # Loop # ======================= # Main loop of the Bot def loop(self): while not self.exit and self.mumble.is_alive(): while self.thread and self.mumble.sound_output.get_buffer_size() > 0.5 and not self.exit: # If the buffer isn't empty, I cannot send new music part, so I wait self._loop_status = f'Wait for buffer {self.mumble.sound_output.get_buffer_size():.3f}' time.sleep(0.01) raw_music = None if self.thread: # I get raw from ffmpeg thread # move playhead forward self._loop_status = 'Reading raw' if self.song_start_at == -1: self.song_start_at = time.time() - self.playhead self.playhead = time.time() - self.song_start_at raw_music = self.thread.stdout.read(self.pcm_buffer_size) self.read_pcm_size += len(raw_music) if self.redirect_ffmpeg_log: try: self.last_ffmpeg_err = self.thread_stderr.readline() if self.last_ffmpeg_err: self.log.debug("ffmpeg: " + self.last_ffmpeg_err.strip("\n")) except: pass if raw_music: # Adjust the volume and send it to mumble self.volume_cycle() if not self.on_interrupting and len(raw_music) == self.pcm_buffer_size: self.mumble.sound_output.add_sound( audioop.mul(raw_music, 2, self.volume_helper.real_volume)) elif self.read_pcm_size == 0: self.mumble.sound_output.add_sound( audioop.mul(self._fadeout(raw_music, self.stereo, fadein=True), 2, self.volume_helper.real_volume)) elif self.on_interrupting or len(raw_music) < self.pcm_buffer_size: self.mumble.sound_output.add_sound( audioop.mul(self._fadeout(raw_music, self.stereo, fadein=False), 2, self.volume_helper.real_volume)) self.thread.kill() self.thread = None time.sleep(0.1) self.on_interrupting = False else: time.sleep(0.1) else: time.sleep(0.1) if not self.is_pause and not raw_music: self.thread = None # bot is not paused, but ffmpeg thread has gone. # indicate that last song has finished, or the bot just resumed from pause, or something is wrong. if self.read_pcm_size < self.pcm_buffer_size \ and var.playlist.current_index != -1 \ and self.last_ffmpeg_err: current = var.playlist.current_item() self.log.error("bot: cannot play music %s", current.format_debug_string()) self.log.error("bot: with ffmpeg error: %s", self.last_ffmpeg_err) self.last_ffmpeg_err = "" self.send_channel_msg(tr('unable_play', item=current.format_title())) var.playlist.remove_by_id(current.id) var.cache.free_and_delete(current.id) # move to the next song. if not self.wait_for_ready: # if wait_for_ready flag is not true, move to the next song. if var.playlist.next(): current = var.playlist.current_item() self.log.debug(f"bot: next into the song: {current.format_debug_string()}") try: self.start_download(current) self.wait_for_ready = True self.song_start_at = -1 self.playhead = 0 except ValidationFailedError as e: self.send_channel_msg(e.msg) var.playlist.remove_by_id(current.id) var.cache.free_and_delete(current.id) else: self._loop_status = 'Empty queue' else: # if wait_for_ready flag is true, means the pointer is already # pointing to target song. start playing current = var.playlist.current_item() if current: if current.is_ready(): self.wait_for_ready = False self.read_pcm_size = 0 self.launch_music(current, self.playhead) self.last_volume_cycle_time = time.time() self.async_download_next() elif current.is_failed(): var.playlist.remove_by_id(current.id) self.wait_for_ready = False else: self._loop_status = 'Wait for the next item to be ready' else: self.wait_for_ready = False while self.mumble.sound_output.get_buffer_size() > 0 and self.mumble.is_alive(): # Empty the buffer before exit time.sleep(0.01) time.sleep(0.5) if self.exit: self._loop_status = "exited" if var.config.getboolean('bot', 'save_playlist') \ and var.config.get("bot", "save_music_library"): self.log.info("bot: save playlist into database") var.playlist.save() def volume_cycle(self): delta = time.time() - self.last_volume_cycle_time if self.on_ducking and self.ducking_release < time.time(): self.on_ducking = False self._max_rms = 0 if delta > 0.001: if self.is_ducking and self.on_ducking: self.volume_helper.real_volume = \ (self.volume_helper.real_volume - self.volume_helper.ducking_volume_set) * math.exp(- delta / 0.2) \ + self.volume_helper.ducking_volume_set else: self.volume_helper.real_volume = self.volume_helper.volume_set - \ (self.volume_helper.volume_set - self.volume_helper.real_volume) * math.exp(- delta / 0.5) self.last_volume_cycle_time = time.time() def ducking_sound_received(self, user, sound): rms = audioop.rms(sound.pcm, 2) self._max_rms = max(rms, self._max_rms) if self._display_rms: if rms < self.ducking_threshold: print('%6d/%6d ' % (rms, self._max_rms) + '-' * int(rms / 200), end='\r') else: print('%6d/%6d ' % (rms, self._max_rms) + '-' * int(self.ducking_threshold / 200) + '+' * int((rms - self.ducking_threshold) / 200), end='\r') if rms > self.ducking_threshold: if self.on_ducking is False: self.log.debug("bot: ducking triggered") self.on_ducking = True self.ducking_release = time.time() + 1 # ducking release after 1s def _fadeout(self, _pcm_data, stereo=False, fadein=False): pcm_data = bytearray(_pcm_data) if stereo: if not fadein: mask = [math.exp(-x / 60) for x in range(0, int(len(pcm_data) / 4))] else: mask = [math.exp(-x / 60) for x in reversed(range(0, int(len(pcm_data) / 4)))] for i in range(int(len(pcm_data) / 4)): pcm_data[4 * i:4 * i + 2] = struct.pack(" 0: self.wait_for_ready = True else: self.wait_for_ready = False self.log.info("bot: music stopped.") def interrupt(self): # Kill the ffmpeg thread if self.thread: self.on_interrupting = True time.sleep(0.1) self.song_start_at = -1 self.read_pcm_size = 0 def pause(self): # Kill the ffmpeg thread self.interrupt() self.is_pause = True self.song_start_at = -1 if len(var.playlist) > 0: self.pause_at_id = var.playlist.current_item().id self.log.info(f"bot: music paused at {self.playhead:.2f} seconds.") def resume(self): self.is_pause = False if var.playlist.current_index == -1: var.playlist.next() self.playhead = 0 return music_wrapper = var.playlist.current_item() if not music_wrapper or not music_wrapper.id == self.pause_at_id or not music_wrapper.is_ready(): self.playhead = 0 return self.wait_for_ready = True self.pause_at_id = "" def start_web_interface(addr, port): global formatter import interface # setup logger werkzeug_logger = logging.getLogger('werkzeug') logfile = util.solve_filepath(var.config.get('webinterface', 'web_logfile')) if logfile: handler = logging.handlers.RotatingFileHandler(logfile, mode='a', maxBytes=10240, backupCount=3) # Rotate after 10KB, leave 3 old logs else: handler = logging.StreamHandler() werkzeug_logger.addHandler(handler) interface.init_proxy() interface.web.env = 'development' interface.web.secret_key = var.config.get('webinterface', 'flask_secret') interface.web.run(port=port, host=addr) if __name__ == '__main__': supported_languages = util.get_supported_language() parser = argparse.ArgumentParser( description='Bot for playing music on Mumble') # General arguments parser.add_argument("--config", dest='config', type=str, default='configuration.ini', help='Load configuration from this file. Default: configuration.ini') parser.add_argument("--db", dest='db', type=str, default=None, help='Settings database file') parser.add_argument("--music-db", dest='music_db', type=str, default=None, help='Music library database file') parser.add_argument("--lang", dest='lang', type=str, default=None, help='Preferred language. Support ' + ", ".join(supported_languages)) parser.add_argument("-q", "--quiet", dest="quiet", action="store_true", help="Only Error logs") parser.add_argument("-v", "--verbose", dest="verbose", action="store_true", help="Show debug log") # Mumble arguments parser.add_argument("-s", "--server", dest="host", type=str, help="Hostname of the Mumble server") parser.add_argument("-u", "--user", dest="user", type=str, help="Username for the bot") parser.add_argument("-P", "--password", dest="password", type=str, help="Server password, if required") parser.add_argument("-T", "--tokens", dest="tokens", type=str, help="Server tokens to enter a channel, if required (multiple entries separated with comma ','") parser.add_argument("-p", "--port", dest="port", type=int, help="Port for the Mumble server") parser.add_argument("-c", "--channel", dest="channel", type=str, help="Default channel for the bot") parser.add_argument("-C", "--cert", dest="certificate", type=str, default=None, help="Certificate file") parser.add_argument("-b", "--bandwidth", dest="bandwidth", type=int, help="Bandwidth used by the bot") args = parser.parse_args() # ====================== # Load Config # ====================== config = configparser.ConfigParser(interpolation=None, allow_no_value=True) default_config = configparser.ConfigParser(interpolation=None, allow_no_value=True) var.config = config if len(default_config.read( util.solve_filepath('configuration.default.ini'), encoding='utf-8')) == 0: logging.error("Could not read default configuration file 'configuration.default.ini', please check" "your installation.") sys.exit() if len(config.read( [util.solve_filepath('configuration.default.ini'), util.solve_filepath(args.config)], encoding='utf-8')) == 0: logging.error(f'Could not read configuration from file "{args.config}"') sys.exit() extra_configs = util.check_extra_config(config, default_config) if extra_configs: extra_str = ", ".join([f"'[{k}] {v}'" for (k, v) in extra_configs]) logging.error(f'Unexpected config items {extra_str} defined in your config file. ' f'This is likely caused by a recent change in the names of config items, ' f'or the removal of obsolete config items. Please refer to the changelog.') sys.exit() # ====================== # Setup Logger # ====================== bot_logger = logging.getLogger("bot") bot_logger.setLevel(logging.INFO) if args.verbose: bot_logger.setLevel(logging.DEBUG) bot_logger.debug("Starting in DEBUG loglevel") elif args.quiet: bot_logger.setLevel(logging.ERROR) bot_logger.error("Starting in ERROR loglevel") logfile = util.solve_filepath(var.config.get('bot', 'logfile').strip()) handler = None if logfile: print(f"Redirecting stdout and stderr to log file: {logfile}") handler = logging.handlers.RotatingFileHandler(logfile, mode='a', maxBytes=10240, backupCount=3) # Rotate after 10KB, leave 3 old logs if var.config.getboolean("bot", "redirect_stderr"): sys.stderr = util.LoggerIOWrapper(bot_logger, logging.INFO, fallback_io_buffer=sys.stderr.buffer) else: handler = logging.StreamHandler() util.set_logging_formatter(handler, bot_logger.level) bot_logger.addHandler(handler) logging.getLogger("root").addHandler(handler) var.bot_logger = bot_logger # ====================== # Load Database # ====================== if args.user: username = args.user else: username = var.config.get("bot", "username") sanitized_username = "".join([x if x.isalnum() else "_" for x in username]) var.settings_db_path = args.db if args.db is not None else util.solve_filepath( config.get("bot", "database_path") or f"settings-{sanitized_username}.db") var.music_db_path = args.music_db if args.music_db is not None else util.solve_filepath( config.get("bot", "music_database_path")) var.db = SettingsDatabase(var.settings_db_path) if var.config.get("bot", "save_music_library"): var.music_db = MusicDatabase(var.music_db_path) else: var.music_db = MusicDatabase(":memory:") DatabaseMigration(var.db, var.music_db).migrate() var.music_folder = util.solve_filepath(var.config.get('bot', 'music_folder')) if not var.music_folder.endswith(os.sep): # The file searching logic assumes that the music folder ends in a / var.music_folder = var.music_folder + os.sep var.tmp_folder = util.solve_filepath(var.config.get('bot', 'tmp_folder')) # ====================== # Translation # ====================== lang = "" if args.lang: lang = args.lang else: lang = var.config.get('bot', 'language') if lang not in supported_languages: raise KeyError(f"Unsupported language {lang}") var.language = lang constants.load_lang(lang) # ====================== # Prepare Cache # ====================== var.cache = MusicCache(var.music_db) if var.config.getboolean("bot", "refresh_cache_on_startup"): var.cache.build_dir_cache() # ====================== # Load playback mode # ====================== playback_mode = None if var.db.has_option("playlist", "playback_mode"): playback_mode = var.db.get('playlist', 'playback_mode') else: playback_mode = var.config.get('bot', 'playback_mode') if playback_mode in ["one-shot", "repeat", "random", "autoplay"]: var.playlist = media.playlist.get_playlist(playback_mode) else: raise KeyError(f"Unknown playback mode '{playback_mode}'") # ====================== # Create bot instance # ====================== var.bot = MumbleBot(args) command.register_all_commands(var.bot) # load playlist if var.config.getboolean('bot', 'save_playlist'): var.bot_logger.info("bot: load playlist from previous session") var.playlist.load() # ============================ # Start the web interface # ============================ if var.config.getboolean("webinterface", "enabled"): wi_addr = var.config.get("webinterface", "listening_addr") wi_port = var.config.getint("webinterface", "listening_port") tt = threading.Thread( target=start_web_interface, name="WebThread", args=(wi_addr, wi_port)) tt.daemon = True bot_logger.info('Starting web interface on {}:{}'.format(wi_addr, wi_port)) tt.start() # Start the main loop. var.bot.loop() ================================================ FILE: requirements.txt ================================================ flask yt-dlp python-magic Pillow mutagen requests packaging pymumble>=1.2 pyradios ================================================ FILE: scripts/commit_new_translation.sh ================================================ #!/usr/bin/env bash git remote set-url origin https://azlux:$GITHUB_API@github.com/azlux/botamusique/ echo "=> Fetching for bot-traduora branch..." if git fetch origin bot-traduora; then echo "==> bot-traduora branch exists" git branch bot-traduora FETCH_HEAD CREATE_PR=false else echo "==> bot-traduora branch doesn't exist, create one" git branch bot-traduora CREATE_PR=true fi git checkout bot-traduora echo "=> Fetching updates from the server..." $SOURCE_DIR/scripts/sync_translation.py --lang-dir $SOURCE_DIR/lang/ --client $TRADUORA_R_CLIENT --secret $TRADUORA_R_SECRET --fetch git add lang/* git status if [ "$PUSH" = "true" ]; then echo "=> Pushing updates to bot-traduora branch..." if GIT_COMMITTER_NAME='Traduora Bot' GIT_COMMITTER_EMAIL='noreply@azlux.fr' git commit -m 'Bot: Update translation' --author "Traduora Bot "; then git push origin bot-traduora sleep 2 if $CREATE_PR; then GITHUB_USER="azlux" GITHUB_TOKEN="$GITHUB_API" hub pull-request -m "Bot: TRADUORA Update"; fi exit 0 fi echo "==> There's nothing to push." exit 0 fi ================================================ FILE: scripts/sync_translation.py ================================================ #!/usr/bin/env python3 import os import re import argparse import requests base_url = "https://translate.azlux.fr/api/v1" project_id = "4aafb197-3282-47b3-a197-0ca870cf6ab2" lang_dir = "" def get_access_header(client, secret): data = {"grant_type": "client_credentials", "client_id": client, "client_secret": secret} r = requests.post(f"{base_url}/auth/token", json=data) if r.status_code != 200: print("Access denied! Please check your client ID or secret.") exit(1) token = r.json()["access_token"] headers = {"Authorization": "Bearer " + token, "Accept": "application/json, text/plain, */*"} return headers def fetch_translation(r_client, r_secret): headers = get_access_header(r_client, r_secret) r = requests.get(f"{base_url}/projects/{project_id}/translations", headers=headers) translations = r.json()['data'] for translation in translations: lang_code = translation['locale']['code'] print(f" - Fetching {lang_code}") params = {'locale': lang_code, 'format': 'jsonnested'} r = requests.get(f"{base_url}/projects/{project_id}/exports", params=params, headers=headers) with open(os.path.join(lang_dir, f"{lang_code}.json"), "wb") as f: f.write(r.content) def push_strings(w_client, w_secret): print("Pushing local translation files into the remote host...") headers = get_access_header(w_client, w_secret) lang_files = os.listdir(lang_dir) lang_list = [] for lang_file in lang_files: match = re.search("([a-z]{2}_[A-Z]{2})\.json", lang_file) if match: lang_list.append(match[1]) for lang in lang_list: print(f" - Pushing {lang}") params = {'locale': lang, 'format': 'jsonnested'} files = {'file': open(os.path.join(lang_dir, f"{lang}.json"), 'r')} r = requests.post(f"{base_url}/projects/{project_id}/imports", params=params, headers=headers, files=files) assert r.status_code == 200, f"Unable to push {lang} into remote host. {r.status_code}" if __name__ == "__main__": parser = argparse.ArgumentParser( description="Sync translation files with azlux's traduora server.") parser.add_argument("--lang-dir", dest="lang_dir", type=str, help="Directory of the lang files.") parser.add_argument("--client", dest="client", type=str, help="Client ID used to access the server.") parser.add_argument("--secret", dest="secret", type=str, help="Secret used to access the server.") parser.add_argument("--fetch", dest='fetch', action="store_true", help='Fetch translation files from the server.') parser.add_argument("--push", dest='push', action="store_true", help='Push local translation files into the server.') args = parser.parse_args() lang_dir = args.lang_dir if not args.client or not args.secret: print("Client ID and secret need to be provided!") exit(1) if args.push: push_strings(args.client, args.secret) if args.fetch: fetch_translation(args.client, args.secret) print("Done.") ================================================ FILE: scripts/translate_templates.py ================================================ #!/usr/bin/env python3 import argparse import os import json import re import jinja2 default_lang_dict = {} lang_dict = {} lang_dir = "" template_dir = "" def load_lang(lang): with open(os.path.join(lang_dir, f"{lang}.json"), "r") as f: return json.load(f) def tr(option): try: if option in lang_dict['web'] and lang_dict['web'][option]: string = lang_dict['web'][option] else: string = default_lang_dict['web'][option] return string except KeyError: raise KeyError("Missed strings in language file: '{string}'. " .format(string=option)) if __name__ == "__main__": parser = argparse.ArgumentParser( description="Populate html templates with translation strings.") parser.add_argument("--lang-dir", dest="lang_dir", type=str, help="Directory of the lang files.") parser.add_argument("--template-dir", dest="template_dir", type=str, help="Directory of the template files.") args = parser.parse_args() lang_dir = args.lang_dir template_dir = args.template_dir html_files = os.listdir(template_dir) for html_file in html_files: match = re.search("(.+)\.template\.html", html_file) if match is None: continue print(f"Populating {html_file} with translations...") basename = match[1] with open(os.path.join(template_dir, f"{html_file}"), "r") as f: html = f.read() lang_files = os.listdir(lang_dir) lang_list = [] default_lang_dict = load_lang("en_US") for lang_file in lang_files: match = re.search("([a-z]{2}_[A-Z]{2})\.json", lang_file) if match: lang_list.append(match[1]) template = jinja2.Template(html) for lang in lang_list: print(f" - Populating {lang}...") lang_dict = load_lang(lang) with open(os.path.join(template_dir, f"{basename}.{lang}.html"), "w") as f: f.write(template.render(tr=tr)) print("Done.") ================================================ FILE: scripts/update_translation_to_server.sh ================================================ #!/usr/bin/env bash set -e git remote set-url origin https://azlux:$GITHUB_API@github.com/azlux/botamusique/ git pull origin master echo "=> Checking if translations in this commit differ from the server..." git branch testing-translation master git checkout testing-translation $SOURCE_DIR/scripts/sync_translation.py --lang-dir $SOURCE_DIR/lang/ --client $TRADUORA_R_CLIENT --secret $TRADUORA_R_SECRET --fetch if [ -z "$(git diff)" ]; then echo "==> No difference found." exit 0 fi echo "==> Modifications found." echo "=> Check if the modifications are based on the translations on the server..." n=1 COMMON_FOUND=false while [ $n -le 20 ]; do echo "==> Comparing server's translations with master~$n ($(git show --oneline --quiet master~$n))" CHANGED_LANG_FILE=$(git diff --name-only master~$n | grep "lang/" || true) if [ -z "$CHANGED_LANG_FILE" ]; then COMMON_FOUND=true break else echo "==> Modified lang files: $CHANGED_LANG_FILE" fi (( n++ )) done if (! $COMMON_FOUND); then echo "==> CONFLICTS: Previous commits doesn't share the same translations with the server." echo " There are unmerged translation updates on the server." echo " Please manually update these changes or wait for the pull request" echo " created by the translation bot get merged." exit 1 fi echo "==> master~$n ($(git show --oneline --quiet master~$n)) shares the same translations with the server." echo "=> Preparing to push local translation updates to the server..." git checkout -f master $SOURCE_DIR/scripts/sync_translation.py --lang-dir $SOURCE_DIR/lang/ --client $TRADUORA_W_CLIENT --secret $TRADUORA_W_SECRET --push echo "=> Fix translation format..." $SOURCE_DIR/scripts/sync_translation.py --lang-dir $SOURCE_DIR/lang/ --client $TRADUORA_R_CLIENT --secret $TRADUORA_R_SECRET --fetch git add lang/* git status if GIT_COMMITTER_NAME='Traduora Bot' GIT_COMMITTER_EMAIL='noreply@azlux.fr' git commit -m 'Bot: Reformat translation'; then git push origin master fi exit 0 ================================================ FILE: static/.gitignore ================================================ css/ js/ ================================================ FILE: update.sh ================================================ #!/usr/bin/env bash case "$1" in stable) curl -Lo /tmp/botamusique.tar.gz https://packages.azlux.fr/botamusique/sources-stable.tar.gz tar -xzf /tmp/botamusique.tar.gz -C /tmp/ cp -r /tmp/botamusique/* . rm -r /tmp/botamusique rm -r /tmp/botamusique.tar.gz ;; testing) curl -Lo /tmp/botamusique.tar.gz https://packages.azlux.fr/botamusique/sources-testing.tar.gz tar -xzf /tmp/botamusique.tar.gz -C /tmp/ cp -r /tmp/botamusique/* . rm -r /tmp/botamusique rm -r /tmp/botamusique.tar.gz ;; *) ;; esac exit 0 ================================================ FILE: util.py ================================================ #!/usr/bin/python3 # coding=utf-8 import hashlib import html import magic import os import io import sys import variables as var import zipfile import re import subprocess as sp import logging from importlib import reload from sys import platform import traceback import requests from packaging import version import yt_dlp as youtube_dl YT_PKG_NAME = 'yt-dlp' log = logging.getLogger("bot") def solve_filepath(path): if not path: return '' if path[0] == '/': return path elif os.path.exists(path): return path else: mydir = os.path.dirname(os.path.realpath(__file__)) return mydir + '/' + path def get_recursive_file_list_sorted(path): filelist = [] for root, dirs, files in os.walk(path, topdown=True, onerror=None, followlinks=True): relroot = root.replace(path, '', 1) if relroot != '' and relroot in var.config.get('bot', 'ignored_folders'): continue for file in files: if file in var.config.get('bot', 'ignored_files'): continue fullpath = os.path.join(path, relroot, file) if not os.access(fullpath, os.R_OK): continue try: mime = magic.from_file(fullpath, mime=True) if 'audio' in mime or 'audio' in magic.from_file(fullpath).lower() or 'video' in mime: filelist.append(os.path.join(relroot, file)) except: pass filelist.sort() return filelist # - zips files # - returns the absolute path of the created zip file # - zip file will be in the applications tmp folder (according to configuration) # - format of the filename itself = prefix_hash.zip # - prefix can be controlled by the caller # - hash is a sha1 of the string representation of the directories' contents (which are # zipped) def zipdir(files, zipname_prefix=None): zipname = var.tmp_folder if zipname_prefix and '../' not in zipname_prefix: zipname += zipname_prefix.strip().replace('/', '_') + '_' _hash = hashlib.sha1(str(files).encode()).hexdigest() zipname += _hash + '.zip' if os.path.exists(zipname): return zipname zipf = zipfile.ZipFile(zipname, 'w', zipfile.ZIP_DEFLATED) for file_to_add in files: if not os.access(file_to_add, os.R_OK): continue if file_to_add in var.config.get('bot', 'ignored_files'): continue add_file_as = os.path.basename(file_to_add) zipf.write(file_to_add, add_file_as) zipf.close() return zipname def get_user_ban(): res = "List of ban hash" for i in var.db.items("user_ban"): res += "
" + i[0] return res def new_release_version(target): if target == "testing": r = requests.get("https://packages.azlux.fr/botamusique/testing-version") else: r = requests.get("https://packages.azlux.fr/botamusique/version") v = r.text return v.rstrip() def fetch_changelog(): r = requests.get("https://packages.azlux.fr/botamusique/changelog") c = r.text return c def check_update(current_version): global log log.debug("update: checking for updates...") new_version = new_release_version(var.config.get('bot', 'target_version')) if version.parse(new_version) > version.parse(current_version): changelog = fetch_changelog() log.info(f"update: new version {new_version} found, current installed version {current_version}.") log.info(f"update: changelog: {changelog}") changelog = changelog.replace("\n", "
") return new_version, changelog else: log.debug("update: no new version found.") return None, None def update(current_version): global log target = var.config.get('bot', 'target_version') new_version = new_release_version(target) msg = "" if target == "git": msg = "git install, I do nothing
" elif (target == "stable" and version.parse(new_version) > version.parse(current_version)) or \ (target == "testing" and version.parse(new_version) != version.parse(current_version)): log.info('update: new version, start updating...') tp = sp.check_output(['/usr/bin/env', 'bash', 'update.sh', target]).decode() log.debug(tp) log.info('update: update pip libraries dependencies') sp.check_output([var.config.get('bot', 'pip3_path'), 'install', '--upgrade', '-r', 'requirements.txt']).decode() msg = "New version installed, please restart the bot.
" log.info(f'update: starting update {YT_PKG_NAME} via pip3') tp = sp.check_output([var.config.get('bot', 'pip3_path'), 'install', '--upgrade', YT_PKG_NAME]).decode() if f"Collecting {YT_PKG_NAME}" in tp.splitlines(): msg += "Update done: " + tp.split('Successfully installed')[1] else: msg += YT_PKG_NAME.capitalize() + " is up-to-date" reload(youtube_dl) msg += "
" + YT_PKG_NAME.capitalize() + " reloaded" return msg def pipe_no_wait(): """ Generate a non-block pipe used to fetch the STDERR of ffmpeg. """ if platform == "linux" or platform == "linux2" or platform == "darwin" or platform.startswith("openbsd") or platform.startswith("freebsd"): import fcntl import os pipe_rd = 0 pipe_wd = 0 if hasattr(os, "pipe2"): pipe_rd, pipe_wd = os.pipe2(os.O_NONBLOCK) else: pipe_rd, pipe_wd = os.pipe() try: fl = fcntl.fcntl(pipe_rd, fcntl.F_GETFL) fcntl.fcntl(pipe_rd, fcntl.F_SETFL, fl | os.O_NONBLOCK) except: print(sys.exc_info()[1]) return None, None return pipe_rd, pipe_wd elif platform == "win32": # https://stackoverflow.com/questions/34504970/non-blocking-read-on-os-pipe-on-windows import msvcrt import os from ctypes import windll, byref, wintypes, WinError, POINTER from ctypes.wintypes import HANDLE, DWORD, BOOL pipe_rd, pipe_wd = os.pipe() LPDWORD = POINTER(DWORD) PIPE_NOWAIT = wintypes.DWORD(0x00000001) ERROR_NO_DATA = 232 SetNamedPipeHandleState = windll.kernel32.SetNamedPipeHandleState SetNamedPipeHandleState.argtypes = [HANDLE, LPDWORD, LPDWORD, LPDWORD] SetNamedPipeHandleState.restype = BOOL h = msvcrt.get_osfhandle(pipe_rd) res = windll.kernel32.SetNamedPipeHandleState(h, byref(PIPE_NOWAIT), None, None) if res == 0: print(WinError()) return None, None return pipe_rd, pipe_wd class Dir(object): def __init__(self, path): self.name = os.path.basename(path.strip('/')) self.fullpath = path self.subdirs = {} self.files = [] def add_file(self, file): if file.startswith(self.name + '/'): file = file.replace(self.name + '/', '', 1) if '/' in file: # This file is in a subdir subdir = file.split('/')[0] if subdir in self.subdirs: self.subdirs[subdir].add_file(file) else: self.subdirs[subdir] = Dir(os.path.join(self.fullpath, subdir)) self.subdirs[subdir].add_file(file) else: self.files.append(file) return True def get_subdirs(self, path=None): subdirs = [] if path and path != '' and path != './': subdir = path.split('/')[0] if subdir in self.subdirs: searchpath = '/'.join(path.split('/')[1::]) subdirs = self.subdirs[subdir].get_subdirs(searchpath) subdirs = list(map(lambda subsubdir: os.path.join(subdir, subsubdir), subdirs)) else: subdirs = self.subdirs return subdirs def get_subdirs_recursively(self, path=None): subdirs = [] if path and path != '' and path != './': subdir = path.split('/')[0] if subdir in self.subdirs: searchpath = '/'.join(path.split('/')[1::]) subdirs = self.subdirs[subdir].get_subdirs_recursively(searchpath) else: subdirs = list(self.subdirs.keys()) for key, val in self.subdirs.items(): subdirs.extend(map(lambda subdir: key + '/' + subdir, val.get_subdirs_recursively())) subdirs.sort() return subdirs def get_files(self, path=None): files = [] if path and path != '' and path != './': subdir = path.split('/')[0] if subdir in self.subdirs: searchpath = '/'.join(path.split('/')[1::]) files = self.subdirs[subdir].get_files(searchpath) else: files = self.files return files def get_files_recursively(self, path=None): files = [] if path and path != '' and path != './': subdir = path.split('/')[0] if subdir in self.subdirs: searchpath = '/'.join(path.split('/')[1::]) files = self.subdirs[subdir].get_files_recursively(searchpath) else: files = self.files for key, val in self.subdirs.items(): files.extend(map(lambda file: key + '/' + file, val.get_files_recursively())) return files def render_text(self, ident=0): print('{}{}/'.format(' ' * (ident * 4), self.name)) for key, val in self.subdirs.items(): val.render_text(ident + 1) for file in self.files: print('{}{}'.format(' ' * (ident + 1) * 4, file)) # Parse the html from the message to get the URL def get_url_from_input(string): string = string.strip() if not (string.startswith("http") or string.startswith("HTTP")): res = re.search('href="(.+?)"', string, flags=re.IGNORECASE) if res: string = res.group(1) else: return "" match = re.search("(http|https)://(\S*)?/(\S*)", string, flags=re.IGNORECASE) if match: url = match[1].lower() + "://" + match[2].lower() + "/" + match[3] # https://github.com/mumble-voip/mumble/issues/4999 return html.unescape(url) else: return "" def youtube_search(query): global log import json try: cookie_file = var.config.get('youtube_dl', 'cookie_file') cookie = parse_cookie_file(cookie_file) if cookie_file else {} r = requests.get("https://www.youtube.com/results", cookies=cookie, params={'search_query': query}, timeout=5) result_json_match = re.findall(r">var ytInitialData = (.*?);", r.text) if not len(result_json_match): log.error("util: can not interpret youtube search web page") return False result_big_json = json.loads(result_json_match[0]) results = [] try: for item in result_big_json['contents']['twoColumnSearchResultsRenderer']\ ['primaryContents']['sectionListRenderer']['contents'][0]\ ['itemSectionRenderer']['contents']: if 'videoRenderer' not in item: continue video_info = item['videoRenderer'] title = video_info['title']['runs'][0]['text'] video_id = video_info['videoId'] uploader = video_info['ownerText']['runs'][0]['text'] results.append([video_id, title, uploader]) except (json.JSONDecodeError, KeyError): log.error("util: can not interpret youtube search web page") return False return results except (requests.exceptions.ConnectionError, requests.exceptions.HTTPError, requests.exceptions.Timeout): error_traceback = traceback.format_exc().split("During")[0] log.error("util: youtube query failed with error:\n %s" % error_traceback) return False def get_media_duration(path): command = ("ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path) process = sp.Popen(command, stdout=sp.PIPE, stderr=sp.PIPE) stdout, stderr = process.communicate() try: if not stderr: return float(stdout) else: return 0 except ValueError: return 0 def parse_time(human): match = re.search("(?:(\d\d):)?(?:(\d\d):)?(\d+(?:\.\d*)?)", human, flags=re.IGNORECASE) if match: if match[1] is None and match[2] is None: return float(match[3]) elif match[2] is None: return float(match[3]) + 60 * int(match[1]) else: return float(match[3]) + 60 * int(match[2]) + 3600 * int(match[1]) else: raise ValueError("Invalid time string given.") def format_time(seconds): hours = seconds // 3600 seconds = seconds % 3600 minutes = seconds // 60 seconds = seconds % 60 return f"{hours:d}:{minutes:02d}:{seconds:02d}" def parse_file_size(human): units = {"B": 1, "KB": 1024, "MB": 1024 * 1024, "GB": 1024 * 1024 * 1024, "TB": 1024 * 1024 * 1024 * 1024, "K": 1024, "M": 1024 * 1024, "G": 1024 * 1024 * 1024, "T": 1024 * 1024 * 1024 * 1024} match = re.search("(\d+(?:\.\d*)?)\s*([A-Za-z]+)", human, flags=re.IGNORECASE) if match: num = float(match[1]) unit = match[2].upper() if unit in units: return int(num * units[unit]) raise ValueError("Invalid file size given.") def get_salted_password_hash(password): salt = os.urandom(10) hashed = hashlib.pbkdf2_hmac('sha1', password.encode("utf-8"), salt, 100000) return hashed.hex(), salt.hex() def verify_password(password, salted_hash, salt): hashed = hashlib.pbkdf2_hmac('sha1', password.encode("utf-8"), bytearray.fromhex(salt), 100000) if hashed.hex() == salted_hash: return True return False def get_supported_language(): root_dir = os.path.dirname(__file__) lang_files = os.listdir(os.path.join(root_dir, 'lang')) lang_list = [] for lang_file in lang_files: match = re.search("([a-z]{2}_[A-Z]{2})\.json", lang_file) if match: lang_list.append(match[1]) return lang_list def set_logging_formatter(handler: logging.Handler, logging_level): if logging_level == logging.DEBUG: formatter = logging.Formatter( "[%(asctime)s] > [%(threadName)s] > " "[%(filename)s:%(lineno)d] %(message)s" ) else: formatter = logging.Formatter( '[%(asctime)s %(levelname)s] %(message)s', "%b %d %H:%M:%S") handler.setFormatter(formatter) def get_snapshot_version(): import subprocess wd = os.getcwd() root_dir = os.path.dirname(__file__) os.chdir(root_dir) ver = "unknown" if os.path.exists(os.path.join(root_dir, ".git")): try: ret = subprocess.check_output(["git", "describe", "--tags"]).strip() ver = ret.decode("utf-8") except (FileNotFoundError, subprocess.CalledProcessError): try: with open(os.path.join(root_dir, ".git/refs/heads/master")) as f: ver = "g" + f.read()[:7] except FileNotFoundError: pass os.chdir(wd) return ver class LoggerIOWrapper(io.TextIOWrapper): def __init__(self, logger: logging.Logger, logging_level, fallback_io_buffer): super().__init__(fallback_io_buffer, write_through=True) self.logger = logger self.logging_level = logging_level def write(self, text): if isinstance(text, bytes): msg = text.decode('utf-8').rstrip() self.logger.log(self.logging_level, msg) super().write(msg + "\n") else: self.logger.log(self.logging_level, text.rstrip()) super().write(text + "\n") class VolumeHelper: def __init__(self, plain_volume=0, ducking_plain_volume=0): self.plain_volume_set = 0 self.plain_ducking_volume_set = 0 self.volume_set = 0 self.ducking_volume_set = 0 self.real_volume = 0 self.set_volume(plain_volume) self.set_ducking_volume(ducking_plain_volume) def set_volume(self, plain_volume): self.volume_set = self._convert_volume(plain_volume) self.plain_volume_set = plain_volume def set_ducking_volume(self, plain_volume): self.ducking_volume_set = self._convert_volume(plain_volume) self.plain_ducking_volume_set = plain_volume def _convert_volume(self, volume): if volume == 0: return 0 # convert input of 0~1 into -35~5 dB dB = -35 + volume * 40 # Some dirty trick to stretch the function, to make to be 0 when input is -35 dB return (10 ** (dB / 20) - 10 ** (-35 / 20)) / (1 - 10 ** (-35 / 20)) def get_size_folder(path): global log folder_size = 0 for (path, dirs, files) in os.walk(path): for file in files: filename = os.path.join(path, file) try: folder_size += os.path.getsize(filename) except (FileNotFoundError, OSError): continue return int(folder_size / (1024 * 1024)) def clear_tmp_folder(path, size): global log if size == -1: return elif size == 0: for (path, dirs, files) in os.walk(path): for file in files: filename = os.path.join(path, file) try: os.remove(filename) except (FileNotFoundError, OSError): continue else: if get_size_folder(path=path) > size: all_files = "" for (path, dirs, files) in os.walk(path): all_files = [os.path.join(path, file) for file in files] # exclude invalid symlinks (linux) all_files = [file for file in all_files if os.path.exists(file)] all_files.sort(key=lambda x: os.path.getmtime(x)) size_tp = 0 for idx, file in enumerate(all_files): size_tp += os.path.getsize(file) if int(size_tp / (1024 * 1024)) > size: log.info("Cleaning tmp folder") to_remove = all_files[:idx] print(to_remove) for f in to_remove: log.debug("Removing " + f) try: os.remove(os.path.join(path, f)) except (FileNotFoundError, OSError): continue return def check_extra_config(config, template): extra = [] for key in config.sections(): if key in ['radio']: continue for opt in config.options(key): if not template.has_option(key, opt): extra.append((key, opt)) return extra def parse_cookie_file(cookiefile): # https://stackoverflow.com/a/54659484/1584825 cookies = {} with open (cookiefile, 'r') as fp: for line in fp: if not re.match(r'^#', line): lineFields = line.strip().split('\t') cookies[lineFields[5]] = lineFields[6] return cookies ================================================ FILE: variables.py ================================================ from typing import Type, TYPE_CHECKING if TYPE_CHECKING: import mumbleBot import media.playlist import media.cache import database bot: 'mumbleBot.MumbleBot' = None playlist: 'media.playlist.BasePlaylist' = None cache: 'media.cache.MusicCache' = None user = "" is_proxified = False settings_db_path = None music_db_path = None db = None music_db: 'database.MusicDatabase' = None config: 'database.SettingsDatabase' = None bot_logger = None music_folder = "" tmp_folder = "" language = "" ================================================ FILE: web/.editorconfig ================================================ [*] charset = utf-8 insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true quote_type = single [*.json] quote_type = double ================================================ FILE: web/.eslintrc.json ================================================ { "parser": "@babel/eslint-parser", "env": { "browser": true, "es6": true, "es2017": true, "es2020": true, "es2021": true, "jquery": true }, "plugins": [ "@babel", "import", "jsdoc", "jquery" ], "extends": [ "eslint:recommended", "plugin:import/errors", "plugin:import/warnings", "plugin:jsdoc/recommended", "plugin:jquery/deprecated" ], "rules": { "max-len": ["warn", { "code": 120 }], "linebreak-style": "off", "jsdoc/require-jsdoc": "off", "import/unambiguous": "error", "import/no-commonjs": "error", "import/no-amd": "error", "import/no-nodejs-modules": "error", "import/no-deprecated": "error", "import/extensions": ["error", "always"], "import/no-unresolved": ["error", { "commonjs": true }] } } ================================================ FILE: web/.gitattributes ================================================ package-lock.json text eol=lf ================================================ FILE: web/.gitignore ================================================ !* node_modules/ ================================================ FILE: web/babel.config.json ================================================ { "plugins": [ "@babel/plugin-proposal-class-properties" ] } ================================================ FILE: web/js/app.mjs ================================================ import {library, dom} from '@fortawesome/fontawesome-svg-core/index.es.js'; import { faTimesCircle, faPlus, faCheck, faUpload, faTimes, faTrash, faPlay, faPause, faFastForward, faPlayCircle, faLightbulb, faTrashAlt, faDownload, faSyncAlt, faEdit, faVolumeUp, faVolumeDown, faRobot, faRedo, faRandom, faTasks } from '@fortawesome/free-solid-svg-icons/index.es.js'; import {faFileAlt} from '@fortawesome/free-regular-svg-icons/index.es.js'; library.add( // Solid faTimesCircle, faPlus, faCheck, faUpload, faTimes, faTrash, faPlay, faPause, faFastForward, faPlayCircle, faLightbulb, faTrashAlt, faDownload, faSyncAlt, faEdit, faVolumeUp, faVolumeDown, faRobot, faRedo, faRandom, faTasks, // Regular faFileAlt ); // Old application code import './main.mjs'; // New application code import Theme from './lib/theme.mjs'; document.addEventListener('DOMContentLoaded', () => { Theme.init(); // Replace any existing tags with and set up a MutationObserver to // continue doing this as the DOM changes. dom.watch(); document.getElementById('theme-switch-btn').addEventListener('click', () => { Theme.swap(); }); }); ================================================ FILE: web/js/lib/text.mjs ================================================ import {validateString, validateNumber} from './type.mjs'; /** * Truncate string length by characters. * * @param {string} text String to format. * @param {number} limit Maximum number of characters in resulting string. * @param {string} ending Ending to use if string is trucated. * * @returns {string} Formatted string. */ export function limitChars(text, limit = 50, ending = '...') { validateString(text); validateNumber(limit); validateString(ending); // Check if string is already below limit if (text.length <= limit) { return text; } // Limit string length by characters return text.substring(0, limit - ending.length) + ending; } /** * Truncate string length by words. * * @param {string} text String to format. * @param {number} limit Maximum number of words in resulting string. * @param {string} ending Ending to use if string is trucated. * * @returns {string} Formatted string. */ export function limitWords(text, limit = 10, ending = '...') { validateString(text); validateNumber(limit); validateString(ending); // Limit string length by words return text.split(' ').splice(0, limit).join(' ') + ending; } ================================================ FILE: web/js/lib/theme.mjs ================================================ export default class { /** * @property {boolean} dark Interal state for dark theme activation. * @private */ static #dark = false; /** * Inialize the theme class. */ static init() { // Check LocalStorage for dark theme selection if (localStorage.getItem('darkTheme') === 'true') { // Update page theme this.set(true); } } /** * Set page theme and update local storage variable. * * @param {boolean} dark Whether to activate dark theme. */ static set(dark = false) { // Swap CSS to selected theme document.getElementById('pagestyle') .setAttribute('href', 'static/css/' + (dark ? 'dark' : 'main') + '.css'); // Update local storage localStorage.setItem('darkTheme', dark); // Update internal state this.#dark = dark; } /** * Swap page theme. */ static swap() { this.set(!this.#dark); } } ================================================ FILE: web/js/lib/type.mjs ================================================ /** * Checks if `value` is the type `Object` excluding `Function` and `null` * * @param {*} value The value to check. * @returns {boolean} Returns `true` if `value` is an object, otherwise `false`. */ export function isObject(value) { return (Object.prototype.toString.call(value) === '[object Object]'); } /** * Checks if `value` is the type `string` * * @param {*} value The value to check. * @returns {boolean} Returns `true` if `value` is a string, otherwise `false`. */ export function isString(value) { return (typeof value === 'string'); } /** * Checks if `value` is the type `number` * * @param {*} value The value to check. * @returns {boolean} Returns `true` if `value` is a number, otherwise `false`. */ export function isNumber(value) { return (typeof value === 'number'); } /** * Validate parameter is of type object. * * @param {string} value Variable to validate. * @throws Error if not an object. */ export function validateObject(value) { if (!isObject(value)) { throw new TypeError('Parameter "value" must be of type object.'); } } /** * Validate parameter is of type string. * * @param {string} value Variable to validate. * @throws Error if not an string. */ export function validateString(value) { if (!isString(value)) { throw new TypeError('Parameter "value" must be of type string.'); } } /** * Validate parameter is of type number. * * @param {number} value Variable to validate. * @throws Error if not an number. */ export function validateNumber(value) { if (!isNumber(value)) { throw new TypeError('Parameter "value" must be of type number.'); } } ================================================ FILE: web/js/lib/util.mjs ================================================ export function isOverflown(element) { return element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth; } export function hash(string) { if (typeof string != 'string') return 0; let hash = 0; if (string.length === 0) { return hash; } for (let i = 0; i < string.length; i++) { const char = string.charCodeAt(i); hash = ((hash<<5)-hash)+char; hash = hash & hash; // Convert to 32bit integer } return hash; } export function getColor(string) { const num = hash(string) % 8; switch (num) { case 0: return 'primary'; case 1: return 'secondary'; case 2: return 'success'; case 3: return 'danger'; case 4: return 'warning'; case 5: return 'info'; case 6: return 'light'; case 7: return 'dark'; } } export function setProgressBar(bar, progress, text = '') { const progPos = (-1 * (1 - progress) * bar.scrollWidth).toString(); const progStr = (progress * 100).toString(); bar.setAttribute('aria-valuenow', progStr); bar.style.transform = 'translateX(' + progPos + 'px)'; bar.textContent = text; } export function secondsToStr(seconds) { seconds = Math.floor(seconds); const mins = Math.floor(seconds / 60); const secs = seconds % 60; return ('00' + mins).slice(-2) + ':' + ('00' + secs).slice(-2); } ================================================ FILE: web/js/main.mjs ================================================ import 'jquery/src/jquery.js'; import 'jquery-migrate/src/migrate.js'; import Popper from 'popper.js/dist/esm/popper.js'; import { Modal, Toast, Tooltip, } from 'bootstrap/js/src/index.js'; import { getColor, isOverflown, setProgressBar, secondsToStr, } from './lib/util.mjs'; import {limitChars} from './lib/text.mjs'; $('#uploadSelectFile').on('change', function() { // get the file name const fileName = $(this).val().replace('C:\\fakepath\\', ' '); // replace the "Choose a file" label $(this).next('.custom-file-label').html(fileName); }); // ---------------------- // ------ Playlist ------ // ---------------------- const pl_item_template = $('.playlist-item-template'); const pl_id_element = $('.playlist-item-id'); const pl_index_element = $('.playlist-item-index'); const pl_title_element = $('.playlist-item-title'); const pl_artist_element = $('.playlist-item-artist'); const pl_thumb_element = $('.playlist-item-thumbnail'); const pl_type_element = $('.playlist-item-type'); const pl_path_element = $('.playlist-item-path'); const pl_tag_edit_element = $('.playlist-item-edit'); const notag_element = $('.library-item-notag'); // these elements are shared with library const tag_element = $('.library-item-tag'); const addTagModal = new Modal(document.getElementById('addTagModal')); const playlist_loading = $('#playlist-loading'); const playlist_table = $('#playlist-table'); const playlist_empty = $('#playlist-empty'); const playlist_expand = $('.playlist-expand'); let playlist_items = null; let playlist_ver = 0; let playlist_current_index = 0; let playlist_range_from = 0; let playlist_range_to = 0; let last_volume = 0; let playing = false; const playPauseBtn = $('#play-pause-btn'); const fastForwardBtn = $('#fast-forward-btn'); const volumeSlider = document.getElementById('volume-slider'); const playModeBtns = { 'one-shot': $('#one-shot-mode-btn'), 'random': $('#random-mode-btn'), 'repeat': $('#repeat-mode-btn'), 'autoplay': $('#autoplay-mode-btn'), }; const playModeIcon = { 'one-shot': 'fa-tasks', 'random': 'fa-random', 'repeat': 'fa-redo', 'autoplay': 'fa-robot', }; playPauseBtn.on('click', togglePlayPause); fastForwardBtn.on('click', () => { request('post', { action: 'next', }); }); document.getElementById('clear-playlist-btn').addEventListener('click', () => { request('post', {action: 'clear'}); }); // eslint-disable-next-line guard-for-in for (const playMode in playModeBtns) { playModeBtns[playMode].on('click', () => { changePlayMode(playMode); }); } function request(_url, _data, refresh = false) { console.log(_data); $.ajax({ type: 'POST', url: _url, data: _data, statusCode: { 200: function(data) { if (data.ver !== playlist_ver) { checkForPlaylistUpdate(); } updateControls(data.empty, data.play, data.mode, data.volume); updatePlayerPlayhead(data.playhead); }, 403: function() { location.reload(true); }, }, }); if (refresh) { location.reload(true); } } function addPlaylistItem(item) { pl_id_element.val(item.id); pl_index_element.html(item.index + 1); pl_title_element.html(item.title); pl_artist_element.html(item.artist); pl_thumb_element.attr('src', item.thumbnail); pl_thumb_element.attr('alt', limitChars(item.title)); pl_type_element.html(item.type); pl_path_element.html(item.path); const item_copy = pl_item_template.clone(); item_copy.attr('id', 'playlist-item-' + item.index); item_copy.addClass('playlist-item').removeClass('d-none'); const tags = item_copy.find('.playlist-item-tags'); tags.empty(); const tag_edit_copy = pl_tag_edit_element.clone(); tag_edit_copy.click(function() { addTagModalShow(item.id, item.title, item.tags); }); tag_edit_copy.appendTo(tags); if (item.tags.length > 0) { item.tags.forEach(function(tag_tuple) { const tag_copy = tag_element.clone(); tag_copy.html(tag_tuple[0]); tag_copy.addClass('badge-' + tag_tuple[1]); tag_copy.appendTo(tags); }); } else { const tag_copy = notag_element.clone(); tag_copy.appendTo(tags); } item_copy.appendTo(playlist_table); } function displayPlaylist(data) { playlist_table.animate({ opacity: 0, }, 200, function() { playlist_loading.hide(); $('.playlist-item').remove(); const items = data.items; const length = data.length; if (items.length === 0) { playlist_empty.removeClass('d-none'); playlist_table.animate({opacity: 1}, 200); return; } playlist_items = {}; for (const i in items) { playlist_items[items[i].index] = items[i]; } const start_from = data.start_from; playlist_range_from = start_from; playlist_range_to = start_from + items.length - 1; if (items.length < length && start_from > 0) { let _from = start_from - 5; _from = _from > 0 ? _from : 0; const _to = start_from - 1; if (_to > 0) { insertExpandPrompt(_from, start_from + length - 1, _from, _to, length); } } items.forEach( function(item) { addPlaylistItem(item); }, ); if (items.length < length && start_from + items.length < length) { const _from = start_from + items.length; let _to = start_from + items.length - 1 + 10; _to = _to < length - 1 ? _to : length - 1; if (start_from + items.length < _to) { insertExpandPrompt(start_from, _to, _from, _to, length); } } displayActiveItem(data.current_index); updatePlayerInfo(playlist_items[data.current_index]); bindPlaylistEvent(); playlist_table.animate({opacity: 1}, 200); }); } function displayActiveItem(current_index) { $('.playlist-item').removeClass('table-active'); $('#playlist-item-' + current_index).addClass('table-active'); } function insertExpandPrompt(real_from, real_to, display_from, display_to, total_length) { const expand_copy = playlist_expand.clone(); expand_copy.addClass('playlist-item'); expand_copy.removeClass('d-none'); if (display_from !== display_to) { expand_copy.find('.playlist-expand-item-range').html((display_from + 1) + '~' + (display_to + 1) + ' of ' + (total_length) + ' items'); } else { expand_copy.find('.playlist-expand-item-range').html(display_from + ' of ' + (total_length) + ' items'); } expand_copy.addClass('playlist-item'); expand_copy.appendTo(playlist_table); expand_copy.click(function() { playlist_range_from = real_from; playlist_range_to = real_to; updatePlaylist(); }); } function updatePlaylist() { playlist_table.animate({ opacity: 0, }, 200, function() { playlist_empty.addClass('d-none'); playlist_loading.show(); playlist_table.find('.playlist-item').css('opacity', 0); let data = {}; if (!(playlist_range_from === 0 && playlist_range_to === 0)) { data = { range_from: playlist_range_from, range_to: playlist_range_to, }; } $.ajax({ type: 'GET', url: 'playlist', data: data, statusCode: { 200: displayPlaylist, }, }); playlist_table.animate({ opacity: 1, }, 200); }); } function checkForPlaylistUpdate() { $.ajax({ type: 'POST', url: 'post', statusCode: { 200: function(data) { if (data.ver !== playlist_ver) { playlist_ver = data.ver; playlist_range_from = 0; playlist_range_to = 0; updatePlaylist(); } if (data.current_index !== playlist_current_index) { if (data.current_index !== -1) { if ((data.current_index > playlist_range_to || data.current_index < playlist_range_from)) { playlist_range_from = 0; playlist_range_to = 0; updatePlaylist(); } else { playlist_current_index = data.current_index; updatePlayerInfo(playlist_items[data.current_index]); displayActiveItem(data.current_index); } } } updateControls(data.empty, data.play, data.mode, data.volume); if (!data.empty) { updatePlayerPlayhead(data.playhead); } }, }, }); } function bindPlaylistEvent() { $('.playlist-item-play').unbind().click( function(e) { request('post', { 'play_music': ($(e.currentTarget).parent().parent().parent().find('.playlist-item-index').html() - 1), }); }, ); $('.playlist-item-trash').unbind().click( function(e) { request('post', { 'delete_music': ($(e.currentTarget).parent().parent().parent().find('.playlist-item-index').html() - 1), }); }, ); } function updateControls(empty, play, mode, volume) { updatePlayerControls(play, empty); if (empty) { playPauseBtn.prop('disabled', true); fastForwardBtn.prop('disabled', true); } else { playPauseBtn.prop('disabled', false); fastForwardBtn.prop('disabled', false); if (play) { playing = true; playPauseBtn.find('[data-fa-i2svg]').removeClass('fa-play').addClass('fa-pause'); // PR #180: Since this button changes behavior dynamically, we change its // ARIA labels in JS instead of only adding them statically in the HTML playPauseBtn.attr('aria-label', 'Pause'); } else { playing = false; playPauseBtn.find('[data-fa-i2svg]').removeClass('fa-pause').addClass('fa-play'); // PR #180: Since this button changes behavior dynamically, we change its // ARIA labels in JS instead of only adding them statically in the HTML playPauseBtn.attr('aria-label', 'Play'); } } for (const otherMode of Object.values(playModeBtns)) { otherMode.removeClass('active'); } playModeBtns[mode].addClass('active'); const playModeIndicator = $('#modeIndicator'); for (const icon_class of Object.values(playModeIcon)) { playModeIndicator.removeClass(icon_class); } playModeIndicator.addClass(playModeIcon[mode]); if (volume !== last_volume) { last_volume = volume; if (volume > 1) { volumeSlider.value = 1; } else if (volume < 0) { volumeSlider.value = 0; } else { volumeSlider.value = volume; } } } function togglePlayPause() { if (playing) { request('post', { action: 'pause', }); } else { request('post', { action: 'resume', }); } } function changePlayMode(mode) { request('post', { action: mode, }); } // --------------------- // ------ Browser ------ // --------------------- const filters = { file: $('#filter-type-file'), url: $('#filter-type-url'), radio: $('#filter-type-radio'), }; const filter_dir = $('#filter-dir'); const filter_keywords = $('#filter-keywords'); // eslint-disable-next-line guard-for-in for (const filter in filters) { filters[filter].on('click', (e) => { setFilterType(e, filter); }); } function setFilterType(event, type) { event.preventDefault(); if (filters[type].hasClass('active')) { filters[type].removeClass('active btn-primary').addClass('btn-secondary'); filters[type].find('input[type=radio]').removeAttr('checked'); } else { filters[type].removeClass('btn-secondary').addClass('active btn-primary'); filters[type].find('input[type=radio]').attr('checked', 'checked'); } if (type === 'file') { filter_dir.prop('disabled', !filters['file'].hasClass('active')); } updateResults(); } filter_dir.change(function() { updateResults(); }); filter_keywords.change(function() { updateResults(); }); const item_template = $('#library-item'); function bindLibraryResultEvent() { $('.library-thumb-col').unbind().hover( function(e) { $(e.currentTarget).find('.library-thumb-grp').addClass('library-thumb-grp-hover'); }, function(e) { $(e.currentTarget).find('.library-thumb-grp').removeClass('library-thumb-grp-hover'); }, ); $('.library-info-title').unbind().hover( function(e) { $(e.currentTarget).parent().find('.library-thumb-grp').addClass('library-thumb-grp-hover'); }, function(e) { $(e.currentTarget).parent().find('.library-thumb-grp').removeClass('library-thumb-grp-hover'); }, ); $('.library-item-play').unbind().click( function(e) { request('post', { 'add_item_at_once': $(e.currentTarget).parent().parent().parent().find('.library-item-id').val(), }); }, ); $('.library-item-trash').unbind().click( function(e) { request('post', { 'delete_item_from_library': $(e.currentTarget).parent().parent().find('.library-item-id').val(), }); updateResults(active_page); }, ); $('.library-item-download').unbind().click( function(e) { const id = $(e.currentTarget).parent().parent().find('.library-item-id').val(); // window.open('/download?id=' + id); downloadId(id); }, ); $('.library-item-add-next').unbind().click( function(e) { const id = $(e.currentTarget).parent().parent().find('.library-item-id').val(); request('post', { 'add_item_next': id, }); }, ); $('.library-item-add-bottom').unbind().click( function(e) { const id = $(e.currentTarget).parent().parent().find('.library-item-id').val(); request('post', { 'add_item_bottom': id, }); }, ); } const lib_filter_tag_group = $('#filter-tags'); const lib_filter_tag_element = $('.filter-tag'); const lib_group = $('#library-group'); const id_element = $('.library-item-id'); const title_element = $('.library-item-title'); const artist_element = $('.library-item-artist'); const thumb_element = $('.library-item-thumb'); const type_element = $('.library-item-type'); const path_element = $('.library-item-path'); const tag_edit_element = $('.library-item-edit'); // var notag_element = $(".library-item-notag"); // var tag_element = $(".library-item-tag"); const library_tags = []; function updateLibraryControls() { $.ajax({ type: 'GET', url: 'library/info', statusCode: { 200: displayLibraryControls, 403: function() { location.reload(true); }, }, }); } function displayLibraryControls(data) { $('#maxUploadFileSize').val(data.max_upload_file_size); if (data.upload_enabled) { $('#uploadDisabled').val('false'); $('#upload').show(); } else { $('#uploadDisabled').val('true'); $('#upload').hide(); } if (data.delete_allowed) { $('#deleteAllowed').val('true'); } else { $('#deleteAllowed').val('false'); $('.library-delete').remove(); } const dataList = $('#upload-target-dirs'); const dirs = []; filter_dir.find('option').each(function(i, dir_element){ dirs.push(dir_element.value); }); if (data.dirs.length > 0) { console.log(data.dirs); data.dirs.forEach(function(dir) { if(!dirs.includes(dir)) { $('').appendTo(filter_dir); $(' {{ tr('page_title') }}
{{ tr('index') }} {{ tr('title') }} {{ tr('url_path') }} {{ tr('action') }}
{{ tr('aria_spinner') }}
{{ tr('aria_empty_box') }}
{{ tr('expand_playlist') }}

{{ tr('music_library') }}

{{ tr('filters') }}


{{ tr('type') }}
{{ tr('tags') }}
{{ tr('aria_spinner') }}

{{ tr('upload_file') }}

{{ tr('add_url') }}

{{ tr('add_radio') }}

================================================ FILE: web/templates/need_token.template.html ================================================ {{ tr('page_title') }}
{{ tr('token_required') }}

{{ tr('token_required') }}

{{ tr('token_required_message') }}
================================================ FILE: web/vscode.eslintrc.json ================================================ { "parserOptions": { "babelOptions": { "configFile": "./web/babel.config.json" } } } ================================================ FILE: web/webpack.config.cjs ================================================ const path = require('path'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = { mode: 'production', devtool: 'source-map', entry: { main: [ './js/app.mjs', './sass/app.scss', ], dark: [ './sass/app-dark.scss', ], }, output: { filename: 'static/js/[name].js', path: path.resolve(__dirname, '../'), }, plugins: [ new MiniCssExtractPlugin({ filename: 'static/css/[name].css', }), new HtmlWebpackPlugin({ filename: 'templates/index.template.html', template: './templates/index.template.html', inject: false, }), new HtmlWebpackPlugin({ filename: 'templates/need_token.template.html', template: './templates/need_token.template.html', inject: false, }), ], module: { rules: [{ test: /\.s[ac]ss$/i, use: [ MiniCssExtractPlugin.loader, 'css-loader', // translates CSS into CommonJS modules { loader: 'postcss-loader', options: { postcssOptions: { plugins: [ [ 'autoprefixer', { // Options }, ], ], }, }, }, 'sass-loader', // compiles Sass to CSS ], }, { test: /\.m?js$/, exclude: /(node_modules|bower_components)/, resolve: { fullySpecified: false, }, use: { loader: 'babel-loader', options: { presets: [ [ '@babel/preset-env', { 'corejs': '3.6', 'useBuiltIns': 'usage', }, ], ], }, }, }, ], }, };