From d5934a99fb356aca7549c30719145ab279d75c19 Mon Sep 17 00:00:00 2001 From: evilchili Date: Sun, 4 Dec 2022 12:09:27 -0800 Subject: [PATCH] implementing themes and refactoring path operations --- groove/db/scanner.py | 11 +- groove/exceptions.py | 18 ++ groove/path.py | 90 ++++++++ groove/webserver/themes.py | 62 ++++++ groove/webserver/webserver.py | 44 ++-- static/player.js | 241 +++++++++++++++++++++ themes/blue_train/README.md | 10 + themes/blue_train/static/styles.css | 259 +++++++++++++++++++++++ themes/blue_train/templates/playlist.tpl | 66 ++++++ 9 files changed, 778 insertions(+), 23 deletions(-) create mode 100644 groove/path.py create mode 100644 groove/webserver/themes.py create mode 100644 static/player.js create mode 100644 themes/blue_train/README.md create mode 100644 themes/blue_train/static/styles.css create mode 100644 themes/blue_train/templates/playlist.tpl diff --git a/groove/db/scanner.py b/groove/db/scanner.py index 7884273..b97d74f 100644 --- a/groove/db/scanner.py +++ b/groove/db/scanner.py @@ -1,8 +1,6 @@ import asyncio import logging import os -import sys - import music_tag from pathlib import Path @@ -10,20 +8,17 @@ from typing import Callable, Union, Iterable from sqlalchemy import func import groove.db +import groove.path class MediaScanner: """ Scan a directory structure containing audio files and import them into the database. """ - def __init__(self, root: Path, db: Callable, glob: Union[str, None] = None) -> None: + def __init__(self, root: Union[Path, None], db: Callable, glob: Union[str, None] = None) -> None: self._db = db self._glob = tuple((glob or os.environ.get('MEDIA_GLOB')).split(',')) - try: - self._root = root or Path(os.environ.get('MEDIA_ROOT')) - except TypeError: - logging.error("Could not find media root. Do you need to define MEDIA_ROOT in your environment?") - sys.exit(1) + self._root = root or groove.path.media_root() logging.debug(f"Configured media scanner for root: {self._root}") @property diff --git a/groove/exceptions.py b/groove/exceptions.py index a21ed70..ff514b6 100644 --- a/groove/exceptions.py +++ b/groove/exceptions.py @@ -3,3 +3,21 @@ class APIHandlingException(Exception): """ An API reqeust could not be encoded or decoded. """ + + +class ThemeMissingException(Exception): + """ + The specified theme could not be loaded. + """ + + +class ThemeConfigurationError(Exception): + """ + A theme is missing required files or configuration. + """ + + +class ConfigurationError(Exception): + """ + An error was discovered with the Groove on Demand configuration. + """ diff --git a/groove/path.py b/groove/path.py new file mode 100644 index 0000000..7722fef --- /dev/null +++ b/groove/path.py @@ -0,0 +1,90 @@ +import os + +from pathlib import Path +from groove.exceptions import ConfigurationError, ThemeMissingException + +_setup_hint = "You may be able to solve this error by running 'groove setup'." +_reinstall_hint = "You might need to reinstall Groove On Demand to fix this error." + + +def root(): + path = os.environ.get('GROOVE_ON_DEMAND_ROOT', None) + if not path: + raise ConfigurationError(f"GROOVE_ON_DEMAND_ROOT is not defined in your environment.\n\n{_setup_hint}") + path = Path(path).expanduser() + if not path.exists or not path.is_dir: + raise ConfigurationError( + "The Groove on Demand root directory (GROOVE_ON_DEMAND_ROOT) " + f"does not exist or isn't a directory.\n\n{_reinstall_hint}" + ) + return Path(path) + + +def media_root(): + path = os.environ.get('MEDIA_ROOT', None) + if not path: + raise ConfigurationError(f"MEDIA_ROOT is not defined in your environment.\n\n{_setup_hint}") + path =Path(path).expanduser() + if not path.exists and path.is_dir: + raise ConfigurationError( + "The media_root directory (MEDIA_ROOT) doesn't exist, or isn't a directory.\n\n{_setup_hint}" + ) + return path + + +def media(relpath): + return themes_root() / Path(relpath) + + +def static_root(): + dirname = os.environ.get('STATIC_PATH', 'static') + path = root() / Path(dirname) + if not path.exists or not path.is_dir: + raise ConfigurationError( + f"The static assets directory {dirname} (STATIC_PATH) " + f"doesn't exist, or isn't a directory.\n\n{_reinstall_hint}" + ) + return path + + +def static(relpath): + return static_root() / Path(relpath) + + +def themes_root(): + dirname = os.environ.get('THEMES_PATH', 'themes') + path = root() / Path(dirname) + if not path.exists or not path.is_dir: + raise ConfigurationError( + f"The themes directory {dirname} (THEMES_PATH) " + f"doesn't exist, or isn't a directory.\n\n{_reinstall_hint}" + ) + return path + + +def theme(name): + path = themes_root() / Path(name) + if not path.exists or not path.is_dir: + available = ','.join(available_themes) + raise ThemeMissingException( + f"A theme directory named {name} does not exist or isn't a directory. " + "Perhaps there is a typo in the name?\n" + f"Available themes: {available}" + ) + return path + + +def theme_static(relpath): + return Path('static') / Path(relpath) + + +def theme_template(template_name): + return Path('templates') / Path(f"{template_name}.tpl") + + +def available_themes(): + return [theme.name for theme in themes_root().iterdir() if theme.is_dir()] + + +def database(): + return root() / Path(os.environ.get('DATABASE_PATH', 'groove_on_demand.db')) diff --git a/groove/webserver/themes.py b/groove/webserver/themes.py new file mode 100644 index 0000000..ae5bfc8 --- /dev/null +++ b/groove/webserver/themes.py @@ -0,0 +1,62 @@ +import logging +import os +from collections import namedtuple +from pathlib import Path + +from groove.exceptions import ThemeConfigurationError, ConfigurationError + +import groove.path + + +Theme = namedtuple('Theme', 'path,author,author_link,version,about') + + +def load_theme(name=None): + name = os.environ.get('DEFAULT_THEME', name) + if not name: + raise ConfigurationError( + "It seems like DEFAULT_THEME is not set in your current environment.\n" + "Running 'groove setup' may help you fix this problem." + ) + theme_path = groove.path.theme(name) + theme_info = _get_theme_info(theme_path) + return Theme( + path=theme_path, + **theme_info + ) + + +def _get_theme_info(theme_path): + readme = theme_path / Path('README.md') + if not readme.exists: + raise ThemeConfigurationError( + "The theme is missing a required file: README.md.\n" + "Refer to the Groove On Demand documentation for help creating themes." + ) + config = {'about': ''} + with readme.open() as fh: + in_credits = False + in_block = False + for line in fh.readlines(): + line = line.strip() + if line == '## Credits': + in_credits = True + continue + config['about'] += line + if not in_credits: + continue + if line == '```': + if not in_block: + in_block = True + continue + break + try: + (key, value) = line.split(':', 1) + key = key.strip() + value = value.strip() + except ValueError: + logging.warn(f"Could not parse credits line: {line}") + continue + logging.debug(f"Setting theme '{key}' to '{value}'.") + config[key] = value + return config diff --git a/groove/webserver/webserver.py b/groove/webserver/webserver.py index 54fcf30..95b5488 100644 --- a/groove/webserver/webserver.py +++ b/groove/webserver/webserver.py @@ -1,7 +1,6 @@ import logging import json import os -from pathlib import Path import bottle from bottle import HTTPResponse, template, static_file @@ -11,9 +10,7 @@ import groove.db from groove.auth import is_authenticated from groove.db.manager import database_manager from groove.playlist import Playlist -from groove.webserver import requests - -# from groove.exceptions import APIHandlingException +from groove.webserver import requests, themes server = bottle.Bottle() @@ -42,6 +39,16 @@ def start(host: str, port: int, debug: bool) -> None: # pragma: no cover ) +def serve(template_name, theme=None, **template_args): + theme = themes.load_theme(theme) + return HTTPResponse(status=200, body=template( + str(theme.path / groove.path.theme_template(template_name)), + url=requests.url(), + theme=theme, + **template_args + )) + + @server.route('/') def index(): return "Groovy." @@ -55,7 +62,18 @@ def build(): @server.route('/static/') def server_static(filepath): - return static_file(filepath, root='static') + theme = themes.load_theme() + asset = theme.path / groove.path.theme_static(filepath) + if asset.exists(): + root = asset.parent + else: + root = groove.path.static_root() + asset = groove.path.static(filepath) + if asset.is_dir(): + logging.warning("Asset {asset} is a directory; returning 404.") + return HTTPResponse(status=404, body="Not found.") + logging.debug(f"Serving asset {asset.name} from {root}") + return static_file(asset.name, root=root) @bottle.auth_basic(is_authenticated) @@ -79,8 +97,11 @@ def serve_track(request, track_id, db): groove.db.track.c.id == track_id ).one() - path = Path(os.environ['MEDIA_ROOT']) / Path(track['relpath']) - return static_file(path.name, root=path.parent) + path = groove.path.media(track['relpath']) + if path.exists: + return static_file(path.name, root=path.parent) + else: + return HTTPResponse(status=404, body="Not found") @server.route('/playlist/') @@ -97,15 +118,8 @@ def serve_playlist(slug, db): logging.debug(playlist.as_dict['entries']) pl = playlist.as_dict - for entry in pl['entries']: sig = requests.encode([str(entry['track_id'])], uri='/track') entry['url'] = f"/track/{sig}/{entry['track_id']}" - template_path = Path(os.environ['TEMPLATE_PATH']) / Path('playlist.tpl') - body = template( - str(template_path), - url=requests.url(), - playlist=pl - ) - return HTTPResponse(status=200, body=body) + return serve('playlist', playlist=pl) diff --git a/static/player.js b/static/player.js new file mode 100644 index 0000000..3908e36 --- /dev/null +++ b/static/player.js @@ -0,0 +1,241 @@ +/*! + * Howler.js Audio Player Demo + * howlerjs.com + * + * (c) 2013-2020, James Simpson of GoldFire Studios + * goldfirestudios.com + * + * MIT License + */ + +// Cache references to DOM elements. +var elms = ['track', 'timer', 'duration', 'playBtn', 'pauseBtn', 'prevBtn', 'nextBtn', 'playlistBtn', 'progress', 'bar', 'loading', 'playlist', 'list', 'barEmpty', 'barFull', 'sliderBtn']; +elms.forEach(function(elm) { + window[elm] = document.getElementById(elm); +}); + +/** + * Player class containing the state of our playlist and where we are in it. + * Includes all methods for playing, skipping, updating the display, etc. + * @param {Array} playlist Array of objects with playlist song details ({title, url, howl}). + */ +var Player = function(playlist) { + this.playlist = playlist; + this.index = 0; + + // Display the title of the first track. + track.innerHTML = '1. ' + playlist[0].title; + + // Setup the playlist display. + playlist.forEach(function(song) { + var div = document.createElement('div'); + div.className = 'list-song'; + div.innerHTML = song.title; + div.onclick = function() { + player.skipTo(playlist.indexOf(song)); + }; + list.appendChild(div); + }); +}; +Player.prototype = { + /** + * Play a song in the playlist. + * @param {Number} index Index of the song in the playlist (leave empty to play the first or current). + */ + play: function(index) { + var self = this; + var sound; + + index = typeof index === 'number' ? index : self.index; + var data = self.playlist[index]; + + // If we already loaded this track, use the current one. + // Otherwise, setup and load a new Howl. + if (data.howl) { + sound = data.howl; + } else { + sound = data.howl = new Howl({ + src: [data.url], + html5: true, // Force to HTML5 so that the audio can stream in (best for large files). + onplay: function() { + // Display the duration. + duration.innerHTML = self.formatTime(Math.round(sound.duration())); + + // Start updating the progress of the track. + requestAnimationFrame(self.step.bind(self)); + + pauseBtn.style.display = 'block'; + }, + onload: function() { + loading.style.display = 'none'; + }, + onend: function() { + self.skip('next'); + }, + onpause: function() { + }, + onstop: function() { + }, + onseek: function() { + // Start updating the progress of the track. + requestAnimationFrame(self.step.bind(self)); + } + }); + } + + // Begin playing the sound. + sound.play(); + + // Update the track display. + track.innerHTML = (index + 1) + '. ' + data.title; + + // Show the pause button. + if (sound.state() === 'loaded') { + playBtn.style.display = 'none'; + pauseBtn.style.display = 'block'; + } else { + loading.style.display = 'block'; + playBtn.style.display = 'none'; + pauseBtn.style.display = 'none'; + } + + // Keep track of the index we are currently playing. + self.index = index; + }, + + /** + * Pause the currently playing track. + */ + pause: function() { + var self = this; + + // Get the Howl we want to manipulate. + var sound = self.playlist[self.index].howl; + + // Puase the sound. + sound.pause(); + + // Show the play button. + playBtn.style.display = 'block'; + pauseBtn.style.display = 'none'; + }, + + /** + * Skip to the next or previous track. + * @param {String} direction 'next' or 'prev'. + */ + skip: function(direction) { + var self = this; + + // Get the next track based on the direction of the track. + var index = 0; + if (direction === 'prev') { + index = self.index - 1; + if (index < 0) { + index = self.playlist.length - 1; + } + } else { + index = self.index + 1; + if (index >= self.playlist.length) { + index = 0; + } + } + + self.skipTo(index); + }, + + /** + * Skip to a specific track based on its playlist index. + * @param {Number} index Index in the playlist. + */ + skipTo: function(index) { + var self = this; + + // Stop the current track. + if (self.playlist[self.index].howl) { + self.playlist[self.index].howl.stop(); + } + + // Reset progress. + progress.style.width = '0%'; + + // Play the new track. + self.play(index); + }, + + /** + * Set the volume and update the volume slider display. + * @param {Number} val Volume between 0 and 1. + */ + volume: function(val) { + var self = this; + + // Update the global volume (affecting all Howls). + Howler.volume(val); + }, + + /** + * Seek to a new position in the currently playing track. + * @param {Number} per Percentage through the song to skip. + */ + seek: function(per) { + var self = this; + + // Get the Howl we want to manipulate. + var sound = self.playlist[self.index].howl; + + // Convert the percent into a seek position. + if (sound.playing()) { + sound.seek(sound.duration() * per); + } + }, + + /** + * The step called within requestAnimationFrame to update the playback position. + */ + step: function() { + var self = this; + + // Get the Howl we want to manipulate. + var sound = self.playlist[self.index].howl; + + // Determine our current seek position. + var seek = sound.seek() || 0; + timer.innerHTML = self.formatTime(Math.round(seek)); + progress.style.width = (((seek / sound.duration()) * 100) || 0) + '%'; + + // If the sound is still playing, continue stepping. + if (sound.playing()) { + requestAnimationFrame(self.step.bind(self)); + } + }, + + /** + * Format the time from seconds to M:SS. + * @param {Number} secs Seconds to format. + * @return {String} Formatted time. + */ + formatTime: function(secs) { + var minutes = Math.floor(secs / 60) || 0; + var seconds = (secs - minutes * 60) || 0; + + return minutes + ':' + (seconds < 10 ? '0' : '') + seconds; + } +}; + +// Setup our new audio player class and pass it the playlist. +var player = new Player(playlist_tracks); + +// Bind our player controls. +playBtn.addEventListener('click', function() { + player.play(); +}); +pauseBtn.addEventListener('click', function() { + player.pause(); +}); +prevBtn.addEventListener('click', function() { + player.skip('prev'); +}); +nextBtn.addEventListener('click', function() { + player.skip('next'); +}); diff --git a/themes/blue_train/README.md b/themes/blue_train/README.md new file mode 100644 index 0000000..3ce9ce6 --- /dev/null +++ b/themes/blue_train/README.md @@ -0,0 +1,10 @@ +# Blue Train + +This is the default theme for Groove on Demand, inspired by the cover to John Coltrane's 1957 album _Blue Train_, [designed by Reid Miles](https://www.immigrantentrepreneurship.org/images/john-coltrane-blue-train-album-cover-1957/). + +## Credits +``` +author: @evilchili +author_link: https://linernotes.club/@evilchili +version: 1.0 +``` diff --git a/themes/blue_train/static/styles.css b/themes/blue_train/static/styles.css new file mode 100644 index 0000000..5070ab9 --- /dev/null +++ b/themes/blue_train/static/styles.css @@ -0,0 +1,259 @@ +html { + width: 100%; + height: 100%; + overflow: hidden; + padding: 0; + margin: 0; + outline: 0; + font-family: 'Clarendon MT Std', sans-serif; +} + +body { + width: 100%; + height: 100%; + padding: 0; + margin: 0; + overflow: hidden; + background: rgb(0,4,16); + background: linear-gradient(0deg, rgba(0,4,16,1) 31%, rgba(1,125,147,1) 97%); +} + +a, a:visited { + text-decoration: none; + color: #f1f2f6; +} +a:active, a:hover { + color: #70bc45; +} + +#container { + overflow-x: wrap; + width: 96%; + height: 96%; + margin: auto; + margin-top: 1em; +} + +#details { + margin-left: 0.25em; + display: flex; + border-bottom: 1px solid rgb(255,255,255,0.3); +} + +#poster { + display: none; + width: 10em; + height: 10em; + background: #DDD; +} +#poster > img { + display: none; +} + + +/* informmation */ +#playlist_title { + margin-bottom: 0.25em; + color: #f1f2f6; +} +#playlist_desc { +} + + + +#player { + width: 100%; + margin: 1em 0; +} + +#controls { + width: 5em; + height: 5em; + vertical-align: middle; + padding-right: 1em; +} +#controls > div { + width: 5em; + height: 5em; +} + + +#track { + color: #70bc45; + font-size: 1.5em; +} + +#track_controls { + display: flex; +} + +#timer { + margin-left:0; + padding-left:0; +} +#duration { +} + +/* Controls */ +.widget { + margin-right: 0.5em; + padding: 0 0.5em; +} +.btn { + cursor: pointer; + opacity: 0.75; +} +.btn:hover { + opacity: 1; +} +#big_button { + position: relative; + background-color: #fff; + border-radius: 50%; + text-align: center; + vertical-align: middle; + line-height: 5em; +} +#playBtn { + font-family: sans-serif; + font-size: 5em; + padding-left: 0.1em; +} +#pauseBtn { + font-family: sans-serif; + display: none; + font-size: 4em; +} +#prevBtn { + color: #fff; + text-align: right; + vertical-align: middle; + line-height: 0.75em; + font-size: 1.5em; + padding:0; +} +#nextBtn { + color: #fff; + text-align: left; + vertical-align: middle; + line-height: 0.75em; + font-size: 1.5em; + padding:0; +} + + +/* Progress */ +#bar { + position: relative; + flex-grow: 1; + text-align: center; + vertical-align: middle; + line-height: 1em; +} +#bar > hr { + border: none; + border-bottom: 1px solid #000; + position: absolute; + height: 1px; + width: calc(100% - 1em); +} + +#progress { + position: relative; + width: 0%; + height: 0.33em; + margin: 0.33em 0; + background-color: #bbb; +} + +/* Loading */ +#loading { + display: none; +} + +/* Plylist */ +#playlist { + display: block; + margin-left: 0.25em; +} +#list { + font-size: 1.25em; +} +.list-song { + padding: 0.25em; +} +.list-song:hover { + cursor: pointer; + background: #f1f2f6; +} + +#footer { + font-family: helvetica, sans-serif; + border-top: 1px solid rgb(128,128,128,0.3); + text-align: right; + color: #888; + font-size: 0.9em; + letter-spacing: 0.1em; + margin: 0; + margin-top: 1em; + padding-top: 1em; +} +#footer a { + color: #aaa; +} + +/* Volume */ +#volume { + display: none; +} +.bar { +} +#barEmpty { + width: 90%; + opacity: 0.5; + cursor: pointer; +} +#barFull { + width: 90%; +} +#sliderBtn { + width: 50px; + height: 50px; + cursor: pointer; +} + +/* Fade-In */ +.fadeout { + webkit-animation: fadeout 0.5s; + -ms-animation: fadeout 0.5s; + animation: fadeout 0.5s; +} +.fadein { + webkit-animation: fadein 0.5s; + -ms-animation: fadein 0.5s; + animation: fadein 0.5s; +} +@keyframes fadein { + from { opacity: 0; } + to { opacity: 1; } +} +@-webkit-keyframes fadein { + from { opacity: 0; } + to { opacity: 1; } +} +@-ms-keyframes fadein { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes fadeout { + from { opacity: 1; } + to { opacity: 0; } +} +@-webkit-keyframes fadeout { + from { opacity: 1; } + to { opacity: 0; } +} +@-ms-keyframes fadeout { + from { opacity: 1; } + to { opacity: 0; } +} diff --git a/themes/blue_train/templates/playlist.tpl b/themes/blue_train/templates/playlist.tpl new file mode 100644 index 0000000..c38c7d8 --- /dev/null +++ b/themes/blue_train/templates/playlist.tpl @@ -0,0 +1,66 @@ + + + + + + Groove On Demand + + + + + + + +
+ +
+
+ +
+
+

{{playlist['name']}}

+ {{playlist['description']}} +
+
+ + + + + + +
+
+
+
+
+
+
+
+
+
0:00
+
+
+
+
+
0:00
+
+
+
+
+
+
+
+ +
+ + +