Compare commits

..

14 Commits

10 changed files with 465 additions and 243 deletions

2
.gitignore vendored
View File

@ -2,3 +2,5 @@ __pycache__/
*.swp
*.swo
*.json
*.db
config.py

15
LICENSE Normal file
View File

@ -0,0 +1,15 @@
ISC License
Copyright (c) 2019, iou1name <iou1name@steelbea.me>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

View File

@ -4,7 +4,7 @@ Stream some music.
## Requirements
Python 3.6+
FFmpeg compiled with `--enable-libopus`
Python packages: `flask gunicorn mutagen Flask-RESTful`
Python packages: `flask gunicorn mutagen`
## Install
1. Get on the floor

107
buckler_flask.py Normal file
View File

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
Session interface middlewares to integrate the flask app with Buckler.
"""
import json
import urllib.parse
import urllib.request
from datetime import datetime
from flask.sessions import SessionInterface, SessionMixin
from flask import session, redirect, request
import config
class BucklerSessionInterface(SessionInterface):
"""
Queries the Buckler server for session data to the current user and
application.
"""
def __init__(self):
self.url = config.buckler['url']
self.app_id = config.buckler['app_id']
self.app_key = config.buckler['app_key']
def open_session(self, app, request):
"""Called when a request is initiated."""
user_id = request.cookies.get('userid')
user_sid = request.cookies.get('session')
params = {
'app_id': self.app_id,
'app_key': self.app_key,
'userid': user_id,
'session': user_sid
}
params = urllib.parse.urlencode(params)
req = urllib.request.Request(self.url + f"/get_session?{params}")
res = urllib.request.urlopen(req)
data = json.loads(res.read())
if data.get('error'):
return None
session = BucklerSession()
session.update(data['session_data'])
session.meta = data['meta']
session.cookies = request.cookies
return session
def save_session(self, app, session, response):
"""Called at the end of a request."""
if not session.modified:
return
user_id = session.meta.get('user_id')
user_sid = session.meta.get('user_sid')
params = {
'app_id': self.app_id,
'app_key': self.app_key,
'userid': user_id,
'session': user_sid
}
params = urllib.parse.urlencode(params)
data = json.dumps(session)
req = urllib.request.Request(
self.url + f"/set_session?{params}",
data=data.encode('utf8'),
method='POST')
res = urllib.request.urlopen(req)
last_used = datetime.fromisoformat(session.meta['last_used'])
now = datetime.now(last_used.tzinfo)
delta = now - last_used
if delta.seconds > 600:
response.set_cookie(
'userid',
session.cookies['userid'],
max_age=30*24*60*60,
secure=True,
httponly=True)
response.set_cookie(
'session',
session.cookies['session'],
max_age=30*24*60*60,
secure=True,
httponly=True)
class BucklerSession(dict, SessionMixin):
"""A server side session class based on the Buckler security shield."""
def __init__(self):
super().__init__()
self.modified = False
def __setitem__(self, key, value):
super().__setitem__(key, value)
self.modified = True
def require_auth():
"""
Requires the user to be properly authenticated with Buckler before
accessing any views on the application.
"""
if not hasattr(session, 'meta'):
resp = redirect(config.buckler['login_url'])
resp.set_cookie('redirect', request.url)
return resp

11
config.py.template Executable file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env python3
"""
Configuration settings for Musik.
`buckler` specifies settings pertaining to the Buckler server.
"""
buckler = {
'url': "http://127.0.0.1:5400/buckler",
'app_id': 1,
'app_key': """password""",
'login_url': "/buckler/login",
}

194
database.py Normal file
View File

@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""
API for interacting with the Musik database.
"""
import os
import sqlite3
import multiprocessing
import mutagen
import mutagen.mp3
MUSIC_DIR = "/home/iou1name/music/Music"
MUSIC_EXT = ['flac', 'mp3', 'wav', 'm4a']
DB_LOCK = multiprocessing.Lock()
def db_execute(*args, **kwargs):
"""
Opens a connection to the app's database and executes the SQL statements
passed to this function.
"""
with sqlite3.connect('musik.db') as con:
DB_LOCK.acquire()
con.row_factory = sqlite3.Row
cur = con.cursor()
res = cur.execute(*args, **kwargs)
DB_LOCK.release()
return res
def db_execute_many(*args, **kwargs):
"""
Same as `db_executei()` except with `cur.executemany()`.
"""
with sqlite3.connect('musik.db') as con:
DB_LOCK.acquire()
con.row_factory = sqlite3.Row
cur = con.cursor()
res = cur.executemany(*args, **kwargs)
DB_LOCK.release()
return res
def init_database():
"""
Initializes the database.
"""
try:
db_execute("SELECT * FROM tracks LIMIT 1").fetchone()
except sqlite3.OperationalError:
DB_LOCK.release()
db_execute("CREATE TABLE tracks("
"filepath TEXT PRIMARY KEY,"
"artist TEXT,"
"albumartist TEXT,"
"date TEXT,"
"album TEXT,"
"discnumber TEXT,"
"tracknumber TEXT,"
"title TEXT,"
"genre TEXT,"
"length TEXT,"
"last_modified INTEGER"
")"
)
build_library(MUSIC_DIR)
def build_library(root_dir):
"""Walks the music directory and builds a library of tracks."""
print("Building library")
filepaths = []
for dir_name, sub_dirs, files in os.walk(root_dir):
for file in files:
if not os.path.splitext(file)[1][1:] in MUSIC_EXT:
continue
filepath = os.path.join(root_dir, dir_name, file)
last_modified = os.path.getmtime(filepath)
filepaths.append((filepath, last_modified))
tracks_prev = db_execute("SELECT * FROM tracks")
tracks_prev = {track['filepath']: track for track in tracks_prev}
global worker
def worker(tup):
"""Worker for multi-processing tracks."""
filepath, last_modified = tup
track_prev = tracks_prev.get(filepath)
if track_prev:
if track_prev['last_modified'] >= last_modified:
return tuple(track_prev)
data = read_track(filepath)
return data
with multiprocessing.Pool() as pool:
mapping = pool.imap(worker, filepaths)
tracks = []
prev_percent = 0
while True:
try:
tracks.append(mapping.next())
except StopIteration:
break
percent = round(len(tracks) / len(filepaths) * 100, 2)
if percent >= prev_percent + 2.5:
print(f"{percent}%")
prev_percent = percent
db_execute("DELETE FROM tracks")
db_execute_many(
"INSERT INTO tracks VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
tracks
)
print("Done")
def read_track(filepath):
"""
Reads the specified file and extracts relevant information from it.
Returns a tuple.
"""
if filepath.endswith("mp3"):
m = mutagen.mp3.EasyMP3(filepath)
else:
m = mutagen.File(filepath)
artist = m.get('artist', [''])[0]
if m.get('albumartist'):
albumartist = m.get('albumartist', [''])[0]
else:
albumartist = m.get('artist', [''])[0]
date = m.get('date', [''])[0]
album = m.get('album', [''])[0]
discnumber = m.get('discnumber', [''])[0]
tracknumber = m.get('tracknumber', [''])[0]
title = m.get('title', [''])[0]
genre = m.get('genre', [''])[0]
length = str(int(m.info.length) // 60) + ":"
length += str(int(m.info.length) % 60)
last_modified = os.path.getmtime(filepath)
d = locals()
d.pop('m')
return tuple(d.values())
def get_artist_albums(artist):
"""
Returns a list of all albums produced by the specified `artist`.
"""
albums = db_execute(
"SELECT DISTINCT album FROM tracks WHERE albumartist = ? ORDER BY date ASC",
(artist,)
).fetchall()
albums = [row[0] for row in albums]
return albums
def get_album_tracks(artist, album):
"""
Returns a list of all tracks on the `album` produced by `artist`.
"""
tracks = db_execute(
"SELECT discnumber, tracknumber, title FROM tracks "
"WHERE albumartist = ? AND album = ?"
"ORDER BY discnumber ASC, tracknumber ASC",
(artist, album)
).fetchall()
tracks = [tuple(row) for row in tracks]
return tracks
def get_track(artist, album, discnumber, tracknumber):
"""
Returns a dictionary containing all information about a specific track.
"""
track = db_execute(
"SELECT * FROM tracks "
"WHERE albumartist = ? AND album = ? "
"AND discnumber = ? AND tracknumber = ?",
(artist, album, discnumber, tracknumber)
).fetchone()
return dict(track)
def get_random_track():
"""
Returns a random track.
"""
track = db_execute(
"SELECT * FROM tracks ORDER BY random() LIMIT 1"
).fetchone()
return dict(track)
def get_all_artists():
"""
Returns a list of all artist names.
"""
artists = db_execute(
"SELECT DISTINCT albumartist FROM tracks ORDER BY albumartist ASC"
)
artists = [row[0] for row in artists]
return artists

254
musik.py
View File

@ -8,194 +8,84 @@ import random
import subprocess
from urllib import parse
from flask import Flask, Response, render_template, send_file, url_for
from flask_restful import reqparse, abort, Api, Resource
import mutagen
from flask import Flask, Response, render_template, send_file, url_for, request, abort
import database
import buckler_flask
MUSIC_DIR = "/home/iou1name/music/Music"
MUSIC_EXT = ['flac', 'mp3', 'wav', 'm4a']
FFMPEG_CMD = [
'ffmpeg', '-y',
'-loglevel', 'panic',
'-i', '',
'-codec:a', 'libopus',
'-b:a', '64k',
'-f', 'opus',
'-'
]
class Track:
def __init__(self, filepath=None, d=None, coverart=""):
if d:
for attr, value in d.items():
setattr(self, attr, value)
return
if filepath.endswith("mp3"):
m = mutagen.mp3.EasyMP3(filepath)
else:
m = mutagen.File(filepath)
self.tracknumber = m.get('tracknumber', [''])[0]
self.title = m.get('title', [''])[0]
self.artist = m.get('artist', [''])[0]
self.album = m.get('album', [''])[0]
self.date = m.get('date', [''])[0]
self.length = str(int(m.info.length) // 60) + ":"
self.length += str(int(m.info.length) % 60)
self.filepath = filepath
self.coverart = coverart
def build_library(root_dir):
"""Walks the music directory and builds a library of tracks."""
print("Building library")
tracks = []
for dir_name, sub_dirs, files in os.walk(root_dir):
for file in files:
if not os.path.splitext(file)[1][1:] in MUSIC_EXT:
continue
filepath = os.path.join(root_dir, dir_name, file)
if "folder.jpg" in files:
coverart = os.path.join(root_dir, dir_name, "folder.jpg")
else:
coverart = ""
track = Track(filepath, coverart=coverart)
tracks.append(track)
print("Done")
return tracks
def init_library():
"""Loads the library from file, or builds a new one if one isn't found."""
if os.path.isfile("library.json"):
with open("library.json", "r") as file:
tracks = json.loads(file.read())
tracks = [Track(d=d) for d in tracks]
else:
tracks = build_library(MUSIC_DIR)
with open("library.json", "w") as file:
file.write(json.dumps([vars(t) for t in tracks]))
return tracks
app = Flask(__name__)
api = Api(app)
tracks = init_library()
app.session_interface = buckler_flask.BucklerSessionInterface()
app.before_request(buckler_flask.require_auth)
database.init_database()
@app.route('/')
def index():
"""Main index page."""
artists = list(set(t.artist for t in tracks))
artists.sort()
artists = database.get_all_artists()
return render_template('index.html', **locals())
parser = reqparse.RequestParser()
parser.add_argument('artist')
parser.add_argument('album')
parser.add_argument('track')
@app.route('/select')
def select():
"""Retrieve information about an artist, album or track."""
artist = request.args.get('artist')
album = request.args.get('album')
discnumber = request.args.get('discnumber')
tracknumber = request.args.get('tracknumber')
def validate_select_args(args):
"""
If a track is specified, both artist and album must also be specified.
If an album is specified, the artist must also be specified.
"""
if args.get('track'):
if not args.get('artist') or not args.get('album'):
abort(400, message="Artist and album must also be specified.")
elif args.get('album'):
if not args.get('artist'):
abort(400, message="Artist must also be specified.")
elif not args.get('artist'):
abort(400, message="You must specify at least an artist.")
class Selection(Resource):
def get(self):
global tracks
args = parser.parse_args()
validate_select_args(args)
if args.get('track'):
for track in tracks:
if (track.artist == args.get('artist') and
track.album == args.get('album') and
track.title == args.get('track')):
break
else:
abort(404, message="Track does not exist.")
found = dict(vars(track))
found.pop('filepath')
found['streampath'] = url_for(
'stream',
artist=parse.quote(track.artist, safe=''),
album=parse.quote(track.album, safe=''),
track=parse.quote(track.title, safe=''))
found['coverart'] = url_for(
'coverart',
artist=parse.quote(track.artist, safe=''),
album=parse.quote(track.album, safe=''),
track=parse.quote(track.title, safe=''))
return found
elif args.get('album'):
found = []
for track in tracks:
if (track.artist == args.get('artist') and
track.album == args.get('album')):
found.append(track)
if not found:
abort(404, message="Album does not exist.")
found = [t.tracknumber + " - " + t.title for t in found]
found.sort()
return found
elif args.get('artist'):
found = []
for track in tracks:
if track.artist == args.get('artist'):
found.append(track)
if not found:
abort(404, message="Artist does not exist.")
found = list(set(t.album for t in found))
return sorted(found)
class RandomSelection(Resource):
def get(self):
global tracks
track = random.choice(tracks)
found = dict(vars(track))
found.pop('filepath')
found['streampath'] = url_for(
'stream',
artist=track.artist,
album=track.album,
track=track.title)
found['coverart'] = url_for(
'coverart',
artist=track.artist,
album=track.album,
track=track.title)
return found
api.add_resource(Selection, '/select')
api.add_resource(RandomSelection, '/select/random')
api.init_app(app)
@app.route('/stream/<artist>/<album>/<track>')
def stream(artist, album, track):
"""View for the raw audio file."""
artist, album, track = map(parse.unquote, (artist, album, track))
for t in tracks:
if (t.artist == artist and
t.album == album and
t.title == track):
break
if tracknumber:
result = database.get_track(artist, album, discnumber, tracknumber)
if not result:
return ("Valid artist, album, discnumber and tracknumber "
"parameters must be supplied.")
else:
result.pop('filepath')
elif album:
result = database.get_album_tracks(artist, album)
if not result:
return "Valid artist and album parameters must be supplied."
elif artist:
result = database.get_artist_albums(artist)
if not result:
return "Valid artist parameter must be supplied."
else:
abort(404, message="Track does not exist.")
return "Invalid query."
return json.dumps(result)
FFMPEG_CMD[5] = t.filepath
@app.route('/select/random')
def select_random():
"""
Selects a random track.
"""
track = database.get_random_track()
track.pop('filepath')
return json.dumps(track)
@app.route('/stream')
def stream():
"""View for the raw audio file."""
artist = parse.unquote(request.args.get('artist'))
album = parse.unquote(request.args.get('album'))
discnumber = parse.unquote(request.args.get('discnumber'))
tracknumber = parse.unquote(request.args.get('tracknumber'))
track = database.get_track(artist, album, discnumber, tracknumber)
if not track:
return "Track does not exist."
FFMPEG_CMD[5] = track['filepath']
def generate():
with subprocess.Popen(FFMPEG_CMD, stdout=subprocess.PIPE) as proc:
@ -206,23 +96,27 @@ def stream(artist, album, track):
return Response(generate(), mimetype="audio/ogg")
@app.route('/coverart/<artist>/<album>/<track>')
def coverart(artist, album, track):
@app.route('/coverart')
def coverart():
"""View for the raw audio file."""
artist, album, track = map(parse.unquote, (artist, album, track))
for t in tracks:
if (t.artist == artist and
t.album == album and
t.title == track):
break
artist = parse.unquote(request.args.get('artist'))
album = parse.unquote(request.args.get('album'))
discnumber = parse.unquote(request.args.get('discnumber'))
tracknumber = parse.unquote(request.args.get('tracknumber'))
track = database.get_track(artist, album, discnumber, tracknumber)
if not track:
return "Track does not exist."
cover_path = os.path.join(os.path.dirname(track['filepath']), 'folder.jpg')
if os.path.isfile(cover_path):
return send_file(cover_path)
else:
abort(404, message="Track does not exist.")
if t.coverart:
return send_file(t.coverart)
else:
return "False"
return "No cover art for this track found."
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
database.build_library(database.MUSIC_DIR)
app.run(host='0.0.0.0', port=5150)

View File

@ -4,14 +4,13 @@ body {
}
img {
max-width:100%;
max-height:100%;
max-height: 30vh;
}
#globalContainer {
display: flex;
flex-direction: column;
height: 100vh;
display: grid;
grid-template-rows: auto 1fr auto;
}
#titleContainer {
@ -19,11 +18,7 @@ img {
}
#navigationContainer {
flex: auto;
overflow: auto;
display: flex;
flex-direction: row;
height: 100%;
border-top: 2px solid #ccc;
border-bottom: 2px solid #ccc;
}
@ -34,22 +29,15 @@ img {
}
#playerContainer {
height: 30vh;
flex: 0 0 auto;
display: flex;
flex-direction: row;
justify-content: center;
}
#albumCover {
padding-right: 1em;
padding-left: 1em;
padding-bottom: 3px;
box-sizing: border-box;
}
#playerControls {
text-align: center;
padding-right: 0.5em;
padding-bottom: 0.5em;
}

View File

@ -3,73 +3,83 @@ function load() {
document.getElementById('player').addEventListener('ended', function() {
// shuffle
if (document.getElementById('shuffle').checked) {
var httpRequest;
httpRequest = new XMLHttpRequest();
httpRequest.onreadystatechange = function () {
if (httpRequest.readyState !== XMLHttpRequest.DONE) { return; }
if (httpRequest.status !== 200) { return; }
let track = JSON.parse(httpRequest.responseText);
change_track(track);
};
httpRequest.open('GET', api_uri + '/random', true);
httpRequest.send();
fetch(api_uri + '/random').then(function(response) {
return response.json();
}).then(function(json) {
change_track(json);
});
}
});
}
function select_artist(select) {
var httpRequest;
httpRequest = new XMLHttpRequest();
httpRequest.onreadystatechange = function () {
if (httpRequest.readyState !== XMLHttpRequest.DONE) { return; }
if (httpRequest.status !== 200) { return; }
nav_items = JSON.parse(httpRequest.responseText);
let params = {
artist: select.value,
};
let query = Object.keys(params)
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
.join('&');
fetch(api_uri + '?' + query).then(function(response) {
return response.json();
}).then(function(json) {
let html_str = '';
for (let i = 0; i < nav_items.length; i++) {
html_str += '<option value="' + nav_items[i] + '">' + nav_items[i] + '</option>';
for (let i = 0; i < json.length; i++) {
html_str += '<option value="' + json[i] + '">' + json[i] + '</option>';
}
document.getElementById('albumList').innerHTML = html_str;
document.getElementById('trackList').innerHTML = '';
};
httpRequest.open('GET', api_uri + '?artist=' + select.value, true);
httpRequest.send();
});
}
function select_album(select) {
var httpRequest;
httpRequest = new XMLHttpRequest();
httpRequest.onreadystatechange = function () {
if (httpRequest.readyState !== XMLHttpRequest.DONE) { return; }
if (httpRequest.status !== 200) { return; }
nav_items = JSON.parse(httpRequest.responseText);
let params = {
artist: document.getElementById('artistList').value,
album: select.value,
};
let query = Object.keys(params)
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
.join('&');
fetch(api_uri + '?' + query).then(function(response) {
return response.json();
}).then(function(json) {
let html_str = '';
for (let i = 0; i < nav_items.length; i++) {
html_str += '<option value="' + nav_items[i].replace(/\d* - /, '') + '">' + nav_items[i] + '</option>';
for (let i = 0; i < json.length; i++) {
html_str += '<option data-discnumber="' + json[i][0] + '" data-tracknumber="' + json[i][1]+ '" value="' + json[i][2] + '">' + json[i][0] + '.' + json[i][1] + ' - ' + json[i][2] + '</option>';
}
document.getElementById('trackList').innerHTML = html_str;
};
httpRequest.open('GET', api_uri + '?artist=' + document.getElementById('artistList').value + '&album=' + select.value, true);
httpRequest.send();
});
}
function select_track(select) {
var httpRequest;
httpRequest = new XMLHttpRequest();
httpRequest.onreadystatechange = function () {
if (httpRequest.readyState !== XMLHttpRequest.DONE) { return; }
if (httpRequest.status !== 200) { return; }
let track = JSON.parse(httpRequest.responseText);
change_track(track);
let params = {
artist: document.getElementById('artistList').value,
album: document.getElementById('albumList').value,
discnumber: select.selectedOptions[0].dataset.discnumber,
tracknumber: select.selectedOptions[0].dataset.tracknumber,
};
httpRequest.open('GET', api_uri + '?artist=' + document.getElementById('artistList').value + '&album=' + document.getElementById('albumList').value + '&track=' + select.value, true);
httpRequest.send();
let query = Object.keys(params)
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
.join('&');
fetch(api_uri + '?' + query).then(function(response) {
return response.json();
}).then(function(json) {
change_track(json);
});
}
function change_track(track) {
let params = {
artist: track.albumartist,
album: track.album,
discnumber: track.discnumber,
tracknumber: track.tracknumber,
};
let query = Object.keys(params)
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
.join('&');
let source = document.getElementById('stream');
source.src = track.streampath;
source.src = document.location.href + '/stream?' + query;
let player = document.getElementById('player');
player.load();
player.play();
@ -77,5 +87,6 @@ function change_track(track) {
document.getElementById('nowPlayingAlbum').innerHTML = track.album;
document.getElementById('nowPlayingTitle').innerHTML = track.title;
document.getElementById('albumCover').firstChild.src = track.coverart;
document.getElementById('albumCover').firstChild.src = document.location.href + 'coverart?' + query;
document.title = 'Musik | ' + track.artist + ' - ' + track.title;
}

View File

@ -5,7 +5,7 @@
<link rel="stylesheet" type="text/css" href="/static/musik.css">
<script type="text/javascript" src="/static/musik.js"></script>
<script>
const api_uri = "{{ url_for('selection') }}";
const api_uri = "{{ url_for('select') }}";
</script>
<script>window.onload = load;</script>
</head>
@ -30,7 +30,7 @@
</div>
</div>
<div id="playerContainer">
<span id="albumCover"><img src=""/></span>
<span id="albumCover"><img src=""></span>
<span id="playerControls">
<h4><span id="nowPlayingArtist"></span> - <span id="nowPlayingAlbum"></span></h4>
<h3 id="nowPlayingTitle"></h3>