diff --git a/.gitignore b/.gitignore index dab0ffd..3894248 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ *.swp *.swo *.json +*.db diff --git a/README.md b/README.md index 85a22a5..ee6f7e7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/database.py b/database.py new file mode 100644 index 0000000..f01904e --- /dev/null +++ b/database.py @@ -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 diff --git a/musik.py b/musik.py index d8464f4..2b0d329 100755 --- a/musik.py +++ b/musik.py @@ -6,228 +6,83 @@ import os import json import random import subprocess -import multiprocessing 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 -import mutagen.mp3 +from flask import Flask, Response, render_template, send_file, url_for, request, abort + +import database -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): - 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__) -api = Api(app) -tracks = init_library() +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=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///') -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: @@ -238,20 +93,21 @@ def stream(artist, album, track): return Response(generate(), mimetype="audio/ogg") -@app.route('/coverart///') -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 - else: - abort(404, message="Track does not exist.") - - if os.path.isfile(t.coverart): - return send_file(t.coverart) + 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: return "No cover art for this track found." diff --git a/static/musik.js b/static/musik.js index 95d9221..1eae6b7 100644 --- a/static/musik.js +++ b/static/musik.js @@ -32,7 +32,13 @@ function select_artist(select) { document.getElementById('albumList').innerHTML = html_str; 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(); } @@ -45,11 +51,18 @@ function select_album(select) { nav_items = JSON.parse(httpRequest.responseText); let html_str = ''; for (let i = 0; i < nav_items.length; i++) { - html_str += ''; + 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(); } @@ -63,13 +76,32 @@ function select_track(select) { let track = JSON.parse(httpRequest.responseText); 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(); } 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'); - source.src = track.streampath; + source.src = document.location.href + '/stream?' + query; let player = document.getElementById('player'); player.load(); player.play(); @@ -77,5 +109,5 @@ 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; } diff --git a/templates/index.html b/templates/index.html index 31f7ddd..a9a383c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5,7 +5,7 @@