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..1335fca --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +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-webm3u plugin from source +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..97464d1 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include README.md +include beetsplug/webm3u/config_default.yaml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..607bead --- /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-webm3u +BUILD_IMG=beets-webm3u-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-webm3u: 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..251b45b --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# beets-webm3u + +A [beets](https://github.com/beetbox/beets) plugin to serve M3U playlists via HTTP. + +## Features + +* Allows to access M3U playlists (generated by the [smartplaylist plugin](https://beets.readthedocs.io/en/stable/plugins/smartplaylist.html)) via HTTP. +* Rewrites playlist item URIs to be accessible via HTTP, allowing to maintain a single set of playlists with local paths instead of having to generate each playlist with multiple URI formats (one per client/integration) upfront. + +## Installation + +```sh +python3 -m pip install beets-webm3u +``` + +## Configuration + +Enable the plugin and add a `webm3u` section to your beets `config.yaml` as follows: +```yaml +plugins: + - webm3u + +webm3u: + host: '127.0.0.1' + port: 8339 + cors: '' + cors_supports_credentials: false + reverse_proxy: false + include_paths: false + playlist_dir: /data/playlists +``` + +## Usage + +Once the `webm3u` plugin is enabled within your beets configuration, you can run it as follows: +```sh +beet webm3u +``` + +Once the server started, you can browse [`http://localhost:8339`](http://localhost:8339). + +### CLI + +``` +Usage: beet webm3u [options] + +Options: + -h, --help show this help message and exit + -d, --debug debug mode +``` + +## 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/webm3u/__init__.py b/beetsplug/webm3u/__init__.py new file mode 100644 index 0000000..9789944 --- /dev/null +++ b/beetsplug/webm3u/__init__.py @@ -0,0 +1,70 @@ +from flask import Flask +from beets.plugins import BeetsPlugin +from beets.ui import Subcommand, decargs +from optparse import OptionParser +from beetsplug.web import ReverseProxied +from beetsplug.webm3u.routes import bp + + +class PlaylistServerPlugin(BeetsPlugin): + def __init__(self): + super().__init__() + self.config.add( + { + 'host': '127.0.0.1', + 'port': 8339, + 'cors': '', + 'cors_supports_credentials': False, + 'reverse_proxy': False, + 'include_paths': False, + 'playlist_dir': None, + } + ) + + def commands(self): + p = OptionParser() + p.add_option('-d', '--debug', action='store_true', default=False, help='debug mode') + c = Subcommand('webm3u', parser=p, help='serve the playlists via HTTP') + c.func = self._run_server + return [c] + + def _run_server(self, lib, opts, args): + create_app().run( + host=self.config['host'].as_str(), + port=self.config['port'].get(int), + debug=opts.debug, + threaded=True, + ) + + def _configure_app(self, app, lib): + app.config['lib'] = lib + app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False + app.config['INCLUDE_PATHS'] = self.config['include_paths'] + app.config['READONLY'] = True + + 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), + ) + + if self.config['reverse_proxy']: + app.wsgi_app = ReverseProxied(app.wsgi_app) + +def create_app(): + app = Flask(__name__) + + @app.route('/') + def home(): + return '' + + app.register_blueprint(bp) + + return app diff --git a/beetsplug/webm3u/routes.py b/beetsplug/webm3u/routes.py new file mode 100644 index 0000000..cbc6340 --- /dev/null +++ b/beetsplug/webm3u/routes.py @@ -0,0 +1,30 @@ +import os +from flask import Flask, Blueprint, send_from_directory, send_file, abort +from beets import config + + +bp = Blueprint('webm3u_bp', __name__, template_folder='templates') + +@bp.route('/playlists/', defaults={'path': ''}) +@bp.route('/playlists/') +def index_page(path): + root_dir = config['webm3u']['playlist_dir'].get() + if not root_dir: + root_dir = config['smartplaylist']['playlist_dir'].get() + abs_path = os.path.join(root_dir, path) + _check_path(root_dir, abs_path) + if not os.path.exists(abs_path): + return abort(404) + if os.path.isfile(abs_path): + # TODO: transform item URIs within playlist + return send_file(abs_path) + else: + # Generate html/json directory listing + return 'playlist dir' + #return send_from_directory(root_dir, path, as_attachment=True) + +def _check_path(root_dir, path): + path = os.path.normpath(path) + root_dir = os.path.normpath(root_dir) + if path != root_dir and not path.startswith(root_dir+os.sep): + raise Exception('request path {} is outside the root directory {}'.format(path, root_dir)) diff --git a/example_beets_config.yaml b/example_beets_config.yaml new file mode 100644 index 0000000..68fb008 --- /dev/null +++ b/example_beets_config.yaml @@ -0,0 +1,29 @@ +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: + - webm3u + - smartplaylist + - ytimport + +webm3u: + host: 127.0.0.1 + port: 8339 + playlist_dir: /data/playlists + +smartplaylist: + auto: false + output: m3u8 + playlist_dir: /data/playlists + relative_to: /data/playlists + playlists: + - name: all.m3u8 + query: '' + +ytimport: + directory: /data/ytimport diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..15908e5 --- /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-webm3u', + version='0.0.0-dev', + author='Max Goltzsche', + description='Serve M3U playlists via HTTP', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/mgoltzsche/beets-webm3u', + 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