You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

370 lines
13 KiB

2 years ago
import os
from flask import Flask, Response, request
import re
import json
import click
from time import time
from flask import send_from_directory
def which(*names):
for d in os.environ.get("PATH").split(":"):
for name in names:
path = os.path.join(d, name)
if os.path.exists(path):
return path
raise ValueError("Couldn't find %s anywhere in $PATH" % names)
MADPLAY = which("madplay")
CURL = which("curl")
FLAC = which("flac")
LAME = which("lame")
FFMPEG = which("avconv", "ffmpeg")
OGGDEC = which("oggdec")
OGGENC = which("oggenc")
TYPES = {
"mp3": "audio/mpeg",
"flac": "audio/flac",
"oga": "audio/ogg",
"ogg": "audio/ogg",
"wma": "audio/x-ms-wma",
"wav": "audio/wave",
}
RE_URL = re.compile(
r'^https?://' # http:// or https://
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain...
r'localhost|' # localhost...
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|' # ...or ipv4
r'\[?[A-F0-9]*:[A-F0-9:]+\]?)' # ...or ipv6
r'(?::\d+)?' # optional port
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
XML_ENTITIES = (
("&", "&"),
('"', """),
("'", "'"),
(">", ">"),
("<", "&lt;"),
)
def escape_xml_entities(j):
for letter, replacement in XML_ENTITIES:
j = j.replace(letter, replacement)
return j
def generate_xspf(root, names):
names.sort()
yield "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
yield "<playlist xmlns=\"http://xspf.org/ns/0/\" version=\"1\">"
yield "<meta rel=\"timestamp\">%d</meta>" % time()
yield "<trackList>"
for name in names:
path = os.path.join(root, name)
basename, extension = os.path.splitext(name)
import mutagen
import mutagen.mp3
try:
if name.lower().endswith(".mp3"):
tags = mutagen.mp3.EasyMP3(path)
else:
tags = mutagen.File(path)
except mutagen.MutagenError:
click.echo("Failed to parse metainformation for %s" % repr(path))
# Fetch stream attributes
sample_rate = getattr(tags.info, "sample_rate", None)
duration = getattr(tags.info, "length", None)
channels = getattr(tags.info, "channels", None)
bitrate = getattr(tags.info, "bitrate", None)
bit_depth = getattr(tags.info, "bits_per_sample", None)
# Workaround for calculating bitrate
if not bitrate and duration:
bitrate = int(item.size / duration) / 1000 * 8000
# Artist
artist_name = tags.pop("artist", (None,))[0]
if not artist_name:
artist_name = tags.pop("----:com.apple.iTunes:Artist Credit", (None,))[0]
# Track album
album_title = tags.pop("album", (None,))[0]
if not album_title:
album_title = tags.pop("----:com.apple.iTunes:Album Credit", (None,))[0]
# Album artist
album_artist_name = None
if not album_artist_name:
album_artist_name = tags.pop("----:com.apple.iTunes:Album Artist Credit", (None,))[0]
# Track title
track_title = tags.pop("title", (None,))[0]
# Track year
year, = tags.pop("date", (None,))
if not year:
year, = tags.pop("----:com.apple.iTunes:ORIGINAL YEAR", (None,))
def parse_x_of_y(mixed):
if not mixed:
return None, None
elif "\x00" in mixed: # Probably bug in mutagen
return None, None
elif re.match("\s*\d+\s*$", mixed):
return int(mixed), None
elif re.match("\s*\d+\s*/\s*\d+\s*$", mixed):
return [int(j) for j in mixed.split("/", 1)]
elif re.match("\s*/\s*\d+\s*$", mixed):
return None, int(mixed.split("/", 1)[1])
return None, None
# Disc numbers
disc_number, disc_count = None, None
disc, = tags.pop("discnumber", (None,))
if disc:
disc_number, disc_count = parse_x_of_y(disc)
# Release country
country_code, = tags.pop("releasecountry", (None,))
if not country_code:
country_code, = tags.pop("----:com.apple.iTunes:MusicBrainz Album Release Country", (None,))
# Track number could be in various formats: '1', '1/12', 'B2'
track_number = tags.pop("tracknumber", (None,))[0]
track_number, track_count = parse_x_of_y(track_number)
if not track_number:
(track_number, track_count), = tags.pop("trkn", ((None,None),))
# Normalize country code
if country_code:
country_code = country_code.lower()
if country_code in ("xe", "xw"):
country_code = None
yield "<track>"
yield "<location>%s</location>" % escape_xml_entities(name)
if duration:
yield "<duration>%d</duration>" % (duration * 1000)
if track_title:
yield "<title>%s</title>" % escape_xml_entities(track_title)
if artist_name:
yield "<creator>%s</creator>" % escape_xml_entities(artist_name)
if album_title:
yield "<album>%s</album>" % escape_xml_entities(album_title)
else:
yield "<title>%s</title>" % escape_xml_entities(basename)
if track_number:
yield "<trackNum>%d</trackNum>" % track_number
if track_count:
yield "<meta rel=\"track-count\">%d</meta>" % track_count
if disc_number:
yield "<meta rel=\"disc-number\">%d</meta>" % disc_number
if disc_count:
yield "<meta rel=\"disc-count\">%d</meta>" % disc_count
if year:
yield "<meta rel=\"year\">%s</meta>" % year
if album_artist_name:
yield "<meta rel=\"album-artist\">%s</meta>" % escape_xml_entities(album_artist_name)
yield "<meta rel=\"mimetype\">%s</meta>" % TYPES[extension[1:].lower()]
yield "<meta rel=\"bitrate\">%s</meta>" % bitrate
yield "<meta rel=\"bit_depth\">%s</meta>" % bit_depth
yield "<meta rel=\"sample_rate\">%s</meta>" % sample_rate
yield "</track>"
yield "</trackList>"
yield "</playlist>"
@click.command("metagen")
@click.option('--root', '-r', prompt='Path to media directory', help='Root directory to index')
@click.option('--force', '-f', is_flag=True, help="Reindex even if up to date according to timestamps")
def metagen(root, force):
for abspath, dirs, files in os.walk(root):
playlist = os.path.join(abspath, "index.xspf")
needed = False
relevant_files = []
for name in files:
for extension, mimetype in TYPES.items():
if name.lower().endswith("." + extension):
needed = True
relevant_files.append(name)
if "index.xspf" in files:
# TODO proper handling here, microsecond differences
if not force and int(os.stat(abspath).st_mtime) <= int(os.stat(playlist).st_mtime):
click.echo("Folder %s is up to date" % repr(abspath))
needed = False
if not relevant_files:
click.echo("No more relevant files, removing %s" % playlist)
os.remove(playlist)
if "index.xspf.part" in files:
click.echo("Cleaning up %s.part" % playlist)
os.remove(playlist + ".part")
if needed:
with open(playlist + ".part", "w") as fh:
for chunk in generate_xspf(abspath, relevant_files):
fh.write(chunk)
os.rename(playlist + ".part", playlist)
click.echo("Generated %s" % repr(playlist))
@click.command("serve")
@click.option('--root', prompt='Path to media directory', help='Root directory to serve')
def serve(root):
app = Flask(__name__)
@app.route("/api/transcode/")
def transcode():
def build_pipeline(source, output_mimetype):
sys.stderr.write("Transcoding from: %s\n" % source)
basename, extension = os.path.splitext(source)
extension = extension.lower()
try:
input_mimetype = TYPES.get(extension)
except:
raise ValueError("Unknown input file extension:", extension)
sys.stderr.write("Input mimetype: %s\n" % input_mimetype)
sys.stderr.write("Output mimetype: %s\n" % output_mimetype)
pipeline = []
if source.startswith("http://") or source.startswith("https://"):
# Escape path component
url = urlparse.urlparse(source)
source = url.scheme + "://" + url.hostname.encode("idna") + urllib.quote(url.path)
yield CURL, "--insecure", "--silent", "--limit-rate", "300k", source # 1Mbps is pretty max
else:
# Perhaps we could read file directly here if transcoder and web server are in the same machine?
raise ValueError("Won't transcode:" + source)
if input_mimetype == "audio/wave":
pass # Do nothing
elif input_mimetype == "audio/mpeg":
yield MADPLAY, "-", "-o", "wave:-"
elif input_mimetype == "audio/ogg":
yield OGGDEC, "-", "-o", "-"
elif input_mimetype == "audio/flac":
if output_mimetype != "audio/ogg": # oggenc handles FLAC input aswell
yield FLAC, "--decode", "-", "--stdout", "--silent"
else:
# Use FFMPEG/avconv as fallback for decoding wma
yield FFMPEG, "-i", "-", "-f", "wav", "-", "-loglevel", "quiet"
if output_mimetype == "audio/wave":
pass
elif output_mimetype == "audio/mpeg":
yield LAME, "--quiet", "-aq", "2", "-", "-"
elif output_mimetype == "audio/ogg":
yield OGGENC, "-", "-o", "-"
else:
raise ValueError("Don't know how to encode mimetype: %s" % output_mimetype)
mimetype = request.args.get("mimetype")
pipeline = tuple(build_pipeline(request.args.get("url"), mimetype))
first = True
for element in pipeline:
if not first:
print("|",)
first = False
print(" ".join(repr(j) if " " in j else j for j in element))
print()
process = None
for element in pipeline:
process = Popen(element, stdout=PIPE, stdin=process.stdout if process else PIPE, close_fds=True)
return send_file(process.stdout, "stream", mimetype)
@app.route("/api/search/")
def search():
expr = re.compile(request.args.get('expr', 'metallica'), re.IGNORECASE)
limit = request.args.get('limit', 100)
def generate():
yield 'retry:999999999999\n' # Tell browser to never reconnect
total = 0
hits = 0
counter = 0
for abspath, dirs, files in os.walk(root):
dirs.sort()
relpath = os.path.relpath(abspath, root)
hit = False
filtered_filenames = []
for name in files:
lower_name = name.lower()
for extension, mimetype in TYPES.items():
if lower_name.endswith("." + extension):
filtered_filenames.append(name)
total += 1
if expr.search(name):
hit = True
hits += 1
if expr.search(relpath):
hit = True
if hit and filtered_filenames:
yield "event:chdir\ndata:"
yield relpath
yield "\n\n"
yield "event:xspf\n"
yield "data:"
if "index.xspf" in files:
with open(os.path.join(abspath, "index.xspf")) as fh:
yield fh.read()
else:
yield from generate_xspf(abspath, filtered_filenames)
yield "\n"
yield "\n"
counter += 1
if counter > limit:
break
if counter > limit:
break
yield "event:done\n"
yield "data:%s\n" % json.dumps({"relevant_files":total, "hits":hits})
yield "\n"
return Response(generate(), mimetype='text/event-stream')
@app.route('/api/stream/<path:path>')
def send_stream(path):
return send_from_directory(root, path)
@app.route('/<path:path>')
def send_js(path):
return send_from_directory('static', path)
app.run(host= '0.0.0.0', debug=True, threaded=True)
@click.group()
def entry_point(): pass
entry_point.add_command(serve)
entry_point.add_command(metagen)
if __name__ == '__main__':
entry_point()