261 lines
6.5 KiB
Python
Executable File
261 lines
6.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Music streaming.
|
|
"""
|
|
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
|
|
|
|
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()
|
|
|
|
@app.route('/')
|
|
def index():
|
|
"""Main index page."""
|
|
artists = list(set(t.artist for t in tracks))
|
|
artists.sort()
|
|
return render_template('index.html', **locals())
|
|
|
|
|
|
parser = reqparse.RequestParser()
|
|
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=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:
|
|
abort(404, message="Track does not exist.")
|
|
|
|
FFMPEG_CMD[5] = t.filepath
|
|
|
|
def generate():
|
|
with subprocess.Popen(FFMPEG_CMD, stdout=subprocess.PIPE) as proc:
|
|
data = proc.stdout.read(1024)
|
|
while data:
|
|
yield data
|
|
data = proc.stdout.read(1024)
|
|
return Response(generate(), mimetype="audio/ogg")
|
|
|
|
|
|
@app.route('/coverart/<artist>/<album>/<track>')
|
|
def coverart(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:
|
|
abort(404, message="Track does not exist.")
|
|
|
|
if os.path.isfile(t.coverart):
|
|
return send_file(t.coverart)
|
|
else:
|
|
return "No cover art for this track found."
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host='0.0.0.0', port=5150)
|