Skip to content

Commit

Permalink
feat: show item count and top artists per playlist
Browse files Browse the repository at this point in the history
  • Loading branch information
mgoltzsche committed Mar 17, 2024
1 parent 0b3ab19 commit c037ea5
Show file tree
Hide file tree
Showing 7 changed files with 205 additions and 41 deletions.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 8 additions & 0 deletions beetsplug/webm3u/__init__.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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')
Expand Down
105 changes: 101 additions & 4 deletions beetsplug/webm3u/playlist.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,113 @@
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()
while line := file.readline():
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
Expand Down
92 changes: 58 additions & 34 deletions beetsplug/webm3u/routes.py
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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:
Expand All @@ -30,37 +31,38 @@ def playlist_index():
@bp.route('/playlists/', defaults={'path': ''})
@bp.route('/playlists/<path:path>')
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/<path:path>')
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)
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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)
5 changes: 4 additions & 1 deletion beetsplug/webm3u/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ <h1>{{title}}</h1>
{% endfor %}
{% for file in files %}
<li>
<a href="{{ quote(file.path) }}">&#127925; {{ file.name }} <span>({{ humanize(file.size) }})</span></a>
<a href="{{ quote(file.path) }}">&#127925; {{ file.name }} <span>({{ humanize_size(file.size) }})</span></a>
</li>
{% endfor %}
</ul>
Expand Down
32 changes: 32 additions & 0 deletions beetsplug/webm3u/templates/playlists.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}}</title>
<link rel="stylesheet" type="text/css" href= "{{ url_for('static',filename='style.css') }}">
</head>
<body>
<h1>{{title}}</h1>

<p>
<a href="javascript:history.back()">&#129176; back</a>
</p>

<ul>
<li>
<a href="../">..</a>
</li>
{% for dir in directories %}
<li>
<a href="{{ quote(dir) }}/">&#128193; {{ dir }}</a>
</li>
{% endfor %}
{% for file in files %}
<li>
<a href="{{ quote(file.path) }}">&#127925; {{ file.name }} <span>&nbsp;&#128337;{{ humanize_duration(file.duration) }} &#119672;{{ file.count }} &bull; {{ file.info }}</span></a>
</li>
{% endfor %}
</ul>
</body>
</html>

0 comments on commit c037ea5

Please sign in to comment.