diff --git a/Moo/__init__.py b/Moo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Moo/play/__init__.py b/Moo/play/__init__.py new file mode 100644 index 0000000..c44354e --- /dev/null +++ b/Moo/play/__init__.py @@ -0,0 +1,2 @@ +from . import lib +from . import config diff --git a/Moo/play/app.py b/Moo/play/app.py new file mode 100644 index 0000000..89db528 --- /dev/null +++ b/Moo/play/app.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 + +''' +Moo.play +~~~~~~~~ + +Flask app for playing music files + +$ workon moo +$ export FLASK_APP=Moo/play/app.py +$ export FLASK_DEBUG=1 +$ export FLASK_ENV=development +$ flask run +''' + +import os +import random + +from urllib.parse import quote + +from flask import Flask, Response, render_template, request, abort +from flask import redirect, url_for +from Moo.play import config, lib + + +app = Flask(__name__) +app.config.from_object(config) +base = app.config['BASE'] +index = lib.index(app.config) + + +@app.route('/') +def root(): + return render_template('index.html', base=base, index=index) + + +@app.route('/album/') +def album_route(alkey): + alb, metadata = album_data(alkey, index) + return serve_album(alkey, alb, metadata) + + +@app.route('/index/') +def alpha_index(letter): + alpha = list() + + for path in index: + alname = str(path).replace(base, '') + if alname.upper().startswith('/' + letter): + alpha.append(path) + + return render_template('index.html', + alpha=letter, base=base, + index=sorted(alpha)) + + +@app.route('/covers/') +def alpha_covers(letter): + alpha = list() + + for path in index: + alname = str(path).replace(base, '') + if alname.upper().startswith('/' + letter): + alpha.append(path) + + return render_template('covers.html', alpha=letter, base=base, + index=sorted(alpha)) + + +@app.route('/covers') +def covers(): + return render_template('covers.html', index=index, base=base) + + +@app.route('/debug') +def debug(): + return render_template('debug.html', data=index) + + +@app.route('/img/track//') +def cover_track(tnum, alkey): + try: + album, metadata = album_data(alkey, index) + mkey = sorted(metadata.keys())[int(tnum) - 1] + tpath = metadata[mkey]['fpath'] + return lib.cover(os.path.join(base, alkey), tpath) + except TypeError: + return app.send_static_file('cover.png') + + +@app.route('/img/') +def cover(alkey): + try: + return lib.cover(os.path.join(base, alkey)) + except TypeError: + return app.send_static_file('cover.png') + + +@app.route('/meta/') +def meta_route(alkey): + album, metadata = album_data(alkey, index) + return metadata + + +@app.route('/random') +def rando(): + ind = random.choice(range(len(index))) + alkey = str(index[ind]) + album, metadata = album_data(alkey, index) + return redirect('/album' + quote(alkey.replace(base, ''))) + + +@app.route('/track//') +def track(tnum, alkey): + alb, metadata = album_data(alkey, index) + return serve_album(alkey, alb, metadata, int(tnum) - 1) + + +def album_data(alkey, index): + ''' + returns album, metadata tuple or aborts + ''' + alb = lib.album(alkey, index) + + if not alb: + abort(404) + + try: + metadata = lib.metadata(base, alb) + except FileNotFoundError: + abort(404) + + if not metadata: + raise ValueError('{} has no METADATA'.format(quote(alkey))) + + return alb, metadata + + +def serve_album(alkey, album, metadata, track_ind=None): + + if track_ind is not None: + autoplay = True + lib.update_history(alkey) + else: + autoplay = False + track_ind = 0 + + mkey = sorted(metadata.keys())[track_ind] + tnum = track_ind + 1 + track = metadata[mkey] + + try: + tags = lib.tags(base, alkey, track) + except AttributeError: + raise ValueError("NO TAGS: album/{}".format(quote(alkey))) + + terms = lib.terms(metadata, track) + control = lib.control(base, index, metadata, track_ind) + + return render_template( + 'album.html', + album=album, + alkey=alkey, + autoplay=autoplay, + base=base, + control=control, + index=index, + info=lib.info(track, metadata), + metadata=metadata, + mkey=mkey, + personnel=lib.personnel(metadata), + related=lib.related(index, terms), + tags=tags, + terms=terms, + tnum=tnum, + track=track) diff --git a/Moo/play/config.py b/Moo/play/config.py new file mode 100644 index 0000000..c6e284d --- /dev/null +++ b/Moo/play/config.py @@ -0,0 +1,11 @@ +''' +Moo config +''' + +AUTHOR = 'siznax' +PACKAGE = 'moo' +VERSION = '0.1' +WWW = 'https://github.com/siznax/moo' + +BASE ='/Users/steve/Music/Lina' +BASE ='/Users/steve/Music/Steve' diff --git a/Moo/play/lib.py b/Moo/play/lib.py new file mode 100644 index 0000000..b0fe05d --- /dev/null +++ b/Moo/play/lib.py @@ -0,0 +1,560 @@ +''' +Methods supporting Moo.play +''' + +import hashlib +import json +import os +import random +import string + +from io import BytesIO +from math import ceil + +from collections import Counter +from pathlib import Path +from unidecode import unidecode +from urllib.parse import quote + +import mutagen + +from flask import app, send_file, url_for +from .tags import mp3_fields, mp4_fields +from .utils import h_m + +# https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Audio_codecs +# +MEDIATYPES = { + "AAC": "audio/aac", + "FLAC": "audio/ogg", + "MP4": "audio/mp4", + "MP3": "audio/mp3", + "MPEG": "audio/mpeg", + "OGG": "audio/ogg", + "WAV": "audio/wav", +} + + +def album(key, index): + ''' + returns album from index matching key + ''' + for path in index: + if str(path).endswith(key): + return str(path) + + +def control(base, index, metadata, track_ind): + ''' + returns next, prev, and random track numbers as dict + ''' + ntracks = len(metadata) + _next = track_ind + 2 + prev = track_ind + + if _next > ntracks: + _next = 0 + + if prev < 1: + prev = 0 + + ind = random.choice(range(len(index))) + alkey = str(index[ind]).replace(base, '') + + return { + 'next': _next, + 'prev': prev, + 'ralbum': alkey, + 'rtrack': random.choice(range(ntracks)) + 1, + 'track_num': track_ind + 1} + + +def cover(fpath, tpath=None): + ''' + returns audio file APIC (album cover) data as HTTP response + ''' + apic = get_apic(fpath, tpath) + + try: + data = apic.data + mtype = apic.mime + except AttributeError: # try MP4Cover (list of strings) + data = apic[0] + mtype = 'image/jpeg' + + return send_file( + BytesIO(data), + attachment_filename='apic', + mimetype=mtype) + + +def flat_data(mfile, fpath): + enc = type(mfile).__name__ + tags = dict(mfile.tags) + flat = dict() + + for item in tags: + if isinstance(tags[item], list): + if isinstance(tags[item][0], list): + flat[item] = ", ".join(tags[item][0]) + else: + flat[item] = tags[item][0] + + if enc == 'MP3': + flat = rekey_mp3_tags(flat or tags) + + if enc == 'MP4': + flat = rekey_mp4_tags(flat or tags) + + flat = rekey_catchall(flat) + + if 'track' in flat: + flat['track'] = flat_val(flat, 'track') + + if 'disc' in flat: + flat['disc'] = flat_val(flat, 'disc') + + return flat + + +def flat_val(data, key): + ''' + returns flattened value from metadata + ''' + obj = data[key] + + if isinstance(obj, tuple): + return int(obj[0]) + + if isinstance(obj, str): + try: + return int(obj) + except ValueError: + if '/' in obj: + return obj.split('/')[0] + return obj + + +def get_apic(fpath, tpath=None): + ''' + returns track image if tpath, else first attached picture + (APIC) found in tracks + ''' + def img(track_path): + try: + meta = mutagen.File(track_path) + for tag in meta.tags: + if tag.startswith('APIC') or tag.startswith('covr'): + return meta[tag] + except AttributeError: + pass # not a media file + except (mutagen.id3.ID3NoHeaderError, mutagen.MutagenError): + pass # no ID3 headers + + if tpath: + track_img = img(tpath) + if track_img: + return track_img + + for track in Path(fpath).iterdir(): + track_img = img(str(track)) + if track_img: + return track_img + + +def get_term(meta): + ''' + returns "one good" term from a metadata value + ''' + excludes = ('album', 'artist', 'artists', 'blue', 'blues', 'from', + 'great', 'jazz', 'music', 'rock', 'song', 'this', + 'unknown', 'various') + + if len(meta.split()) == 1: + words = [meta] + else: + words = meta.split() + + def ascii(word): + out = word.split("'")[0] # keep possessive root + out, _ = os.path.splitext(out) # drop extension + out = "".join([x for x in out if x not in string.punctuation]) + return unidecode(out) + + for _ in words: + word = ascii(_) + if len(word) > 3 and word.lower() not in excludes: + return word + + +def index(config, sort=None, offset=None, limit=None): + ''' + returns list of albums (PosixPaths), reverse-sorted by mtime + albums are paths that do not contain directories + ''' + offset = offset or 0 + sort_key = os.path.getmtime + + if sort: + if sort == 'atime': + sort_key = os.path.getatime + if sort == 'ctime': + sort_key = os.path.getctime + + paths = sorted( + Path(config['BASE']).rglob('*'), + key=sort_key, + reverse=True) + + albums = list() # paths that do not contain directories + + count = 0 + for path in paths: + leaf = True + + try: + for child in path.iterdir(): + if child.is_dir(): + leaf = False + continue + except NotADirectoryError: + continue + + if leaf: # and path has children? + albums.append(path) + count += 1 + + if limit and count >= limit: + break + + return albums[offset:limit] + + +def info(track, metadata): + ''' + returns album info as tuple-list from metadata + ''' + out = list() + length = 0.0 + ntracks = len(metadata) + size = 0 + + for meta in metadata: + length += metadata[meta].get('length', 0.0) + size += metadata[meta].get('size', 0) + + if 'year' in track: + out.append(('year', track['year'][:4])) + + if 'genre' in track: + out.append(('genre', track['genre'])) + + out.append(('ntracks', "{} tracks".format(ntracks))) + + if int(length): + out.append(('length', h_m(int(length)))) + + out.append(('size', "{} MB".format(round(size / 1e6, 1)))) + + return out + + +def mediatype(enc): + ''' + returns mediatype given encoding + ''' + for mtype in MEDIATYPES: + if enc.upper().startswith(mtype): + return MEDIATYPES[mtype] + + +def metadata(base, album): + ''' + returns metadata dict for a single track from album path + ''' + out = dict() + + for ind, fpath in enumerate(sorted(Path(album).iterdir())): + key = str(fpath) + audio = mutagen.File(fpath) + encoding = type(audio).__name__ + data = dict() + + if not hasattr(audio, 'tags'): + continue + + try: + data = flat_data(audio, fpath) + data.update(vars(audio.info)) + data['type'] = audio.mime[0] + except (AttributeError, TypeError): + pass + + data['encoding'] = encoding + data['src'] = quote(key.replace(base, '/static/Moo')) + + if 'title' not in data: + name, ext = os.path.splitext(fpath.name) + data['title'] = name + + if 'track' not in data: + data['track'] = ind + 1 + + data.update(stat(str(fpath))) + + out[key] = data + + out = parse_fnames(out) + + return sorted_tracks(out) + + +def parse_fnames(mdata): + updated = False + words = Counter() + dashes = Counter() + + for item in mdata: + if 'title' in mdata[item]: + continue + + path = Path(mdata[item]['fpath']) + + pwords = path.name.split() + pdash = path.name.split('-') + + tmp = dict() + tmp['title'] = path.name + + words.update(pwords) + dashes.update(pdash) + + mdata[item].update(tmp) + updated = True + + if not updated: + return mdata + + for item in mdata: + mdata[item]['_words'] = dict(words) + mdata[item]['_dashes'] = dict(dashes) + + return mdata + + +def personnel(metadata): + ''' + return set of personnel in album metadata + ''' + _ = set() + + for item in metadata: + track = metadata[item] + + if track.get('artist'): + _.add(track['artist']) + if track.get('composer'): + _.add(track['composer']) + if track.get('conductor'): + _.add(track['conductor']) + + return list(_) + + +def rekey_catchall(tags): + fields = { + 'discnumber': 'disc', + 'tracknumber': 'track', + } + + _ = dict() + + for item in tags: + if item in fields: + key = fields[item] + else: + key = item + _[key] = tags[item] + + return _ + +def rekey_mp3_tags(tags): + ''' + normalize MP3 tags to ID3 + ''' + def get_val(obj): + if hasattr(obj, 'text'): + text = obj.text + if isinstance(text, list): + return ", ".join([str(x) for x in text]) + else: + return str(text) + else: + return str(obj) + + _ = dict() + + for item in tags: + if item.startswith('APIC'): + _['APIC'] = tags[item].mime + continue + if 'MusicBrainz' in item: + _['MBID'] = tags[item] + continue + if 'purl' in item: + _['URL'] = tags[item] + continue + for field in mp3_fields: + if item.startswith(field): + _[mp3_fields[field]] = get_val(tags[item]) + + return _ + + +def rekey_mp4_tags(tags): + ''' + normalize MP4 tags to ID3 + ''' + _ = dict() + + for item in tags: + if item in mp4_fields: + _[mp4_fields[item]] = tags[item] + + return _ + + +def related(index, terms): + ''' + returns index pruned by search term + ''' + _ = list() + + for path in index: + dpath = unidecode(str(path).lower()) + for term in terms: + if term.lower() in dpath: + _.append(path) + + return list(set(_)) + + +def sorted_tracks(meta): + ''' + returns album metadata as dict + with keys sorted by track, disc number + ''' + discs = list() + ntracks = len(meta) + out = dict() + zeroes = 2 if ntracks < 100 else 3 + numbers = list(range(1, ntracks + 1)) + + for track in meta: + if 'disc' in meta[track]: + discs.append(meta[track]['disc']) + + discs = list(set(discs)) + + for ind, track in sorted(enumerate(meta)): + if 'track' in meta[track]: + tnum = meta[track]['track'] + try: + numbers.remove(int(tnum)) + except ValueError: + tnum = numbers.pop(0) + else: + tnum = numbers.pop(0) + + if isinstance(tnum, int): + tnum = str(tnum) + else: + tnum = tnum.split('/')[0].zfill(zeroes) + + if 'disc' in meta[track] and len(discs) > 1: + dnum = str(meta[track]['disc']).zfill(zeroes) + key = '{}.{}'.format(dnum, tnum.zfill(zeroes)) + else: + key = tnum.zfill(zeroes) + + # print('ind = {}, tnum = {} {}'.format(ind, tnum, numbers)) + + out[key] = meta[track] + out[key]['fname'] = track + out[key]['_key'] = key + + if len(out) != ntracks: + raise ValueError("Problem sorting tracks {} != {}".format( + len(out), ntracks)) + + return out + + +def stat(fpath): + '''returns file stat data as dict''' + stat = os.stat(fpath) + return { + 'fpath': fpath, + 'atime': stat.st_atime, + 'mtime': stat.st_mtime, + 'size': stat.st_size} + + +def tags(base, alkey, track): + ''' + returns all ID3 tags for a single track from album path as dict + ''' + for fname in Path(os.path.join(base, alkey)).iterdir(): + if track.get('fname') == str(fname): + audio = mutagen.File(str(fname)) + + try: + tags = dict(audio.tags) + except TypeError: + return None + + for item in audio.tags: + if str(item).upper().startswith('APIC'): + tags[item] = audio.tags[item].mime + elif str(item).upper().startswith('COVR'): + tags[item] = vars(audio.tags[item][0]) + + return tags + +def terms(metadata, track): + ''' + returns list of "related" search terms from track, album metadata + ''' + tmp = list() + + if track.get('artist'): + tmp.append(get_term(track['artist'])) + + if track.get('album_artist'): + tmp.append(get_term(track['album_artist'])) + + if track.get('album'): + tmp.append(get_term(track['album'])) + + if track.get('title'): + tmp.append(get_term(track['title'])) + else: + if track.get('genre'): + tmp.append(get_term(track['genre'])) + + return sorted(list(set([x for x in tmp if x]))) + + +def tracks(fpath): + ''' + returns a list of tracks from a filepath + ''' + return [str(x) for x in Path(fpath).iterdir()] + + +def update_history(path): + ''' + writes album path to .history file + ''' + with open("HISTORY", "w") as _: + _.write(path + "\n") diff --git a/Moo/play/static/album.png b/Moo/play/static/album.png new file mode 100644 index 0000000..686c147 Binary files /dev/null and b/Moo/play/static/album.png differ diff --git a/Moo/play/static/apple.png b/Moo/play/static/apple.png new file mode 100644 index 0000000..c66e45e Binary files /dev/null and b/Moo/play/static/apple.png differ diff --git a/Moo/play/static/archive.org.jpg b/Moo/play/static/archive.org.jpg new file mode 100644 index 0000000..7ff21a3 Binary files /dev/null and b/Moo/play/static/archive.org.jpg differ diff --git a/Moo/play/static/audio.js b/Moo/play/static/audio.js new file mode 100644 index 0000000..815bdeb --- /dev/null +++ b/Moo/play/static/audio.js @@ -0,0 +1,301 @@ +/** Moo **/ + +var DEBUG = false + +var audio = document.querySelector("audio") +var clock = document.getElementById('clock') +var control = document.getElementById('control') +var title = document.getElementById('title') +var track = document.getElementById('track') + + +function _addClass(obj, cls) { + obj.classList.add(cls) +} + +function _removeClass(obj, cls) { + obj.classList.remove(cls) +} + +function blurgray(sel) { + let elm = document.querySelector(sel) + elm.style.filter = "blur(8px) grayscale(1)" +} + + +function blurgray_clear(sel) { + let elm = document.querySelector(sel) + elm.style.filter = "blur(0) grayscale(0)" +} + +function dark() { + let body = document.getElementById("body") + body.classList.toggle("dark") +} + +function gotoCovers() { + window.location = "/covers" +} + +function gotoTrack(num) { + let ntracks = parseInt(control.getAttribute('ntracks')) + if (parseInt(num) <= ntracks) { + let alkey = track.getAttribute("alkey") + location = "/track/" + num + "/" + alkey + } +} + +function glowOn() { + track.classList.add("glow") + window.setTimeout(_removeClass, 150, track, "glow") + window.setTimeout(_addClass, 250, track, "glow") + + title.classList.add("glow") + window.setTimeout(_removeClass, 50, title, "glow") + window.setTimeout(_addClass, 300, title, "glow") +} + + +function glowOff() { + title.classList.remove("glow") + track.classList.remove("glow") +} + + +function gotoIndex() { + window.location = "/" +} + + +function gotoNext() { + let next = control.getAttribute('next') + if (next > 0) { + title.classList.remove("glow") + track.classList.remove("glow") + gotoTrack(next) + } +} + + +function gotoPrev() { + let prev = control.getAttribute('prev') + if (prev > 0) { + title.classList.remove("glow") + track.classList.remove("glow") + gotoTrack(prev) + } +} + + +function gotoRandom() { + location = '/random' +} + + +function hide(sel) { + elm = document.querySelectorAll(sel) + elm.forEach(function(obj) { + obj.style.visibility = "hidden" + }) +} + + +// function hideOtherOverlays(not_id) { +// ols = document.querySelectorAll('.overlay') +// ols.forEach(function(item) { +// if (item.id != 'not_id') { +// item.style.display = 'none' +// } +// }) +// } + + +function keyDown(e) { + + // alert(e.keyCode) + + if (e.code == "ArrowUp") { + e.preventDefault() + gotoPrev() + } + if (e.code == "ArrowRight") { + e.preventDefault() + gotoNext() + } + if (e.code == "ArrowDown") { + e.preventDefault() + gotoNext() + } + if (e.code == "ArrowLeft") { + e.preventDefault() + gotoPrev() + } +} + + +function keyPressed(e) { + + // alert(e.code) + + if (e.code == "Space") { + e.preventDefault() + if (audio.paused) { + audio.play() + glowOn() + } + else { + audio.pause() + glowOff() + } + } + + else if (e.code == "KeyN") { gotoNext() } + else if (e.code == "KeyP") { gotoPrev() } + else if (e.code == "KeyR") { gotoRandom() } + else if (e.code == "KeyT") { playRandomTrack() } + + else if (e.code == "KeyC") { toggle_hidden('covers') } + else if (e.code == "KeyD") { toggle_hidden("metadata") } + else if (e.code == "KeyG") { toggle_hidden("tags") } + else if (e.code == "KeyH") { toggle_hidden("help") } + else if (e.code == "KeyI") { toggle_hidden('index') } + else if (e.code == "KeyL") { toggle_hidden("classical") } + else if (e.code == "KeyM") { toggle_hidden("related") } + + else if (e.code == "Digit1") { gotoTrack(1) } + else if (e.code == "Digit2") { gotoTrack(2) } + else if (e.code == "Digit3") { gotoTrack(3) } + else if (e.code == "Digit4") { gotoTrack(4) } + else if (e.code == "Digit5") { gotoTrack(5) } + else if (e.code == "Digit6") { gotoTrack(6) } + else if (e.code == "Digit7") { gotoTrack(7) } + else if (e.code == "Digit8") { gotoTrack(8) } + else if (e.code == "Digit9") { gotoTrack(9) } + else if (e.code == "Digit0") { gotoTrack(10) } + + else { + } +} + + +function playRandomTrack() { + let rtrack = track.getAttribute("rtrack") + gotoTrack(rtrack) +} + + +function toggle(id) { /* assumes element is initially visible */ + let elm = document.getElementById(id) + let type = elm.tagName + + if (!elm.style.display) { + elm.style.display = "none" + } else if (elm.style.display == "block") { + elm.style.display = "none" + } else if (elm.style.display == "table") { + elm.style.display = "none" + } else { + if (type == 'TABLE') { + elm.style.display = "table" + } else { + elm.style.display = "block" + } + } +} + + +function toggle_hidden(id) { /* assumes element is initially hidden */ + + let elm = document.getElementById(id) + let cover = document.getElementById("cover_img") + let type = elm.tagName + + if (!elm.style.display) { + if (type == 'TABLE') { + elm.style.display = "table" + } else { + elm.style.display = "block" + } + } else if (elm.style.display == "none") { + if (type == 'TABLE') { + elm.style.display = "table" + } else { + elm.style.display = "block" + } + } else { + elm.style.display = "none" + } +} + + +// toggle_overlay(id) assumes element is initially NOT visible +// +function toggle_overlay(id) { + + if (DEBUG) { console.log("toggle_overlay('" + id + "')") } + + let elm = document.getElementById(id) + let disp = elm.style.display + let type = elm.tagName + + // hideOtherOverlays(id) + + if (!disp) { + // blurgray('img#cover') + if (type == 'table') { + elm.style.display = 'table' + } else { + elm.style.display = 'block' + } + } else if (disp === 'none') { + // blurgray('img#cover') + if (type == 'table') { + elm.style.display = 'table' + } else { + elm.style.display = 'block' + } + } else if (disp = 'block') { + // blurgray_clear('img#cover') + elm.style.display = 'none' + } else { + // blurgray_clear('img#cover') + elm.style.display = 'none' + } + + if (DEBUG) { + console.log("'" + disp + "' => " + elm.style.display) + } +} + + +function updateClock() { + if (clock) { + let time = new Date().toLocaleTimeString() + let ampm = time.split(" ")[1] + let hm_ = time.split(":", 2).join(":") + clock.innerHTML = hm_ + " " + ampm + "" + } +} + + +function init(version) { + document.addEventListener("keydown", keyDown) + document.addEventListener("keypress", keyPressed) + + let audio = document.querySelector("audio") + if (audio) { + audio.addEventListener("play", glowOn) + audio.addEventListener("pause", glowOff) + audio.addEventListener("ended", gotoNext) + } + + // let navigator = document.getElementById('navigator') + // let nav = window.navigator + // console.log(nav) + // navigator.innerHTML = nav.userAgent + // console.log(nav.userAgent) + + setInterval(updateClock, 1000) + + console.log('Moo ' + version) +} diff --git a/Moo/play/static/color.css b/Moo/play/static/color.css new file mode 100644 index 0000000..6c07289 --- /dev/null +++ b/Moo/play/static/color.css @@ -0,0 +1,67 @@ +body { + background-color: black; + color: gray; +} + +a { color: orange; } /* color (base) */ +a:hover { color: gold } /* bright */ + +a .track { color: rgba(255,255,255,0.3) } + +.border { border: 1px solid rgba(255,255,255,0.2) } + +.dim1 { color: rgba(0,0,0,0.1) } +.dim2 { color: rgba(0,0,0,0.25) } +.dim3 { color: rgba(0,0,0,0.5) } + +.dark { color: darkorange } +.bright { color: gold } +.highlight { color: white } + +kbd { + background: rgba(255,255,255,0.3); + color: white; +} + +div#help { + background: rgba(0,0,0,0.9); + color: gray; +} + +div#info a { + background-color: rgba(255,255,255,0.25); + color: black; +} + +div#info #track { + background-color: darkorange; + color: black; +} + +div#info #total_tracks { + background-color: black; + color: darkorange; +} + +div#related { + background-color: rgba(0,0,0,0.9); + color: white; +} + +table#tracklist .track_num { + color: rgba(255,255,255,0.15); +} + +table#tracklist tr.active a { + color: darkorange; +} +table#tracklist tr:hover a.track { + color: orange; +} +table#tracklist tr:hover .track_num { + color: darkorange; +} + +#title { + text-shadow: 0px 0px 16px; +} diff --git a/Moo/play/static/cover.png b/Moo/play/static/cover.png new file mode 100644 index 0000000..222637b Binary files /dev/null and b/Moo/play/static/cover.png differ diff --git a/Moo/play/static/dark.css b/Moo/play/static/dark.css new file mode 100644 index 0000000..3631a73 --- /dev/null +++ b/Moo/play/static/dark.css @@ -0,0 +1,94 @@ +body.dark { + background-color: black; + color: gray; +} + +body.dark a { + color: silver; +} + +body.dark .border { + border: 1px solid rgba(200,200,200,0.2); +} + +body.dark .overlay { + background-color: black; +} + +body.dark .pill { + background-color: black; +} + +body.dark #artist { + color: white; +} + +body.dark div#player #title.glow { + color: rgba(240, 0, 240, 1); /* purple */ + text-shadow: 0px 0px 24px rgba(240, 0, 240, 1); /* purple */ + + color: rgba(255, 165, 0, 1); /* orange */ + text-shadow: 0px 0px 24px rgba(255, 165, 0, 1); /* orange */ +} + +body.dark div#player #track.glow { + background-color: rgba(240, 0, 240, 0.1); /* purple */ + color: rgba(240, 0, 240, 1); /* purple */ + + color: rgba(255, 165, 0, 1); /* orange */ + background-color: rgba(255, 165, 0, 0.1); /* orange */ +} + +body.dark #tracklist a { + color: gray; +} + +body.dark table#tracklist a { + color: #333; +} + +body.dark table#tracklist div.track_num { + color: #333; +} + +body.dark table#tracklist tr:hover div.track_num { + color: white; +} + +body.dark table#tracklist tr:hover div.title { + background-color: #111; +} + +body.dark table#tracklist tr:hover div.title a { + color: silver; +} + +body.dark table#tracklist tr.active div.title a { + color: white; +} + +body.dark #personnel { + color: #333; +} + +body.dark #player { + background-color: #111; +} + +body.dark table.data th { + border: 1px solid #222; + background-color: #111; + color: white; +} + +body.dark table.data td { + border: 1px solid #222; +} + +body.dark #help, body.dark #index_help { + color: white; +} + +body.dark #index h1 { + color: white; +} \ No newline at end of file diff --git a/Moo/play/static/debug.css b/Moo/play/static/debug.css new file mode 100644 index 0000000..107d59b --- /dev/null +++ b/Moo/play/static/debug.css @@ -0,0 +1,16 @@ +div { + border: 1px solid gray; +} + +div.border { + border: 1px solid white; +} + +table { + border: 1px solid red; + border-radius: 4px; +} + +table.border { + border: 1px solid magenta; +} diff --git a/Moo/play/static/die.png b/Moo/play/static/die.png new file mode 100644 index 0000000..c81e4fd Binary files /dev/null and b/Moo/play/static/die.png differ diff --git a/Moo/play/static/discogs.png b/Moo/play/static/discogs.png new file mode 100644 index 0000000..eda601a Binary files /dev/null and b/Moo/play/static/discogs.png differ diff --git a/Moo/play/static/ffmpeg.png b/Moo/play/static/ffmpeg.png new file mode 100644 index 0000000..4a479d8 Binary files /dev/null and b/Moo/play/static/ffmpeg.png differ diff --git a/Moo/play/static/genius.com.png b/Moo/play/static/genius.com.png new file mode 100644 index 0000000..70ebcfe Binary files /dev/null and b/Moo/play/static/genius.com.png differ diff --git a/Moo/play/static/itunes.png b/Moo/play/static/itunes.png new file mode 100644 index 0000000..30fb901 Binary files /dev/null and b/Moo/play/static/itunes.png differ diff --git a/Moo/play/static/lame.gif b/Moo/play/static/lame.gif new file mode 100644 index 0000000..20d2a5a Binary files /dev/null and b/Moo/play/static/lame.gif differ diff --git a/Moo/play/static/layout.css b/Moo/play/static/layout.css new file mode 100644 index 0000000..2314960 --- /dev/null +++ b/Moo/play/static/layout.css @@ -0,0 +1,111 @@ +a { text-decoration: none } + +a:hover { } + +body { + margin: 0; + padding: 0; + font-family: sans-serif; + text-align: center; + background-color: whitesmoke; +} + +html { + margin: 0; + padding: 0; +} + +kbd { + margin: 8px; + padding: 4px 8px; + border-radius: 4px; + background-color: black; + color: yellow !important; +} + +smcap { + font-size: small; +} + +table { + border-collapse: collapse; +} + +table.data { + text-align: left; + font-size: smaller; + margin: 16px 0; +} +table.data th { + font-size: larger; + padding: 8px; + border: 1px solid silver; + + font-weight: bold; + text-align: center; + + background-color: dimgray; + color: black; +} +table.data td { + vertical-align: top; + padding: 2px 4px; + border: 1px solid silver; +} +table.data tr td:nth-child(1) { + width: 20%; + word-break: break-all +} +table.data tr td:nth-child(2) { word-break: break-all } + +.border { + border: 1px solid silver; +} + +.btn { cursor: pointer } + +.center { align: center; text-align: center; } +.help { cursor: help } +.left { align: left; text-align: left; } + +.pad { padding: 4px } +.pad2 { padding: 8px } +.pad3 { padding: 16px } + +.pill { + display: inline-block; + margin: 0; + margin-right: 4px; + margin-bottom: 8px; + padding: 4px 8px; + border-radius: 4px; + background-color: lightgray; + color: dimgray; +} +.pill img { + height: 18px; + vertical-align: bottom; + filter: grayscale(1); +} +.pill:hover img { + filter: grayscale(0); +} + +.round { border-radius: 4px } +.round2 { border-radius: 8px } +.round3 { border-radius: 16px } + +.hidden { + display: none; +} + +.inverted { /* must be last class */ + background-color: black; + color: white; +} + +#alpha a { + margin-right: 8px; +} + +#heart { margin: 8px } diff --git a/Moo/play/static/media.css b/Moo/play/static/media.css new file mode 100644 index 0000000..d192ba0 --- /dev/null +++ b/Moo/play/static/media.css @@ -0,0 +1,35 @@ +/* Extra small devices */ +@media only screen and (max-width: 600px) { + /* html { border: 8px solid red } */ + div#cover { position: static; margin: 0; padding: 0; width: 100%; } + div#music { position: static; margin: 0; padding: 0; width: 100%; } + div#player { margin: 0; } + div#help, div#related, div#tags { margin: 16px; max-height: 300px; } + div#title { font-size: 24px; } + img#cover { border: none; border-radius: 0; } +} + +/* Small devices (portrait tablets and large phones) */ +@media only screen and (min-width: 600px) { + /* html { border: 8px solid blue } */ + div#cover { position: static; margin: 0; padding: 0; width: 100%; } + div#music { position: static; margin: 0; padding: 0; width: 100%; } + div#player { margin: 0; } + div#related, div#tags { margin: 16px; max-height: 300px; } + div#title { font-size: 24px; } + img#cover { border: none; border-radius: 0; } +} + +/* Medium devices (landscape tablets) */ +@media only screen and (min-width: 768px) { +} + +/* Large devices (laptops/desktops) */ +@media only screen and (min-width: 992px) { + /* html { border: 12px solid orange } */ +} + +/* Extra large devices (large laptops and desktops) */ +@media only screen and (min-width: 1200px) { + /* html { border: 16px solid yellow } */ +} diff --git a/Moo/play/static/moo.ico b/Moo/play/static/moo.ico new file mode 100644 index 0000000..ee75e8b Binary files /dev/null and b/Moo/play/static/moo.ico differ diff --git a/Moo/play/static/moo.png b/Moo/play/static/moo.png new file mode 100644 index 0000000..c853fb6 Binary files /dev/null and b/Moo/play/static/moo.png differ diff --git a/Moo/play/static/music-brainz.png b/Moo/play/static/music-brainz.png new file mode 100644 index 0000000..f19907f Binary files /dev/null and b/Moo/play/static/music-brainz.png differ diff --git a/Moo/play/static/safari.png b/Moo/play/static/safari.png new file mode 100644 index 0000000..50c4b58 Binary files /dev/null and b/Moo/play/static/safari.png differ diff --git a/Moo/play/static/web.png b/Moo/play/static/web.png new file mode 100644 index 0000000..3fa69a7 Binary files /dev/null and b/Moo/play/static/web.png differ diff --git a/Moo/play/static/wikipedia.png b/Moo/play/static/wikipedia.png new file mode 100644 index 0000000..24678df Binary files /dev/null and b/Moo/play/static/wikipedia.png differ diff --git a/Moo/play/static/youtube.png b/Moo/play/static/youtube.png new file mode 100644 index 0000000..e10047e Binary files /dev/null and b/Moo/play/static/youtube.png differ diff --git a/Moo/play/tags.py b/Moo/play/tags.py new file mode 100644 index 0000000..23bc49f --- /dev/null +++ b/Moo/play/tags.py @@ -0,0 +1,77 @@ +''' +Moo Tag Fields +~~~~~~~~~~~~~~ +''' + + +mp3_fields = { + # https://id3.org/id3v2.4.0-frames + 'APIC': 'APIC', + 'COMM': 'comments', + 'COMM::eng': 'URL', + 'COMM::XXX': 'URL', + 'LINK': 'link', + 'MCDI': 'identifier', + 'PCNT': 'play-count', + 'SYLT': 'lyrics-synchronised', + 'TALB': 'album', + 'TBPM': 'bpm', + 'TCOM': 'composer', + 'TCON': 'genre', + 'TCOP': 'copyright', + 'TDRC': 'year', + 'TENC': 'encoder', + 'TEXT': 'lyricist', + 'TFLT': 'file-type', + 'TIPL': 'personnel', + 'TIT1': 'group', + 'TIT2': 'title', + 'TIT3': 'subtitle', + 'TLAN': 'language', + 'TLEN': 'length', + 'TMCL': 'credits', + 'TMED': 'mediatype', + 'TMOO': 'mood', + 'TPE1': 'artist', + 'TPE2': 'album_artist', + 'TPE3': 'conductor', + 'TPE4': 'remix', + 'TPOS': 'disc', + 'TPUB': 'publisher', + 'TRCK': 'track', + 'TRSN': 'radio', + 'TRSO': 'owner', + 'TSSE': 'encoding', + 'TSST': 'set-subtitle', + 'TXXX': 'info', + 'USER': 'terms', + 'USLT': 'lyrics', + 'WCOM': 'commercial', + 'WCOP': 'copyright', + 'WOAF': 'audio-file-url', + 'WOAR': 'artist-url', + 'WOAS': 'audio-source-url', + 'WORS': 'radio-url', + 'WPUB': 'publisher-url', + 'WXXX': 'URL', +} + + +mp4_fields = { + # https://mutagen.readthedocs.io/en/latest/api/mp4.html#mutagen.mp4.MP4Tags + '\xa9ART': 'artist', + '\xa9alb': 'album', + '\xa9cmt': 'comment', + '\xa9day': 'year', + '\xa9gen': 'genre', + '\xa9mvc': 'movement-count', + '\xa9mvi': 'movement-index', + '\xa9nam': 'title', + '\xa9too': 'encoder', + '\xa9wrt': 'composer', + 'aART': 'album_artist', + 'rtng': 'rating', + 'shwm': 'movement', + 'stik': 'media-kind', + 'trkn': 'track', +} diff --git a/Moo/play/templates/album.html b/Moo/play/templates/album.html new file mode 100644 index 0000000..2783116 --- /dev/null +++ b/Moo/play/templates/album.html @@ -0,0 +1,53 @@ +{% extends "layout.html" %} + +{% block body %} + +{% include "control.html" %} + + + +
+ {% include "help.html" %} + {% include "index_body.html" %} + {% include "covers_body.html" %} + {% include "related.html" %} + {% include "cover.html" %} +
+ +
+ {% include "player.html" %} + {% include "classical.html" %} + {% include "tags.html" %} + {% include "metadata.html" %} + {% include "tracklist.html" %} + {% include "personnel.html" %} +
+ +{% endblock %} diff --git a/Moo/play/templates/audio.html b/Moo/play/templates/audio.html new file mode 100644 index 0000000..b964118 --- /dev/null +++ b/Moo/play/templates/audio.html @@ -0,0 +1,50 @@ + + + + + + + + + + +
+ 🔙 + +
{{ control.track_num }}
+
+ 🔜 + + +
diff --git a/Moo/play/templates/bootstrap.html b/Moo/play/templates/bootstrap.html new file mode 100644 index 0000000..2506b09 --- /dev/null +++ b/Moo/play/templates/bootstrap.html @@ -0,0 +1,84 @@ + + + + + + {% block title %}{{ total }}{% endblock %} + + + + + +
+ +{% block body %} + +
+
+ + cover +
+
+ +
+
+ Artist — + Album
+
+
+
+ +
+
+ {% for tnum in metadata | sort %} + + {% endfor %} +
+
+ +
+ + + + +{% endblock %} + +
+ + + + + + + diff --git a/Moo/play/templates/classical.html b/Moo/play/templates/classical.html new file mode 100644 index 0000000..37d218d --- /dev/null +++ b/Moo/play/templates/classical.html @@ -0,0 +1,10 @@ + diff --git a/Moo/play/templates/control.html b/Moo/play/templates/control.html new file mode 100644 index 0000000..51024e6 --- /dev/null +++ b/Moo/play/templates/control.html @@ -0,0 +1,7 @@ + diff --git a/Moo/play/templates/cover.html b/Moo/play/templates/cover.html new file mode 100644 index 0000000..5e13898 --- /dev/null +++ b/Moo/play/templates/cover.html @@ -0,0 +1,3 @@ + diff --git a/Moo/play/templates/covers.html b/Moo/play/templates/covers.html new file mode 100644 index 0000000..4ad70ed --- /dev/null +++ b/Moo/play/templates/covers.html @@ -0,0 +1,7 @@ +{% extends "layout.html" %} + +{% block body %} + +{% include "covers_body.html" %} + +{% endblock %} diff --git a/Moo/play/templates/covers_body.html b/Moo/play/templates/covers_body.html new file mode 100644 index 0000000..bfc291f --- /dev/null +++ b/Moo/play/templates/covers_body.html @@ -0,0 +1,102 @@ +{% if request.path.startswith('/covers') %} +{% set size = '256px' %} +{% else %} +{% set recent = index[:100] %} +{% set classes = "hidden" %} +{% set size = '120px' %} +{% endif %} + + + +
+ + {% if recent %} + +

+ Recently added {{ recent | length }} / + {{ "{:,}".format(index | length) }} + 🦥 +

+ {% for path in recent %} + {% set href = path | replace(base + '/', '') %} + + + {{ path.name }} + {% endfor %} + + {% else %} + +

+ {{ "{:,}".format(index | length) }} + {% if alpha %}"{{ alpha }}"{% endif %} + albums + 🦥 +

+ +

+ A + B + C + D + E + F + G + H + I + J + K + L + M +
+ N + O + P + Q + R + S + T + U + V + W + X + Y + Z +

+ + {% for path in index %} + {% set href = path | replace(base + '/', '') %} + + + {{ path.name }} + {% endfor %} + + {% endif %} + +

+ {{ config.PACKAGE }} + {{ config.VERSION }} + + {{ config.AUTHOR }} +

+ +
diff --git a/Moo/play/templates/debug.html b/Moo/play/templates/debug.html new file mode 100644 index 0000000..c3d7161 --- /dev/null +++ b/Moo/play/templates/debug.html @@ -0,0 +1,3 @@ +{% for item in data %} +{{ item }} +{% endfor %} diff --git a/Moo/play/templates/help.html b/Moo/play/templates/help.html new file mode 100644 index 0000000..e341e1e --- /dev/null +++ b/Moo/play/templates/help.html @@ -0,0 +1,140 @@ +{% if request.path == '/' %} + {% set style="float: right" %} + {% set classes = "border left round" %} +{% else %} + {% set classes = "border hidden left round" %} +{% endif %} + + + + +
+ +

Help h

+ +

+

+ 🦥 + Go to album Index
+
+ + die + Go to random album
+
+ 🔆 + Toggle dimmer
+

+ +

Keyboard shortcuts

+ +

+ h show this help +

+ +

+

cgo to album covers
+
igo to album index
+
rgo to random album
+

+ +

+

1play track 1
+
0play track 10
+
nplay next track
+
pplay previous track
+
tplay random track
+

+

+

upplay previous track
+
downplay next track
+
rightplay next track
+
leftplay previous track
+

+ +

+

mshow more (related) albums
+
cshow classical
+

+ +

+

dtoggle meta-data
+
gtoggle tags
+

+ +

Encoder/codec

+ +

+

+ Safari + Apple Lossless requires Safari
+ +
+ FFmpeg + Encoded by FFmpeg
+ +
+ iTunes + Encoded by iTunes + or whatever
+ +
+ LAME + Encoded by LAME
+

+ +

Track Links

+ +

+

+ web + Go to web link from media tag
+ +
+ Discogs + Go to Discogs album
+ +
+ + Genius.com + Go to Genius.com lyrics
+ +
+ + Internet Archive + Go to Internet Archive search results
+ +
+ + MusicBrainz + Go to MusicBrainz release
+ +
+ YouTube + Go to YouTube video
+ +
? Show this help
+

+ +

+ moo + {{ config.VERSION }} + + {{ config.AUTHOR }} +

+
diff --git a/Moo/play/templates/index.html b/Moo/play/templates/index.html new file mode 100644 index 0000000..6ac4dd8 --- /dev/null +++ b/Moo/play/templates/index.html @@ -0,0 +1,7 @@ +{% extends "layout.html" %} + +{% block body %} + +{% include "index_body.html" %} + +{% endblock %} diff --git a/Moo/play/templates/index_body.html b/Moo/play/templates/index_body.html new file mode 100644 index 0000000..d093d40 --- /dev/null +++ b/Moo/play/templates/index_body.html @@ -0,0 +1,76 @@ +{% if request.path == '/' or request.path.startswith('/index/') %} +{% else %} +{% set classes = "hidden" %} +{% endif %} + + + +
+ +

+ {{ "{:,}".format(index | length) }} + {% if alpha %}"{{ alpha }}"{% endif %} + albums + 🦥 +

+ +

+ Covers + + die random +

+ +

+ A + B + C + D + E + F + G + H + I + J + K + L + M +
+ N + O + P + Q + R + S + T + U + V + W + X + Y + Z +

+ +

+ {% include "help.html" %} +

+ +

+

    + {% for path in index %} + {% set href = path | replace(base + "/", '') %} +
  1. {{ href }}
  2. + {% endfor %} +
+

+ +

+ moo {{ config.VERSION }} + + {{ config.AUTHOR }} +

+ +
diff --git a/Moo/play/templates/info.html b/Moo/play/templates/info.html new file mode 100644 index 0000000..32ccbd1 --- /dev/null +++ b/Moo/play/templates/info.html @@ -0,0 +1,110 @@ +
+ + {% for item in info %} + {{ item[1] }} + {% endfor %} + + {{ track.encoding }} + + {% if track.bitrate %} + + {{ "{} Kbps".format((track.bitrate / 1000) | int) }} + + {% endif %} + + {% if track.encoder %} + {% set encoder = track.encoder %} + {% set enc = track.encoder.lower() %} + {% endif %} + + {% if track.encoder_info %} + {% set encoder = track.encoder_info %} + {% set enc = track.encoder_info.lower() %} + {% endif %} + + {% if encoder and enc.startswith('itunes') %} + + iTunes + {% endif %} + + {% if encoder and enc.startswith('lavf') %} + + FFmpeg + {% endif %} + + {% if encoder and enc.startswith('lame') %} + + LAME + {% endif %} + + {% if track.codec and track.codec.upper() == 'ALAC' %} + + Safari + {% endif %} + + {% if track.URL %} + + URL + {% endif %} + + {% if track.album %} + + Discogs + {% endif %} + + {% if track.artist %} + + Genius.com + {% endif %} + + {% set iaq="https://archive.org/search.php?query=mediatype:%22audio%22" %} + + {% if track.artist %} + {% set iac="creator:%22" + track.artist | urlencode + "%22" %} + {% endif %} + + {% if track.album %} + {% set iat=" ".join(track.album.split()[:3]) %} + {% endif %} + + {% if track.artist or track.album %} + + Internet Archive + {% endif %} + + {% if track.artist %} + + + MusicBrainz + + + Wikipedia + + + YouTube + + {% endif %} + + ? +
diff --git a/Moo/play/templates/layout.html b/Moo/play/templates/layout.html new file mode 100644 index 0000000..cdbb7c9 --- /dev/null +++ b/Moo/play/templates/layout.html @@ -0,0 +1,50 @@ + + + + + + + + + + + + {% block title %} + + {% if track and track.artist %} + + {% if request.path.startswith('/album') %} + + {{ track.artist }} ({{ track.year }}) {{ track.album }} + + {% elif request.path.startswith('/track') %} + + "{{ track.title }}" [{{ track.track }}] + {{ track.album }} ({{ track.year }}) {{ track.artist }} + + {% else %} + moo + {% endif %} + + {% else %} + moo + {% endif %} + + {% endblock %} + + + + + + + + {% block body %}{% endblock %} + + + + + diff --git a/Moo/play/templates/links.html b/Moo/play/templates/links.html new file mode 100644 index 0000000..701977f --- /dev/null +++ b/Moo/play/templates/links.html @@ -0,0 +1,2 @@ + diff --git a/Moo/play/templates/metadata.html b/Moo/play/templates/metadata.html new file mode 100644 index 0000000..3e0a7b2 --- /dev/null +++ b/Moo/play/templates/metadata.html @@ -0,0 +1,16 @@ + + + + + + {% for field in track | sort %} + + + + + {% endfor %} + + diff --git a/Moo/play/templates/personnel.html b/Moo/play/templates/personnel.html new file mode 100644 index 0000000..77cf6ab --- /dev/null +++ b/Moo/play/templates/personnel.html @@ -0,0 +1,20 @@ +{% if personnel | length > 1 %} + + + +
+

Personnel ({{ personnel | length }})

+
    + {% for person in personnel | sort %} +
  • {{ person }}
  • + {% endfor %} +
+
+ +{% endif %} diff --git a/Moo/play/templates/player.html b/Moo/play/templates/player.html new file mode 100644 index 0000000..6d2e715 --- /dev/null +++ b/Moo/play/templates/player.html @@ -0,0 +1,55 @@ + + + +
+ {% include "widget.html" %} + +
+ {{ track.artist or track.album_artist or 'Unknown' }}
+ + + +
{{ track.title }}
+ + {% include "audio.html" %} + {% include "info.html" %} + {% include "links.html" %} +
diff --git a/Moo/play/templates/related.html b/Moo/play/templates/related.html new file mode 100644 index 0000000..1f0a78a --- /dev/null +++ b/Moo/play/templates/related.html @@ -0,0 +1,44 @@ + + + + diff --git a/Moo/play/templates/tags.html b/Moo/play/templates/tags.html new file mode 100644 index 0000000..5c5dc39 --- /dev/null +++ b/Moo/play/templates/tags.html @@ -0,0 +1,18 @@ + + + + + {% if tags %} + {% for item in tags | sort %} + + + + + {% endfor %} + {% else %} + + {% endif %} + diff --git a/Moo/play/templates/tracklist.html b/Moo/play/templates/tracklist.html new file mode 100644 index 0000000..7c4a0b2 --- /dev/null +++ b/Moo/play/templates/tracklist.html @@ -0,0 +1,53 @@ + + + + + {% for key in metadata | sort %} + + + + + {% endfor %} +
{{ loop.index }}
+ +
diff --git a/Moo/play/templates/widget.html b/Moo/play/templates/widget.html new file mode 100644 index 0000000..9883ddc --- /dev/null +++ b/Moo/play/templates/widget.html @@ -0,0 +1,38 @@ + + + + + + + + + + +
+
🔆
+
+ + die + + 🦥 +
diff --git a/Moo/play/utils.py b/Moo/play/utils.py new file mode 100644 index 0000000..39accb8 --- /dev/null +++ b/Moo/play/utils.py @@ -0,0 +1,46 @@ +''' +Moo Utilities +~~~~~~~~~~~~~ +''' + +from math import floor, ceil + + +def h_m(seconds, colons=False): + '''returns h(ours) m(inutes) from seconds''' + out = list() + minute = 60 + hour = 60 * minute + + if seconds >= hour: + hours = floor(seconds / hour) + seconds = seconds % hour + if colons: + out.append(str(hours).zfill(2)) + else: + out.append('{}h'.format(hours)) + + if seconds >= minute: + minutes = ceil(seconds / minute) + if colons: + out.append(str(minutes).zfill(2)) + else: + out.append('{}m'.format(minutes)) + seconds = seconds % minute + else: + if colons: + out.append('00') + + # if seconds: + # if colons: + # out.append(str(seconds).zfill(2)) + # else: + # out.append('{}s'.format(seconds)) + # else: + # if colons: + # out.append('00') + + if colons: + return ":".join(out) + + return " ".join(out) diff --git a/README.md b/README.md new file mode 100644 index 0000000..89a2859 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +Moo +=== + +_An album-forward, read-only music player_ + + +[![moo](screenshot.png)](screenshot.png) + + +Choose your music folder: + +``` +Moo/play/config.py: + +BASE ='/Users/steve/Music' +``` + +Clone the app: + +``` +$ git clone https://github.com/siznax/moo.git +``` + +Start the app: + +``` +$ cd moo +$ ./play.sh +# visit localhost:5000 in your browser +``` + +Progress +-------- + +``` +- [x] Album-forward index, playback +- [x] Play random album or track +- [x] Show related albums, index, covers +- [x] Show help, keyboard shortcuts, etc. +- [ ] Add `jQuery` for AJAX +- [ ] Add `typeahead.js` on index +- [ ] Add `Bootstrap` for responsive +- [ ] Raise flag for DRM'd audio +``` + + +@siznax diff --git a/play.sh b/play.sh new file mode 100755 index 0000000..3152f56 --- /dev/null +++ b/play.sh @@ -0,0 +1,4 @@ +export FLASK_APP=Moo/play/app.py +export FLASK_DEBUG=1 +export FLASK_ENV=development +flask run diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..b704f92 Binary files /dev/null and b/screenshot.png differ diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d514bb2 --- /dev/null +++ b/setup.py @@ -0,0 +1,10 @@ +from setuptools import setup, find_packages + +setup( + name='Moo', + author='Steve Sisney', + author_email='steve@siznax.net', + packages=find_packages(), + install_requires=['flask', 'mutagen', 'unidecode'], + version='0.1' +)