Compare commits

..

No commits in common. "d1e065d9f6d606cd7b1131f88a2b7c0307ecbd99" and "b0e4b956a9ddd3172dfd18bdc8aafe50cbef45b1" have entirely different histories.

6 changed files with 155 additions and 262 deletions

1
.gitignore vendored
View File

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

268
musik.py
View File

@ -3,16 +3,17 @@
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, url_for from flask import Flask, Response, render_template, send_file
from flask_restful import reqparse, abort, Api, Resource from werkzeug.utils import secure_filename
import mutagen
MUSIC_DIR = "/home/iou1name/music/Music" MUSIC_DIR = "/mnt/music/Music"
MUSIC_EXT = ['flac', 'mp3', 'wav', 'm4a'] MUSIC_EXT = ['flac', 'mp3', 'wav', 'm4a']
FFMPEG_CMD = [ FFMPEG_CMD = [
'ffmpeg', '-y', 'ffmpeg', '-y',
@ -24,178 +25,46 @@ FFMPEG_CMD = [
'-' '-'
] ]
class Track: def build_tree(root_dir):
def __init__(self, filepath=None, d=None, coverart=""): """Walks the music directory and builds a tree."""
if d: print("Building tree.")
for attr, value in d.items(): tree = defaultdict(dict)
setattr(self, attr, value) for dirName, subDirs, files in os.walk(root_dir):
return if dirName == root_dir:
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
filepath = os.path.join(root_dir, dir_name, file) reg = re.search(".*/(.+?) - (.+) \(\d{4}\)", dirName)
if "folder.jpg" in files: if not reg:
coverart = os.path.join(root_dir, dir_name, "folder.jpg") print(dirName)
else: continue
coverart = "" artist, album = reg.groups()
track = Track(filepath, coverart=coverart) tracks = [f for f in files if f.rpartition('.')[2] in MUSIC_EXT]
tracks.append(track) tree[artist][album] = tracks
print("Done") return tree
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."""
artists = list(set(t.artist for t in tracks)) nav_items = os.listdir(MUSIC_DIR)
artists.sort() nav_items.sort()
nav_items = [item + '/' for item in nav_items]
cd = "/"
return render_template('index.html', **locals()) return render_template('index.html', **locals())
parser = reqparse.RequestParser() @app.route('/stream/<path:track>')
parser.add_argument('artist') def stream(track):
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."""
artist, album, track = map(parse.unquote, (artist, album, track)) track = parse.unquote(track)
for t in tracks: path = os.path.join(MUSIC_DIR, track)
if (t.artist == artist and path = os.path.abspath(path)
t.album == album and if not path.startswith(MUSIC_DIR):
t.title == track): return "False"
break if not os.path.isfile(path):
else: return "False"
abort(404, message="Track does not exist.") FFMPEG_CMD[5] = path
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:
@ -206,22 +75,63 @@ 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('/get_dir/<path:directory>/')
def coverart(artist, album, track): @app.route('/get_dir/')
"""View for the raw audio file.""" def get_dir(directory=""):
artist, album, track = map(parse.unquote, (artist, album, track)) """Returns the contents of the requested directory."""
for t in tracks: directory = directory.replace("DOTDOT", "..")
if (t.artist == artist and directory = os.path.join(MUSIC_DIR, directory)
t.album == album and directory = os.path.abspath(directory)
t.title == track): if not directory.startswith(MUSIC_DIR):
break
else:
abort(404, message="Track does not exist.")
if t.coverart:
return send_file(t.coverart)
else:
return "False" 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."""
path = os.path.join(MUSIC_DIR, cover)
path = os.path.abspath(path)
if not path.startswith(MUSIC_DIR):
return "False"
if not os.path.isfile(path):
return "False"
return send_file(path)
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;
} }
.list { #currentDirectory {
height: 100%; padding-left: 0.5em;
width: 100%; }
#navItems {
list-style-type: none;
} }
#playerContainer { #playerContainer {

View File

@ -8,74 +8,71 @@ function load() {
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', api_uri + '/random', true); httpRequest.open('GET', '/musik/get_shuffle', true);
httpRequest.send(); httpRequest.send();
} }
}); });
} }
function select_artist(select) {
var httpRequest; function navigate(item) {
httpRequest = new XMLHttpRequest(); if (/\..{3,5}$/.test(item)) {
httpRequest.onreadystatechange = function () { select_track(item);
if (httpRequest.readyState !== XMLHttpRequest.DONE) { return; } }
if (httpRequest.status !== 200) { return; } else {
nav_items = JSON.parse(httpRequest.responseText); get_dir(item);
let html_str = '';
for (let i = 0; i < nav_items.length; i++) {
html_str += '<option value="' + nav_items[i] + '">' + nav_items[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) { function select_track(item) {
var httpRequest; let cd = document.getElementById('currentDirectory').innerText;
httpRequest = new XMLHttpRequest(); let track = cd + item;
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); 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) { function change_track(track) {
let source = document.getElementById('stream'); let source = document.getElementById('stream');
source.src = track.streampath; source.src = '/musik/stream' + encodeURIComponent(track);
let player = document.getElementById('player'); let player = document.getElementById('player');
player.load(); player.load();
player.play(); player.play();
document.getElementById('nowPlayingArtist').innerHTML = track.artist; document.getElementById('nowPlaying').innerHTML = track;
document.getElementById('nowPlayingAlbum').innerHTML = track.album;
document.getElementById('nowPlayingTitle').innerHTML = track.title;
document.getElementById('albumCover').firstChild.src = track.coverart; 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;
httpRequest = new XMLHttpRequest();
httpRequest.onreadystatechange = function () {
if (httpRequest.readyState !== XMLHttpRequest.DONE) { return; }
if (httpRequest.status !== 200) { return; }
nav_items = JSON.parse(httpRequest.responseText);
document.getElementById('currentDirectory').innerText = nav_items.shift();
let html_str = '';
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>`;
}
document.getElementById('navItems').innerHTML = html_str;
};
httpRequest.open('GET', '/musik/get_dir/' + item, true);
httpRequest.send();
} }

View File

@ -4,36 +4,23 @@
<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="artistListContainer" class="list"> <div id="currentDirectory">{{ cd }}</div>
<select id="artistList" size="2" class="list" onchange="select_artist(this)"> <ul id="navItems">
{% for artist in artists %} {% for item in nav_items %}
<option value="{{ artist }}">{{ artist }}</option> <li><a href="javascript:void(0);" onclick="navigate('{{ item }}')">{{ item }}</a></li>
{% endfor %} {% endfor %}
</select> </ul>
</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">
<h4><span id="nowPlayingArtist"></span> - <span id="nowPlayingAlbum"></span></h4> <h3 id="nowPlaying"></h3>
<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>