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 is a [Mumble](https://www.mumble.info/) music bot.
Predicted functionalities will be those people would expect from any classic music player.
[](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


-----
## 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 = "
"
for i in var.db.items("url_ban"):
ban_list += "
" + i[0] + "
"
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 = "
"
for i in var.db.items("url_ban"):
ban_list += "
" + i[0] + "
"
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 = "
"
for i in var.db.items("url_whitelist"):
ban_list += "
" + i[0] + "
"
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') + "
"]
try:
count = 0
music_wrappers = []
for file_dict in file_dicts:
file = file_dict['title']
match = re.search(parameter, file)
if match and match[0]:
count += 1
music_wrapper = get_cached_wrapper(dict_to_item(file_dict), user)
music_wrappers.append(music_wrapper)
log.info("cmd: add to playlist: " + music_wrapper.format_debug_string())
msgs.append("
")
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
!rbplay ID
Station Name
Genre
Codec/Bitrate
Country
'
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"
{station_id}
{station_name}
{genre}
{codec}/{bitrate}
{country}
"
msg += '
'
# 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
!rbplay ID
Station Name
'
for s in rb_stations:
station_id = s['stationuuid']
station_name = s['name']
msg += f'
{station_id}
{station_name}
'
msg += '
'
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 += '
ID
Station Name
Genre
Codec/Bitrate
Country
Homepage
' + \
f"
{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 = '
Index
Title
Uploader
'
for index, item in enumerate(results[start:start + count]):
msg += '
'
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") + "
"]
try:
count = 0
for index, file in enumerate(files):
if parameter:
match = re.search(parameter, file['path'])
if not match:
continue
count += 1
if count > ITEMS_PER_PAGE:
break
msgs.append("
{:d} - {:s} ({:s})
".format(index + 1, file['title'], file['path']))
if count != 0:
msgs.append("
")
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') + "
")
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') + "
"]
music_wrappers = []
for index in indexes:
if 1 <= index <= len(song_shortlist):
kwargs = song_shortlist[index - 1]
kwargs['user'] = user
music_wrapper = get_cached_wrapper_from_scrap(**kwargs)
music_wrappers.append(music_wrapper)
log.info("cmd: add to playlist: " + music_wrapper.format_debug_string())
msgs.append("
")
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') + "
"]
count = 0
for index in indexes:
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)
log.info("cmd: remove from library: " + music_wrapper.format_debug_string())
msgs.append("
")
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
\n
!kill - Bot stoppen
\n
!update - Bot update
\n
!userban {user} - Nutzer bannen
\n
!userunban {user} - Nutzer entbannen
\n
!urlbanlist - Zeige alle gebannten URLs an
\n
!urlban [{url}] - Banne {url} (oder das aktuelle Lied, wenn leer) and lösche diese URL aus der Bibliothek.
\n
!urlunban {url} - Entbanne {url}
\n
!rescan {url} - Erneuere den lokalen Cache der Musikdateien
\n
!dropdatabase - Lösche die aktuelle Datenbank. Dadurch gehen alle Einstellungen und die Bibliothek verloren.
\n
\nWebinterface\n
\n
!webuserlist - Zeige alle Nutzer, die auf das Webinterface zugreifen dürfen (wenn die Authentifizierung auf 'password' gestellt ist).
\n
!webuseradd {nick name} - Erlaube {nick name} den Zugriff auf das Webinterface (wenn die Authentifizierung auf 'password' gestellt ist).
\n
!webuserdel {nick name} - Lösche den Zugriff von {nick name} auf das Webinterface (wenn die Authentifizierung auf 'password' gestellt ist).
\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
\n
!kill - kill the bot
\n
!update - update the bot
\n
!userban {user} - ban a user
\n
!userunban {user} - unban a user
\n
!urlbanlist - list banned url
\n
!urlban [{url}] - ban {url} (or current item's url by default) and remove this url from the library.
\n
!urlunban {url} - unban {url}
\n
!rescan {url} - rebuild local music file cache
\n
!dropdatabase - clear the entire database, you will lose all settings and music library.
\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": "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
\n
!web - get the URL of the web interface, if enabled.
\n
!play (or !p) [{num}] [{start_from}] - resume from pausing / start to play (the num-th song is num if given)
\n
!pause - pause
\n
!stop - stop playing
\n
!skip - jump to the next song
\n
!last - jump to the last song
\n
!volume {volume} - get or change the volume (from 0 to 100)
\n
!mode [{mode}] - get or set the playback mode, {mode} should be one of one-shot (remove\nitem once played), repeat (looping through the playlist), random (randomize the playlist),\nautoplay (randomly grab something from the music library).
\n
!duck on/off - enable or disable ducking function
\n
!duckv {volume} - set the volume of the bot when ducking is activated
\n
!duckthres - set the threshold of volume to activate ducking (3000 by default)
\n
!oust - stop playing and go to default channel
\n
\nPlaylist\n
\n
!now (or !np) - display the current song
\n
!queue - display items in the playlist
\n
!tag {tags} - add all items with tags {tags}, tags separated by \",\".
\n
!file (or !f) {path/folder/keyword} - add a single file to the playlist by its path or keyword in its path.
\n
!filematch (or !fm) {pattern} - add all files that match regex {pattern}
\n
!url {url} - add Youtube or SoundCloud music
\n
!playlist {url} [{offset}] - add all items in a Youtube or SoundCloud playlist, and start with the {offset}-th item
\n
!radio {url} - append a radio {url} to the playlist
\n
!rbquery {keyword} - query http://www.radio-browser.info for a radio station
\n
!rbplay {id} - play a radio station with {id} (eg. !rbplay 96746)
\n
!ysearch {keywords} - query youtube. Use !ysearch -n to turn the page.
\n
!yplay {keywords} - add the first search result of {keywords} into the playlist.
\n
!shortlist (or !sl) {indexes/*} - add {indexes}-th item (or all items if * is given) on the shortlist.
\n
!rm {num} - remove the num-th song on the playlist
\n
!repeat [{num}] - repeat current song {num} (1 by default) times.
\n
!random - randomize the playlist.
\n
\nMusic Library\n
\n
!search {keywords} - find item with {keywords} in the music library, keywords separated by space.
\n
!listfile [{pattern}] - display list of available files (whose paths match the regex pattern if {pattern} is given)
\n
!addtag [{index}] {tags} - add {tags} to {index}-th(current song if {index} is omitted) item on the playlist, tags separated by \",\".
\n
!addtag * {tags} - add {tags} to all items on the playlist.
\n
!untag [{index/*}] {tags}/* - remove {tags}/all tags from {index}-th(current song if {index} is omitted) item on the playlist.
\n
!findtagged (or !ft) {tags} - find item with {tags} in the music library.
\n
!delete {index} - delete {index}-th item on the shortlist from the music library.
\n
\nOther\n
\n
!joinme {token} - join your own channel with {token}.
\n
!password {password} - change your password, used to access the web interface.
\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": "
!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": "