Skip to content

Commit

Permalink
feat: support configurable http routes
Browse files Browse the repository at this point in the history
...mapped to beets plugins
  • Loading branch information
mgoltzsche committed Feb 19, 2024
0 parents commit 829223b
Show file tree
Hide file tree
Showing 15 changed files with 440 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Makefile
/*.egg-info/
/build
/data
/tests
/.git
57 changes: 57 additions & 0 deletions .github/workflows/workflow.yml
Original file line number Diff line number Diff line change
@@ -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

5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/*.egg-info/
/build
/dist
__pycache__
/data
17 changes: 17 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
include README.md
include beetsplug/webrouter/config_default.yaml
71 changes: 71 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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) .

85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 2 additions & 0 deletions beetsplug/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
92 changes: 92 additions & 0 deletions beetsplug/webrouter/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 10 additions & 0 deletions beetsplug/webrouter/config_default.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 829223b

Please sign in to comment.