Compare commits

...

11 Commits

6 changed files with 260 additions and 153 deletions

1
.gitignore vendored
View File

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

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` Python packages: `flask gunicorn mutagen Flask-RESTful`
## Install ## Install
1. Get on the floor 1. Get on the floor

266
musik.py
View File

@ -3,17 +3,16 @@
Music streaming. Music streaming.
""" """
import os import os
import re
import json import json
import random import random
import subprocess import subprocess
from urllib import parse from urllib import parse
from collections import defaultdict
from flask import Flask, Response, render_template, send_file from flask import Flask, Response, render_template, send_file, url_for
from werkzeug.utils import secure_filename from flask_restful import reqparse, abort, Api, Resource
import mutagen
MUSIC_DIR = "/mnt/music/Music" MUSIC_DIR = "/home/iou1name/music/Music"
MUSIC_EXT = ['flac', 'mp3', 'wav', 'm4a'] MUSIC_EXT = ['flac', 'mp3', 'wav', 'm4a']
FFMPEG_CMD = [ FFMPEG_CMD = [
'ffmpeg', '-y', 'ffmpeg', '-y',
@ -25,46 +24,178 @@ FFMPEG_CMD = [
'-' '-'
] ]
def build_tree(root_dir): class Track:
"""Walks the music directory and builds a tree.""" def __init__(self, filepath=None, d=None, coverart=""):
print("Building tree.") if d:
tree = defaultdict(dict) for attr, value in d.items():
for dirName, subDirs, files in os.walk(root_dir): setattr(self, attr, value)
if dirName == root_dir: 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 continue
reg = re.search(".*/(.+?) - (.+) \(\d{4}\)", dirName) filepath = os.path.join(root_dir, dir_name, file)
if not reg: if "folder.jpg" in files:
print(dirName) coverart = os.path.join(root_dir, dir_name, "folder.jpg")
continue else:
artist, album = reg.groups() coverart = ""
tracks = [f for f in files if f.rpartition('.')[2] in MUSIC_EXT] track = Track(filepath, coverart=coverart)
tree[artist][album] = tracks tracks.append(track)
return tree 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__) app = Flask(__name__)
api = Api(app)
tracks = init_library()
@app.route('/') @app.route('/')
def index(): def index():
"""Main index page.""" """Main index page."""
nav_items = os.listdir(MUSIC_DIR) artists = list(set(t.artist for t in tracks))
nav_items.sort() artists.sort()
nav_items = [item + '/' for item in nav_items]
cd = "/"
return render_template('index.html', **locals()) return render_template('index.html', **locals())
@app.route('/stream/<path:track>') parser = reqparse.RequestParser()
def stream(track): parser.add_argument('artist')
parser.add_argument('album')
parser.add_argument('track')
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.""" """View for the raw audio file."""
track = parse.unquote(track) artist, album, track = map(parse.unquote, (artist, album, track))
path = os.path.join(MUSIC_DIR, track) for t in tracks:
path = os.path.abspath(path) if (t.artist == artist and
if not path.startswith(MUSIC_DIR): t.album == album and
return "False" t.title == track):
if not os.path.isfile(path): break
return "False" else:
FFMPEG_CMD[5] = path abort(404, message="Track does not exist.")
FFMPEG_CMD[5] = t.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:
@ -75,63 +206,22 @@ def stream(track):
return Response(generate(), mimetype="audio/ogg") return Response(generate(), mimetype="audio/ogg")
@app.route('/get_dir/<path:directory>/') @app.route('/coverart/<artist>/<album>/<track>')
@app.route('/get_dir/') def coverart(artist, album, track):
def get_dir(directory=""):
"""Returns the contents of the requested directory."""
directory = directory.replace("DOTDOT", "..")
directory = os.path.join(MUSIC_DIR, directory)
directory = os.path.abspath(directory)
if not directory.startswith(MUSIC_DIR):
return "False"
if not os.path.isdir(directory):
return "False"
nav_items = os.listdir(directory)
nav_items.sort()
if directory != MUSIC_DIR:
nav_items = [".."] + nav_items
nav_items_new = []
for item in nav_items:
if os.path.isdir(os.path.join(directory, item)):
item += '/'
nav_items_new.append(item)
nav_items_new = [directory.replace(MUSIC_DIR, '') + '/'] + nav_items_new
return json.dumps(nav_items_new)
@app.route('/get_shuffle')
def shuffle():
"""Returns a randomly selected track from the library."""
item = random.choice(os.listdir(MUSIC_DIR))
path = os.path.join(MUSIC_DIR, item)
n = 0
while not item.rpartition('.')[2] in MUSIC_EXT:
n += 1
item = random.choice(os.listdir(path))
if os.path.isdir(os.path.join(path, item)):
path = os.path.join(path, item)
if n == 5:
item = random.choice(os.listdir(MUSIC_DIR))
path = os.path.join(MUSIC_DIR, item)
n = 0
path = os.path.join(path, item)
return path.replace(MUSIC_DIR, '')
@app.route('/album_cover/<path:cover>')
def album_cover(cover):
"""View for the raw audio file.""" """View for the raw audio file."""
path = os.path.join(MUSIC_DIR, cover) artist, album, track = map(parse.unquote, (artist, album, track))
path = os.path.abspath(path) for t in tracks:
if not path.startswith(MUSIC_DIR): if (t.artist == artist and
return "False" t.album == album and
if not os.path.isfile(path): t.title == track):
return "False" break
else:
abort(404, message="Track does not exist.")
return send_file(path) if t.coverart:
return send_file(t.coverart)
else:
return "False"
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -21,16 +21,16 @@ img {
#navigationContainer { #navigationContainer {
flex: auto; flex: auto;
overflow: auto; overflow: auto;
display: flex;
flex-direction: row;
height: 100%;
border-top: 2px solid #ccc; border-top: 2px solid #ccc;
border-bottom: 2px solid #ccc; border-bottom: 2px solid #ccc;
} }
#currentDirectory { .list {
padding-left: 0.5em; height: 100%;
} width: 100%;
#navItems {
list-style-type: none;
} }
#playerContainer { #playerContainer {

View File

@ -5,74 +5,77 @@ function load() {
if (document.getElementById('shuffle').checked) { if (document.getElementById('shuffle').checked) {
var httpRequest; var httpRequest;
httpRequest = new XMLHttpRequest(); httpRequest = new XMLHttpRequest();
httpRequest.onreadystatechange = function() { httpRequest.onreadystatechange = function () {
if (httpRequest.readyState !== XMLHttpRequest.DONE) { return; } if (httpRequest.readyState !== XMLHttpRequest.DONE) { return; }
if (httpRequest.status !== 200) { return; } if (httpRequest.status !== 200) { return; }
let track = httpRequest.responseText;
let track = JSON.parse(httpRequest.responseText);
change_track(track); change_track(track);
}; };
httpRequest.open('GET', '/musik/get_shuffle', true); httpRequest.open('GET', api_uri + '/random', true);
httpRequest.send(); httpRequest.send();
} }
}); });
} }
function select_artist(select) {
function navigate(item) {
if (/\..{3,5}$/.test(item)) {
select_track(item);
}
else {
get_dir(item);
}
}
function select_track(item) {
let cd = document.getElementById('currentDirectory').innerText;
let track = cd + item;
change_track(track);
}
function change_track(track) {
let source = document.getElementById('stream');
source.src = '/musik/stream' + encodeURIComponent(track);
let player = document.getElementById('player');
player.load();
player.play();
document.getElementById('nowPlaying').innerHTML = track;
let arr = track.split('/');
let art = document.getElementById('albumCover');
art.firstChild.src = '/musik/album_cover' + arr.slice(0, arr.length-1).join("/") + '/folder.jpg';
}
function get_dir(item) {
let cd = document.getElementById('currentDirectory').innerText;
if (item === "../") {
if (cd !== "/") {
item = cd.slice(0, cd.slice(0, -1).lastIndexOf("/")+1);
}
else {
return;
}
}
else {
item = cd + item;
}
var httpRequest; var httpRequest;
httpRequest = new XMLHttpRequest(); httpRequest = new XMLHttpRequest();
httpRequest.onreadystatechange = function () { httpRequest.onreadystatechange = function () {
if (httpRequest.readyState !== XMLHttpRequest.DONE) { return; } if (httpRequest.readyState !== XMLHttpRequest.DONE) { return; }
if (httpRequest.status !== 200) { return; } if (httpRequest.status !== 200) { return; }
nav_items = JSON.parse(httpRequest.responseText); nav_items = JSON.parse(httpRequest.responseText);
document.getElementById('currentDirectory').innerText = nav_items.shift();
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 += `<li><a href="javascript:void(0);" onclick="navigate(\`${nav_items[i]}\`)">${nav_items[i]}</a></li>`; html_str += '<option value="' + nav_items[i] + '">' + nav_items[i] + '</option>';
} }
document.getElementById('navItems').innerHTML = html_str; document.getElementById('albumList').innerHTML = html_str;
document.getElementById('trackList').innerHTML = '';
}; };
httpRequest.open('GET', '/musik/get_dir/' + item, true); httpRequest.open('GET', api_uri + '?artist=' + select.value, true);
httpRequest.send(); 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 html_str = '';
for (let i = 0; i < nav_items.length; i++) {
html_str += '<option value="' + nav_items[i].replace(/\d* - /, '') + '">' + nav_items[i] + '</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);
};
httpRequest.open('GET', api_uri + '?artist=' + document.getElementById('artistList').value + '&album=' + document.getElementById('albumList').value + '&track=' + select.value, true);
httpRequest.send();
}
function change_track(track) {
let source = document.getElementById('stream');
source.src = track.streampath;
let player = document.getElementById('player');
player.load();
player.play();
document.getElementById('nowPlayingArtist').innerHTML = track.artist;
document.getElementById('nowPlayingAlbum').innerHTML = track.album;
document.getElementById('nowPlayingTitle').innerHTML = track.title;
document.getElementById('albumCover').firstChild.src = track.coverart;
}

View File

@ -4,23 +4,36 @@
<title>Musik</title> <title>Musik</title>
<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>
const api_uri = "{{ url_for('selection') }}";
</script>
<script>window.onload = load;</script> <script>window.onload = load;</script>
</head> </head>
<body> <body>
<div id="globalContainer"> <div id="globalContainer">
<div id="titleContainer"><h1>Musik</h1></div> <div id="titleContainer"><h1>Musik</h1></div>
<div id="navigationContainer"> <div id="navigationContainer">
<div id="currentDirectory">{{ cd }}</div> <div id="artistListContainer" class="list">
<ul id="navItems"> <select id="artistList" size="2" class="list" onchange="select_artist(this)">
{% for item in nav_items %} {% for artist in artists %}
<li><a href="javascript:void(0);" onclick="navigate('{{ item }}')">{{ item }}</a></li> <option value="{{ artist }}">{{ artist }}</option>
{% endfor %} {% endfor %}
</ul> </select>
</div>
<div id="albumListContainer" class="list">
<select id="albumList" size="2" class="list" onchange="select_album(this)">
</select>
</div>
<div id="trackListContainer" class="list">
<select id="trackList" size="2" class="list" onchange="select_track(this)">
</select>
</div>
</div> </div>
<div id="playerContainer"> <div id="playerContainer">
<span id="albumCover"><img src=""/></span> <span id="albumCover"><img src=""/></span>
<span id="playerControls"> <span id="playerControls">
<h3 id="nowPlaying"></h3> <h4><span id="nowPlayingArtist"></span> - <span id="nowPlayingAlbum"></span></h4>
<h3 id="nowPlayingTitle"></h3>
<audio id="player" controls> <audio id="player" controls>
<source id="stream" src="" type="audio/ogg"> <source id="stream" src="" type="audio/ogg">
</audio> </audio>