diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e12a6be --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +Makefile +/*.egg-info/ +/build +/data +/tests +/.git diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml new file mode 100644 index 0000000..01c9a50 --- /dev/null +++ b/.github/workflows/workflow.yml @@ -0,0 +1,57 @@ +name: Build and release + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: # Run release builds sequentially, cancel outdated PR builds + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + permissions: # Grant write access to github.token within non-pull_request builds + contents: write + id-token: write + + steps: + - name: Check out code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + persist-credentials: false + + - id: release + name: Prepare release + uses: mgoltzsche/conventional-release@v0 + with: + commit-files: setup.py + + - name: Bump module version + if: steps.release.outputs.publish # runs only if release build + run: | + sed -Ei "s/^( +version=).+/\1'$RELEASE_VERSION',/" setup.py + + - name: Unit test + run: make test + + - name: E2E test + run: make test-e2e + + - name: Build wheel + run: | + set -u + echo Building beets-ytimport wheel $RELEASE_VERSION + make + + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + if: steps.release.outputs.publish # runs only if release build + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c624bff --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/*.egg-info/ +/build +/dist +__pycache__ +/data diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..41c2868 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM ghcr.io/mgoltzsche/beets-plugins:0.13.1 + +# Install bats +USER root:root +ARG BATS_VERSION=1.10.0 +RUN set -eux; \ + wget -qO - https://github.com/bats-core/bats-core/archive/refs/tags/v${BATS_VERSION}.tar.gz | tar -C /tmp -xzf -; \ + /tmp/bats-core-$BATS_VERSION/install.sh /opt/bats; \ + ln -s /opt/bats/bin/bats /usr/local/bin/bats; \ + rm -rf /tmp/bats-core-$BATS_VERSION + +# Install beets-webrouter plugin from source +RUN python -m pip install beetstream==1.2.0 beets-webm3u==0.1.0 +COPY dist /plugin/dist +RUN python -m pip install /plugin/dist/* +COPY example_beets_config.yaml /etc/beets/default-config.yaml +USER beets:beets diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..71cd781 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Max Goltzsche + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..fd31c04 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include README.md +include beetsplug/webrouter/config_default.yaml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..310d5ed --- /dev/null +++ b/Makefile @@ -0,0 +1,71 @@ +PYPI_REPO=https://upload.pypi.org/legacy/ +define DOCKERFILE +FROM python:3.9-alpine +RUN python -m pip install twine==4.0.2 +endef +export DOCKERFILE +BEETS_IMG=beets-webrouter +BUILD_IMG=beets-webrouter-build +DOCKER_OPTS=--rm -u `id -u`:`id -g` \ + -v "`pwd`:/work" -w /work \ + --entrypoint sh $(BUILD_IMG) -c + +.PHONY: wheel +wheel: clean python-container + docker run $(DOCKER_OPTS) 'python3 setup.py bdist_wheel' + +.PHONY: test +test: beets-container + # Run unit tests + mkdir -p data/beets + @docker run --rm -u `id -u`:`id -g` \ + -v "`pwd`:/plugin" -w /plugin \ + -v "`pwd`/data:/data" \ + --entrypoint sh $(BEETS_IMG) -c \ + 'set -x; python -m unittest discover /plugin/tests' + +.PHONY: test-e2e +test-e2e: beets-container + # Run e2e tests + mkdir -p data/beets + @docker run --rm -u `id -u`:`id -g` -w /plugin \ + -v "`pwd`:/plugin" -w /plugin \ + -v "`pwd`/data:/data" \ + -v "`pwd`/example_beets_config.yaml:/data/beets/config.yaml" \ + $(BEETS_IMG) \ + sh -c 'set -x; bats -T tests/e2e' + +.PHONY: beets-sh +beets-sh beets-webrouter: beets-%: beets-container + mkdir -p data/beets + docker run -ti --rm -u `id -u`:`id -g` --network=host \ + -v "`pwd`:/plugin" \ + -v "`pwd`/data:/data" \ + -v "`pwd`/example_beets_config.yaml:/data/beets/config.yaml" \ + -v /run:/host/run \ + -e PULSE_SERVER=unix:/host/run/user/`id -u`/pulse/native \ + $(BEETS_IMG) $* + +.PHONY: beets-container +beets-container: wheel + docker build --rm -t $(BEETS_IMG) . + +.PHONY: release +release: clean wheel + docker run -e PYPI_USER -e PYPI_PASS -e PYPI_REPO=$(PYPI_REPO) \ + $(DOCKER_OPTS) \ + 'python3 -m twine upload --repository-url "$$PYPI_REPO" -u "$$PYPI_USER" -p "$$PYPI_PASS" dist/*' + +.PHONY: clean +clean: + rm -rf build dist *.egg-info + find . -name __pycache__ -exec rm -rf {} \; || true + +.PHONY: clean-data +clean-data: clean + rm -rf data + +.PHONY: python-container +python-container: + echo "$$DOCKERFILE" | docker build --rm -f - -t $(BUILD_IMG) . + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4628d58 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# beets-webrouter + +A [beets](https://github.com/beetbox/beets) plugin to serve multiple Beets APIs on the same server/host/port. + +This allows serve the beets web UI, a Subsonic API as well as the generated M3U playlists with a single `beets webrouter` command. + +## Installation + +```sh +python3 -m pip install beets-webrouter +``` + +## Configuration + +Enable the plugin and add a `webrouter` section to your beets `config.yaml` as follows: +```yaml +plugins: + - webrouter + - web + - webm3u + - beetstream + - aura + - smartplaylist + +webrouter: + routes: + /: + plugin: web + /subsonic: + plugin: beetstream + config: + never_transcode: true + /aura: + plugin: aura + blueprint: aura_bp + /m3u: + plugin: webm3u + +aura: + page_limit: 100 + +smartplaylist: + auto: false + output: m3u + playlist_dir: /data/playlists + relative_to: /data/playlists + playlists: + - name: all.m3u + query: '' +``` + +## Usage + +Once the `webrouter` plugin is enabled within your beets configuration, you can run it as follows: +```sh +beet webrouter +``` + +Once the server started, you can browse [`http://localhost:8337`](http://localhost:8337). + +### CLI + +``` +Usage: beet webrouter [options] +``` + +## Development + +Run the unit tests (containerized): +```sh +make test +``` + +Run the e2e tests (containerized): +```sh +make test-e2e +``` + +To test your plugin changes manually, you can run a shell within a beets docker container as follows: +```sh +make beets-sh +``` + +A temporary beets library is written to `./data`. +It can be removed by calling `make clean-data`. diff --git a/beetsplug/__init__.py b/beetsplug/__init__.py new file mode 100644 index 0000000..3ad9513 --- /dev/null +++ b/beetsplug/__init__.py @@ -0,0 +1,2 @@ +from pkgutil import extend_path +__path__ = extend_path(__path__, __name__) diff --git a/beetsplug/webrouter/__init__.py b/beetsplug/webrouter/__init__.py new file mode 100644 index 0000000..6c9df3c --- /dev/null +++ b/beetsplug/webrouter/__init__.py @@ -0,0 +1,92 @@ +import codecs +import mediafile +import os +import re +import importlib +from flask import Flask +from beets.plugins import BeetsPlugin +from beets.ui import Subcommand, decargs +from beets import config +from optparse import OptionParser +from confuse import ConfigSource, load_yaml +from werkzeug.middleware.dispatcher import DispatcherMiddleware +from werkzeug.exceptions import NotFound +from beetsplug.web import ReverseProxied +from flask import Blueprint + + +class WebRouterPlugin(BeetsPlugin): + def __init__(self): + super().__init__() + config_file_path = os.path.join(os.path.dirname(__file__), 'config_default.yaml') + source = ConfigSource(load_yaml(config_file_path) or {}, config_file_path) + self.config.add(source) + + def commands(self): + p = OptionParser() + p.add_option('-d', '--debug', action='store_true', default=False, help='debug mode') + c = Subcommand('webrouter', parser=p, help='serve the web UI, Subsonic API and playlists') + c.func = self._run_cmd + return [c] + + def _run_cmd(self, lib, opts, args): + app = Flask(__name__) + routes = {} + blueprint_routes = [] + + for k,v in self.config['routes'].items(): + plugin = v['plugin'].get() + if plugin: + mod = importlib.import_module('beetsplug.{}'.format(plugin)) + if 'blueprint' in v and v['blueprint'].get(): + blueprint_routes.append({ + 'prefix': k, + 'blueprint': getattr(mod, v['blueprint'].get()), + }) + else: + if hasattr(mod, 'create_app'): + modapp = mod.create_app() + else: + modapp = mod.app + self._configure_app(modapp, v['config'].items(), lib) + if k == '/': + app = modapp + else: + routes[k] = modapp + + for e in blueprint_routes: + app.register_blueprint(e['blueprint'], url_prefix=e['prefix']) + + app.wsgi_app = DispatcherMiddleware(app.wsgi_app, routes) + + app.run( + host=self.config['host'].as_str(), + port=self.config['port'].get(int), + debug=opts.debug, + threaded=True, + ) + + def _configure_app(self, app, cfg, lib): + app.config['lib'] = lib + app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False + app.config['INCLUDE_PATHS'] = self.config['include_paths'] + app.config['READONLY'] = self.config["readonly"] + + if self.config['cors']: + self._log.info('Enabling CORS with origin {}', self.config['cors']) + from flask_cors import CORS + + app.config['CORS_ALLOW_HEADERS'] = 'Content-Type' + app.config['CORS_RESOURCES'] = { + r'/*': {'origins': self.config['cors'].get(str)} + } + CORS( + app, + supports_credentials=self.config['cors_supports_credentials'].get(bool), + ) + + for k,v in cfg: + app.config[k] = v.get() + + if self.config['reverse_proxy']: + app.wsgi_app = ReverseProxied(app.wsgi_app) diff --git a/beetsplug/webrouter/config_default.yaml b/beetsplug/webrouter/config_default.yaml new file mode 100644 index 0000000..37a221e --- /dev/null +++ b/beetsplug/webrouter/config_default.yaml @@ -0,0 +1,10 @@ +host: "127.0.0.1" +port: 8337 +cors: "" +cors_supports_credentials: false +reverse_proxy: false +include_paths: false +readonly: true +routes: + /: + plugin: web diff --git a/example_beets_config.yaml b/example_beets_config.yaml new file mode 100644 index 0000000..0616ac2 --- /dev/null +++ b/example_beets_config.yaml @@ -0,0 +1,41 @@ +directory: /data/music +library: /data/musiclibrary.db + +paths: + default: Albums/%title{$albumartist}/$album%aunique{}/$track $title + singleton: Singles/%title{$artist}/$title + comp: Compilations/$album%aunique{}/$track $title + +plugins: + - webrouter + - web + - webm3u + - beetstream + - aura + - smartplaylist + +webrouter: + routes: + /: + plugin: web + /subsonic: + plugin: beetstream + config: + never_transcode: true + /aura: + plugin: aura + blueprint: aura_bp + /m3u: + plugin: webm3u + +aura: + page_limit: 100 + +smartplaylist: + auto: false + output: m3u + playlist_dir: /data/playlists + relative_to: /data/playlists + playlists: + - name: all.m3u + query: '' diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..0433cef --- /dev/null +++ b/setup.py @@ -0,0 +1,24 @@ +import setuptools + +with open('README.md', 'r') as fh: + long_description = fh.read() + +setuptools.setup( + name='beets-webrouter', + version='0.0.0-dev', + author='Max Goltzsche', + description='Serve multiple beets APIs on the same host/port', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/mgoltzsche/beets-webrouter', + packages=setuptools.find_packages(), + include_package_data=True, + classifiers=[ + 'Programming Language :: Python :: 3.6', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + ], + install_requires=[ + 'beets', + ] +) diff --git a/tests/e2e/tests.bats b/tests/e2e/tests.bats new file mode 100644 index 0000000..4d965f0 --- /dev/null +++ b/tests/e2e/tests.bats @@ -0,0 +1 @@ +#!/usr/bin/env bats diff --git a/tests/test_todo.py b/tests/test_todo.py new file mode 100644 index 0000000..3521888 --- /dev/null +++ b/tests/test_todo.py @@ -0,0 +1,6 @@ +import unittest + +class TestTODO(unittest.TestCase): + + def test_todo(self): + pass