diff --git a/.gitignore b/.gitignore index 63e92ba9..48a2fc0d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ dist/ songs/ qrcode.png .DS_Store +**build +**dist +**logs* diff --git a/pikaraoke/__init__.py b/pikaraoke/__init__.py index c5828b0a..1af85b96 100644 --- a/pikaraoke/__init__.py +++ b/pikaraoke/__init__.py @@ -1,13 +1,30 @@ from pikaraoke.karaoke import Karaoke -from pikaraoke.lib.get_platform import get_platform +from pikaraoke.lib.get_platform import Platform, get_platform +from pikaraoke.lib.utils import ( + PiKaraokeServer, + filename_from_path, + get_current_app, + hash_dict, + is_admin, + translate, + url_escape, +) from pikaraoke.version import __version__ PACKAGE = __package__ VERSION = __version__ __all__ = [ - "VERSION", - "PACKAGE", - Karaoke.__name__, + filename_from_path.__name__, + get_current_app.__name__, get_platform.__name__, + hash_dict.__name__, + is_admin.__name__, + Karaoke.__name__, + "PACKAGE", + PiKaraokeServer.__name__, + Platform.__name__, + translate.__name__, + url_escape.__name__, + "VERSION", ] diff --git a/pikaraoke/app.py b/pikaraoke/app.py index 5e7ddd90..9e062513 100644 --- a/pikaraoke/app.py +++ b/pikaraoke/app.py @@ -1,960 +1,138 @@ -import argparse -import datetime -import hashlib -import json import logging -import os import signal -import subprocess -import sys -import threading -import time +import webbrowser +from contextlib import contextmanager +from urllib.parse import quote import cherrypy -import flask_babel -import psutil -from flask import ( - Flask, - flash, - make_response, - redirect, - render_template, - request, - send_file, - url_for, -) +import flask +from flask import Flask, request from flask_babel import Babel -from flask_paginate import Pagination, get_page_parameter -from selenium import webdriver -from selenium.webdriver.chrome.options import Options -from selenium.webdriver.chrome.service import Service -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait -from pikaraoke import VERSION, karaoke +from pikaraoke import PiKaraokeServer, filename_from_path +from pikaraoke.config import ConfigType from pikaraoke.constants import LANGUAGES -from pikaraoke.lib.get_platform import get_platform, is_raspberry_pi +from pikaraoke.karaoke import Karaoke +from pikaraoke.lib.get_platform import get_platform +from pikaraoke.lib.logger import configure_logger +from pikaraoke.lib.parse_args import parse_args +from pikaraoke.routes.admin_routes import admin_bp +from pikaraoke.routes.auth_routes import auth_bp +from pikaraoke.routes.file_routes import file_bp +from pikaraoke.routes.home_routes import home_bp +from pikaraoke.routes.karaoke_routes import karaoke_bp -try: - from urllib.parse import quote, unquote -except ImportError: - from urllib import quote, unquote +logger = logging.getLogger(__name__) -_ = flask_babel.gettext +def create_app(admin_pw: str, karaoke: Karaoke, config_class: ConfigType = ConfigType.DEVELOPMENT): + # Handle sigterm, apparently cherrypy won't shut down without explicit handling + signal.signal(signal.SIGTERM, lambda signum, stack_frame: karaoke.stop()) -app = Flask(__name__) -app.secret_key = os.urandom(24) -app.jinja_env.add_extension("jinja2.ext.i18n") -app.config["BABEL_TRANSLATION_DIRECTORIES"] = "translations" -app.config["JSON_SORT_KEYS"] = False -babel = Babel(app) -site_name = "PiKaraoke" -admin_password = None -raspberry_pi = is_raspberry_pi() -linux = get_platform() == "linux" + app: PiKaraokeServer = flask.Flask(__name__) + app.config.from_object(config_class.value) # Load config + # Initialize extensions + app.jinja_env.add_extension("jinja2.ext.i18n") + babel = Babel(app) + babel.init_app(app) -def filename_from_path(file_path, remove_youtube_id=True): - rc = os.path.basename(file_path) - rc = os.path.splitext(rc)[0] - if remove_youtube_id: - try: - rc = rc.split("---")[0] # removes youtube id if present - except TypeError: - # more fun python 3 hacks - rc = rc.split("---".encode("utf-8", "ignore"))[0] - return rc + # Define application-specific attributes + app.karaoke = karaoke + app.platform = get_platform() + if admin_pw: + app.config["ADMIN_PASSWORD"] = admin_pw -def arg_path_parse(path): - if type(path) == list: - return " ".join(path) - else: - return path - - -def url_escape(filename): - return quote(filename.encode("utf8")) - - -def hash_dict(d): - return hashlib.md5( - json.dumps(d, sort_keys=True, ensure_ascii=True).encode("utf-8", "ignore") - ).hexdigest() - - -def is_admin(): - if admin_password == None: - return True - if "admin" in request.cookies: - a = request.cookies.get("admin") - if a == admin_password: - return True - return False - - -@babel.localeselector -def get_locale(): - """Select the language to display the webpage in based on the Accept-Language header""" - return request.accept_languages.best_match(LANGUAGES.keys()) - - -@app.route("/") -def home(): - return render_template( - "home.html", - site_title=site_name, - title="Home", - transpose_value=k.now_playing_transpose, - admin=is_admin(), - ) - - -@app.route("/auth", methods=["POST"]) -def auth(): - d = request.form.to_dict() - p = d["admin-password"] - if p == admin_password: - resp = make_response(redirect("/")) - expire_date = datetime.datetime.now() - expire_date = expire_date + datetime.timedelta(days=90) - resp.set_cookie("admin", admin_password, expires=expire_date) - # MSG: Message shown after logging in as admin successfully - flash(_("Admin mode granted!"), "is-success") - else: - resp = make_response(redirect(url_for("login"))) - # MSG: Message shown after failing to login as admin - flash(_("Incorrect admin password!"), "is-danger") - return resp - + app.jinja_env.globals.update(filename_from_path=filename_from_path) + app.jinja_env.globals.update(url_escape=quote) -@app.route("/login") -def login(): - return render_template("login.html") + @babel.localeselector + def get_locale() -> str | None: + """Select the language to display the webpage in based on the Accept-Language header""" + return request.accept_languages.best_match(LANGUAGES.keys()) + # Register blueprints or routes here + app.register_blueprint(home_bp) + app.register_blueprint(auth_bp) + app.register_blueprint(admin_bp) + app.register_blueprint(file_bp) + app.register_blueprint(karaoke_bp) -@app.route("/logout") -def logout(): - resp = make_response(redirect("/")) - resp.set_cookie("admin", "") - flash("Logged out of admin mode!", "is-success") - return resp + return app -@app.route("/nowplaying") -def nowplaying(): +@contextmanager +def start_server(): try: - if len(k.queue) >= 1: - next_song = k.queue[0]["title"] - next_user = k.queue[0]["user"] - else: - next_song = None - next_user = None - rc = { - "now_playing": k.now_playing, - "now_playing_user": k.now_playing_user, - "now_playing_command": k.now_playing_command, - "up_next": next_song, - "next_user": next_user, - "now_playing_url": k.now_playing_url, - "is_paused": k.is_paused, - "transpose_value": k.now_playing_transpose, - "volume": k.volume, - } - rc["hash"] = hash_dict(rc) # used to detect changes in the now playing data - return json.dumps(rc) - except Exception as e: - logging.error("Problem loading /nowplaying, pikaraoke may still be starting up: " + str(e)) - return "" - - -# Call this after receiving a command in the front end -@app.route("/clear_command") -def clear_command(): - k.now_playing_command = None - return "" - - -@app.route("/queue") -def queue(): - return render_template( - "queue.html", queue=k.queue, site_title=site_name, title="Queue", admin=is_admin() - ) - - -@app.route("/get_queue") -def get_queue(): - if len(k.queue) >= 1: - return json.dumps(k.queue) - else: - return json.dumps([]) - - -@app.route("/queue/addrandom", methods=["GET"]) -def add_random(): - amount = int(request.args["amount"]) - rc = k.queue_add_random(amount) - if rc: - flash("Added %s random tracks" % amount, "is-success") - else: - flash("Ran out of songs!", "is-warning") - return redirect(url_for("queue")) - - -@app.route("/queue/edit", methods=["GET"]) -def queue_edit(): - action = request.args["action"] - if action == "clear": - k.queue_clear() - flash("Cleared the queue!", "is-warning") - return redirect(url_for("queue")) - else: - song = request.args["song"] - song = unquote(song) - if action == "down": - result = k.queue_edit(song, "down") - if result: - flash("Moved down in queue: " + song, "is-success") - else: - flash("Error moving down in queue: " + song, "is-danger") - elif action == "up": - result = k.queue_edit(song, "up") - if result: - flash("Moved up in queue: " + song, "is-success") - else: - flash("Error moving up in queue: " + song, "is-danger") - elif action == "delete": - result = k.queue_edit(song, "delete") - if result: - flash("Deleted from queue: " + song, "is-success") - else: - flash("Error deleting from queue: " + song, "is-danger") - return redirect(url_for("queue")) - - -@app.route("/enqueue", methods=["POST", "GET"]) -def enqueue(): - if "song" in request.args: - song = request.args["song"] - else: - d = request.form.to_dict() - song = d["song-to-add"] - if "user" in request.args: - user = request.args["user"] - else: - d = request.form.to_dict() - user = d["song-added-by"] - rc = k.enqueue(song, user) - song_title = filename_from_path(song) - return json.dumps({"song": song_title, "success": rc}) - - -@app.route("/skip") -def skip(): - k.skip() - return redirect(url_for("home")) - - -@app.route("/pause") -def pause(): - k.pause() - return redirect(url_for("home")) - - -@app.route("/transpose/", methods=["GET"]) -def transpose(semitones): - k.transpose_current(int(semitones)) - return redirect(url_for("home")) - - -@app.route("/restart") -def restart(): - k.restart() - return redirect(url_for("home")) - - -@app.route("/volume/") -def volume(volume): - k.volume_change(float(volume)) - return redirect(url_for("home")) - - -@app.route("/vol_up") -def vol_up(): - k.vol_up() - return redirect(url_for("home")) - - -@app.route("/vol_down") -def vol_down(): - k.vol_down() - return redirect(url_for("home")) - - -@app.route("/search", methods=["GET"]) -def search(): - if "search_string" in request.args: - search_string = request.args["search_string"] - if "non_karaoke" in request.args and request.args["non_karaoke"] == "true": - search_results = k.get_search_results(search_string) - else: - search_results = k.get_karaoke_search_results(search_string) - else: - search_string = None - search_results = None - return render_template( - "search.html", - site_title=site_name, - title="Search", - songs=k.available_songs, - search_results=search_results, - search_string=search_string, - ) - - -@app.route("/autocomplete") -def autocomplete(): - q = request.args.get("q").lower() - result = [] - for each in k.available_songs: - if q in each.lower(): - result.append( - {"path": each, "fileName": k.filename_from_path(each), "type": "autocomplete"} - ) - response = app.response_class(response=json.dumps(result), mimetype="application/json") - return response - - -@app.route("/browse", methods=["GET"]) -def browse(): - search = False - q = request.args.get("q") - if q: - search = True - page = request.args.get(get_page_parameter(), type=int, default=1) - - available_songs = k.available_songs - - letter = request.args.get("letter") - - if letter: - result = [] - if letter == "numeric": - for song in available_songs: - f = k.filename_from_path(song)[0] - if f.isnumeric(): - result.append(song) - else: - for song in available_songs: - f = k.filename_from_path(song).lower() - if f.startswith(letter.lower()): - result.append(song) - available_songs = result - - if "sort" in request.args and request.args["sort"] == "date": - songs = sorted(available_songs, key=lambda x: os.path.getctime(x)) - songs.reverse() - sort_order = "Date" - else: - songs = available_songs - sort_order = "Alphabetical" - - results_per_page = 500 - pagination = Pagination( - css_framework="bulma", - page=page, - total=len(songs), - search=search, - record_name="songs", - per_page=results_per_page, - ) - start_index = (page - 1) * (results_per_page - 1) - return render_template( - "files.html", - pagination=pagination, - sort_order=sort_order, - site_title=site_name, - letter=letter, - # MSG: Title of the files page. - title=_("Browse"), - songs=songs[start_index : start_index + results_per_page], - admin=is_admin(), - ) - - -@app.route("/download", methods=["POST"]) -def download(): - d = request.form.to_dict() - song = d["song-url"] - user = d["song-added-by"] - if "queue" in d and d["queue"] == "on": - queue = True - else: - queue = False - - # download in the background since this can take a few minutes - t = threading.Thread(target=k.download_video, args=[song, queue, user]) - t.daemon = True - t.start() - - flash_message = ( - "Download started: '" + song + "'. This may take a couple of minutes to complete. " - ) - - if queue: - flash_message += "Song will be added to queue." - else: - flash_message += 'Song will appear in the "available songs" list.' - flash(flash_message, "is-info") - return redirect(url_for("search")) - - -@app.route("/qrcode") -def qrcode(): - return send_file(k.qr_code_path, mimetype="image/png") - - -@app.route("/logo") -def logo(): - return send_file(k.logo_path, mimetype="image/png") - - -@app.route("/end_song", methods=["GET"]) -def end_song(): - k.end_song() - return "ok" - - -@app.route("/start_song", methods=["GET"]) -def start_song(): - k.start_song() - return "ok" - - -@app.route("/files/delete", methods=["GET"]) -def delete_file(): - if "song" in request.args: - song_path = request.args["song"] - if song_path in k.queue: - flash( - "Error: Can't delete this song because it is in the current queue: " + song_path, - "is-danger", - ) - else: - k.delete(song_path) - flash("Song deleted: " + song_path, "is-warning") - else: - flash("Error: No song parameter specified!", "is-danger") - return redirect(url_for("browse")) - + logger.debug("Starting server...") + cherrypy.engine.start() + yield + finally: + logger.debug("Stopping the server...") + cherrypy.engine.exit() + logger.debug("Server stopped.") -@app.route("/files/edit", methods=["GET", "POST"]) -def edit_file(): - queue_error_msg = "Error: Can't edit this song because it is in the current queue: " - if "song" in request.args: - song_path = request.args["song"] - # print "SONG_PATH" + song_path - if song_path in k.queue: - flash(queue_error_msg + song_path, "is-danger") - return redirect(url_for("browse")) - else: - return render_template( - "edit.html", - site_title=site_name, - title="Song File Edit", - song=song_path.encode("utf-8", "ignore"), - ) - else: - d = request.form.to_dict() - if "new_file_name" in d and "old_file_name" in d: - new_name = d["new_file_name"] - old_name = d["old_file_name"] - if k.is_song_in_queue(old_name): - # check one more time just in case someone added it during editing - flash(queue_error_msg + song_path, "is-danger") - else: - # check if new_name already exist - file_extension = os.path.splitext(old_name)[1] - if os.path.isfile(os.path.join(k.download_path, new_name + file_extension)): - flash( - "Error Renaming file: '%s' to '%s'. Filename already exists." - % (old_name, new_name + file_extension), - "is-danger", - ) - else: - k.rename(old_name, new_name) - flash( - "Renamed file: '%s' to '%s'." % (old_name, new_name), - "is-warning", - ) - else: - flash("Error: No filename parameters were specified!", "is-danger") - return redirect(url_for("browse")) - -@app.route("/splash") -def splash(): - # Only do this on Raspberry Pis - if raspberry_pi: - status = subprocess.run(["iwconfig", "wlan0"], stdout=subprocess.PIPE).stdout.decode( - "utf-8" - ) - text = "" - if "Mode:Master" in status: - # Wifi is setup as a Access Point - ap_name = "" - ap_password = "" - - if os.path.isfile("/etc/raspiwifi/raspiwifi.conf"): - f = open("/etc/raspiwifi/raspiwifi.conf", "r") - - # Override the default values according to the configuration file. - for line in f.readlines(): - line = line.split("#", 1)[0] - if "ssid_prefix=" in line: - ap_name = line.split("ssid_prefix=")[1].strip() - elif "wpa_key=" in line: - ap_password = line.split("wpa_key=")[1].strip() - - if len(ap_password) > 0: - text = [ - f"Wifi Network: {ap_name} Password: {ap_password}", - f"Configure Wifi: {k.url.rpartition(':')[0]}", - ] - else: - text = [f"Wifi Network: {ap_name}", f"Configure Wifi: {k.url.rpartition(':',1)[0]}"] - else: - # You are connected to Wifi as a client - text = "" - else: - # Not a Raspberry Pi - text = "" - - return render_template( - "splash.html", - blank_page=True, - url=k.url, - hostap_info=text, - hide_url=k.hide_url, - hide_overlay=k.hide_overlay, - screensaver_timeout=k.screensaver_timeout, - ) - - -@app.route("/info") -def info(): - url = k.url - - # cpu - cpu = str(psutil.cpu_percent()) + "%" - - # mem - memory = psutil.virtual_memory() - available = round(memory.available / 1024.0 / 1024.0, 1) - total = round(memory.total / 1024.0 / 1024.0, 1) - memory = ( - str(available) + "MB free / " + str(total) + "MB total ( " + str(memory.percent) + "% )" - ) - - # disk - disk = psutil.disk_usage("/") - # Divide from Bytes -> KB -> MB -> GB - free = round(disk.free / 1024.0 / 1024.0 / 1024.0, 1) - total = round(disk.total / 1024.0 / 1024.0 / 1024.0, 1) - disk = str(free) + "GB free / " + str(total) + "GB total ( " + str(disk.percent) + "% )" - - # youtube-dl - youtubedl_version = k.youtubedl_version - - return render_template( - "info.html", - site_title=site_name, - title="Info", - url=url, - memory=memory, - cpu=cpu, - disk=disk, - ffmpeg_version=k.ffmpeg_version, - youtubedl_version=youtubedl_version, - platform=k.platform, - os_version=k.os_version, - is_pi=raspberry_pi, - is_linux=linux, - pikaraoke_version=VERSION, - admin=is_admin(), - admin_enabled=admin_password != None, +def configure_server(app: Flask, port: int): + cherrypy.tree.graft(app, "/") + cherrypy.config.update( + { + "engine.autoreload.on": False, + "log.screen": True, + "server.socket_port": port, + "server.socket_host": "0.0.0.0", + "server.thread_pool": 100, + } ) -# Delay system commands to allow redirect to render first -def delayed_halt(cmd): - time.sleep(1.5) - k.queue_clear() - cherrypy.engine.stop() - cherrypy.engine.exit() - k.stop() - if cmd == 0: - sys.exit() - if cmd == 1: - os.system("shutdown now") - if cmd == 2: - os.system("reboot") - if cmd == 3: - process = subprocess.Popen(["raspi-config", "--expand-rootfs"]) - process.wait() - os.system("reboot") - - -def update_youtube_dl(): - time.sleep(3) - k.upgrade_youtubedl() - - -@app.route("/update_ytdl") -def update_ytdl(): - if is_admin(): - flash( - "Updating youtube-dl! Should take a minute or two... ", - "is-warning", - ) - th = threading.Thread(target=update_youtube_dl) - th.start() - else: - flash("You don't have permission to update youtube-dl", "is-danger") - return redirect(url_for("home")) - - -@app.route("/refresh") -def refresh(): - if is_admin(): - k.get_available_songs() - else: - flash("You don't have permission to shut down", "is-danger") - return redirect(url_for("browse")) - - -@app.route("/quit") -def quit(): - if is_admin(): - flash("Quitting pikaraoke now!", "is-warning") - th = threading.Thread(target=delayed_halt, args=[0]) - th.start() - else: - flash("You don't have permission to quit", "is-danger") - return redirect(url_for("home")) - - -@app.route("/shutdown") -def shutdown(): - if is_admin(): - flash("Shutting down system now!", "is-danger") - th = threading.Thread(target=delayed_halt, args=[1]) - th.start() - else: - flash("You don't have permission to shut down", "is-danger") - return redirect(url_for("home")) - - -@app.route("/reboot") -def reboot(): - if is_admin(): - flash("Rebooting system now!", "is-danger") - th = threading.Thread(target=delayed_halt, args=[2]) - th.start() - else: - flash("You don't have permission to Reboot", "is-danger") - return redirect(url_for("home")) - - -@app.route("/expand_fs") -def expand_fs(): - if is_admin() and raspberry_pi: - flash("Expanding filesystem and rebooting system now!", "is-danger") - th = threading.Thread(target=delayed_halt, args=[3]) - th.start() - elif not raspberry_pi: - flash("Cannot expand fs on non-raspberry pi devices!", "is-danger") - else: - flash("You don't have permission to resize the filesystem", "is-danger") - return redirect(url_for("home")) - - -# Handle sigterm, apparently cherrypy won't shut down without explicit handling -signal.signal(signal.SIGTERM, lambda signum, stack_frame: k.stop()) - - -def get_default_dl_dir(platform): - if raspberry_pi: - return "~/pikaraoke-songs" - elif platform == "windows": - legacy_directory = os.path.expanduser("~\\pikaraoke\\songs") - if os.path.exists(legacy_directory): - return legacy_directory - else: - return "~\\pikaraoke-songs" - else: - legacy_directory = "~/pikaraoke/songs" - if os.path.exists(legacy_directory): - return legacy_directory - else: - return "~/pikaraoke-songs" - - def main(): - platform = get_platform() - default_port = 5555 - default_ffmpeg_port = 5556 - default_volume = 0.85 - default_normalize_audio = False - default_splash_delay = 3 - default_screensaver_delay = 300 - default_log_level = logging.INFO - default_prefer_hostname = False - - default_dl_dir = get_default_dl_dir(platform) - default_youtubedl_path = "yt-dlp" - - # parse CLI args - parser = argparse.ArgumentParser() - - parser.add_argument( - "-p", - "--port", - help="Desired http port (default: %d)" % default_port, - default=default_port, - required=False, - ) - parser.add_argument( - "--window-size", - help="Desired window geometry in pixels, specified as width,height", - default=0, - required=False, - ) - parser.add_argument( - "-f", - "--ffmpeg-port", - help=f"Desired ffmpeg port. This is where video stream URLs will be pointed (default: {default_ffmpeg_port})", - default=default_ffmpeg_port, - required=False, - ) - parser.add_argument( - "-d", - "--download-path", - nargs="+", - help="Desired path for downloaded songs. (default: %s)" % default_dl_dir, - default=default_dl_dir, - required=False, - ) - parser.add_argument( - "-y", - "--youtubedl-path", - nargs="+", - help="Path of youtube-dl. (default: %s)" % default_youtubedl_path, - default=default_youtubedl_path, - required=False, - ) - parser.add_argument( - "-v", - "--volume", - help="Set initial player volume. A value between 0 and 1. (default: %s)" % default_volume, - default=default_volume, - required=False, - ) - parser.add_argument( - "-n", - "--normalize-audio", - help="Normalize volume. May cause performance issues on slower devices (default: %s)" - % default_normalize_audio, - action="store_true", - default=default_normalize_audio, - required=False, - ) - parser.add_argument( - "-s", - "--splash-delay", - help="Delay during splash screen between songs (in secs). (default: %s )" - % default_splash_delay, - default=default_splash_delay, - required=False, - ) - parser.add_argument( - "-t", - "--screensaver-timeout", - help="Delay before the screensaver begins (in secs). (default: %s )" - % default_screensaver_delay, - default=default_screensaver_delay, - required=False, - ) - parser.add_argument( - "-l", - "--log-level", - help=f"Logging level int value (DEBUG: 10, INFO: 20, WARNING: 30, ERROR: 40, CRITICAL: 50). (default: {default_log_level} )", - default=default_log_level, - required=False, - ) - parser.add_argument( - "--hide-url", - action="store_true", - help="Hide URL and QR code from the splash screen.", - required=False, - ) - parser.add_argument( - "--prefer-hostname", - action="store_true", - help=f"Use the local hostname instead of the IP as the connection URL. Use at your discretion: mDNS is not guaranteed to work on all LAN configurations. Defaults to {default_prefer_hostname}", - default=default_prefer_hostname, - required=False, - ) - parser.add_argument( - "--hide-raspiwifi-instructions", - action="store_true", - help="Hide RaspiWiFi setup instructions from the splash screen.", - required=False, - ) - parser.add_argument( - "--hide-splash-screen", - "--headless", - action="store_true", - help="Headless mode. Don't launch the splash screen/player on the pikaraoke server", - required=False, - ) - parser.add_argument( - "--high-quality", - action="store_true", - help="Download higher quality video. Note: requires ffmpeg and may cause CPU, download speed, and other performance issues", - required=False, - ) - parser.add_argument( - "--logo-path", - nargs="+", - help="Path to a custom logo image file for the splash screen. Recommended dimensions ~ 2048x1024px", - default=None, - required=False, - ), - parser.add_argument( - "-u", - "--url", - help="Override the displayed IP address with a supplied URL. This argument should include port, if necessary", - default=None, - required=False, - ), - parser.add_argument( - "-m", - "--ffmpeg-url", - help="Override the ffmpeg address with a supplied URL.", - default=None, - required=False, - ), - parser.add_argument( - "--hide-overlay", - action="store_true", - help="Hide overlay that shows on top of video with pikaraoke QR code and IP", - required=False, - ), - parser.add_argument( - "--admin-password", - help="Administrator password, for locking down certain features of the web UI such as queue editing, player controls, song editing, and system shutdown. If unspecified, everyone is an admin.", - default=None, - required=False, - ), - - args = parser.parse_args() - - if args.admin_password: - admin_password = args.admin_password - - app.jinja_env.globals.update(filename_from_path=filename_from_path) - app.jinja_env.globals.update(url_escape=quote) + args = parse_args() + configure_logger(log_level=logging.DEBUG) # setup/create download directory if necessary - dl_path = os.path.expanduser(arg_path_parse(args.download_path)) - if not dl_path.endswith("/"): - dl_path += "/" - if not os.path.exists(dl_path): - print("Creating download path: " + dl_path) - os.makedirs(dl_path) - - parsed_volume = float(args.volume) - if parsed_volume > 1 or parsed_volume < 0: - # logging.warning("Volume must be between 0 and 1. Setting to default: %s" % default_volume) - print( - f"[ERROR] Volume: {args.volume} must be between 0 and 1. Setting to default: {default_volume}" - ) - parsed_volume = default_volume + download_path = args.download_path.expanduser() + download_path.mkdir(parents=True, exist_ok=True) - # Configure karaoke process - global k - k = karaoke.Karaoke( + karaoke = Karaoke( port=args.port, ffmpeg_port=args.ffmpeg_port, - download_path=dl_path, - youtubedl_path=arg_path_parse(args.youtubedl_path), + download_path=download_path, splash_delay=args.splash_delay, log_level=args.log_level, - volume=parsed_volume, - normalize_audio=args.normalize_audio, + volume=args.volume, hide_url=args.hide_url, hide_raspiwifi_instructions=args.hide_raspiwifi_instructions, hide_splash_screen=args.hide_splash_screen, high_quality=args.high_quality, - logo_path=arg_path_parse(args.logo_path), + logo_path=str(args.logo_path), hide_overlay=args.hide_overlay, screensaver_timeout=args.screensaver_timeout, url=args.url, ffmpeg_url=args.ffmpeg_url, prefer_hostname=args.prefer_hostname, ) - k.upgrade_youtubedl() - # Start the CherryPy WSGI web server - cherrypy.tree.graft(app, "/") - # Set the configuration of the web server - cherrypy.config.update( - { - "engine.autoreload.on": False, - "log.screen": True, - "server.socket_port": int(args.port), - "server.socket_host": "0.0.0.0", - "server.thread_pool": 100, - } + app = create_app( + admin_pw=args.admin_password, karaoke=karaoke, config_class=ConfigType.DEVELOPMENT ) - cherrypy.engine.start() - - # Start the splash screen using selenium - if not args.hide_splash_screen: - if raspberry_pi: - service = Service(executable_path="/usr/bin/chromedriver") - else: - service = None - options = Options() - if args.window_size: - options.add_argument("--window-size=%s" % (args.window_size)) - options.add_argument("--window-position=0,0") + app.karaoke.upgrade_youtubedl() - options.add_argument("--kiosk") - options.add_argument("--start-maximized") - options.add_experimental_option("excludeSwitches", ["enable-automation"]) - driver = webdriver.Chrome(service=service, options=options) - driver.get(f"{k.url}/splash") - driver.add_cookie({"name": "user", "value": "PiKaraoke-Host"}) - # Clicking this counts as an interaction, which will allow the browser to autoplay audio - wait = WebDriverWait(driver, 60) - elem = wait.until(EC.element_to_be_clickable((By.ID, "permissions-button"))) - elem.click() + configure_server(app=app, port=args.port) + configure_logger() # Because Cherrypy configures its logger differently - # Start the karaoke process - k.run() + with start_server(), karaoke: + logger.debug("Server is running.") - # Close running processes when done - if not args.hide_splash_screen: - driver.close() - cherrypy.engine.exit() + # Start the splash screen using selenium + if not args.hide_splash_screen: + url = f"http://{karaoke.ip}:5555/splash" + logger.debug(f"Opening in default browser at {url}") + webbrowser.open(url) - sys.exit() + karaoke.run() if __name__ == "__main__": diff --git a/pikaraoke/config.py b/pikaraoke/config.py new file mode 100644 index 00000000..3888f3c9 --- /dev/null +++ b/pikaraoke/config.py @@ -0,0 +1,39 @@ +import enum +import secrets + + +class Config: + """Base configuration.""" + + SECRET_KEY = secrets.token_bytes(24) + BABEL_TRANSLATION_DIRECTORIES = "translations" + JSON_SORT_KEYS = False + SITE_NAME = "PiKaraoke" + ADMIN_PASSWORD = None + # Add other base settings here + + +class DevelopmentConfig(Config): + """Development configuration.""" + + DEBUG = True + + +class ProductionConfig(Config): + """Production configuration.""" + + DEBUG = False + # Add production-specific settings here + + +# Example environment-specific settings +class TestingConfig(Config): + """Testing configuration.""" + + TESTING = True + + +class ConfigType(enum.Enum): + DEVELOPMENT = DevelopmentConfig + PRODUCTION = ProductionConfig + TESTING = TestingConfig diff --git a/pikaraoke/karaoke.py b/pikaraoke/karaoke.py index 1eac373f..d7fa5798 100644 --- a/pikaraoke/karaoke.py +++ b/pikaraoke/karaoke.py @@ -1,4 +1,3 @@ -import contextlib import json import logging import os @@ -8,42 +7,50 @@ import time from pathlib import Path from queue import Empty, Queue -from subprocess import CalledProcessError, check_output +from subprocess import check_output from threading import Thread +from typing import TypedDict from urllib.parse import urlparse import ffmpeg import qrcode +import yt_dlp from unidecode import unidecode from pikaraoke.lib.file_resolver import FileResolver -from pikaraoke.lib.get_platform import ( - get_ffmpeg_version, - get_os_version, - get_platform, - is_raspberry_pi, - supports_hardware_h264_encoding, -) +from pikaraoke.lib.get_platform import get_platform + +logger = logging.getLogger(__name__) + +YT_DLP_VERSION = yt_dlp.version.__version__ +YT_DLP_CMD = "yt-dlp" + + +class SongQueue(TypedDict): + user: str + file: str + title: str + semitones: float # Support function for reading lines from ffmpeg stderr without blocking -def enqueue_output(out, queue): +def enqueue_output(out, queue: Queue): for line in iter(out.readline, b""): queue.put(line) out.close() -def decode_ignore(input): +def decode_ignore(input: bytes): return input.decode("utf-8", "ignore").strip() class Karaoke: raspi_wifi_config_ip = "10.0.0.1" - raspi_wifi_conf_file = "/etc/raspiwifi/raspiwifi.conf" - raspi_wifi_config_installed = os.path.exists(raspi_wifi_conf_file) + raspi_wifi_conf_file = Path("/etc/raspiwifi/raspiwifi.conf") + raspi_wifi_config_installed = raspi_wifi_conf_file.is_file() - queue = [] - available_songs = [] + queue: list[SongQueue] = [] + available_songs: list[str] = [] # These all get sent to the /nowplaying endpoint for client-side polling now_playing = None @@ -57,36 +64,26 @@ class Karaoke: is_paused = True process = None qr_code_path = None - base_path = os.path.dirname(__file__) volume = None loop_interval = 500 # in milliseconds - default_logo_path = os.path.join(base_path, "logo.png") + base_path = Path(__file__).parent screensaver_timeout = 300 # in seconds ffmpeg_process = None - ffmpeg_log = None - ffmpeg_version = get_ffmpeg_version() - supports_hardware_h264_encoding = supports_hardware_h264_encoding() - normalize_audio = False - - raspberry_pi = is_raspberry_pi() - os_version = get_os_version() def __init__( self, + logo_path: Path, port=5555, ffmpeg_port=5556, - download_path="/usr/lib/pikaraoke/songs", + download_path: Path = "/usr/lib/pikaraoke/songs", hide_url=False, hide_raspiwifi_instructions=False, hide_splash_screen=False, high_quality=False, volume=0.85, - normalize_audio=False, log_level=logging.DEBUG, splash_delay=2, - youtubedl_path="/usr/local/bin/yt-dlp", - logo_path=None, hide_overlay=False, screensaver_timeout=300, url=None, @@ -94,6 +91,7 @@ def __init__( prefer_hostname=True, ): # override with supplied constructor args if provided + self.logo_path = logo_path self.port = port self.ffmpeg_port = ffmpeg_port self.hide_url = hide_url @@ -103,9 +101,6 @@ def __init__( self.high_quality = high_quality self.splash_delay = int(splash_delay) self.volume = volume - self.normalize_audio = normalize_audio - self.youtubedl_path = youtubedl_path - self.logo_path = self.default_logo_path if logo_path == None else logo_path self.hide_overlay = hide_overlay self.screensaver_timeout = screensaver_timeout self.url_override = url @@ -114,42 +109,40 @@ def __init__( # other initializations self.platform = get_platform() self.screen = None + self.youtubedl_version = self.read_youtubedl_version() - logging.basicConfig( - format="[%(asctime)s] %(levelname)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - level=int(log_level), + # Get /tmp dir + try: + fr = FileResolver("logo.png") + self._tmp_dir = fr.tmp_dir + except Exception as e: + logger.error("Failed to find /tmp dir. Using '/tmp/pikaraoke'") + self._tmp_dir = Path("/tmp/pikaraoke") + + self._tmp_dir.parent.mkdir(parents=True, exist_ok=True) + + logger.debug( + f"http port: {self.port}\n" + f"ffmpeg port {self.ffmpeg_port}\n" + f"hide URL: {self.hide_url}\n" + f"prefer hostname: {self.prefer_hostname}\n" + f"url override: {self.url_override}\n" + f"hide RaspiWiFi instructions: {self.hide_raspiwifi_instructions}\n" + f"headless (hide splash): {self.hide_splash_screen}\n" + f"splash_delay: {self.splash_delay}\n" + f"screensaver_timeout: {self.screensaver_timeout}\n" + f"high quality video: {self.high_quality}\n" + f"download path: {self.download_path}\n" + f"default volume: {self.volume}\n" + f"logo path: {self.logo_path}\n" + f"log_level: {log_level}\n" + f"hide overlay: {self.hide_overlay}\n" + f"base_path: {self.base_path}\n" + f"tmp dir: {self._tmp_dir}\n" ) - logging.debug( - f""" - http port: {self.port} - ffmpeg port {self.ffmpeg_port} - hide URL: {self.hide_url} - prefer hostname: {self.prefer_hostname} - url override: {self.url_override} - hide RaspiWiFi instructions: {self.hide_raspiwifi_instructions} - headless (hide splash): {self.hide_splash_screen} - splash_delay: {self.splash_delay} - screensaver_timeout: {self.screensaver_timeout} - high quality video: {self.high_quality} - download path: {self.download_path} - default volume: {self.volume} - normalize audio: {self.normalize_audio} - youtube-dl path: {self.youtubedl_path} - logo path: {self.logo_path} - log_level: {log_level} - hide overlay: {self.hide_overlay} - - platform: {self.platform} - os version: {self.os_version} - ffmpeg version: {self.ffmpeg_version} - hardware h264 encoding: {self.supports_hardware_h264_encoding} - youtubedl-version: {self.get_youtubedl_version()} -""" - ) # Generate connection URL and QR code, - if self.raspberry_pi: + if self.platform.is_rpi(): # retry in case pi is still starting up # and doesn't have an IP yet (occurs when launched from /etc/rc.local) end_time = int(time.time()) + 30 @@ -158,16 +151,16 @@ def __init__( addresses = addresses_str.split(" ") self.ip = addresses[0] if not self.is_network_connected(): - logging.debug("Couldn't get IP, retrying....") + logger.debug("Couldn't get IP, retrying....") else: break else: - self.ip = self.get_ip() + self.ip = self._get_ip() - logging.debug("IP address (for QR code and splash screen): " + self.ip) + logger.debug("IP address (for QR code and splash screen): " + self.ip) if self.url_override != None: - logging.debug("Overriding URL with " + self.url_override) + logger.debug("Overriding URL with " + self.url_override) self.url = self.url_override else: if self.prefer_hostname: @@ -184,14 +177,11 @@ def __init__( # get songs from download_path self.get_available_songs() - - self.get_youtubedl_version() - self.generate_qr_code() # Other ip-getting methods are unreliable and sometimes return 127.0.0.1 # https://stackoverflow.com/a/28950776 - def get_ip(self): + def _get_ip(self): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: # doesn't even have to be reachable @@ -205,7 +195,7 @@ def get_ip(self): def get_raspi_wifi_conf_vals(self): """Extract values from the RaspiWiFi configuration file.""" - f = open(self.raspi_wifi_conf_file, "r") + f = self.raspi_wifi_conf_file.open() # Define default values. # @@ -228,39 +218,11 @@ def get_raspi_wifi_conf_vals(self): return (server_port, ssid_prefix, ssl_enabled) - def get_youtubedl_version(self): - self.youtubedl_version = ( - check_output([self.youtubedl_path, "--version"]).strip().decode("utf8") - ) - return self.youtubedl_version - - def upgrade_youtubedl(self): - logging.info("Upgrading youtube-dl, current version: %s" % self.youtubedl_version) - try: - output = ( - check_output([self.youtubedl_path, "-U"], stderr=subprocess.STDOUT) - .decode("utf8") - .strip() - ) - except CalledProcessError as e: - output = e.output.decode("utf8") - logging.info(output) - if "You installed yt-dlp with pip or using the wheel from PyPi" in output: - try: - logging.info("Attempting youtube-dl upgrade via pip3...") - output = check_output(["pip3", "install", "--upgrade", "yt-dlp"]).decode("utf8") - except FileNotFoundError: - logging.info("Attempting youtube-dl upgrade via pip...") - output = check_output(["pip", "install", "--upgrade", "yt-dlp"]).decode("utf8") - logging.info(output) - self.get_youtubedl_version() - logging.info("Done. New version: %s" % self.youtubedl_version) - def is_network_connected(self): return not len(self.ip) < 7 def generate_qr_code(self): - logging.debug("Generating URL QR code") + logger.debug("Generating URL QR code") qr = qrcode.QRCode( version=1, box_size=1, @@ -269,18 +231,22 @@ def generate_qr_code(self): qr.add_data(self.url) qr.make() img = qr.make_image() - self.qr_code_path = os.path.join(self.base_path, "qrcode.png") - img.save(self.qr_code_path) + + self.qr_code_path = self._tmp_dir.joinpath("qrcode.png") + self.qr_code_path.parent.mkdir(parents=True, exist_ok=True) + + logger.debug(f"{self.qr_code_path=}") + img.save(str(self.qr_code_path)) def get_search_results(self, textToSearch): - logging.info("Searching YouTube for: " + textToSearch) + logger.info("Searching YouTube for: " + textToSearch) num_results = 10 yt_search = 'ytsearch%d:"%s"' % (num_results, unidecode(textToSearch)) - cmd = [self.youtubedl_path, "-j", "--no-playlist", "--flat-playlist", yt_search] - logging.debug("Youtube-dl search command: " + " ".join(cmd)) + cmd = [YT_DLP_CMD, "-j", "--no-playlist", "--flat-playlist", yt_search] + logger.debug("Youtube-dl search command: " + " ".join(cmd)) try: output = subprocess.check_output(cmd).decode("utf-8", "ignore") - logging.debug("Search results: " + output) + logger.debug("Search results: " + output) rc = [] for each in output.split("\n"): if len(each) > 2: @@ -290,112 +256,105 @@ def get_search_results(self, textToSearch): rc.append([j["title"], j["url"], j["id"]]) return rc except Exception as e: - logging.debug("Error while executing search: " + str(e)) + logger.debug("Error while executing search: " + str(e)) raise e def get_karaoke_search_results(self, songTitle): return self.get_search_results(songTitle + " karaoke") - def download_video(self, video_url, enqueue=False, user="Pikaraoke"): - logging.info("Downloading video: " + video_url) - dl_path = self.download_path + "%(title)s---%(id)s.%(ext)s" + def download_video(self, video_url: str, enqueue=False, user="Pikaraoke"): + logger.info(f"Downloading video: {video_url}") + + yt_id = self.get_youtube_id_from_url(video_url) + found_song = self.find_song_by_youtube_id(yt_id) + + if found_song: + logger.info(f"Already downloaded the song `{Path(found_song).name}`") + if enqueue: + self.enqueue(found_song, user) + + return 0 + + dl_path = self.download_path.joinpath("%(title)s---%(id)s.%(ext)s") file_quality = ( "bestvideo[ext!=webm][height<=1080]+bestaudio[ext!=webm]/best[ext!=webm]" if self.high_quality else "mp4" ) - cmd = [self.youtubedl_path, "-f", file_quality, "-o", dl_path, video_url] - logging.debug("Youtube-dl command: " + " ".join(cmd)) - rc = subprocess.call(cmd) - if rc != 0: - logging.error("Error code while downloading, retrying once...") - rc = subprocess.call(cmd) # retry once. Seems like this can be flaky - if rc == 0: - logging.debug("Song successfully downloaded: " + video_url) + cmd = [YT_DLP_CMD, "-f", file_quality, "-o", str(dl_path), video_url] + logger.debug("Youtube-dl command: " + " ".join(cmd)) + result = subprocess.call(cmd) + if result != 0: + logger.error("Error code while downloading, retrying once...") + result = subprocess.call(cmd) # retry once. Seems like this can be flaky + if result == 0: + logger.debug("Song successfully downloaded: " + video_url) self.get_available_songs() if enqueue: - y = self.get_youtube_id_from_url(video_url) - s = self.find_song_by_youtube_id(y) - if s: - self.enqueue(s, user) + if found_song: + self.enqueue(found_song, user) else: - logging.error("Error queueing song: " + video_url) + logger.error("queueing song: " + video_url) else: - logging.error("Error downloading song: " + video_url) - return rc + logger.error("Error downloading song: " + video_url) + + return result def get_available_songs(self): - logging.info("Fetching available songs in: " + self.download_path) + logger.info(f"Fetching available songs in: {self.download_path}") types = [".mp4", ".mp3", ".zip", ".mkv", ".avi", ".webm", ".mov"] - files_grabbed = [] - P = Path(self.download_path) - for file in P.rglob("*.*"): - base, ext = os.path.splitext(file.as_posix()) - if ext.lower() in types: - if os.path.isfile(file.as_posix()): - logging.debug("adding song: " + file.name) - files_grabbed.append(file.as_posix()) - - self.available_songs = sorted(files_grabbed, key=lambda f: str.lower(os.path.basename(f))) - - def delete(self, song_path): - logging.info("Deleting song: " + song_path) - with contextlib.suppress(FileNotFoundError): - os.remove(song_path) - ext = os.path.splitext(song_path) - # if we have an associated cdg file, delete that too - cdg_file = song_path.replace(ext[1], ".cdg") - if os.path.exists(cdg_file): - os.remove(cdg_file) + files_grabbed: list[Path] = [] + + for file in self.download_path.rglob("*.*"): + if file.suffix.lower() in types and file.is_file(): + logger.debug("adding song: " + file.name) + files_grabbed.append(file) + + sorted_files = sorted(files_grabbed, key=lambda f: f.name.lower()) + self.available_songs = list(map(str, sorted_files)) + logger.debug(f"{self.available_songs=}") + + def delete(self, song_path: Path): + logger.info(f"Deleting song: {song_path}") + song_path.unlink(missing_ok=True) + + cdg_file = song_path.with_suffix(".cdg") + cdg_file.unlink(missing_ok=True) self.get_available_songs() - def rename(self, song_path, new_name): - logging.info("Renaming song: '" + song_path + "' to: " + new_name) - ext = os.path.splitext(song_path) - if len(ext) == 2: - new_file_name = new_name + ext[1] - os.rename(song_path, self.download_path + new_file_name) + def rename(self, song_path: str, new_name: str): + logger.info("Renaming song: '" + song_path + "' to: " + new_name) + base, ext = os.path.splitext(song_path) + new_file_name = new_name + ext + os.rename(song_path, str(self.download_path) + new_file_name) + # if we have an associated cdg file, rename that too - cdg_file = song_path.replace(ext[1], ".cdg") + cdg_file = song_path.replace(ext, ".cdg") if os.path.exists(cdg_file): - os.rename(cdg_file, self.download_path + new_name + ".cdg") + os.rename(cdg_file, str(self.download_path) + new_name + ".cdg") self.get_available_songs() - def filename_from_path(self, file_path): - rc = os.path.basename(file_path) - rc = os.path.splitext(rc)[0] - rc = rc.split("---")[0] # removes youtube id if present - return rc + def filename_from_path(self, file_path: str) -> str: + return Path(file_path).stem.split("---")[0] # removes youtube id if present def find_song_by_youtube_id(self, youtube_id): - for each in self.available_songs: - if youtube_id in each: - return each - logging.error("No available song found with youtube id: " + youtube_id) + for song in self.available_songs: + if youtube_id in song: + return song + logger.error(f"No available song found with {youtube_id=}") return None - def get_youtube_id_from_url(self, url): - if "v=" in url: # accomodates youtube.com/watch?v= and m.youtube.com/?v= - s = url.split("watch?v=") - else: # accomodates youtu.be/ - s = url.split("u.be/") + def get_youtube_id_from_url(self, url: str): + s = url.split("watch?v=") if len(s) == 2: - if "?" in s[1]: # Strip uneeded Youtube Params - s[1] = s[1][0 : s[1].index("?")] return s[1] else: - logging.error("Error parsing youtube id from url: " + url) + logger.error("Error parsing youtube id from url: " + url) return None - def log_ffmpeg_output(self): - if self.ffmpeg_log != None and self.ffmpeg_log.qsize() > 0: - while self.ffmpeg_log.qsize() > 0: - output = self.ffmpeg_log.get_nowait() - logging.debug("[FFMPEG] " + decode_ignore(output)) - - def play_file(self, file_path, semitones=0): - logging.info(f"Playing file: {file_path} transposed {semitones} semitones") + def play_file(self, file: str, semitones: int = 0): + logger.info(f"Playing file: {file} transposed {semitones} semitones") stream_uid = int(time.time()) stream_url = f"{self.ffmpeg_url}/{stream_uid}" # pass a 0.0.0.0 IP to ffmpeg which will work for both hostnames and direct IP access @@ -406,14 +365,14 @@ def play_file(self, file_path, semitones=0): ) # The pitch value is (2^x/12), where x represents the number of semitones try: - fr = FileResolver(file_path) + fr = FileResolver(file) except Exception as e: - logging.error("Error resolving file: " + str(e)) + logger.error("Error resolving file: " + str(e)) self.queue.pop(0) return False # use h/w acceleration on pi - default_vcodec = "h264_v4l2m2m" if self.supports_hardware_h264_encoding else "libx264" + default_vcodec = "h264_v4l2m2m" if self.platform == "raspberry_pi" else "libx264" # just copy the video stream if it's an mp4 or webm file, since they are supported natively in html5 # otherwise use the default h264 codec vcodec = ( @@ -423,38 +382,28 @@ def play_file(self, file_path, semitones=0): ) vbitrate = "5M" # seems to yield best results w/ h264_v4l2m2m on pi, recommended for 720p. - # copy the audio stream if no transposition/normalization, otherwise reincode with the aac codec + # copy the audio stream if no transposition, otherwise use the aac codec is_transposed = semitones != 0 - acodec = "aac" if is_transposed or self.normalize_audio else "copy" + acodec = "aac" if is_transposed else "copy" input = ffmpeg.input(fr.file_path) audio = input.audio.filter("rubberband", pitch=pitch) if is_transposed else input.audio - # normalize the audio - audio = audio.filter("loudnorm", i=-16, tp=-1.5, lra=11) if self.normalize_audio else audio - - # Ffmpeg outputs "Stream #0" when the stream is ready to consume - stream_ready_string = "Stream #" if fr.cdg_file_path != None: # handle CDG files - logging.info("Playing CDG/MP3 file: " + file_path) - # Ffmpeg outputs "Video: cdgraphics" when the stream is ready to consume - stream_ready_string = "Video: cdgraphics" + logger.info("Playing CDG/MP3 file: " + file) # copyts helps with sync issues, fps=25 prevents ffmpeg from needlessly encoding cdg at 300fps cdg_input = ffmpeg.input(fr.cdg_file_path, copyts=None) video = cdg_input.video.filter("fps", fps=25) - # cdg is very fussy about these flags. - # pi ffmpeg needs to encode to aac and cant just copy the mp3 stream - # It alse appears to have memory issues with hardware acceleration h264_v4l2m2m + # cdg is very fussy about these flags. pi needs to encode to aac and cant just copy the mp3 stream output = ffmpeg.output( audio, video, ffmpeg_url, - vcodec="libx264", + vcodec=vcodec, acodec="aac", - preset="ultrafast", pix_fmt="yuv420p", listen=1, f="mp4", - video_bitrate="500k", + video_bitrate=vbitrate, movflags="frag_keyframe+default_base_moof", ) else: @@ -465,7 +414,6 @@ def play_file(self, file_path, semitones=0): ffmpeg_url, vcodec=vcodec, acodec=acodec, - preset="ultrafast", listen=1, f="mp4", video_bitrate=vbitrate, @@ -473,7 +421,7 @@ def play_file(self, file_path, semitones=0): ) args = output.get_args() - logging.debug(f"COMMAND: ffmpeg " + " ".join(args)) + logger.debug(f"COMMAND: ffmpeg {args}") self.kill_ffmpeg() @@ -481,60 +429,66 @@ def play_file(self, file_path, semitones=0): # ffmpeg outputs everything useful to stderr for some insane reason! # prevent reading stderr from being a blocking action - self.ffmpeg_log = Queue() - t = Thread(target=enqueue_output, args=(self.ffmpeg_process.stderr, self.ffmpeg_log)) + q = Queue() + t = Thread(target=enqueue_output, args=(self.ffmpeg_process.stderr, q)) t.daemon = True t.start() while self.ffmpeg_process.poll() is None: try: - output = self.ffmpeg_log.get_nowait() - logging.debug("[FFMPEG] " + decode_ignore(output)) + output = q.get_nowait() + logger.debug("[FFMPEG] " + decode_ignore(output)) except Empty: pass else: - if stream_ready_string in decode_ignore(output): - logging.debug("Stream ready!") - self.now_playing = self.filename_from_path(file_path) - self.now_playing_filename = file_path + if "Stream #" in decode_ignore(output): + logger.debug("Stream ready!") + # Ffmpeg outputs "Stream #0" when the stream is ready to consume + self.now_playing = self.filename_from_path(file) + self.now_playing_filename = file self.now_playing_transpose = semitones self.now_playing_url = stream_url self.now_playing_user = self.queue[0]["user"] self.is_paused = False self.queue.pop(0) - # Pause until the stream is playing + # Keep logging output until the splash screen reports back that the stream is playing max_retries = 100 while self.is_playing == False and max_retries > 0: time.sleep(0.1) # prevents loop from trying to replay track + try: + output = q.get_nowait() + logger.debug("[FFMPEG] " + decode_ignore(output)) + except Empty: + pass max_retries -= 1 if self.is_playing: - logging.debug("Stream is playing") + logger.debug("Stream is playing") break else: - logging.error( + logger.error( "Stream was not playable! Run with debug logging to see output. Skipping track" ) self.end_song() break def kill_ffmpeg(self): - logging.debug("Killing ffmpeg process") + logger.debug("Killing ffmpeg process") if self.ffmpeg_process: self.ffmpeg_process.kill() def start_song(self): - logging.info(f"Song starting: {self.now_playing}") + logger.info(f"Song starting: {self.now_playing}") self.is_playing = True def end_song(self): - logging.info(f"Song ending: {self.now_playing}") + logger.info(f"Song ending: {self.now_playing}") self.reset_now_playing() self.kill_ffmpeg() - logging.debug("ffmpeg process killed") + logger.debug("ffmpeg process killed") def transpose_current(self, semitones): - logging.info(f"Transposing current song {self.now_playing} by {semitones} semitones") + logger.info(f"Transposing current song {self.now_playing} by {semitones} semitones") # Insert the same song at the top of the queue with transposition self.enqueue(self.now_playing_filename, self.now_playing_user, semitones, True) self.skip() @@ -542,15 +496,18 @@ def transpose_current(self, semitones): def is_file_playing(self): return self.is_playing - def is_song_in_queue(self, song_path): - for each in self.queue: - if each["file"] == song_path: - return True - return False + def is_song_in_queue(self, song_path: str): + return any(song.get("file", "") == song_path for song in self.queue) - def enqueue(self, song_path, user="Pikaraoke", semitones=0, add_to_front=False): + def enqueue( + self, + song_path: str, + user: str = "Pikaraoke", + semitones: int = 0, + add_to_front: bool = False, + ): if self.is_song_in_queue(song_path): - logging.warn("Song is already in queue, will not add: " + song_path) + logger.warning(f"Song is already in queue, will not add: {song_path=}") return False else: queue_item = { @@ -559,37 +516,40 @@ def enqueue(self, song_path, user="Pikaraoke", semitones=0, add_to_front=False): "title": self.filename_from_path(song_path), "semitones": semitones, } + logger.debug(f"Creating {queue_item=}") + if add_to_front: - logging.info("'%s' is adding song to front of queue: %s" % (user, song_path)) + logger.info(f"'{user}' is adding song to front of queue: {song_path}") self.queue.insert(0, queue_item) else: - logging.info("'%s' is adding song to queue: %s" % (user, song_path)) + logger.info(f"'{user}' is adding song to queue: {song_path}") self.queue.append(queue_item) + return True def queue_add_random(self, amount): - logging.info("Adding %d random songs to queue" % amount) + logger.info("Adding %d random songs to queue" % amount) songs = list(self.available_songs) # make a copy if len(songs) == 0: - logging.warn("No available songs!") + logger.warning("No available songs!") return False i = 0 while i < amount: r = random.randint(0, len(songs) - 1) if self.is_song_in_queue(songs[r]): - logging.warn("Song already in queue, trying another... " + songs[r]) + logger.warning("Song already in queue, trying another... " + songs[r]) else: self.enqueue(songs[r], "Randomizer") i += 1 songs.pop(r) if len(songs) == 0: - logging.warn("Ran out of songs!") + logger.warning("Ran out of songs!") return False return True def queue_clear(self): - logging.info("Clearing queue!") - self.queue = [] + logger.info("Clearing queue!") + self.queue.clear() self.skip() def queue_edit(self, song_name, action): @@ -602,86 +562,87 @@ def queue_edit(self, song_name, action): else: index += 1 if song == None: - logging.error("Song not found in queue: " + song["file"]) + logger.error("Song not found in queue: " + song["file"]) return False if action == "up": if index < 1: - logging.warn("Song is up next, can't bump up in queue: " + song["file"]) + logger.warning("Song is up next, can't bump up in queue: " + song["file"]) return False else: - logging.info("Bumping song up in queue: " + song["file"]) + logger.info("Bumping song up in queue: " + song["file"]) del self.queue[index] self.queue.insert(index - 1, song) return True elif action == "down": if index == len(self.queue) - 1: - logging.warn("Song is already last, can't bump down in queue: " + song["file"]) + logger.warning("Song is already last, can't bump down in queue: " + song["file"]) return False else: - logging.info("Bumping song down in queue: " + song["file"]) + logger.info("Bumping song down in queue: " + song["file"]) del self.queue[index] self.queue.insert(index + 1, song) return True elif action == "delete": - logging.info("Deleting song from queue: " + song["file"]) + logger.info("Deleting song from queue: " + song["file"]) del self.queue[index] return True else: - logging.error("Unrecognized direction: " + action) + logger.error("Unrecognized direction: " + action) return False def skip(self): if self.is_file_playing(): - logging.info("Skipping: " + self.now_playing) + logger.info("Skipping: " + self.now_playing) self.now_playing_command = "skip" return True else: - logging.warning("Tried to skip, but no file is playing!") + logger.warning("Tried to skip, but no file is playing!") return False def pause(self): if self.is_file_playing(): - logging.info("Toggling pause: " + self.now_playing) + logger.info("Toggling pause: " + self.now_playing) self.now_playing_command = "pause" self.is_paused = not self.is_paused return True else: - logging.warning("Tried to pause, but no file is playing!") + logger.warning("Tried to pause, but no file is playing!") return False def volume_change(self, vol_level): self.volume = vol_level - logging.debug(f"Setting volume to: {self.volume}") + logger.debug(f"Setting volume to: {self.volume}") if self.is_file_playing(): self.now_playing_command = f"volume_change: {self.volume}" return True def vol_up(self): self.volume += 0.1 - logging.debug(f"Increasing volume by 10%: {self.volume}") + logger.debug(f"Increasing volume by 10%: {self.volume}") if self.is_file_playing(): self.now_playing_command = "vol_up" return True else: - logging.warning("Tried to volume up, but no file is playing!") + logger.warning("Tried to volume up, but no file is playing!") return False def vol_down(self): self.volume -= 0.1 - logging.debug(f"Decreasing volume by 10%: {self.volume}") + logger.debug(f"Decreasing volume by 10%: {self.volume}") if self.is_file_playing(): self.now_playing_command = "vol_down" return True else: - logging.warning("Tried to volume down, but no file is playing!") + logger.warning("Tried to volume down, but no file is playing!") return False def restart(self): if self.is_file_playing(): + logger.info("Restarting song.") self.now_playing_command = "restart" return True else: - logging.warning("Tried to restart, but no file is playing!") + logger.warning("Tried to restart, but no file is playing!") return False def stop(self): @@ -698,11 +659,10 @@ def reset_now_playing(self): self.is_paused = True self.is_playing = False self.now_playing_transpose = 0 - self.ffmpeg_log = None def run(self): - logging.info("Starting PiKaraoke!") - logging.info(f"Connect the player host to: {self.url}/splash") + logger.info("Starting PiKaraoke!") + logger.info(f"Connect the player host to: {self.url}/splash") self.running = True while self.running: try: @@ -715,9 +675,36 @@ def run(self): while i < (self.splash_delay * 1000): self.handle_run_loop() i += self.loop_interval - self.play_file(self.queue[0]["file"], self.queue[0]["semitones"]) - self.log_ffmpeg_output() + self.play_file( + file=self.queue[0]["file"], + semitones=self.queue[0]["semitones"], + ) self.handle_run_loop() except KeyboardInterrupt: - logging.warn("Keyboard interrupt: Exiting pikaraoke...") + logger.warning("Keyboard interrupt: Exiting pikaraoke...") self.running = False + + def read_youtubedl_version(self): + self.youtubedl_version = check_output([YT_DLP_CMD, "--version"]).strip().decode("utf8") + return self.youtubedl_version + + def upgrade_youtubedl(self): + logger.info("Upgrading youtube-dl. Current version: %s" % self.youtubedl_version) + try: + output = ( + check_output([YT_DLP_CMD, "-U"], stderr=subprocess.STDOUT).decode("utf8").strip() + ) + logger.info(output) + except subprocess.CalledProcessError as error: + output = error.output.decode("utf8") + logger.error(f"Failed to upgrade youtubedl. {error=} {output=}") + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + logger.debug(f"Removing '{self.qr_code_path.name}' from '{self.qr_code_path}'") + self.qr_code_path.unlink(missing_ok=True) + + logger.debug(f"Removing tmp dir '{self._tmp_dir.name}' from '{self._tmp_dir}'") + self._tmp_dir.rmdir() diff --git a/pikaraoke/lib/file_resolver.py b/pikaraoke/lib/file_resolver.py index 68c221fc..301e5e0d 100644 --- a/pikaraoke/lib/file_resolver.py +++ b/pikaraoke/lib/file_resolver.py @@ -1,76 +1,101 @@ +import logging import os import re import shutil import zipfile +from pathlib import Path -from pikaraoke.lib.get_platform import get_platform +from .get_platform import get_platform + +logger = logging.getLogger(__name__) -# Processes a given file path and determines the file format and file path, extracting zips into cdg + mp3 if necessary. class FileResolver: - file_path = None - cdg_file_path = None - file_extension = None - pid = os.getpid() # for scoping tmp directories to this process + """Processes a given file path to determine the file format and file path + + Extracting zips into cdg + mp3 if necessary. + """ + + def __init__(self, file: str): + self._pid = os.getpid() # for scoping tmp directories to this process + + self._file_path = None + self._cdg_file_path = None + self._file_extension = None - def __init__(self, file_path): # Determine tmp directories (for things like extracted cdg files) - if get_platform() == "windows": - self.tmp_dir = os.path.expanduser( - r"~\\AppData\\Local\\Temp\\pikaraoke\\" + str(self.pid) + r"\\" + if get_platform().is_windows(): + self.tmp_dir = ( + Path.home() / "AppData" / "Local" / "Temp" / "pikaraoke" / str(self._pid) / "" ) else: - self.tmp_dir = f"/tmp/pikaraoke/{self.pid}" - self.resolved_file_path = self.process_file(file_path) - - # Extract zipped cdg + mp3 files into a temporary directory, and set the paths to both files. - def handle_zipped_cdg(self, file_path): - extracted_dir = os.path.join(self.tmp_dir, "extracted") - if os.path.exists(extracted_dir): - shutil.rmtree(extracted_dir) # clears out any previous extractions + self.tmp_dir = Path("/tmp") / "pikaraoke" / str(self._pid) + + self.resolved_file_path = self._process_file(Path(file)) + + @property + def file_path(self): + return self._file_path + + @property + def cdg_file_path(self): + return self._cdg_file_path + + @property + def file_extension(self): + return self._file_extension + + def _handle_zipped_cdg(self, file_path: Path): + """Extract zipped cdg + mp3 files into a temporary directory + + Sets the paths to both files. + """ + extracted_dir = self.tmp_dir.joinpath("extracted") + if extracted_dir.is_dir(): + shutil.rmtree(str(extracted_dir)) # clears out any previous extractions with zipfile.ZipFile(file_path, "r") as zip_ref: zip_ref.extractall(extracted_dir) mp3_file = None cdg_file = None - files = os.listdir(extracted_dir) - print(files) + files = extracted_dir.iterdir() + for file in files: - ext = os.path.splitext(file)[1] - if ext.casefold() == ".mp3": + file_extension = file.suffix.casefold() + if file_extension == ".mp3": mp3_file = file - elif ext.casefold() == ".cdg": + elif file_extension == ".cdg": cdg_file = file - if (mp3_file is not None) and (cdg_file is not None): - if os.path.splitext(mp3_file)[0] == os.path.splitext(cdg_file)[0]: - self.file_path = os.path.join(extracted_dir, mp3_file) - self.cdg_file_path = os.path.join(extracted_dir, cdg_file) + + if all([mp3_file, cdg_file]): + if mp3_file.stem == cdg_file.stem: + self._file_path = extracted_dir.joinpath(mp3_file) + self._cdg_file_path = extracted_dir.joinpath(cdg_file) else: raise Exception("Zipped .mp3 file did not have a matching .cdg file: " + files) else: raise Exception("No .mp3 or .cdg was found in the zip file: " + file_path) - def handle_mp3_cdg(self, file_path): - f = os.path.splitext(os.path.basename(file_path))[0] + def _handle_mp3_cdg(self, file_path: Path): + f = file_path.stem pattern = f + ".cdg" rule = re.compile(re.escape(pattern), re.IGNORECASE) - p = os.path.dirname(file_path) # get the path, not the filename - print(p) - print(pattern) - for n in os.listdir(p): - if rule.match(n): - self.file_path = file_path - self.cdg_file_path = file_path.replace(".mp3", ".cdg") - return True + p = file_path.parent # get the path, not the filename + + for n in p.iterdir(): + if n.is_file() and rule.match(n.name): + self._file_path = file_path + self._cdg_file_path = file_path.with_suffix(".cdg") + return raise Exception("No matching .cdg file found for: " + file_path) - def process_file(self, file_path): - file_extension = os.path.splitext(file_path)[1].casefold() - self.file_extension = file_extension - if file_extension == ".zip": - self.handle_zipped_cdg(file_path) - elif file_extension == ".mp3": - self.handle_mp3_cdg(file_path) + def _process_file(self, file_path: Path): + self._file_extension = file_path.suffix.casefold() + + if self._file_extension == ".zip": + self._handle_zipped_cdg(file_path) + elif self._file_extension == ".mp3": + self._handle_mp3_cdg(file_path) else: - self.file_path = file_path + self._file_path = file_path diff --git a/pikaraoke/lib/get_platform.py b/pikaraoke/lib/get_platform.py index 8384ce23..857ac853 100644 --- a/pikaraoke/lib/get_platform.py +++ b/pikaraoke/lib/get_platform.py @@ -3,6 +3,86 @@ import re import subprocess import sys +from enum import Enum + + +class Platform(Enum): + """Which OS the current host is among OSX, RPI, LINUX, WINDOWS, UNKNOWN. + + Supports methods: `is_rpi()` `is_windows()` `is_linux()` `is_mac()`. + + ### Example: + + ``` + platform = Platform.LINUX + platform.is_linux() # Returns True + platform.is_max() # Returns False + ``` + """ + + OSX = "osx" + RPI = "rpi" + LINUX = "linux" + WINDOWS = "windows" + UNKNOWN = "unknown" + + def is_rpi(self): + """Check if the platform is Raspberry Pi + + Returns: + bool: True if the platform is Raspberry Pi, False otherwise + """ + return self == Platform.RPI + + def is_windows(self): + """Check if the platform is Windows + + Returns: + bool: True if the platform is Windows, False otherwise + """ + return self == Platform.WINDOWS + + def is_linux(self): + """Check if the platform is Linux + + Returns: + bool: True if the platform is Linux, False otherwise + """ + return self == Platform.LINUX + + def is_mac(self): + """Check if the platform is macOS + + Returns: + bool: True if the platform is macOS, False otherwise + """ + return self == Platform.OSX + + def is_unknown(self): + """Check if the platform is unknown + + Returns: + bool: True if the platform is unknown, False otherwise + """ + return self == Platform.UNKNOWN + + +def get_platform() -> Platform: + """Determine the current platform + + Returns: + Platform: The current platform as a member of the Platform enum + """ + if "darwin" in sys.platform: + return Platform.OSX + elif _is_raspberry_pi(): + return Platform.RPI + elif sys.platform.startswith(Platform.LINUX.value): + return Platform.LINUX + elif sys.platform.startswith("win"): + return Platform.WINDOWS + else: + return Platform.UNKNOWN def get_ffmpeg_version(): @@ -21,40 +101,12 @@ def get_ffmpeg_version(): return "Unable to parse FFmpeg version" -def is_raspberry_pi(): - try: - return ( - os.uname()[4][:3] == "arm" or os.uname()[4] == "aarch64" - ) and sys.platform != "darwin" - except AttributeError: - return False - - -def get_platform(): - if sys.platform == "darwin": - return "osx" - elif is_raspberry_pi(): - try: - with open("/proc/device-tree/model", "r") as file: - model = file.read().strip() - if "Raspberry Pi" in model: - return model # Returns something like "Raspberry Pi 4 Model B Rev 1.2" - except FileNotFoundError: - return "Rasperry Pi - unrecognized" - elif sys.platform.startswith("linux"): - return "linux" - elif sys.platform.startswith("win"): - return "windows" - else: - return "unknown" - - def get_os_version(): return platform.version() def supports_hardware_h264_encoding(): - if is_raspberry_pi(): + if _is_raspberry_pi(): platform = get_platform() # Raspberry Pi >= 5 no longer has hardware GPU decoding @@ -66,3 +118,12 @@ def supports_hardware_h264_encoding(): return True else: return False + + +def _is_raspberry_pi() -> bool: + try: + return ( + os.uname()[4][:3] == "arm" or os.uname()[4] == "aarch64" + ) and sys.platform != "darwin" + except AttributeError: + return False diff --git a/pikaraoke/lib/logger.py b/pikaraoke/lib/logger.py new file mode 100644 index 00000000..bdbb72d5 --- /dev/null +++ b/pikaraoke/lib/logger.py @@ -0,0 +1,135 @@ +import logging +import logging.handlers +from datetime import datetime +from functools import wraps +from pathlib import Path + +from flask import current_app + +from pikaraoke import PACKAGE # Because __package__ will return pikaraoke.lib +from pikaraoke import Platform, get_platform + + +def get_log_directory() -> Path: + """Get the log directory path based on the operating system + + Returns: + Path: The path to the log directory + + Raises: + OSError: If the operating system is unsupported + """ + platform: Platform = get_platform() + + if platform.is_unknown(): + raise OSError("Unsupported OS. Can't determine logs folder.") + + user_home = Path.home() + if platform.is_windows(): + return user_home / "AppData" / "Local" / PACKAGE / "Logs" + + return user_home / ".config" / PACKAGE / "logs" # macOs and Linux use the same log path + + +def clean_old_logs(log_dir: Path, max_files: int = 5): + """Remove old log files, keeping only the most recent `max_files` logs + + This function sorts log files in the specified directory by their modification time + and removes the oldest files until only `max_files` remain. + + Args: + log_dir (Path): The directory where the log files are stored. + max_files (int, optional): The maximum number of log files to keep. Defaults to 5. + + Raises: + FileNotFoundError: If the specified log directory does not exist. + PermissionError: If there is no permission to delete log files. + + Example: + ```python + from pathlib import Path + clean_old_logs(Path('/var/log/myapp'), max_files=10) + ``` + """ + import os + + log_files = sorted(log_dir.glob("*.log"), key=os.path.getmtime) + while len(log_files) > max_files: + old_log = log_files.pop(0) + old_log.unlink() + + +class CustomFormatter(logging.Formatter): + def format(self, record): + record.levelname = record.levelname.ljust(8) # Adjust the number as needed + return super().format(record) + + +def configure_logger( + log_level: int = logging.DEBUG, log_dir: Path | None = None, max_log_files: int = 5 +): + """Configures the logger with log file, format and level + + Sets up a file to log to, the level to log at and configures a nice format for the log to be + displayed. There are two formatters, one for the console and one for the log file. The console + formatter is a bit simpler and removes the date and time as it's quite long and noisy. The + console typically just wants to get a quick overview. The log file will hold all of the detailed + information on time and date. + + The log files are stored under logs/ folder and the name is the date and time appended .log + + This configurations also discovers all loggers and configures them the same if there are other + loggers by third party libraries that have their own configuration. + + Args: + log_level (int): The log level to log at. logging.[DEBUG | INFO | ERROR | CRITICAL | WARN ]. + Defaults to logging.DEBUG. + log_dir (Path | None): Where to store the logs. Defaults to system default. + max_log_files (int): Keeps only the previous (n) number of log files. Defaults to 5. + """ + if log_dir is None: + log_dir = get_log_directory() + + clean_old_logs(log_dir=log_dir, max_files=max_log_files) + + # Generate filename with current date and time + log_filename = log_dir / datetime.now().strftime("%Y-%m-%d_%H-%M-%S.log") + log_dir.mkdir(exist_ok=True, parents=True) # Create logs/ folder + + # Create handlers + # file_handler = logging.FileHandler(log_filename) + file_handler = logging.handlers.RotatingFileHandler( + log_filename, maxBytes=10 * 1024**2, backupCount=5 + ) + stream_handler = logging.StreamHandler() + + # Create formatters + file_formatter = CustomFormatter( + "[%(asctime)s] %(levelname)s %(message)s", datefmt="%d.%m.%Y %H:%M:%S" + ) + console_formatter = logging.Formatter("%(levelname)s %(message)s", datefmt="%H:%M:%S") + + # Set formatters to handlers + file_handler.setFormatter(file_formatter) + stream_handler.setFormatter(console_formatter) + + # Configure logging + logging.basicConfig(level=log_level, handlers=[file_handler, stream_handler]) + + # Ensure all existing loggers use the same configuration + for name in logging.root.manager.loggerDict: + logger = logging.getLogger(name) + logger.handlers.clear() # Clear existing handlers + if isinstance(logger, logging.Logger): + logger.addHandler(file_handler) + logger.addHandler(stream_handler) + logger.setLevel(log_level) + + +def log_endpoint_access(func): + @wraps(func) + def wrapper(*args, **kwargs): + current_app.logger.debug(f"Endpoint /{func.__name__} accessed.") + return func(*args, **kwargs) + + return wrapper diff --git a/pikaraoke/lib/parse_args.py b/pikaraoke/lib/parse_args.py new file mode 100644 index 00000000..8441c906 --- /dev/null +++ b/pikaraoke/lib/parse_args.py @@ -0,0 +1,221 @@ +import argparse +import importlib.resources as pkg_resources +import logging +from pathlib import Path + +from pikaraoke import resources +from pikaraoke.lib.get_platform import Platform, get_platform + +logger = logging.getLogger(__name__) + + +def get_default_dl_dir(platform: Platform) -> Path: + default_dir = Path.home() / "pikaraoke-songs" + legacy_dir = Path.home() / "pikaraoke" / "songs" + + if not platform.is_rpi() and legacy_dir.exists(): + return legacy_dir + + return default_dir + + +PORT = 5555 +PORT_FFMPEG = 5556 +VOLUME = 0.85 +DELAY_SPLASH = 3 +DELAY_SCREENSAVER = 300 +LOG_LEVEL = logging.INFO +PREFER_HOSTNAME = False +PLATFORM = get_platform() +DL_DIR: Path = get_default_dl_dir(PLATFORM) + + +def _volume_type(input): + """Verify the volume input""" + try: + volume = float(input) + except ValueError: + raise argparse.ArgumentTypeError( + f"Volume must be a float between 0 and 1, but got '{input}'" + ) + + if volume < 0 or volume > 1: + raise argparse.ArgumentTypeError(f"Volume must be between 0 and 1, but got {volume}") + + return volume + + +class ArgsNamespace(argparse.Namespace): + """Provides typehints to the input args""" + + port: int + window_size: str + ffmpeg_port: int + download_path: Path + volume: float + splash_delay: float + screensaver_timeout: float + log_level: int + hide_url: bool + prefer_hostname: bool + hide_raspiwifi_instructions: bool + hide_splash_screen: bool + high_quality: bool + logo_path: Path + url: str | None + ffmpeg_url: str | None + hide_overlay: bool + admin_password: str | None + + +def _get_logo_path(): + try: + # Access the resource using importlib.resources + with pkg_resources.path(resources, "logo.png") as logo_path: + return logo_path + except Exception as e: + print(f"Error accessing logo.png: {e}") + return None + + +def parse_args() -> ArgsNamespace: + # Usage example to get path to logo.png inside the executable + logo_path_default = _get_logo_path() # Works in poetry + parser = argparse.ArgumentParser() + + parser.add_argument( + "-p", + "--port", + help="Desired http port (default: %d)" % PORT, + default=PORT, + required=False, + ) + parser.add_argument( + "--window-size", + help="Desired window geometry in pixels, specified as width,height", + default=0, + required=False, + ) + parser.add_argument( + "-f", + "--ffmpeg-port", + help=f"Desired ffmpeg port. This is where video stream URLs will be pointed (default: {PORT_FFMPEG})", + default=PORT_FFMPEG, + required=False, + ) + parser.add_argument( + "-d", + "--download-path", + help=f"Desired path for downloaded songs. Defaults to {DL_DIR}", + default=DL_DIR, + type=Path, + ) + parser.add_argument( + "-y", + "--youtubedl-path", + help=f"(DEPRECATED!) Path to yt-dlp binary. Defaults to None.", + default=None, + type=Path, + required=False, + ) + + parser.add_argument( + "-v", + "--volume", + help="Set initial player volume. A value between 0 and 1. (default: %s)" % VOLUME, + default=VOLUME, + type=_volume_type, + required=False, + ) + parser.add_argument( + "-s", + "--splash-delay", + help="Delay during splash screen between songs (in secs). (default: %s )" % DELAY_SPLASH, + default=DELAY_SPLASH, + required=False, + ) + parser.add_argument( + "-t", + "--screensaver-timeout", + help="Delay before the screensaver begins (in secs). (default: %s )" % DELAY_SCREENSAVER, + default=DELAY_SCREENSAVER, + required=False, + ) + parser.add_argument( + "-l", + "--log-level", + help=f"Logging level int value (DEBUG: 10, INFO: 20, WARNING: 30, ERROR: 40, CRITICAL: 50). (default: {LOG_LEVEL} )", + default=LOG_LEVEL, + required=False, + ) + parser.add_argument( + "--hide-url", + action="store_true", + help="Hide URL and QR code from the splash screen.", + required=False, + ) + parser.add_argument( + "--prefer-hostname", + action="store_true", + help=f"Use the local hostname instead of the IP as the connection URL. Use at your discretion: mDNS is not guaranteed to work on all LAN configurations. Defaults to {PREFER_HOSTNAME}", + default=PREFER_HOSTNAME, + required=False, + ) + parser.add_argument( + "--hide-raspiwifi-instructions", + action="store_true", + help="Hide RaspiWiFi setup instructions from the splash screen.", + required=False, + ) + parser.add_argument( + "--hide-splash-screen", + "--headless", + action="store_true", + help="Headless mode. Don't launch the splash screen/player on the pikaraoke server", + required=False, + ) + parser.add_argument( + "--high-quality", + action="store_true", + help="Download higher quality video. Note: requires ffmpeg and may cause CPU, download speed, and other performance issues", + required=False, + ) + + parser.add_argument( + "--logo-path", + help="Path to a custom logo image file for the splash screen. Recommended dimensions ~ 2048x1024px", + default=logo_path_default, + type=Path, + ) + + parser.add_argument( + "-u", + "--url", + help="Override the displayed IP address with a supplied URL. This argument should include port, if necessary", + default=None, + required=False, + ) + + parser.add_argument( + "-m", + "--ffmpeg-url", + help="Override the ffmpeg address with a supplied URL.", + default=None, + required=False, + ) + + parser.add_argument( + "--hide-overlay", + action="store_true", + help="Hide overlay that shows on top of video with pikaraoke QR code and IP", + required=False, + ) + + parser.add_argument( + "--admin-password", + help="Administrator password, for locking down certain features of the web UI such as queue editing, player controls, song editing, and system shutdown. If unspecified, everyone is an admin.", + default=None, + required=False, + ) + + return parser.parse_args(namespace=ArgsNamespace()) diff --git a/pikaraoke/lib/utils.py b/pikaraoke/lib/utils.py new file mode 100644 index 00000000..c6fce2eb --- /dev/null +++ b/pikaraoke/lib/utils.py @@ -0,0 +1,112 @@ +import hashlib +import json +from pathlib import Path +from typing import cast +from urllib.parse import quote + +import flask +import flask_babel + +from pikaraoke import Karaoke +from pikaraoke.lib.get_platform import Platform + +translate = flask_babel.gettext +"""Alias for the gettext function from Flask-Babel + +This is used for marking strings for translation in the application. + +Example usage: + message = translate("This is a translatable string") +""" + + +def filename_from_path(file_path: str, remove_youtube_id: bool = True) -> str: + """Extract the filename from a given file path, optionally removing YouTube ID + + Args: + file_path (str): The path to the file. + remove_youtube_id (bool): Removes YouTube ID from the filename by partitioning the name at + '---' and returning the part before it. Defaults to True. + + Returns: + str: The extracted filename, optionally without the YouTube ID. + """ + return (name := Path(file_path).stem).partition("---")[0] if remove_youtube_id else name + + +def url_escape(filename: str) -> str: + """Encode a filename to be safely included in a URL + + This function takes a filename, encodes it in UTF-8, and then applies URL encoding + to make sure all special characters are properly escaped, allowing the filename to + be safely used as part of a URL. Example: `'abc def' -> 'abc%20def'` + + Args: + filename (str): The filename to be encoded. + + Returns: + str: The URL-encoded filename. + """ + return quote(filename.encode("utf8")) + + +def hash_dict(dictionary: dict) -> str: + """Compute an MD5 hash of a dictionary + + This function serializes a dictionary to a JSON string with sorted keys and ensures + ASCII encoding. It then computes the MD5 hash of the UTF-8 encoded JSON string and + returns the hexadecimal digest of the hash. + + Args: + dictionary (dict): The dictionary to be hashed. + + Returns: + str: The hexadecimal MD5 hash of the JSON-encoded dictionary. + """ + return hashlib.md5( + json.dumps(dictionary, sort_keys=True, ensure_ascii=True).encode("utf-8", "ignore") + ).hexdigest() + + +def is_admin(password: str | None) -> bool: + """Determine if the provided password matches the admin cookie value + + This function checks if the provided password is `None` or if it matches + the value of the "admin" cookie in the current Flask request. If the password + is `None`, the function assumes the user is an admin. If the "admin" cookie + is present and its value matches the provided password, the function returns `True`. + Otherwise, it returns `False`. + + Args: + password (str): The password to check against the admin cookie value. + + Returns: + bool: `True` if the password matches the admin cookie or if the password is `None`, + `False` otherwise. + """ + return password is None or flask.request.cookies.get("admin") == password + + +class PiKaraokeServer(flask.Flask): + """Child class of `Flask` with custom attributes to provide intellisense to the app object""" + + platform: Platform + karaoke: Karaoke + + +def get_current_app() -> PiKaraokeServer: + """Retrieve the current Flask application instance cast to a PiKaraokeServer type + + This function assumes that the Flask application instance is of type PiKaraokeServer, + which is a custom subclass of Flask. It provides a type-safe way to access the current + application and its custom attributes. The objective is to get intellisense on the + current_app object. + + Returns: + PiKaraokeServer: The current Flask application instance cast to PiKaraokeServer. + + Raises: + TypeError: If the current application is not of type PiKaraokeServer, a TypeError + might be raised when performing operations on the casted object. + """ + return cast(PiKaraokeServer, flask.current_app) diff --git a/pikaraoke/lib/vlcclient.py b/pikaraoke/lib/vlcclient.py index b3a69b19..608aeade 100644 --- a/pikaraoke/lib/vlcclient.py +++ b/pikaraoke/lib/vlcclient.py @@ -13,7 +13,7 @@ import requests -from pikaraoke.lib.get_platform import get_platform, is_raspberry_pi +from pikaraoke.lib.get_platform import get_platform def get_default_vlc_path(platform): @@ -49,7 +49,6 @@ def __init__(self, port=5002, path=None, qrcode=None, url=None): self.path = get_default_vlc_path(self.platform) else: self.path = path - self.raspberry_pi = is_raspberry_pi() # Determine tmp directories (for things like extracted cdg files) if self.platform == "windows": @@ -179,7 +178,7 @@ def play_file_transpose(self, file_path, semitones): # Different resampling algorithms are supported. The best one is slower, while the fast one exhibits # low quality. - if self.raspberry_pi: + if self.platform.is_rpi(): # pi sounds bad on hightest quality setting (CPU not sufficient) speex_quality = 10 src_type = 1 diff --git a/pikaraoke/logo.png b/pikaraoke/resources/logo.png similarity index 100% rename from pikaraoke/logo.png rename to pikaraoke/resources/logo.png diff --git a/pikaraoke/routes/admin_routes.py b/pikaraoke/routes/admin_routes.py new file mode 100644 index 00000000..47671b0b --- /dev/null +++ b/pikaraoke/routes/admin_routes.py @@ -0,0 +1,148 @@ +import os +import subprocess +import sys +import threading +import time + +import cherrypy +import psutil +from flask import Blueprint, current_app, flash, redirect, render_template, url_for +from yt_dlp.version import __version__ as yt_dlp_version + +from pikaraoke import Karaoke, __version__, get_current_app, is_admin +from pikaraoke.lib.logger import log_endpoint_access + +admin_bp = Blueprint("admin", __name__) + + +# Delay system commands to allow redirect to render first +def _delayed_halt(karaoke: Karaoke, cmd: int): + time.sleep(1.5) + karaoke.queue_clear() + cherrypy.engine.stop() + cherrypy.engine.exit() + karaoke.stop() + if cmd == 0: + sys.exit() + if cmd == 1: + os.system("shutdown now") + if cmd == 2: + os.system("reboot") + if cmd == 3: + process = subprocess.Popen(["raspi-config", "--expand-rootfs"]) + process.wait() + os.system("reboot") + + +@admin_bp.route("/refresh") +@log_endpoint_access +def refresh(): + current_app = get_current_app() + + if is_admin(current_app.config["ADMIN_PASSWORD"]): + current_app.karaoke.get_available_songs() + else: + flash("You don't have permission to shut down", "is-danger") + return redirect(url_for("home.browse")) + + +@admin_bp.route("/quit") +@log_endpoint_access +def quit(): + current_app = get_current_app() + + if is_admin(current_app.config["ADMIN_PASSWORD"]): + flash("Quitting pikaraoke now!", "is-warning") + th = threading.Thread(target=_delayed_halt, args=[current_app.karaoke, 0]) + th.start() + else: + flash("You don't have permission to quit", "is-danger") + return redirect(url_for("home.home")) + + +@admin_bp.route("/shutdown") +@log_endpoint_access +def shutdown(): + if is_admin(current_app.config["ADMIN_PASSWORD"]): + flash("Shutting down system now!", "is-danger") + th = threading.Thread(target=_delayed_halt, args=[1]) + th.start() + else: + flash("You don't have permission to shut down", "is-danger") + return redirect(url_for("home.home")) + + +@admin_bp.route("/reboot") +@log_endpoint_access +def reboot(): + if is_admin(current_app.config["ADMIN_PASSWORD"]): + flash("Rebooting system now!", "is-danger") + th = threading.Thread(target=_delayed_halt, args=[2]) + th.start() + else: + flash("You don't have permission to Reboot", "is-danger") + return redirect(url_for("home.home")) + + +@admin_bp.route("/expand_fs") +@log_endpoint_access +def expand_fs(): + current_app = get_current_app() + + if is_admin(current_app.config["ADMIN_PASSWORD"]) and current_app.platform.is_rpi(): + flash("Expanding filesystem and rebooting system now!", "is-danger") + th = threading.Thread(target=_delayed_halt, args=[current_app, 3]) + th.start() + elif not current_app.platform.is_rpi(): + flash("Cannot expand fs on non-raspberry pi devices!", "is-danger") + else: + flash("You don't have permission to resize the filesystem", "is-danger") + + return redirect(url_for("home.home")) + + +@admin_bp.route("/info") +@log_endpoint_access +def info(): + current_app = get_current_app() + url = current_app.karaoke.url + + # cpu + cpu = str(psutil.cpu_percent()) + "%" + + # mem + memory = psutil.virtual_memory() + available = round(memory.available / 1024.0 / 1024.0, 1) + total = round(memory.total / 1024.0 / 1024.0, 1) + memory = ( + str(available) + "MB free / " + str(total) + "MB total ( " + str(memory.percent) + "% )" + ) + + # disk + disk = psutil.disk_usage("/") + # Divide from Bytes -> KB -> MB -> GB + free = round(disk.free / 1024.0 / 1024.0 / 1024.0, 1) + total = round(disk.total / 1024.0 / 1024.0 / 1024.0, 1) + disk = str(free) + "GB free / " + str(total) + "GB total ( " + str(disk.percent) + "% )" + + return render_template( + "info.html", + site_title=current_app.config["SITE_NAME"], + title="Info", + url=url, + memory=memory, + cpu=cpu, + disk=disk, + youtubedl_version=yt_dlp_version, + is_pi=current_app.platform.is_rpi(), + pikaraoke_version=__version__, + admin=is_admin(current_app.config["ADMIN_PASSWORD"]), + admin_enabled=current_app.config["ADMIN_PASSWORD"] != None, + ) + + +@admin_bp.route("/update_ytdl") +@log_endpoint_access +def update_ytdl(): + # Support for updating ytdl removed + return redirect(url_for("admin.info")) diff --git a/pikaraoke/routes/auth_routes.py b/pikaraoke/routes/auth_routes.py new file mode 100644 index 00000000..94653569 --- /dev/null +++ b/pikaraoke/routes/auth_routes.py @@ -0,0 +1,51 @@ +import datetime + +from flask import ( + Blueprint, + current_app, + flash, + make_response, + redirect, + render_template, + request, + url_for, +) + +from pikaraoke import translate +from pikaraoke.lib.logger import log_endpoint_access + +auth_bp = Blueprint( + "auth", + __name__, +) + + +@auth_bp.route("/auth", methods=["POST"]) +@log_endpoint_access +def auth(): + d = request.form.to_dict() + pw = d.get("admin-password") + if pw == current_app.config["ADMIN_PASSWORD"]: + resp = make_response(redirect("/")) + expire_date = datetime.datetime.now() + datetime.timedelta(days=90) + resp.set_cookie("admin", current_app.config["ADMIN_PASSWORD"], expires=expire_date) + flash(translate("Admin mode granted!"), "is-success") + else: + resp = make_response(redirect(url_for("auth.login"))) + flash(translate("Incorrect admin password!"), "is-danger") + return resp + + +@auth_bp.route("/login") +@log_endpoint_access +def login(): + return render_template("login.html") + + +@auth_bp.route("/logout") +@log_endpoint_access +def logout(): + resp = make_response(redirect("/")) + resp.set_cookie("admin", "") + flash("Logged out of admin mode!", "is-success") + return resp diff --git a/pikaraoke/routes/file_routes.py b/pikaraoke/routes/file_routes.py new file mode 100644 index 00000000..d1725779 --- /dev/null +++ b/pikaraoke/routes/file_routes.py @@ -0,0 +1,101 @@ +from pathlib import Path + +from flask import ( + Blueprint, + flash, + redirect, + render_template, + request, + send_file, + url_for, +) + +from pikaraoke import get_current_app +from pikaraoke.lib.logger import log_endpoint_access + +file_bp = Blueprint("file", __name__) + + +@file_bp.route("/files/delete", methods=["GET"]) +@log_endpoint_access +def delete_file(): + current_app = get_current_app() + if "song" in request.args: + song_path = request.args["song"] + if song_path in current_app.karaoke.queue: + flash( + "Error: Can't delete this song because it is in the current queue: " + song_path, + "is-danger", + ) + else: + current_app.karaoke.delete(Path(song_path)) + flash("Song deleted: " + song_path, "is-warning") + else: + flash("Error: No song parameter specified!", "is-danger") + + return redirect(url_for("home.browse")) + + +@file_bp.route("/files/edit", methods=["GET", "POST"]) +@log_endpoint_access +def edit_file(): + current_app = get_current_app() + queue_error_msg = "Error: Can't edit this song because it is in the current queue: " + if "song" in request.args: + song_path = request.args["song"] + # print "SONG_PATH" + song_path + if song_path in current_app.karaoke.queue: + flash(queue_error_msg + song_path, "is-danger") + return redirect(url_for("home.browse")) + + return render_template( + "edit.html", + site_title=current_app.config["SITE_NAME"], + title="Song File Edit", + song=song_path.encode("utf-8", "ignore"), + ) + + d = request.form.to_dict() + if "new_file_name" in d and "old_file_name" in d: + new_name = d["new_file_name"] + old_name = d["old_file_name"] + if current_app.karaoke.is_song_in_queue(old_name): + # check one more time just in case someone added it during editing + flash(queue_error_msg + song_path, "is-danger") + else: + # check if new_name already exist + file_extension = Path(old_name).suffix + new_file_path = ( + Path(current_app.karaoke.download_path) + .joinpath(new_name) + .with_suffix(file_extension) + ) + if new_file_path.is_file(): + flash( + "Error Renaming file: '%s' to '%s'. Filename already exists." + % (old_name, new_name + file_extension), + "is-danger", + ) + else: + current_app.karaoke.rename(old_name, new_name) + flash( + "Renamed file: '%s' to '%s'." % (old_name, new_name), + "is-warning", + ) + else: + flash("Error: No filename parameters were specified!", "is-danger") + return redirect(url_for("home.browse")) + + +@file_bp.route("/logo") +@log_endpoint_access +def logo(): + current_app = get_current_app() + return send_file(current_app.karaoke.logo_path, mimetype="image/png") + + +@file_bp.route("/qrcode") +@log_endpoint_access +def qrcode(): + current_app = get_current_app() + return send_file(current_app.karaoke.qr_code_path, mimetype="image/png") diff --git a/pikaraoke/routes/home_routes.py b/pikaraoke/routes/home_routes.py new file mode 100644 index 00000000..bc2f8b5c --- /dev/null +++ b/pikaraoke/routes/home_routes.py @@ -0,0 +1,182 @@ +import subprocess +from pathlib import Path + +from flask import Blueprint, render_template, request +from flask_paginate import Pagination, get_page_parameter + +from pikaraoke import get_current_app, is_admin +from pikaraoke.lib.logger import log_endpoint_access + +home_bp = Blueprint("home", __name__) + + +@home_bp.route("/") +@log_endpoint_access +def home() -> str: + current_app = get_current_app() + return render_template( + "home.html", + site_title=current_app.config["SITE_NAME"], + title="Home", + transpose_value=current_app.karaoke.now_playing_transpose, + admin=is_admin(password=current_app.config["ADMIN_PASSWORD"]), + ) + + +@home_bp.route("/splash") +@log_endpoint_access +def splash(): + current_app = get_current_app() + + # Only do this on Raspberry Pis + if current_app.platform.is_rpi(): + status = subprocess.run(["iwconfig", "wlan0"], stdout=subprocess.PIPE).stdout.decode( + "utf-8" + ) + text = "" + if "Mode:Master" in status: + # Wifi is setup as a Access Point + ap_name = "" + ap_password = "" + + config_file = Path("/etc/raspiwifi/raspiwifi.conf") + if config_file.is_file(): + content = config_file.read_text() + + # Override the default values according to the configuration file. + for line in content.splitlines(): + line = line.split("#", 1)[0] + if "ssid_prefix=" in line: + ap_name = line.split("ssid_prefix=")[1].strip() + elif "wpa_key=" in line: + ap_password = line.split("wpa_key=")[1].strip() + + if len(ap_password) > 0: + text = [ + f"Wifi Network: {ap_name} Password: {ap_password}", + f"Configure Wifi: {current_app.karaoke.url.rpartition(':')[0]}", + ] + else: + text = [ + f"Wifi Network: {ap_name}", + f"Configure Wifi: {current_app.rpartition(':',1)[0]}", + ] + else: + # You are connected to Wifi as a client + text = "" + else: + # Not a Raspberry Pi + text = "" + + return render_template( + "splash.html", + blank_page=True, + url=current_app.karaoke.url, + hostap_info=text, + hide_url=current_app.karaoke.hide_url, + hide_overlay=current_app.karaoke.hide_overlay, + screensaver_timeout=current_app.karaoke.screensaver_timeout, + ) + + +@home_bp.route("/queue") +@log_endpoint_access +def queue() -> str: + current_app = get_current_app() + return render_template( + "queue.html", + queue=current_app.karaoke.queue, + site_title=current_app.config["SITE_NAME"], + title="Queue", + admin=is_admin(password=current_app.config["ADMIN_PASSWORD"]), + ) + + +@home_bp.route("/browse", methods=["GET"]) +@log_endpoint_access +def browse(): + current_app = get_current_app() + search = False + q = request.args.get("q") + if q: + search = True + page = request.args.get(get_page_parameter(), type=int, default=1) + + available_songs = current_app.karaoke.available_songs + + letter = request.args.get("letter") + + if letter: + result = [] + if letter == "numeric": + for song in available_songs: + f = current_app.karaoke.filename_from_path(song)[0] + if f.isnumeric(): + result.append(song) + else: + for song in available_songs: + f = current_app.karaoke.filename_from_path(song).lower() + if f.startswith(letter.lower()): + result.append(song) + available_songs = result + + if "sort" in request.args and request.args["sort"] == "date": + songs = sorted(available_songs, key=lambda x: Path(x).stat().st_ctime) + songs.reverse() + sort_order = "Date" + else: + songs = available_songs + sort_order = "Alphabetical" + + # Ensure songs is a list of strings + songs = [str(song) for song in songs] + + results_per_page = 500 + pagination = Pagination( + css_framework="bulma", + page=page, + total=len(songs), + search=search, + record_name="songs", + per_page=results_per_page, + ) + + start_index = (page - 1) * (results_per_page - 1) + + return render_template( + "files.html", + pagination=pagination, + sort_order=sort_order, + site_title=current_app.config["SITE_NAME"], + letter=letter, + # MSG: Title of the files page. + title="Browse", + songs=songs[start_index : start_index + results_per_page], + admin=is_admin(current_app.config["ADMIN_PASSWORD"]), + ) + + +@home_bp.route("/search", methods=["GET"]) +@log_endpoint_access +def search(): + current_app = get_current_app() + current_app.logger.debug(f"{request.args=}") + + if "search_string" in request.args: + search_string = request.args["search_string"] + if "non_karaoke" in request.args and request.args["non_karaoke"] == "true": + search_results = current_app.karaoke.get_search_results(search_string) + else: + search_results = current_app.karaoke.get_karaoke_search_results(search_string) + else: + search_string = None + search_results = None + + return render_template( + "search.html", + site_title=current_app.config["SITE_NAME"], + title="Search", + songs=current_app.karaoke.available_songs, + search_results=search_results, + search_string=search_string, + ) diff --git a/pikaraoke/routes/karaoke_routes.py b/pikaraoke/routes/karaoke_routes.py new file mode 100644 index 00000000..7e286cbc --- /dev/null +++ b/pikaraoke/routes/karaoke_routes.py @@ -0,0 +1,245 @@ +import json +import logging +import threading +from urllib.parse import unquote + +from flask import Blueprint, flash, redirect, request, url_for + +from pikaraoke import filename_from_path, get_current_app, hash_dict +from pikaraoke.lib.logger import log_endpoint_access + +karaoke_bp = Blueprint("karaoke", __name__) +logger = logging.getLogger(__name__) + + +@karaoke_bp.route("/nowplaying") +# @log_endpoint_access # Annoyingly a lot of requests +def nowplaying() -> str: + current_app = get_current_app() + try: + if len(current_app.karaoke.queue) >= 1: + next_song = current_app.karaoke.queue[0]["title"] + next_user = current_app.karaoke.queue[0]["user"] + else: + next_song = None + next_user = None + rc = { + "now_playing": current_app.karaoke.now_playing, + "now_playing_user": current_app.karaoke.now_playing_user, + "now_playing_command": current_app.karaoke.now_playing_command, + "up_next": next_song, + "next_user": next_user, + "now_playing_url": current_app.karaoke.now_playing_url, + "is_paused": current_app.karaoke.is_paused, + "transpose_value": current_app.karaoke.now_playing_transpose, + "volume": current_app.karaoke.volume, + } + rc["hash"] = hash_dict(rc) # used to detect changes in the now playing data + return json.dumps(rc) + except Exception as e: + logging.error("Problem loading /nowplaying, pikaraoke may still be starting up: " + str(e)) + return "" + + +# Call this after receiving a command in the front end +@karaoke_bp.route("/clear_command") +@log_endpoint_access +def clear_command(): + current_app = get_current_app() + current_app.karaoke.now_playing_command = None + return "" + + +@karaoke_bp.route("/get_queue") +@log_endpoint_access +def get_queue() -> str: + current_app = get_current_app() + return json.dumps(current_app.karaoke.queue if len(current_app.karaoke.queue) >= 1 else []) + + +@karaoke_bp.route("/queue/addrandom", methods=["GET"]) +@log_endpoint_access +def add_random(): + current_app = get_current_app() + amount = int(request.args["amount"]) + rc = current_app.karaoke.queue_add_random(amount) + if rc: + flash("Added %s random tracks" % amount, "is-success") + else: + flash("Ran out of songs!", "is-warning") + + return redirect(url_for("home.queue")) + + +@karaoke_bp.route("/queue/edit", methods=["GET"]) +@log_endpoint_access +def queue_edit(): + current_app = get_current_app() + action = request.args["action"] + if action == "clear": + current_app.karaoke.queue_clear() + flash("Cleared the queue!", "is-warning") + return redirect(url_for("home.queue")) + + song = unquote(request.args["song"]) + if action == "down": + if current_app.karaoke.queue_edit(song, "down"): + flash("Moved down in queue: " + song, "is-success") + else: + flash("Error moving down in queue: " + song, "is-danger") + elif action == "up": + if current_app.karaoke.queue_edit(song, "up"): + flash("Moved up in queue: " + song, "is-success") + else: + flash("Error moving up in queue: " + song, "is-danger") + elif action == "delete": + if current_app.karaoke.queue_edit(song, "delete"): + flash("Deleted from queue: " + song, "is-success") + else: + flash("Error deleting from queue: " + song, "is-danger") + + return redirect(url_for("home.queue")) + + +@karaoke_bp.route("/enqueue", methods=["POST", "GET"]) +@log_endpoint_access +def enqueue(): + current_app = get_current_app() + if "song" in request.args: + song = request.args["song"] + else: + d = request.form.to_dict() + song = d["song-to-add"] + if "user" in request.args: + user = request.args["user"] + else: + d = request.form.to_dict() + user = d["song-added-by"] + rc = current_app.karaoke.enqueue(song, user) + song_title = filename_from_path(song) + + return json.dumps({"song": song_title, "success": rc}) + + +@karaoke_bp.route("/skip") +@log_endpoint_access +def skip(): + current_app = get_current_app() + current_app.karaoke.skip() + return redirect(url_for("home.home")) + + +@karaoke_bp.route("/pause") +@log_endpoint_access +def pause(): + current_app = get_current_app() + current_app.karaoke.pause() + return redirect(url_for("home.home")) + + +@karaoke_bp.route("/transpose/", methods=["GET"]) +@log_endpoint_access +def transpose(semitones): + current_app = get_current_app() + current_app.karaoke.transpose_current(int(semitones)) + return redirect(url_for("home.home")) + + +@karaoke_bp.route("/restart") +@log_endpoint_access +def restart(): + current_app = get_current_app() + current_app.karaoke.restart() + return redirect(url_for("home.home")) + + +@karaoke_bp.route("/volume/") +@log_endpoint_access +def volume(volume): + current_app = get_current_app() + current_app.karaoke.volume_change(float(volume)) + return redirect(url_for("home.home")) + + +@karaoke_bp.route("/vol_up") +@log_endpoint_access +def vol_up(): + current_app = get_current_app() + current_app.karaoke.vol_up() + return redirect(url_for("home.home")) + + +@karaoke_bp.route("/vol_down") +@log_endpoint_access +def vol_down(): + current_app = get_current_app() + current_app.karaoke.vol_down() + return redirect(url_for("home.home")) + + +@karaoke_bp.route("/download", methods=["POST"]) +@log_endpoint_access +def download(): + current_app = get_current_app() + d = request.form.to_dict() + current_app.logger.debug(f"Got download request: {d=}") + song = d["song-url"] + user = d["song-added-by"] + + if "queue" in d and d["queue"] == "on": + queue = True + else: + queue = False + + # download in the background since this can take a few minutes + t = threading.Thread(target=current_app.karaoke.download_video, args=[song, queue, user]) + t.daemon = True + t.start() + + flash_message = ( + "Download started: '" + song + "'. This may take a couple of minutes to complete. " + ) + + if queue: + flash_message += "Song will be added to queue." + else: + flash_message += 'Song will appear in the "available songs" list.' + flash(flash_message, "is-info") + return redirect(url_for("home.search")) + + +@karaoke_bp.route("/end_song", methods=["GET"]) +@log_endpoint_access +def end_song(): + current_app = get_current_app() + current_app.karaoke.end_song() + return "ok" + + +@karaoke_bp.route("/start_song", methods=["GET"]) +@log_endpoint_access +def start_song(): + current_app = get_current_app() + current_app.karaoke.start_song() + return "ok" + + +@karaoke_bp.route("/autocomplete") +@log_endpoint_access +def autocomplete(): + current_app = get_current_app() + + q = request.args.get("q").lower() + result = [ + { + "path": song, + "fileName": current_app.karaoke.filename_from_path(song), + "type": "autocomplete", + } + for song in current_app.karaoke.available_songs + if q in song.lower() + ] + response = current_app.response_class(response=json.dumps(result), mimetype="application/json") + + current_app.logger.debug(f"Autocomplete response: {result}") + return response diff --git a/pikaraoke/templates/base.html b/pikaraoke/templates/base.html index b3e9aed4..a6dc1208 100644 --- a/pikaraoke/templates/base.html +++ b/pikaraoke/templates/base.html @@ -179,23 +179,23 @@