Showing preview only (948K chars total). Download the full file or copy to clipboard to get everything.
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
-----
<div align="center">
<img src="static/image/logo.png" alt="botamusique" width="200px" />
<h1>botamusique</h1>
</div>
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)**
<details>
<summary>Click to expand!</summary>
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
```
</details>
**Build from source code**
<details>
<summary>Click to expand!</summary>
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/
```
</details>
## 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="<br />"):
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("<br>")
bot.send_msg(msg, text)
msg = ""
msg += newline
bot.send_msg(msg, text)
def send_multi_lines_in_channel(bot, lines, linebreak="<br />"):
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("<br>")
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 = "<ul>"
for i in var.db.items("url_ban"):
ban_list += "<li>" + i[0] + "</li>"
ban_list += "</ul>"
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 = "<ul>"
for i in var.db.items("url_ban"):
ban_list += "<li>" + i[0] + "</li>"
ban_list += "</ul>"
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 = "<ul>"
for i in var.db.items("url_whitelist"):
ban_list += "<li>" + i[0] + "</li>"
ban_list += "</ul>"
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("<b>{:s}</b> ({: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("<b>{:d}</b> - <b>{:s}</b> ({: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') + "<ul>"]
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("<li><b>{}</b> ({})</li>".format(music_wrapper.item().title,
file[:match.span()[0]]
+ "<b style='color:pink'>"
+ file[match.span()[0]: match.span()[1]]
+ "</b>"
+ file[match.span()[1]:]
))
if count != 0:
msgs.append("</ul>")
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 += "<br />" + 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<table><tr><th>!rbplay ID</th><th>Station Name</th><th>Genre</th><th>Codec/Bitrate</th><th>Country</th></tr>'
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"<tr><td>{station_id}</td><td>{station_name}</td><td>{genre}</td><td>{codec}/{bitrate}</td><td>{country}</td></tr>"
msg += '</table>'
# 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<table><tr><th>!rbplay ID</th><th>Station Name</th></tr>'
for s in rb_stations:
station_id = s['stationuuid']
station_name = s['name']
msg += f'<tr><td>{station_id}</td><td>{station_name}</td>'
msg += '</table>'
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 += '<table><tr><th>ID</th><th>Station Name</th><th>Genre</th><th>Codec/Bitrate</th><th>Country</th><th>Homepage</th></tr>' + \
f"<tr><td>{parameter}</td><td>{stationname}</td><td>{genre}</td><td>{codec}/{bitrate}</td><td>{country}</td><td>{homepage}</td></tr></table>"
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 = '<table><tr><th width="10%">Index</th><th>Title</th><th width="20%">Uploader</th></tr>'
for index, item in enumerate(results[start:start + count]):
msg += '<tr><td>{index:d}</td><td>{title}</td><td>{uploader}</td></tr>'.format(
index=index + 1, title=item[1], uploader=item[2])
msg += '</table>'
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") + "<ul>"]
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("<li><b>{:d}</b> - <b>{:s}</b> ({:s})</li>".format(index + 1, file['title'], file['path']))
if count != 0:
msgs.append("</ul>")
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 = "<sup>{}</sup>".format(", ".join(music.item().tags))
if i == var.playlist.current_index:
newline = "<b style='color:orange'>{} ({}) {} </b> {}".format(i + 1, music.display_type(),
music.format_title(), tags)
else:
newline = '<b>{}</b> ({}) {} {}'.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') + "<ul>"]
count = 0
tags = parameter.split(",")
tags = list(map(lambda t: t.strip(), tags))
music_wrappers = get_cached_wrappers_by_tags(tags, user)
for music_wrapper in music_wrappers:
count += 1
log.info("cmd: add to playlist: " + music_wrapper.format_debug_string())
msgs.append("<li><b>{}</b> (<i>{}</i>)</li>".format(music_wrapper.item().title, ", ".join(music_wrapper.item().tags)))
if count != 0:
msgs.append("</ul>")
var.playlist.extend(music_wrappers)
send_multi_lines_in_channel(bot, msgs, "")
else:
bot.send_msg(tr("no_file"), text)
def cmd_add_tag(bot, user, text, command, parameter):
global log
params = parameter.split(" ", 1)
index = 0
tags = []
if len(params) == 2 and params[0].isdigit():
index = params[0]
tags = list(map(lambda t: t.strip(), params[1].split(",")))
elif len(params) == 2 and params[0] == "*":
index = "*"
tags = list(map(lambda t: t.strip(), params[1].split(",")))
else:
index = str(var.playlist.current_index + 1)
tags = list(map(lambda t: t.strip(), parameter.split(",")))
if tags[0]:
if index.isdigit() and 1 <= int(index) <= len(var.playlist):
var.playlist[int(index) - 1].add_tags(tags)
log.info(f"cmd: add tags {', '.join(tags)} to song {var.playlist[int(index) - 1].format_debug_string()}")
bot.send_msg(tr("added_tags",
tags=", ".join(tags),
song=var.playlist[int(index) - 1].format_title()), text)
return
elif index == "*":
for item in var.playlist:
item.add_tags(tags)
log.info(f"cmd: add tags {', '.join(tags)} to song {item.format_debug_string()}")
bot.send_msg(tr("added_tags_to_all", tags=", ".join(tags)), text)
return
bot.send_msg(tr('bad_parameter', command=command), text)
def cmd_remove_tag(bot, user, text, command, parameter):
global log
params = parameter.split(" ", 1)
index = 0
tags = []
if len(params) == 2 and params[0].isdigit():
index = params[0]
tags = list(map(lambda t: t.strip(), params[1].split(",")))
elif len(params) == 2 and params[0] == "*":
index = "*"
tags = list(map(lambda t: t.strip(), params[1].split(",")))
else:
index = str(var.playlist.current_index + 1)
tags = list(map(lambda t: t.strip(), parameter.split(",")))
if tags[0]:
if index.isdigit() and 1 <= int(index) <= len(var.playlist):
if tags[0] != "*":
var.playlist[int(index) - 1].remove_tags(tags)
log.info(f"cmd: remove tags {', '.join(tags)} from song {var.playlist[int(index) - 1].format_debug_string()}")
bot.send_msg(tr("removed_tags",
tags=", ".join(tags),
song=var.playlist[int(index) - 1].format_title()), text)
return
else:
var.playlist[int(index) - 1].clear_tags()
log.info(f"cmd: clear tags from song {var.playlist[int(index) - 1].format_debug_string()}")
bot.send_msg(tr("cleared_tags",
song=var.playlist[int(index) - 1].format_title()), text)
return
elif index == "*":
if tags[0] != "*":
for item in var.playlist:
item.remove_tags(tags)
log.info(f"cmd: remove tags {', '.join(tags)} from song {item.format_debug_string()}")
bot.send_msg(tr("removed_tags_from_all", tags=", ".join(tags)), text)
return
else:
for item in var.playlist:
item.clear_tags()
log.info(f"cmd: clear tags from song {item.format_debug_string()}")
bot.send_msg(tr("cleared_tags_from_all"), text)
return
bot.send_msg(tr('bad_parameter', command=command), text)
def cmd_find_tagged(bot, user, text, command, parameter):
global song_shortlist
if not parameter:
bot.send_msg(tr('bad_parameter', command=command), text)
return
msgs = [tr('multiple_file_found') + "<ul>"]
count = 0
tags = parameter.split(",")
tags = list(map(lambda t: t.strip(), tags))
music_dicts = var.music_db.query_music_by_tags(tags)
song_shortlist = music_dicts
for i, music_dict in enumerate(music_dicts):
item = dict_to_item(music_dict)
count += 1
if count > ITEMS_PER_PAGE:
break
msgs.append("<li><b>{:d}</b> - <b>{}</b> (<i>{}</i>)</li>".format(i + 1, item.title, ", ".join(item.tags)))
if count != 0:
msgs.append("</ul>")
if count > ITEMS_PER_PAGE:
msgs.append(tr("records_omitted"))
msgs.append(tr("shortlist_instruction"))
send_multi_lines(bot, msgs, text, "")
else:
bot.send_msg(tr("no_file"), text)
def cmd_search_library(bot, user, text, command, parameter):
global song_shortlist
if not parameter:
bot.send_msg(tr('bad_parameter', command=command), text)
return
msgs = [tr('multiple_file_found') + "<ul>"]
count = 0
_keywords = parameter.split(" ")
keywords = []
for kw in _keywords:
if kw:
keywords.append(kw)
music_dicts = var.music_db.query_music_by_keywords(keywords)
if music_dicts:
items = dicts_to_items(music_dicts)
song_shortlist = music_dicts
if len(items) == 1:
music_wrapper = get_cached_wrapper(items[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)
else:
for item in items:
count += 1
if count > ITEMS_PER_PAGE:
break
if len(item.tags) > 0:
msgs.append("<li><b>{:d}</b> - [{}] <b>{}</b> (<i>{}</i>)</li>".format(count, item.display_type(), item.title, ", ".join(item.tags)))
else:
msgs.append("<li><b>{:d}</b> - [{}] <b>{}</b> </li>".format(count, item.display_type(), item.title, ", ".join(item.tags)))
if count != 0:
msgs.append("</ul>")
if count > ITEMS_PER_PAGE:
msgs.append(tr("records_omitted"))
msgs.append(tr("shortlist_instruction"))
send_multi_lines(bot, msgs, text, "")
else:
bot.send_msg(tr("no_file"), text)
else:
bot.send_msg(tr("no_file"), text)
def cmd_shortlist(bot, user, text, command, parameter):
global song_shortlist, log
if parameter.strip() == "*":
msgs = [tr('multiple_file_added') + "<ul>"]
music_wrappers = []
for kwargs in song_shortlist:
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("<li>[{}] <b>{}</b></li>".format(music_wrapper.item().type, music_wrapper.item().title))
var.playlist.extend(music_wrappers)
msgs.append("</ul>")
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') + "<ul>"]
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("<li>[{}] <b>{}</b></li>".format(music_wrapper.item().type, music_wrapper.item().title))
else:
var.playlist.extend(music_wrappers)
bot.send_msg(tr('bad_parameter', command=command), text)
return
var.playlist.extend(music_wrappers)
msgs.append("</ul>")
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') + "<ul>"]
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("<li>[{}] <b>{}</b></li>".format(music_wrapper.item().type, music_wrapper.item().title))
var.playlist.remove_by_id(music_dict['id'])
var.cache.free_and_delete(music_dict['id'])
count += 1
else:
bot.send_msg(tr('bad_parameter', command=command), text)
return
if count == 0:
bot.send_msg(tr('bad_parameter', command=command), text)
return
msgs.append("</ul>")
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:
# <https://github.com/ytdl-org/youtube-dl#http-error-429-too-many-requests-or-402-payment-required>
# '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:
# <https://wiki.mumble.info/wiki/Mumo#Set_Status>
#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" <a href=\"{item.url}\"><i>{item.url}</i></a>"
duration = item.duration
elif isinstance(item, PlaylistURLItem):
path = f" <a href=\"{item.url}\"><i>{item.url}</i></a>"
artist = f" <a href=\"{item.playlist_url}\"><i>{item.playlist_title}</i></a>"
duration = item.duration
elif isinstance(item, RadioItem):
path = f" <a href=\"{item.url}\"><i>{item.url}</i></a>"
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": "<b>{song}</b> wurde mit <i>{tags}</i> verschlagwortet.",
"added_tags_to_all": "Alle Lieder in der Playlist wurden mit <i>{tags}</i> verschlagwortet.",
"admin_help": "<h3>Adminbefehle</h3>\n<b>Bot</b>\n<ul>\n<li><b>!<u>k</u>ill </b> - Bot stoppen</li>\n<li><b>!update </b> - Bot update</li>\n<li><b>!userban </b> {user} - Nutzer bannen</li>\n<li><b>!userunban </b> {user} - Nutzer entbannen</li>\n<li><b>!urlbanlist </b> - Zeige alle gebannten URLs an</li>\n<li><b>!urlban </b> [{url}] - Banne {url} (oder das aktuelle Lied, wenn leer) and lösche diese URL aus der Bibliothek.</li>\n<li><b>!urlunban </b> {url} - Entbanne {url}</li>\n<li><b>!rescan </b> {url} - Erneuere den lokalen Cache der Musikdateien</li>\n<li><b>!dropdatabase</b> - Lösche die aktuelle Datenbank. Dadurch gehen alle Einstellungen und die Bibliothek verloren.</li>\n</ul>\n<b>Webinterface</b>\n<ul>\n<li><b>!<u>webuserlist</u></b> - Zeige alle Nutzer, die auf das Webinterface zugreifen dürfen (wenn die Authentifizierung auf 'password' gestellt ist).</li>\n<li><b>!<u>webuseradd</u> {nick name}</b> - Erlaube {nick name} den Zugriff auf das Webinterface (wenn die Authentifizierung auf 'password' gestellt ist).</li>\n<li><b>!<u>webuserdel</u> {nick name}</b> - Lösche den Zugriff von {nick name} auf das Webinterface (wenn die Authentifizierung auf 'password' gestellt ist).</li>\n</ul>",
"auto_paused": "Sende <i>!play</i>, um die Wiedergabe fortzusetzen!",
"bad_command": "<i>{command}</i>: Befehl nicht verfügbar. Sende <i>!help</i>, um dir alle möglichen Befehle anzuzeigen.",
"bad_parameter": "<i>{command}</i>: 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 <i>{mode}</i> gesetzt.",
"change_volume": "Lautstärke wurde von {user} auf {volume} gesetzt.",
"cleared": "Playlist wurde geleert.",
"cleared_tags": "Alle Tags wurden von <b>{song}</b> 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: <i>{mode}</i>",
"current_volume": "Aktuelle Lautstärke: {volume}.",
"database_dropped": "Datenbank gelöscht. Alle Einträge wurde gelöscht.",
"download_in_progress": "<b>{item}</b> 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": "<b>{artist} - {title}</b> <i>wurde von</i> {user} <i>hinzugefügt.</i> ",
"file_missed": "Datei {file} nicht gefunden. Das Element wurde aus der Playlist entfernt.",
"help": "",
"invalid_index": "<i>{index}</i> ist ein ungültiger Index. Sende <i>!queue</i>, 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": "<h2>Update verfügbar!</h2> Version {new_version} von botamusique ist verfügbar! <hr />\n<h3>Changelog</h3>\n{changelog} <hr /> Sende <i>!update</i>, 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 <i>!{command} {{page}}</i>, 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 <i>!sl {indexes}</i>, 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 <i>!sl {{indexes}}</i>, um das gewünscht Element abzuspielen.\n<i>!ytquery -n</i>, 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 <i>{tags}</i> to <b>{song}</b>.",
"added_tags_to_all": "Added tags <i>{tags}</i> to songs on the playlist.",
"admin_help": "<h3>Admin command</h3>\n<b>Bot</b>\n<ul>\n<li><b>!<u>k</u>ill </b> - kill the bot</li>\n<li><b>!update </b> - update the bot</li>\n<li><b>!userban </b> {user} - ban a user</li>\n<li><b>!userunban </b> {user} - unban a user</li>\n<li><b>!urlbanlist </b> - list banned url</li>\n<li><b>!urlban </b> [{url}] - ban {url} (or current item's url by default) and remove this url from the library.</li>\n<li><b>!urlunban </b> {url} - unban {url}</li>\n<li><b>!rescan </b> {url} - rebuild local music file cache</li>\n<li><b>!dropdatabase</b> - clear the entire database, you will lose all settings and music library.</li>\n</ul>\n<b>Web Interface</b>\n<ul>\n<li><b>!<u>webuserlist</u></b> - list all users that have the permission of accessing the web interface, if auth mode is 'password'.</li>\n<li><b>!<u>webuseradd</u> {nick name}</b> - grant the user with {nick name} the access to the web interface, if auth mode is 'password'.</li>\n<li><b>!<u>webuserdel</u> {nick name}</b> - revoke the access to the web interface of {nick name}, if auth mode is 'password'.</li>\n</ul>",
"auto_paused": "Use <i>!play</i> to resume music!",
"bad_command": "<i>{command}</i>: command not found.",
"bad_parameter": "<i>{command}</i>: 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 <i>{mode}</i> by {user}.",
"change_volume": "Volume set to {volume} by {user}.",
"cleared": "Playlist emptied.",
"cleared_tags": "Removed all tags from <b>{song}</b>.",
"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 <i>{mode}</i>.",
"current_volume": "Current volume: {volume}.",
"database_dropped": "Database dropped. All records have gone.",
"download_in_progress": "Download of <b>{item}</b> 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": "<b>{artist} - {title}</b> <i>added by</i> {user}",
"file_missed": "Music file '{file}' missed! This item has been removed from the playlist.",
"help": "<h3>Commands</h3>\n<b>Control</b>\n<ul>\n<li> <b>!<u>w</u>eb</b> - get the URL of the web interface, if enabled. </li>\n<li> <b>!play </b> (or <b>!p</b>) [{num}] [{start_from}] - resume from pausing / start to play (the num-th song is num if given) </li>\n<li> <b>!<u>pa</u>use </b> - pause </li>\n<li> <b>!<u>st</u>op </b> - stop playing </li>\n<li> <b>!<u>sk</u>ip </b> - jump to the next song </li>\n<li> <b>!<u>la</u>st </b> - jump to the last song </li>\n<li> <b>!<u>v</u>olume </b> {volume} - get or change the volume (from 0 to 100) </li>\n<li> <b>!<u>m</u>ode </b> [{mode}] - get or set the playback mode, {mode} should be one of <i>one-shot</i> (remove\nitem once played), <i>repeat</i> (looping through the playlist), <i>random</i> (randomize the playlist),\n<i>autoplay</i> (randomly grab something from the music library).</li>\n<li> <b>!duck </b> on/off - enable or disable ducking function </li>\n<li> <b>!duckv </b> {volume} - set the volume of the bot when ducking is activated </li>\n<li> <b>!<u>duckt</u>hres </b> - set the threshold of volume to activate ducking (3000 by default) </li>\n<li> <b>!<u>o</u>ust </b> - stop playing and go to default channel </li>\n</ul>\n<b>Playlist</b>\n<ul>\n<li> <b>!<u>n</u>ow </b> (or <b>!np</b>) - display the current song </li>\n<li> <b>!<u>q</u>ueue </b> - display items in the playlist </li>\n<li> <b>!<u>t</u>ag </b> {tags} - add all items with tags {tags}, tags separated by \",\". </li>\n<li> <b>!file </b>(or <b>!f</b>) {path/folder/keyword} - add a single file to the playlist by its path or keyword in its path. </li>\n<li> <b>!<u>filem</u>atch </b>(or <b>!fm</b>) {pattern} - add all files that match regex {pattern} </li>\n<li> <b>!<u>ur</u>l </b> {url} - add Youtube or SoundCloud music </li>\n<li> <b>!<u>playl</u>ist </b> {url} [{offset}] - add all items in a Youtube or SoundCloud playlist, and start with the {offset}-th item </li>\n<li> <b>!<u>rad</u>io </b> {url} - append a radio {url} to the playlist </li>\n<li> <b>!<u>rbq</u>uery </b> {keyword} - query http://www.radio-browser.info for a radio station </li>\n<li> <b>!<u>rbp</u>lay </b> {id} - play a radio station with {id} (eg. !rbplay 96746) </li>\n<li> <b>!<u>ys</u>earch </b> {keywords} - query youtube. Use <i>!ysearch -n</i> to turn the page. </li>\n<li> <b>!<u>yp</u>lay </b> {keywords} - add the first search result of {keywords} into the playlist.</li>\n<li> <b>!<u>sh</u>ortlist </b> (or <b>!sl</b>) {indexes/*} - add {indexes}-th item (or all items if * is given) on the shortlist. </li>\n<li> <b>!rm </b> {num} - remove the num-th song on the playlist </li>\n<li> <b>!<u>rep</u>eat </b> [{num}] - repeat current song {num} (1 by default) times.</li>\n<li> <b>!<u>ran</u>dom </b> - randomize the playlist.</li>\n</ul>\n<b>Music Library</b>\n<ul>\n<li> <b>!<u>se</u>arch </b> {keywords} - find item with {keywords} in the music library, keywords separated by space.</li>\n<li> <b>!<u>li</u>stfile </b> [{pattern}] - display list of available files (whose paths match the regex pattern if {pattern} is given) </li>\n<li> <b>!<u>addt</u>ag </b> [{index}] {tags} - add {tags} to {index}-th(current song if {index} is omitted) item on the playlist, tags separated by \",\". </li>\n<li> <b>!<u>addt</u>ag </b> * {tags} - add {tags} to all items on the playlist. </li>\n<li> <b>!<u>un</u>tag </b> [{index/*}] {tags}/* - remove {tags}/all tags from {index}-th(current song if {index} is omitted) item on the playlist. </li>\n<li> <b>!<u>fin</u>dtagged </b> (or <b>!ft</b>) {tags} - find item with {tags} in the music library. </li>\n<li> <b>!<u>del</u>ete </b> {index} - delete {index}-th item on the shortlist from the music library. </li>\n</ul>\n<b>Other</b>\n<ul>\n<li> <b>!<u>j</u>oinme {token} </b> - join your own channel with {token}.</li>\n<li> <b>!<u>password</u> {password} </b> - change your password, used to access the web interface.</li>\n</ul>",
"invalid_index": "Invalid index <i>{index}</i>. Use <i>!queue</i> 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": "<h2>Update Available!</h2> Version {new_version} of botamusique is available! <hr />\n<h3>Changelog</h3> {changelog} <hr /> Send <i>!update</i> 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 <i>!{command} {{page}}</i> 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": "<a href=\"{url}\"><b>{title}</b></a> <i>from</i> {name} <i>added by</i> {user}",
"rb_play_empty": "Please specify a radio station ID!",
"rb_query_result": "This is the result of your query, send <i> !rbplay {ID} </i> to play a station:",
"records_omitted": "...",
"removed_tags": "Removed tags <i>{tags}</i> from <b>{song}</b>.",
"removed_tags_from_all": "Removed tags <i>{tags}</i> 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 <b>{version}</b>.",
"shortlist_instruction": "Use <i>!sl {indexes}</i> to play the item you want.",
"start_updating": "Start updating...",
"stopped": "Music stopped.",
"too_long": "<b>{song}</b> is too long ({duration} > {max_duration}), removed from playlist!",
"unable_download": "Unable to download <b>{item}</b>. Removed from the library.",
"unable_play": "Unable to play <b>{item}</b>. Removed from the library.",
"unknown_mode": "Unknown playback mode '{mode}'. It should be one of <i>one-shot</i>, <i>repeat</i>, <i>random</i>.",
"update_successful": "<h2>botamusique v{version} Installed!</h2><hr />\n<h3>Changelog</h3> {changelog} <hr /> Visit <a href=\"https://github.com/azlux/botamusique\">our github repo</a> for more details!",
"url": "URL",
"url_ban": "The URL {url} is banned! Removed from playlist!",
"url_ban_list": "List of banned URL: <br>{list}",
"url_ban_success": "The following URL is banned: {url}.",
"url_from_playlist": "URL",
"url_from_playlist_item": "<a href=\"{url}\"><b>{title}</b></a> <i>from playlist</i> <a href=\"{playlist_url}\">{playlist}</a> <i>added by</i> {user}",
"url_item": "<a href=\"{url}\"><b>{title}</b></a> <i>added by</i> {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: <br>{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: <br>{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: <br /> {users}",
"webpage_address": "Your own address to access the web interface is <a href=\"{address}\">{address}</a>",
"which_command": "Do you mean <br /> {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 <i>!sl {{indexes}}</i> to play the item you want. <br />\n<i>!ytquery -n</i> 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": "<strong>Are you really sure?</strong> <br /> 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 <span\n class=\"playlist-expand-item-range\"></span> 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.<br />\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 <i>{tags}</i> fueron añadidas a <b>{song}</b>.",
"added_tags_to_all": "Etiquetas <i>{tags}</i> fueron añadidas a las canciones en la lista de reproducción.",
"admin_help": "<h3>Comandos de administrador</h3>\n<b>Bot</b>\n<ul>\n<li><b>!<u>k</u>ill </b> - matar al bot</li>\n<li><b>!update </b> - actualizar al bot</li>\n<li><b>!userban </b> {user} - banear a un usuario</li>\n<li><b>!userunban </b> {user} - desbanear a un usuario</li>\n<li><b>!urlbanlist </b> - listar url baneadas</li>\n<li><b>!urlban </b> [{url}] - banear {url} (o por defecto, la url del ítem actual) y eliminar esta url de la biblioteca.</li>\n<li><b>!urlunban </b> {url} - desbanear {url}</li>\n<li><b>!rescan </b> {url} - reconstruir caché local de ficheros de música</li>\n<li><b>!dropdatabase</b> - borrar toda la base de datos. Esto eliminará toda su configuración y su biblioteca musical.</li>\n</ul>\n<b>Interfaz Web</b>\n<ul>\n<li><b>!<u>webuserlist</u></b> - lista todos los usuarios que tienen permiso de acceder a la interfaz web, si el modo de autenticación es 'contraseña'.</li>\n<li><b>!<u>webuseradd</u> {nickname}</b> - otorga al usuario con {nickname} acceso a la interfaz web, si el modo de autenticación es 'contraseña'.</li>\n<li><b>!<u>webuserdel</u> {nickname}</b> - revoca el acceso a la interfaz web para {nickname}, si el modo de autenticación es 'contraseña'.</li>\n</ul>",
"auto_paused": "Usa <i>!play</i> para continuar la reproducción!",
"bad_command": "<i>{command}</i>: comando no encontrado.",
"bad_parameter": "<i>{command}</i>: 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 <i>{mode}</i> 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 <b>{song}</b>.",
"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 <i>{mode}</i>.",
"current_volume": "Volumen actual: {volume}.",
"database_dropped": "Base de datos descartada. Todos los registros se han ido.",
"download_in_progress": "Descarga de <b>{item}</b> 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": "<b>{artist} - {title}</b> <i>añadido por</i> {user}",
"file_missed": "Fichero de música '{file}' no encontrado! Este ítem ha sido eliminado de la lista de reproducción.",
"help": "<h3>Comandos</h3>\n<b>Control</b>\n<ul>\n<li> <b>!<u>w</u>eb</b> - obtener la URL de la interfaz web, en caso de estar activada. </li>\n<li> <b>!play </b> (or <b>!p</b>) [{n}] [{empezar_desde}] - continuar desde pausa / empezar a reproducir (desde la n-ésima canción, si n es introducido) </li>\n<li> <b>!<u>pa</u>use </b> - pausar </li>\n<li> <b>!<u>st</u>op </b> - parar la reproducción </li>\n<li> <b>!<u>sk</u>ip </b> - saltar a la siguiente canción </li>\n<li> <b>!<u>la</u>st </b> - saltar a la última canción </li>\n<li> <b>!<u>v</u>olume </b> {volumen} - obtener o cambiar el volumen (de 0 a 100) </li>\n<li> <b>!<u>m</u>ode </b> [{modo}] - obtener o ajustar el modo de reproducción. {modo} debiera ser o bien <i>one-shot</i> (eliminar el ítem de la lista una vez reproducido), <i>repeat</i> (repetir la lista de reproducción una vez terminada), <i>random</i> (aleatorizar la reproducción), o <i>autoplay</i> (reproducir una muestra aleatoria de canciones de la biblioteca musical).</li>\n<li> <b>!duck </b> on/off - activar o desactivar funcionalidad de agache </li>\n<li> <b>!duckv </b> - ajustar el volumen del bot para cuando se está en modo de agache </li>\n<li> <b>!<u>duckt</u>hres </b> - ajustar el nivel de volumen de habla que activa el agache (3000 por defecto) </li>\n<li> <b>!<u>o</u>ust </b> - parar la reproducción e ir al canal por defecto del bot </li>\n</ul>\n<b>Lista de Reproducción</b>\n<ul>\n<li> <b>!<u>n</u>ow </b> (o <b>!np</b>) - mostrar la canción actual </li>\n<li> <b>!<u>q</u>ueue </b> - mostrar ítems actualmente en la lista de reproducción </li>\n<li> <b>!<u>t</u>ag </b> {etiquetas} - añadir todos los ítems con etiquetas {etiquetas}. Éstas deben ir separadas por coma (\",\"). </li>\n<li> <b>!file </b>(or <b>!f</b>) {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. </li>\n<li> <b>!<u>filem</u>atch </b>(o <b>!fm</b>) {patrón} - añade todos los ficheros que calzan con la expresión regular {patrón}. </li>\n<li> <b>!<u>ur</u>l </b> {url} - añade música de Youtube o de SoundCloud </li>\n<li> <b>!<u>playl</u>ist </b> {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 </li>\n<li> <b>!<u>rad</u>io </b> {url} - agrega una radio {url} a la lista de reproducción </li>\n<li> <b>!<u>rbq</u>uery </b> {palabra clave} - envía una query a http://www.radio-browser.info para una estación de radio </li>\n<li> <b>!<u>rbp</u>lay </b> {id} - reproduce una estación de radio con {id} (por ejemplo, !rbplay 96746) </li>\n<li> <b>!<u>ys</u>earch </b> {palabras clave} - busca en youtube. Use <i>!ysearch -n</i> para avanzar la página. </li>\n<li> <b>!<u>yp</u>lay </b> {palabras clave} - añade el primer resultado de la búsqueda de {palabras clave} en Youtube a la lista de reproducción.</li>\n<li> <b>!<u>sh</u>ortlist </b> (o <b>!sl</b>) {n/*} - añade el {n}-ésimo elemento (o todos los elementos si se entrega *) en la lista corta. </li>\n<li> <b>!rm </b> {n} - elimina la n-ésima canción en la lista de reproducción </li>\n<li> <b>!<u>rep</u>eat </b> [{n}] - repite la canción actual {n} veces (1 por defecto).</li>\n<li> <b>!<u>ran</u>dom </b> - baraja la lista de reproducción.</li>\n</ul>\n<b>Biblioteca Musical</b>\n<ul>\n<li> <b>!<u>se</u>arch </b> {palabras clave} - encuentra elemento con {palabras clave} en la biblioteca musical. Palabras clave separadas por espacios</li>\n<li> <b>!<u>li</u>stfile </b> [{patrón}] - muestra la lista de ficheros disponibles (cuyas rutas calzan con la expresión regular {patrón}, si éste es entregado) </li>\n<li> <b>!<u>addt</u>ag </b> [{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 (\",\"). </li>\n<li> <b>!<u>addt</u>ag </b> * {etiquetas} - añade {etiquetas} a todos los elementos en la lista de reproducción. </li>\n<li> <b>!<u>un</u>tag </b> [{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. </li>\n<li> <b>!<u>fin</u>dtagged </b> (o <b>!ft</b>) {etiquetas} - encuentra elemento con {etiquetas} en la biblioteca musical. </li>\n<li> <b>!<u>del</u>ete </b> {n} - elimina {n}-ésimo elemento en la lista corta, de la biblioteca musical. </li>\n</ul>\n<b>Otros</b>\n<ul>\n<li> <b>!<u>j</u>oinme {token} </b> - unirse a tu propio canal con {token}.</li>\n<li> <b>!<u>password</u> {contraseña} </b> - cambia la contraseña que usa para acceder a la interfaz web.</li>\n</ul>",
"invalid_index": "Índice <i>{index}</i> 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": "<h2>Actualización disponible!</h2> La versión {new_version} de botamusique está disponible! <hr />\n<h3>Lista de cambios:</h3> {changelog} <hr /> Envía <i>!update</i> 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 <i>!{command} {{page}}</i> 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": "<a href=\"{url}\"><b>{title}</b></a> <i>de</i> {name} <i>añadido por</i> {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 <i> !rbplay {ID} </i> para reproducir una estación:",
"records_omitted": "...",
"removed_tags": "Eliminadas las etiquetas <i>{tags}</i> de <b>{song}</b>.",
"removed_tags_from_all": "Eliminadas las etiquetas <i>{tags}</i> 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 <b>{version}</b>.",
"shortlist_instruction": "Use <i>!sl {índices}</i> para reproducir los elementos que usted desea.",
"start_updating": "Empezando la actualización...",
"stopped": "Música fue detenida.",
"too_long": "<b>{song}</b> es muy larga ({duration} > {max_duration}). Eliminada de la lista de reproducción!",
"unable_download": "No fue posible descargar <b>{item}</b>. Eliminado de la biblioteca.",
"unable_play": "No fue posible reproducir <b>{item}</b>. Eliminado de la biblioteca.",
"unknown_mode": "Modo de reproducción '{mode}' desconocido. Debiera ser o bien <i>one-shot</i>, <i>repeat</i> o <i>random</i>.",
"update_successful": "<h2>botamusique v{version} instalado!</h2><hr />\n<h3>Lista de cambios</h3> {changelog} <hr /> Visite <a href=\"https://github.com/azlux/botamusique\">nuestro repositorio en Github</a> 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": "<a href=\"{url}\"><b>{title}</b></a> <i>de lista de reproducción</i> <a href=\"{playlist_url}\">{playlist}</a> <i>añadido por</i> {user}",
"url_item": "<a href=\"{url}\"><b>{title}</b></a> <i>añadido por</i> {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: <br /> {users}",
"webpage_address": "Tu dirección web para acceder a la interfaz es <a href=\"{address}\">{address}</a>",
"which_command": "Quieres decir <br /> {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 <i>!sl {{índices}}</i> para reproducir el elemento que usted desea. <br />\n<i>!ytquery -n</i> 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": "<strong>¿Está realmente seguro?</strong> <br /> 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 <span\n class=\"playlist-expand-item-range\"></span> 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.<br />\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 <i>{tags}</i> ajoutés à <b>{song}</b>.",
"added_tags_to_all": "Tags <i>{tags}</i> ajoutés aux musiques de la playlist.",
"admin_help": "<h3>Commandes Admin</h3>\n<b>Bot</b>\n<ul>\n<li><b>!<u>k</u>ill </b> - tuer le bot</li>\n<li><b>!update </b> - update the bot</li>\n<li><b>!userban </b> {user} - bannir un utilisateur</li>\n<li><b>!userunban </b> {user} - unban a user</li>\n<li><b>!urlbanlist </b> - liste url interdite</li>\n<li><b>!urlban </b> [{url}] - interdire {url} (ou l'url de l'élément courant par défaut) et supprimer cette url de la bibliothèque.</li>\n<li><b>!urlunban </b> {url} - unban {url}</li>\n<li><b>!rescan </b> {url} - reconstruction du cache des fichiers musicaux locaux</li>\n<li><b>!dropdatabase</b> - effacez toute la base de données, vous perdrez tous les paramètres et la bibliothèque musicale.</li>\n</ul>\n<b>Interface Web</b>\n<ul>\n<li><b>!<u>webuserlist</u></b> - liste de tous les utilisateurs qui ont la permission d'accéder à l'interface web, si le mode d'authentification est 'password'.</li>\n<li><b>!<u>webuseradd</u> {nick name}</b> - accorder à l'utilisateur avec {nick name} l'accès à l'interface web, si le mode d'authentification est 'password'.</li>\n<li><b>!<u>webuserdel</u> {nick name}</b> - révoquer l'accès à l'interface web de {nick name}, si le mode d'authentification est 'password'.</li>\n</ul>",
"auto_paused": "<i>!play</i> 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 <i>{mode}</i> par {user}.",
"change_volume": "Volume réglé sur {volume} par {user}.",
"cleared": "Playlist vidée.",
"cleared_tags": "Suppression de tous les tag de <b>{song}</b>.",
"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 <i>{mode}</i>.",
"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 <b>{item}</b> 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": "<b>{artist} - {title}</b> <i>ajouté par</i> {user}",
"file_missed": "Fichier audio '{file}' introuvable! Cet élément a été supprimé de la playlist.",
"help": "<h3>Commandes</h3>\n<b>Control</b>\n<ul>\n<li> <b>!<u>w</u>eb</b> - obtenir l'URL de l'interface web, si elle est activée. </li>\n<li> <b>!play </b> (ou <b>!p</b>) [{num}] [{start_from}] - reprise de la pause / début de la lecture (à partir de la n° X s'il est donné) </li>\n<li> <b>!<u>pa</u>use</b> - pause </li>\n<li> <b>!<u>st</u>op</b> - arrêtez de jouer </li>\n<li> <b>!<u>sk</u>ip</b> - passer à la chanson suivante </li>\n<li> <b>!<u>la</u>st</b> - passer à la dernière chanson </li>\n<li> <b>!<u>v</u>olume</b> {volume} - obtenir ou modifier le volume (de 0 à 100) </li>\n<li> <b>!<u>m</u>ode</b> [{mode}] - obtenir ou définir le mode de lecture, {mode} doit être l'un de <i>one-shot</i> (supprimer l'élément une fois joué), <i>repeat</i> (boucle de la liste de lecture), <i>ramdom</i> (liste de lecture aléatoire),\n<i>autoplay</i> (prendre au hasard dans la bibliothèque musicale).</li>\n<li> <b>!duck</b> on/off - activer ou désactiver la fonction d'esquive </li>\n<li> <b>!duckv</b> {volume} - définit le volume du bot lorsque le ducking est activé </li>\n<li> <b>!<u>duckt</u>hres</b> - définir le seuil de volume pour activer le ducking (3000 par défaut) </li>\n<li> <b>!<u>o</u>ust</b> - arrêtez de jouer et passez sur le canal par défaut </li>\n</ul>\n<b>Playist</b>\n<ul>\n<li> <b>!<u>n</u>ow </b> (ou <b>!np</b>) - afficher la chanson actuelle </li>\n<li> <b>!<u>q</u>ueue </b> - afficher les éléments de la playlist </li>\n<li> <b>!<u>t</u>ag </b> {balises} - ajouter tous les éléments avec les tags {tags}, les balises séparées par \",\". </li>\n<li> <b>!file</b> (ou <b>!f</b>) {chemin/dossier/mot-clé} - ajoute un seul fichier à la playlist par son chemin ou un mot-clé. </li>\n<li> <b>!<u>filem</u>atch </b>(ou <b>!fm</b>) {pattern} - ajouter tous les fichiers qui correspondent à la regex {pattern} </li>\n<li> <b>!<u>ur</u>l </b> {url} - ajouter de la musique Youtube ou SoundCloud </li>\n<li> <b>!<u>playl</u>ist </b> {url} [{offset}] - ajouter tous les éléments d'une liste de lecture Youtube ou SoundCloud, et commencer par le {offset}-ième élément </li>\n<li> <b>!<u>rad</u>io </b> {url} - ajouter une radio {url} à la playlist </li>\n<li> <b>!<u>rbq</u>uery </b> {keyword} - interroger http://www.radio-browser.info pour une station de radio </li>\n<li> <b>!<u>rbp</u>lay </b> {id} - jouer une station de radio avec {id} (ex. !rbplay 96746) </li>\n<li> <b>!<u>ys</u>earch </b> {keywords} - requête youtube. Utilisez <i>!ysearch -n</i> pour aller à la page d'après. </li>\n<li> <b>!<u>yp</u>lay </b> {keywords} - ajouter le premier résultat de recherche de {keyword} dans la playlist.</li>\n<li> <b>!<u>sh</u>ortlist </b> (ou <b>!sl</b>) {index/*} - ajouter {index}-ième élément (ou tous les éléments si * est donné) de la liste. </li>\n<li> <b>!rm </b> {num} - supprimer le num-ième morceau de la playlist </li>\n<li> <b>!<u>rep</u>eat </b> [{num}] - répéter la chanson actuelle {num} (1 par défaut) times.</li>\n<li> <b>!<u>ran</u>dom </b> - randomiser la playlist.</li>\n</ul>\n<b>Bibliothèque musicale</b>\n<ul>\n<li> <b>!<u>se</u>arch </b> {keywords} - trouver un élément avec {mots-clés} dans la bibliothèque musicale, mots-clés séparés par un espace.</li>\n<li> <b>!<u>li</u>stfile </b> [{pattern}] - affiche la liste des fichiers disponibles (dont les chemins correspondent au motif de regex si {pattern} est donné) </li>\n<li> <b>!<u>addt</u>ag </b> [{index}] {tags} - ajouter {tags} à {index} (current song if {index} n'existe pas) de la playliste, tags séparer par \",\". </li>\n<li> <b>!<u>addt</u>ag </b> * {tags} - ajouter des {tags} à tous les éléments de la playlist. </li>\n<li> <b>!<u>un</u>tag </b> [{index/*}] {tags}/* - supprimer {tags}/toutes les tags de {index}-th(current song if {index} is oitted) item on the playlist. </li>\n<li> <b>!<u>fin</u>dtagged </b> (ou <b>!ft</b>) {tags} - trouver un élément avec des {balises} dans la bibliothèque. </li>\n<li> <b>!<u>del</u>ete </b> {index} - supprimer le {index}-ième élément de la liste de la bibliothèque. </li>\n</ul>\n<b>Autre</b>\n<ul>\n<li> <b>!<u>j</u>oinme {token} </b> - rejoins votre propre channel mumble avec {token}.</li>\n<li> <b>!<u>password</u> {password} </b> - changer votre mot de passe, utilisé pour accéder à l'interface web.</li>\n</ul>",
"invalid_index": "Index non valide <i>{index}</i>. 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": "<h2>Mise à jour disponible!</h2> La version {new_version} de botamusique est disponible ! <hr />\n<h3>Changelog</h3> {changelog} <hr /> Envoyer <i>!update</i> 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 <i>!{command} {{page}}</i> 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": "<a href=\"{url}\"><b>{title}</b></a> <i>from</i> {name} <i>ajouté par</i> {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 <i>{tags}</i> de <b>{song}</b>.",
"removed_tags_from_all": "Suppression des tags <i>{tags}</i> 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 <b>{version}{/b}.",
"shortlist_instruction": "Utilisez <i>!sl {indexes}</i> pour jouer l'élément que vous voulez.",
"start_updating": "Début de la mise à jour...",
"stopped": "Musique arrêté.",
"too_long": "<b>{song}</b> est trop long ({duration} > {max_duration}), supprimé de la playlist !",
"unable_download": "Impossible de télécharger <b>{item}</b>. Retiré de la bibliothèque.",
"unable_play": "Impossible de jouer <b>{item}</b>. Retiré de la bibliothèque.",
"unknown_mode": "Mode de lecture \"{mode}\" inconnu. Il devrait s'agir d'un des modes suivants : <i>one-shot</i>, <i>repeat</i>, <i>random</i>.",
"update_successful": "<h2>botamusique v{version} Installé ! </h2><hr />\n<h3>Changelog</h3> {changelog} <hr /> Visitez <a href=\"https://github.com/azlux/botamusique\">notre repo github</a> pour plus de détails !",
"url": "URL",
"url_ban": "URL {url} est interdite !",
"url_ban_list": "Liste des URL bannies:<br>{list=",
"url_ban_success": "L'URL suivante est interdite: {url}",
"url_from_playlist": "URL",
"url_from_playlist_item": "<a href=\"{url}\"><b>{title}</b></a> <i>depuis la playlist</i> <a href=\"{playlist_url}\">{playlist}</a> <i>ajouté par</i> {user}",
"url_item": "<a href=\"{url}\"><b>{title}</b></a> <i>ajouté par</i> {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: <br>{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:<br>{list}",
"user_ban_success": "L'utilisateur {user} est banni.",
"user_password_set": "Vot
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
SYMBOL INDEX (439 symbols across 20 files)
FILE: command.py
function register_all_commands (line 25) | def register_all_commands(bot):
function send_multi_lines (line 90) | def send_multi_lines(bot, lines, text, linebreak="<br />"):
function send_multi_lines_in_channel (line 107) | def send_multi_lines_in_channel(bot, lines, linebreak="<br />"):
function send_item_added_message (line 124) | def send_item_added_message(bot, wrapper, index, text):
function cmd_joinme (line 145) | def cmd_joinme(bot, user, text, command, parameter):
function cmd_user_ban (line 152) | def cmd_user_ban(bot, user, text, command, parameter):
function cmd_user_unban (line 166) | def cmd_user_unban(bot, user, text, command, parameter):
function cmd_url_ban (line 174) | def cmd_url_ban(bot, user, text, command, parameter):
function cmd_url_ban_list (line 202) | def cmd_url_ban_list(bot, user, text, command, parameter):
function cmd_url_unban (line 211) | def cmd_url_unban(bot, user, text, command, parameter):
function cmd_url_whitelist (line 220) | def cmd_url_whitelist(bot, user, text, command, parameter):
function cmd_url_whitelist_list (line 236) | def cmd_url_whitelist_list(bot, user, text, command, parameter):
function cmd_url_unwhitelist (line 245) | def cmd_url_unwhitelist(bot, user, text, command, parameter):
function cmd_play (line 254) | def cmd_play(bot, user, text, command, parameter):
function cmd_pause (line 287) | def cmd_pause(bot, user, text, command, parameter):
function cmd_play_file (line 294) | def cmd_play_file(bot, user, text, command, parameter, do_not_refresh_ca...
function cmd_play_file_match (line 348) | def cmd_play_file_match(bot, user, text, command, parameter, do_not_refr...
function cmd_play_url (line 391) | def cmd_play_url(bot, user, text, command, parameter):
function cmd_play_playlist (line 409) | def cmd_play_playlist(bot, user, text, command, parameter):
function cmd_play_radio (line 432) | def cmd_play_radio(bot, user, text, command, parameter):
function cmd_rb_query (line 459) | def cmd_rb_query(bot, user, text, command, parameter):
function cmd_rb_play (line 517) | def cmd_rb_play(bot, user, text, command, parameter):
function cmd_yt_search (line 558) | def cmd_yt_search(bot, user, text, command, parameter):
function _yt_format_result (line 592) | def _yt_format_result(results, start, count):
function cmd_yt_play (line 602) | def cmd_yt_play(bot, user, text, command, parameter):
function cmd_help (line 618) | def cmd_help(bot, user, text, command, parameter):
function cmd_stop (line 625) | def cmd_stop(bot, user, text, command, parameter):
function cmd_clear (line 636) | def cmd_clear(bot, user, text, command, parameter):
function cmd_kill (line 643) | def cmd_kill(bot, user, text, command, parameter):
function cmd_update (line 650) | def cmd_update(bot, user, text, command, parameter):
function cmd_stop_and_getout (line 663) | def cmd_stop_and_getout(bot, user, text, command, parameter):
function cmd_volume (line 673) | def cmd_volume(bot, user, text, command, parameter):
function cmd_max_volume (line 693) | def cmd_max_volume(bot, user, text, command, parameter):
function cmd_ducking (line 709) | def cmd_ducking(bot, user, text, command, parameter):
function cmd_ducking_threshold (line 729) | def cmd_ducking_threshold(bot, user, text, command, parameter):
function cmd_ducking_volume (line 742) | def cmd_ducking_volume(bot, user, text, command, parameter):
function cmd_current_music (line 755) | def cmd_current_music(bot, user, text, command, parameter):
function cmd_skip (line 764) | def cmd_skip(bot, user, text, command, parameter):
function cmd_last (line 777) | def cmd_last(bot, user, text, command, parameter):
function cmd_remove (line 787) | def cmd_remove(bot, user, text, command, parameter):
function cmd_list_file (line 820) | def cmd_list_file(bot, user, text, command, parameter):
function cmd_queue (line 857) | def cmd_queue(bot, user, text, command, parameter):
function cmd_random (line 881) | def cmd_random(bot, user, text, command, parameter):
function cmd_repeat (line 888) | def cmd_repeat(bot, user, text, command, parameter):
function cmd_mode (line 909) | def cmd_mode(bot, user, text, command, parameter):
function cmd_play_tags (line 927) | def cmd_play_tags(bot, user, text, command, parameter):
function cmd_add_tag (line 951) | def cmd_add_tag(bot, user, text, command, parameter):
function cmd_remove_tag (line 987) | def cmd_remove_tag(bot, user, text, command, parameter):
function cmd_find_tagged (line 1037) | def cmd_find_tagged(bot, user, text, command, parameter):
function cmd_search_library (line 1070) | def cmd_search_library(bot, user, text, command, parameter):
function cmd_shortlist (line 1117) | def cmd_shortlist(bot, user, text, command, parameter):
function cmd_delete_from_library (line 1176) | def cmd_delete_from_library(bot, user, text, command, parameter):
function cmd_drop_database (line 1228) | def cmd_drop_database(bot, user, text, command, parameter):
function cmd_refresh_cache (line 1242) | def cmd_refresh_cache(bot, user, text, command, parameter):
function cmd_web_access (line 1252) | def cmd_web_access(bot, user, text, command, parameter):
function cmd_user_password (line 1278) | def cmd_user_password(bot, user, text, command, parameter):
function cmd_web_user_add (line 1292) | def cmd_web_user_add(bot, user, text, command, parameter):
function cmd_web_user_remove (line 1309) | def cmd_web_user_remove(bot, user, text, command, parameter):
function cmd_web_user_list (line 1326) | def cmd_web_user_list(bot, user, text, command, parameter):
function cmd_version (line 1336) | def cmd_version(bot, user, text, command, parameter):
function cmd_real_time_rms (line 1341) | def cmd_real_time_rms(bot, user, text, command, parameter):
function cmd_loop_state (line 1345) | def cmd_loop_state(bot, user, text, command, parameter):
function cmd_item (line 1349) | def cmd_item(bot, user, text, command, parameter):
FILE: constants.py
function load_lang (line 10) | def load_lang(lang):
function tr_cli (line 19) | def tr_cli(option, *argv, **kwargs):
function tr_web (line 30) | def tr_web(option, *argv, **kwargs):
function _tr (line 41) | def _tr(string, *argv, **kwargs):
function commands (line 58) | def commands(command):
FILE: database.py
class DatabaseError (line 12) | class DatabaseError(Exception):
class Condition (line 16) | class Condition:
method __init__ (line 17) | def __init__(self):
method sql (line 27) | def sql(self, conn: sqlite3.Connection = None):
method _regexp (line 45) | def _regexp(expr, item):
method or_equal (line 51) | def or_equal(self, column, equals_to, case_sensitive=True):
method and_equal (line 65) | def and_equal(self, column, equals_to, case_sensitive=True):
method or_like (line 79) | def or_like(self, column, equals_to, case_sensitive=True):
method and_like (line 93) | def and_like(self, column, equals_to, case_sensitive=True):
method and_regexp (line 107) | def and_regexp(self, column, regex):
method or_regexp (line 119) | def or_regexp(self, column, regex):
method or_sub_condition (line 131) | def or_sub_condition(self, sub_condition):
method or_not_sub_condition (line 143) | def or_not_sub_condition(self, sub_condition):
method and_sub_condition (line 155) | def and_sub_condition(self, sub_condition):
method and_not_sub_condition (line 167) | def and_not_sub_condition(self, sub_condition):
method limit (line 179) | def limit(self, limit):
method offset (line 184) | def offset(self, offset):
method order_by (line 189) | def order_by(self, order_by, desc=False):
class SettingsDatabase (line 200) | class SettingsDatabase:
method __init__ (line 201) | def __init__(self, db_path):
method get (line 204) | def get(self, section, option, **kwargs):
method getboolean (line 219) | def getboolean(self, section, option, **kwargs):
method getfloat (line 222) | def getfloat(self, section, option, **kwargs):
method getint (line 225) | def getint(self, section, option, **kwargs):
method set (line 228) | def set(self, section, option, value):
method has_option (line 236) | def has_option(self, section, option):
method remove_option (line 247) | def remove_option(self, section, option):
method remove_section (line 254) | def remove_section(self, section):
method items (line 261) | def items(self, section):
method drop_table (line 272) | def drop_table(self):
class MusicDatabase (line 279) | class MusicDatabase:
method __init__ (line 280) | def __init__(self, db_path):
method insert_music (line 283) | def insert_music(self, music_dict, _conn=None):
method query_music_ids (line 332) | def query_music_ids(self, condition: Condition):
method query_all_paths (line 340) | def query_all_paths(self):
method query_all_tags (line 352) | def query_all_tags(self):
method query_music_count (line 364) | def query_music_count(self, condition: Condition):
method query_music (line 376) | def query_music(self, condition: Condition, _conn=None):
method _query_music_by_plain_sql_cond (line 389) | def _query_music_by_plain_sql_cond(self, sql_cond, _conn=None):
method query_music_by_id (line 399) | def query_music_by_id(self, _id, _conn=None):
method query_music_by_keywords (line 406) | def query_music_by_keywords(self, keywords, _conn=None):
method query_music_by_tags (line 414) | def query_music_by_tags(self, tags, _conn=None):
method manage_special_tags (line 422) | def manage_special_tags(self):
method query_tags (line 432) | def query_tags(self, condition: Condition):
method query_random_music (line 450) | def query_random_music(self, count, condition: Condition = None):
method _result_to_dict (line 466) | def _result_to_dict(self, results):
method delete_music (line 484) | def delete_music(self, condition: Condition):
method drop_table (line 492) | def drop_table(self):
class DatabaseMigration (line 499) | class DatabaseMigration:
method __init__ (line 500) | def __init__(self, settings_db: SettingsDatabase, music_db: MusicDatab...
method migrate (line 512) | def migrate(self):
method settings_database_migrate (line 516) | def settings_database_migrate(self):
method music_database_migrate (line 547) | def music_database_migrate(self):
method has_table (line 576) | def has_table(self, table, conn):
method create_settings_table_version_2 (line 583) | def create_settings_table_version_2(self, conn):
method create_music_table_version_1 (line 596) | def create_music_table_version_1(self, conn):
method create_music_table_version_4 (line 614) | def create_music_table_version_4(self, conn):
method settings_table_migrate_from_0_to_1 (line 617) | def settings_table_migrate_from_0_to_1(self, conn):
method settings_table_migrate_from_1_to_2 (line 624) | def settings_table_migrate_from_1_to_2(self, conn):
method music_table_migrate_from_0_to_1 (line 645) | def music_table_migrate_from_0_to_1(self, conn):
method music_table_migrate_from_1_to_2 (line 659) | def music_table_migrate_from_1_to_2(self, conn):
method music_table_migrate_from_2_to_4 (line 677) | def music_table_migrate_from_2_to_4(self, conn):
FILE: interface.py
class ReverseProxied (line 28) | class ReverseProxied(object):
method __init__ (line 46) | def __init__(self, app):
method __call__ (line 49) | def __call__(self, environ, start_response):
function init_proxy (line 73) | def init_proxy():
function check_auth (line 82) | def check_auth(username, password):
function authenticate (line 100) | def authenticate():
function requires_auth (line 112) | def requires_auth(f):
function tag_color (line 183) | def tag_color(tag):
function build_tags_color_lookup (line 203) | def build_tags_color_lookup():
function get_all_dirs (line 211) | def get_all_dirs():
function index (line 229) | def index():
function playlist (line 235) | def playlist():
function status (line 311) | def status():
function post (line 335) | def post():
function build_library_query_condition (line 514) | def build_library_query_condition(form):
function library_info (line 555) | def library_info():
function library (line 575) | def library():
function upload (line 685) | def upload():
function download (line 737) | def download():
FILE: media/cache.py
class ItemNotCachedError (line 17) | class ItemNotCachedError(Exception):
class MusicCache (line 21) | class MusicCache(dict):
method __init__ (line 22) | def __init__(self, db: MusicDatabase):
method get_item_by_id (line 28) | def get_item_by_id(self, id):
method get_item (line 43) | def get_item(self, **kwargs):
method get_items_by_tags (line 65) | def get_items_by_tags(self, tags):
method fetch (line 76) | def fetch(self, id):
method save (line 84) | def save(self, id):
method free_and_delete (line 89) | def free_and_delete(self, id):
method free (line 102) | def free(self, id):
method free_all (line 107) | def free_all(self):
method build_dir_cache (line 111) | def build_dir_cache(self):
class CachedItemWrapper (line 136) | class CachedItemWrapper:
method __init__ (line 137) | def __init__(self, lib, id, type, user):
method item (line 145) | def item(self):
method to_dict (line 151) | def to_dict(self):
method validate (line 156) | def validate(self):
method prepare (line 163) | def prepare(self):
method uri (line 170) | def uri(self):
method add_tags (line 173) | def add_tags(self, tags):
method remove_tags (line 179) | def remove_tags(self, tags):
method clear_tags (line 185) | def clear_tags(self):
method is_ready (line 191) | def is_ready(self):
method is_failed (line 194) | def is_failed(self):
method format_current_playing (line 197) | def format_current_playing(self):
method format_song_string (line 200) | def format_song_string(self):
method format_title (line 203) | def format_title(self):
method format_debug_string (line 206) | def format_debug_string(self):
method display_type (line 209) | def display_type(self):
function get_cached_wrapper (line 214) | def get_cached_wrapper(item, user):
function get_cached_wrappers (line 220) | def get_cached_wrappers(items, user):
function get_cached_wrapper_from_scrap (line 228) | def get_cached_wrapper_from_scrap(**kwargs):
function get_cached_wrapper_from_dict (line 234) | def get_cached_wrapper_from_dict(dict_from_db, user):
function get_cached_wrappers_from_dicts (line 240) | def get_cached_wrappers_from_dicts(dicts_from_db, user):
function get_cached_wrapper_by_id (line 248) | def get_cached_wrapper_by_id(id, user):
function get_cached_wrappers_by_tags (line 253) | def get_cached_wrappers_by_tags(tags, user):
FILE: media/file.py
function file_item_builder (line 26) | def file_item_builder(**kwargs):
function file_item_loader (line 30) | def file_item_loader(_dict):
function file_item_id_generator (line 34) | def file_item_id_generator(**kwargs):
class FileItem (line 43) | class FileItem(BaseItem):
method __init__ (line 44) | def __init__(self, path, from_dict=None):
method uri (line 68) | def uri(self):
method is_ready (line 71) | def is_ready(self):
method validate (line 74) | def validate(self):
method _get_info_from_tag (line 86) | def _get_info_from_tag(self):
method _prepare_thumbnail (line 185) | def _prepare_thumbnail(im):
method to_dict (line 192) | def to_dict(self):
method format_debug_string (line 201) | def format_debug_string(self):
method format_song_string (line 207) | def format_song_string(self, user):
method format_current_playing (line 214) | def format_current_playing(self, user):
method format_title (line 223) | def format_title(self):
method display_type (line 230) | def display_type(self):
FILE: media/item.py
function example_builder (line 8) | def example_builder(**kwargs):
function example_loader (line 12) | def example_loader(_dict):
function example_id_generator (line 16) | def example_id_generator(**kwargs):
function dicts_to_items (line 25) | def dicts_to_items(music_dicts):
function dict_to_item (line 33) | def dict_to_item(music_dict):
class ValidationFailedError (line 37) | class ValidationFailedError(Exception):
method __init__ (line 38) | def __init__(self, msg = None):
class PreparationFailedError (line 41) | class PreparationFailedError(Exception):
method __init__ (line 42) | def __init__(self, msg = None):
class BaseItem (line 45) | class BaseItem:
method __init__ (line 46) | def __init__(self, from_dict=None):
method is_ready (line 68) | def is_ready(self):
method is_failed (line 71) | def is_failed(self):
method validate (line 74) | def validate(self):
method uri (line 77) | def uri(self):
method prepare (line 80) | def prepare(self):
method add_tags (line 83) | def add_tags(self, tags):
method remove_tags (line 89) | def remove_tags(self, tags):
method clear_tags (line 95) | def clear_tags(self):
method format_song_string (line 100) | def format_song_string(self, user):
method format_current_playing (line 103) | def format_current_playing(self, user):
method format_title (line 106) | def format_title(self):
method format_debug_string (line 109) | def format_debug_string(self):
method display_type (line 112) | def display_type(self):
method to_dict (line 115) | def to_dict(self):
FILE: media/playlist.py
function get_playlist (line 14) | def get_playlist(mode, _list=None, _index=None):
class BasePlaylist (line 40) | class BasePlaylist(list):
method __init__ (line 41) | def __init__(self):
method is_empty (line 51) | def is_empty(self):
method from_list (line 54) | def from_list(self, _list, current_index):
method append (line 62) | def append(self, item: CachedItemWrapper):
method insert (line 72) | def insert(self, index, item):
method extend (line 89) | def extend(self, items):
method next (line 98) | def next(self):
method point_to (line 109) | def point_to(self, index):
method find (line 114) | def find(self, id):
method __delitem__ (line 121) | def __delitem__(self, key):
method remove (line 124) | def remove(self, index):
method remove_by_id (line 146) | def remove_by_id(self, id):
method current_item (line 158) | def current_item(self):
method next_index (line 165) | def next_index(self):
method next_item (line 172) | def next_item(self):
method randomize (line 179) | def randomize(self):
method clear (line 191) | def clear(self):
method save (line 199) | def save(self):
method load (line 208) | def load(self):
method _debug_print (line 224) | def _debug_print(self):
method async_validate (line 233) | def async_validate(self):
method _check_valid (line 240) | def _check_valid(self):
class OneshotPlaylist (line 274) | class OneshotPlaylist(BasePlaylist):
method __init__ (line 275) | def __init__(self):
method current_item (line 280) | def current_item(self):
method from_list (line 291) | def from_list(self, _list, current_index):
method next (line 301) | def next(self):
method next_index (line 318) | def next_index(self):
method next_item (line 324) | def next_item(self):
method point_to (line 330) | def point_to(self, index):
class RepeatPlaylist (line 338) | class RepeatPlaylist(BasePlaylist):
method __init__ (line 339) | def __init__(self):
method next (line 343) | def next(self):
method next_index (line 355) | def next_index(self):
method next_item (line 362) | def next_item(self):
class RandomPlaylist (line 368) | class RandomPlaylist(BasePlaylist):
method __init__ (line 369) | def __init__(self):
method from_list (line 373) | def from_list(self, _list, current_index):
method next (line 378) | def next(self):
class AutoPlaylist (line 393) | class AutoPlaylist(OneshotPlaylist):
method __init__ (line 394) | def __init__(self):
method refresh (line 398) | def refresh(self):
method clear (line 412) | def clear(self):
method next (line 416) | def next(self):
FILE: media/radio.py
function get_radio_server_description (line 15) | def get_radio_server_description(url):
function get_radio_title (line 66) | def get_radio_title(url):
function radio_item_builder (line 93) | def radio_item_builder(**kwargs):
function radio_item_loader (line 100) | def radio_item_loader(_dict):
function radio_item_id_generator (line 104) | def radio_item_id_generator(**kwargs):
class RadioItem (line 113) | class RadioItem(BaseItem):
method __init__ (line 114) | def __init__(self, url, name="", from_dict=None):
method validate (line 130) | def validate(self):
method is_ready (line 134) | def is_ready(self):
method uri (line 137) | def uri(self):
method to_dict (line 140) | def to_dict(self):
method format_debug_string (line 147) | def format_debug_string(self):
method format_song_string (line 153) | def format_song_string(self, user):
method format_current_playing (line 161) | def format_current_playing(self, user):
method format_title (line 164) | def format_title(self):
method display_type (line 167) | def display_type(self):
FILE: media/url.py
function url_item_builder (line 23) | def url_item_builder(**kwargs):
function url_item_loader (line 27) | def url_item_loader(_dict):
function url_item_id_generator (line 31) | def url_item_id_generator(**kwargs):
class URLItem (line 40) | class URLItem(BaseItem):
method __init__ (line 41) | def __init__(self, url, from_dict=None):
method uri (line 63) | def uri(self):
method is_ready (line 66) | def is_ready(self):
method validate (line 77) | def validate(self):
method prepare (line 119) | def prepare(self):
method _get_info_from_url (line 127) | def _get_info_from_url(self):
method _download (line 162) | def _download(self):
method _read_thumbnail_from_file (line 225) | def _read_thumbnail_from_file(self, path_thumbnail):
method _prepare_thumbnail (line 230) | def _prepare_thumbnail(self, im):
method to_dict (line 237) | def to_dict(self):
method format_debug_string (line 248) | def format_debug_string(self):
method format_song_string (line 254) | def format_song_string(self, user):
method format_current_playing (line 262) | def format_current_playing(self, user):
method format_title (line 272) | def format_title(self):
method display_type (line 275) | def display_type(self):
FILE: media/url_from_playlist.py
function get_playlist_info (line 12) | def get_playlist_info(url, start_index=0, user=""):
function playlist_url_item_builder (line 70) | def playlist_url_item_builder(**kwargs):
function playlist_url_item_loader (line 77) | def playlist_url_item_loader(_dict):
class PlaylistURLItem (line 86) | class PlaylistURLItem(URLItem):
method __init__ (line 87) | def __init__(self, url, title, playlist_url, playlist_title, from_dict...
method to_dict (line 100) | def to_dict(self):
method format_debug_string (line 107) | def format_debug_string(self):
method format_song_string (line 114) | def format_song_string(self, user):
method format_current_playing (line 122) | def format_current_playing(self, user):
method display_type (line 132) | def display_type(self):
FILE: mumbleBot.py
class MumbleBot (line 34) | class MumbleBot:
method __init__ (line 37) | def __init__(self, args):
method ctrl_caught (line 212) | def ctrl_caught(self, signal, frame):
method get_version (line 228) | def get_version(self):
method register_command (line 234) | def register_command(self, cmd, handle, no_partial_match=False, access...
method set_comment (line 245) | def set_comment(self):
method set_avatar (line 248) | def set_avatar(self):
method join_channel (line 257) | def join_channel(self):
method message_received (line 269) | def message_received(self, text):
method send_msg (line 358) | def send_msg(self, msg, text):
method send_channel_msg (line 363) | def send_channel_msg(self, msg):
method is_admin (line 369) | def is_admin(user):
method get_user_count_in_channel (line 380) | def get_user_count_in_channel(self):
method users_changed (line 394) | def users_changed(self, user, message):
method launch_music (line 419) | def launch_music(self, music_wrapper, start_from=0):
method async_download_next (line 451) | def async_download_next(self):
method async_download (line 470) | def async_download(self, item):
method start_download (line 478) | def start_download(self, item):
method _download (line 485) | def _download(self, item):
method loop (line 511) | def loop(self):
method volume_cycle (line 627) | def volume_cycle(self):
method ducking_sound_received (line 645) | def ducking_sound_received(self, user, sound):
method _fadeout (line 661) | def _fadeout(self, _pcm_data, stereo=False, fadein=False):
method play (line 690) | def play(self, index=-1, start_at=0):
method clear (line 705) | def clear(self):
method stop (line 712) | def stop(self):
method interrupt (line 721) | def interrupt(self):
method pause (line 730) | def pause(self):
method resume (line 739) | def resume(self):
function start_web_interface (line 756) | def start_web_interface(addr, port):
FILE: scripts/sync_translation.py
function get_access_header (line 14) | def get_access_header(client, secret):
function fetch_translation (line 33) | def fetch_translation(r_client, r_secret):
function push_strings (line 48) | def push_strings(w_client, w_secret):
FILE: scripts/translate_templates.py
function load_lang (line 15) | def load_lang(lang):
function tr (line 20) | def tr(option):
FILE: util.py
function solve_filepath (line 27) | def solve_filepath(path):
function get_recursive_file_list_sorted (line 40) | def get_recursive_file_list_sorted(path):
function zipdir (line 72) | def zipdir(files, zipname_prefix=None):
function get_user_ban (line 98) | def get_user_ban():
function new_release_version (line 105) | def new_release_version(target):
function fetch_changelog (line 114) | def fetch_changelog():
function check_update (line 120) | def check_update(current_version):
function update (line 135) | def update(current_version):
function pipe_no_wait (line 165) | def pipe_no_wait():
class Dir (line 216) | class Dir(object):
method __init__ (line 217) | def __init__(self, path):
method add_file (line 223) | def add_file(self, file):
method get_subdirs (line 239) | def get_subdirs(self, path=None):
method get_subdirs_recursively (line 252) | def get_subdirs_recursively(self, path=None):
method get_files (line 268) | def get_files(self, path=None):
method get_files_recursively (line 280) | def get_files_recursively(self, path=None):
method render_text (line 295) | def render_text(self, ident=0):
function get_url_from_input (line 305) | def get_url_from_input(string):
function youtube_search (line 323) | def youtube_search(query):
function get_media_duration (line 363) | def get_media_duration(path):
function parse_time (line 378) | def parse_time(human):
function format_time (line 391) | def format_time(seconds):
function parse_file_size (line 399) | def parse_file_size(human):
function get_salted_password_hash (line 412) | def get_salted_password_hash(password):
function verify_password (line 419) | def verify_password(password, salted_hash, salt):
function get_supported_language (line 426) | def get_supported_language():
function set_logging_formatter (line 438) | def set_logging_formatter(handler: logging.Handler, logging_level):
function get_snapshot_version (line 451) | def get_snapshot_version():
class LoggerIOWrapper (line 473) | class LoggerIOWrapper(io.TextIOWrapper):
method __init__ (line 474) | def __init__(self, logger: logging.Logger, logging_level, fallback_io_...
method write (line 479) | def write(self, text):
class VolumeHelper (line 489) | class VolumeHelper:
method __init__ (line 490) | def __init__(self, plain_volume=0, ducking_plain_volume=0):
method set_volume (line 501) | def set_volume(self, plain_volume):
method set_ducking_volume (line 505) | def set_ducking_volume(self, plain_volume):
method _convert_volume (line 509) | def _convert_volume(self, volume):
function get_size_folder (line 520) | def get_size_folder(path):
function clear_tmp_folder (line 534) | def clear_tmp_folder(path, size):
function check_extra_config (line 571) | def check_extra_config(config, template):
function parse_cookie_file (line 584) | def parse_cookie_file(cookiefile):
FILE: web/js/lib/text.mjs
function limitChars (line 12) | function limitChars(text, limit = 50, ending = '...') {
function limitWords (line 35) | function limitWords(text, limit = 10, ending = '...') {
FILE: web/js/lib/theme.mjs
method init (line 11) | static init() {
method set (line 24) | static set(dark = false) {
method swap (line 39) | static swap() {
FILE: web/js/lib/type.mjs
function isObject (line 7) | function isObject(value) {
function isString (line 17) | function isString(value) {
function isNumber (line 27) | function isNumber(value) {
function validateObject (line 37) | function validateObject(value) {
function validateString (line 49) | function validateString(value) {
function validateNumber (line 61) | function validateNumber(value) {
FILE: web/js/lib/util.mjs
function isOverflown (line 1) | function isOverflown(element) {
function hash (line 5) | function hash(string) {
function getColor (line 19) | function getColor(string) {
function setProgressBar (line 42) | function setProgressBar(bar, progress, text = '') {
function secondsToStr (line 50) | function secondsToStr(seconds) {
FILE: web/js/main.mjs
function request (line 98) | function request(_url, _data, refresh = false) {
function addPlaylistItem (line 122) | function addPlaylistItem(item) {
function displayPlaylist (line 160) | function displayPlaylist(data) {
function displayActiveItem (line 212) | function displayActiveItem(current_index) {
function insertExpandPrompt (line 217) | function insertExpandPrompt(real_from, real_to, display_from, display_to...
function updatePlaylist (line 237) | function updatePlaylist() {
function checkForPlaylistUpdate (line 265) | function checkForPlaylistUpdate() {
function bindPlaylistEvent (line 299) | function bindPlaylistEvent() {
function updateControls (line 316) | function updateControls(empty, play, mode, volume) {
function togglePlayPause (line 362) | function togglePlayPause() {
function changePlayMode (line 374) | function changePlayMode(mode) {
function setFilterType (line 400) | function setFilterType(event, type) {
function bindLibraryResultEvent (line 428) | function bindLibraryResultEvent() {
function updateLibraryControls (line 508) | function updateLibraryControls() {
function displayLibraryControls (line 521) | function displayLibraryControls(data) {
function addResultItem (line 592) | function addResultItem(item) {
function getFilters (line 629) | function getFilters(dest_page = 1) {
function updateResults (line 656) | function updateResults(dest_page = 1) {
function downloadId (line 740) | function downloadId(id) {
function processResults (line 753) | function processResults(data) {
function addTagModalShow (line 842) | function addTagModalShow(_id, _title, _tag_tuples) {
function uploadStart (line 995) | function uploadStart() {
function setUploadError (line 1024) | function setUploadError(filename, error) {
function generateUploadProgressItem (line 1034) | function generateUploadProgressItem(file) {
function uploadNextFile (line 1053) | function uploadNextFile() {
function uploadCancel (line 1115) | function uploadCancel() {
function playerSetIdle (line 1183) | function playerSetIdle() {
function updatePlayerInfo (line 1192) | function updatePlayerInfo(item) {
function updatePlayerControls (line 1218) | function updatePlayerControls(play, empty) {
function updatePlayerPlayhead (line 1242) | function updatePlayerPlayhead(playhead) {
function playheadDragged (line 1284) | function playheadDragged(event) {
Condensed preview — 66 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,005K chars).
[
{
"path": ".dockerignore",
"chars": 6,
"preview": ".git/\n"
},
{
"path": ".drone.yml",
"chars": 5258,
"preview": "kind: pipeline\ntype: docker\nname: deployement-local\n\nvolumes:\n - name: repo\n host:\n path: /media/raid5/data/pac"
},
{
"path": ".github/FUNDING.yml",
"chars": 29,
"preview": "open_collective: botamusique\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 1095,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\n\n---\n\n**Describe the bug**\nA clear and concise descriptio"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 560,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\n\n---\n\n**Is your feature request related to a problem? "
},
{
"path": ".gitignore",
"chars": 1464,
"preview": "# Created by .ignore support plugin (hsz.mobi)\n### Python template\n# Byte-compiled / optimized / DLL files\n__pycache__/\n"
},
{
"path": ".gitmodules",
"chars": 0,
"preview": ""
},
{
"path": "Dockerfile",
"chars": 822,
"preview": "ARG ARCH=\nFROM python:3.11-slim-bullseye AS python-builder\nENV DEBIAN_FRONTEND=noninteractive\nWORKDIR /botamusique\n\nRUN "
},
{
"path": "Dockerfile.local",
"chars": 1717,
"preview": "ARG ARCH=\n\nFROM ${ARCH}python:3-slim-bullseye AS source\nARG VERSION=master\nENV DEBIAN_FRONTEND=noninteractive\nWORKDIR /b"
},
{
"path": "LICENSE",
"chars": 1057,
"preview": "MIT License\n\nCopyright (c) 2016 \n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this s"
},
{
"path": "README.md",
"chars": 12742,
"preview": "# Important Announcement\n\nHello everyone,\n\nFirst, let's look at the problems:\n1. I don't use mumble anymore, working on "
},
{
"path": "command.py",
"chars": 51549,
"preview": "# coding=utf-8\nimport logging\nimport secrets\nimport datetime\nimport json\nimport re\nimport pymumble_py3 as pymumble\n\nfrom"
},
{
"path": "configuration.default.ini",
"chars": 3848,
"preview": "# ========================================================\n# botamusique Default Configuration File\n# Version 6\n# ===="
},
{
"path": "configuration.example.ini",
"chars": 9805,
"preview": "# ========================================================\n# botamusique example configuration file\n# Version 6\n# ===="
},
{
"path": "constants.py",
"chars": 1985,
"preview": "import os\nimport json\n\nimport variables as var\n\ndefault_lang_dict = {}\nlang_dict = {}\n\n\ndef load_lang(lang):\n global "
},
{
"path": "database.py",
"chars": 23462,
"preview": "import os\nimport re\nimport sqlite3\nimport json\nimport datetime\nimport time\nimport logging\n\nlog = logging.getLogger(\"bot\""
},
{
"path": "entrypoint.sh",
"chars": 1419,
"preview": "#!/usr/bin/env bash\ncommand=( \"${@}\" )\n\nif [ \"$1\" == \"bash\" ] || [ \"$1\" == \"sh\" ]; then\n exec \"${@}\"\nfi\n\nif [ -n \"$BA"
},
{
"path": "interface.py",
"chars": 28496,
"preview": "#!/usr/bin/python3\nimport sqlite3\nfrom functools import wraps\nfrom flask import Flask, render_template, request, redirec"
},
{
"path": "lang/de_DE.json",
"chars": 8442,
"preview": "{\n \"cli\": {\n \"added_tags\": \"<b>{song}</b> wurde mit <i>{tags}</i> verschlagwortet.\",\n \"added_tags_to_al"
},
{
"path": "lang/en_US.json",
"chars": 15074,
"preview": "{\n \"cli\": {\n \"added_tags\": \"Added tags <i>{tags}</i> to <b>{song}</b>.\",\n \"added_tags_to_all\": \"Added t"
},
{
"path": "lang/es_ES.json",
"chars": 16840,
"preview": "{\n \"cli\": {\n \"added_tags\": \"Etiquetas <i>{tags}</i> fueron añadidas a <b>{song}</b>.\",\n \"added_tags_to_"
},
{
"path": "lang/fr_FR.json",
"chars": 16526,
"preview": "{\n \"cli\": {\n \"added_tags\": \"Tags <i>{tags}</i> ajoutés à <b>{song}</b>.\",\n \"added_tags_to_all\": \"Tags <"
},
{
"path": "lang/it_IT.json",
"chars": 15743,
"preview": "{\n \"cli\": {\n \"added_tags\": \"Tag <i>{tags}</i> aggiunti a <b>{song}</b>.\",\n \"added_tags_to_all\": \"I tag "
},
{
"path": "lang/ja_JP.json",
"chars": 12130,
"preview": "{\n \"cli\": {\n \"added_tags\": \"<b>{song}</b>に<i>{tags}</i>というタグを追加しました。\",\n \"added_tags_to_all\": \"再生リストの曲に<"
},
{
"path": "lang/nl_NL.json",
"chars": 4991,
"preview": "{\n \"cli\": {\n \"added_tags\": \"\",\n \"added_tags_to_all\": \"\",\n \"admin_help\": \"\",\n \"auto_paused"
},
{
"path": "lang/pt_BR.json",
"chars": 17299,
"preview": "{\n \"cli\": {\n \"added_tags\": \"As etiquetas <i>{tags}</i> foram adicionadas em <b>{song}</b>.\",\n \"added_ta"
},
{
"path": "lang/zh_CN.json",
"chars": 11114,
"preview": "{\n \"cli\": {\n \"added_tags\": \"已将标签 <i>{tags}</i> 添加到 <b>{song}</b>。\",\n \"added_tags_to_all\": \"已将标签 <i>{tag"
},
{
"path": "media/README.md",
"chars": 313,
"preview": "```\n+----------------------------------------------------------+\n| <-| URLItem <-- URLFromPlaylistItem "
},
{
"path": "media/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "media/cache.py",
"chars": 7990,
"preview": "import logging\nimport os\n\nimport json\nimport threading\n\nfrom media.item import item_builders, item_id_generators, dict_t"
},
{
"path": "media/file.py",
"chars": 7509,
"preview": "import os\nimport re\nfrom io import BytesIO\nimport base64\nimport hashlib\nimport mutagen\nfrom PIL import Image\n\nimport uti"
},
{
"path": "media/item.py",
"chars": 3056,
"preview": "import logging\n\nitem_builders = {}\nitem_loaders = {}\nitem_id_generators = {}\n\n\ndef example_builder(**kwargs):\n return"
},
{
"path": "media/playlist.py",
"chars": 12375,
"preview": "import json\nimport threading\nimport logging\nimport random\nimport time\n\nimport variables as var\nfrom media.cache import ("
},
{
"path": "media/radio.py",
"chars": 5569,
"preview": "import re\nimport logging\nimport struct\nimport requests\nimport traceback\nimport hashlib\n\nfrom media.item import BaseItem\n"
},
{
"path": "media/url.py",
"chars": 9558,
"preview": "import threading\nimport logging\nimport os\nimport hashlib\nimport traceback\nfrom PIL import Image\nimport yt_dlp as youtube"
},
{
"path": "media/url_from_playlist.py",
"chars": 4848,
"preview": "import logging\nimport yt_dlp as youtube_dl\nfrom constants import tr_cli as tr\nimport variables as var\nfrom media.item im"
},
{
"path": "mumbleBot.py",
"chars": 39013,
"preview": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\nimport re\nimport threading\nimport time\nimport sys\nimport math\nimport sign"
},
{
"path": "requirements.txt",
"chars": 82,
"preview": "flask\nyt-dlp\npython-magic\nPillow\nmutagen\nrequests\npackaging\npymumble>=1.2\npyradios"
},
{
"path": "scripts/commit_new_translation.sh",
"chars": 1112,
"preview": "#!/usr/bin/env bash\n\ngit remote set-url origin https://azlux:$GITHUB_API@github.com/azlux/botamusique/\n\necho \"=> Fetchin"
},
{
"path": "scripts/sync_translation.py",
"chars": 3301,
"preview": "#!/usr/bin/env python3\n\nimport os\nimport re\nimport argparse\nimport requests\n\nbase_url = \"https://translate.azlux.fr/api/"
},
{
"path": "scripts/translate_templates.py",
"chars": 2147,
"preview": "#!/usr/bin/env python3\nimport argparse\nimport os\nimport json\nimport re\nimport jinja2\n\ndefault_lang_dict = {}\nlang_dict ="
},
{
"path": "scripts/update_translation_to_server.sh",
"chars": 2022,
"preview": "#!/usr/bin/env bash\n\nset -e\n\ngit remote set-url origin https://azlux:$GITHUB_API@github.com/azlux/botamusique/\ngit pull "
},
{
"path": "static/.gitignore",
"chars": 8,
"preview": "css/\njs/"
},
{
"path": "update.sh",
"chars": 622,
"preview": "#!/usr/bin/env bash\n\ncase \"$1\" in\n stable)\n curl -Lo /tmp/botamusique.tar.gz https://packages.azlux.fr/botamus"
},
{
"path": "util.py",
"chars": 19491,
"preview": "#!/usr/bin/python3\n# coding=utf-8\n\nimport hashlib\nimport html\nimport magic\nimport os\nimport io\nimport sys\nimport variabl"
},
{
"path": "variables.py",
"chars": 511,
"preview": "from typing import Type, TYPE_CHECKING\n\nif TYPE_CHECKING:\n import mumbleBot\n import media.playlist\n import medi"
},
{
"path": "web/.editorconfig",
"chars": 167,
"preview": "[*]\ncharset = utf-8\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\ntrim_trailing_whitespace = true\nquo"
},
{
"path": "web/.eslintrc.json",
"chars": 841,
"preview": "{\n \"parser\": \"@babel/eslint-parser\",\n \"env\": {\n \"browser\": true,\n \"es6\": true,\n \"es2017\": true,\n \"es2020\":"
},
{
"path": "web/.gitattributes",
"chars": 30,
"preview": "package-lock.json text eol=lf\n"
},
{
"path": "web/.gitignore",
"chars": 17,
"preview": "!*\nnode_modules/\n"
},
{
"path": "web/babel.config.json",
"chars": 69,
"preview": "{\n \"plugins\": [\n \"@babel/plugin-proposal-class-properties\"\n ]\n}\n"
},
{
"path": "web/js/app.mjs",
"chars": 1153,
"preview": "import {library, dom} from '@fortawesome/fontawesome-svg-core/index.es.js';\nimport {\n faTimesCircle, faPlus, faCheck, f"
},
{
"path": "web/js/lib/text.mjs",
"chars": 1172,
"preview": "import {validateString, validateNumber} from './type.mjs';\n\n/**\n * Truncate string length by characters.\n *\n * @param {s"
},
{
"path": "web/js/lib/theme.mjs",
"chars": 977,
"preview": "export default class {\n /**\n * @property {boolean} dark Interal state for dark theme activation.\n * @private\n"
},
{
"path": "web/js/lib/type.mjs",
"chars": 1641,
"preview": "/**\n * Checks if `value` is the type `Object` excluding `Function` and `null`\n *\n * @param {*} value The value to check."
},
{
"path": "web/js/lib/util.mjs",
"chars": 1364,
"preview": "export function isOverflown(element) {\n return element.scrollHeight > element.clientHeight || element.scrollWidth > ele"
},
{
"path": "web/js/main.mjs",
"chars": 36480,
"preview": "import 'jquery/src/jquery.js';\nimport 'jquery-migrate/src/migrate.js';\nimport Popper from 'popper.js/dist/esm/popper.js'"
},
{
"path": "web/package-lock.json",
"chars": 459352,
"preview": "{\n \"name\": \"botamusique\",\n \"lockfileVersion\": 2,\n \"requires\": true,\n \"packages\": {\n \"\": {\n \"name\": \"botamusi"
},
{
"path": "web/package.json",
"chars": 1604,
"preview": "{\n \"name\": \"botamusique\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"lint\": \"eslint --config .eslintrc."
},
{
"path": "web/sass/app-dark.scss",
"chars": 147,
"preview": "@import '~bootswatch/dist/darkly/variables';\n@import '~bootstrap/scss/bootstrap';\n@import '~bootswatch/dist/darkly/boots"
},
{
"path": "web/sass/app.scss",
"chars": 56,
"preview": "@import '~bootstrap/scss/bootstrap';\n\n@import './main';\n"
},
{
"path": "web/sass/main.scss",
"chars": 4104,
"preview": ".btn-space {\n margin-right: 5px;\n}\n\n/* Playlist */\n.playlist-item {\n transition: all 0.2s ease-in-out;\n}\n\n.playlis"
},
{
"path": "web/templates/index.template.html",
"chars": 30940,
"preview": "<!DOCTYPE html>\n\n<html lang=\"en\">\n\n<head>\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink"
},
{
"path": "web/templates/need_token.template.html",
"chars": 1439,
"preview": "<!DOCTYPE html>\n\n<head>\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n <"
},
{
"path": "web/vscode.eslintrc.json",
"chars": 103,
"preview": "{\n \"parserOptions\": {\n \"babelOptions\": {\n \"configFile\": \"./web/babel.config.json\"\n }\n }\n}\n"
},
{
"path": "web/webpack.config.cjs",
"chars": 1864,
"preview": "const path = require('path');\nconst MiniCssExtractPlugin = require('mini-css-extract-plugin');\nconst HtmlWebpackPlugin ="
}
]
About this extraction
This page contains the full source code of the azlux/botamusique GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 66 files (904.8 KB), approximately 299.7k tokens, and a symbol index with 439 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.