# MousikóFídi
# Copyright (C) 2019,2020 Hristos N. Triantafillou
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import codecs
import datetime
import json
import math
import os
import toml
import urllib
import yaml
from flask import (
Flask,
abort,
flash,
jsonify,
render_template,
redirect,
request,
send_from_directory,
session,
url_for,
)
from mutagen.flac import FLAC, FLACNoHeaderError
from mutagen.mp3 import MP3, HeaderNotFoundError as MP3HeaderNotFoundError
from mutagen.mp4 import MP4, MP4StreamInfoError
from mutagen.oggvorbis import OggVorbis, OggVorbisHeaderError
from pkg_resources import DistributionNotFound, get_distribution
from typing import Union as T
try:
__version__ = get_distribution("MousikoFidi").version
except DistributionNotFound:
__version__ = "Local Dev Build"
app = Flask(__name__)
debug = os.getenv("FLASK_ENV") == "development"
DEFAULT_CFG = """#
# The MousikóFídi configuration file
#
# Documentation: https://mousikofidi.info/config/
#
[library]
# A comma-separated list of directory paths enclosed in double quotes.
# The open and close brackets are required.
dirs = [
"{home}/.config/fidi/audio",
#"{home}/music"
#"{home}/video"
]
[player]
# Use keyboard shortcuts to control the player?
keyboard_controls = true
[playlists]
# Allow deletion of saved playlists?
allow_delete = false
# Path to the directory where playlists get saved to.
dir = "{home}/music/playlists"
# Allow saving of playlists?
allow_save = true
[ui]
# Path to the favicon that MousikóFídi will use.
favicon_path = "/fidi.png"
# Should MousikóFídi show special logos on predefined "holidays"?
holidays = true
# Should MousikóFídi show icons in the UI?
icons = true
# Path to the logo image that MousikóFídi will use.
logo_path = "/fidi.png"
# Should MousikóFídi try and display cover art?
show_cover_art = true
# The name of your MousikóFídi instance; this is what will
# display on the main page of the application.
site_name = "MousikóFídi - Your Music Cloud"
# The default theme.
theme = "light"
[other]
# Keep this secret.
secret_key = "{secret}\""""
MIMETYPES = {
".flac": "audio/flac",
".mkv": "video/webm",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".webm": "video/webm",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
}
LOGOS = {
"*": "fidi.png",
"month:10": "fidi-oct.png",
"month:12": "fidi-dec.png",
"04-20": "fidi-420.png",
"08-09": "fidi-birth.png",
}
THEMES = {
"dark": "/water/dark.standalone",
"light": "/water/light.standalone",
"nes": "/nes/nes",
"terminal": "/terminal",
"terminal-green": "/terminal-green",
"terminal-solarized": "/terminal-solarized",
}
TITLE_LIMIT = 17
AUDIO_FILE_EXTENSIONS = [".mp3", ".ogg", ".flac"]
COMMON_OTHER_EXTENSIONS = [".cue", ".m3u", ".log", ".toc"]
IMAGE_FILE_EXTENSIONS = [".jpg", ".jpeg", ".png"]
VIDEO_FILE_EXTENSIONS = [".mkv", ".mp4", ".webm"]
try:
local_config_file = os.path.join(
os.path.abspath(os.path.dirname(os.getenv("FLASK_APP"))), "fidi.toml"
)
except (AttributeError, TypeError):
local_config_file = None
user_config_path = os.path.join(os.getenv("HOME"), ".config", "fidi")
user_config_file = os.path.join(user_config_path, "config.toml")
dbg = app.logger.debug
err = app.logger.error
wrn = app.logger.warning
def has_extension(filename: str, extensions) -> bool:
# Support a single extension
if type(extensions) != list:
extensions = [extensions]
_, extension = os.path.splitext(filename)
extension = extension.lower()
return extension in extensions
def file_metadata(file_path: str) -> dict:
data_dict = {"cover_art": None}
ft = None
if has_extension(file_path, ".flac"):
try:
flac = FLAC(file_path)
data_string = flac.pprint()
ft = "flac"
# Try to get cover art
if flac.pictures:
p = flac.pictures[0]
data_dict.update(
{
"cover_art": "data:{m};base64,{b}".format(
m=p.mime, b=codecs.encode(p.data, "base64").decode()
)
}
)
except FLACNoHeaderError:
wrn("This file is not a valid flac file: " + file_path)
return {}
elif has_extension(file_path, ".mp3"):
try:
mp3 = MP3(file_path)
data_string = mp3.pprint()
ft = "mp3"
# Try to get cover art
for t in mp3.tags:
if t.startswith("APIC"):
# Is there a better way than all this splitting?!
mimetype = (
mp3.tags.pprint()
.split("\n")[0]
.split("=")[1]
.split(", ")[1]
.split(" ")[1]
.strip("(")
)
filename = (
mp3.tags.pprint()
.split("\n")[0]
.split("=")[1]
.split(", ")[1]
.split(" ")[0]
)
data_dict.update(
{
"cover_art": "data:{m};base64,{b}".format(
m=mimetype,
b=codecs.encode(
mp3.tags["APIC:{}".format(filename)].data, "base64"
).decode(),
).rstrip("\n")
}
)
except MP3HeaderNotFoundError:
wrn("This file is not a valid mp3 file: " + file_path)
return {}
elif has_extension(file_path, ".ogg"):
try:
ogg = OggVorbis(file_path)
data_string = ogg.tags.pprint()
ft = "ogg"
# TODO: Try to get cover art; the "base64" we get from the file seems to be invalid...
# if "metadata_block_picture" in ogg.tags.keys():
# data_dict.update(
# {
# "cover_art": "data:image;base64,{}".format(
# ogg.tags["metadata_block_picture"][0]
# )
# }
# )
except OggVorbisHeaderError:
wrn("This file is not a valid ogg vorbis file: " + file_path)
return {}
elif has_extension(file_path, ".mp4"):
try:
mp4 = MP4(file_path)
data_string = mp4.pprint().strip("\xa9").rstrip("\xa9")
ft = "mp4"
# TODO: Try to get cover art; the "base64" we get from the file seems to be invalid...
# if "covr" in mp4.tags.keys():
# s = "data:image/{t};base64,{base64}"
# if mp4.tags["covr"][0].imageformat == mp4.tags["covr"][0].FORMAT_JPEG:
# t = "jpeg"
# elif mp4.tags["covr"][0].imageformat == mp4.tags["covr"][0].FORMAT_PNG:
# t = "png"
# data_dict.update(
# {
# "cover_art": s.format(
# t=t,
# base64=codecs.encode(
# mp4.tags["covr"][0].capitalize(), "base64"
# ).decode(),
# )
# }
# )
except MP4StreamInfoError:
wrn("This file is not a valid mp4 file: " + file_path)
return {}
else:
data_string = ""
data_list = data_string.split("\n")
_data = data_list[0]
if ft:
# Calculate and inject the track length
if ft == "mp3":
raw_length = _data.split(",")[-1].split()[0]
del data_list[0]
elif ft in ("flac", "mp4"):
raw_length = _data.split(",")[1].split()[0]
del data_list[0]
elif ft == "ogg":
raw_length = str(int(ogg.info.length))
length = str(datetime.timedelta(seconds=math.ceil(float(raw_length)))).split(
"."
)[0]
if length.split(":")[0] == "0":
length = ":".join(length.split(":")[1:])
if length.split(":")[0].startswith("00"):
length = length[1:]
if length.split(":")[0] != "0" and length.split(":")[0].startswith("0"):
length = length[1:]
data_list.append("length=" + length)
for item in data_list:
data_dict.update({item.split("=")[0]: item.split("=")[-1]})
return data_dict
def browse_dir(context: dict, path: str) -> dict:
dbg("Reading Dir: " + path)
try:
dir_items = sorted(os.listdir(path))
except PermissionError:
dbg("Got a PermissionError on '{}'!".format(path))
dir_items = list()
flash(
"""<p class="bold center red">The directory '{}' could not be read due to a permissions error!</p>""".format(
path
)
)
dir_list = []
item_list = []
file_list = []
_audio_list = []
_video_list = []
for i in dir_items:
item_path = os.path.join(path, i)
metadata = get_metadata_dict(item_path)
if is_audio_file(item_path):
dbg("Audio found: " + i)
file_list.append(item_path)
_audio_list.append(file_dict(item_path, metadata, "audio"))
item_list.append(file_dict(item_path, metadata, "audio"))
context["audio_player"] = True
elif is_video_file(item_path):
dbg("Video found: " + i)
file_list.append(item_path)
_video_list.append(file_dict(item_path, metadata, "video"))
item_list.append(file_dict(item_path, metadata, "video"))
context["video_player"] = True
elif os.path.isdir(item_path):
dbg("Dir found: " + i)
dir_list.append(dir_dict(item_path))
audio_list = make_unique_slugs(_audio_list)
video_list = make_unique_slugs(_video_list)
music_dirs = []
for d in context["music_dirs"]:
music_dirs.append(d["full_path"])
context["cover_art"] = select_cover_art(path)
context["file_list"] = file_list
context["item_list"] = item_list
context["audio_list"] = audio_list
context["video_list"] = video_list
context["page_name"] = path
context["page_path"] = breadcrumb_links_from_path(path, music_dirs)
context["playlist_add"] = True
context["playlist_rm"] = False
context["item_type"] = "dir"
context["dir_list"] = dir_list
return context
def browse_file(context: dict, path: str) -> dict:
dbg("Reading File: " + path)
file_name = path.split("/")[-1]
metadata = get_metadata_dict(path)
if is_audio_file(path):
context["item_type"] = "audio"
elif is_video_file(path):
context["item_type"] = "video"
music_dirs = []
for d in context["music_dirs"]:
music_dirs.append(d["full_path"])
for tag in (
"album",
"artist",
"comment",
"cover_art",
"date",
"encoded_by",
"genre",
"length",
"lyrics",
"title",
"track",
"tracktotal",
):
try:
context[tag] = metadata[tag]
except KeyError:
# A given track may have any or none of the above tags.
pass
cover_art = None
if context["show_cover_art"]:
cover_art = context["cover_art"]
context["cover_art"] = cover_art or select_cover_art(path)
context["page_name"] = metadata["title"] or file_name
context["full_path"] = path
context["path"] = path.strip("/")
context["file_name"] = file_name
context["page_path"] = breadcrumb_links_from_path(path, music_dirs)
return context
def config_to_string(config_file: str) -> str:
try:
with open(config_file, "r") as f:
lines = f.readlines()
return "".join(lines)
except FileNotFoundError:
pass
def request_context(config_data: dict) -> dict:
favicon = select_logo(config_data, "favicon_path")
logo = select_logo(config_data, "logo_path")
m = config_data["library"]["dirs"]
music_dirs = paths_list(m)
playlists = (
None
if config_data["playlists"]["dir"] == "None"
else list_playlists(config_data["playlists"]["dir"])
)
try:
icons = session["icons"]
except KeyError:
icons = config_data["ui"]["icons"]
try:
queue = session["queue"]
except KeyError:
queue = []
try:
username = session["username"]
except KeyError:
username = None
css, theme = select_css()
return {
"playlist_delete": config_data["playlists"]["allow_delete"],
"css": css,
"debug": debug,
"show_cover_art": config_data["ui"]["show_cover_art"],
"favicon_path": favicon,
"icons": icons,
"keyboard_controls": config_data["player"]["keyboard_controls"],
"logo_path": logo,
"music_dirs": music_dirs,
"playlist_dir": config_data["playlists"]["dir"],
"playlist_save": config_data["playlists"]["allow_save"],
"playlists": playlists,
"secret_key": config_data["other"]["secret_key"],
"site_name": config_data["ui"]["site_name"],
"queue": queue,
"theme": theme,
"username": username,
}
def gen_secret():
"""
Escaped byte sequences and stray double quotes make TOML parsers mad,
so do this instead of os.urandom() to generate a secret_key.
"""
import random
import string
return "".join(
random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits)
for _ in range(50)
)
def yaml2toml(y: str) -> str:
"""
Given a YAML-formatted string that is a valid MousikóFídi
configuration, return a TOML-formatted equivalent.
"""
t = {
"library": {"dirs": []},
"playlists": {"allow_delete": None, "dir": None, "allow_save": None},
# This was added after the TOML switch
"player": {"keyboard_controls": True},
"ui": {
"show_cover_art": None,
"favicon_path": None,
"holidays": None,
"icons": None,
"logo_path": None,
"site_name": None,
"theme": None,
},
"other": {"secret_key": None},
}
yml = yaml.load(y, Loader=yaml.BaseLoader)
if "music_dirs" in yml.keys():
for d in yml["music_dirs"]:
t["library"]["dirs"].append(d)
if "playlist" in yml.keys():
if "allow_delete" in yml["playlist"].keys():
t["playlists"]["allow_delete"] = bool(yml["playlist"]["allow_delete"])
if "dir" in yml["playlist"].keys():
t["playlists"]["dir"] = yml["playlist"]["dir"]
if "save" in yml["playlist"].keys():
t["playlists"]["allow_save"] = bool(yml["playlist"]["save"])
if "favicon_path" in yml.keys():
t["ui"]["favicon_path"] = yml["favicon_path"]
if "holidays" in yml.keys():
t["ui"]["holidays"] = bool(yml["holidays"])
if "icons" in yml.keys():
t["ui"]["icons"] = bool(yml["icons"])
if "logo_path" in yml.keys():
t["ui"]["logo_path"] = yml["logo_path"]
if "cover_art" in yml.keys():
t["ui"]["show_cover_art"] = bool(yml["cover_art"])
if "site_name" in yml.keys():
t["ui"]["site_name"] = yml["site_name"]
if "theme" in yml.keys():
t["ui"]["theme"] = yml["theme"]
if "secret_key" in yml.keys():
wrn(
"Your secret_key is being replaced, some settings may be forgotten as a one-time event."
)
t["other"]["secret_key"] = gen_secret()
return toml.dumps(t)
def init(
fidi_config=None,
use_config=(
os.getenv("FIDI_CONFIG_PATH")
or os.getenv("FIDI_CONFIG")
or os.getenv("FIDI_CFG_PATH")
or os.getenv("FIDI_CFG")
or None
),
) -> dict:
if use_config:
fidi_config = use_config
possible_toml = fidi_config.replace("yml", "toml").replace("yaml", "toml")
dbg("Reading User-Supplied Config: " + fidi_config)
# If the user supplies a YAML file, try to find a TOML file next
# to it and use that instead. Also show the user a couple warnings.
if (
fidi_config.endswith("yml")
or fidi_config.endswith("yaml")
and os.path.isfile(possible_toml)
):
wrn(
"Please stop trying to use this file, it's likely been converted and can be deleted:"
)
wrn(fidi_config)
wrn("Using a TOML-formatted file found at: " + possible_toml)
fidi_config = possible_toml
# uwsgi blows up if we don't first test local_config_file here..
elif local_config_file and os.path.isfile(local_config_file):
dbg("Reading Module-Local Config: " + local_config_file)
fidi_config = local_config_file
elif os.path.isfile(user_config_file):
dbg("Reading User Config: " + user_config_file)
fidi_config = user_config_file
try:
oldyaml = fidi_config.replace("toml", "yml")
except AttributeError:
# There's no YAML-formatted config file to convert
oldyaml = user_config_file.replace("toml", "yml")
if oldyaml and os.path.isfile(oldyaml):
dbg("Old YAML-formatted config file found, performing conversion...")
newtoml = oldyaml.replace("yml", "toml").replace("yaml", "toml")
fidi_config = newtoml
toml_config = yaml2toml(config_to_string(oldyaml))
os.rename(oldyaml, oldyaml + "-DELETE-ME")
wrn(
"The old YAML-formatted config file has been left behind: "
+ oldyaml
+ "-DELETE-ME"
)
wrn("Please delete this file, MousikóFídi will not warn you about this again.")
with open(newtoml, "w") as f:
f.write("#\n")
f.write(
"# Generated by MousikóFídi from an old YAML-formatted config at: {}\n".format(
datetime.datetime.now().strftime("%c")
)
)
f.write("#\n")
f.write("\n")
for line in toml_config.split("\n"):
f.write(line + "\n")
if not fidi_config:
# No config found or given, create and use a default.
a = os.path.join(user_config_path, "audio")
if not os.path.exists(user_config_path) and not os.path.isdir(user_config_path):
wrn("Creating the fidi config directory...")
os.makedirs(a)
import shutil
wrn("Installing a sample audio file...")
shutil.copy(
os.path.abspath(
os.path.join(os.path.dirname(__file__), "example", "mousikofidi.mp3")
),
a,
)
wrn("No CFG found or given, creating a default for use.")
cfg = DEFAULT_CFG.format(home=os.getenv("HOME"), secret=gen_secret())
with open(user_config_file, "w") as f:
for line in cfg.split("\n"):
f.write(line + "\n")
fidi_config = user_config_file
c = toml.loads(config_to_string(fidi_config))
def bail(name: str) -> dict:
err("The '[{}]' section is missing from your config file!".format(name))
err("Add it or rename your existing file to have a new, valid one generated!")
err("Exiting now because we are unable to do anything...")
return exit()
required_keys = ("library", "player", "playlists", "ui", "other")
for k in required_keys:
if k not in c.keys():
return bail(k)
# TODO: A more DRY way to handle checking for configs
if "dirs" in c["library"].keys():
if not isinstance(c["library"]["dirs"], list):
err(
"Your '[library]' section is missing a valid 'dirs' definition, so you will have no library."
)
c["library"]["dirs"] = []
elif "dirs" not in c["library"].keys():
err(
"Your '[library]' section is missing a 'dirs' definition, so you will have no library."
)
c["library"]["dirs"] = []
if "secret_key" in c["other"].keys():
app.secret_key = c["other"]["secret_key"]
# Sort of obscure the key
c["other"]["secret_key"] = True
else:
err(
"No 'secret_key' was found in the configuration file! Settings functionality will be disabled..."
)
c["other"]["secret_key"] = None
if "keyboard_controls" not in c["player"].keys():
wrn(
"No 'player.keyboard_controls' value was found in the configuration file! Defaulting to on..."
)
c["player"]["keyboard_controls"] = True
if "allow_delete" in c["playlists"].keys():
if isinstance(c["playlists"]["allow_delete"], str):
# Try to parse string versions of bools
if c["playlists"]["allow_delete"].lower() == "true":
c["playlists"]["allow_delete"] = True
else:
c["playlists"]["allow_delete"] = False
else:
wrn(
"No 'playlists.allow_delete' value was found in the configuration file! Defaulting to off..."
)
c["playlists"]["allow_delete"] = False
if "allow_save" in c["playlists"].keys():
if isinstance(c["playlists"]["allow_save"], str):
# Try to parse string versions of bools
if c["playlists"]["allow_save"].lower() == "true":
c["playlists"]["allow_save"] = True
else:
c["playlists"]["allow_save"] = False
else:
wrn(
"No 'playlists.allow_save' value was found in the configuration file! Defaulting to off..."
)
c["playlists"]["allow_save"] = False
if "dir" not in c["playlists"].keys():
wrn("No 'playlists.dir' value was found in the configuration file!")
wrn(
"Note: 'playlists.allow_delete' and 'playlists.allow_save' are forced to off if not already!"
)
c["playlists"]["allow_delete"] = False
c["playlists"]["allow_save"] = False
c["playlists"]["dir"] = "None"
if "favicon_path" not in c["ui"].keys():
wrn(
"No 'ui.favicon_path' value was found in the configuration file! Defaulting to \"/fidi.png\"..."
)
c["ui"]["favicon_path"] = "/fidi.png"
if "holidays" in c["ui"].keys():
if isinstance(c["ui"]["holidays"], str):
# Try to parse string versions of bools
if c["ui"]["holidays"].lower() == "true":
c["ui"]["holidays"] = True
else:
c["ui"]["holidays"] = False
else:
wrn(
"No 'ui.holidays' value was found in the configuration file! Defaulting to on..."
)
c["ui"]["holidays"] = True
if "icons" in c["ui"].keys():
if isinstance(c["ui"]["icons"], str):
# Try to parse string versions of bools
if c["ui"]["icons"].lower() == "true":
c["ui"]["icons"] = True
else:
c["ui"]["icons"] = False
else:
wrn(
"No 'ui.icons' value was found in the configuration file! Defaulting to on..."
)
c["ui"]["icons"] = True
if "logo_path" not in c["ui"].keys():
wrn(
"No 'ui.logo_path' value was found in the configuration file! Defaulting to \"/fidi.png\"..."
)
c["ui"]["logo_path"] = "/fidi.png"
if "show_cover_art" in c["ui"].keys():
if isinstance(c["ui"]["show_cover_art"], str):
# Try to parse string versions of bools
if c["ui"]["show_cover_art"].lower() == "true":
c["ui"]["show_cover_art"] = True
else:
c["ui"]["show_cover_art"] = False
else:
wrn(
"No 'ui.show_cover_art' value was found in the configuration file! Defaulting to on..."
)
c["ui"]["show_cover_art"] = True
if "site_name" not in c["ui"].keys():
wrn(
"No 'ui.site_name' value was found in the configuration file! Defaulting to \"MousikóFídi - Your Music Cloud\"..."
)
c["ui"]["site_name"] = "MousikóFídi - Your Music Cloud"
if "theme" in c["ui"].keys():
theme = c["ui"]["theme"].lower()
if theme in THEMES.keys():
dbg("Using the configured theme: " + theme)
else:
wrn("Unrecognized theme: " + theme)
wrn("Using the default theme: light")
c["ui"]["theme"] = "light"
else:
wrn(
"No 'ui.theme' value was found in the configuration file! Defaulting to light..."
)
c["ui"]["theme"] = "light"
return c
def get_metadata_dict(file_path: str) -> dict:
d = {
"artist": None,
"album": None,
"date": None,
"genre": None,
"length": None,
"title": None,
"title_mobile": None,
"track": None,
"tracktotal": None,
}
metadata = file_metadata(file_path)
if is_audio_file(file_path):
d.update(
{
"album": get_metadata_value(
[
"ALBUM",
"album",
# for mp3
"TALB",
],
metadata,
)
}
)
d.update(
{
"artist": get_metadata_value(
[
# TODO: make this ordering configurable
"ARTIST",
"ARTIST_CREDIT",
"ARTISTSORT",
"artist",
"ALBUMARTIST",
"ALBUMARTIST_CREDIT",
"ALBUMARTISTSORT",
# for mp3
"TPE1",
],
metadata,
)
}
)
if metadata:
d.update({"cover_art": metadata["cover_art"]})
d.update(
{
"date": get_metadata_value(
[
"DATE",
"ORIGINALDATE",
"YEAR",
"date",
# for mp3
"TDRC",
],
metadata,
)
}
)
d.update(
{
"genre": get_metadata_value(
[
"GENRE",
"genre",
# for mp3
"TCON",
],
metadata,
)
}
)
d.update(
{
"title": get_metadata_value(
[
"TITLE",
"title",
# for mp3
"TIT2",
],
metadata,
)
or file_path.split(os.path.sep)[-1]
}
)
d.update({"track": get_metadata_value(["TRACK", "TRACKNUMBER"], metadata)})
tracktotal = get_metadata_value(
["TRACKTOTAL", "TRACKC", "TOTALTRACKS", "TRCK"], metadata
)
if tracktotal and "/" in tracktotal:
# MP3s commonly have the track number and total stored as one value...
trackdata = tracktotal.split("/")
d.update({"track": trackdata[0].strip()})
d.update({"tracktotal": trackdata[1].strip()})
else:
d.update({"tracktotal": tracktotal})
d.update({"comment": get_metadata_value(["COMMENT", "COMM"], metadata)})
d.update({"encoded_by": get_metadata_value(["ENCODED-BY", "TENC"], metadata)})
d.update({"lyrics": get_metadata_value(["LYRICS", "USLT"], metadata)})
elif is_video_file(file_path):
if file_path.endswith(".mp4"):
if metadata:
d.update({"cover_art": metadata["cover_art"]})
for k, v in metadata.items():
if "ART" in k:
d.update({"artist": v})
elif "alb" in k:
d.update({"album": v})
elif "cmt" in k:
d.update({"comment": v})
elif "day" in k:
d.update({"date": v})
elif "enc" in k:
d.update({"encoded_by": v})
elif "gen" in k:
d.update({"genre": v})
elif "lyr" in k:
d.update({"lyrics": v})
elif "nam" in k:
d.update({"title": v})
elif "trkn" in k:
track_list = v.strip("(").strip(")").split(",")
if track_list[0] != "0":
d.update({"track": track_list[0].strip(" ")})
if track_list[1] != "0":
d.update({"tracktotal": track_list[1].strip(" ")})
elif os.path.isdir(file_path):
# We don't try to find embedded images in directories...
d.update({"cover_art": None})
if d["title"] is None:
d["title"] = file_path.split(os.path.sep)[-1]
if len(d["title"].split()) == 1 and len(d["title"]) > TITLE_LIMIT:
d["title_mobile"] = d["title"][:TITLE_LIMIT] + "…"
else:
d["title_mobile"] = d["title"]
d.update({"length": get_metadata_value(["length"], metadata)})
return d
def get_metadata_value(key_list: list, metadata: dict) -> T[str, None]:
for key in key_list:
try:
return metadata[key].strip()
except KeyError:
pass
return None
def dir_dict(path: str) -> dict:
return {"name": path.split(os.path.sep)[-1], "path": path.strip("/")}
def file_dict(path: str, metadata: dict, ftype: str, title_limit=TITLE_LIMIT) -> dict:
file_name = path.split(os.path.sep)[-1]
file_name_mobile = path.split(os.path.sep)[-1]
title = metadata["title"]
title_mobile = title
if len(file_name.split()) == 1 and len(file_name) > title_limit:
file_name_mobile = file_name[:title_limit] + "…"
if len(file_name.split()) > 1 and len(file_name) > title_limit:
for chunk in file_name.split():
if len(chunk) > title_limit:
file_name_mobile = file_name[:title_limit] + "…"
if title:
if len(title.split()) == 1 and len(title) > title_limit:
title_mobile = title[:title_limit] + "…"
if len(title.split()) > 1 and len(title) > title_limit:
for chunk in title.split():
if len(chunk) > title_limit:
title_mobile = title[:title_limit] + "…"
return {
"album": metadata["album"],
"artist": metadata["artist"],
"genre": metadata["genre"],
"length": metadata["length"],
"title": title,
"title_mobile": title_mobile,
"track": metadata["track"],
"type": ftype,
"tracktotal": metadata["tracktotal"],
"file_name": file_name,
"file_name_mobile": file_name_mobile,
"file_path": path.strip("/"),
"slug": title_slug(title or file_name),
}
def breadcrumb_links_from_path(path: str, music_dirs: list) -> str:
link_string = ""
path_string = ""
for d in music_dirs:
if path.startswith(d):
_path = d.strip("/")
new_path = path.replace(d, "").strip("/")
dir_list = new_path.split("/")
if dir_list[0]:
link_string += '<a href="{url}?goto={eid}">{path}</a>'.format(
url=url_for(".dir_detail", path=_path),
path=d,
eid=dir_list[0] or path.split(os.path.sep)[-1],
)
else:
link_string += '<a href="{url}">{path}</a>'.format(
url=url_for(".dir_detail", path=_path), path=d
)
path_string += d
count = 0
for dd in dir_list:
if dd:
path_string = os.path.join(path_string, dd)
if os.path.isdir(path_string):
if dd == dir_list[-1]:
link_string += ' / <a href="{url}">{name}</a>'.format(
name=dd,
url=url_for(".dir_detail", path=path_string.strip("/")),
)
else:
link_string += ' / <a href="{url}?goto={eid}">{name}</a>'.format(
eid=dir_list[count + 1].split(os.path.sep)[-1],
name=dd,
url=url_for(".dir_detail", path=path_string.strip("/")),
)
elif os.path.isfile(path_string):
link_string += " / {}".format(dd)
# TODO: only do this if dd?
count += 1
return link_string
def get_playlists(pdir: str) -> list:
plist_contents = []
plists = []
if os.path.isdir(pdir):
for plist in os.listdir(pdir):
ppath = os.path.join(pdir, plist)
if os.path.isfile(ppath):
with open(ppath, "r") as f:
plist_contents = f.readlines()
plists.append(
{
# TODO: is name really needed?
"name": plist.split(".m3u")[0],
"filename": plist,
"count": len(plist_contents),
}
)
return sorted(plists, key=lambda n: n["name"])
return list()
def is_audio_file(file_path: str) -> bool:
return os.path.isfile(file_path) and has_extension(file_path, AUDIO_FILE_EXTENSIONS)
def is_common_file(file_path: str) -> bool:
return os.path.isfile(file_path) and has_extension(
file_path, COMMON_OTHER_EXTENSIONS
)
def is_image_file(file_path: str) -> bool:
return os.path.isfile(file_path) and has_extension(file_path, IMAGE_FILE_EXTENSIONS)
def is_video_file(file_path: str) -> bool:
return os.path.isfile(file_path) and has_extension(file_path, VIDEO_FILE_EXTENSIONS)
def is_valid_path(req_ctx: dict, path: str) -> bool:
_path = path.strip("/").rstrip("/")
abs_path = os.path.abspath(os.path.sep + _path)
for d in req_ctx["music_dirs"]:
full_path = d["full_path"]
if abs_path.startswith(full_path) and (
os.path.isdir(abs_path) or os.path.isfile(abs_path)
):
return True
return False
def list_playlists(playlist_dir: str) -> list:
playlists = []
dbg("Checking playlist dir: " + playlist_dir)
if os.path.isdir(playlist_dir):
contents = os.listdir(playlist_dir)
if contents:
for i in contents:
if i.endswith(".m3u"):
p = os.path.join(playlist_dir, i)
playlists.append(p)
return sorted(playlists)
def make_unique_slugs(item_list: list) -> list:
used_slugs = []
count = 0
slug_extra = 0
for i in item_list:
if "slug" in i.keys():
if i["slug"] in used_slugs:
newslug = i["slug"] + str(slug_extra)
while newslug in used_slugs:
slug_extra += 1
newslug = i["slug"] + str(slug_extra)
item_list[count]["slug"] = newslug
used_slugs.append(newslug)
else:
used_slugs.append(i["slug"])
count += 1
return item_list
def paths_list(music_dirs: list) -> list:
dl = []
for md in music_dirs:
path = md.strip("/")
dl.append({"full_path": os.path.join(os.path.sep, path), "path": path})
return dl
def select_cover_art(path: str) -> str:
if not app.fidiConfig["ui"]["show_cover_art"]:
return None
cover_art = None
images = []
if os.path.isfile(path):
_dir = os.path.dirname(path)
elif os.path.isdir(path):
_dir = path
for filename in os.listdir(_dir):
for ext in IMAGE_FILE_EXTENSIONS:
if filename.endswith(ext):
images.append(filename)
if len(images) == 1:
cover_art = os.path.join(_dir, images[0])
for img in images:
_img = img.lower()
if "cover" in _img or "folder" in _img or "front" in _img or "cover" in _img:
cover_art = os.path.join(_dir, img)
break
if cover_art:
return url_for(".serve_file", path=cover_art.strip("/"))
elif images:
return url_for(".serve_file", path=os.path.join(_dir, images[0]).strip("/"))
dbg("No cover art found for dir: " + path)
def select_logo(config: dict, item: str, fakenow=None) -> str:
# Don't spoil a user's custom settings
if item == "favicon_path":
if config["ui"]["favicon_path"] != "/fidi.png":
favicon = config["ui"]["favicon_path"]
if debug and favicon.startswith("/"):
return "/static" + favicon
return favicon
# Same here
if item == "logo_path":
if config["ui"]["logo_path"] != "/fidi.png":
logo = config["ui"]["logo_path"]
if debug and logo.startswith("/"):
return "/static" + logo
return logo
if config["ui"]["holidays"]:
logo = config["ui"]["logo_path"]
if fakenow:
now = fakenow
else:
now = datetime.datetime.now()
for logo_date in LOGOS.keys():
if "-" in logo_date:
sdate = logo_date.split("-")
if len(sdate) == 2:
month, date = sdate
today_month, today_date = now.strftime("%m-%d").split("-")
if month == today_month and date == today_date:
dbg(
"Activating holiday {item} for '{date}'!".format(
item=item, date=logo_date
)
)
logo = "/" + LOGOS[logo_date]
elif ":" in logo_date:
date_type, date = logo_date.split(":")
if date_type == "day":
if date == now.strftime("%d"):
dbg(
"Activating holiday {item} for '{date}'!".format(
item=item, date=logo_date
)
)
logo = "/" + LOGOS[logo_date]
elif date_type == "month":
if date == now.strftime("%m"):
dbg(
"Activating holiday {item} for '{date}'!".format(
item=item, date=logo_date
)
)
logo = "/" + LOGOS[logo_date]
elif logo_date == "*":
logo = "/" + LOGOS[logo_date]
if debug:
logo = "/static" + logo
return logo
else:
# No holidays!
if debug:
return "/static" + config["ui"][item]
return config["ui"][item]
def select_css() -> tuple:
if debug:
path = "/static/"
else:
path = "/"
try:
theme = session["theme"]
except KeyError:
theme = app.fidiConfig["ui"]["theme"]
if theme in THEMES.keys():
css = [
path + "css/normalize.css",
path + "fa/css/fontawesome.css",
path + "fa/css/solid.css",
path + "css" + THEMES[theme] + ".css",
path + "css/fidi.css",
]
else:
css = [
path + "css/normalize.css",
path + "fa/css/fontawesome.css",
path + "fa/css/solid.css",
path + "css/fidi.css",
theme,
]
theme = "custom"
if theme == "nes":
css.append(path + "css/fidi-nes.css")
return css, theme
def title_slug(title: str, slug_limit=20) -> str:
return "".join(thing for thing in title if thing.isalnum()).lower()[:slug_limit]
app.fidiConfig = init()
# Begin routes
@app.errorhandler(404)
def not_found(e):
c = request_context(app.fidiConfig)
c["code"] = 404
c["error_text"] = "The page you requested does not exist!"
c["page_name"] = "404 Not Found"
return (render_template("error.html", **c), c["code"])
@app.errorhandler(500)
def internal_server_error(e):
c = request_context(app.fidiConfig)
c["code"] = 500
c[
"error_text"
] = "A programming error has occured. Check the application log for more information."
c["page_name"] = "Internal Server Error"
return (render_template("error.html", **c), c["code"])
@app.route("/")
def index():
c = request_context(app.fidiConfig)
c["page_name"] = "Welcome"
c["plists"] = get_playlists(c["playlist_dir"])
return render_template("index.html", **c)
@app.route("/about")
def about():
c = request_context(app.fidiConfig)
c["page_name"] = "About MousikóFídi"
c["version"] = __version__
if __version__ != "Local Dev Build":
url = "https://git.sr.ht/~hristoast/mousikofidi/tree/" + __version__.replace(
"-devel", ""
)
else:
url = None
c["version_url"] = url
return render_template("about.html", **c)
@app.route("/browse")
def browse():
c = request_context(app.fidiConfig)
c["dir_list"] = c["music_dirs"]
c["page_name"] = "Media Dirs"
c["plists"] = get_playlists(c["playlist_dir"])
c["top_link"] = True
return render_template("dirs.html", **c)
@app.route("/browse/<path:path>")
def dir_detail(path):
_c = request_context(app.fidiConfig)
full_path = os.path.join(os.path.sep, path)
if not is_valid_path(_c, full_path):
return abort(404)
if os.path.isfile(full_path):
c = browse_file(_c, full_path)
elif os.path.isdir(full_path):
c = browse_dir(_c, full_path)
c["top_link"] = True
else:
abort(404)
c["link_button"] = True
return render_template("dir_detail.html", **c)
@app.route("/queue")
def queue():
c = request_context(app.fidiConfig)
c["page_name"] = "Your Queue"
c["playlist_save"] = app.fidiConfig["playlists"]["allow_save"]
c["top_link"] = True
return render_template("queue.html", **c)
@app.route("/playlists")
def playlists():
c = request_context(app.fidiConfig)
c["page_name"] = "Playlists"
c["plists"] = get_playlists(c["playlist_dir"])
c["top_link"] = True
return render_template("playlists.html", **c)
@app.route("/playlist/<name>")
def playlist_detail(name):
c = request_context(app.fidiConfig)
file_list = []
audio_list = []
video_list = []
_audio_list = []
_video_list = []
bunk_tracks = []
plist_file = os.path.join(c["playlist_dir"], name + ".m3u")
if os.path.isfile(plist_file):
with open(plist_file) as f:
playlist_items = f.readlines()
for _i in playlist_items:
i = _i.rstrip("\n")
dbg("playlist item: " + i)
if is_audio_file(i):
metadata = get_metadata_dict(i)
file_list.append(i)
_audio_list.append(file_dict(i, metadata, "audio"))
elif is_video_file(i):
metadata = get_metadata_dict(i)
_video_list.append(file_dict(i, metadata, "video"))
file_list.append(i)
else:
if i and not is_common_file(i) and not is_image_file(i):
bunk_tracks.append(i)
else:
return abort(404)
if bunk_tracks:
err(
"The following items in this playlist could not be loaded because they are not valid audio or video:"
)
for t in bunk_tracks:
err(t)
if _audio_list:
audio_list = make_unique_slugs(_audio_list)
if _video_list:
video_list = make_unique_slugs(_video_list)
c["link_button"] = True
c["bunk_tracks"] = bunk_tracks
c["file_list"] = file_list
c["audio_list"] = audio_list
c["video_list"] = video_list
c["page_name"] = "Playlist: " + name
c["playlist_add"] = True
c["playlist_detail"] = True
c["playlist_name"] = name
c["top_link"] = True
return render_template("playlist.html", **c)
@app.route("/playlist/<name>/delete", methods=("GET", "POST"))
def playlist_delete(name):
c = request_context(app.fidiConfig)
if not c["playlist_delete"]:
return about(404)
c["playlist_delete"] = c["playlist_delete"]
c["playlist_name"] = name
if request.method == "POST":
c["page_name"] = "Playlist Deleted!"
c["playlist_name"] = name
plist_file = os.path.join(c["playlist_dir"], name + ".m3u")
os.remove(plist_file)
flash(
'<p class="bold center notify-bg-blue" style="font-size: 1.2em;">The playlist "{}" has been deleted!</p>'.format(
name
)
)
return redirect(url_for(".playlists"))
c["page_name"] = "Delete Playlist: {} ?".format(name)
return render_template("playlist_delete.html", **c)
@app.route("/serve/<path:path>")
def serve_file(path):
_c = request_context(app.fidiConfig)
if not is_valid_path(_c, path):
return abort(404)
_path = os.path.sep + urllib.parse.unquote(path).rstrip("/")
dirname = os.path.sep.join((_path.split(os.path.sep)[:-1]))
filename = _path.split(os.path.sep)[-1]
try:
payload = send_from_directory(
dirname, filename, mimetype=MIMETYPES[filename.split(".")[-1]]
)
except KeyError:
payload = send_from_directory(dirname, filename)
return payload
@app.route("/settings")
def settings():
c = request_context(app.fidiConfig)
themes = []
for theme, path in THEMES.items():
d = {"name": theme}
if theme == "nes":
d.update({"proper": theme.upper()})
else:
d.update({"proper": theme.capitalize()})
themes.append(d)
c["themes"] = themes
c["page_name"] = "Settings"
return render_template("settings.html", **c)
@app.route("/settings/edit", methods=("POST",))
def settings_edit():
_icons = request.form.get("icons")
_theme = request.form.get("theme")
if _theme:
session["theme"] = _theme
if _icons == "disabled":
dbg("DISABLING")
session["icons"] = False
dbg(str(session["icons"]))
elif _icons == "enabled":
dbg("ENABLING")
session["icons"] = True
dbg(str(session["icons"]))
return redirect(url_for(".settings"))
@app.route("/test-js")
def test_js():
_audio_list = []
_video_list = []
audio_list = []
video_list = []
example_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "example"))
single_file_e = None
if os.path.isdir(example_dir):
for a in (
"fake.flac",
"fake.mp3",
"fake.ogg",
"real.flac",
"real.mp3",
"real.ogg",
):
path = os.path.join(example_dir, a)
m = get_metadata_dict(path)
d = file_dict(path, m, "audio")
_audio_list.append(d)
_audio_list.append(d)
for v in ("fake.mp4", "fake.webm", "real.mp4", "real.webm"):
path = os.path.join(example_dir, v)
m = get_metadata_dict(path)
d = file_dict(path, m, "video")
_video_list.append(d)
_video_list.append(d)
if _audio_list:
audio_list = make_unique_slugs(_audio_list)
single_file_e = audio_list[0]["file_path"]
if _video_list:
video_list = make_unique_slugs(_video_list)
c = request_context(app.fidiConfig)
c.update(
{
"page_name": "Javascript Tests Page!",
"audio_list": audio_list,
"video_list": video_list,
"single_file_e": single_file_e,
}
)
return render_template("test_js.html", **c)
@app.route("/manifest.webmanifest")
def webmanifest():
c = request_context(app.fidiConfig)
return jsonify(
{
"name": c["site_name"],
"start_url": ".",
"display": "standalone",
"background_color": "#000" if c["theme"] == "dark" else "#fff",
"description": "A MousikóFídi instance called: " + c["site_name"],
# TODO: Don't hardcode the size or type
"icons": [{"src": c["logo_path"], "sizes": "416x416", "type": "image/png"}],
}
)
# API routes
@app.route("/api/v1/metadata/<path:path>")
def metadata_for_path(path):
c = request_context(app.fidiConfig)
full_path = os.path.join(os.path.sep, path)
if not is_valid_path(c, full_path):
return jsonify({"error": "404: Not Found"}), 404
return jsonify(get_metadata_dict(full_path))
@app.route("/api/v1/playlist/<name>")
def playlist_detail_as_json(name):
c = request_context(app.fidiConfig)
audio_list = []
video_list = []
bunk_tracks = []
plist_file = os.path.join(c["playlist_dir"], name + ".m3u")
if os.path.isfile(plist_file):
with open(plist_file) as f:
playlist_items = f.readlines()
for _i in playlist_items:
i = _i.rstrip("\n")
dbg("playlist item: " + i)
if is_audio_file(i):
audio_list.append(i.strip("/"))
elif is_video_file(i):
video_list.append(i.strip("/"))
else:
if i and not is_image_file(i):
bunk_tracks.append(i)
else:
return jsonify({"error": "404: Not Found"}), 404
if len(bunk_tracks) > 0:
err('Bunk tracks detected on the "{}" playlist:'.format(name))
for t in bunk_tracks:
err(t)
else:
bunk_tracks = None
return jsonify({"audio": audio_list, "video": video_list, "error": bunk_tracks})
@app.route("/api/v1/playlists")
def playlists_as_json():
return jsonify(get_playlists(request_context(app.fidiConfig)["playlist_dir"]))
@app.route("/api/v1/queue/<cmd>", methods=["POST"])
def queuectl(cmd):
c = request_context(app.fidiConfig)
link = request.host_url + "playlist/"
name = None
if cmd == "save" and c["playlist_save"]:
allowed_chars = (" ", "_", "+")
playlist_dir = c["playlist_dir"]
data = json.loads(request.data.decode())
__name = (
data.get("name") or datetime.datetime.now().strftime("%Y%m%d%H%m%S%f")[0:-4]
)
_name = " ".join(__name.split())
name = "".join(s for s in _name if s.isalnum() or s in allowed_chars).strip(" ")
link = link + name
queue = data.get("queue")
audio_queue = queue["audio"]
video_queue = queue["video"]
playlist_file_full_path = os.path.join(playlist_dir, name + ".m3u")
with open(playlist_file_full_path, "w") as f:
if audio_queue:
for path in audio_queue.values():
f.write(os.path.sep + path + "\n")
if video_queue:
for path in video_queue.values():
f.write(os.path.sep + path + "\n")
return jsonify({"link": link, "name": name})