diff --git a/Dockerfile b/Dockerfile index 1335fca..ffbd3c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/mgoltzsche/beets-plugins:0.13.1 +FROM ghcr.io/mgoltzsche/beets-plugins:0.14.0 # Install bats USER root:root diff --git a/beetsplug/webm3u/__init__.py b/beetsplug/webm3u/__init__.py index 827b40a..aacb126 100644 --- a/beetsplug/webm3u/__init__.py +++ b/beetsplug/webm3u/__init__.py @@ -1,9 +1,11 @@ from flask import Flask, render_template +from beets import config 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 +from beetsplug.webm3u.playlist import PlaylistProvider class WebM3UPlugin(BeetsPlugin): @@ -64,6 +66,12 @@ def _configure_app(self, app, lib): def create_app(): app = Flask(__name__) + playlist_dir = config['webm3u']['playlist_dir'].get() + if not playlist_dir: + playlist_dir = config['smartplaylist']['playlist_dir'].get() + + app.config['playlist_provider'] = PlaylistProvider(playlist_dir) + @app.route('/') def home(): return render_template('index.html') diff --git a/beetsplug/webm3u/playlist.py b/beetsplug/webm3u/playlist.py index 168b1bd..27e89b6 100644 --- a/beetsplug/webm3u/playlist.py +++ b/beetsplug/webm3u/playlist.py @@ -1,8 +1,105 @@ +import glob +import os +import pathlib import re +import sys +from flask import current_app as app +from werkzeug.utils import safe_join -def parse_playlist(filepath): - # CAUTION: attribute values that contain ',' or ' ' are not supported - extinf_regex = re.compile(r'^#EXTINF:([0-9]+)( [^,]+)?,[\s]*(.*)') +extinf_regex = re.compile(r'^#EXTINF:([0-9]+)( [^,]+)?,[\s]*(.*)') +highint32 = 1<<31 + +class PlaylistProvider: + def __init__(self, dir): + self.dir = dir + self._playlists = {} + + def _refresh(self): + self._playlists = {p.id: p for p in self._load_playlists()} + app.logger.debug(f"Loaded {len(self._playlists)} playlists") + + def _load_playlists(self): + paths = glob.glob(os.path.join(self.dir, "**.m3u8")) + paths += glob.glob(os.path.join(self.dir, "**.m3u")) + paths.sort() + for path in paths: + try: + yield self._playlist(path) + except Exception as e: + app.logger.error(f"Failed to load playlist {filepath}: {e}") + + def playlists(self): + self._refresh() + playlists = self._playlists + ids = [k for k, v in playlists if v] + ids.sort() + return [playlists[id] for id in ids] + + def playlist(self, id): + filepath = safe_join(self.dir, id) + playlist = self._playlist(filepath) + if playlist.id not in self._playlists: # add to cache + playlists = self._playlists.copy() + playlists[playlist.id] = playlist + self._playlists = playlists + return playlist + + def _playlist(self, filepath): + id = self._path2id(filepath) + name = pathlib.Path(os.path.basename(filepath)).stem + playlist = self._playlists.get(id) + mtime = pathlib.Path(filepath).stat().st_mtime + if playlist and playlist.modified == mtime: + return playlist # cached metadata + app.logger.debug(f"Loading playlist {filepath}") + return Playlist(id, name, mtime, filepath) + + def _path2id(self, filepath): + return os.path.relpath(filepath, self.dir) + +class Playlist: + def __init__(self, id, name, modified, path): + self.id = id + self.name = name + self.modified = modified + self.path = path + self.count = 0 + self.duration = 0 + artists = {} + max_artists = 10 + for item in self.items(): + self.count += 1 + self.duration += item.duration + artist = Artist(item.title.split(' - ')[0]) + found = artists.get(artist.key) + if found: + found.count += 1 + else: + if len(artists) > max_artists: + l = _sortedartists(artists)[:max_artists] + artists = {a.key: a for a in l} + artists[artist.key] = artist + self.artists = ', '.join([a.name for a in _sortedartists(artists)]) + + def items(self): + return parse_m3u_playlist(self.path) + +def _sortedartists(artists): + l = [a for _,a in artists.items()] + l.sort(key=lambda a: (highint32-a.count, a.name)) + return l + +class Artist: + def __init__(self, name): + self.key = name.lower() + self.name = name + self.count = 1 + +def parse_m3u_playlist(filepath): + ''' + Parses an M3U playlist and yields its items, one at a time. + CAUTION: Attribute values that contain ',' or ' ' are not supported! + ''' with open(filepath, 'r', encoding='UTF-8') as file: linenum = 0 item = PlaylistItem() @@ -10,7 +107,7 @@ def parse_playlist(filepath): line = line.rstrip() linenum += 1 if linenum == 1: - assert line == '#EXTM3U', 'File is not an EXTM3U playlist!' + assert line == '#EXTM3U', f"File {filepath} is not an EXTM3U playlist!" continue if len(line.strip()) == 0: continue diff --git a/beetsplug/webm3u/routes.py b/beetsplug/webm3u/routes.py index 681b5d2..1b02fd0 100644 --- a/beetsplug/webm3u/routes.py +++ b/beetsplug/webm3u/routes.py @@ -1,11 +1,11 @@ import os import re import glob -from flask import Flask, Blueprint, current_app, send_from_directory, send_file, abort, render_template, request, url_for, jsonify, Response, stream_with_context from beets import config +from flask import Flask, Blueprint, current_app, send_from_directory, send_file, abort, render_template, request, url_for, jsonify, Response, stream_with_context from pathlib import Path from urllib.parse import quote, quote_plus -from beetsplug.webm3u.playlist import parse_playlist +from werkzeug.utils import safe_join MIMETYPE_HTML = 'text/html' MIMETYPE_JSON = 'application/json' @@ -18,8 +18,9 @@ @bp.route('/playlists/index.m3u8') def playlist_index(): uri_format = request.args.get('uri-format') - root_dir = _playlist_dir() - playlists = glob.glob(os.path.join(root_dir, "**.m3u8")) + playlist_dir = playlist_provider().dir + playlists = glob.glob(os.path.join(playlist_dir, "**.m3u8")) + playlists += glob.glob(os.path.join(playlist_dir, "**.m3u")) playlists.sort() q = '' if uri_format: @@ -30,37 +31,38 @@ def playlist_index(): @bp.route('/playlists/', defaults={'path': ''}) @bp.route('/playlists/') def playlists(path): - root_dir = _playlist_dir() - return _serve_files('Playlists', root_dir, path, _filter_m3u_files, _send_playlist) + playlist_dir = playlist_provider().dir + return _serve_files('playlists.html', 'Playlists', playlist_dir, path, _filter_m3u_files, _send_playlist, _playlist_info) @bp.route('/audio/', defaults={'path': ''}) @bp.route('/audio/') def audio(path): root_dir = config['directory'].get() - return _serve_files('Audio files', root_dir, path, _filter_none, send_file) + return _serve_files('files.html', 'Audio files', root_dir, path, _filter_none, send_file, _file_info) def _m3u_line(filepath, query): title = Path(os.path.basename(filepath)).stem - uri = _item_url('playlists', filepath, _playlist_dir()) + playlist_dir = playlist_provider().dir + uri = _item_url('playlists', filepath, playlist_dir) return f'#EXTINF:0,{title}\n{uri}{query}\n' -def _playlist_dir(): - root_dir = config['webm3u']['playlist_dir'].get() - if not root_dir: - return config['smartplaylist']['playlist_dir'].get() - return root_dir - def _send_playlist(filepath): - return Response(stream_with_context(_transform_playlist(filepath)), mimetype=MIMETYPE_MPEGURL) + provider = playlist_provider() + relpath = os.path.relpath(filepath, provider.dir) + playlist = provider.playlist(relpath) + return Response(stream_with_context(_transform_playlist(playlist)), mimetype=MIMETYPE_MPEGURL) -def _transform_playlist(filepath): +def playlist_provider(): + return current_app.config['playlist_provider'] + +def _transform_playlist(playlist): music_dir = os.path.normpath(config['directory'].get()) - playlist_dir = os.path.dirname(filepath) + playlist_dir = playlist_provider().dir uri_format = request.args.get('uri-format') skipped = False yield '#EXTM3U\n' - for item in parse_playlist(filepath): + for item in playlist.items(): item_uri = item.uri if item_uri.startswith('./') or item_uri.startswith('../'): item_uri = os.path.join(playlist_dir, item_uri) @@ -92,24 +94,24 @@ def _filter_m3u_files(filename): def _filter_none(filename): return True -def _serve_files(title, root_dir, path, filter, handler): - abs_path = os.path.join(root_dir, path) - _check_path(root_dir, abs_path) +def _serve_files(tpl, title, root_dir, path, filter, handler, infofn): + abs_path = safe_join(root_dir, path) if not os.path.exists(abs_path): return abort(404) if os.path.isfile(abs_path): return handler(abs_path) else: - f = _files(abs_path, filter) + f = _files(abs_path, filter, infofn) dirs = _directories(abs_path) mimetypes = (MIMETYPE_JSON, MIMETYPE_HTML) mimetype = request.accept_mimetypes.best_match(mimetypes, MIMETYPE_JSON) if mimetype == MIMETYPE_HTML: - return render_template('list.html', + return render_template(tpl, title=title, files=f, directories=dirs, - humanize=_humanize_size, + humanize_size=_humanize_size, + humanize_duration=_humanize_duration, quote=quote, ) else: @@ -118,19 +120,31 @@ def _serve_files(title, root_dir, path, filter, handler): 'files': f, }) -def _files(dir, filter): +def _files(dir, filter, infofn): l = [f for f in os.listdir(dir) if _is_file(dir, f) and filter(f)] l.sort() - return [_file_dto(dir, f) for f in l] + return [infofn(dir, f) for f in l] -def _file_dto(dir, filename): - st = os.stat(os.path.join(dir, filename)) +def _file_info(dir, filename): + st = os.stat(safe_join(dir, filename)) return { 'name': Path(filename).stem, 'path': filename, 'size': st.st_size, } +def _playlist_info(dir, filename): + filepath = os.path.join(dir, filename) + relpath = os.path.relpath(filepath, playlist_provider().dir) + playlist = playlist_provider().playlist(relpath) + return { + 'name': playlist.name, + 'path': playlist.id, + 'count': playlist.count, + 'duration': playlist.duration, + 'info': playlist.artists, + } + def _is_file(dir, filename): f = os.path.join(dir, filename) return os.path.isfile(f) @@ -143,15 +157,25 @@ def _directories(dir): def _join(dir, filename): return os.path.join(dir, filename) -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(f"request path {path} is outside the root directory {root_dir}") - def _humanize_size(num): for unit in ("", "K", "M", "G", "T", "P", "E", "Z"): if abs(num) < 1000.0: return f"{num:.0f}{unit}B" num /= 1000.0 return f"{num:.1f}YB" + +minute = 60 +hour = 60 * minute +day = 24 * hour + +def _humanize_duration(seconds): + days = seconds / day + if days > 1: + return '{:.0f}d'.format(days) + hours = seconds / hour + if hours > 1: + return '{:.0f}h'.format(hours) + minutes = seconds / minute + if minutes > 1: + return '{:.0f}m'.format(minutes) + return '{:.0f}s'.format(seconds) diff --git a/beetsplug/webm3u/static/style.css b/beetsplug/webm3u/static/style.css index aff351c..1810acd 100644 --- a/beetsplug/webm3u/static/style.css +++ b/beetsplug/webm3u/static/style.css @@ -11,9 +11,12 @@ ul, li { li a { display: block; padding: 0.5em 1em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } li a span { - font-size: 0.7em + font-size: 0.7em; } li:nth-child(odd) { background-color: #fafafa; diff --git a/beetsplug/webm3u/templates/list.html b/beetsplug/webm3u/templates/files.html similarity index 92% rename from beetsplug/webm3u/templates/list.html rename to beetsplug/webm3u/templates/files.html index 2b066b6..1f3968c 100644 --- a/beetsplug/webm3u/templates/list.html +++ b/beetsplug/webm3u/templates/files.html @@ -24,7 +24,7 @@

{{title}}

{% endfor %} {% for file in files %}
  • - 🎵 {{ file.name }} ({{ humanize(file.size) }}) + 🎵 {{ file.name }} ({{ humanize_size(file.size) }})
  • {% endfor %} diff --git a/beetsplug/webm3u/templates/playlists.html b/beetsplug/webm3u/templates/playlists.html new file mode 100644 index 0000000..f785499 --- /dev/null +++ b/beetsplug/webm3u/templates/playlists.html @@ -0,0 +1,32 @@ + + + + + + {{title}} + + + +

    {{title}}

    + +

    + 🢘 back +

    + + + +