switched database to sqlite. changed api to use query strings. removed library dependency.

This commit is contained in:
iou1name 2019-05-31 10:28:21 -04:00
parent 38808dba67
commit a7ae72c82a
6 changed files with 312 additions and 219 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ __pycache__/
*.swp *.swp
*.swo *.swo
*.json *.json
*.db

View File

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

204
database.py Normal file
View File

@ -0,0 +1,204 @@
#!/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_unsafe(*args, **kwargs):
"""
A non-thread-safe version of `db_execute()`. Only use if you're
certain there will be no competing threads for the database.
"""
with sqlite3.connect('musik.db') as con:
con.row_factory = sqlite3.Row
cur = con.cursor()
res = cur.execute(*args, **kwargs)
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_unsafe("SELECT * FROM tracks LIMIT 1").fetchone()
except sqlite3.OperationalError:
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"
")"
)
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)
filepaths.append(filepath)
global worker
def worker(filepath):
"""Worker for multi-processing tracks."""
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_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)
d = locals()
d.pop('m')
return tuple(d.values())
def insert_track(data):
"""
Inserts a new item into the `tracks` table. `data` should be a tuple
containing an item for every column in the `tracks` table. See the
table declarion in `init_database()` for more information.
"""
db_execute(
"INSERT INTO tracks VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
data
)
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 artist = ? 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 artist = ? 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 artist = ? 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 artist ASC"
)
artists = [row[0] for row in artists]
return artists

278
musik.py
View File

@ -6,228 +6,83 @@ import os
import json import json
import random import random
import subprocess import subprocess
import multiprocessing
from urllib import parse from urllib import parse
from flask import Flask, Response, render_template, send_file, url_for from flask import Flask, Response, render_template, send_file, url_for, request, abort
from flask_restful import reqparse, abort, Api, Resource
import mutagen import database
import mutagen.mp3
MUSIC_DIR = "/home/iou1name/music/Music"
MUSIC_EXT = ['flac', 'mp3', 'wav', 'm4a']
FFMPEG_CMD = [ FFMPEG_CMD = [
'ffmpeg', '-y', 'ffmpeg', '-y',
'-loglevel', 'panic', '-loglevel', 'panic',
'-i', '', '-i', '',
'-codec:a', 'libopus',
'-b:a', '64k', '-b:a', '64k',
'-f', 'opus', '-f', 'opus',
'-' '-'
] ]
class Track:
def __init__(self, filepath=None, d=None):
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.discnumber = m.get('discnumber', [''])[0]
self.title = m.get('title', [''])[0]
if m.get('albumartist'):
self.artist = m.get('albumartist', [''])[0]
else:
self.artist = m.get('artist', [''])[0]
self.album = m.get('album', [''])[0]
self.date = m.get('date', [''])[0]
self.genre = m.get('genre', [''])[0]
self.length = str(int(m.info.length) // 60) + ":"
self.length += str(int(m.info.length) % 60)
self.filepath = filepath
self.coverart = os.path.join(
os.path.dirname(self.filepath), 'folder.jpg')
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)
filepaths.append(filepath)
global worker
def worker(filepath):
"""Worker for multi-processing tracks."""
track = Track(filepath)
return track
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
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
def escape(string):
"""Escape things."""
string = parse.quote(string, safe='')
string = string.replace('&', '%26')
return string
app = Flask(__name__) app = Flask(__name__)
api = Api(app) database.init_database()
tracks = init_library()
@app.route('/') @app.route('/')
def index(): def index():
"""Main index page.""" """Main index page."""
artists = list(set(t.artist for t in tracks)) artists = database.get_all_artists()
artists.sort()
return render_template('index.html', **locals()) return render_template('index.html', **locals())
parser = reqparse.RequestParser() @app.route('/select')
parser.add_argument('artist') def select():
parser.add_argument('album') """Retrieve information about an artist, album or track."""
parser.add_argument('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 tracknumber:
""" result = database.get_track(artist, album, discnumber, tracknumber)
If a track is specified, both artist and album must also be specified. if not result:
If an album is specified, the artist must also be specified. return ("Valid artist, album, discnumber and tracknumber "
""" "parameters must be supplied.")
if args.get('track'): else:
if not args.get('artist') or not args.get('album'): result.pop('filepath')
abort(400, message="Artist and album must also be specified.") elif album:
elif args.get('album'): result = database.get_album_tracks(artist, album)
if not args.get('artist'): if not result:
abort(400, message="Artist must also be specified.") return "Valid artist and album parameters must be supplied."
elif not args.get('artist'): elif artist:
abort(400, message="You must specify at least an artist.") result = database.get_artist_albums(artist)
if not result:
class Selection(Resource): return "Valid artist parameter must be supplied."
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=escape(track.artist),
album=escape(track.album),
track=escape(track.title))
found['coverart'] = url_for(
'coverart',
artist=escape(track.artist),
album=escape(track.album),
track=escape(track.title))
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 = [f"{t.discnumber}.{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.")
seen = {track.album: track.date for track in found}
found = sorted(seen.keys(), key=lambda album: seen[album])
return 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
else: 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(): def generate():
with subprocess.Popen(FFMPEG_CMD, stdout=subprocess.PIPE) as proc: with subprocess.Popen(FFMPEG_CMD, stdout=subprocess.PIPE) as proc:
@ -238,20 +93,21 @@ def stream(artist, album, track):
return Response(generate(), mimetype="audio/ogg") return Response(generate(), mimetype="audio/ogg")
@app.route('/coverart/<artist>/<album>/<track>') @app.route('/coverart')
def coverart(artist, album, track): def coverart():
"""View for the raw audio file.""" """View for the raw audio file."""
artist, album, track = map(parse.unquote, (artist, album, track)) artist = parse.unquote(request.args.get('artist'))
for t in tracks: album = parse.unquote(request.args.get('album'))
if (t.artist == artist and discnumber = parse.unquote(request.args.get('discnumber'))
t.album == album and tracknumber = parse.unquote(request.args.get('tracknumber'))
t.title == track):
break track = database.get_track(artist, album, discnumber, tracknumber)
else: if not track:
abort(404, message="Track does not exist.") return "Track does not exist."
if os.path.isfile(t.coverart): cover_path = os.path.join(os.path.dirname(track['filepath']), 'folder.jpg')
return send_file(t.coverart) if os.path.isfile(cover_path):
return send_file(cover_path)
else: else:
return "No cover art for this track found." return "No cover art for this track found."

View File

@ -32,7 +32,13 @@ function select_artist(select) {
document.getElementById('albumList').innerHTML = html_str; document.getElementById('albumList').innerHTML = html_str;
document.getElementById('trackList').innerHTML = ''; document.getElementById('trackList').innerHTML = '';
}; };
httpRequest.open('GET', api_uri + '?artist=' + select.value.replace('&', '%26'), true); let params = {
artist: select.value,
};
let query = Object.keys(params)
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
.join('&');
httpRequest.open('GET', api_uri + '?' + query, true);
httpRequest.send(); httpRequest.send();
} }
@ -45,11 +51,18 @@ function select_album(select) {
nav_items = JSON.parse(httpRequest.responseText); nav_items = JSON.parse(httpRequest.responseText);
let html_str = ''; let html_str = '';
for (let i = 0; i < nav_items.length; i++) { for (let i = 0; i < nav_items.length; i++) {
html_str += '<option value="' + nav_items[i].replace(/^.*? - /, '') + '">' + nav_items[i] + '</option>'; html_str += '<option data-discnumber="' + nav_items[i][0] + '" data-tracknumber="' + nav_items[i][1]+ '" value="' + nav_items[i][2] + '">' + nav_items[i][0] + '.' + nav_items[i][1] + ' - ' + nav_items[i][2] + '</option>';
} }
document.getElementById('trackList').innerHTML = html_str; document.getElementById('trackList').innerHTML = html_str;
}; };
httpRequest.open('GET', api_uri + '?artist=' + document.getElementById('artistList').value.replace('&', '%26') + '&album=' + select.value.replace('&', '%26'), true); let params = {
artist: document.getElementById('artistList').value,
album: select.value,
};
let query = Object.keys(params)
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
.join('&');
httpRequest.open('GET', api_uri + '?' + query, true);
httpRequest.send(); httpRequest.send();
} }
@ -63,13 +76,32 @@ function select_track(select) {
let track = JSON.parse(httpRequest.responseText); let track = JSON.parse(httpRequest.responseText);
change_track(track); change_track(track);
}; };
httpRequest.open('GET', api_uri + '?artist=' + document.getElementById('artistList').value.replace('&', '%26') + '&album=' + document.getElementById('albumList').value.replace('&', '%26') + '&track=' + select.value.replace('&', '%26'), true); let params = {
artist: document.getElementById('artistList').value,
album: document.getElementById('albumList').value,
discnumber: select.selectedOptions[0].dataset.discnumber,
tracknumber: select.selectedOptions[0].dataset.tracknumber,
};
let query = Object.keys(params)
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
.join('&');
httpRequest.open('GET', api_uri + '?' + query, true);
httpRequest.send(); httpRequest.send();
} }
function change_track(track) { function change_track(track) {
let params = {
artist: track.artist,
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'); let source = document.getElementById('stream');
source.src = track.streampath; source.src = document.location.href + '/stream?' + query;
let player = document.getElementById('player'); let player = document.getElementById('player');
player.load(); player.load();
player.play(); player.play();
@ -77,5 +109,5 @@ function change_track(track) {
document.getElementById('nowPlayingAlbum').innerHTML = track.album; document.getElementById('nowPlayingAlbum').innerHTML = track.album;
document.getElementById('nowPlayingTitle').innerHTML = track.title; document.getElementById('nowPlayingTitle').innerHTML = track.title;
document.getElementById('albumCover').firstChild.src = track.coverart; document.getElementById('albumCover').firstChild.src = document.location.href + '/coverart?' + query;
} }

View File

@ -5,7 +5,7 @@
<link rel="stylesheet" type="text/css" href="/static/musik.css"> <link rel="stylesheet" type="text/css" href="/static/musik.css">
<script type="text/javascript" src="/static/musik.js"></script> <script type="text/javascript" src="/static/musik.js"></script>
<script> <script>
const api_uri = "{{ url_for('selection') }}"; const api_uri = "{{ url_for('select') }}";
</script> </script>
<script>window.onload = load;</script> <script>window.onload = load;</script>
</head> </head>