├── test ├── __init__.py ├── audio │ ├── silence.mp3 │ ├── silence.ogg │ ├── silence.wav │ ├── too-short.mp3 │ ├── orig │ │ ├── padded.flac │ │ ├── silence.flac │ │ ├── too-short.flac │ │ ├── interleaved.flac │ │ ├── padded-end.flac │ │ ├── padded-start.flac │ │ ├── sine-unicode.flac │ │ ├── padded-start-long.flac │ │ ├── silence-unicode.flac │ │ └── unpadded-interleaved.flac │ ├── padded-stereo.mp3 │ ├── silence-32kHz.mp3 │ ├── silence-112kbps.mp3 │ ├── interleaved-stereo.mp3 │ ├── not-an-audio-file.mp3 │ ├── padded-end-stereo.mp3 │ ├── padded-jointstereo.mp3 │ ├── silence-stripped.mp3 │ ├── sine-unicode-mono.mp3 │ ├── padded-start-stereo.mp3 │ ├── sine-unicode-stereo.mp3 │ ├── interleaved-jointstereo.mp3 │ ├── padded-end-jointstereo.mp3 │ ├── silence-unicode-stereo.mp3 │ ├── padded-start-jointstereo.mp3 │ ├── sine-unicode-jointstereo.mp3 │ ├── silence-unicode-jointstereo.mp3 │ ├── unpadded-interleaved-stereo.mp3 │ └── unpadded-interleaved-jointstereo.mp3 ├── utils.py ├── test_cli_init.py ├── test_cli_playlog.py ├── test_cli_disable_expired.py ├── test_api_dev_server.py ├── test_cli_reanalyze.py ├── test_api_utils.py ├── test_cli_import.py ├── test_cli_fsck.py ├── test_player.py └── test_api.py ├── klangbecken ├── __init__.py ├── __main__.py ├── settings.py ├── api.py └── player.py ├── requirements-test.txt ├── requirements.txt ├── doc ├── system-overview.odp ├── system-overview.png ├── simulation-screenshot.png ├── systemd │ ├── klangbecken-fsck.timer │ ├── klangbecken-reload-jingles.timer │ ├── klangbecken-disable-expired.timer │ ├── liquidsoap@klangbecken.service.d │ │ └── overrides.conf │ ├── klangbecken-disable-expired.service │ ├── klangbecken-fsck.service │ ├── klangbecken.env │ ├── klangbecken-reload-jingles.service │ ├── liquidsoap@.service │ └── virtual-saemubox.service ├── klangbecken_api.wsgi ├── klangbecken.conf ├── send-now-playing.sh ├── simulation-scripts │ ├── simulate-playlist-reloads.py │ ├── simulate-saemubox.py │ ├── generate-tracks.py │ └── analysis.py ├── authentication-middleware.md ├── simulation-timing-changes.patch ├── data-dir.md ├── cli.md ├── additional-tools.md ├── api.md ├── simulation.md ├── design.md └── deployment.md ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── python-package.yml │ └── liquidsoap-script.yml ├── setup.cfg ├── setup.py ├── deploy.sh ├── README.md └── klangbecken.liq /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /klangbecken/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.1" 2 | -------------------------------------------------------------------------------- /klangbecken/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | coverage==7.6.1 3 | flake8==7.1.1 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | docopt==0.6.2 2 | mutagen==1.47.0 3 | PyJWT==2.9.0 4 | Werkzeug==3.0.4 5 | -------------------------------------------------------------------------------- /doc/system-overview.odp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/doc/system-overview.odp -------------------------------------------------------------------------------- /doc/system-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/doc/system-overview.png -------------------------------------------------------------------------------- /test/audio/silence.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/silence.mp3 -------------------------------------------------------------------------------- /test/audio/silence.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/silence.ogg -------------------------------------------------------------------------------- /test/audio/silence.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/silence.wav -------------------------------------------------------------------------------- /test/audio/too-short.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/too-short.mp3 -------------------------------------------------------------------------------- /test/audio/orig/padded.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/orig/padded.flac -------------------------------------------------------------------------------- /test/audio/orig/silence.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/orig/silence.flac -------------------------------------------------------------------------------- /test/audio/padded-stereo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/padded-stereo.mp3 -------------------------------------------------------------------------------- /test/audio/silence-32kHz.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/silence-32kHz.mp3 -------------------------------------------------------------------------------- /doc/simulation-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/doc/simulation-screenshot.png -------------------------------------------------------------------------------- /test/audio/orig/too-short.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/orig/too-short.flac -------------------------------------------------------------------------------- /test/audio/silence-112kbps.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/silence-112kbps.mp3 -------------------------------------------------------------------------------- /test/audio/interleaved-stereo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/interleaved-stereo.mp3 -------------------------------------------------------------------------------- /test/audio/not-an-audio-file.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/not-an-audio-file.mp3 -------------------------------------------------------------------------------- /test/audio/orig/interleaved.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/orig/interleaved.flac -------------------------------------------------------------------------------- /test/audio/orig/padded-end.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/orig/padded-end.flac -------------------------------------------------------------------------------- /test/audio/orig/padded-start.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/orig/padded-start.flac -------------------------------------------------------------------------------- /test/audio/orig/sine-unicode.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/orig/sine-unicode.flac -------------------------------------------------------------------------------- /test/audio/padded-end-stereo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/padded-end-stereo.mp3 -------------------------------------------------------------------------------- /test/audio/padded-jointstereo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/padded-jointstereo.mp3 -------------------------------------------------------------------------------- /test/audio/silence-stripped.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/silence-stripped.mp3 -------------------------------------------------------------------------------- /test/audio/sine-unicode-mono.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/sine-unicode-mono.mp3 -------------------------------------------------------------------------------- /test/audio/padded-start-stereo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/padded-start-stereo.mp3 -------------------------------------------------------------------------------- /test/audio/sine-unicode-stereo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/sine-unicode-stereo.mp3 -------------------------------------------------------------------------------- /test/audio/interleaved-jointstereo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/interleaved-jointstereo.mp3 -------------------------------------------------------------------------------- /test/audio/orig/padded-start-long.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/orig/padded-start-long.flac -------------------------------------------------------------------------------- /test/audio/orig/silence-unicode.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/orig/silence-unicode.flac -------------------------------------------------------------------------------- /test/audio/padded-end-jointstereo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/padded-end-jointstereo.mp3 -------------------------------------------------------------------------------- /test/audio/silence-unicode-stereo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/silence-unicode-stereo.mp3 -------------------------------------------------------------------------------- /test/audio/padded-start-jointstereo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/padded-start-jointstereo.mp3 -------------------------------------------------------------------------------- /test/audio/sine-unicode-jointstereo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/sine-unicode-jointstereo.mp3 -------------------------------------------------------------------------------- /test/audio/orig/unpadded-interleaved.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/orig/unpadded-interleaved.flac -------------------------------------------------------------------------------- /test/audio/silence-unicode-jointstereo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/silence-unicode-jointstereo.mp3 -------------------------------------------------------------------------------- /test/audio/unpadded-interleaved-stereo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/unpadded-interleaved-stereo.mp3 -------------------------------------------------------------------------------- /test/audio/unpadded-interleaved-jointstereo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiorabe/klangbecken/HEAD/test/audio/unpadded-interleaved-jointstereo.mp3 -------------------------------------------------------------------------------- /doc/systemd/klangbecken-fsck.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Run klangbecken-fsck.service daily 3 | 4 | [Timer] 5 | OnCalendar=daily 6 | 7 | [Install] 8 | WantedBy=timers.target 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | klangbecken.egg-info/ 3 | .tox/ 4 | .venv/ 5 | .vscode/ 6 | .pytest_cache/ 7 | **/__pycache__ 8 | .coverage 9 | .pre-commit-config.yaml 10 | *.pyc 11 | *.bak 12 | *~ 13 | -------------------------------------------------------------------------------- /doc/systemd/klangbecken-reload-jingles.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Run klangbecken-reload-jingles.service daily 3 | 4 | [Timer] 5 | OnCalendar=*-*-* *:05:30 6 | 7 | [Install] 8 | WantedBy=timers.target 9 | -------------------------------------------------------------------------------- /doc/systemd/klangbecken-disable-expired.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Run klangbecken-disable-expired.service daily 3 | 4 | [Timer] 5 | OnCalendar=*-*-* *:05:00 6 | 7 | [Install] 8 | WantedBy=timers.target 9 | -------------------------------------------------------------------------------- /doc/systemd/liquidsoap@klangbecken.service.d/overrides.conf: -------------------------------------------------------------------------------- 1 | [Service] 2 | EnvironmentFile=/etc/klangbecken.conf 3 | IOSchedulingClass=best-effort 4 | CPUSchedulingPolicy=rr 5 | IOSchedulingPriority=1 6 | CPUSchedulingPriority=90 7 | -------------------------------------------------------------------------------- /doc/systemd/klangbecken-disable-expired.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Disable expired Klangbecken tracks 3 | 4 | [Service] 5 | Type=simple 6 | EnvironmentFile=/etc/klangbecken.conf 7 | ExecStart=/bin/bash -c '${KLANGBECKEN_COMMAND} disable-expired -d ${KLANGBECKEN_DATA_DIR}' 8 | 9 | [Install] 10 | WantedBy=multi-user.target 11 | -------------------------------------------------------------------------------- /doc/systemd/klangbecken-fsck.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Klangbecken data directory consistency check 3 | 4 | [Service] 5 | Type=simple 6 | EnvironmentFile=/etc/klangbecken.conf 7 | ExecStart=/bin/bash -c '${KLANGBECKEN_COMMAND} fsck -d ${KLANGBECKEN_DATA_DIR} 2> ${KLANGBECKEN_DATA_DIR}/log/fsck.log' 8 | 9 | [Install] 10 | WantedBy=multi-user.target 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | 5 | # Maintain dependencies for GitHub Actions 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | interval: "daily" 10 | 11 | # Maintain dependencies for pip 12 | - package-ecosystem: "pip" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | -------------------------------------------------------------------------------- /doc/systemd/klangbecken.env: -------------------------------------------------------------------------------- 1 | KLANGBECKEN_DATA_DIR=/var/lib/klangbecken 2 | KLANGBECKEN_COMMAND=/usr/local/venvs/klangbecken-py39/bin/klangbecken 3 | KLANGBECKEN_PLAYER_SOCKET=/var/run/liquidsoap/klangbecken.sock 4 | KLANGBECKEN_ALSA_DEVICE=default:CARD=Axia 5 | KLANGBECKEN_EXTERNAL_PLAY_LOGGER=/usr/local/bin/send-now-playing.sh {title} {artist} {play_count} 6 | LANG=en_US.UTF-8 7 | -------------------------------------------------------------------------------- /doc/systemd/klangbecken-reload-jingles.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Reload Jingles playlist after expire to workaround missing inotify support 3 | 4 | [Service] 5 | Type=simple 6 | EnvironmentFile=/etc/klangbecken.conf 7 | ExecStart=/bin/bash -c 'echo "jingles.reload" | nc -U ${KLANGBECKEN_PLAYER_SOCKET}' 8 | User=liquidsoap 9 | Group=liquidsoap 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /doc/klangbecken_api.wsgi: -------------------------------------------------------------------------------- 1 | from klangbecken.api import klangbecken_api 2 | 3 | with open("/etc/klangbecken.conf") as f: 4 | config = dict( 5 | line.rstrip()[len("KLANGBECKEN_") :].split("=", 1) 6 | for line in f.readlines() 7 | if line.startswith("KLANGBECKEN_") 8 | ) 9 | 10 | application = klangbecken_api( 11 | config["API_SECRET"], config["DATA_DIR"], config["PLAYER_SOCKET"] 12 | ) 13 | -------------------------------------------------------------------------------- /doc/klangbecken.conf: -------------------------------------------------------------------------------- 1 | KLANGBECKEN_DATA_DIR=/var/lib/klangbecken 2 | KLANGBECKEN_COMMAND=/usr/local/venvs/klangbecken-py39/bin/klangbecken 3 | KLANGBECKEN_PLAYER_SOCKET=/var/run/liquidsoap/klangbecken.sock 4 | KLANGBECKEN_ALSA_DEVICE=default:CARD=Axia 5 | KLANGBECKEN_EXTERNAL_PLAY_LOGGER=/usr/local/bin/send-now-playing.sh {title} {artist} {play_count} 6 | KLANGBECKEN_API_SECRET=******************************************************** 7 | LANG=en_US.UTF-8 8 | -------------------------------------------------------------------------------- /doc/systemd/liquidsoap@.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Liquidsoap 3 | Documentation=http://liquidsoap.fm man:liquidsoap(1) 4 | Requires=sound.target 5 | Wants=network-online.target 6 | After=network-online.target sound.target 7 | 8 | [Service] 9 | PermissionsStartOnly=true 10 | ExecStartPre=-/bin/mkdir -p /var/log/liquidsoap 11 | ExecStartPre=-/bin/chown liquidsoap /var/log/liquidsoap 12 | ExecStart=/usr/bin/liquidsoap -v /etc/liquidsoap/%i.liq 13 | User=liquidsoap 14 | Group=liquidsoap 15 | 16 | [Install] 17 | WantedBy=multi-user.target 18 | -------------------------------------------------------------------------------- /doc/systemd/virtual-saemubox.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Virtual Saemubox Service 3 | After=liquidsoap@klangbecken.service 4 | 5 | [Service] 6 | Type=simple 7 | User=liquidsoap 8 | Group=liquidsoap 9 | ExecStart=/usr/local/bin/virtual-saemubox --udp=false --socket=true --socket-path=/var/run/liquidsoap/klangbecken.sock --socket-pattern "klangbecken.on_air %%v\n" --pathfinder=pathfinder-01.audio.int.rabe.ch:9600 --pathfinder-auth "Admin 720d1b70409186e4775b3f2f082753bb" 10 | StandardOutput=syslog 11 | StandardError=syslog 12 | Restart=always 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /doc/send-now-playing.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ssh klangbecken@vm-0020.vm-admin.int.rabe.ch -p 8093 'cat > Eingang/now-playing.xml' < 5 | 6 | 7 | <![CDATA[$1]]> 8 | 9 | 10 | Other 11 | MPEG-Audiodatei 12 | 1 13 | 14 | 15 | 16 | 17 | 320 18 | 19 | 20 | 21 | $3 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | EOF 31 | -------------------------------------------------------------------------------- /doc/simulation-scripts/simulate-playlist-reloads.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import random 3 | import sys 4 | import time 5 | 6 | here = pathlib.Path(__file__).parent.resolve() 7 | root = here.parent.parent 8 | sys.path.append(str(root)) 9 | 10 | from klangbecken.player import LiquidsoapClient # noqa: E402 11 | 12 | print("Simulating random playlist reloads") 13 | print("----------------------------------") 14 | 15 | 16 | client = LiquidsoapClient(str(root / "klangbecken.sock")) 17 | 18 | intervals = (1, 1, 2, 2, 3, 3, 36, 37, 38, 800, 850, 900, 1000, 1100, 1200) 19 | playlists = "music classics jingles".split() 20 | 21 | i = 0 22 | while True: 23 | i += 1 24 | with client: 25 | client.command(f"{random.choice(playlists)}.reload") 26 | print(".", end="", flush=True) 27 | 28 | if i % 24 == 0: 29 | print() 30 | 31 | time.sleep(random.choice(intervals)) 32 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-20.04 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: '3.x' 18 | 19 | - name: Install additional Python interpreters 20 | run: | 21 | sudo add-apt-repository ppa:deadsnakes/ppa 22 | sudo apt-get update 23 | sudo apt-get install python3.9 python3.9-distutils python3.10 python3.10-distutils python3.11 python3.12 24 | 25 | - name: Install ffmpeg 26 | run: sudo apt-get install ffmpeg 27 | 28 | - name: Install tox 29 | run: | 30 | python -m pip install --upgrade pip 31 | python -m pip install tox 32 | 33 | - name: Run tox 34 | run: python -m tox --skip-missing false 35 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | ####################################################################### 2 | [isort] 3 | multi_line_output=3 4 | include_trailing_comma=True 5 | force_grid_wrap=0 6 | combine_as_imports=True 7 | line_length=88 8 | 9 | ####################################################################### 10 | [flake8] 11 | extend-ignore=E203 12 | max-line-length=88 13 | exclude=.venv,.tox 14 | max-complexity=10 15 | 16 | ####################################################################### 17 | [tox:tox] 18 | skip_missing_interpreters = True 19 | envlist = begin, py39, py310, py311, py312, flake8, coverage 20 | 21 | [testenv:begin] 22 | commands = coverage erase 23 | 24 | [testenv] 25 | deps = -rrequirements-test.txt 26 | commands = coverage run -m unittest discover 27 | 28 | [testenv:flake8] 29 | commands = flake8 . 30 | 31 | [testenv:coverage] 32 | commands = coverage report 33 | 34 | ####################################################################### 35 | [coverage:report] 36 | include=klangbecken/*.py 37 | ignore_errors = True 38 | show_missing = True 39 | # 100% of the code must be covered by unit tests 40 | fail_under = 100 41 | -------------------------------------------------------------------------------- /doc/simulation-scripts/simulate-saemubox.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import random 3 | import sys 4 | import time 5 | 6 | here = pathlib.Path(__file__).parent.resolve() 7 | root = here.parent.parent 8 | sys.path.append(str(root)) 9 | 10 | from klangbecken.player import LiquidsoapClient # noqa: E402 11 | 12 | print("Simulating the virtual Sämubox") 13 | print("------------------------------", end="") 14 | 15 | on_air = True 16 | duration = 3 17 | 18 | ls_client = LiquidsoapClient(str(root / "klangbecken.sock")) 19 | with ls_client: 20 | ls_client.command("klangbecken.on_air True") 21 | 22 | i = 0 23 | while True: 24 | if i % 24 == 0: 25 | print(f"\nDay {i//24 + 1: >3}: ", end="") 26 | i += 1 27 | duration -= 1 28 | 29 | if on_air: 30 | print("+", end="", flush=True) 31 | else: 32 | print("_", end="", flush=True) 33 | 34 | if duration == 0: 35 | on_air = not on_air 36 | with ls_client: 37 | ls_client.command(f"klangbecken.on_air {on_air}") 38 | 39 | if on_air: 40 | duration = random.choice([1, 2, 3, 3, 4, 4, 5, 6]) 41 | else: 42 | duration = random.choice([1, 1, 2, 2, 3, 4]) 43 | 44 | time.sleep(36) # Sleep for one "hour" 45 | -------------------------------------------------------------------------------- /.github/workflows/liquidsoap-script.yml: -------------------------------------------------------------------------------- 1 | name: Liquidsoap script 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | env: 8 | KLANGBECKEN_DATA: /tmp/data 9 | 10 | jobs: 11 | check: 12 | strategy: 13 | matrix: 14 | liquidsoap: [1.3.2, 1.3.7] 15 | 16 | runs-on: ubuntu-20.04 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: '3.x' 25 | 26 | - name: Install Python dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install -r requirements.txt 30 | 31 | - name: Docker pull 32 | run: docker pull radiorabe/liquidsoap:alpine-${{matrix.liquidsoap}} 33 | 34 | - name: Check liquidsoap version 35 | run: docker run --rm radiorabe/liquidsoap:alpine-${{matrix.liquidsoap}} --version 36 | 37 | - name: Prepare data directory 38 | run: python -m klangbecken init -d $KLANGBECKEN_DATA 39 | 40 | - name: Docker run check 41 | run: | 42 | docker run --rm -e KLANGBECKEN_DATA=$KLANGBECKEN_DATA -v $KLANGBECKEN_DATA:$KLANGBECKEN_DATA -v `pwd`:/var/lib/liquidsoap radiorabe/liquidsoap:alpine-${{matrix.liquidsoap}} --check /var/lib/liquidsoap/klangbecken.liq 43 | -------------------------------------------------------------------------------- /test/utils.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import io 3 | import sys 4 | 5 | 6 | @contextlib.contextmanager 7 | def capture(command, *args, **kwargs): 8 | out, sys.stdout = sys.stdout, io.StringIO() 9 | err, sys.stderr = sys.stderr, io.StringIO() 10 | commandException = None 11 | contextException = None 12 | ret = None 13 | try: 14 | try: 15 | ret = command(*args, **kwargs) 16 | except BaseException as e: 17 | # Catch any exception, store it for now, and first capture 18 | # all the output, before re-raising the exception. 19 | commandException = e 20 | sys.stdout.seek(0) 21 | sys.stderr.seek(0) 22 | out_data = sys.stdout.read() 23 | err_data = sys.stderr.read() 24 | sys.stdout = out 25 | sys.stderr = err 26 | try: 27 | yield out_data, err_data, ret 28 | except BaseException as e: 29 | # Catch any exception thrown from within the context manager 30 | # (often unittest assertions), and re-raise it later unmodified. 31 | contextException = e 32 | finally: 33 | # Do not ignore exceptions from within the context manager, 34 | # in case of a deliberately failing command. 35 | # Thus, prioritize contextException over commandException 36 | if contextException: 37 | raise contextException 38 | elif commandException: 39 | raise commandException 40 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from setuptools import find_packages, setup 4 | 5 | here = pathlib.Path(__file__).parent.resolve() 6 | long_description = (here / "README.md").read_text(encoding="utf-8") 7 | 8 | setup( 9 | name="klangbecken", 10 | version="0.1.1", 11 | description="Klangbecken Audio Player", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/radiorabe/klangbecken", 15 | author="Marco Schmalz", 16 | author_email="marco@schess.ch", 17 | packages=find_packages(include=["klangbecken"]), 18 | platforms="linux", 19 | python_requires=">=3.6", 20 | license="AGPLv3", 21 | license_file="LICENSE", 22 | classifiers=[ 23 | "Development Status :: 4 - Beta", 24 | "Environment :: Web Environment", 25 | "Intended Audience :: Developers", 26 | "License :: OSI Approved :: GNU Affero General Public License v3", 27 | "Operating System :: POSIX", 28 | "Programming Language :: Python :: 3", 29 | "Programming Language :: Python :: 3 :: Only", 30 | "Programming Language :: Python :: 3.7", 31 | "Programming Language :: Python :: 3.8", 32 | "Programming Language :: Python :: 3.9", 33 | "Programming Language :: Python :: 3.10", 34 | "Topic :: Internet :: WWW/HTTP :: WSGI", 35 | "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", 36 | ], 37 | install_requires=["docopt", "mutagen", "PyJWT >= 2.0.0", "Werkzeug >= 2.0.0"], 38 | extras_require={ 39 | "dev": ["tox", "black", "isort"], 40 | "test": ["flake8", "coverage"], 41 | }, 42 | entry_points={"console_scripts": ["klangbecken=klangbecken.cli:main"]}, 43 | ) 44 | -------------------------------------------------------------------------------- /doc/authentication-middleware.md: -------------------------------------------------------------------------------- 1 | # Authentication Middleware 2 | 3 | As an alternative to using PAM for authentication you can write your own wsgi middleware. 4 | 5 | Here is some minimalistic sample code: 6 | ```python 7 | from werkzeug.security import check_password_hash 8 | from werkzeug.wrappers import Request 9 | 10 | class PasswordFileAuthenticationMiddleware: 11 | def __init__(self, app, password_file): 12 | self.app = app 13 | self.password_file = password_file 14 | 15 | def __call__(self, environ, start_response): 16 | if environ["REQUEST_METHOD"] == "POST" and environ["PATH_INFO"] == "/auth/login/": 17 | request = Request(environ) 18 | username = request.form["login"] 19 | password = request.form["password"] 20 | with open(self.password_file) as f: 21 | passwords = dict(line.rstrip().split(":", maxsplit=1) for line in f) 22 | if username not in passwords: 23 | raise Unauthorized() 24 | if not check_password_hash(passwords[username], password): 25 | raise Unauthorized() 26 | environ["REMOTE_USER"] = username 27 | return self.app(environ, start_response) 28 | ``` 29 | 30 | To use it wrap the API `application` in your middleware at the very end of your wsgi file: 31 | ```python 32 | ... 33 | application = PasswordFileAuthenticationMiddleware(application, "/path/to/pwfile") 34 | ``` 35 | 36 | To generate password hashes use `werkzeug`'s helper function: 37 | ```python 38 | >>> from werkzeug.security import generate_password_hash 39 | >>> pwhash = generate_password_hash("love") 40 | >>> pwhash 41 | 'pbkdf2:sha256:260000$MMNfmCYMFGGVMBuL$d4d48bb539d111f42111e657d32346a203064255d3f36e0cc353b3f22ceddb20' 42 | >>> with open("/path/to/pwfile", "a") as f: 43 | ... print("john_doe", "pwhash", sep=":", file=f) 44 | ``` 45 | -------------------------------------------------------------------------------- /doc/simulation-scripts/generate-tracks.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | from random import choice, gauss, random 4 | 5 | from mutagen import File 6 | 7 | if not os.path.isdir("./sim-data"): 8 | os.mkdir("./sim-data") 9 | 10 | if len(os.listdir("./sim-data")) > 0: 11 | print("ERROR: 'sim-data' directory not empty") 12 | exit(1) 13 | 14 | colors = ("white", "pink", "brown", "blue", "violet", "velvet") 15 | bitrates = (128, 160, 192, 256, 320, 320, 320) 16 | samplerates = (44100, 48000) 17 | 18 | playlists = { 19 | "music": { 20 | "number_of_tracks": 1334, 21 | "length_avg": 2.3, 22 | "length_stdev": 0.7, 23 | "length_min": 0.3, 24 | "length_max": 6.1, 25 | "freq": 880, 26 | "generator": "sine=f={freq}:r={samplerate}:d={duration},stereotools", 27 | "title": "{i:0>4} ({duration:.2f} s, {freq:.0f} Hz)", 28 | }, 29 | "classics": { 30 | "number_of_tracks": 1280, 31 | "length_avg": 2.4, 32 | "length_stdev": 0.7, 33 | "length_min": 0.45, 34 | "length_max": 9.7, 35 | "freq": 440, 36 | "generator": "sine=f={freq}:r={samplerate}:d={duration},stereotools", 37 | "title": "{i:0>4} ({duration:.2f} s, {freq:.0f} Hz)", 38 | }, 39 | "jingles": { 40 | "number_of_tracks": 22, 41 | "length_avg": 0.12, 42 | "length_stdev": 0.17, 43 | "length_min": 0.03, 44 | "length_max": 0.8, 45 | "freq": 0, 46 | "generator": "anoisesrc=d={duration}:c={color}:r={samplerate},stereotools", 47 | "title": "{i:0>4} ({duration:.2f} s, {color} noise)", 48 | }, 49 | } 50 | 51 | for playlist, config in playlists.items(): 52 | for i in range(1, config["number_of_tracks"] + 1): 53 | duration = 0 54 | while not (config["length_min"] < duration < config["length_max"]): 55 | duration = gauss(config["length_avg"], config["length_stdev"]) 56 | 57 | freq = config["freq"] + (random() - 0.5) * 160 58 | color = choice(colors) 59 | bitrate = choice(bitrates) 60 | samplerate = choice(samplerates) 61 | filename = f"./sim-data/{playlist}-{i:0>4}.mp3" 62 | subprocess.check_call( 63 | [ 64 | *"ffmpeg -hide_banner -loglevel panic -filter_complex".split(), 65 | config["generator"].format(**config, **locals()), 66 | *"-c:a libmp3lame -b:a".split(), 67 | f"{bitrate}k", 68 | filename, 69 | ] 70 | ) 71 | f = File(filename, easy=True) 72 | f["artist"] = f"{playlist.capitalize()}" 73 | f["title"] = config["title"].format(**config, **locals()) 74 | f.save() 75 | -------------------------------------------------------------------------------- /klangbecken/settings.py: -------------------------------------------------------------------------------- 1 | import mutagen.mp3 2 | 3 | # Supported playlist names 4 | PLAYLISTS = ("music", "classics", "jingles") 5 | 6 | # Supported file types 7 | # Map file extension to mutagen class for all supported file types 8 | FILE_TYPES = { 9 | "mp3": mutagen.mp3.EasyMP3, 10 | } 11 | 12 | # Supported datetime format 13 | # ISO8601 datetime with optional fraction of a second (milli- or microseconds) and 14 | # mandatory timezone specification 15 | ISO8601_TZ_AWARE_RE = ( 16 | # Date 17 | r"(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T" 18 | # Time (optionally with a fraction of a second) 19 | r"(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(\.[0-9]+)?" 20 | # Timezone information (+/- offset from UTC) 21 | r"[+-](?:2[0-3]|[01][0-9]):[0-5][0-9]" 22 | ) 23 | 24 | # Supported Metadata 25 | # Keys map to type (and contract) checks. 26 | # A check can be a: 27 | # - Type class (e.g. float) 28 | # - Function taking one argument and returning True or False 29 | # (e.g. lambda x: x > 0 for positive numbers) 30 | # - String interpreted as a regular expression. The checked values are 31 | # expected to be strings. (e.g. r"[1-9][0-9]{3}" for four digit zip codes) 32 | # - List or tuple containing an arbitrary combination of the above. 33 | # 34 | # Note: The checks are evaluated in their specified order. 35 | METADATA = { 36 | "id": r"^[a-z0-9]{8}-([a-z0-9]{4}-){3}[a-z0-9]{12}$", 37 | "ext": (str, lambda ext: ext in FILE_TYPES.keys()), 38 | "playlist": (str, lambda pl: pl in PLAYLISTS), 39 | "original_filename": str, 40 | "import_timestamp": ISO8601_TZ_AWARE_RE, 41 | "weight": (int, lambda c: c >= 0), 42 | "artist": str, 43 | "title": str, 44 | "track_gain": r"^[+-]?[0-9]+(\.[0-9]*) dB$", 45 | "cue_in": (float, lambda n: n >= 0.0), 46 | "cue_out": (float, lambda n: n >= 0.0), 47 | "play_count": (int, lambda n: n >= 0), 48 | "last_play": r"(^$)|(^{0}$)".format(ISO8601_TZ_AWARE_RE), 49 | "channels": (int, lambda n: n in (1, 2)), 50 | "samplerate": (int, lambda n: n in (44100, 48000)), 51 | "bitrate": (int, lambda n: n >= 128), 52 | "uploader": str, 53 | "expiration": r"(^$)|(^{0}$)".format(ISO8601_TZ_AWARE_RE), 54 | } 55 | 56 | # Metadata keys allowed for updates 57 | UPDATE_KEYS = "artist title weight expiration".split() 58 | 59 | # Metadata keys stored in the audio track (as ID3 tags) 60 | TAG_KEYS = "artist title cue_in cue_out track_gain original_filename last_play".split() 61 | 62 | # Metadata keys used for logging 63 | LOG_KEYS = "id playlist original_filename artist title play_count last_play".split() 64 | 65 | # Register additional ID3 tags 66 | mutagen.easyid3.EasyID3.RegisterTXXXKey(key="cue_in", desc="CUE_IN") 67 | mutagen.easyid3.EasyID3.RegisterTXXXKey(key="cue_out", desc="CUE_OUT") 68 | mutagen.easyid3.EasyID3.RegisterTXXXKey(key="track_gain", desc="REPLAYGAIN_TRACK_GAIN") 69 | mutagen.easyid3.EasyID3.RegisterTXXXKey(key="last_play", desc="LAST_PLAY") 70 | mutagen.easyid3.EasyID3.RegisterTXXXKey( 71 | key="original_filename", desc="ORIGINAL_FILENAME" 72 | ) 73 | -------------------------------------------------------------------------------- /doc/simulation-timing-changes.patch: -------------------------------------------------------------------------------- 1 | diff --git a/klangbecken.liq b/klangbecken.liq 2 | index ee300c3..997b229 100644 3 | --- a/klangbecken.liq 4 | +++ b/klangbecken.liq 5 | @@ -65,9 +65,10 @@ on_air = ref false 6 | 7 | # calculate waiting time for repeating a track depending on its playlist 8 | def calc_wait(playlist) = 9 | - if playlist == "music" then 172800.0 # 2 days 10 | - elsif playlist == "classics" then 172800.0 # 2 days 11 | - elsif playlist == "jingles" then 3600.0 # 1 hour 12 | + # WARNING: do not commit 13 | + if playlist == "music" then 1728.0 # 2 days / 100 14 | + elsif playlist == "classics" then 1728.0 # 2 days / 100 15 | + elsif playlist == "jingles" then 36.0 # 1 hour / 100 16 | else 17 | log("WARNING: invalid playlist: #{playlist}", level=1, label="calc_wait") 18 | 0.0 19 | @@ -172,7 +173,8 @@ music = random(weights=[5, 1], [music, classics]) 20 | insert_jingle = ref false 21 | 22 | def jingle_timeout() = 23 | - jingle_times = [5m0s, 20m0s, 35m0s, 50m0s] 24 | + # WARNING: do not commit 25 | + jingle_times = [0s, 10s, 20s, 30s, 40s, 50s] #[5m0s, 20m0s, 35m0s, 50m0s] 26 | if list.fold(fun (a,b) -> a or b, false, jingle_times) then 27 | log("Jingle up next", label="jingle_timeout") 28 | insert_jingle := true 29 | @@ -232,8 +234,9 @@ server.register( 30 | 31 | # Have restart delay and fade dynamically reconfigurable 32 | # for debugging purpose 33 | -restart_delay = interactive.float("restart.delay", 1.0) 34 | -restart_fade = interactive.float("restart.fade", 1.0) 35 | +# WARNING: do not commit 36 | +restart_delay = interactive.float("restart.delay", .01) 37 | +restart_fade = interactive.float("restart.fade", .01) 38 | 39 | def trans(old, new) = 40 | if !restart and source.id(new) == "radio" then 41 | @@ -286,7 +289,8 @@ exec_at(pred=fun() -> list.length(!to_log_filenames) > 0, run_play_logger) 42 | # Apply calculated replay gain 43 | radio = amplify(1., override="replaygain_track_gain", radio) 44 | # Moderate cross-fading 45 | -radio = crossfade(start_next=.5, fade_out=1., fade_in=0., radio) 46 | +# WARNING: do not commit 47 | +radio = crossfade(start_next=.005, fade_out=.01, fade_in=0., radio) 48 | 49 | 50 | # ================================================= # 51 | diff --git a/klangbecken/playlist.py b/klangbecken/playlist.py 52 | index c72712d..f858796 100644 53 | --- a/klangbecken/playlist.py 54 | +++ b/klangbecken/playlist.py 55 | @@ -187,11 +187,12 @@ def ffmpeg_audio_analyzer(playlist, fileId, ext, filename): 56 | # Extract cue points 57 | cue_in, cue_out = _extract_cue_points(output) 58 | 59 | - duration = cue_out - cue_in 60 | - if playlist != "jingles" and duration < 5.0: 61 | - raise UnprocessableEntity(f"Track too short: {duration} < 5 seconds") 62 | - elif playlist == "jingles" and duration < 0.5: 63 | - raise UnprocessableEntity(f"Track too short: {duration} < 0.5 seconds") 64 | + # WARNING: do not commit 65 | + # duration = cue_out - cue_in 66 | + # if playlist != "jingles" and duration < 5.0: 67 | + # raise UnprocessableEntity(f"Track too short: {duration} < 5 seconds") 68 | + # elif playlist == "jingles" and duration < 0.5: 69 | + # raise UnprocessableEntity(f"Track too short: {duration} < 0.5 seconds") 70 | 71 | return [ 72 | MetadataChange("channels", channels), 73 | -------------------------------------------------------------------------------- /doc/data-dir.md: -------------------------------------------------------------------------------- 1 | # Data Directory 2 | 3 | ## Audio Files 4 | 5 | Every track is identified by 6 | * a playlist, in which it appears 7 | * a UUID 8 | * a valid file extension 9 | 10 | For every playlist, there is a directory containing all the audio files. A track cannot be shared between multiple playlists. 11 | 12 | When uploading a new audio track, the file is temporarily stored in the `upload` directory for analysis. Upon success, the file is then moved to the corresponding playlist folder. The UUID and extension are used for it's filename. 13 | 14 | Apart from track title and artist name, additional metadata information is stored directly in the audio files metadata tags: 15 | * Cue points (`cue_in` and `cue_out`) 16 | * Loudness information (`track_gain`) 17 | * A timestamp of the last play (`last_play`) 18 | * The original filename (`original_filename`) 19 | 20 | The cue points, loudness information, and last play timestamp are used by the liquidsoap player for playback. The original filename is stored for debugging purpose. 21 | 22 | Audio file metadata tags can be extracted with the `mutagen-inspect` command line util. 23 | 24 | ## Playlist Files 25 | 26 | Tracks can be _activated_ and _deactivated_ by adding them to the playlist file of the corresponding playlist. A track can be added multiple times to the playlist file to increase it's priority (or `weight`). 27 | 28 | The playlist file is a simple text file in the M3U format. An entry in the playlist is a single line, with the relative path to the audio track. There is one playlist file for every playlist. 29 | 30 | ## Log Files 31 | 32 | The `log` directory contains monthly play log files in the CVS format. The file UUID, playlist name, original filename, artist name, track title, total play count, and last play timestamp are logged. 33 | 34 | ## Metadata Cache 35 | 36 | The file `index.json` caches all metadata information in the JSON format. 37 | 38 | For every track the following information is stored in an object under the file UUID key: 39 | - `id`: File UUID 40 | - `ext`: File type/extension (currently only `mp3` is supported) 41 | - `playlist`: Name of the playlist 42 | - `original_filename`: Original filename of the uploaded file 43 | - `import_timestamp`: Date and time when the file was uploaded or imported (ISO8601 timestamp) 44 | - `weight`: Priority or weight of the track, or how many times it appears in the playlist file (can be zero) 45 | - `artist`: Artist name 46 | - `title`: Track title 47 | - `track_gain`: ReplayGain track gain (in dB) 48 | - `cue_in`: Cue in point (in seconds) 49 | - `cue_out`: Cue out point (in seconds) 50 | - `play_count`: Total play count 51 | - `last_play`: Date and time of the last play if the track has been played at least once (ISO8601 timestamp or empty string) 52 | - `channels`: Number of channels (1: mono, 2: stereo) 53 | - `samplerate`: Sample rate (44.1 or 48 kHz) 54 | - `bitrate`: Bitrate of the encoded stream 55 | - `uploader`: Username of the person uploading the file 56 | - `expiration`: Date and time after which this track should be disabled (ISO8601 timestamp or empty string) 57 | 58 | Information in the metadata cache (except for the `uploader` and `expiration` field) can be be restored or recalculated from the audio, playlist and log files. 59 | 60 | The [`fsck` command](cli.md) can be used to verify the consistency of the metadata cache. 61 | -------------------------------------------------------------------------------- /test/test_cli_init.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import shutil 4 | import tempfile 5 | import unittest 6 | from unittest import mock 7 | 8 | from .utils import capture 9 | 10 | 11 | class InitCmdTestCase(unittest.TestCase): 12 | def setUp(self): 13 | self.tempdir = tempfile.mkdtemp() 14 | 15 | def tearDown(self): 16 | shutil.rmtree(self.tempdir) 17 | 18 | def testInitCmd(self): 19 | from klangbecken.cli import main 20 | 21 | path = os.path.join(self.tempdir, "data") 22 | with mock.patch("sys.argv", f"klangbecken init -d {path}".split()): 23 | main() 24 | self.assertTrue(os.path.exists(path)) 25 | 26 | with open(os.path.join(path, "file"), "w"): 27 | pass 28 | 29 | with self.assertRaises(SystemExit): 30 | with mock.patch("sys.argv", f"klangbecken init -d {path}".split()): 31 | with capture(main) as (out, err, ret): 32 | self.assertIn("ERROR", err) 33 | self.assertIn("not empty", err) 34 | 35 | path = os.path.join(path, "file") 36 | 37 | with self.assertRaises(SystemExit): 38 | with mock.patch("sys.argv", f"klangbecken init -d {path}".split()): 39 | with capture(main) as (out, err, ret): 40 | self.assertIn("ERROR", err) 41 | self.assertIn("not a directory", err) 42 | 43 | def testDataDirCheckOnly(self): 44 | from klangbecken.cli import _check_data_dir 45 | from klangbecken.settings import PLAYLISTS 46 | 47 | for playlist in PLAYLISTS + ("log", "upload"): 48 | path = os.path.join(self.tempdir, playlist) 49 | with self.assertRaises(Exception) as cm: 50 | _check_data_dir(self.tempdir, False) 51 | self.assertIn("Directory", cm.exception.args[0]) 52 | self.assertIn("does not exist", cm.exception.args[0]) 53 | os.mkdir(path) 54 | 55 | for playlist in PLAYLISTS: 56 | path = os.path.join(self.tempdir, playlist + ".m3u") 57 | with self.assertRaises(Exception) as cm: 58 | _check_data_dir(self.tempdir, False) 59 | self.assertIn("Playlist", cm.exception.args[0]) 60 | self.assertIn("does not exist", cm.exception.args[0]) 61 | with open(path, "a"): 62 | pass 63 | 64 | with self.assertRaises(Exception) as cm: 65 | _check_data_dir(self.tempdir, False) 66 | self.assertIn("File", cm.exception.args[0]) 67 | self.assertIn("does not exist", cm.exception.args[0]) 68 | 69 | with open(os.path.join(self.tempdir, "index.json"), "w"): 70 | pass 71 | 72 | _check_data_dir(self.tempdir, False) 73 | 74 | def testDataDirCreation(self): 75 | from klangbecken.cli import _check_data_dir 76 | from klangbecken.settings import PLAYLISTS 77 | 78 | _check_data_dir(self.tempdir, create=True) 79 | for playlist in PLAYLISTS: 80 | path = os.path.join(self.tempdir, playlist) 81 | self.assertTrue(os.path.isdir(path)) 82 | path += ".m3u" 83 | self.assertTrue(os.path.isfile(path)) 84 | 85 | path = os.path.join(self.tempdir, "index.json") 86 | self.assertTrue(os.path.isfile(path)) 87 | with open(path) as f: 88 | self.assertEqual(json.load(f), {}) 89 | -------------------------------------------------------------------------------- /doc/cli.md: -------------------------------------------------------------------------------- 1 | # Command Line Interface 2 | 3 | The command line tool can be called with Python `python -m klangbecken` or as a stand-alone command `klangbecken` when the package is installed. 4 | 5 | ``` 6 | Klangbecken audio playout system. 7 | 8 | Usage: 9 | klangbecken (--help | --version) 10 | klangbecken init [-d DATA_DIR] 11 | klangbecken serve [-d DATA_DIR] [-p PORT] [-b ADDRESS] [-s PLAYER_SOCKET] 12 | klangbecken import [-d DATA_DIR] [-y] [-m] [-M FILE] PLAYLIST FILE... 13 | klangbecken fsck [-d DATA_DIR] 14 | klangbecken playlog [-d DATA_DIR] FILE 15 | klangbecken reanalyze [-d DATA_DIR] [-y] (--all | ID...) 16 | klangbecken disable-expired [-d DATA_DIR] 17 | 18 | Options: 19 | -h, --help 20 | Show this help message and exit. 21 | --version 22 | Show version and exit. 23 | -d DIR, --data=DIR 24 | Set data directory location [default: ./data/]. 25 | -p PORT, --port=PORT 26 | Specify alternate port [default: 5000]. 27 | -b ADDRESS, --bind=ADDRESS 28 | Specify alternate bind address [default: localhost]. 29 | -s PLAYER_SOCKET, --socket=PLAYER_SOCKET 30 | Set the location or address of the liquisoap player socket. 31 | This can either be the path to a UNIX domain socket file or 32 | a domain name and port seperated by a colon (e.g. localhost:123) 33 | [default: ./klangbecken.sock] 34 | -y, --yes 35 | Automatically answer yes to all questions. 36 | -m, --mtime 37 | Use file modification date as import timestamp. 38 | -M FILE, --meta=FILE 39 | Read metadata from JSON file. Files without entries are skipped. 40 | --all 41 | Reanalyze all files. 42 | ``` 43 | 44 | ### `init` 45 | 46 | Initialize the data directory by creating empty playlist files, playlist folders and other default files. 47 | 48 | ### `serve` 49 | 50 | Run the development server. Serves the API from `/api` and the static files from the data directory from `/data`. 51 | 52 | ### `import` 53 | 54 | Batch import audio files to the specified playlist. Artist and title metadata can be supplied from a JSON file mapping filenames to a metadata dict. E.g. 55 | ```json 56 | { 57 | "importfolder/xyz.mp3": {"artist": "Wildecker Herzbuam", "title": "Herzilein"}, 58 | "..." 59 | } 60 | ``` 61 | Files that have no entry in the metadata file are skipped. 62 | 63 | ### `fsck` 64 | 65 | Validate the `index.json` metadata cache integrity. 66 | 67 | ### `playlog` 68 | 69 | Log the airing of a track. This command is called from the liquidsoap player. It updates the `last_play` and `play_count` metadata, appends a row to the monthly play log in `log/`, and calls the external play logger if configured. 70 | 71 | If the environment variable `KLANGBECKEN_EXTERNAL_PLAY_LOGGER` is set, it will be used to call an external play logger. This can for example be used publish the information in a song ticker on a public web site. The command will be interpreted as a formatting string. All supported metadata keys are available (see [settings.py](../klangbecken/settings.py)). 72 | 73 | Example: 74 | ```bash 75 | KLANGBECKEN_EXTERNAL_PLAY_LOGGER="/usr/local/bin/myscript.sh {playlist} {id} {artist} {title}" 76 | ``` 77 | 78 | ### `reanalyze` 79 | 80 | Re-run the audio analyzer for the specified files. 81 | 82 | ### `disable-expired` 83 | 84 | Disable expired tracks, by removing their entries from the playlist file. This command is usually called at least once a day as a cron job or similar. 85 | -------------------------------------------------------------------------------- /doc/additional-tools.md: -------------------------------------------------------------------------------- 1 | # Additional Tools 2 | 3 | ## Working with Python's virtual environments 4 | 5 | ### virtualenvwrapper 6 | 7 | The `virtualenvwrapper` package supports managing many virtual environments. See their [documentation page](https://virtualenvwrapper.readthedocs.io/en/latest/index.html) for more information. 8 | 9 | ### Standard library `venv` package 10 | 11 | Since Python 3.3 the standard library contains the `venv` package supporting the creation of virtual environments. We recommend to create the virtual environment in the root directory of your project and naming it either `.venv` or `venv` (see [The Hichhiker's Guide to Python](https://docs.python-guide.org/dev/virtualenvs/#basic-usage)). 12 | 13 | Creating a virtual environment: 14 | ```bash 15 | python -m venv .venv 16 | ``` 17 | 18 | Activate the virtual environment: 19 | ```bash 20 | source .venv/bin/activate 21 | ``` 22 | 23 | Deactivate the virtual environment: 24 | ```bash 25 | deactivate 26 | ``` 27 | 28 | ### Automatically activate and deactivate virtual environments 29 | 30 | The following helper automatically activates a virtual environment when `cd`-ing into a directory with a accompanying virtual environment. For this to work, the virtual environment must be located in the root directory of your project and be named `.venv` or `venv`. Activation also works whe `cd`-ing into a subdirectory. Changing into a directory outside of your project's directory structure will automatically deactivate the virtual environment. 31 | 32 | Add the following lines to your `~/.bashrc`: 33 | ```bash 34 | _update_path() { 35 | # Activate python virtualenv if '.venv' or 'venv' directory exists 36 | P=$(pwd) 37 | while [[ "$P" != / ]]; do 38 | if [[ -d "$P/.venv" && -f "$P/.venv/bin/activate" ]]; then 39 | if [[ "$P/.venv" != "$VIRTUAL_ENV" ]]; then 40 | source $P/.venv/bin/activate 41 | fi 42 | FOUND_VENV=yes 43 | break 44 | fi 45 | if [[ -d "$P/venv" && -f "$P/venv/bin/activate" ]]; then 46 | if [[ "$P/venv" != "$VIRTUAL_ENV" ]]; then 47 | source $P/venv/bin/activate 48 | fi 49 | FOUND_VENV=yes 50 | break 51 | fi 52 | P=$(dirname "$P") 53 | done 54 | if [[ "$FOUND_VENV" != yes && -v VIRTUAL_ENV ]]; then 55 | deactivate 56 | fi 57 | 58 | unset FOUND_VENV 59 | unset P 60 | true 61 | } 62 | 63 | cd() { 64 | builtin cd "$@" 65 | _update_path 66 | } 67 | 68 | _update_path 69 | 70 | ``` 71 | 72 | ## Automatic code formatting 73 | 74 | #### Automate formatting using pre-commit 75 | 76 | After registering hooks with `install` pre-commit will abort commits if there are black, isort or flake8 changes to be made. If machine fixable (ie. black and isort) pre-commit usually applies those changes leaving you to stage them using `git add` before retrying your commit. 77 | 78 | 79 | ```bash 80 | pip install pre-commit 81 | pre-commit install 82 | ``` 83 | 84 | Store the following configuration in `.pre-commit-config.yaml`: 85 | ```yaml 86 | repos: 87 | - repo: local 88 | hooks: 89 | - id: black 90 | name: black 91 | language: system 92 | entry: black 93 | types: [python] 94 | - id: isort 95 | name: isort 96 | language: system 97 | entry: isort -y 98 | types: [python] 99 | - id: flake8 100 | name: flake8 101 | language: system 102 | entry: flake8 103 | types: [python] 104 | ``` 105 | 106 | You can also run black, isort and flake8 on all content without comitting: 107 | ```bash 108 | pre-commit run -a 109 | ``` 110 | -------------------------------------------------------------------------------- /doc/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | API endpoints by default accept and return data as JSON objects. 4 | 5 | ## Authorization and Authentication 6 | 7 | The API uses JSON web tokens (JWT) for authorizing access to non read-only endpoints. The underlying authentication method must be provided by intercepting `POST` requests to `/api/auth/login/`. The API does not specify a specific authentication method, like password-based or Kerberos, but expects to receive a valid `REMOTE_USER` string in the wsgi environment upon a successful authentication. 8 | 9 | Tokens are valid for 15 minutes. Valid tokens can be renewed indefinitely. Expired tokens can be renewed up to one week after first issuing them. 10 | 11 | **Base Path:** `/api/auth` 12 | 13 | Endpoint | Method | Description 14 | ---------|--------|------------ 15 | `/login` | `POST` | Login and return an newly created token. 16 | `/renew` | `POST` | Renew existing Token. 17 | 18 | Endpoint example: `/api/auth/renew` 19 | 20 | ## Playlist 21 | 22 | The playlist API allows editing static playlist entries. The allowed playlists and file formats are configured in [`klangbecken/settings.py`](../klangbecken/../klangbecken/settings.py). 23 | 24 | **Base Path:** `/api/playlist` 25 | 26 | Endpoint | Method | Description 27 | ---------|--------|------------ 28 | `//`| `POST`| Create new entry to a playlist by uploading an audio file. Returns all extracted and generated metadata. 29 | `//.`| `PUT`| Update playlist entry metadata. Allowed keys are `artist`, `title`, `weight`, and `expiration`. 30 | `//.`| `DELETE` | Delete playlist entry. 31 | 32 | Endpoint example: `/api/playlist/jingles/9967f00b-883a-4aa0-98e7-5085cdc380d3.mp3` 33 | 34 | Data types for playlist entry metadata updates: 35 | * **artist**: `string` 36 | * **title**: `string` 37 | * **weight**: `int` (>= 0) 38 | * **expiration**: empty or ISO8601 datetime `string` 39 | 40 | _Note for datetime strings_: The string _must_ specify a date _and_ a time including hours, minutes and seconds (fractions of a second are optional) separated by the letter `T`. Timezone information _must_ be provided, either as (+/-) offset from UTC or by the letter `Z` for UTC datetime strings. In JS `Date` objects are automatically converted to compatible UTC datetime strings by `JSON.stringify` using `Date.prototype.toJSON()`. 41 | 42 | ## Player 43 | 44 | The player API allows getting information about the running audio player and edit a "play next" queue. 45 | 46 | **Base Path:** `/api/player` 47 | 48 | Endpoint | Method | Description 49 | ---------|--------|------------ 50 | `/`| `GET`| Get player information. 51 | `/reload/`| `POST`| Force player to reload a playlist (usually after modifications). 52 | `/queue/` | `GET` | List queue entries. 53 | `/queue/` | `POST` | Add audio track to queue. Requires a `filename` argument (string in the format `/.`) and returns the assigned `queue_id`. 54 | `/queue/` | `DELETE` | Delete queue entry. 55 | 56 | 57 | Endpoint example: `/api/player/queue/15` 58 | 59 | ## Static Data 60 | 61 | `/data` provides read-only access to the data directory containing the audio, playlist and log files. The metadata cache `index.json` speeds up client operation, by removing the need to perform client-side audio file metadata parsing. 62 | 63 | **Base Path:** `/data` 64 | 65 | Endpoint | Description | Example 66 | ---------|-------------|--------- 67 | `/index.json` | Metadata cache | 68 | `/.m3u` | Playlist files | `/data/music.m3u` 69 | `//.`| Audio files | `/data/jingles/9967f00b-883a-4aa0-98e7-5085cdc380d3.mp3` 70 | `/log/-.csv`| Monthly play log | `/data/log/2020-08.csv` 71 | `/log/fsck.log`| Nightly `fsck` run output | 72 | -------------------------------------------------------------------------------- /test/test_cli_playlog.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import datetime 3 | import json 4 | import os 5 | import shutil 6 | import sys 7 | import tempfile 8 | import unittest 9 | from unittest import mock 10 | 11 | from mutagen import File 12 | 13 | from .utils import capture 14 | 15 | 16 | class PlaylogCmdTestCase(unittest.TestCase): 17 | def setUp(self): 18 | from klangbecken.cli import _check_data_dir, import_cmd 19 | 20 | self.current_path = os.path.dirname(os.path.realpath(__file__)) 21 | self.data_dir = tempfile.mkdtemp() 22 | _check_data_dir(self.data_dir, create=True) 23 | 24 | self.playlog_script = os.path.join(self.data_dir, "playlog.sh") 25 | self.out_file = os.path.join(self.data_dir, "out.txt") 26 | 27 | # create simple script that writes some command line arguments into a file 28 | with open(self.playlog_script, "w") as f: 29 | print("#!/bin/sh", file=f) 30 | print(f"echo $1 > {self.out_file}", file=f) 31 | print(f"echo $3 >> {self.out_file}", file=f) 32 | print(f"echo $5 >> {self.out_file}", file=f) 33 | os.chmod(self.playlog_script, 0o700) 34 | 35 | # Correctly import a couple of files 36 | files = [ 37 | os.path.join(self.current_path, "audio", "padded" + ext) 38 | for ext in "-jointstereo.mp3 -stereo.mp3".split() 39 | ] 40 | try: 41 | args = [self.data_dir, "jingles", files, True] 42 | with capture(import_cmd, *args) as (out, err, ret): 43 | pass 44 | except SystemExit as e: 45 | if e.code != 0: 46 | print(e, file=sys.stderr) 47 | raise (RuntimeError("Command execution failed")) 48 | 49 | def tearDown(self): 50 | shutil.rmtree(self.data_dir) 51 | 52 | def testPlayLog(self): 53 | from klangbecken.cli import main, playlog_cmd 54 | 55 | now = datetime.datetime(2018, 4, 28).astimezone() 56 | 57 | filename = os.listdir(os.path.join(self.data_dir, "jingles"))[0] 58 | path = os.path.join(self.data_dir, "jingles", filename) 59 | 60 | # Make sure that the external command does not get interpreted by a shell 61 | external_command = self.playlog_script + " {id} >> {artist} | {title}" 62 | 63 | with mock.patch("klangbecken.cli.datetime") as dt: 64 | with mock.patch("klangbecken.cli.EXTERNAL_PLAY_LOGGER", external_command): 65 | dt.datetime.now = mock.Mock(return_value=now) 66 | # First call 67 | playlog_cmd(self.data_dir, path) 68 | 69 | with open(os.path.join(self.data_dir, "index.json")) as f: 70 | cache_data = json.load(f) 71 | entry = cache_data[filename.split(".")[0]] 72 | 73 | self.assertEqual(entry["last_play"], now.isoformat()) 74 | self.assertEqual(entry["play_count"], 1) 75 | 76 | mutagenFile = File(path, easy=True) 77 | self.assertEqual(mutagenFile["last_play"][0], now.isoformat()) 78 | 79 | with open(os.path.join(self.data_dir, "log", "2018-04.csv")) as f: 80 | reader = csv.DictReader(f) 81 | entries = list(reader) 82 | entry = entries[0] 83 | 84 | self.assertEqual(len(entries), 1) 85 | self.assertEqual(entry["last_play"], now.isoformat()) 86 | self.assertEqual(entry["play_count"], "1") 87 | 88 | with open(self.out_file) as f: 89 | contents = f.read().strip().split("\n") 90 | self.assertEqual(contents[0], filename.split(".")[0]) 91 | self.assertEqual(contents[1], "'Padded' `Artist`") 92 | self.assertEqual(contents[2], 'Padded | "Title"') 93 | 94 | now = now + datetime.timedelta(days=1) 95 | with mock.patch("klangbecken.cli.datetime") as dt: 96 | with mock.patch("sys.argv", ["", "playlog", "-d", self.data_dir, path]): 97 | dt.datetime.now = mock.Mock(return_value=now) 98 | # Second call with no external play logger, now via the main function 99 | main() 100 | 101 | with open(os.path.join(self.data_dir, "index.json")) as f: 102 | cache_data = json.load(f) 103 | entry = cache_data[filename.split(".")[0]] 104 | 105 | self.assertEqual(entry["last_play"], now.isoformat()) 106 | self.assertEqual(entry["play_count"], 2) 107 | 108 | with open(os.path.join(self.data_dir, "log", "2018-04.csv")) as f: 109 | reader = csv.DictReader(f) 110 | self.assertEqual(len(list(reader)), 2) 111 | -------------------------------------------------------------------------------- /test/test_cli_disable_expired.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import os 4 | import shutil 5 | import sys 6 | import tempfile 7 | import unittest 8 | 9 | from .utils import capture 10 | 11 | 12 | class DisableExpiredTestCase(unittest.TestCase): 13 | def setUp(self): 14 | from klangbecken.cli import _check_data_dir, import_cmd 15 | 16 | self.current_path = os.path.dirname(os.path.realpath(__file__)) 17 | self.tempdir = tempfile.mkdtemp() 18 | self.jingles_dir = os.path.join(self.tempdir, "jingles") 19 | self.jingles_playlist = os.path.join(self.tempdir, "jingles.m3u") 20 | self.index = os.path.join(self.tempdir, "index.json") 21 | _check_data_dir(self.tempdir, create=True) 22 | 23 | # Correctly import a couple of files 24 | files = [ 25 | os.path.join(self.current_path, "audio", "padded" + ext) 26 | for ext in "-stereo.mp3 -jointstereo.mp3 -end-stereo.mp3".split() 27 | ] 28 | try: 29 | args = [self.tempdir, "jingles", files, True] 30 | with capture(import_cmd, *args) as (out, err, ret): 31 | pass 32 | except SystemExit as e: 33 | if e.code != 0: 34 | print(e, file=sys.stderr) 35 | raise (RuntimeError("Command execution failed")) 36 | 37 | def tearDown(self): 38 | shutil.rmtree(self.tempdir) 39 | 40 | def testDisableExpired(self): 41 | from klangbecken.cli import main 42 | from klangbecken.playlist import DEFAULT_PROCESSORS, MetadataChange 43 | 44 | track1, track2, track3 = os.listdir(self.jingles_dir) 45 | 46 | # "empty" run 47 | argv, sys.argv = sys.argv, ["", "disable-expired", "-d", self.tempdir] 48 | try: 49 | with capture(main) as (out, err, ret): 50 | self.assertEqual(err.strip(), "") 51 | finally: 52 | sys.arv = argv 53 | 54 | with open(self.jingles_playlist) as f: 55 | lines = f.readlines() 56 | self.assertEqual(len(lines), 3) 57 | 58 | # modify expiration dates 59 | now = datetime.datetime.now() 60 | past = now - datetime.timedelta(hours=1) 61 | future = now + datetime.timedelta(hours=1) 62 | for processor in DEFAULT_PROCESSORS: 63 | processor( 64 | self.tempdir, 65 | "jingles", 66 | *track1.split("."), 67 | [MetadataChange("expiration", past.astimezone().isoformat())], 68 | ) 69 | processor( 70 | self.tempdir, 71 | "jingles", 72 | *track2.split("."), 73 | [MetadataChange("expiration", future.astimezone().isoformat())], 74 | ) 75 | with open(self.index) as f: 76 | data = json.load(f) 77 | for entry in data.values(): 78 | self.assertEqual(entry["weight"], 1) 79 | 80 | # run for real 81 | argv, sys.argv = sys.argv, ["", "disable-expired", "-d", self.tempdir] 82 | try: 83 | with capture(main) as (out, err, ret): 84 | pass 85 | finally: 86 | sys.arv = argv 87 | 88 | self.assertEqual(err.strip(), "") 89 | self.assertIn(track1, out) 90 | self.assertNotIn(track2, out) 91 | self.assertNotIn(track3, out) 92 | with open(self.jingles_playlist) as f: 93 | lines = [line.strip() for line in f.readlines()] 94 | self.assertEqual(len(lines), 2) 95 | for line in lines: 96 | self.assertNotIn(track1, line) 97 | self.assertNotIn(f"jingles/{track1}", lines) 98 | self.assertIn(f"jingles/{track2}", lines) 99 | self.assertIn(f"jingles/{track3}", lines) 100 | 101 | track1_id = track1.split(".")[0] 102 | with open(self.index) as f: 103 | data = json.load(f) 104 | for key, entry in data.items(): 105 | if key == track1_id: 106 | self.assertEqual(entry["weight"], 0) 107 | self.assertEqual(entry["expiration"], past.astimezone().isoformat()) 108 | else: 109 | self.assertEqual(entry["weight"], 1) 110 | 111 | # run again for real, nothing should happen now 112 | argv, sys.argv = sys.argv, ["", "disable-expired", "-d", self.tempdir] 113 | try: 114 | with capture(main) as (out, err, ret): 115 | pass 116 | finally: 117 | sys.arv = argv 118 | 119 | self.assertEqual(err.strip(), "") 120 | self.assertEqual(out.strip(), "") 121 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # abort on errors 4 | set -e 5 | 6 | # "parse" command line arguments 7 | if [ $# -eq 0 ]; then 8 | MOD_WSGI=yes 9 | elif [ $# -eq 1 ] && [ "$1" = "--no-mod-wsgi" -o "$1" = "-n" ]; then 10 | MOD_WSGI=no 11 | else 12 | echo "Klangbecken deployment script" 13 | echo "Usage:./deploy [-n]" 14 | echo "" 15 | echo "Options:" 16 | echo " -n, --no-mod-wsgi Do not download and install mod_wsgi" 17 | echo "" 18 | echo "Note: Downloading mod_wsgi requires httpd-devel libraries" 19 | echo " to be installed locally" 20 | exit 1 21 | fi 22 | 23 | echo "##################" 24 | echo "# Perform checks #" 25 | echo "##################" 26 | 27 | # are there prod and upstream remotes? 28 | if ! git remote | grep --quiet "^prod$"; then 29 | echo "ERROR: No 'prod' remote configured" 30 | exit 1 31 | fi 32 | if ! git remote | grep --quiet "^upstream$"; then 33 | echo "ERROR: No 'upstream' remote configured" 34 | exit 1 35 | fi 36 | 37 | PROD_HOST=$(git remote get-url prod | cut -d: -f1) 38 | 39 | # are we clean? 40 | if ! git diff --exit-code --quiet; then 41 | echo "ERROR: Working directory is NOT clean." 42 | exit 1 43 | fi 44 | 45 | # are we on the master branch? 46 | if [ "$(git branch --show-current)" != "master" ] 47 | then 48 | echo "ERROR: We are NOT on the 'master' branch." 49 | exit 1 50 | fi 51 | 52 | # fetch latest version from upstream 53 | if ! git fetch upstream master; then 54 | echo 55 | echo "ERROR: cannot fetch current upstream version." 56 | exit 1 57 | fi 58 | 59 | # are we pointing at upstream/master? 60 | if ! git show --no-patch --pretty=format:%D | grep --quiet upstream/master 61 | then 62 | echo "ERROR: 'master' branch is NOT pointing at 'upstream/master'." 63 | echo " Make sure, your local version is in sync with upstream." 64 | exit 1 65 | fi 66 | 67 | # check connection to prod and fetch state 68 | if ! git fetch prod master; then 69 | echo "ERROR: cannot connect to prod to fetch current version." 70 | exit 1 71 | fi 72 | 73 | # is prod/master already up to date? 74 | if git show --no-patch --pretty=format:%D | grep --quiet prod/master 75 | then 76 | echo 77 | echo "ERROR: 'prod' is already up to date." 78 | exit 1 79 | fi 80 | 81 | # check current version 82 | INIT_VERSION=$(sed -n -e 's/^__version__\s*=\s*"\(.*\)"\s*$/\1/p' klangbecken/__init__.py ) 83 | SETUP_VERSION=$(sed -n -e 's/^\s*version\s*=\s*"\(.*\)".*$/\1/p' setup.py) 84 | 85 | if [ "$INIT_VERSION" != "$SETUP_VERSION" ] 86 | then 87 | echo "ERROR: Version numbers in 'setup.py' and 'klangbecken/__init__.py' do not match." 88 | exit 1 89 | fi 90 | 91 | TAG_VERSION=$(git tag --merged HEAD -l "v*" | sort -V | tail -n 1) 92 | if [ "v$SETUP_VERSION" != "$TAG_VERSION" ] 93 | then 94 | echo "ERROR: Tag and package versions do not match." 95 | exit 1 96 | fi 97 | 98 | echo 99 | echo -n "Everything looks good. Start deployment? [Y/n]" 100 | read -n 1 ANS 101 | 102 | if ! [ -z "$ANS" -o "$ANS" = "y" -o "$ANS" = "Y" ]; then 103 | echo " Bye ..." 104 | exit 1 105 | fi 106 | 107 | echo 108 | echo "#####################" 109 | echo "# Increment version #" 110 | echo "#####################" 111 | OLD_VERSION=$SETUP_VERSION 112 | LAST_DIGIT=$(echo "$OLD_VERSION" | cut -d. -f3) 113 | LAST_DIGIT=$((LAST_DIGIT + 1)) 114 | NEW_VERSION=$(echo "$OLD_VERSION" | cut -d. -f1-2)."$LAST_DIGIT" 115 | 116 | sed -i "s/__version__ = \"$OLD_VERSION\"/__version__ = \"$NEW_VERSION\"/" klangbecken/__init__.py 117 | sed -i "s/version=\"$OLD_VERSION\",/version=\"$NEW_VERSION\",/" setup.py 118 | 119 | git add klangbecken/__init__.py setup.py 120 | git commit -m "Version bump v$NEW_VERSION" 121 | 122 | TEMP=$(mktemp -d) 123 | echo 124 | echo "#########################" 125 | echo "# Download dependencies #" 126 | echo "#########################" 127 | pip download --dest "$TEMP" --no-binary :all: -r requirements.txt 128 | if [ "$MOD_WSGI" = "yes" ]; then 129 | if ! pip download --dest "$TEMP" --no-binary :all: mod_wsgi; then 130 | echo 131 | echo "Note: Downloading mod_wsgi requires httpd-devel libraries" 132 | echo " to be installed locally" 133 | exit 1 134 | fi 135 | fi 136 | 137 | echo 138 | echo "###############################" 139 | echo "# Copy dependencies to server #" 140 | echo "###############################" 141 | scp "$TEMP"/* "$PROD_HOST":"dependencies/" 142 | rm "$TEMP"/* 143 | rmdir "$TEMP" 144 | 145 | echo 146 | echo "######################" 147 | echo "# Deploy application #" 148 | echo "######################" 149 | git push prod master 150 | 151 | echo 152 | echo "#######################" 153 | echo "# Finalize deployment #" 154 | echo "#######################" 155 | git tag "v$NEW_VERSION" 156 | git push upstream master --tags 157 | 158 | echo 159 | echo "Deployment successful!" 160 | -------------------------------------------------------------------------------- /test/test_api_dev_server.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import shutil 4 | import tempfile 5 | import unittest 6 | import uuid 7 | 8 | from werkzeug.test import Client 9 | 10 | from .utils import capture 11 | 12 | 13 | class DevServerStartupTestCase(unittest.TestCase): 14 | def setUp(self): 15 | self.current_path = os.path.dirname(os.path.realpath(__file__)) 16 | self.tempdir = tempfile.mkdtemp() 17 | 18 | def tearDown(self): 19 | shutil.rmtree(self.tempdir) 20 | 21 | def testNoFFmpegWarning(self): 22 | from klangbecken.api import development_server 23 | from klangbecken.cli import init_cmd 24 | 25 | init_cmd(self.tempdir) 26 | with capture(development_server, self.tempdir, "player.sock") as ( 27 | out, 28 | err, 29 | ret, 30 | ): 31 | self.assertNotIn("WARNING", out) 32 | 33 | def testDirStructure(self): 34 | from klangbecken.api import development_server 35 | from klangbecken.cli import init_cmd 36 | 37 | self.assertFalse(os.path.isdir(os.path.join(self.tempdir, "music"))) 38 | 39 | with self.assertRaises(Exception): 40 | development_server(self.tempdir, "very secret") 41 | 42 | init_cmd(self.tempdir) 43 | development_server(self.tempdir, "very secret") 44 | self.assertTrue(os.path.isdir(os.path.join(self.tempdir, "music"))) 45 | 46 | with open(os.path.join(self.tempdir, "music", "abc.txt"), "w"): 47 | pass 48 | 49 | development_server(self.tempdir, "very secret") 50 | self.assertTrue(os.path.isdir(os.path.join(self.tempdir, "music"))) 51 | self.assertTrue(os.path.isfile(os.path.join(self.tempdir, "music", "abc.txt"))) 52 | 53 | 54 | class DevServerTestCase(unittest.TestCase): 55 | def setUp(self): 56 | from klangbecken.api import development_server 57 | from klangbecken.cli import init_cmd 58 | 59 | self.current_path = os.path.dirname(os.path.realpath(__file__)) 60 | self.tempdir = tempfile.mkdtemp() 61 | init_cmd(self.tempdir) 62 | app = development_server(self.tempdir, "very secret") 63 | self.client = Client(app) 64 | 65 | def tearDown(self): 66 | shutil.rmtree(self.tempdir) 67 | 68 | def testApi(self): 69 | # Login 70 | resp = self.client.post("/api/auth/login/") 71 | self.assertEqual(resp.status_code, 200) 72 | data = json.loads(resp.data) 73 | self.assertIn("token", data) 74 | token = data["token"] 75 | resp.close() 76 | 77 | # Upload 78 | path = os.path.join(self.current_path, "audio", "sine-unicode-jointstereo.mp3") 79 | with open(path, "rb") as f: 80 | resp = self.client.post( 81 | "/api/playlist/jingles/", 82 | data={"file": (f, "sine-unicode-jointstereo.mp3")}, 83 | headers=[("Authorization", f"Bearer {token}")], 84 | ) 85 | self.assertEqual(resp.status_code, 200) 86 | data = json.loads(resp.data) 87 | fileId = list(data.keys())[0] 88 | self.assertEqual(fileId, str(uuid.UUID(fileId))) 89 | expected = { 90 | "original_filename": "sine-unicode-jointstereo.mp3", 91 | "title": "Sine Title éàè", 92 | "artist": "Sine Artist öäü", 93 | "ext": "mp3", 94 | "weight": 1, 95 | "playlist": "jingles", 96 | "id": fileId, 97 | } 98 | self.assertLessEqual(set(expected.items()), set(data[fileId].items())) 99 | resp.close() 100 | 101 | # Failing upload 102 | path = os.path.join(self.current_path, "audio", "not-an-audio-file.mp3") 103 | with open(path, "rb") as f: 104 | resp = self.client.post( 105 | "/api/playlist/jingles/", 106 | data={"file": (f, "not-an-audio-file.mp3")}, 107 | headers=[("Authorization", f"Bearer {token}")], 108 | ) 109 | self.assertEqual(resp.status_code, 422) 110 | data = json.loads(resp.data) 111 | self.assertIn("Cannot read metadata", data["description"]) 112 | self.assertIn("not-an-audio-file.mp3", data["description"]) 113 | 114 | # Update 115 | resp = self.client.put( 116 | "/api/playlist/jingles/" + fileId + ".mp3", 117 | data=json.dumps({"weight": 4}), 118 | content_type="text/json", 119 | headers=[("Authorization", f"Bearer {token}")], 120 | ) 121 | self.assertEqual(resp.status_code, 200) 122 | resp.close() 123 | 124 | # Get file 125 | resp = self.client.get("/data/jingles/" + fileId + ".mp3") 126 | self.assertEqual(resp.status_code, 200) 127 | resp.close() 128 | 129 | # Get index.json 130 | resp = self.client.get("/data/index.json") 131 | self.assertEqual(resp.status_code, 200) 132 | resp.close() 133 | 134 | # Delete file 135 | resp = self.client.delete( 136 | "/api/playlist/jingles/" + fileId + ".mp3", 137 | headers=[("Authorization", f"Bearer {token}")], 138 | ) 139 | self.assertEqual(resp.status_code, 200) 140 | resp.close() 141 | 142 | # Verify that we are logged out 143 | resp = self.client.post("/api/playlist/jingles/") 144 | self.assertEqual(resp.status_code, 401) 145 | resp.close() 146 | -------------------------------------------------------------------------------- /test/test_cli_reanalyze.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import shutil 4 | import sys 5 | import tempfile 6 | import unittest 7 | from unittest import mock 8 | 9 | from mutagen import File 10 | 11 | from .utils import capture 12 | 13 | 14 | class ReanalyzeCmdTestCase(unittest.TestCase): 15 | def setUp(self): 16 | from klangbecken.cli import _check_data_dir, import_cmd 17 | 18 | self.current_path = os.path.dirname(os.path.realpath(__file__)) 19 | self.data_dir = tempfile.mkdtemp() 20 | _check_data_dir(self.data_dir, create=True) 21 | 22 | # Correctly import a couple of files 23 | files = [ 24 | os.path.join(self.current_path, "audio", "padded" + ext) 25 | for ext in "-jointstereo.mp3 -stereo.mp3".split() 26 | ] 27 | self.file_count = len(files) 28 | 29 | try: 30 | args = [self.data_dir, "jingles", files, True] 31 | with capture(import_cmd, *args) as (out, err, ret): 32 | pass 33 | except SystemExit as e: 34 | if e.code != 0: 35 | print(e, file=sys.stderr) 36 | raise (RuntimeError("Command execution failed")) 37 | 38 | for filename in os.listdir(os.path.join(self.data_dir, "jingles")): 39 | mutagenFile = File( 40 | os.path.join(self.data_dir, "jingles", filename), easy=True 41 | ) 42 | mutagenFile["cue_in"] = "0.0" 43 | mutagenFile["cue_out"] = "5.0" 44 | mutagenFile["track_gain"] = "-3.0 dB" 45 | mutagenFile.save() 46 | 47 | with open(os.path.join(self.data_dir, "index.json"), "r+") as f: 48 | data = json.load(f) 49 | for entry in data.values(): 50 | entry["cue_in"] = 0.0 51 | entry["cue_out"] = 5.0 52 | entry["track_gain"] = "-3.0 dB" 53 | f.seek(0) 54 | f.truncate() 55 | json.dump(data, f) 56 | 57 | def tearDown(self): 58 | shutil.rmtree(self.data_dir) 59 | 60 | def testSingleFileMocked(self): 61 | from klangbecken.cli import reanalyze_cmd 62 | from klangbecken.playlist import MetadataChange 63 | 64 | filename = os.listdir(os.path.join(self.data_dir, "jingles"))[0] 65 | 66 | # mocked call: single file 67 | with mock.patch( 68 | "klangbecken.cli.ffmpeg_audio_analyzer", 69 | return_value=[MetadataChange("cue_in", 3.0)], 70 | ) as analyzer: 71 | with mock.patch( 72 | "klangbecken.cli.DEFAULT_PROCESSORS", [mock.Mock()] 73 | ) as processors: 74 | with capture( 75 | reanalyze_cmd, self.data_dir, [filename.split(".")[0]], False, True 76 | ): 77 | pass 78 | 79 | analyzer.assert_called_once_with("jingles", *filename.split("."), mock.ANY) 80 | analyzer.reset_mock() 81 | processors[0].assert_called_once_with( 82 | self.data_dir, 83 | "jingles", 84 | *filename.split("."), 85 | [MetadataChange("cue_in", 3.0)], 86 | ) 87 | 88 | def testSingleFile(self): 89 | from klangbecken.cli import main 90 | 91 | filename = os.listdir(os.path.join(self.data_dir, "jingles"))[0] 92 | file_id = filename.split(".")[0] 93 | 94 | with mock.patch( 95 | "sys.argv", ["", "reanalyze", "-d", self.data_dir, file_id, "--yes"] 96 | ): 97 | with capture(main): 98 | pass 99 | 100 | mutagenFile = File(os.path.join(self.data_dir, "jingles", filename), easy=True) 101 | self.assertIn("cue_in", mutagenFile) 102 | self.assertIn("cue_out", mutagenFile) 103 | self.assertIn("track_gain", mutagenFile) 104 | self.assertNotEqual(mutagenFile["cue_in"][0], "0.0") 105 | self.assertNotEqual(mutagenFile["cue_out"][0], "5.0") 106 | self.assertNotEqual(mutagenFile["track_gain"][0], "-3.0 dB") 107 | 108 | # failing analysis: overwrite file 109 | open(os.path.join(self.data_dir, "jingles", filename), "w").close() 110 | 111 | with mock.patch( 112 | "sys.argv", ["", "reanalyze", "-d", self.data_dir, file_id, "--yes"] 113 | ): 114 | with capture(main) as (out, err, ret): 115 | pass 116 | 117 | self.assertIn("FAILED: Cannot process audio data", out) 118 | self.assertIn(f"Failed Tracks (1):\n - jingles/{file_id}.mp3", out) 119 | 120 | def testAllFilesMocked(self): 121 | from klangbecken.cli import reanalyze_cmd 122 | from klangbecken.playlist import MetadataChange 123 | 124 | with mock.patch( 125 | "klangbecken.cli.DEFAULT_PROCESSORS", [mock.Mock()] 126 | ) as processors: 127 | with capture(reanalyze_cmd, self.data_dir, [], True, True): 128 | with mock.patch( 129 | "sys.argv", ["", "reanalyze", "-d", self.data_dir, "--all"] 130 | ): 131 | pass 132 | 133 | self.assertEqual(processors[0].call_count, self.file_count) 134 | changed_fields = {"cue_in", "cue_out", "track_gain"} 135 | for call_args in processors[0].call_args_list: 136 | changes = call_args[0][4] 137 | self.assertEqual(len(changes), 3) 138 | self.assertTrue(all(isinstance(c, MetadataChange) for c in changes)) 139 | self.assertTrue(all(c.key in changed_fields for c in changes)) 140 | -------------------------------------------------------------------------------- /test/test_api_utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import doctest 3 | import json 4 | import unittest 5 | from unittest import mock 6 | 7 | from werkzeug.test import Client 8 | 9 | from klangbecken.api_utils import JWTAuthorizationMiddleware 10 | 11 | from .utils import capture 12 | 13 | 14 | # most testing is done in doctests 15 | def load_tests(loader, tests, ignore): 16 | import klangbecken.api_utils 17 | 18 | # load doctests 19 | tests.addTests(doctest.DocTestSuite(klangbecken.api_utils)) 20 | return tests 21 | 22 | 23 | class AdditionalTestCase(unittest.TestCase): 24 | def setUp(self): 25 | from klangbecken.api_utils import API 26 | 27 | app = API() 28 | 29 | @app.PATCH("/") 30 | def root(request): 31 | int("1.5") 32 | 33 | self.app = app 34 | self.client = Client(app) 35 | 36 | def testError500(self): 37 | with capture(self.client.patch, "/") as (out, err, resp): 38 | pass 39 | self.assertEqual(resp.status_code, 500) 40 | self.assertIn("ValueError: invalid literal for int() with base 10: '1.5'", err) 41 | 42 | def testParameterMismatch(self): 43 | from klangbecken.api_utils import API 44 | 45 | app = API() 46 | with self.assertRaises(TypeError): 47 | 48 | @app.POST("/") 49 | def dummy(request, number): 50 | pass 51 | 52 | def testWeakSecret(self): 53 | with self.assertRaises(ValueError): 54 | JWTAuthorizationMiddleware(self.app, "too short") 55 | 56 | with self.assertRaises(ValueError): 57 | JWTAuthorizationMiddleware(self.app, "*****************************") 58 | 59 | 60 | class TokenRenewalTestCase(unittest.TestCase): 61 | def setUp(self): 62 | from klangbecken.api_utils import ( 63 | API, 64 | DummyAuthenticationMiddleware, 65 | JWTAuthorizationMiddleware, 66 | ) 67 | 68 | api = API() 69 | 70 | @api.GET("/") 71 | def root(request): 72 | return "Hello World" 73 | 74 | app = JWTAuthorizationMiddleware(api, "very secret") 75 | app = DummyAuthenticationMiddleware(app) 76 | self.client = Client(app) 77 | 78 | def testImmediateRenewal(self): 79 | resp = self.client.get("/") 80 | self.assertEqual(resp.status_code, 401) 81 | self.assertIn(b"No authorization header supplied", resp.data) 82 | 83 | resp = self.client.post("/auth/login/") 84 | self.assertEqual(resp.status_code, 200) 85 | token = json.loads(resp.data)["token"] 86 | self.assertEqual(len(token.split(".")), 3) 87 | resp = self.client.get("/", headers={"Authorization": f"Bearer {token}"}) 88 | self.assertEqual(resp.status_code, 200) 89 | 90 | resp = self.client.post("/auth/renew/", data=json.dumps({"token": token})) 91 | self.assertEqual(resp.status_code, 200) 92 | token = json.loads(resp.data)["token"] 93 | self.assertEqual(len(token.split(".")), 3) 94 | resp = self.client.get("/", headers={"Authorization": f"Bearer {token}"}) 95 | self.assertEqual(resp.status_code, 200) 96 | 97 | def testExpiredOk(self): 98 | before16mins = datetime.datetime.utcnow() - datetime.timedelta(minutes=16) 99 | with mock.patch("klangbecken.api_utils.datetime") as dt: 100 | dt.datetime.utcnow = mock.Mock(return_value=before16mins) 101 | dt.timedelta = mock.Mock(return_value=datetime.timedelta(minutes=15)) 102 | resp = self.client.post("/auth/login/") 103 | self.assertEqual(resp.status_code, 200) 104 | token = json.loads(resp.data)["token"] 105 | self.assertEqual(len(token.split(".")), 3) 106 | 107 | resp = self.client.get("/") 108 | self.assertEqual(resp.status_code, 401) 109 | resp = self.client.get("/", headers={"Authorization": f"Bearer {token}"}) 110 | self.assertEqual(resp.status_code, 401) 111 | self.assertIn(b"Expired token", resp.data) 112 | 113 | resp = self.client.post("/auth/renew/", data=json.dumps({"token": token})) 114 | self.assertEqual(resp.status_code, 200) 115 | token = json.loads(resp.data)["token"] 116 | self.assertEqual(len(token.split(".")), 3) 117 | resp = self.client.get("/", headers={"Authorization": f"Bearer {token}"}) 118 | self.assertEqual(resp.status_code, 200) 119 | 120 | def testExpiredNok(self): 121 | eightDaysAgo = datetime.datetime.utcnow() - datetime.timedelta(days=8) 122 | with mock.patch("klangbecken.api_utils.datetime") as dt: 123 | dt.datetime.utcnow = mock.Mock(return_value=eightDaysAgo) 124 | dt.timedelta = mock.Mock(return_value=datetime.timedelta(minutes=15)) 125 | resp = self.client.post("/auth/login/") 126 | self.assertEqual(resp.status_code, 200) 127 | token = json.loads(resp.data)["token"] 128 | self.assertEqual(len(token.split(".")), 3) 129 | 130 | resp = self.client.get("/") 131 | self.assertEqual(resp.status_code, 401) 132 | resp = self.client.get("/", headers={"Authorization": f"Bearer {token}"}) 133 | self.assertEqual(resp.status_code, 401) 134 | 135 | resp = self.client.post("/auth/renew/", data=json.dumps({"token": token})) 136 | self.assertEqual(resp.status_code, 401) 137 | self.assertIn(b"Nonrenewable expired token", resp.data) 138 | 139 | def testCorruptedToken(self): 140 | twentyMinutesAgo = datetime.datetime.utcnow() - datetime.timedelta(minutes=20) 141 | with mock.patch("klangbecken.api_utils.datetime") as dt: 142 | dt.datetime.utcnow = mock.Mock(return_value=twentyMinutesAgo) 143 | dt.timedelta = mock.Mock(return_value=datetime.timedelta(minutes=15)) 144 | resp = self.client.post("/auth/login/") 145 | self.assertEqual(resp.status_code, 200) 146 | token = json.loads(resp.data)["token"] 147 | self.assertEqual(len(token.split(".")), 3) 148 | 149 | token = token[:-1] # Corrupt token 150 | resp = self.client.get("/", headers={"Authorization": f"Bearer {token}"}) 151 | self.assertEqual(resp.status_code, 401) 152 | self.assertIn(b"Invalid token", resp.data) 153 | 154 | resp = self.client.post("/auth/renew/", data=json.dumps({"token": token})) 155 | self.assertEqual(resp.status_code, 401) 156 | self.assertIn(b"Invalid token", resp.data) 157 | 158 | def testInvalidHeader(self): 159 | resp = self.client.post("/auth/login/") 160 | self.assertEqual(resp.status_code, 200) 161 | token = json.loads(resp.data)["token"] 162 | self.assertEqual(len(token.split(".")), 3) 163 | resp = self.client.get("/", headers={"Authorization": f"Something {token}"}) 164 | self.assertEqual(resp.status_code, 401) 165 | self.assertIn(b"Invalid authorization header", resp.data) 166 | -------------------------------------------------------------------------------- /doc/simulation.md: -------------------------------------------------------------------------------- 1 | # Accelerated Simulation Runs 2 | 3 | By shortening the track duration by a factor 100 we can cover a day in 15 minutes and an overnight run can produce a month' worth of runtime data. This data can then be analyzed to verify various aspects of the player script. 4 | 5 | ## Preparation 6 | 7 | ### Calculate playlist properties _(optional)_ 8 | 9 | Copy the `index.json` file from production: 10 | ```bash 11 | scp klangbecken-prod-system:/var/lib/klangbecken/index.json index-orig.json 12 | ``` 13 | 14 | Let's see what kind of tracks we have: 15 | ```python 16 | import json 17 | from statistics import mean, pstdev 18 | data = json.load(open("index-orig.json")) 19 | 20 | for playlist in "music classics jingles".split(): 21 | durations = [entry["cue_out"] - entry["cue_in"] for entry in data.values() if entry["playlist"] == playlist] 22 | avg = mean(durations) 23 | print(f"{playlist.capitalize()} playlist: {len(durations)} tracks, {avg:.2f}±{pstdev(durations, avg):.2f} seconds (min: {min(durations):.2f}s, max: {max(durations):.2f}s)") 24 | ``` 25 | 26 | Example output: 27 | ``` 28 | Music playlist: 1334 tracks, 230.63±70.46 seconds (min: 29.24s, max: 610.49s) 29 | Classics playlist: 1280 tracks, 240.21±70.19 seconds (min: 43.69s, max: 975.89s) 30 | Jingles playlist: 22 tracks, 11.31±16.53 seconds (min: 2.24s, max: 76.49s) 31 | ``` 32 | 33 | ### Adjust code 34 | 35 | Some code needs to be adjusted, to make the player and import run as desired with the shortened track length. 36 | 37 | Apply the supplied patch before running the simulation: 38 | ```bash 39 | patch -p1 < doc/simulation-timing-changes.patch 40 | ``` 41 | 42 | **Attention:** Make sure, that you do not accidentally commit these changes. 43 | 44 | After running the simulation you can revert the changes: 45 | ```bash 46 | patch -p1 -R < doc/simulation-timing-changes.patch 47 | ``` 48 | 49 | ##### Applying changes manually _(optional)_ 50 | 51 | Comment out the section that checks the track length in the `ffmpeg_analyzer` function in the [playlist code](../klangbecken/playlist.py). 52 | ```python 53 | # WARNING: do not commit 54 | # duration = cue_out - cue_in 55 | # if playlist != "jingles" and duration < 5.0: 56 | # raise UnprocessableEntity(f"Track too short: {duration} < 5 seconds") 57 | # elif playlist == "jingles" and duration < 0.5: 58 | # raise UnprocessableEntity(f"Track too short: {duration} < 0.5 seconds") 59 | ``` 60 | 61 | Adjust timings in the [player code (klangbecken.liq)](../klangbecken.liq): 62 | 63 | Divide waiting times (before repeating a track) by 100: 64 | ```txt 65 | # WARNING: do not commit 66 | if playlist == "music" then 1728.0 # 2 days / 100 67 | elsif playlist == "classics" then 1728.0 # 2 days / 100 68 | elsif playlist == "jingles" then 36.0 # 1 hour / 100 69 | ``` 70 | 71 | Make jingles run 6 times a minute (15 mins / 100 ≈ 10 seconds): 72 | ```txt 73 | # WARNING: do not commit 74 | jingle_times = [0s, 10s, 20s, 30s, 40s, 50s] #[5m0s, 20m0s, 35m0s, 50m0s] 75 | ``` 76 | 77 | Divide fade in times when starting the klangbecken by 100: 78 | ```txt 79 | # WARNING: do not commit 80 | restart_delay = interactive.float("restart.delay", .01) 81 | restart_fade = interactive.float("restart.fade", .01) 82 | ``` 83 | 84 | Divide the crossfade times by 100: 85 | ```txt 86 | # WARNING: do not commit 87 | radio = crossfade(start_next=.005, fade_out=.01, fade_in=0., radio) 88 | ``` 89 | 90 | ### Generate audio files 91 | 92 | Optionally modify the script [`generate-tracks.py`](simulation-scripts/generate-simulation-tracks.py) with the values generated in the beginning (don't forget to divide the durations by 100), or just leave the default values in place. 93 | 94 | Run the script: 95 | ```bash 96 | python doc/simulation-scripts/generate-tracks.py 97 | ``` 98 | 99 | Grab a drink 🥤. 100 | 101 | ### Import audio files 102 | 103 | Clear your data directory and rebuild it with the `init`-command: 104 | ```bash 105 | mv data data.bak 106 | mv klangbecken.log klangbecken.log.bak 107 | python -m klangbecken init 108 | ``` 109 | 110 | Import the generated tracks: 111 | ```bash 112 | python -m klangbecken import -y music sim-data/music-*.mp3 113 | python -m klangbecken import -y classics sim-data/classic-*.mp3 114 | python -m klangbecken import -y jingles sim-data/jingle-*.mp3 115 | ``` 116 | 117 | Again, this might take a while ⏳. 118 | 119 | Start the API, player and the user interface to modify the priority of some jingles. 120 | 121 | API: 122 | ```bash 123 | python -m klangbecken serve 124 | ``` 125 | 126 | Player: 127 | ```bash 128 | $(opam eval) 129 | liquidsoap klangbecken.liq 130 | ``` 131 | 132 | User interface: 133 | ```bash 134 | cd ../klangbecken-ui 135 | npm run serve 136 | ``` 137 | 138 | Go to http://localhost:8080/jingles and set different priorities for jingles. You should disable at least on track by setting its priority to zero. 139 | 140 | Finally, make sure everything is in fine order: 141 | ```bash 142 | python -m klangbecken fsck 143 | ``` 144 | 145 | ## Run simulation 146 | 147 | Start the liquidsoap player: 148 | ```bash 149 | rm klangbecken.log # clean the log file 150 | eval $(opam env) 151 | liquidsoap klangbecken.liq 152 | ``` 153 | 154 | Start the [virtual Sämubox simulator](simulation-scripts/simulate-saemubox.py), that takes the player on and off air in random hourly intervals: 155 | ```bash 156 | python doc/simulation-scripts/simulate-saemubox.py 157 | ``` 158 | 159 | Start the [playlist reload script](simulation-scripts/simulate-playlist-reloads.py), that simulates playlist editing in random intervals: 160 | ```bash 161 | python doc/simulation-scripts/simulate-playlist-reloads.py 162 | ``` 163 | 164 | ![simulation-screenshot.png](simulation-screenshot.png) 165 | 166 | Watch the simulation util you get tired. Go to sleep 💤. 167 | 168 | _Note_: Let the simulation run at least for a couple of hours to get meaningful results. 169 | 170 | ## Analysis 171 | 172 | After stopping everything, run the analysis script: 173 | ```bash 174 | python doc/simulation-scripts/analysis.py 175 | ``` 176 | 177 | Example output of a two month simulation: 178 | ``` 179 | 1. Did errors occur? 180 | -------------------- 181 | ✅ No 182 | 183 | 2. State of the data directory still good? 184 | ------------------------------------------ 185 | ✅ Yes 186 | 187 | 3. Did all played tracks get logged? 188 | ------------------------------------ 189 | ✅ Yes (17727 track plays) 190 | 191 | 4. Ratio music vs. classics 192 | --------------------------- 193 | ✅ Good: 5.00 to 1 194 | 195 | 5. Music play distribution 196 | -------------------------- 197 | ✅ Normal distribution: 9.02±2.03 198 | 199 | 6. Classics play distribution 200 | ----------------------------- 201 | ✅ Normal distribution: 1.88±1.28 202 | 203 | 7. Weighted jingle play distribution 204 | ------------------------------------ 205 | ✅ Normal distribution: 66.82±9.49 206 | 207 | 8. Disabled jingles not played? 208 | ------------------------------- 209 | ✅ Yes 210 | 211 | 9. Jingle weights respected? 212 | ---------------------------- 213 | ✅ Yes 214 | 215 | 10. Are Jingles played regularly? 216 | --------------------------------- 217 | ✅ Yes 218 | 219 | 11. Waiting time between track plays (music & classics) respected? 220 | ------------------------------------------------------------------ 221 | ✅ Waiting periods almost always met: 2 missed out of 14434 (0.014%) 222 | ``` 223 | -------------------------------------------------------------------------------- /doc/design.md: -------------------------------------------------------------------------------- 1 | # Design 2 | 3 | Radio Bern RaBe is an open comunity radio based in Bern, Switzerland. The station is driven by volunteers, broadcasting more than 80 different shows in more than 20 languages live from our studios. 4 | 5 | When no live or pre-programmed radio shows are on air, we broadcast a twentyfour hour music program, the _Klangbecken_. This project implements the software for the _Klangbecken_. 6 | 7 | 8 | ## Requirements 9 | 10 | The software has a set of requirements for the listeners, the people doing the music programing, and the IT operations team. 11 | 12 | #### Listeners 13 | 14 | The listeners desire a stable, gapless, non-repetitious, high-quality music program. 15 | 16 | #### Music Programming 17 | 18 | Music programmers have to be able to ... 19 | 20 | * ... add and remove audio tracks from and to playlists 21 | * ... edit the audio track metadata 22 | * ... have a special playlist for _jingles_ that air four times an hour 23 | * ... control how often each jingle is aired 24 | * ... specify an end date until which a jingle is played 25 | * ... generate monthly statistics about the aired tracks (mostly jingles) 26 | * ... queue tracks for immediate airing 27 | * ... monitor the state of the system 28 | 29 | #### IT Operations 30 | 31 | The IT operations team wants ... 32 | * ... a stable, reliable system 33 | * ... an independent system that allows maintenance work on other core systems during the time the Klangbecken is on air 34 | * ... fast disaster recovery 35 | * ... easy maintainability and future-proofness 36 | 37 | 38 | ## General Goals 39 | 40 | Apart from the required features for the listeners and music programmers, we aim for the following goals: 41 | 42 | **Self-contained system**: The Klangbecken installation only requires a minimal amount of external services. These are a virtual machine runtime environment and local networking for playback and manually monitoring the state of the system. Playlist editing requires the external authentication service. Additionally basic services like file backup and automatic monitoring are required. 43 | 44 | **Fast recovery**: All data is stored in regular human-readable (where possible) files. A previous state of the system can be restored by simply restoring the files from backup, or alternatively by manually fixing the human-readable files. 45 | 46 | **Automated testing**: All central components of the system are automatically tested by meaningful test cases against multiple versions of our core dependencies (See [actions](https://github.com/radiorabe/klangbecken/actions)). 47 | 48 | **Minimal and mature runtime and test dependencies**: To reduce maintenance, we aim for a sensible minimal set of dependencies. We only depend on stable, mature and maintained libraries. 49 | 50 | 51 | ## Data Directory 52 | 53 | The data directory contains the data files for the entire system. This includes audio, playlist, and log files plus a metadata cache. Except for the audio files, human readable text files are used to store the data. The `fsck` command can be used, to verify the consistency of the directory. For details see the [data directory documentation](data-dir.md) 54 | 55 | 56 | ## CLI 57 | 58 | The [CLI](../klangbecken/cli.py) provides commands to manage the data directory and run the development server. For details see the [command line interface documentation](cli.md). 59 | 60 | 61 | ## API 62 | 63 | The [APIs](../klangbecken/api.py) are built with [werkzeug](https://werkzeug.palletsprojects.com/) and a set of [helpers](../klangbecken/api_utils.py). For details about the available endpoints see the [API documentation](api.md). 64 | 65 | The APIs are built from handler functions, and accept and return JSON data. Required data types can be enforced with type annotations. 66 | 67 | Example: 68 | ```python 69 | from werkzeug.serving import run_simple 70 | from klangbecken.api_utils import API 71 | 72 | app = API() 73 | 74 | @app.GET("/") 75 | def root(request): 76 | return "Hello World" 77 | 78 | @app.POST("/add") 79 | def add(request, a:int, b:int): 80 | return {"result": a + b} 81 | 82 | run_simple("localhost", 6000, app) 83 | ``` 84 | 85 | Test the API: 86 | ```bash 87 | $ curl http://localhost:6000 88 | "Hello World" 89 | 90 | $ curl -X POST -H "Content-Type: text/json" --data '{"a": 15, "b": 27}' http://localhost:6000/add 91 | { 92 | "result": 42 93 | } 94 | ``` 95 | 96 | 97 | ## Playlist Management 98 | 99 | The [playlist code](../klangbecken/api.py) manages the static playlist files in the data directory. 100 | 101 | For every playlist there is: 102 | * an `m3u` playlist file 103 | * a directory containing the audio files 104 | 105 | The audio files are named with a UUID and a valid file extension. Additionally the code maintains an `index.json` metadata cache, containing all metadata for all files. There is no shared data between the playlists. 106 | 107 | Modifications to playlists are done in two steps: 108 | 1. The incoming request is analyzed by _analyzer functions_, each generating a list of changes. 109 | 2. The gathered list of changes is processed by _processor functions_. 110 | 111 | There are three types of changes: `FileAddition`, `MetadataChange` and `FileDeletion`. 112 | 113 | _Analyzer functions_ have the following signature, and return a list of change objects: 114 | ```python 115 | def analyzer(playlist, fileId, ext, filename): 116 | ``` 117 | > Where `playlist` is the name of the playlist, `fileId` the UUID of the file, `ext` the file extension and thus the file type, and `filename` the temporary path to the uploaded file. 118 | 119 | _Processor functions_ process the generated changes. They validate them or write them to the file system. The functions have the following signature: 120 | ```python 121 | def processor(data_dir, playlist, fileId, ext, changes): 122 | ``` 123 | > Where `data_dir` is the data directory, `playlist` the name of the playlist, `fileId` the UUID of the file, `ext` the extension and file type of the file, and `changes` a list of change objects. 124 | 125 | 126 | ## Player Management 127 | 128 | The player [itself](../klangbecken.liq) is written in the [Liquidsoap](https://www.liquidsoap.info/) language. 129 | 130 | It reads and monitors the static playlist files, to build it's playlist. The different playlists are then combined as desired. As a safeguard, the player is "always on", and thus serves as a fallback for live and recorded radio shows. 131 | 132 | In normal operation the [virtual Sämubox](https://github.com/radiorabe/virtual-saemubox) sends a signal to the Klangbecken to come "on air". The Klangbecken then skips to the next track, to start the program at the beginning of an audio track. 133 | 134 | Every played track is logged with using the [play log command](cli.md) at the start of the track. 135 | 136 | Liquidsoap provides a telnet interface for querying run-time information and for the modification of dynamic _queue_ playlists. 137 | 138 | The [`LiquidsoapClient`](../klangbecken/player.py) encapsulates the liquidsoap telnet interface. It supports connecting via TCP with a hostname and port tuple (e.g. `("localhost", 1234)`) or Unix domain sockets (e.g. `./klangbecken.sock`). 139 | 140 | It provides a number of methods to interact with the player. 141 | 142 | Here is an example session: 143 | ```python 144 | >>> from klangbecken.player import LiquidsoapClient 145 | >>> client = LiquidsoapClient() 146 | >>> client.open("klangbecken.sock") 147 | >>> client.info() 148 | {'uptime': '0d 00h 02m 01s', 'liquidsoap_version': 'Liquidsoap 1.4.2', 'api_version': '0.0.13', 'music': '3f712a86-cd57-478f-b3c1-a9a80ceb281f', 'classics': '072f12ef-f4ae-4a9d-ad41-d76f92f6931b', 'jingles': '003ef755-4a82-40b5-b751-d124b85d62a6', 'on_air': {}, 'queue': ''} 149 | >>> client.close() 150 | ``` 151 | 152 | Use the `LiquidsoapClient` as a context manager, to reliably open and close the connection to the player: 153 | 154 | ```python 155 | with LiquidsoapClient(("localhost", 1234)) as client: 156 | queue_id = client.push("data/music/072f12ef-f4ae-4a9d-ad41-d76f92f6931b.mp3") 157 | print(f"Queued track under ID {queue_id}") 158 | ``` 159 | -------------------------------------------------------------------------------- /klangbecken/api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess 4 | import sys 5 | import uuid 6 | 7 | from werkzeug.exceptions import NotFound, UnprocessableEntity 8 | from werkzeug.middleware.dispatcher import DispatcherMiddleware 9 | 10 | from . import __version__ 11 | from .api_utils import API, DummyAuthenticationMiddleware, JWTAuthorizationMiddleware 12 | from .player import LiquidsoapClient 13 | from .playlist import ( 14 | DEFAULT_PROCESSORS, 15 | DEFAULT_UPDATE_ANALYZERS, 16 | DEFAULT_UPLOAD_ANALYZERS, 17 | FileDeletion, 18 | MetadataChange, 19 | ) 20 | from .settings import FILE_TYPES, PLAYLISTS 21 | 22 | 23 | def klangbecken_api( 24 | secret, 25 | data_dir, 26 | player_socket, 27 | *, 28 | upload_analyzers=DEFAULT_UPLOAD_ANALYZERS, 29 | update_analyzers=DEFAULT_UPDATE_ANALYZERS, 30 | processors=DEFAULT_PROCESSORS, 31 | ): 32 | """Construct the Klangbecken API WSGI application. 33 | 34 | This combines the two APIs for the playlists and the player 35 | with the authorization middleware. 36 | """ 37 | playlist = playlist_api(data_dir, upload_analyzers, update_analyzers, processors) 38 | player = player_api(player_socket, data_dir) 39 | 40 | app = API() 41 | app.GET("/")( 42 | lambda request: f"Welcome to the Klangbecken API version {__version__}" 43 | ) 44 | app = DispatcherMiddleware(app, {"/playlist": playlist, "/player": player}) 45 | auth_exempts = [ 46 | ("GET", "/player/"), 47 | ("GET", "/player/queue/"), 48 | ] 49 | app = JWTAuthorizationMiddleware(app, secret, exempt=auth_exempts) 50 | return app 51 | 52 | 53 | def playlist_api( # noqa: C901 54 | data_dir, upload_analyzers, update_analyzers, processors 55 | ): 56 | """Create API for static playlists editing. 57 | 58 | Audio files can be uploaded into a paylist, be removed from playlist, and 59 | metadata about these audio files can be modified. 60 | """ 61 | 62 | playlist_url = "//" 63 | file_url = ( 64 | playlist_url + "." 65 | ) 66 | 67 | api = API() 68 | 69 | @api.POST(playlist_url) 70 | def playlist_upload(request, playlist): 71 | if "file" not in request.files: 72 | raise UnprocessableEntity("No file attribute named 'file' found.") 73 | 74 | try: 75 | uploadFile = request.files["file"] 76 | ext = os.path.splitext(uploadFile.filename)[1].lower()[1:] 77 | fileId = str(uuid.uuid4()) # Generate new file id 78 | tempFile = os.path.join(data_dir, "upload", f"{fileId}.{ext}") 79 | uploadFile.save(tempFile) 80 | 81 | actions = [] 82 | for analyzer in upload_analyzers: 83 | actions += analyzer(playlist, fileId, ext, tempFile) 84 | 85 | actions.append(MetadataChange("original_filename", uploadFile.filename)) 86 | actions.append(MetadataChange("uploader", request.remote_user or "")) 87 | 88 | for processor in processors: 89 | processor(data_dir, playlist, fileId, ext, actions) 90 | 91 | response = { 92 | change.key: change.value 93 | for change in actions 94 | if isinstance(change, MetadataChange) 95 | } 96 | except UnprocessableEntity as e: 97 | e.description = f"{uploadFile.filename}: {e.description}" 98 | raise e 99 | finally: 100 | try: 101 | uploadFile.close() 102 | finally: 103 | os.remove(tempFile) 104 | 105 | return {fileId: response} 106 | 107 | @api.PUT(file_url) 108 | def playlist_update(request, playlist, fileId, ext, data): 109 | fileId = str(fileId) 110 | 111 | actions = [] 112 | for analyzer in update_analyzers: 113 | actions += analyzer(playlist, fileId, ext, data) 114 | 115 | for processor in processors: 116 | processor(data_dir, playlist, fileId, ext, actions) 117 | 118 | @api.DELETE(file_url) 119 | def on_playlist_delete(request, playlist, fileId, ext): 120 | fileId = str(fileId) 121 | 122 | change = [FileDeletion()] 123 | for processor in processors: 124 | processor(data_dir, playlist, fileId, ext, change) 125 | 126 | return api 127 | 128 | 129 | def player_api(player_socket, data_dir): 130 | """Create API to interact with the Liquidsoap player. 131 | 132 | It supports: 133 | - Getting player information 134 | - Handling a dynamic song queue 135 | """ 136 | api = API() 137 | 138 | @api.GET("/") 139 | def player_info(request): 140 | try: 141 | with LiquidsoapClient(player_socket) as client: 142 | return client.info() 143 | except (FileNotFoundError, TimeoutError): 144 | raise NotFound("Player not running") 145 | 146 | @api.POST("/reload/") 147 | def reload_playlist(request, playlist): 148 | with LiquidsoapClient(player_socket) as client: 149 | client.command(f"{playlist}.reload") 150 | 151 | return DispatcherMiddleware(api, {"/queue": queue_api(player_socket, data_dir)}) 152 | 153 | 154 | def queue_api(player_socket, data_dir): 155 | """Create API for queue interaction. 156 | 157 | List queue entries, add new tracks to queue and delete queue entries. 158 | """ 159 | api = API() 160 | 161 | @api.GET("/") 162 | def queue_list(request): 163 | with LiquidsoapClient(player_socket) as client: 164 | return client.queue() 165 | 166 | @api.POST("/") 167 | def queue_push(request, filename: str): 168 | filename_re = r"^({0})/([^/.]+).({1})$".format( 169 | "|".join(PLAYLISTS), "|".join(FILE_TYPES.keys()) 170 | ) 171 | if not re.match(filename_re, filename): 172 | raise UnprocessableEntity("Invalid file path format") 173 | 174 | with LiquidsoapClient(player_socket) as client: 175 | path = os.path.join(data_dir, filename) 176 | if not os.path.isfile(path): 177 | raise NotFound(f"File not found: {filename}") 178 | queue_id = client.push(path) 179 | return {"queue_id": queue_id} 180 | 181 | @api.DELETE("/") 182 | def queue_delete(request, queue_id): 183 | with LiquidsoapClient(player_socket) as client: 184 | client.delete(queue_id) 185 | 186 | return api 187 | 188 | 189 | ########################### 190 | # Stand-alone Application # 191 | ########################### 192 | def development_server(data_dir, player_socket): 193 | """Construct the stand-alone Klangbecken WSGI application for development. 194 | 195 | * Serves data files from the data directory 196 | * Relays API calls to the API 197 | 198 | Authentication is simulated. Loudness and silence analysis are skipped, 199 | if ffmpeg binary is missing. 200 | """ 201 | from werkzeug.middleware.shared_data import SharedDataMiddleware 202 | 203 | from .cli import _check_data_dir 204 | from .playlist import ffmpeg_audio_analyzer 205 | 206 | # Check data dirrectory structure 207 | _check_data_dir(data_dir) 208 | 209 | # Remove ffmpeg_audio_analyzer from analyzers if binary is not present 210 | upload_analyzers = DEFAULT_UPLOAD_ANALYZERS[:] 211 | try: 212 | subprocess.check_output("ffmpeg -version".split()) 213 | except (OSError, subprocess.CalledProcessError): # pragma: no cover 214 | upload_analyzers.remove(ffmpeg_audio_analyzer) 215 | print( 216 | "WARNING: ffmpeg binary not found. No audio analysis is performed.", 217 | file=sys.stderr, 218 | ) 219 | 220 | # Create an API with optional audio analyzer 221 | api = klangbecken_api( 222 | "very secret", data_dir, player_socket, upload_analyzers=upload_analyzers 223 | ) 224 | # Dummy authentication (all username password combinations will pass) 225 | api = DummyAuthenticationMiddleware(api) 226 | # Serve static files from the data directory 227 | app = SharedDataMiddleware(NotFound(), {"/data": data_dir}) 228 | # Relay requests to /api to the klangbecken_api instance 229 | app = DispatcherMiddleware(app, {"/api": api}) 230 | 231 | return app 232 | -------------------------------------------------------------------------------- /klangbecken/player.py: -------------------------------------------------------------------------------- 1 | import re 2 | import socket 3 | import sys 4 | 5 | from werkzeug.exceptions import NotFound 6 | 7 | from .settings import FILE_TYPES, PLAYLISTS 8 | 9 | metadata_re = re.compile(r'^\s*(\S+?)="(.*?)"\s*$', re.M) 10 | 11 | 12 | class LiquidsoapClient: 13 | """Liquidsoap Client. 14 | 15 | Interact with a running Liquidsoap process through it's telnet interface. 16 | 17 | Use as context manager (preferred): 18 | >>> with LiquidsoapClient('/var/run/liquidsoap.sock') as client: 19 | ... client.queue() 20 | 21 | 22 | Use interactively from Python console: 23 | >>> client = LiquidsoapClient() 24 | >>> client.open(("localhost", 1234)) 25 | >>> client.info() 26 | ... 27 | >>> client.command('out.skip') 28 | ... 29 | """ 30 | 31 | def __init__(self, path=None, timeout=0.2): 32 | self.path = path 33 | self.timeout = timeout 34 | self.connected = False 35 | 36 | def __enter__(self): 37 | if not self.connected: 38 | self.open(self.path, self.timeout) 39 | self.log = [] 40 | return self 41 | 42 | def __exit__(self, *exc_info): 43 | # Try to be nice 44 | try: 45 | self.conn.write(b"exit\r\n") 46 | self.conn.read_until(b"Bye!") 47 | finally: 48 | self.close() 49 | 50 | if exc_info[0]: 51 | for cmd, resp in self.log: 52 | print("Command:", cmd, file=sys.stderr) 53 | print("Response:", resp, file=sys.stderr) 54 | print("Exception:", *exc_info, file=sys.stderr) 55 | 56 | del self.log 57 | 58 | def open(self, addr, timeout=0.2): 59 | self.conn = SocketConnection(addr, timeout) 60 | self.connected = True 61 | 62 | def close(self): 63 | self.conn.close() 64 | self.connected = False 65 | 66 | def command(self, cmd): 67 | """Execute a Liquidsoap command. 68 | 69 | Returns the response. 70 | """ 71 | self.conn.write(cmd.encode("ascii", "ignore") + b"\r\n") 72 | ans = self.conn.read_until(b"\r\nEND") 73 | ans = re.sub(b"[\r\n]*END$", b"", ans) 74 | ans = re.sub(b"^[\r\n]*", b"", ans) 75 | ans = re.subn(b"\r", b"", ans)[0] 76 | ans = ans.decode("ascii", "ignore").strip() 77 | if hasattr(self, "log"): 78 | self.log.append((cmd, ans)) 79 | return ans 80 | 81 | def metadata(self, rid): 82 | """Query metadata information for a Liquidsoap request[1]. 83 | 84 | Returns dict with metadata information. 85 | 86 | [1] The scheduling of an audio track for playing is called a 'request' 87 | in Liquidsoap jargon. 88 | """ 89 | ans = self.command(f"request.metadata {rid}") 90 | return dict(re.findall(metadata_re, ans)) 91 | 92 | def info(self): 93 | """Query general information about the state of the player. 94 | 95 | * Versions and uptime 96 | * 'on air' state 97 | * Current track, if on air. 98 | * Next scheduled track for all playlists and the queue, if any. 99 | """ 100 | from . import __version__ 101 | 102 | info = { 103 | "uptime": self.command("uptime"), 104 | "liquidsoap_version": self.command("version"), 105 | "api_version": __version__, 106 | "python_version": sys.version.split()[0], 107 | } 108 | on_air = self.command("klangbecken.on_air").lower() == "true" 109 | info["on_air"] = on_air 110 | 111 | if on_air: 112 | for playlist in PLAYLISTS: 113 | lines = self.command(f"{playlist}.next").strip().split("\n") 114 | lines = [ 115 | line for line in lines if line and not line.startswith("[playing] ") 116 | ] 117 | info[playlist] = _extract_id(lines[0], playlist) if lines else "" 118 | else: 119 | for playlist in PLAYLISTS: 120 | info[playlist] = "" 121 | 122 | if on_air: 123 | on_air_rid = self.command("request.on_air").strip() 124 | if on_air_rid: 125 | metadata = self.metadata(on_air_rid) 126 | info["current_track"] = { 127 | "source": metadata["source"], 128 | "id": _extract_id(metadata["filename"]), 129 | } 130 | else: 131 | info["current_track"] = {} 132 | 133 | queue = ( 134 | self.metadata(rid) for rid in self.command("queue.queue").strip().split() 135 | ) 136 | 137 | queue = (entry for entry in queue if entry["status"] == "ready") 138 | entry = next(queue, None) 139 | info["queue"] = _extract_id(entry["filename"]) if entry else "" 140 | 141 | return info 142 | 143 | def queue(self): 144 | """List the contents of the queue.""" 145 | queue = [ 146 | self.metadata(rid) for rid in self.command("queue.queue").strip().split() 147 | ] 148 | for entry in queue: 149 | if entry["status"] not in ("playing", "ready"): # pragma: no cover 150 | print( 151 | f"WARNING: Queue entry ({entry['rid']}: {entry['filename']} with " 152 | f"invalid status: {entry['status']}", 153 | file=sys.stderr, 154 | ) 155 | 156 | queue = [entry for entry in queue if entry["status"] == "ready"] 157 | queue = [ 158 | { 159 | "id": _extract_id(entry["filename"]), 160 | "queue_id": entry["rid"], 161 | "queue": entry["queue"], 162 | } 163 | for entry in queue 164 | ] 165 | return queue 166 | 167 | def push(self, path): 168 | """Add a new track to the queue. 169 | 170 | Returns the request id for the added track. 171 | """ 172 | rid = self.command(f"queue.push {path}").strip() 173 | if self.metadata(rid)["status"] != "ready": # pragma: no cover 174 | try: 175 | self.delete(rid) 176 | except Exception: 177 | pass 178 | raise LiquidsoapClientQueueError("Queue push failed") 179 | 180 | return rid 181 | 182 | def delete(self, rid): 183 | """Delete track from the queue.""" 184 | if rid not in self.command("queue.secondary_queue").strip().split(): 185 | raise NotFound(f"Track with QueueID '{rid}' not found.") 186 | 187 | ans = self.command(f"queue.remove {rid}") 188 | if ans.strip() != "OK" or self.metadata(rid)["status"] != "destroyed": 189 | raise LiquidsoapClientQueueError("Queue delete failed") # pragma: no cover 190 | 191 | 192 | filename_res = { 193 | playlist: re.compile( 194 | r"^.*{0}/([0-9a-f-]+)\.(?:{1})$".format(playlist, "|".join(FILE_TYPES.keys())) 195 | ) 196 | for playlist in PLAYLISTS 197 | } 198 | 199 | filename_res[None] = re.compile( 200 | r"^.*(?:{0})/([0-9a-f-]+)\.(?:{1})$".format( 201 | "|".join(PLAYLISTS), "|".join(FILE_TYPES.keys()) 202 | ) 203 | ) 204 | 205 | 206 | def _extract_id(filename, playlist=None): 207 | return re.findall(filename_res[playlist], filename)[0] 208 | 209 | 210 | class LiquidsoapClientQueueError(Exception): 211 | pass 212 | 213 | 214 | class SocketConnection: 215 | def __init__(self, addr=None, timeout=None): 216 | self.in_data = b"" 217 | if addr is not None: 218 | self.open(addr, timeout) 219 | 220 | def open(self, addr, timeout=None): 221 | if isinstance(addr, str): # Path to UNIX domain socket 222 | try: 223 | self.sock = socket.socket(socket.AF_UNIX) 224 | self.sock.connect(addr) 225 | self.sock.settimeout(timeout) 226 | except OSError: 227 | try: 228 | self.sock.close() 229 | finally: 230 | pass 231 | raise 232 | else: # IP-Address and Port 233 | self.sock = socket.create_connection(addr, timeout) 234 | 235 | def write(self, data): 236 | self.sock.send(data) 237 | 238 | def read_until(self, expected): 239 | while expected not in self.in_data: 240 | self.in_data += self.sock.recv(128) 241 | expected_end = self.in_data.index(expected) + len(expected) 242 | data = self.in_data[:expected_end] 243 | self.in_data = self.in_data[expected_end:] 244 | return data 245 | 246 | def close(self): 247 | self.sock.close() 248 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Klangbecken 2 | 3 | [![Python package](https://github.com/radiorabe/klangbecken/workflows/Python%20package/badge.svg)](https://github.com/radiorabe/klangbecken/actions?query=workflow%3A%22Python+package%22) 4 | [![Liquidsoap script](https://github.com/radiorabe/klangbecken/workflows/Liquidsoap%20script/badge.svg)](https://github.com/radiorabe/klangbecken/actions?query=workflow%3A%22Liquidsoap+script%22) 5 | [![Code Style Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 6 | 7 | _Klangbecken_ is the minimalistic endless music player for Radio Bern RaBe based on [liquidsoap](https://www.liquidsoap.info). 8 | 9 | It supports configurable and editable playlists, jingle insertion, metadata publishing and more. 10 | 11 | It is [designed](doc/design.md) for stand-alone operation, robustness and good maintainability. All data is stored in common files in a single [data directory](doc/data-dir.md). 12 | 13 | This repository contains three components of the RaBe Klangbecken: 14 | * The [API](doc/api.md) 15 | * The [command line interface](doc/cli.md) 16 | * The [liquidsoap play-out script](klangbecken.liq) 17 | 18 | Two additional components are in their own repository: 19 | * The listener for the current "on air" status, the [virtual Sämubox](https://github.com/radiorabe/virtual-saemubox). 20 | * The [web-based UI](https://github.com/radiorabe/klangbecken-ui) for playlist editing. 21 | 22 | How they interact can be seen in the [system overview diagram](doc/system-overview.png): 23 | 24 | ![System overview diagram](doc/system-overview.png) 25 | 26 | ## System requirements 27 | * Unix-like operating system environment 28 | * **Python** (>= v3.9) 29 | * *docopt* library for parsing command line arguments 30 | * *Werkzeug* library (>= v2.0) for WSGI support 31 | * *PyJWT* library (>= v2.0) for creating and verifying JWT authentication tokens 32 | * *mutagen* library for audio tag editing 33 | * **ffmpeg** binary (>= v2.8) for audio analysis 34 | * **Liquidsoap** audio player (v1.3 _without_ inotify support) 35 | 36 | 37 | ## Local Setup 38 | 39 | Fork this repository and clone it from there: 40 | ```bash 41 | git clone https://github.com/YOUR-GITHUB-USERNAME/klangbecken.git 42 | cd klangbecken 43 | ``` 44 | 45 | Create a virtual environment (also see [additional tools](doc/additional-tools.md)): 46 | ```bash 47 | python -m venv .venv 48 | source .venv/bin/activate 49 | ``` 50 | 51 | ### Install dependencies 52 | Install Python dependencies: 53 | ```bash 54 | pip install -r requirements.txt 55 | ``` 56 | Install `ffmpeg` with your system's package manager. E.g.: 57 | ```bash 58 | yum install ffmpeg 59 | ``` 60 | Install Liquidsoap (_Note:_ On CentOS 7 you can also use our prebuilt [package](https://github.com/radiorabe/centos-rpm-liquidsoap)): 61 | ```bash 62 | yum install opam 63 | opam init 64 | # we need liquidsoap 1.3.7 which does not run after OCaml 4.07.0 65 | opam switch create klangbecken 4.07.0 66 | opam depext alsa mad lame vorbis taglib liquidsoap.1.3.7 67 | opam install alsa mad lame vorbis taglib liquidsoap.1.3.7 68 | eval $(opam env) 69 | ``` 70 | 71 | Install the client UI: 72 | ```bash 73 | cd .. 74 | git clone https://github.com/radiorabe/klangbecken-ui 75 | cd klangbecken-ui 76 | npm install 77 | ``` 78 | 79 | ### Run the programs 80 | 81 | Initialize the data directory: 82 | ```bash 83 | python -m klangbecken init 84 | ``` 85 | 86 | Run the development back-end server (API and data directory): 87 | ```bash 88 | python -m klangbecken serve 89 | ``` 90 | 91 | Run the client development server (user interface): 92 | ```bash 93 | cd ../klangbecken-ui 94 | npm run serve 95 | ``` 96 | 97 | Browse to http://localhost:8080 and start uploading audio files. 98 | 99 | Run the liquidsoap audio player: 100 | ```bash 101 | eval $(opam env) 102 | liquidsoap klangbecken.liq 103 | ``` 104 | 105 | Manually set the on-air status of the player using `netcat`: 106 | ```bash 107 | echo "klangbecken.on_air True" | nc -U -w 1 klangbecken.sock 108 | ``` 109 | 110 | 111 | ## Development 112 | 113 | For contributing to this project, fork this repository, and clone your local working copy from your personal fork. Push commits to your repository to create pull requests with your changes. 114 | 115 | ### Python Package 116 | 117 | The Python code is tested with a test suite and follows the flake8 coding guidelines. 118 | 119 | Before submitting your code you might want to make sure that ... 120 | 121 | 1. ... you have installed the test dependencies 122 | ```bash 123 | pip install -r requirements-test.txt 124 | ``` 125 | 126 | 2. ... the test suite runs without failure 127 | ```bash 128 | python -m unittest discover 129 | ``` 130 | 3. ... all your code is covered by (hopefully) meaningful unit tests 131 | ```bash 132 | coverage run -m unittest discover 133 | coverage report 134 | ``` 135 | 4. ... your code follows the coding style guidelines 136 | ```bash 137 | flake8 138 | ``` 139 | 140 | #### Recommended Tools _(optional)_ 141 | 142 | We recommend the use of `tox`, `black` and `isort` for development. 143 | ```bash 144 | pip install tox black isort 145 | ``` 146 | ##### tox 147 | Instead of running all the above commands manually, `tox` lets you run them all at once for all installed Python versions. Make sure to have at least the Python version installed, that is used in production (currently Python 3.9). `tox` is also what we use in continuos integration, so using it locally helps you to make your code pass it. To call it simply type: 148 | ```bash 149 | tox 150 | ``` 151 | 152 | ##### black 153 | Manually fixing coding style mistakes is a pain. `black` formats your code automatically. 154 | ```bash 155 | black . 156 | ``` 157 | 158 | ##### isort 159 | Finally, `isort` helps to consistently organize package imports. 160 | ```bash 161 | isort . 162 | ``` 163 | 164 | All development tools are preconfigured in [`setup.cfg`](setup.cfg). For additional tools and tips & tricks and see [additional tools](doc/additional-tools.md). 165 | 166 | ### Liquidsoap Script 167 | 168 | Liquidsoap lets you syntax check and type check your script: 169 | ```bash 170 | liquidsoap --check klangbecken.liq 171 | ``` 172 | 173 | #### Simulation 174 | 175 | Apart from type checking, the inherent nature of the liquidsoap language generating a live audio stream makes it difficult to test the code with unit tests. Observing the behavior of the player script and the effects of changes in real-time take lot of time, usually weeks or even months. [Accelerated simulation runs](doc/simulation.md) help to observe the long-time player behavior in a reasonable amount of time. 176 | 177 | ## Deployment 178 | 179 | Your code has passed continuous integration, and your pull request has been accepted. Now you want to deploy your (or somebody else's) code to production. First, some preparations are necessary, but then the deployment script `deploy.sh` automates most of the work deploying the code. 180 | 181 | _Preparations before deploying for the first time:_ 182 | * Make sure that you have access to the production server (e.g. SSH publik key authentication). 183 | * Configure a remote `prod` pointing at the repository on the production system: 184 | ```bash 185 | git add remote prod root@YOUR_PRODUCTION_VM_NAME:klangbecken.git 186 | ``` 187 | * _Optional:_ Install the Apache development libraries locally. E.g.: 188 | ```bash 189 | yum install httpd-devel 190 | ``` 191 | * Configure a remote repository `upstream` pointing at the upstream repository: 192 | ```bash 193 | git remote add upstream git@github.com:radiorabe/klangbecken-ui.git 194 | ``` 195 | * Configure git to automatically fetch tags from `upstream`: 196 | ```bash 197 | git config remote.upstream.tagOpt --tags 198 | ``` 199 | 200 | 201 | _Preparation before deploying_: 202 | * When deploying both, the [front-end](https://github.com/radiorabe/klangbecken-ui) and the back-end app, deploy the front-end _first_. 203 | * Check again that the code you want to deploy passed continuous integration. 204 | * Make sure that your working directory is clean, and that you are on the master branch: 205 | ```bash 206 | git stash 207 | git checkout master 208 | ``` 209 | * Bring your code in sync with the latest version from `upstream`: 210 | ```bash 211 | git fetch upstream 212 | git rebase upstream/master 213 | ``` 214 | * Verify that you are indeed in sync with `upstream`: 215 | ```bash 216 | git show --no-patch 217 | ``` 218 | 219 | _Run the script:_ 220 | ```bash 221 | ./deploy.sh [--no-mod-wsgi] 222 | ``` 223 | It performs the following steps: 224 | - Increment and commit a new version number. 225 | - Download all run-time dependencies. 226 | - Optionally download `mod_wsgi` (Requires `httpd-devel` libraries to be installed locally. Use `--no-mod-wsgi` to skip this step). 227 | - Copy the dependencies to production. 228 | - Push your code to production. 229 | - Install all dependencies in production. 230 | - Install the Python package (API and CLI) in production. 231 | - Reload the web server to load the new API code. 232 | - Copy the liquidsoap script to it's destination. 233 | - If everything was successful, tag the current commit with the new version number, and push it to the `upstream` repository. 234 | 235 | _Finalize deployment:_ 236 | - If the liquidsoap script `klangbecken.liq` changed, restart the liquidsoap player during an "off air" moment: 237 | ```bash 238 | systemctl restart liquidsoap@klangbecken 239 | ``` 240 | 241 | For detailed information on how to setup a productive server see [Deployment](doc/deployment.md). 242 | 243 | ## License 244 | 245 | _Klangbecken_ is released under the terms of the GNU Affero General Public License. Copyright 2017-2022 Radio Bern RaBe. See `LICENSE` for further information. 246 | -------------------------------------------------------------------------------- /klangbecken.liq: -------------------------------------------------------------------------------- 1 | # ================================================= # 2 | # SETTINGS # 3 | # # 4 | # Environment Variables: # 5 | # - KLANGBECKEN_ALSA_DEVICE # 6 | # ALSA device to send sound to # 7 | # Default: default # 8 | # # 9 | # - KLANGBECKEN_DATA_DIR # 10 | # Directory with data # 11 | # Default: ./data # 12 | # # 13 | # - KLANGBECKEN_COMMAND # 14 | # Klangbecken command # 15 | # Default: python -m klangbecken # 16 | # # 17 | # - KLANGBECKEN_PLAYER_SOCKET # 18 | # Path to socket to listen on # 19 | # Default: ./klangbecken.sock # 20 | # ================================================= # 21 | 22 | # return value of environment variable if set, otherwise fallback 23 | def getenv_fallback(var, fallback) = 24 | if getenv(var) != "" then 25 | getenv(var) 26 | else 27 | fallback 28 | end 29 | end 30 | 31 | # log file 32 | set("log.file", true) 33 | set("log.stdout", true) 34 | set("server.telnet", false) 35 | set("server.telnet.port", 1234) 36 | set("log.level", 3) 37 | 38 | # socket 39 | set("server.socket", true) 40 | set("server.socket.path", getenv_fallback("KLANGBECKEN_PLAYER_SOCKET", "./klangbecken.sock")) 41 | set("server.socket.permissions", 0o660) # Make socket group readable/writable 42 | 43 | # Get the Klangbecken data directory 44 | DATA_DIR = getenv_fallback("KLANGBECKEN_DATA_DIR", "./data") 45 | if not file.is_directory(DATA_DIR) then 46 | log("ERROR: Cannot find data directory: #{DATA_DIR}", level=0) 47 | shutdown() 48 | end 49 | 50 | if DATA_DIR == "./data" then 51 | set("log.file.path", "./klangbecken.log") 52 | end 53 | 54 | # Get the klangbecken command 55 | KLANGBECKEN_COMMAND = getenv_fallback("KLANGBECKEN_COMMAND", "python -m klangbecken") 56 | 57 | # Get the alsa device 58 | ALSA_DEVICE = getenv_fallback("KLANGBECKEN_ALSA_DEVICE", "default") 59 | 60 | on_air = ref false 61 | 62 | # ================================================= # 63 | # PLAYLISTS # 64 | # ================================================= # 65 | 66 | # calculate waiting time for repeating a track depending on its playlist 67 | def calc_wait(playlist) = 68 | if playlist == "music" then 172800.0 # 2 days 69 | elsif playlist == "classics" then 172800.0 # 2 days 70 | elsif playlist == "jingles" then 3600.0 # 1 hour 71 | else 72 | log("WARNING: invalid playlist: #{playlist}", level=1, label="calc_wait") 73 | 0.0 74 | end 75 | end 76 | 77 | # check if track was played recently 78 | skipped = ref 0 79 | def check_next_func(r) = 80 | metadata = request.metadata(r) 81 | filename = request.filename(r) 82 | last_play_str = string.trim(metadata["last_play"]) 83 | 84 | if filename == "" then 85 | false 86 | elsif last_play_str == "" then 87 | skipped := 0 88 | log("track was never played before: #{filename}", label="check_next_func") 89 | true 90 | else 91 | # Convert ISO-8601 timestamp to epoch seconds 92 | last_play_str = get_process_output("date +%s -d #{last_play_str}") 93 | last_play_str = string.trim(last_play_str) 94 | last_play = float_of_string(last_play_str) 95 | 96 | diff = gettimeofday() - last_play 97 | playlist = metadata["source"] 98 | if diff < calc_wait(playlist) then 99 | skipped := !skipped + 1 100 | log("track was recently played: #{filename} (#{diff} seconds ago)", label="check_next_func") 101 | if !skipped >= 20 then 102 | skipped := 0 103 | if !on_air then 104 | log("WARNING: too many skipped tracks, playing #{filename} anyway", level=1, label="check_next_func") 105 | end 106 | true 107 | else 108 | false 109 | end 110 | else 111 | skipped := 0 112 | log("next: #{filename} (track was last played #{diff} seconds ago)", label="check_next_func") 113 | true 114 | end 115 | end 116 | end 117 | 118 | # Priority queue 119 | queue = request.equeue(id="queue", length=5.0) 120 | # Convert mono queue entries (jingles) to stereo 121 | queue = audio_to_stereo(queue) 122 | # Cut silence at start and end 123 | queue = cue_cut(queue, cue_in_metadata="cue_in", cue_out_metadata="cue_out") 124 | 125 | # Music playlist 126 | music = playlist( 127 | path.concat(DATA_DIR, "music.m3u"), 128 | id="music", 129 | mode="randomize", 130 | reload_mode="watch", 131 | check_next=check_next_func, 132 | ) 133 | # Cut silence at start and end 134 | music = cue_cut(music, cue_in_metadata="cue_in", cue_out_metadata="cue_out") 135 | 136 | # Classics playlist 137 | classics = playlist( 138 | path.concat(DATA_DIR, "classics.m3u"), 139 | id="classics", 140 | mode="randomize", 141 | reload_mode="watch", 142 | check_next=check_next_func, 143 | ) 144 | # Cut silence at start and end 145 | classics = cue_cut(classics, cue_in_metadata="cue_in", cue_out_metadata="cue_out") 146 | 147 | # Jingles playlist 148 | jingles = playlist( 149 | path.concat(DATA_DIR, "jingles.m3u"), 150 | id="jingles", 151 | mode="randomize", 152 | reload_mode="watch", 153 | check_next=check_next_func, 154 | ) 155 | # Convert mono jingles to stereo 156 | jingles = audio_to_stereo(jingles) 157 | # Cut silence at start and end 158 | jingles = cue_cut(jingles, cue_in_metadata="cue_in", cue_out_metadata="cue_out") 159 | 160 | 161 | # ================================================= # 162 | # MIX MUSIC AND CLASSICS # 163 | # ================================================= # 164 | 165 | music = random(weights=[5, 1], [music, classics]) 166 | 167 | 168 | # ================================================= # 169 | # INSERT JINGLE AND QUEUE TRACKS WHEN NEEDED # 170 | # ================================================= # 171 | 172 | insert_jingle = ref false 173 | 174 | def jingle_timeout() = 175 | jingle_times = [5m0s, 20m0s, 35m0s, 50m0s] 176 | if list.fold(fun (a,b) -> a or b, false, jingle_times) then 177 | log("Jingle up next", label="jingle_timeout") 178 | insert_jingle := true 179 | end 180 | 1.0 181 | end 182 | add_timeout(0.0, jingle_timeout) 183 | 184 | radio = switch(id="radio", [ 185 | ({!insert_jingle}, jingles), 186 | ({!on_air}, queue), 187 | ({true}, music), 188 | ]) 189 | 190 | def on_track_func(m) = 191 | # Reset jingle playing flag 192 | if m["source"] == "jingles" then 193 | insert_jingle := false 194 | end 195 | end 196 | radio = on_track(on_track_func, radio) 197 | 198 | 199 | # ================================================= # 200 | # REGISTER EXTERNAL RESTART COMMAND # 201 | # ================================================= # 202 | 203 | restart = ref false 204 | 205 | def on_air_func(state) = 206 | state = string.case(state) 207 | state = string.trim(state) 208 | if state == "" then 209 | # Return state 210 | "#{!on_air}" 211 | else 212 | on_air := bool_of_string(state) 213 | if !on_air then 214 | log("INFO: Starting Klangbecken", level=2, label="on_air_func") 215 | restart := true 216 | source.skip(radio) 217 | "Klangbecken started" 218 | else 219 | log("INFO: Stopping Klangbecken", level=2, label="on_air_func") 220 | "Klangbecken stopped" 221 | end 222 | end 223 | end 224 | 225 | server.register( 226 | namespace="klangbecken", 227 | "on_air", 228 | on_air_func, 229 | usage="on_air [true|false]", 230 | description="Control if the player is on air. Returns the current state, if called without argument." 231 | ) 232 | 233 | # Have restart delay and fade dynamically reconfigurable 234 | # for debugging purpose 235 | restart_delay = interactive.float("restart.delay", 1.0) 236 | restart_fade = interactive.float("restart.fade", 1.0) 237 | 238 | def trans(old, new) = 239 | if !restart and source.id(new) == "radio" then 240 | restart := false 241 | sequence([blank(duration=restart_delay()), 242 | fade.initial(duration=restart_fade(), new)]) 243 | else 244 | new 245 | end 246 | end 247 | 248 | radio = fallback(track_sensitive=false, 249 | transitions=[trans], 250 | [radio, blank(id="blank")]) 251 | 252 | 253 | # ================================================= # 254 | # LOGGING METADATA # 255 | # ================================================= # 256 | 257 | to_log_filenames = ref [] 258 | 259 | def log_metadata_func(m) = 260 | if !on_air and m["filename"] != "" then 261 | # Prepare play logger 262 | filename = m["filename"] 263 | to_log_filenames := list.append(!to_log_filenames, [filename]) 264 | log("INFO: Playing: #{filename}", label="log_metadata_func", level=2) 265 | end 266 | end 267 | radio = on_track(log_metadata_func, radio) 268 | 269 | def run_play_logger() = 270 | filename = list.hd(!to_log_filenames, default="") 271 | to_log_filenames := list.tl(!to_log_filenames) 272 | if (filename != "") then 273 | log("#{KLANGBECKEN_COMMAND} playlog -d #{DATA_DIR} #{filename}", label="run_play_logger") 274 | system("#{KLANGBECKEN_COMMAND} playlog -d #{DATA_DIR} #{filename}") 275 | end 276 | end 277 | 278 | # Run the logging command in the background, not to lock up the player 279 | exec_at(pred=fun() -> list.length(!to_log_filenames) > 0, run_play_logger) 280 | 281 | 282 | # ================================================= # 283 | # AUDIO PROCESSING # 284 | # ================================================= # 285 | 286 | # Apply calculated replay gain 287 | radio = amplify(1., override="replaygain_track_gain", radio) 288 | # Moderate cross-fading 289 | radio = crossfade(start_next=.5, fade_out=1., fade_in=0., radio) 290 | 291 | 292 | # ================================================= # 293 | # OUTPUT # 294 | # ================================================= # 295 | 296 | output.alsa(id="out", device=ALSA_DEVICE, radio) 297 | -------------------------------------------------------------------------------- /doc/simulation-scripts/analysis.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import csv 3 | import datetime 4 | import itertools 5 | import json 6 | import os 7 | import pathlib 8 | import re 9 | import statistics 10 | import sys 11 | 12 | here = pathlib.Path(__file__).parent.resolve() 13 | root = here.parent.parent 14 | sys.path.append(str(root)) 15 | 16 | if not hasattr(datetime.datetime, "fromisoformat"): 17 | print("ERROR: datetime.fromisoformat missing") 18 | print("Install 'fromisoformat' backport package or use a Python version >= 3.7") 19 | exit(1) 20 | 21 | print("1. Did errors occur?") 22 | print("--------------------") 23 | err_pattern = re.compile(r"(:1\]|warn|error)", re.I) 24 | with open("klangbecken.log") as log_file: 25 | errors = [line.rstrip() for line in log_file if re.search(err_pattern, line)] 26 | if errors: 27 | print("❌ Errors:") 28 | for error in errors: 29 | print(error) 30 | else: 31 | print("✅ No") 32 | print() 33 | 34 | print("2. State of the data directory still good?") 35 | print("------------------------------------------") 36 | 37 | from klangbecken.cli import fsck_cmd # noqa: E402 38 | 39 | try: 40 | fsck_cmd("data") 41 | except SystemExit as e: 42 | if e.code == 0: 43 | print("✅ Yes") 44 | else: 45 | print("❌ Error ocurred") 46 | print() 47 | 48 | print("3. Did all played tracks get logged?") 49 | print("------------------------------------") 50 | with open("klangbecken.log") as log_file: 51 | played = sum(1 for line in log_file if "INFO: Playing:" in line) 52 | 53 | log_entries = [] 54 | for f in os.listdir("data/log"): 55 | log_entries.extend(csv.DictReader(open(os.path.join("data/log", f)))) 56 | 57 | logged = len(log_entries) 58 | 59 | if logged == played: 60 | print(f"✅ Yes ({logged} track plays)") 61 | else: 62 | print( 63 | f"❌ Error: Number of played tracks ({played}) is different from the " 64 | f"number of logged tracks ({logged})" 65 | ) 66 | print() 67 | 68 | 69 | print("4. Ratio music vs. classics") 70 | print("---------------------------") 71 | music = sum(1 for entry in log_entries if entry["playlist"] == "music") 72 | classics = sum(1 for entry in log_entries if entry["playlist"] == "classics") 73 | ratio = music / classics 74 | 75 | if abs(ratio - 5) < 0.25: 76 | print(f"✅ Good: {ratio:.2f} to 1") 77 | else: 78 | print(f"❌ Music vs. classics ratio is off: {ratio:.2f} to 1") 79 | print() 80 | 81 | print("5. Music play distribution") 82 | print("--------------------------") 83 | with open("data/index.json") as index_file: 84 | index_entries = json.load(index_file) 85 | 86 | music_plays = [ 87 | entry["play_count"] 88 | for entry in index_entries.values() 89 | if entry["playlist"] == "music" 90 | ] 91 | avg = statistics.mean(music_plays) 92 | stdev = statistics.pstdev(music_plays, avg) 93 | deciles = statistics.quantiles(music_plays, n=10) 94 | normal_dist = statistics.NormalDist(avg, stdev) 95 | diffs = [ 96 | measured - expected 97 | for measured, expected in zip(deciles, normal_dist.quantiles(n=10)) 98 | ] 99 | 100 | if all(abs(diff) <= 1 for diff in diffs): 101 | print(f"✅ Normal distribution: {avg:.2f}±{stdev:.2f}") 102 | elif all(abs(diff) <= 3 for diff in diffs): 103 | print(f"🔶 Almost normal distribution: {avg:.2f}±{stdev:.2f}") 104 | for i, diff in enumerate(diffs): 105 | if diff > 1: 106 | print( 107 | f" {i + 1}. decile: {diff:.2f} off (measured {deciles[i]:.2f}, " 108 | f"expected {deciles[i] - diff:.2f})" 109 | ) 110 | else: 111 | print("❌ Not normally distributed") 112 | print() 113 | 114 | print("6. Classics play distribution") 115 | print("-----------------------------") 116 | classics_plays = [ 117 | entry["play_count"] 118 | for entry in index_entries.values() 119 | if entry["playlist"] == "classics" 120 | ] 121 | avg = statistics.mean(classics_plays) 122 | stdev = statistics.pstdev(classics_plays, avg) 123 | deciles = statistics.quantiles(classics_plays, n=10) 124 | normal_dist = statistics.NormalDist(avg, stdev) 125 | diffs = [ 126 | measured - expected 127 | for measured, expected in zip(deciles, normal_dist.quantiles(n=10)) 128 | ] 129 | 130 | if all(abs(diff) <= 1 for diff in diffs): 131 | print(f"✅ Normal distribution: {avg:.2f}±{stdev:.2f}") 132 | elif all(abs(diff) <= 3 for diff in diffs): 133 | print(f"🔶 Almost normal distribution: {avg:.2f}±{stdev:.2f}") 134 | for i, diff in enumerate(diffs): 135 | if diff > 1: 136 | print( 137 | f" {i + 1}. decile: {diff:.2f} off (measured {deciles[i]:.2f}, " 138 | f"expected {deciles[i] - diff:.2f})" 139 | ) 140 | else: 141 | print("❌ Not normally distributed") 142 | print() 143 | 144 | print("7. Weighted jingle play distribution") 145 | print("------------------------------------") 146 | jingles_plays = [ 147 | entry["play_count"] / entry["weight"] 148 | for entry in index_entries.values() 149 | if entry["playlist"] == "jingles" and entry["weight"] != 0 150 | ] 151 | avg = statistics.mean(jingles_plays) 152 | stdev = statistics.pstdev(jingles_plays, avg) 153 | deciles = statistics.quantiles(jingles_plays, n=10) 154 | normal_dist = statistics.NormalDist(avg, stdev) 155 | diffs = [ 156 | measured - expected 157 | for measured, expected in zip(deciles, normal_dist.quantiles(n=10)) 158 | ] 159 | 160 | # Uncomment for details: 161 | # print(diffs) 162 | # print(sum(diffs)) 163 | 164 | if all(abs(diff) <= stdev / 2 for diff in diffs) and sum(diffs) < stdev / 2: 165 | print(f"✅ Normal distribution: {avg:.2f}±{stdev:.2f}") 166 | elif all(abs(diff) <= stdev for diff in diffs) and sum(diffs) < stdev: 167 | print(f"🔶 Almost normal distribution: {avg:.2f}±{stdev:.2f}") 168 | for i, diff in enumerate(diffs): 169 | if abs(diff) > stdev / 2: 170 | print( 171 | f" {i + 1}. decile: {diff:.2f} off (measured {deciles[i]:.2f}, " 172 | f"expected {deciles[i] - diff:.2f})" 173 | ) 174 | else: 175 | print("❌ Not normally distributed") 176 | print() 177 | 178 | print("8. Disabled jingles not played?") 179 | print("-------------------------------") 180 | disabled_plays = sum( 181 | entry["play_count"] 182 | for entry in index_entries.values() 183 | if entry["playlist"] == "jingles" and entry["weight"] == 0 184 | ) 185 | if disabled_plays == 0: 186 | print("✅ Yes") 187 | else: 188 | print(f"❌ {disabled_plays} plays of disabled tracks") 189 | print() 190 | 191 | print("9. Jingle weights respected?") 192 | print("----------------------------") 193 | prioritized_jingles = [ 194 | {"weight": entry["weight"], "play_count": entry["play_count"]} 195 | for entry in index_entries.values() 196 | if entry["playlist"] == "jingles" and entry["weight"] > 1 197 | ] 198 | by_weight = sorted(prioritized_jingles, key=lambda x: x["weight"]) 199 | by_plays = sorted(prioritized_jingles, key=lambda x: x["play_count"]) 200 | 201 | # Uncomment for details: 202 | # print(*(e["weight"] for e in by_plays)) 203 | # print(by_plays) 204 | 205 | if by_weight == by_plays: 206 | print("✅ Yes") 207 | else: 208 | print("❌ No") 209 | print() 210 | 211 | print("10. Are Jingles played regularly?") 212 | print("---------------------------------") 213 | spacings = [] 214 | spacing = 0 215 | jingle_plays = 0 216 | for entry in log_entries: 217 | if entry["playlist"] == "jingles": 218 | spacings.append(spacing) 219 | spacing = 0 220 | jingle_plays += 1 221 | else: 222 | spacing += 1 223 | counter = collections.Counter(spacings) 224 | # Look a all "spacings" that happen at least 0.5% of the time 225 | almost_all = list( 226 | itertools.takewhile(lambda x: x[1] / jingle_plays > 0.005, counter.most_common()) 227 | ) 228 | almost_all_min = min(spacing for spacing, _ in almost_all) 229 | almost_all_max = max(spacing for spacing, _ in almost_all) 230 | almost_all_count = sum(count for _, count in almost_all) 231 | # Look at the three most common spacings 232 | most_common = counter.most_common(3) 233 | most_common_min = min(spacing for spacing, _ in most_common) 234 | most_common_max = max(spacing for spacing, _ in most_common) 235 | most_common_count = sum(count for _, count in most_common) 236 | 237 | # Uncomment for details: 238 | # print(counter) 239 | # print(almost_all) 240 | # print(almost_all_count / jingle_plays) 241 | # print(most_common) 242 | # print(most_common_count / jingle_plays) 243 | 244 | if ( 245 | almost_all_min > 1 246 | and almost_all_max < 10 247 | and almost_all_count / jingle_plays > 0.975 248 | and most_common_min > 2 249 | and most_common_max < 7 250 | and most_common_count / jingle_plays > 0.8 251 | ): 252 | print("✅ Yes") 253 | else: 254 | print("❌ No") 255 | print() 256 | 257 | print("11. Waiting time between track plays (music & classics) respected?") 258 | print("------------------------------------------------------------------") 259 | 260 | ids = index_entries.keys() 261 | 262 | music = [ 263 | (entry["id"], entry["last_play"]) 264 | for entry in log_entries 265 | if entry["playlist"] in ("music", "classics") 266 | ] 267 | music_count = len(music) 268 | music_plays = { 269 | id: [ 270 | datetime.datetime.fromisoformat(last_play) 271 | for id_, last_play in music 272 | if id_ == id 273 | ] 274 | for id in ids 275 | } 276 | music_diffs = [ 277 | (id, [(l2 - l1).total_seconds() for l1, l2 in zip(lst, lst[1:])]) 278 | for id, lst in music_plays.items() 279 | ] 280 | music_too_short = [[(id, v) for v in lst if v < 1728] for id, lst in music_diffs] 281 | music_too_short = list(itertools.chain(*music_too_short)) # flatten list 282 | too_short_count = len(music_too_short) 283 | percentage = too_short_count / music_count * 100 284 | 285 | # Uncomment for details: 286 | # print(music_too_short) 287 | 288 | if too_short_count == 0: 289 | print("✅ Waiting periods always met") 290 | elif percentage < 0.5: 291 | print( 292 | f"✅ Waiting periods almost always met: {too_short_count} missed out of " 293 | f"{music_count} ({percentage:.3f}%)" 294 | ) 295 | elif percentage < 2: 296 | print( 297 | f"🔶 Waiting periods mostly met: {too_short_count} missed out of " 298 | f"{music_count} ({percentage:.2f}%)" 299 | ) 300 | else: 301 | print( 302 | f"❌ Waiting periods not met: {too_short_count} missed out of " 303 | f"{music_count} ({percentage:.2f}%)" 304 | ) 305 | print() 306 | -------------------------------------------------------------------------------- /test/test_cli_import.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import os 4 | import shutil 5 | import tempfile 6 | import unittest 7 | from unittest import mock 8 | 9 | import mutagen 10 | 11 | from .utils import capture 12 | 13 | 14 | class ImporterTestCase(unittest.TestCase): 15 | def setUp(self): 16 | from klangbecken.cli import _check_data_dir 17 | 18 | self.current_path = os.path.dirname(os.path.realpath(__file__)) 19 | self.tempdir = tempfile.mkdtemp() 20 | # self.music_dir = os.path.join(self.tempdir, "music") 21 | self.jingles_dir = os.path.join(self.tempdir, "jingles") 22 | _check_data_dir(self.tempdir, create=True) 23 | 24 | def tearDown(self): 25 | shutil.rmtree(self.tempdir) 26 | 27 | def testCorruptDataDir(self): 28 | from klangbecken.cli import main 29 | 30 | os.unlink(os.path.join(self.tempdir, "index.json")) 31 | audio_path = os.path.join(self.current_path, "audio") 32 | cmd = f"klangbecken import -d {self.tempdir} -y -m jingles {audio_path}" 33 | with self.assertRaises(SystemExit) as cm: 34 | with mock.patch("sys.argv", cmd.split()): 35 | with capture(main) as (out, err, ret): 36 | pass 37 | self.assertIn("ERROR: Problem with data directory.", err) 38 | self.assertEqual(cm.exception.code, 1) 39 | 40 | def testImportMtime(self): 41 | from klangbecken.cli import main 42 | 43 | audio_path = os.path.join(self.current_path, "audio") 44 | audio1_path = os.path.join(audio_path, "sine-unicode-stereo.mp3") 45 | audio1_mtime = datetime.datetime.fromtimestamp(os.stat(audio1_path).st_mtime) 46 | 47 | # Import nothing -> usage 48 | cmd = f"klangbecken import -d {self.tempdir} --yes --mtime jingles" 49 | with mock.patch("sys.argv", cmd.split()): 50 | with self.assertRaises(SystemExit) as cm: 51 | with capture(main) as (out, err, ret): 52 | pass 53 | self.assertTrue(hasattr(cm.exception, "usage")) 54 | 55 | # Import one file 56 | cmd = f"klangbecken import -d {self.tempdir} -y -m jingles {audio1_path}" 57 | with self.assertRaises(SystemExit) as cm: 58 | with mock.patch("sys.argv", cmd.split()): 59 | with capture(main) as (out, err, ret): 60 | pass 61 | self.assertIn("Successfully imported 1 of 1 files.", out) 62 | self.assertEqual(cm.exception.code, 0) 63 | 64 | files = [ 65 | f 66 | for f in os.listdir(self.jingles_dir) 67 | if os.path.isfile(os.path.join(self.jingles_dir, f)) 68 | ] 69 | self.assertEqual(len(files), 1) 70 | with open(os.path.join(self.tempdir, "index.json")) as file: 71 | data = json.load(file) 72 | self.assertEqual(len(data.keys()), 1) 73 | import_timestamp = list(data.values())[0]["import_timestamp"] 74 | self.assertLess( 75 | (audio1_mtime - datetime.timedelta(seconds=1)).isoformat(), 76 | import_timestamp, 77 | ) 78 | self.assertGreater( 79 | (audio1_mtime + datetime.timedelta(seconds=1)).isoformat(), 80 | import_timestamp, 81 | ) 82 | self.assertEqual( 83 | list(data.values())[0]["original_filename"], "sine-unicode-stereo.mp3" 84 | ) 85 | 86 | def testImport(self): 87 | from klangbecken.cli import import_cmd 88 | 89 | audio_path = os.path.join(self.current_path, "audio") 90 | audio1_path = os.path.join(audio_path, "sine-unicode-stereo.mp3") 91 | audio2_path = os.path.join(audio_path, "padded-stereo.mp3") 92 | 93 | # Import one file 94 | args = [self.tempdir, "jingles", [audio1_path], True] 95 | with self.assertRaises(SystemExit) as cm: 96 | with capture(import_cmd, *args) as (out, err, ret): 97 | self.assertIn("Successfully imported 1 of 1 files.", out) 98 | self.assertEqual(cm.exception.code, 0) 99 | 100 | files = [ 101 | f 102 | for f in os.listdir(self.jingles_dir) 103 | if os.path.isfile(os.path.join(self.jingles_dir, f)) 104 | ] 105 | self.assertEqual(len(files), 1) 106 | with open(os.path.join(self.tempdir, "index.json")) as file: 107 | self.assertEqual(len(json.load(file).keys()), 1) 108 | 109 | # Try importing inexistent file 110 | args = [self.tempdir, "jingles", [audio2_path, "inexistent"], True] 111 | with self.assertRaises(SystemExit) as cm: 112 | with capture(import_cmd, *args) as (out, err, ret): 113 | pass 114 | self.assertIn("Successfully imported 1 of 2 files.", out) 115 | self.assertIn("WARNING", err) 116 | self.assertEqual(cm.exception.code, 1) 117 | 118 | def testImportFailing(self): 119 | from klangbecken.cli import import_cmd 120 | 121 | audio_path = os.path.join(self.current_path, "audio") 122 | audio1_path = os.path.join(audio_path, "padded-stereo.mp3") 123 | audio2_path = os.path.join(audio_path, "too-short.mp3") 124 | 125 | # Try importing into inexistent playlist 126 | args = [self.tempdir, "nonexistent", [audio1_path], True] 127 | with self.assertRaises(SystemExit) as cm: 128 | with capture(import_cmd, *args) as (out, err, ret): 129 | self.assertEqual(out.strip(), "") 130 | self.assertIn("ERROR", err) 131 | self.assertEqual(cm.exception.code, 1) 132 | 133 | # Try importing into inexistent data dir 134 | args = [self.tempdir, "inexistent", [audio1_path], True] 135 | with self.assertRaises(SystemExit) as cm: 136 | with capture(import_cmd, *args) as (out, err, ret): 137 | self.assertEqual(out.strip(), "") 138 | self.assertIn("ERROR", err) 139 | self.assertEqual(cm.exception.code, 1) 140 | 141 | path = os.path.join(self.tempdir, "file.wmv") 142 | with open(path, "w"): 143 | pass 144 | 145 | # Try importing unsupported file type 146 | args = [self.tempdir, "jingles", [path], True] 147 | with self.assertRaises(SystemExit) as cm: 148 | with capture(import_cmd, *args) as (out, err, ret): 149 | pass 150 | self.assertIn("Successfully imported 0 of 1 files.", out) 151 | self.assertIn("WARNING", err) 152 | self.assertEqual(cm.exception.code, 1) 153 | 154 | # Try importing too short tracks 155 | args = [self.tempdir, "music", [audio1_path], True] 156 | with self.assertRaises(SystemExit) as cm: 157 | with capture(import_cmd, *args) as (out, err, ret): 158 | pass 159 | self.assertIn("Successfully imported 0 of 1 files.", out) 160 | self.assertIn("WARNING: Track too short", err) 161 | self.assertEqual(cm.exception.code, 1) 162 | 163 | args = [self.tempdir, "jingles", [audio2_path], True] 164 | with self.assertRaises(SystemExit) as cm: 165 | with capture(import_cmd, *args) as (out, err, ret): 166 | pass 167 | self.assertIn("Successfully imported 0 of 1 files.", out) 168 | self.assertIn("WARNING: Track too short", err) 169 | self.assertEqual(cm.exception.code, 1) 170 | 171 | def testImportWithMetadataFile(self): 172 | from klangbecken.cli import main 173 | 174 | audio_path = os.path.join(self.current_path, "audio") 175 | audio1_path = os.path.join(audio_path, "sine-unicode-stereo.mp3") 176 | audio2_path = os.path.join(audio_path, "padded-stereo.mp3") 177 | 178 | metadata_path = os.path.join(self.tempdir, "metadata.json") 179 | with open(metadata_path, "w") as f: 180 | json.dump({audio1_path: {"artist": "artist", "title": "title"}}, f) 181 | 182 | # Import one file with additional metadata 183 | cmd = ( 184 | f"klangbecken import -d {self.tempdir} -y -m -M {metadata_path} " 185 | f"jingles {audio1_path}" 186 | ) 187 | with self.assertRaises(SystemExit) as cm: 188 | with mock.patch("sys.argv", cmd.split()): 189 | with capture(main) as (out, err, ret): 190 | pass 191 | self.assertIn("Successfully imported 1 of 1 files.", out) 192 | self.assertEqual(cm.exception.code, 0) 193 | 194 | imported_path = os.listdir(os.path.join(self.tempdir, "jingles"))[0] 195 | imported_path = os.path.join(self.tempdir, "jingles", imported_path) 196 | mutagen_file = mutagen.File(imported_path, easy=True) 197 | self.assertEqual(mutagen_file["artist"][0], "artist") 198 | 199 | # Try importing one file without additional metadata 200 | cmd = ( 201 | f"klangbecken import -d {self.tempdir} -y -m -M {metadata_path} " 202 | f"jingles {audio2_path}" 203 | ) 204 | with self.assertRaises(SystemExit) as cm: 205 | with mock.patch("sys.argv", cmd.split()): 206 | with capture(main) as (out, err, ret): 207 | pass 208 | 209 | self.assertIn("Successfully imported 0 of 1 files.", out) 210 | self.assertIn("Ignoring", out) 211 | self.assertEqual(cm.exception.code, 1) 212 | 213 | @mock.patch("klangbecken.cli.input", return_value="y") 214 | def testImportInteractiveYes(self, input): 215 | from klangbecken.cli import import_cmd 216 | 217 | audio_path = os.path.join(self.current_path, "audio") 218 | audio1_path = os.path.join(audio_path, "sine-unicode-stereo.mp3") 219 | 220 | args = [self.tempdir, "jingles", [audio1_path], False] 221 | with self.assertRaises(SystemExit) as cm: 222 | with capture(import_cmd, *args) as (out, err, ret): 223 | self.assertIn("Successfully analyzed 1 of 1 files.", out) 224 | self.assertIn("Successfully imported 1 of 1 files.", out) 225 | jingles_dir = os.path.join(self.tempdir, "jingles") 226 | file_count = len(os.listdir(jingles_dir)) 227 | self.assertEqual(file_count, 1) 228 | self.assertEqual(cm.exception.code, 0) 229 | 230 | @mock.patch("klangbecken.cli.input", return_value="n") 231 | def testImportInteractiveNo(self, input): 232 | from klangbecken.cli import import_cmd 233 | 234 | audio_path = os.path.join(self.current_path, "audio") 235 | audio1_path = os.path.join(audio_path, "sine-unicode-stereo.mp3") 236 | 237 | args = [self.tempdir, "jingles", [audio1_path], False] 238 | 239 | with self.assertRaises(SystemExit) as cm: 240 | with capture(import_cmd, *args) as (out, err, ret): 241 | self.assertIn("Successfully analyzed 1 of 1 files.", out) 242 | self.assertIn("Successfully imported 0 of 1 files.", out) 243 | jingles_dir = os.path.join(self.tempdir, "jingles") 244 | file_count = len(os.listdir(jingles_dir)) 245 | self.assertEqual(file_count, 0) 246 | self.assertEqual(cm.exception.code, 1) 247 | -------------------------------------------------------------------------------- /doc/deployment.md: -------------------------------------------------------------------------------- 1 | # Deployment 2 | 3 | This guide broadly describes the necessary steps to deploy a productive Klangbecken instance. It is based on the tools and technologies we use at RaBe, but should be applicable to different but similar tools. We use CentOS (RHEL) 7 as operating system, Apache 2 as web server, FreeIPA for authentication, and Axia ALSA sound drivers. 4 | 5 | ## Base Installation 6 | 7 | On your favourite Linux distribution: 8 | * Install Apache including its development libraries with your package manager. 9 | * Install `git` with your package manager. 10 | * Install Python (at least version 3.7) with development libraries using your package manager or from the [source](https://www.python.org/downloads/). 11 | * Install Liquidsoap from our prebuilt [package](https://github.com/radiorabe/centos-rpm-liquidsoap). 12 | 13 | ## Prepare Deployment 14 | 15 | Befor anything else, deploy your **public SSH key** to the `root` user's `authorized_keys` file. 16 | 17 | Create a **virtual environment** for the Python code: 18 | ```bash 19 | mkdir /usr/local/venvs 20 | python3.9 -m venv /usr/local/venvs/klangbecken-py39 21 | ``` 22 | 23 | Initialize a bare **git repository** and create checkout and dependencies directories: 24 | ```bash 25 | git init --bare /root/klangbecken.git 26 | mkdir /root/klangbecken 27 | mkdir /root/dependencies 28 | ``` 29 | 30 | Install a git **deployment hook**: 31 | ```bash 32 | cat > /root/klangbecken.git/hooks/post-receive <<- __EOF_1__ 33 | #!/bin/bash 34 | 35 | git --work-tree=/root/klangbecken/ --git-dir=/root/klangbecken.git checkout -f 36 | source /usr/local/venvs/klangbecken-py39/bin/activate 37 | 38 | echo 39 | echo "##############" 40 | echo "# Installing #" 41 | echo "##############" 42 | pip install --upgrade --no-index --find-links /root/dependencies/ --requirement /root/klangbecken/requirements.txt 43 | pip install --upgrade --no-index --find-links /root/dependencies/ mod_wsgi 44 | rm /root/dependencies/* 45 | pip install --force-reinstall --no-index --no-deps /root/klangbecken 46 | 47 | echo 48 | echo "#############" 49 | echo "# Reloading #" 50 | echo "#############" 51 | systemctl reload httpd 52 | if ! cmp --quiet /root/klangbecken/klangbecken.liq /etc/liquidsoap/klangbecken.liq; then 53 | mv /etc/liquidsoap/klangbecken.liq /etc/liquidsoap/klangbecken.liq.bak 54 | cp /root/klangbecken/klangbecken.liq /etc/liquidsoap/klangbecken.liq 55 | echo 56 | echo "WARNING: Liquidsoap script changed!" 57 | echo "Run 'systemctl restart liquidsoap@klangbecken' during an off-air moment" 58 | fi 59 | echo 60 | echo "Done!" 61 | __EOF_1__ 62 | 63 | chmod +x /root/klangbecken.git/hooks/post-receive 64 | ``` 65 | 66 | Initialize the **data directory**: 67 | ```bash 68 | source /usr/local/venvs/klangbecken-py39/bin/activate 69 | python -m klangbecken init -d /var/lib/klangbecken 70 | ``` 71 | 72 | Set the **access rights**, such that both apache and liquidsoap users can read and write the data directory: 73 | ```bash 74 | groupadd --system klangbecken 75 | usermod -a -G klangbecken apache 76 | usermod -a -G klangbecken liquidsoap 77 | 78 | chgrp -R klangbecken /var/lib/klangbecken/ 79 | chmod g+s,g+w /var/lib/klangbecken/ /var/lib/klangbecken/*/ 80 | setfacl -m "default:group::rw" /var/lib/klangbecken/ /var/lib/klangbecken/*/ 81 | 82 | # SELinux configuration 83 | semanage fcontext -a -t httpd_sys_rw_content_t "/var/lib/klangbecken.*" 84 | restorecon -vR /var/lib/klangbecken/ 85 | ``` 86 | 87 | _On your local machine in your klangbecken git repository_ point a **remote to the production system**: 88 | ```bash 89 | git remote add prod root@NAME_OF_YOUR_VM:klangbecken.git 90 | ``` 91 | 92 | **Deploy** the code (including `mod_wsgi`) for the first time to production: 93 | ```bash 94 | ./deploy.sh 95 | ``` 96 | _Note:_ To be able to download the `mod_wsgi` package, make sure you have the apache development libraries installed locally. 97 | 98 | ## Global Configuration 99 | 100 | Add a file `/etc/klangbecken.conf` with the global configuration: 101 | ```bash 102 | KLANGBECKEN_DATA_DIR=/var/lib/klangbecken 103 | KLANGBECKEN_COMMAND=/usr/local/venvs/klangbecken-py39/bin/klangbecken 104 | KLANGBECKEN_PLAYER_SOCKET=/var/run/liquidsoap/klangbecken.sock 105 | KLANGBECKEN_ALSA_DEVICE=default:CARD=Axia 106 | KLANGBECKEN_EXTERNAL_PLAY_LOGGER= 107 | KLANGBECKEN_API_SECRET=*********************************************** 108 | LANG=en_US.UTF-8 109 | ``` 110 | 111 | Replace the `KLANGBECKEN_API_SECRET` with a sufficiently long and random secret key. For example by executing `dd if=/dev/urandom bs=1 count=33 2>/dev/null | base64 -w 0 | rev | cut -b 2- | rev`. 112 | 113 | Set the `KLANGBECKEN_ALSA_DEVICE` to your sound card device (`default:CARD=Axia` when you use the Axia ALSA drivers). Optionally specify a `KLANGBECKEN_EXTERNAL_PLAY_LOGGER` command (see [command line interface](cli.md)). 114 | 115 | ## Liquidsoap 116 | 117 | Add an override file for the `liquidsoap@klangbecken` service: 118 | ```bash 119 | mkdir /etc/systemd/system/liquidsoap@klangbecken.service.d 120 | cat > /etc/systemd/system/liquidsoap@klangbecken.service.d/overrides.conf <<- __EOF_1__ 121 | [Service] 122 | EnvironmentFile=/etc/klangbecken.conf 123 | IOSchedulingClass=best-effort 124 | CPUSchedulingPolicy=rr 125 | IOSchedulingPriority=1 126 | CPUSchedulingPriority=90 127 | __EOF_1__ 128 | ``` 129 | 130 | Make sure `/var/run/liquidsoap` exists after booting: 131 | ```bash 132 | cat > /etc/tmpfiles.d/liquidsoap.conf <<-__EOF_2__ 133 | d /var/run/liquidsoap 0755 liquidsoap liquidsoap - - 134 | __EOF_2__ 135 | ``` 136 | 137 | Add liquidsoap user to the `audio` group 138 | ```bash 139 | usermod -a -G audio liquidsoap 140 | ``` 141 | 142 | Enable the service: 143 | ```bash 144 | systemctl enable liquidsoap@klangbecken.service 145 | ``` 146 | 147 | ## Apache 148 | 149 | ### API with `mod_wsgi` 150 | 151 | Add a wsgi file loading the API: 152 | ```bash 153 | cat > /var/www/klangbecken_api.wsgi <<-__EOF__ 154 | from klangbecken.api import klangbecken_api 155 | 156 | with open("/etc/klangbecken.conf") as f: 157 | config = dict( 158 | line.rstrip()[len("KLANGBECKEN_") :].split("=", 1) 159 | for line in f.readlines() 160 | if line.startswith("KLANGBECKEN_") 161 | ) 162 | 163 | application = klangbecken_api( 164 | config["API_SECRET"], config["DATA_DIR"], config["PLAYER_SOCKET"] 165 | ) 166 | __EOF__ 167 | ``` 168 | 169 | Configure Apache to use the `mod_wsgi` module: 170 | ```bash 171 | cat > /etc/httpd/conf.modules.d/10-wsgi.conf <<-__EOF__ 172 | LoadModule wsgi_module /usr/local/venvs/klangbecken-py39/lib/python3.9/site-packages/mod_wsgi/server/mod_wsgi-py39.cpython-39-x86_64-linux-gnu.so 173 | __EOF__ 174 | ``` 175 | Make sure, that the library file (`*.so`) exists at the configured location. 176 | 177 | Configure the API in your Apache `VirtualHost` configuration: 178 | ```txt 179 | WSGIDaemonProcess klangbecken user=apache group=klangbecken python-home=/usr/local/venvs/klangbecken-py39 180 | WSGIProcessGroup klangbecken 181 | WSGIScriptAlias /api /var/www/klangbecken_api.wsgi 182 | 183 | # Forward authorization header to API 184 | RewriteEngine On 185 | RewriteCond %{HTTP:Authorization} ^(.*) 186 | RewriteRule .* - [e=HTTP_AUTHORIZATION:%1] 187 | ``` 188 | 189 | ### Authentication 190 | 191 | We use `mod_authnz_pam` and `mod_intercept_form_submit` to intercept login requests and authenticate users with PAM. 192 | 193 | Configure your Apache `VirtualHost` configuration: 194 | ```txt 195 | LoadModule authnz_pam_module modules/mod_authnz_pam.so 196 | LoadModule intercept_form_submit_module modules/mod_intercept_form_submit.so 197 | 198 | 199 | InterceptFormPAMService klangbecken 200 | InterceptFormLogin login 201 | InterceptFormPassword password 202 | InterceptFormClearRemoteUserForSkipped on 203 | InterceptFormPasswordRedact on 204 | InterceptFormLoginRealms YOUR_LDAP_REALM '' 205 | 206 | 207 | ``` 208 | 209 | _Note:_ If you use LDAP, configure `YOUR_LDAP_REALM` to the correct realm. Otherwise remove the corresponding line. 210 | 211 | Allow apache to use PAM (SELinux configuration): 212 | ```bash 213 | setsebool -P allow_httpd_mod_auth_pam 1 214 | ``` 215 | 216 | Configure PAM to limit access to users in certain user groups: 217 | ```bash 218 | cat > /etc/pam.d/klangbecken <<-__EOF__ 219 | auth required pam_sss.so 220 | account required pam_sss.so 221 | account required pam_access.so accessfile=/etc/klangbecken-http-access.conf 222 | __EOF__ 223 | 224 | cat > /etc/klangbecken-http-access.conf <<-__EOF__ 225 | + : (staff) : ALL 226 | + : (admins) : ALL 227 | - : ALL : ALL 228 | __EOF__ 229 | ``` 230 | 231 | Check [authentication-middleware.md](authentication-middleware.md) for an alternative to PAM based authentication. 232 | 233 | ### Data Directory 234 | 235 | Configure forwarding requests to `/data` to the data directory in your Apache `VirtualHost` configuration: 236 | ```txt 237 | Alias "/data" "/var/lib/klangbecken" 238 | 239 | Require all granted 240 | 241 | ``` 242 | 243 | ### Front End 244 | 245 | Configure redirection rules for the front end in your Apache `VirtualHost` configuration: 246 | ```txt 247 | 248 | 249 | RewriteEngine On 250 | RewriteBase / 251 | RewriteRule ^index\.html$ - [L] 252 | RewriteCond %{REQUEST_FILENAME} !-f 253 | RewriteCond %{REQUEST_FILENAME} !-d 254 | RewriteRule . /index.html [L] 255 | 256 | 257 | ``` 258 | 259 | Fork and clone the front end code from `git@github.com:radiorabe/klangbecken-ui.git` and configure the `PROD_HOST` variable in the deployment script `deploy.sh`. 260 | 261 | Run the script to build the project, and copy the files to production: 262 | ```bash 263 | ./deploy.sh 264 | ``` 265 | 266 | ## Systemd Services and Timers 267 | 268 | The directory [`doc/systemd`](systemd/) contains example service files for all described services. The files can be copied to `/etc/systemd/system/` on production. 269 | 270 | ### "On Air" Status Listener (Virtual Sämubox) 271 | 272 | Install the Virtual Sämubox binary: https://github.com/radiorabe/virtual-saemubox/releases/latest 273 | 274 | Install the [`virtual-saemubox.service`](systemd/virtual-saemubox.service), that sends to current "on air" status to the Liquidsoap player and enable it: 275 | ```bash 276 | systemctl enable virtual-saemubox.service 277 | ``` 278 | 279 | ### Data Directory Consistency Check 280 | 281 | Install the [`klangbecken-fsck.service`](systemd/klangbecken-fsck.service) and [`klangbecken-fsck.timer`](systemd/klangbecken-fsck.timer) files for the `fsck` service, that nightly checks the consistency of the data directory, and enable the timer: 282 | ```bash 283 | systemctl enable klangbecken-fsck.timer 284 | ``` 285 | 286 | ### Automatically Disable Expired Tracks 287 | Install the [`klangbecken-disable-expired.service`](systemd/klangbecken-disable-expired.service) and [`klangbecken-disable-expired.timer`](systemd/klangbecken-disable-expired.timer) files for the `disable-expired` service, that hourly checks for and disables expired tracks (mostly jingles), and enable the timer: 288 | ```bash 289 | systemctl enable klangbecken-disable-expired.timer 290 | ``` 291 | 292 | ## Monitoring Checks 293 | 294 | The following script checks whether the the Klangbecken had been off air for more than a day. Use it in your monitoring service. 295 | 296 | ```bash 297 | cat > /usr/local/bin/check_off_air_status <<- __EOF__ 298 | #!/bin/env python3.9 299 | 300 | import csv 301 | import datetime 302 | import os 303 | import pathlib 304 | 305 | 306 | if not hasattr(datetime.datetime, "fromisoformat"): 307 | print("ERROR: datetime.fromisoformat missing") 308 | print("Install 'fromisoformat' backport package or use a Python version >= 3.7") 309 | exit(1) 310 | 311 | DATA_DIR = os.environ.get("KLANGBECKEN_DATA_DIR", "/var/lib/klangbecken") 312 | path = list((pathlib.Path(DATA_DIR) / "log").glob("*.csv"))[-1] 313 | with open(path) as f: 314 | reader = csv.DictReader(f) 315 | entry = list(reader)[-1] 316 | 317 | last_play = datetime.datetime.fromisoformat(entry["last_play"]) 318 | now = datetime.datetime.now().astimezone() 319 | 320 | if now - last_play > datetime.timedelta(days=1): 321 | print("WARNING: Klangbecken offline for more than one day.") 322 | print(f"Last track play registered at {last_play}") 323 | exit(1) 324 | __EOF__ 325 | 326 | chmod +x /usr/local/bin/check_off_air_status 327 | ``` 328 | -------------------------------------------------------------------------------- /test/test_cli_fsck.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import shutil 4 | import sys 5 | import tempfile 6 | import unittest 7 | 8 | from .utils import capture 9 | 10 | 11 | class FsckTestCase(unittest.TestCase): 12 | def setUp(self): 13 | from klangbecken.cli import _check_data_dir, import_cmd 14 | 15 | self.current_path = os.path.dirname(os.path.realpath(__file__)) 16 | self.tempdir = tempfile.mkdtemp() 17 | self.jingles_dir = os.path.join(self.tempdir, "jingles") 18 | _check_data_dir(self.tempdir, create=True) 19 | 20 | # Correctly import a couple of files 21 | files = [ 22 | os.path.join(self.current_path, "audio", "padded-" + spec + ".mp3") 23 | for spec in "stereo jointstereo start-stereo end-stereo".split() 24 | ] 25 | try: 26 | args = [self.tempdir, "jingles", files, True] 27 | with capture(import_cmd, *args) as (out, err, ret): 28 | pass 29 | except SystemExit as e: 30 | if e.code != 0: 31 | print(e, file=sys.stderr) 32 | raise (RuntimeError("Command execution failed")) 33 | 34 | def tearDown(self): 35 | shutil.rmtree(self.tempdir) 36 | 37 | def testFsckCorruptIndexJson(self): 38 | from klangbecken.cli import main 39 | 40 | index_path = os.path.join(self.tempdir, "index.json") 41 | with open(index_path, "w"): 42 | pass 43 | 44 | argv, sys.argv = sys.argv, ["", "fsck", "-d", self.tempdir] 45 | try: 46 | with self.assertRaises(SystemExit) as cm: 47 | with capture(main) as (out, err, ret): 48 | self.assertIn("ERROR", err) 49 | self.assertEqual(cm.exception.code, 1) 50 | finally: 51 | sys.arv = argv 52 | 53 | def testFsckCorruptDataDir(self): 54 | from klangbecken.cli import main 55 | 56 | shutil.rmtree(self.jingles_dir) 57 | 58 | argv, sys.argv = sys.argv, ["", "fsck", "-d", self.tempdir] 59 | try: 60 | with self.assertRaises(SystemExit) as cm: 61 | with capture(main) as (out, err, ret): 62 | self.assertIn("ERROR", err) 63 | self.assertEqual(cm.exception.code, 1) 64 | finally: 65 | sys.arv = argv 66 | 67 | def testFsckInexistentDataDir(self): 68 | from klangbecken.cli import main 69 | 70 | argv, sys.argv = sys.argv, ["", "fsck", "-d", "invalid"] 71 | try: 72 | # inexistent data_dir 73 | with self.assertRaises(SystemExit) as cm: 74 | with capture(main) as (out, err, ret): 75 | self.assertIn("ERROR", err) 76 | self.assertIn("Data directory 'invalid' does not exist", err) 77 | self.assertEqual(cm.exception.code, 1) 78 | finally: 79 | sys.arv = argv 80 | 81 | def testFsck(self): 82 | from klangbecken.cli import main 83 | 84 | argv, sys.argv = sys.argv, ["", "fsck", "-d", self.tempdir] 85 | try: 86 | # correct invocation 87 | with self.assertRaises(SystemExit) as cm: 88 | with capture(main) as (out, err, ret): 89 | self.assertEqual(err.strip(), "") 90 | self.assertEqual(cm.exception.code, 0) 91 | finally: 92 | sys.arv = argv 93 | 94 | def testFsckWithInterleavingPlaylogs(self): 95 | from klangbecken.cli import main, playlog_cmd 96 | 97 | # log one one track play 98 | track1 = os.listdir(self.jingles_dir)[0] 99 | track2 = os.listdir(self.jingles_dir)[1] 100 | playlog_cmd(self.tempdir, os.path.join("jingles", track1)) 101 | 102 | # back up index.json cache 103 | shutil.copy( 104 | os.path.join(self.tempdir, "index.json"), 105 | os.path.join(self.tempdir, "index.json.bak"), 106 | ) 107 | 108 | # log two mor track plays 109 | playlog_cmd(self.tempdir, os.path.join("jingles", track1)) 110 | playlog_cmd(self.tempdir, os.path.join("jingles", track2)) 111 | 112 | # restore index.json cache 113 | shutil.copy( 114 | os.path.join(self.tempdir, "index.json.bak"), 115 | os.path.join(self.tempdir, "index.json"), 116 | ) 117 | 118 | argv, sys.argv = sys.argv, ["", "fsck", "-d", self.tempdir] 119 | 120 | try: 121 | # correct invocation (should not raise error) 122 | with self.assertRaises(SystemExit) as cm: 123 | with capture(main) as (out, err, ret): 124 | self.assertEqual(err.strip(), "") 125 | self.assertEqual(cm.exception.code, 0) 126 | finally: 127 | sys.arv = argv 128 | 129 | def testFsckWithTooManyInterleavingPlaylogs(self): 130 | from klangbecken.cli import main, playlog_cmd 131 | 132 | # log one one track play 133 | track1 = os.listdir(self.jingles_dir)[0] 134 | track2 = os.listdir(self.jingles_dir)[1] 135 | track3 = os.listdir(self.jingles_dir)[2] 136 | track4 = os.listdir(self.jingles_dir)[3] 137 | 138 | playlog_cmd(self.tempdir, os.path.join("jingles", track1)) 139 | 140 | # back up index.json cache 141 | shutil.copy( 142 | os.path.join(self.tempdir, "index.json"), 143 | os.path.join(self.tempdir, "index.json.bak"), 144 | ) 145 | 146 | # log two mor track plays 147 | playlog_cmd(self.tempdir, os.path.join("jingles", track1)) 148 | playlog_cmd(self.tempdir, os.path.join("jingles", track2)) 149 | playlog_cmd(self.tempdir, os.path.join("jingles", track3)) 150 | playlog_cmd(self.tempdir, os.path.join("jingles", track4)) 151 | 152 | # restore index.json cache 153 | shutil.copy( 154 | os.path.join(self.tempdir, "index.json.bak"), 155 | os.path.join(self.tempdir, "index.json"), 156 | ) 157 | 158 | argv, sys.argv = sys.argv, ["", "fsck", "-d", self.tempdir] 159 | 160 | try: 161 | with self.assertRaises(SystemExit) as cm: 162 | with capture(main) as (out, err, ret): 163 | self.assertIn("ERROR", err) 164 | self.assertIn("last_play", err) 165 | self.assertEqual(cm.exception.code, 1) 166 | finally: 167 | sys.arv = argv 168 | 169 | def testIndexWithWrongId(self): 170 | from klangbecken.cli import main 171 | 172 | argv, sys.argv = sys.argv, ["", "fsck", "-d", self.tempdir] 173 | 174 | index_path = os.path.join(self.tempdir, "index.json") 175 | with open(index_path) as f: 176 | data = json.load(f) 177 | 178 | entry1, entry2 = list(data.values())[:2] 179 | entry1["id"], entry2["id"] = entry2["id"], entry1["id"] 180 | with open(index_path, "w") as f: 181 | json.dump(data, f) 182 | 183 | try: 184 | with self.assertRaises(SystemExit) as cm: 185 | with capture(main) as (out, err, ret): 186 | self.assertIn("ERROR", err) 187 | self.assertIn("Id mismatch", err) 188 | self.assertEqual(cm.exception.code, 1) 189 | finally: 190 | sys.arv = argv 191 | 192 | def testIndexMissingEntries(self): 193 | from klangbecken.cli import main 194 | 195 | argv, sys.argv = sys.argv, ["", "fsck", "-d", self.tempdir] 196 | 197 | index_path = os.path.join(self.tempdir, "index.json") 198 | with open(index_path) as f: 199 | data = json.load(f) 200 | 201 | entry = next(iter(data.values())) 202 | del entry["cue_out"] 203 | 204 | with open(index_path, "w") as f: 205 | json.dump(data, f) 206 | 207 | try: 208 | with self.assertRaises(SystemExit) as cm: 209 | with capture(main) as (out, err, ret): 210 | self.assertIn("ERROR", err) 211 | self.assertIn("missing entries: cue_out", err) 212 | self.assertEqual(cm.exception.code, 1) 213 | finally: 214 | sys.arv = argv 215 | 216 | def testIndexTooManyEntries(self): 217 | from klangbecken.cli import main 218 | 219 | argv, sys.argv = sys.argv, ["", "fsck", "-d", self.tempdir] 220 | 221 | index_path = os.path.join(self.tempdir, "index.json") 222 | with open(index_path) as f: 223 | data = json.load(f) 224 | 225 | entry = next(iter(data.values())) 226 | entry["whatever"] = "whatever" 227 | 228 | with open(index_path, "w") as f: 229 | json.dump(data, f) 230 | 231 | try: 232 | with self.assertRaises(SystemExit) as cm: 233 | with capture(main) as (out, err, ret): 234 | self.assertIn("ERROR", err) 235 | self.assertIn("too many entries: whatever", err) 236 | self.assertEqual(cm.exception.code, 1) 237 | finally: 238 | sys.arv = argv 239 | 240 | def testIndexMissingFile(self): 241 | from klangbecken.cli import main 242 | 243 | argv, sys.argv = sys.argv, ["", "fsck", "-d", self.tempdir] 244 | 245 | os.remove(os.path.join(self.jingles_dir, os.listdir(self.jingles_dir)[0])) 246 | 247 | try: 248 | with self.assertRaises(SystemExit) as cm: 249 | with capture(main) as (out, err, ret): 250 | self.assertIn("ERROR", err) 251 | self.assertIn("file does not exist", err) 252 | self.assertEqual(cm.exception.code, 1) 253 | finally: 254 | sys.arv = argv 255 | 256 | def testTagsValueMismatch(self): 257 | from klangbecken.cli import main 258 | from klangbecken.settings import FILE_TYPES 259 | 260 | argv, sys.argv = sys.argv, ["", "fsck", "-d", self.tempdir] 261 | 262 | file_path = os.path.join(self.jingles_dir, os.listdir(self.jingles_dir)[0]) 263 | FileType = FILE_TYPES[file_path.split(".")[-1]] 264 | mutagenfile = FileType(file_path) 265 | mutagenfile["artist"] = "Whatever" 266 | mutagenfile.save() 267 | 268 | try: 269 | with self.assertRaises(SystemExit) as cm: 270 | with capture(main) as (out, err, ret): 271 | self.assertIn("ERROR", err) 272 | self.assertIn("tag value mismatch", err) 273 | self.assertIn("artist", err) 274 | self.assertEqual(cm.exception.code, 1) 275 | finally: 276 | sys.arv = argv 277 | 278 | def testPlaylistWeightMismatch(self): 279 | from klangbecken.cli import main 280 | 281 | argv, sys.argv = sys.argv, ["", "fsck", "-d", self.tempdir] 282 | 283 | playlist_path = os.path.join(self.tempdir, "jingles.m3u") 284 | with open(playlist_path) as f: 285 | lines = f.readlines() 286 | with open(playlist_path, "w") as f: 287 | f.writelines(lines[::2]) # only write back every second line 288 | 289 | try: 290 | with self.assertRaises(SystemExit) as cm: 291 | with capture(main) as (out, err, ret): 292 | self.assertIn("ERROR", err) 293 | self.assertIn("Playlist weight mismatch", err) 294 | self.assertEqual(cm.exception.code, 1) 295 | finally: 296 | sys.arv = argv 297 | 298 | def testDanglingPlaylistEntries(self): 299 | from klangbecken.cli import main 300 | 301 | argv, sys.argv = sys.argv, ["", "fsck", "-d", self.tempdir] 302 | 303 | playlist_path = os.path.join(self.tempdir, "jingles.m3u") 304 | with open(playlist_path, "a") as f: 305 | f.write("jingles/not_an_uuid.mp3\n") 306 | 307 | try: 308 | with self.assertRaises(SystemExit) as cm: 309 | with capture(main) as (out, err, ret): 310 | self.assertIn("ERROR", err) 311 | self.assertIn("Dangling playlist entries", err) 312 | self.assertIn("not_an_uuid", err) 313 | self.assertEqual(cm.exception.code, 1) 314 | finally: 315 | sys.arv = argv 316 | 317 | def testDanglingFiles(self): 318 | from klangbecken.cli import main 319 | 320 | argv, sys.argv = sys.argv, ["", "fsck", "-d", self.tempdir] 321 | 322 | with open(os.path.join(self.tempdir, "jingles", "not_an_uuid"), "w"): 323 | pass 324 | 325 | try: 326 | with self.assertRaises(SystemExit) as cm: 327 | with capture(main) as (out, err, ret): 328 | self.assertIn("ERROR", err) 329 | self.assertIn("Dangling files", err) 330 | self.assertIn("not_an_uuid", err) 331 | self.assertEqual(cm.exception.code, 1) 332 | finally: 333 | sys.arv = argv 334 | -------------------------------------------------------------------------------- /test/test_player.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import shutil 4 | import socket 5 | import socketserver 6 | import sys 7 | import tempfile 8 | import threading 9 | import unittest 10 | from unittest import mock 11 | 12 | from werkzeug.exceptions import NotFound 13 | 14 | 15 | class EchoHandler(socketserver.BaseRequestHandler): 16 | def handle(self): 17 | while True: 18 | msg = self.request.recv(8192) 19 | if not msg or msg.strip() == b"exit": 20 | self.request.send(b"Bye!\n") 21 | break 22 | self.request.send(msg) 23 | 24 | 25 | def get_port(): 26 | while True: 27 | port = random.randrange(1024, 65535) 28 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 29 | try: 30 | sock.bind(("localhost", port)) 31 | sock.close() 32 | return port 33 | except OSError: 34 | pass 35 | 36 | 37 | class LiquidsoapClientTestCase(unittest.TestCase): 38 | def setUp(self): 39 | self.tempdir = tempfile.mkdtemp() 40 | 41 | def tearDown(self): 42 | shutil.rmtree(self.tempdir) 43 | 44 | def testOpenAndUnix(self): 45 | from klangbecken.player import LiquidsoapClient 46 | 47 | settings = [ 48 | (socketserver.TCPServer, ("localhost", get_port())), 49 | (socketserver.UnixStreamServer, os.path.join(self.tempdir, "test.sock")), 50 | ] 51 | for Server, addr in settings: 52 | with Server(addr, EchoHandler) as serv: 53 | thread = threading.Thread(target=serv.serve_forever) 54 | thread.start() 55 | client = LiquidsoapClient(addr) 56 | with client: 57 | result = client.command("\r\n\r\nhello\r\nworld\r\n\r\nEND") 58 | self.assertEqual(result, "hello\nworld") 59 | with client: 60 | with self.assertRaises(Exception) as cm: 61 | client.command("Does not contain the finishing sentinel.") 62 | self.assertIn("timed out", cm.exception.args[0]) 63 | with client: 64 | result = client.command( 65 | "\r\n\r\nThis is the END of the world\r\n\r\nEND" 66 | ) 67 | self.assertEqual(result, "This is the END of the world") 68 | serv.shutdown() 69 | thread.join() 70 | 71 | def testCommandLoggingOnError(self): 72 | from klangbecken.player import LiquidsoapClient 73 | 74 | from .utils import capture 75 | 76 | Server = socketserver.UnixStreamServer 77 | addr = os.path.join(self.tempdir, "test.sock") 78 | with Server(addr, EchoHandler) as serv: 79 | thread = threading.Thread(target=serv.serve_forever) 80 | thread.start() 81 | client = LiquidsoapClient(addr) 82 | 83 | def do(): 84 | with client: 85 | client.command("\r\n\r\nhello\r\nworld\r\n\r\nEND") 86 | raise Exception("Something terrible happened") 87 | 88 | with self.assertRaises(Exception) as cm: 89 | with capture(do) as (out, err, ret): 90 | pass 91 | self.assertEqual("Something terrible happened", cm.exception.args[0]) 92 | self.assertIn("Something terrible happened", err) 93 | self.assertIn("Command:", err) 94 | self.assertIn("Response:", err) 95 | self.assertIn("hello", err) 96 | 97 | serv.shutdown() 98 | thread.join() 99 | 100 | def testMetadata(self): 101 | from klangbecken.player import LiquidsoapClient 102 | 103 | client = LiquidsoapClient() 104 | client.command = mock.Mock( 105 | return_value='rid="15"\ntitle="title"\nartist="artist"' 106 | ) 107 | 108 | result = client.metadata(15) 109 | client.command.assert_called_once_with("request.metadata 15") 110 | self.assertEqual(result, {"rid": "15", "artist": "artist", "title": "title"}) 111 | 112 | def testInfoOnAir(self): 113 | from klangbecken import __version__ 114 | from klangbecken.player import LiquidsoapClient 115 | 116 | command_calls = [ 117 | ("uptime", "0d 00h 08m 54s"), 118 | ("version", "Liquidsoap 1.4.2"), 119 | ("klangbecken.on_air", "true"), 120 | ( 121 | "music.next", 122 | "[ready] data/music/2e3fc9b6-36ee-4640-9efd-cdf10560adb4.mp3", 123 | ), 124 | ( 125 | "classics.next", 126 | """[playing] data/classics/4daabe44-6d48-47c4-a187-592cf048b039.mp3""", 127 | ), 128 | ("jingles.next", ""), 129 | ("request.on_air", "8"), 130 | ( 131 | "request.metadata 8", 132 | '''playlist_position="1" 133 | rid="8" 134 | source="classics" 135 | temporary="false" 136 | filename="data/classics/4daabe44-6d48-47c4-a187-592cf048b039.mp3"''', 137 | ), 138 | ("queue.queue", "0 1"), 139 | ( 140 | "request.metadata 0", 141 | '''queue="primary" 142 | rid="0" 143 | status="ready" 144 | source="queue" 145 | temporary="false" 146 | filename="data/classics/4daabe44-6d48-47c4-a187-592cf048b039.mp3"''', 147 | ), 148 | ] 149 | 150 | def side_effect(actual_command): 151 | command, result = command_calls.pop(0) 152 | self.assertEqual(command, actual_command) 153 | return result 154 | 155 | client = LiquidsoapClient() 156 | client.command = mock.Mock(side_effect=side_effect) 157 | 158 | result = client.info() 159 | self.assertEqual( 160 | result, 161 | { 162 | "uptime": "0d 00h 08m 54s", 163 | "liquidsoap_version": "Liquidsoap 1.4.2", 164 | "api_version": __version__, 165 | "python_version": sys.version.split()[0], 166 | "music": "2e3fc9b6-36ee-4640-9efd-cdf10560adb4", 167 | "classics": "", 168 | "jingles": "", 169 | "on_air": True, 170 | "current_track": { 171 | "source": "classics", 172 | "id": "4daabe44-6d48-47c4-a187-592cf048b039", 173 | }, 174 | "queue": "4daabe44-6d48-47c4-a187-592cf048b039", 175 | }, 176 | ) 177 | self.assertEqual(command_calls, []) 178 | 179 | def testInfoOnAirNoCurrentTrack(self): 180 | from klangbecken import __version__ 181 | from klangbecken.player import LiquidsoapClient 182 | 183 | command_calls = [ 184 | ("uptime", "0d 00h 08m 54s"), 185 | ("version", "Liquidsoap 1.4.2"), 186 | ("klangbecken.on_air", "true"), 187 | ( 188 | "music.next", 189 | "[ready] data/music/2e3fc9b6-36ee-4640-9efd-cdf10560adb4.mp3", 190 | ), 191 | ( 192 | "classics.next", 193 | "[playing] data/classics/4daabe44-6d48-47c4-a187-592cf048b039.mp3", 194 | ), 195 | ("jingles.next", ""), 196 | ("request.on_air", ""), 197 | ("queue.queue", "0 1"), 198 | ( 199 | "request.metadata 0", 200 | '''queue="primary" 201 | rid="0" 202 | status="ready" 203 | source="queue" 204 | temporary="false" 205 | filename="data/classics/4daabe44-6d48-47c4-a187-592cf048b039.mp3"''', 206 | ), 207 | ] 208 | 209 | def side_effect(actual_command): 210 | command, result = command_calls.pop(0) 211 | self.assertEqual(command, actual_command) 212 | return result 213 | 214 | client = LiquidsoapClient() 215 | client.command = mock.Mock(side_effect=side_effect) 216 | 217 | result = client.info() 218 | self.assertEqual( 219 | result, 220 | { 221 | "uptime": "0d 00h 08m 54s", 222 | "liquidsoap_version": "Liquidsoap 1.4.2", 223 | "api_version": __version__, 224 | "python_version": sys.version.split()[0], 225 | "music": "2e3fc9b6-36ee-4640-9efd-cdf10560adb4", 226 | "classics": "", 227 | "jingles": "", 228 | "on_air": True, 229 | "current_track": {}, 230 | "queue": "4daabe44-6d48-47c4-a187-592cf048b039", 231 | }, 232 | ) 233 | self.assertEqual(command_calls, []) 234 | 235 | def testInfoOffAir(self): 236 | from klangbecken import __version__ 237 | from klangbecken.player import LiquidsoapClient 238 | 239 | command_calls = [ 240 | ("uptime", "0d 00h 08m 54s"), 241 | ("version", "Liquidsoap 1.4.2"), 242 | ("klangbecken.on_air", "false"), 243 | ("queue.queue", "0 1"), 244 | ( 245 | "request.metadata 0", 246 | '''queue="primary" 247 | rid="0" 248 | status="ready" 249 | source="queue" 250 | temporary="false" 251 | filename="data/classics/4daabe44-6d48-47c4-a187-592cf048b039.mp3"''', 252 | ), 253 | ( 254 | "request.metadata 1", 255 | '''queue="secondary" 256 | rid="1" 257 | status="ready" 258 | source="queue" 259 | temporary="false" 260 | filename="data/classics/4daabe44-6d48-47c4-a187-592cf048b039.mp3"''', 261 | ), 262 | ] 263 | 264 | def side_effect(actual_command): 265 | command, result = command_calls.pop(0) 266 | self.assertEqual(command, actual_command) 267 | return result 268 | 269 | client = LiquidsoapClient() 270 | client.command = mock.Mock(side_effect=side_effect) 271 | result = client.info() 272 | self.assertEqual( 273 | result, 274 | { 275 | "uptime": "0d 00h 08m 54s", 276 | "liquidsoap_version": "Liquidsoap 1.4.2", 277 | "api_version": __version__, 278 | "python_version": sys.version.split()[0], 279 | "on_air": False, 280 | "music": "", 281 | "classics": "", 282 | "jingles": "", 283 | "queue": "4daabe44-6d48-47c4-a187-592cf048b039", 284 | }, 285 | ) 286 | # self.assertEqual(command_calls, []) 287 | 288 | def testQueue(self): 289 | from klangbecken.player import LiquidsoapClient 290 | 291 | command_calls = [ 292 | ("queue.queue", "0 1"), 293 | ( 294 | "request.metadata 0", 295 | '''queue="primary" 296 | rid="0" 297 | status="ready" 298 | source="queue" 299 | temporary="false" 300 | filename="data/classics/4daabe44-6d48-47c4-a187-592cf048b039.mp3"''', 301 | ), 302 | ( 303 | "request.metadata 1", 304 | '''queue="secondary" 305 | rid="1" 306 | status="ready" 307 | source="queue" 308 | temporary="false" 309 | filename="data/classics/4daabe44-6d48-47c4-a187-592cf048b039.mp3"''', 310 | ), 311 | ] 312 | 313 | def side_effect(actual_command): 314 | command, result = command_calls.pop(0) 315 | self.assertEqual(command, actual_command) 316 | return result 317 | 318 | client = LiquidsoapClient() 319 | client.command = mock.Mock(side_effect=side_effect) 320 | 321 | result = client.queue() 322 | self.assertEqual( 323 | result, 324 | [ 325 | { 326 | "id": "4daabe44-6d48-47c4-a187-592cf048b039", 327 | "queue_id": "0", 328 | "queue": "primary", 329 | }, 330 | { 331 | "id": "4daabe44-6d48-47c4-a187-592cf048b039", 332 | "queue_id": "1", 333 | "queue": "secondary", 334 | }, 335 | ], 336 | ) 337 | self.assertEqual(command_calls, []) 338 | 339 | def testPush(self): 340 | from klangbecken.player import LiquidsoapClient 341 | 342 | command_calls = [ 343 | ("queue.push data/classics/4daabe44-6d48-47c4-a187-592cf048b039.mp3", "1"), 344 | ( 345 | "request.metadata 1", 346 | '''queue="primary" 347 | rid="1" 348 | status="ready" 349 | source="queue" 350 | temporary="false" 351 | filename="data/classics/4daabe44-6d48-47c4-a187-592cf048b039.mp3"''', 352 | ), 353 | ] 354 | 355 | def side_effect(actual_command): 356 | command, result = command_calls.pop(0) 357 | self.assertEqual(command, actual_command) 358 | return result 359 | 360 | client = LiquidsoapClient() 361 | client.command = mock.Mock(side_effect=side_effect) 362 | result = client.push("data/classics/4daabe44-6d48-47c4-a187-592cf048b039.mp3") 363 | self.assertEqual(result, "1") 364 | self.assertEqual(command_calls, []) 365 | 366 | def testDelete(self): 367 | from klangbecken.player import LiquidsoapClient 368 | 369 | command_calls = [ 370 | ("queue.secondary_queue", "2 4"), 371 | ("queue.remove 2", "OK"), 372 | ( 373 | "request.metadata 2", 374 | '''queue="secondary" 375 | rid="2" 376 | status="destroyed" 377 | source="queue" 378 | temporary="false" 379 | filename="data/classics/4daabe44-6d48-47c4-a187-592cf048b039.mp3"''', 380 | ), 381 | ] 382 | 383 | def side_effect(actual_command): 384 | command, result = command_calls.pop(0) 385 | self.assertEqual(command, actual_command) 386 | return result 387 | 388 | client = LiquidsoapClient() 389 | client.command = mock.Mock(side_effect=side_effect) 390 | client.delete("2") 391 | self.assertEqual(command_calls, []) 392 | 393 | def testDeleteNotFound(self): 394 | from klangbecken.player import LiquidsoapClient 395 | 396 | command_calls = [ 397 | ("queue.secondary_queue", "2 4"), 398 | # Should not be called: 399 | # ("queue.remove 3", "ERROR: No such request in queue!"), 400 | ] 401 | 402 | def side_effect(actual_command): 403 | command, result = command_calls.pop(0) 404 | self.assertEqual(command, actual_command) 405 | return result 406 | 407 | client = LiquidsoapClient() 408 | client.command = mock.Mock(side_effect=side_effect) 409 | with self.assertRaises(NotFound): 410 | client.delete("3") 411 | self.assertEqual(command_calls, []) 412 | -------------------------------------------------------------------------------- /test/test_api.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import os 4 | import shutil 5 | import tempfile 6 | import unittest 7 | import uuid 8 | from unittest import mock 9 | 10 | from werkzeug.test import Client 11 | 12 | 13 | class GenericAPITestCase(unittest.TestCase): 14 | @mock.patch( 15 | "klangbecken.api.JWTAuthorizationMiddleware", lambda app, *args, **kwargs: app 16 | ) 17 | def setUp(self): 18 | from klangbecken.api import klangbecken_api 19 | 20 | self.app = klangbecken_api( 21 | "very secret", 22 | "data_dir", 23 | "player.sock", 24 | upload_analyzers=[], 25 | update_analyzers=[], 26 | processors=[], 27 | ) 28 | self.client = Client(self.app) 29 | 30 | def test_application(self): 31 | self.assertTrue(callable(self.app)) 32 | 33 | def testUrls(self): 34 | resp = self.client.get("/") 35 | self.assertEqual(resp.status_code, 200) 36 | resp = self.client.get("/playlist/music/") 37 | self.assertEqual(resp.status_code, 405) 38 | resp = self.client.get("/playlist/jingles/") 39 | self.assertEqual(resp.status_code, 405) 40 | resp = self.client.get("/playlist/nonexistant/") 41 | self.assertEqual(resp.status_code, 404) 42 | resp = self.client.get("/öäü/") 43 | self.assertEqual(resp.status_code, 404) 44 | resp = self.client.post("/playlist/jingles") 45 | self.assertIn(resp.status_code, (301, 308)) 46 | resp = self.client.post("/playlist/music/") 47 | self.assertEqual(resp.status_code, 422) 48 | resp = self.client.post("/playlist/jingles/something") 49 | self.assertEqual(resp.status_code, 404) 50 | resp = self.client.put("/playlist/music/") 51 | self.assertEqual(resp.status_code, 405) 52 | resp = self.client.put("/playlist/jingles/something") 53 | self.assertEqual(resp.status_code, 404) 54 | resp = self.client.put("/playlist/jingles/something.mp3") 55 | self.assertEqual(resp.status_code, 404) 56 | resp = self.client.put("/playlist/music/" + str(uuid.uuid4())) 57 | self.assertEqual(resp.status_code, 404) 58 | resp = self.client.put("/playlist/music/" + str(uuid.uuid4()) + ".mp3") 59 | self.assertEqual(resp.status_code, 415) 60 | resp = self.client.put("/playlist/classics/" + str(uuid.uuid4()) + ".mp3") 61 | self.assertEqual(resp.status_code, 415) 62 | resp = self.client.put("/playlist/jingles/" + str(uuid.uuid4()) + ".mp3") 63 | self.assertEqual(resp.status_code, 415) 64 | resp = self.client.put("/playlist/jingles/" + str(uuid.uuid4()) + ".ttt") 65 | self.assertEqual(resp.status_code, 404) 66 | resp = self.client.delete("/playlist/music/") 67 | self.assertEqual(resp.status_code, 405) 68 | resp = self.client.delete("/playlist/jingles/something") 69 | self.assertEqual(resp.status_code, 404) 70 | resp = self.client.delete("/playlist/jingles/something.mp3") 71 | self.assertEqual(resp.status_code, 404) 72 | resp = self.client.delete("/playlist/music/" + str(uuid.uuid4())) 73 | self.assertEqual(resp.status_code, 404) 74 | resp = self.client.delete("/playlist/music/" + str(uuid.uuid4()) + ".mp3") 75 | self.assertEqual(resp.status_code, 200) 76 | resp = self.client.delete("/playlist/classics/" + str(uuid.uuid4()) + ".mp3") 77 | self.assertEqual(resp.status_code, 200) 78 | resp = self.client.delete("/playlist/jingles/" + str(uuid.uuid4()) + ".mp3") 79 | self.assertEqual(resp.status_code, 200) 80 | resp = self.client.delete("/playlist/music/" + str(uuid.uuid4()) + ".ttt") 81 | self.assertEqual(resp.status_code, 404) 82 | resp = self.client.get("/player/") 83 | self.assertEqual(resp.status_code, 404) 84 | self.assertIn(b"Player not running", resp.data) 85 | 86 | 87 | class AuthTestCase(unittest.TestCase): 88 | def setUp(self): 89 | from klangbecken.api import klangbecken_api 90 | 91 | with mock.patch( 92 | "klangbecken.api.DEFAULT_UPLOAD_ANALYZERS", [lambda *args: []] 93 | ), mock.patch( 94 | "klangbecken.api.DEFAULT_UPDATE_ANALYZERS", [lambda *args: []] 95 | ), mock.patch( 96 | "klangbecken.api.DEFAULT_PROCESSORS", [lambda *args: None] 97 | ): 98 | app = klangbecken_api( 99 | "very secret", 100 | "inexistent_dir", 101 | "nix.sock", 102 | ) 103 | self.client = Client(app) 104 | 105 | def testFailingAuth(self): 106 | resp = self.client.post("/playlist/music/") 107 | self.assertEqual(resp.status_code, 401) 108 | resp = self.client.put("/playlist/jingles/" + str(uuid.uuid4()) + ".mp3") 109 | self.assertEqual(resp.status_code, 401) 110 | resp = self.client.delete("/playlist/music/" + str(uuid.uuid4()) + ".ogg") 111 | self.assertEqual(resp.status_code, 401) 112 | 113 | def testFailingLogin(self): 114 | resp = self.client.get("/auth/login/") 115 | self.assertEqual(resp.status_code, 401) 116 | self.assertNotIn("Set-Cookie", resp.headers) 117 | 118 | resp = self.client.post("/auth/login/") 119 | self.assertEqual(resp.status_code, 401) 120 | self.assertNotIn("Set-Cookie", resp.headers) 121 | 122 | def testLogin(self): 123 | resp = self.client.post("/auth/login/", environ_base={"REMOTE_USER": "xyz"}) 124 | self.assertEqual(resp.status_code, 200) 125 | response_data = json.loads(resp.data) 126 | self.assertIn("token", response_data) 127 | self.assertRegex(response_data["token"], r"([a-zA-Z0-9_-]+\.){2}[a-zA-Z0-9_-]+") 128 | 129 | 130 | class PlaylistAPITestCase(unittest.TestCase): 131 | @mock.patch( 132 | "klangbecken.api.JWTAuthorizationMiddleware", lambda app, *args, **kwargs: app 133 | ) 134 | def setUp(self): 135 | from klangbecken.api import klangbecken_api 136 | from klangbecken.playlist import FileAddition, MetadataChange 137 | 138 | self.upload_analyzer = mock.Mock( 139 | return_value=[ 140 | FileAddition("testfile"), 141 | MetadataChange("testkey", "testvalue"), 142 | ] 143 | ) 144 | self.update_analyzer = mock.Mock(return_value=["UpdateChange"]) 145 | self.processor = mock.MagicMock() 146 | 147 | app = klangbecken_api( 148 | "very secret", 149 | "data_dir", 150 | "player.sock", 151 | upload_analyzers=[self.upload_analyzer], 152 | update_analyzers=[self.update_analyzer], 153 | processors=[self.processor], 154 | ) 155 | self.client = Client(app) 156 | 157 | @mock.patch("werkzeug.datastructures.FileStorage.save", lambda *args: None) 158 | @mock.patch("os.remove", lambda fname: None) 159 | def testUpload(self): 160 | from klangbecken.playlist import FileAddition, MetadataChange 161 | 162 | # Correct upload 163 | resp = self.client.post( 164 | "/playlist/music/", data={"file": (io.BytesIO(b"testcontent"), "test.mp3")} 165 | ) 166 | self.assertEqual(resp.status_code, 200) 167 | data = json.loads(resp.data) 168 | fileId = list(data.keys())[0] 169 | self.assertEqual(fileId, str(uuid.UUID(fileId))) 170 | self.assertEqual( 171 | list(data.values())[0], 172 | {"testkey": "testvalue", "original_filename": "test.mp3", "uploader": ""}, 173 | ) 174 | self.update_analyzer.assert_not_called() 175 | self.upload_analyzer.assert_called_once() 176 | args = self.upload_analyzer.call_args[0] 177 | self.assertEqual(args[0], "music") 178 | self.assertEqual(args[1], fileId) 179 | self.assertEqual(args[2], "mp3") 180 | self.assertTrue(isinstance(args[3], str)) 181 | self.assertTrue(args[3].startswith("data_dir/upload/")) 182 | 183 | self.processor.assert_called_once_with( 184 | "data_dir", 185 | "music", 186 | fileId, 187 | "mp3", 188 | [ 189 | FileAddition("testfile"), 190 | MetadataChange("testkey", "testvalue"), 191 | MetadataChange("original_filename", "test.mp3"), 192 | MetadataChange("uploader", ""), 193 | ], 194 | ) 195 | 196 | self.upload_analyzer.reset_mock() 197 | self.processor.reset_mock() 198 | 199 | def testUpdate(self): 200 | # Update weight correctly 201 | fileId = str(uuid.uuid4()) 202 | resp = self.client.put( 203 | "/playlist/music/" + fileId + ".mp3", 204 | data=json.dumps({"weight": 4}), 205 | content_type="text/json", 206 | ) 207 | self.assertEqual(resp.status_code, 200) 208 | self.update_analyzer.assert_called_once_with( 209 | "music", fileId, "mp3", {"weight": 4} 210 | ) 211 | self.upload_analyzer.assert_not_called() 212 | self.processor.assert_called_once_with( 213 | "data_dir", "music", fileId, "mp3", ["UpdateChange"] 214 | ) 215 | self.update_analyzer.reset_mock() 216 | self.processor.reset_mock() 217 | 218 | # Update artist and title correctly 219 | resp = self.client.put( 220 | "/playlist/music/" + fileId + ".mp3", 221 | data=json.dumps({"artist": "A", "title": "B"}), 222 | content_type="text/json", 223 | ) 224 | self.assertEqual(resp.status_code, 200) 225 | self.update_analyzer.assert_called_once_with( 226 | "music", fileId, "mp3", {"artist": "A", "title": "B"} 227 | ) 228 | self.processor.assert_called_once_with( 229 | "data_dir", "music", fileId, "mp3", ["UpdateChange"] 230 | ) 231 | self.update_analyzer.reset_mock() 232 | self.processor.reset_mock() 233 | 234 | # Update with invalid json format 235 | resp = self.client.put( 236 | "/playlist/music/" + fileId + ".mp3", 237 | data='{ a: " }', 238 | content_type="text/json", 239 | ) 240 | self.assertEqual(resp.status_code, 415) 241 | self.assertIn(b"invalid JSON", resp.data) 242 | self.update_analyzer.assert_not_called() 243 | 244 | # Update with invalid unicode format 245 | resp = self.client.put( 246 | "/playlist/music/" + fileId + ".mp3", data=b"\xFF", content_type="text/json" 247 | ) 248 | self.assertEqual(resp.status_code, 415) 249 | self.assertIn(b"invalid UTF-8 data", resp.data) 250 | self.update_analyzer.assert_not_called() 251 | 252 | def testDelete(self): 253 | from klangbecken.playlist import FileDeletion 254 | 255 | fileId = str(uuid.uuid4()) 256 | resp = self.client.delete("/playlist/music/" + fileId + ".mp3") 257 | self.assertEqual(resp.status_code, 200) 258 | self.update_analyzer.assert_not_called() 259 | self.upload_analyzer.assert_not_called() 260 | self.processor.assert_called_once_with( 261 | "data_dir", "music", fileId, "mp3", [FileDeletion()] 262 | ) 263 | self.upload_analyzer.reset_mock() 264 | self.processor.reset_mock() 265 | 266 | 267 | class PlayerAPITestCase(unittest.TestCase): 268 | def setUp(self): 269 | from klangbecken.api import player_api 270 | 271 | self.liquidsoap_client = mock.MagicMock(name="LiquidsoapClient") 272 | self.liquidsoap_client_class = mock.Mock(return_value=self.liquidsoap_client) 273 | self.liquidsoap_client.__enter__ = mock.Mock( 274 | return_value=self.liquidsoap_client 275 | ) 276 | self.tempdir = tempfile.mkdtemp() 277 | app = player_api("inexistent.sock", self.tempdir) 278 | os.mkdir(os.path.join(self.tempdir, "music")) 279 | with open(os.path.join(self.tempdir, "music", "titi.mp3"), "w"): 280 | pass 281 | self.client = Client(app) 282 | 283 | def tearDown(self): 284 | shutil.rmtree(self.tempdir) 285 | 286 | def testInfo(self): 287 | self.liquidsoap_client.info = mock.Mock(return_value="info") 288 | 289 | with mock.patch( 290 | "klangbecken.api.LiquidsoapClient", self.liquidsoap_client_class 291 | ): 292 | resp = self.client.get("/") 293 | self.assertEqual(resp.status_code, 200) 294 | self.assertIn(b"info", resp.data) 295 | self.liquidsoap_client.info.assert_called_once_with() 296 | 297 | def testReloadPlaylist(self): 298 | self.liquidsoap_client.command = mock.Mock(return_value="") 299 | 300 | with mock.patch( 301 | "klangbecken.api.LiquidsoapClient", self.liquidsoap_client_class 302 | ): 303 | resp = self.client.post("/reload/jingles") 304 | self.assertEqual(resp.status_code, 200) 305 | self.liquidsoap_client.command.assert_called_once_with("jingles.reload") 306 | 307 | def testQueueListCorrect(self): 308 | self.liquidsoap_client.queue = mock.Mock(return_value="queue") 309 | with mock.patch( 310 | "klangbecken.api.LiquidsoapClient", self.liquidsoap_client_class 311 | ): 312 | resp = self.client.get("/queue/") 313 | self.assertEqual(resp.status_code, 200) 314 | self.assertIn(b"queue", resp.data) 315 | self.liquidsoap_client.queue.assert_called_once_with() 316 | 317 | def testQueuePushCorrect(self): 318 | self.liquidsoap_client.push = mock.Mock(return_value="my_id") 319 | with mock.patch( 320 | "klangbecken.api.LiquidsoapClient", self.liquidsoap_client_class 321 | ): 322 | resp = self.client.post( 323 | "/queue/", data=json.dumps({"filename": "music/titi.mp3"}) 324 | ) 325 | self.assertEqual(resp.status_code, 200) 326 | data = json.loads(resp.data) 327 | self.assertEqual(data["queue_id"], "my_id") 328 | self.liquidsoap_client.push.assert_called_once_with( 329 | os.path.join(self.tempdir, "music", "titi.mp3") 330 | ) 331 | 332 | def testQueuePushIncorrect(self): 333 | self.liquidsoap_client.push = mock.Mock(return_value="my_track_id") 334 | with mock.patch( 335 | "klangbecken.api.LiquidsoapClient", self.liquidsoap_client_class 336 | ): 337 | resp = self.client.post( 338 | "/queue/", data=json.dumps({"filename": "music/tata.mp3"}) 339 | ) 340 | self.assertEqual(resp.status_code, 404) 341 | self.liquidsoap_client.push.assert_not_called() 342 | 343 | with mock.patch( 344 | "klangbecken.api.LiquidsoapClient", self.liquidsoap_client_class 345 | ): 346 | resp = self.client.post( 347 | "/queue/", data=json.dumps({"filename": "music/titi.abc"}) 348 | ) 349 | self.assertEqual(resp.status_code, 422) 350 | self.liquidsoap_client.push.assert_not_called() 351 | 352 | with mock.patch( 353 | "klangbecken.api.LiquidsoapClient", self.liquidsoap_client_class 354 | ): 355 | resp = self.client.post( 356 | "/queue/", data=json.dumps({"file": "music/titi.mp3"}) 357 | ) 358 | self.assertEqual(resp.status_code, 422) 359 | self.liquidsoap_client.push.assert_not_called() 360 | 361 | def testQueueDelete(self): 362 | with mock.patch( 363 | "klangbecken.api.LiquidsoapClient", self.liquidsoap_client_class 364 | ): 365 | resp = self.client.delete("/queue/15") 366 | self.assertEqual(resp.status_code, 200) 367 | self.liquidsoap_client.delete.assert_called_once_with("15") 368 | --------------------------------------------------------------------------------