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.
 
 
 
 

369 lines
13 KiB

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()