Skip to content

Commit

Permalink
feat: serve playlists
Browse files Browse the repository at this point in the history
...without rewriting item URIs yet
  • Loading branch information
mgoltzsche committed Feb 19, 2024
0 parents commit 468e215
Show file tree
Hide file tree
Showing 15 changed files with 410 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
16 changes: 16 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
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/webm3u/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-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) .

70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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`.
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__)
70 changes: 70 additions & 0 deletions beetsplug/webm3u/__init__.py
Original file line number Diff line number Diff line change
@@ -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 '<html><body><ul><li><a href="playlists">Playlists</a></li><li><a href="media">Audio files</a></ul></body></html>'

app.register_blueprint(bp)

return app
30 changes: 30 additions & 0 deletions beetsplug/webm3u/routes.py
Original file line number Diff line number Diff line change
@@ -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/<path:path>')
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))
29 changes: 29 additions & 0 deletions example_beets_config.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 468e215

Please sign in to comment.