Skip to content

Commit d3b7e2e

Browse files
authored
Merge pull request #13 from mgoltzsche/playlist-support
Implement getPlaylists and getPlaylist endpoints
2 parents 6978b70 + 39a5b9b commit d3b7e2e

File tree

5 files changed

+173
-14
lines changed

5 files changed

+173
-14
lines changed

beetsplug/beetstream/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
"""Beetstream is a Beets.io plugin that exposes SubSonic API endpoints."""
1717
from beets.plugins import BeetsPlugin
18+
from beets import config
1819
from beets import ui
1920
import flask
2021
from flask import g
@@ -39,6 +40,7 @@ def home():
3940
import beetsplug.beetstream.albums
4041
import beetsplug.beetstream.artists
4142
import beetsplug.beetstream.dummy
43+
import beetsplug.beetstream.playlists
4244
import beetsplug.beetstream.search
4345
import beetsplug.beetstream.songs
4446
import beetsplug.beetstream.users
@@ -55,6 +57,7 @@ def __init__(self):
5557
'reverse_proxy': False,
5658
'include_paths': False,
5759
'never_transcode': False,
60+
'playlist_dir': '',
5861
})
5962

6063
def commands(self):
@@ -75,6 +78,10 @@ def func(lib, opts, args):
7578

7679
app.config['INCLUDE_PATHS'] = self.config['include_paths']
7780
app.config['never_transcode'] = self.config['never_transcode']
81+
playlist_dir = self.config['playlist_dir']
82+
if not playlist_dir:
83+
playlist_dir = config['smartplaylist']['playlist_dir'].get()
84+
app.config['playlist_dir'] = playlist_dir
7885

7986
# Enable CORS if required.
8087
if self.config['cors']:

beetsplug/beetstream/dummy.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,20 +41,6 @@ def getLicense():
4141
l.set("trialExpires", "3000-01-01T00:00:00.000Z")
4242
return Response(xml_to_string(root), mimetype='text/xml')
4343

44-
# TODO link with https://beets.readthedocs.io/en/stable/plugins/playlist.html
45-
@app.route('/rest/getPlaylists', methods=["GET", "POST"])
46-
@app.route('/rest/getPlaylists.view', methods=["GET", "POST"])
47-
def playlists():
48-
res_format = request.values.get('f') or 'xml'
49-
if (is_json(res_format)):
50-
return jsonpify(request, wrap_res("playlists", {
51-
"playlist": []
52-
}))
53-
else:
54-
root = get_xml_root()
55-
ET.SubElement(root, 'playlists')
56-
return Response(xml_to_string(root), mimetype='text/xml')
57-
5844
@app.route('/rest/getMusicFolders', methods=["GET", "POST"])
5945
@app.route('/rest/getMusicFolders.view', methods=["GET", "POST"])
6046
def music_folder():
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import glob
2+
import os
3+
import pathlib
4+
import re
5+
from werkzeug.utils import safe_join
6+
7+
extinf_regex = re.compile(r'^#EXTINF:([0-9]+)( [^,]+)?,[\s]*(.*)')
8+
9+
class PlaylistProvider:
10+
def __init__(self, dir):
11+
self._dir = dir
12+
self._playlists = {}
13+
14+
def _refresh(self):
15+
paths = glob.glob(os.path.join(self._dir, "**.m3u8"))
16+
paths += glob.glob(os.path.join(self._dir, "**.m3u"))
17+
paths.sort()
18+
self._playlists = {self._path2id(p): self._playlist(p) for p in paths}
19+
20+
def playlists(self):
21+
self._refresh()
22+
ids = [k for k in self._playlists]
23+
ids.sort()
24+
return [self._playlists[id] for id in ids]
25+
26+
def playlist(self, id):
27+
self._refresh()
28+
filepath = safe_join(self._dir, id)
29+
return self._playlist(filepath)
30+
31+
def _playlist(self, filepath):
32+
id = self._path2id(filepath)
33+
name = pathlib.Path(os.path.basename(filepath)).stem
34+
playlist = self._playlists.get(id)
35+
mtime = pathlib.Path(filepath).stat().st_mtime
36+
if playlist and playlist.modified == mtime:
37+
return playlist # cached
38+
return Playlist(id, name, mtime, filepath)
39+
40+
def _path2id(self, filepath):
41+
return os.path.relpath(filepath, self._dir)
42+
43+
class Playlist():
44+
def __init__(self, id, name, modified, path):
45+
self.id = id
46+
self.name = name
47+
self.modified = modified
48+
self.path = path
49+
self.count = 0
50+
self.duration = 0
51+
for item in parse_m3u_playlist(self.path):
52+
self.count += 1
53+
self.duration += item.duration
54+
55+
def items(self):
56+
return parse_m3u_playlist(self.path)
57+
58+
def parse_m3u_playlist(filepath):
59+
'''
60+
Parses an M3U playlist and yields its items, one at a time.
61+
CAUTION: Attribute values that contain ',' or ' ' are not supported!
62+
'''
63+
with open(filepath, 'r', encoding='UTF-8') as file:
64+
linenum = 0
65+
item = PlaylistItem()
66+
while line := file.readline():
67+
line = line.rstrip()
68+
linenum += 1
69+
if linenum == 1:
70+
assert line == '#EXTM3U', f"File {filepath} is not an EXTM3U playlist!"
71+
continue
72+
if len(line.strip()) == 0:
73+
continue
74+
m = extinf_regex.match(line)
75+
if m:
76+
item = PlaylistItem()
77+
duration = m.group(1)
78+
item.duration = int(duration)
79+
attrs = m.group(2)
80+
if attrs:
81+
item.attrs = {k: v.strip('"') for k,v in [kv.split('=') for kv in attrs.strip().split(' ')]}
82+
else:
83+
item.attrs = {}
84+
item.title = m.group(3)
85+
continue
86+
if line.startswith('#'):
87+
continue
88+
item.uri = line
89+
yield item
90+
item = PlaylistItem()
91+
92+
class PlaylistItem():
93+
def __init__(self):
94+
self.title = None
95+
self.duration = None
96+
self.uri = None
97+
self.attrs = None

beetsplug/beetstream/playlists.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import xml.etree.cElementTree as ET
2+
from beetsplug.beetstream.utils import *
3+
from beetsplug.beetstream import app
4+
from flask import g, request, Response
5+
from .playlistprovider import PlaylistProvider
6+
7+
_playlist_provider = PlaylistProvider('')
8+
9+
# TODO link with https://beets.readthedocs.io/en/stable/plugins/playlist.html
10+
@app.route('/rest/getPlaylists', methods=['GET', 'POST'])
11+
@app.route('/rest/getPlaylists.view', methods=['GET', 'POST'])
12+
def playlists():
13+
res_format = request.values.get('f') or 'xml'
14+
playlists = playlist_provider().playlists()
15+
if (is_json(res_format)):
16+
return jsonpify(request, wrap_res('playlists', {
17+
'playlist': [map_playlist(p) for p in playlists]
18+
}))
19+
else:
20+
root = get_xml_root()
21+
playlists_el = ET.SubElement(root, 'playlists')
22+
for p in playlists:
23+
playlist_el = ET.SubElement(playlists_el, 'playlist')
24+
map_playlist_xml(playlist_el, p)
25+
return Response(xml_to_string(root), mimetype='text/xml')
26+
27+
@app.route('/rest/getPlaylist', methods=['GET', 'POST'])
28+
@app.route('/rest/getPlaylist.view', methods=['GET', 'POST'])
29+
def playlist():
30+
res_format = request.values.get('f') or 'xml'
31+
id = request.values.get('id')
32+
playlist = playlist_provider().playlist(id)
33+
items = playlist.items()
34+
if (is_json(res_format)):
35+
p = map_playlist(playlist)
36+
p['entry'] = [_song(item.attrs['id']) for item in items]
37+
return jsonpify(request, wrap_res('playlist', p))
38+
else:
39+
root = get_xml_root()
40+
playlist_xml = ET.SubElement(root, 'playlist')
41+
map_playlist_xml(playlist_xml, playlist)
42+
for item in items:
43+
song = g.lib.get_item(item.attrs['id'])
44+
entry = ET.SubElement(playlist_xml, 'entry')
45+
map_song_xml(entry, song)
46+
return Response(xml_to_string(root), mimetype='text/xml')
47+
48+
def _song(id):
49+
return map_song(g.lib.get_item(int(id)))
50+
51+
def playlist_provider():
52+
_playlist_provider._dir = app.config['playlist_dir']
53+
return _playlist_provider

beetsplug/beetstream/utils.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,22 @@ def map_artist_xml(xml, artist_name):
208208
xml.set("albumCount", "1")
209209
xml.set("artistImageUrl", "https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg")
210210

211+
def map_playlist(playlist):
212+
return {
213+
'id': playlist.id,
214+
'name': playlist.name,
215+
'songCount': playlist.count,
216+
'duration': playlist.duration,
217+
'created': timestamp_to_iso(playlist.modified),
218+
}
219+
220+
def map_playlist_xml(xml, playlist):
221+
xml.set('id', playlist.id)
222+
xml.set('name', playlist.name)
223+
xml.set('songCount', str(playlist.count))
224+
xml.set('duration', str(ceil(playlist.duration)))
225+
xml.set('created', timestamp_to_iso(playlist.modified))
226+
211227
def artist_name_to_id(name):
212228
base64_name = base64.b64encode(name.encode('utf-8')).decode('utf-8')
213229
return f"{ARTIST_ID_PREFIX}{base64_name}"

0 commit comments

Comments
 (0)