diff --git a/pikaraoke/app.py b/pikaraoke/app.py index 48059b0e..0f279f62 100644 --- a/pikaraoke/app.py +++ b/pikaraoke/app.py @@ -1,48 +1,39 @@ -import datetime import hashlib import json import logging import os -import re import signal -import subprocess import sys -import threading -import time import cherrypy import flask_babel -import psutil -from flask import ( - Flask, - Response, - flash, - jsonify, - make_response, - redirect, - render_template, - request, - send_file, - session, - url_for, -) +from flask import Flask, redirect, request, session, url_for from flask_babel import Babel -from flask_paginate import Pagination, get_page_parameter -from pikaraoke import VERSION, karaoke +from pikaraoke import karaoke from pikaraoke.constants import LANGUAGES from pikaraoke.lib.args import parse_pikaraoke_args -from pikaraoke.lib.background_music import create_randomized_playlist +from pikaraoke.lib.current_app import get_karaoke_instance from pikaraoke.lib.ffmpeg import is_ffmpeg_installed -from pikaraoke.lib.file_resolver import delete_tmp_dir, get_tmp_dir +from pikaraoke.lib.file_resolver import delete_tmp_dir from pikaraoke.lib.get_platform import get_platform, is_raspberry_pi -from pikaraoke.lib.raspi_wifi_config import get_raspi_wifi_text from pikaraoke.lib.selenium import launch_splash_screen +from pikaraoke.routes.admin import admin_bp +from pikaraoke.routes.background_music import background_music_bp +from pikaraoke.routes.files import files_bp +from pikaraoke.routes.home import home_bp +from pikaraoke.routes.images import images_bp +from pikaraoke.routes.info import info_bp +from pikaraoke.routes.preferences import preferences_bp +from pikaraoke.routes.queue import queue_bp +from pikaraoke.routes.search import search_bp +from pikaraoke.routes.splash import splash_bp +from pikaraoke.routes.stream import stream_bp try: - from urllib.parse import quote, unquote + from urllib.parse import quote except ImportError: - from urllib import quote, unquote + from urllib import quote _ = flask_babel.gettext @@ -53,38 +44,21 @@ 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" -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 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 +# Register blueprints for additional routes +app.register_blueprint(home_bp) +app.register_blueprint(stream_bp) +app.register_blueprint(preferences_bp) +app.register_blueprint(admin_bp) +app.register_blueprint(background_music_bp) +app.register_blueprint(queue_bp) +app.register_blueprint(images_bp) +app.register_blueprint(files_bp) +app.register_blueprint(search_bp) +app.register_blueprint(info_bp) +app.register_blueprint(splash_bp) @babel.localeselector @@ -98,52 +72,9 @@ def get_locale(): return locale -@app.route("/") -def home(): - return render_template( - "home.html", - site_title=site_name, - title="Home", - transpose_value=k.now_playing_transpose, - admin=is_admin(), - is_transpose_enabled=k.is_transpose_enabled, - ) - - -@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.route("/login") -def login(): - return render_template("login.html") - - -@app.route("/logout") -def logout(): - resp = make_response(redirect("/")) - resp.set_cookie("admin", "") - # MSG: Message shown after logging out as admin successfully - flash(_("Logged out of admin mode!"), "is-success") - return resp - - @app.route("/nowplaying") def nowplaying(): + k = get_karaoke_instance() try: if len(k.queue) >= 1: next_song = k.queue[0]["title"] @@ -177,287 +108,63 @@ def nowplaying(): # Call this after receiving a command in the front end @app.route("/clear_command") def clear_command(): + k = get_karaoke_instance() 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: - # MSG: Message shown after adding random tracks - flash(_("Added %s random tracks") % amount, "is-success") - else: - # MSG: Message shown after running out songs to add during random track addition - 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() - # MSG: Message shown after clearing the queue - 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: - # MSG: Message shown after moving a song down in the queue - flash(_("Moved down in queue") + ": " + song, "is-success") - else: - # MSG: Message shown after failing to move a song down in the queue - flash(_("Error moving down in queue") + ": " + song, "is-danger") - elif action == "up": - result = k.queue_edit(song, "up") - if result: - # MSG: Message shown after moving a song up in the queue - flash(_("Moved up in queue") + ": " + song, "is-success") - else: - # MSG: Message shown after failing to move a song up in the queue - flash(_("Error moving up in queue") + ": " + song, "is-danger") - elif action == "delete": - result = k.queue_edit(song, "delete") - if result: - # MSG: Message shown after deleting a song from the queue - flash(_("Deleted from queue") + ": " + song, "is-success") - else: - # MSG: Message shown after failing to delete a song from the queue - 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 = k.filename_from_path(song) - return json.dumps({"song": song_title, "success": rc}) - - @app.route("/skip") def skip(): + k = get_karaoke_instance() k.skip() - return redirect(url_for("home")) + return redirect(url_for("home.home")) @app.route("/pause") def pause(): + k = get_karaoke_instance() k.pause() - return redirect(url_for("home")) + return redirect(url_for("home.home")) @app.route("/transpose/", methods=["GET"]) def transpose(semitones): + k = get_karaoke_instance() k.transpose_current(int(semitones)) - return redirect(url_for("home")) + return redirect(url_for("home.home")) @app.route("/restart") def restart(): + k = get_karaoke_instance() k.restart() - return redirect(url_for("home")) + return redirect(url_for("home.home")) @app.route("/volume/") def volume(volume): + k = get_karaoke_instance() k.volume_change(float(volume)) - return redirect(url_for("home")) + return redirect(url_for("home.home")) @app.route("/vol_up") def vol_up(): + k = get_karaoke_instance() k.vol_up() - return redirect(url_for("home")) + return redirect(url_for("home.home")) @app.route("/vol_down") def vol_down(): + k = get_karaoke_instance() 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"] - title = d["song-title"] - 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, title]) - t.daemon = True - t.start() - - displayed_title = title if title else song - flash_message = ( - # MSG: Message shown after starting a download. Song title is displayed in the message. - _("Download started: %s. This may take a couple of minutes to complete.") - % displayed_title - ) - - if queue: - # MSG: Message shown after starting a download that will be adding a song to the queue. - flash_message += _("Song will be added to queue.") - else: - # MSG: Message shown after after starting a download. - 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") - - -# Routes for streaming background music -@app.route("/bg_music/", methods=["GET"]) -def bg_music(file): - mp3_path = os.path.join(k.bg_music_path, file) - return send_file(mp3_path, mimetype="audio/mpeg") - - -# Route for getting the randomized background music playlist -@app.route("/bg_playlist", methods=["GET"]) -def bg_playlist(): - if (k.bg_music_path == None) or (not os.path.exists(k.bg_music_path)): - return jsonify([]) - playlist = create_randomized_playlist(k.bg_music_path, "/bg_music", 50) - return jsonify(playlist) + return redirect(url_for("home.home")) @app.route("/end_song", methods=["GET", "POST"]) def end_song(): + k = get_karaoke_instance() d = request.form.to_dict() reason = d["reason"] if "reason" in d else None k.end_song(reason) @@ -466,359 +173,11 @@ def end_song(): @app.route("/start_song", methods=["GET"]) def start_song(): + k = get_karaoke_instance() k.start_song() return "ok" -@app.route("/files/delete", methods=["GET"]) -def delete_file(): - if "song" in request.args: - song_path = request.args["song"] - exists = any(item.get("file") == song_path for item in k.queue) - if exists: - flash( - # MSG: Message shown after trying to delete a song that is in the queue. - _("Error: Can't delete this song because it is in the current queue") - + ": " - + song_path, - "is-danger", - ) - else: - k.delete(song_path) - # MSG: Message shown after deleting a song. Followed by the song path - flash(_("Song deleted: %s") % k.filename_from_path(song_path), "is-warning") - else: - # MSG: Message shown after trying to delete a song without specifying the song. - flash(_("Error: No song specified!"), "is-danger") - return redirect(url_for("browse")) - - -@app.route("/files/edit", methods=["GET", "POST"]) -def edit_file(): - # MSG: Message shown after trying to edit a song that is in the queue. - 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 + old_name, "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( - # MSG: Message shown after trying to rename a file to a name that already exists. - _("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( - # MSG: Message shown after renaming a file. - _("Renamed file: %s to %s") % (old_name, new_name), - "is-warning", - ) - else: - # MSG: Message shown after trying to edit a song without specifying the filename. - 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: - # handle raspiwifi connection mode - text = get_raspi_wifi_text() - 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, - disable_bg_music=k.disable_bg_music, - disable_score=k.disable_score, - bg_music_volume=k.bg_music_volume, - ) - - -@app.route("/info") -def info(): - url = k.url - - # cpu - try: - cpu = str(psutil.cpu_percent()) + "%" - except: - cpu = _("CPU usage query unsupported") - - # 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, - is_transpose_enabled=k.is_transpose_enabled, - 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, - disable_bg_music=k.disable_bg_music, - bg_music_volume=int(100 * k.bg_music_volume), - disable_score=k.disable_score, - hide_url=k.hide_url, - limit_user_songs_by=k.limit_user_songs_by, - hide_notifications=k.hide_notifications, - hide_overlay=k.hide_overlay, - normalize_audio=k.normalize_audio, - complete_transcode_before_play=k.complete_transcode_before_play, - high_quality_audio=k.high_quality, - splash_delay=k.splash_delay, - screensaver_timeout=k.screensaver_timeout, - volume=int(100 * k.volume), - buffer_size=k.buffer_size, - ) - - -@app.route("/update_ytdl") -def update_ytdl(): - def update_youtube_dl(): - time.sleep(3) - k.upgrade_youtubedl() - - if is_admin(): - flash( - # MSG: Message shown after starting the youtube-dl update. - _("Updating youtube-dl! Should take a minute or two... "), - "is-warning", - ) - th = threading.Thread(target=update_youtube_dl) - th.start() - else: - # MSG: Message shown after trying to update youtube-dl without admin permissions. - 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: - # MSG: Message shown after trying to refresh the song list without admin permissions. - flash(_("You don't have permission to shut down"), "is-danger") - return redirect(url_for("browse")) - - -@app.route("/quit") -def quit(): - if is_admin(): - # MSG: Message shown after quitting pikaraoke. - msg = _("Exiting pikaraoke now!") - flash(msg, "is-danger") - k.send_message_to_splash(msg, "danger") - th = threading.Thread(target=delayed_halt, args=[0]) - th.start() - else: - # MSG: Message shown after trying to quit pikaraoke without admin permissions. - flash(_("You don't have permission to quit"), "is-danger") - return redirect(url_for("home")) - - -@app.route("/shutdown") -def shutdown(): - if is_admin(): - # MSG: Message shown after shutting down the system. - msg = _("Shutting down system now!") - flash(msg, "is-danger") - k.send_message_to_splash(msg, "danger") - th = threading.Thread(target=delayed_halt, args=[1]) - th.start() - else: - # MSG: Message shown after trying to shut down the system without admin permissions. - flash(_("You don't have permission to shut down"), "is-danger") - return redirect(url_for("home")) - - -@app.route("/reboot") -def reboot(): - if is_admin(): - # MSG: Message shown after rebooting the system. - msg = _("Rebooting system now!") - flash(msg, "is-danger") - k.send_message_to_splash(msg, "danger") - th = threading.Thread(target=delayed_halt, args=[2]) - th.start() - else: - # MSG: Message shown after trying to reboot the system without admin permissions. - 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: - # MSG: Message shown after expanding the filesystem. - flash(_("Expanding filesystem and rebooting system now!"), "is-danger") - th = threading.Thread(target=delayed_halt, args=[3]) - th.start() - elif not raspberry_pi: - # MSG: Message shown after trying to expand the filesystem on a non-raspberry pi device. - flash(_("Cannot expand fs on non-raspberry pi devices!"), "is-danger") - else: - # MSG: Message shown after trying to expand the filesystem without admin permissions - flash(_("You don't have permission to resize the filesystem"), "is-danger") - return redirect(url_for("home")) - - -@app.route("/change_preferences", methods=["GET"]) -def change_preferences(): - if is_admin(): - preference = request.args["pref"] - val = request.args["val"] - - rc = k.change_preferences(preference, val) - - return jsonify(rc) - else: - # MSG: Message shown after trying to change preferences without admin permissions. - flash(_("You don't have permission to change preferences"), "is-danger") - return redirect(url_for("info")) - - -@app.route("/clear_preferences", methods=["GET"]) -def clear_preferences(): - if is_admin(): - rc = k.clear_preferences() - if rc[0]: - flash(rc[1], "is-success") - else: - flash(rc[1], "is-danger") - else: - # MSG: Message shown after trying to clear preferences without admin permissions. - flash(_("You don't have permission to clear preferences"), "is-danger") - return redirect(url_for("home")) - - -# Streams the file in chunks from the filesystem (chrome supports it, safari does not) -@app.route("/stream/") -def stream(id): - file_path = os.path.join(get_tmp_dir(), f"{id}.mp4") - - def generate(): - position = 0 # Initialize the position variable - chunk_size = 10240 * 1000 * 25 # Read file in up to 25MB chunks - with open(file_path, "rb") as file: - # Keep yielding file chunks as long as ffmpeg process is transcoding - while k.ffmpeg_process.poll() is None: - file.seek(position) # Move to the last read position - chunk = file.read(chunk_size) - if chunk is not None and len(chunk) > 0: - yield chunk - position += len(chunk) # Update the position with the size of the chunk - time.sleep(1) # Wait a bit before checking the file size again - chunk = file.read(chunk_size) # Read the last chunk - yield chunk - position += len(chunk) # Update the position with the size of the chunk - - return Response(generate(), mimetype="video/mp4") - - -# Streams the file in full with proper range headers -# (Safari compatible, but requires the ffmpeg transcoding to be complete to know file size) -@app.route("/stream/full/") -def stream_full(id): - file_path = os.path.join(get_tmp_dir(), f"{id}.mp4") - try: - file_size = os.path.getsize(file_path) - range_header = request.headers.get("Range", None) - if not range_header: - with open(file_path, "rb") as file: - file_content = file.read() - return Response(file_content, mimetype="video/mp4") - # Extract range start and end from Range header (e.g., "bytes=0-499") - range_match = re.search(r"bytes=(\d+)-(\d*)", range_header) - start, end = range_match.groups() - start = int(start) - end = int(end) if end else file_size - 1 - # Generate response with part of file - with open(file_path, "rb") as file: - file.seek(start) - data = file.read(end - start + 1) - status_code = 206 # Partial content - headers = { - "Content-Type": "video/mp4", - "Accept-Ranges": "bytes", - "Content-Range": f"bytes {start}-{end}/{file_size}", - "Content-Length": str(len(data)), - } - return Response(data, status=status_code, headers=headers) - except IOError: - # MSG: Message shown after trying to stream a file that does not exist. - flash(_("File not found."), "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 main(): platform = get_platform() @@ -830,17 +189,12 @@ def main(): ) sys.exit(1) - if args.admin_password: - global admin_password - admin_password = args.admin_password - # setup/create download directory if necessary if not os.path.exists(args.download_path): print("Creating download path: " + args.download_path) os.makedirs(args.download_path) # Configure karaoke process - global k k = karaoke.Karaoke( port=args.port, download_path=args.download_path, @@ -868,6 +222,14 @@ def main(): config_file_path=args.config_file_path, ) + # expose karaoke object to the flask app + with app.app_context(): + app.k = k + + # expose shared configuration variables to the flask app + app.config["ADMIN_PASSWORD"] = args.admin_password + app.config["SITE_NAME"] = "PiKaraoke" + # Expose some functions to jinja templates app.jinja_env.globals.update(filename_from_path=k.filename_from_path) app.jinja_env.globals.update(url_escape=quote) @@ -888,6 +250,9 @@ def main(): ) cherrypy.engine.start() + # Handle sigterm, apparently cherrypy won't shut down without explicit handling + signal.signal(signal.SIGTERM, lambda signum, stack_frame: k.stop()) + # force headless mode when on Android if (platform == "android") and not args.hide_splash_screen: args.hide_splash_screen = True diff --git a/pikaraoke/karaoke.py b/pikaraoke/karaoke.py index 46cba8c1..d5adcd68 100644 --- a/pikaraoke/karaoke.py +++ b/pikaraoke/karaoke.py @@ -442,6 +442,8 @@ def play_file(self, file_path, semitones=0): semitones != 0 or self.normalize_audio or is_transcoding_required(file_path) ) + logging.debug(f"Requires transcoding: {requires_transcoding}") + try: fr = FileResolver(file_path) except Exception as e: diff --git a/pikaraoke/lib/background_music.py b/pikaraoke/lib/background_music.py deleted file mode 100644 index e778f4bf..00000000 --- a/pikaraoke/lib/background_music.py +++ /dev/null @@ -1,25 +0,0 @@ -import os -import random -import urllib - - -def create_randomized_playlist(input_directory, base_url, max_songs=50): - # Get all mp3 files in the given directory - files = [ - f - for f in os.listdir(input_directory) - if f.lower().endswith(".mp3") or f.lower().endswith(".mp4") - ] - - # Shuffle the list of mp3 files - random.shuffle(files) - files = files[:max_songs] - - # Create the playlist - playlist = [] - for mp3 in files: - mp3 = urllib.parse.quote(mp3.encode("utf8")) - url = f"{base_url}/{mp3}" - playlist.append(f"{url}") - - return playlist diff --git a/pikaraoke/lib/current_app.py b/pikaraoke/lib/current_app.py new file mode 100644 index 00000000..8a0964ac --- /dev/null +++ b/pikaraoke/lib/current_app.py @@ -0,0 +1,69 @@ +import os +import subprocess +import sys +import time + +import cherrypy +from flask import current_app, request + +from pikaraoke.karaoke import Karaoke + + +def is_admin() -> bool: + """Determine if the current app's admin 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`. + Returns: + bool: `True` if the password matches the admin cookie or if the password is `None`, + `False` otherwise. + """ + password = get_admin_password() + return password is None or request.cookies.get("admin") == password + + +def get_karaoke_instance() -> Karaoke: + """Get the current app's Karaoke instance + This function returns the Karaoke instance stored in the current app's configuration. + Returns: + Karaoke: The Karaoke instance stored in the current app's configuration. + """ + return current_app.k + + +def get_admin_password() -> str: + """Get the admin password from the current app's configuration + This function returns the admin password stored in the current app's configuration. + Returns: + str: The admin password stored in the current app's configuration. + """ + return current_app.config["ADMIN_PASSWORD"] + + +def get_site_name() -> str: + """Get the site name from the current app's configuration + This function returns the site name stored in the current app's configuration. + Returns: + str: The site name stored in the current app's configuration. + """ + return current_app.config["SITE_NAME"] + + +def delayed_halt(cmd): + time.sleep(1.5) + current_app.k.queue_clear() + cherrypy.engine.stop() + cherrypy.engine.exit() + current_app.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") diff --git a/pikaraoke/lib/selenium.py b/pikaraoke/lib/selenium.py index 83c7feeb..c3736d1c 100644 --- a/pikaraoke/lib/selenium.py +++ b/pikaraoke/lib/selenium.py @@ -3,16 +3,13 @@ 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.lib.get_platform import is_raspberry_pi - def launch_splash_screen(karaoke, window_size=None): if karaoke.is_raspberry_pi: - service = service(executable_path="/usr/bin/chromedriver") + service = Service(executable_path="/usr/bin/chromedriver") else: service = None options = Options() diff --git a/pikaraoke/messages.pot b/pikaraoke/messages.pot index 721ca04a..ee11df7f 100644 --- a/pikaraoke/messages.pot +++ b/pikaraoke/messages.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2025-01-03 00:00-0800\n" +"POT-Creation-Date: 2025-01-05 22:49-0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,141 +17,122 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.9.1\n" -#. Message shown after logging in as admin successfully -#: app.py:139 -msgid "Admin mode granted!" -msgstr "" - -#. Message shown after failing to login as admin -#: app.py:143 -msgid "Incorrect admin password!" -msgstr "" - -#. Message shown after logging out as admin successfully -#: app.py:157 -msgid "Logged out of admin mode!" -msgstr "" - -#. Message shown after adding random tracks -#: app.py:218 -#, python-format -msgid "Added %s random tracks" -msgstr "" - -#. Message shown after running out songs to add during random track addition -#: app.py:221 -msgid "Ran out of songs!" +#: karaoke.py:242 +msgid "Your preferences were changed successfully" msgstr "" -#. Message shown after clearing the queue -#: app.py:231 -msgid "Cleared the queue!" +#: karaoke.py:245 +msgid "Something went wrong! Your preferences were not changed" msgstr "" -#. Message shown after moving a song down in the queue -#: app.py:240 -msgid "Moved down in queue" +#: karaoke.py:250 +msgid "Your preferences were cleared successfully" msgstr "" -#. Message shown after failing to move a song down in the queue -#: app.py:243 -msgid "Error moving down in queue" +#: karaoke.py:252 +msgid "Something went wrong! Your preferences were not cleared" msgstr "" -#. Message shown after moving a song up in the queue -#: app.py:248 -msgid "Moved up in queue" +#. Message shown after the download is started +#: karaoke.py:345 +#, python-format +msgid "Downloading video: %s" msgstr "" -#. Message shown after failing to move a song up in the queue -#: app.py:251 -msgid "Error moving up in queue" +#. Message shown after the download is completed and queued +#: karaoke.py:357 +#, python-format +msgid "Downloaded and queued: %s" msgstr "" -#. Message shown after deleting a song from the queue -#: app.py:256 -msgid "Deleted from queue" +#. Message shown after the download is completed but not queued +#: karaoke.py:360 +#, python-format +msgid "Downloaded: %s" msgstr "" -#. Message shown after failing to delete a song from the queue -#: app.py:259 -msgid "Error deleting from queue" +#. Message shown after the download is completed but the adding to queue fails +#: karaoke.py:369 +msgid "Error queueing song: " msgstr "" -#. Title of the files page. -#. Navigation link for the page where the user can add existing songs to the -#. queue. -#: app.py:407 templates/base.html:201 -msgid "Browse" +#. Message shown after the download process is completed but the song is not +#. found +#: karaoke.py:372 +msgid "Error downloading song: " msgstr "" -#. Message shown after starting a download. Song title is displayed in the -#. message. -#: app.py:432 +#. Message shown when the song ends abnormally +#: karaoke.py:570 #, python-format -msgid "Download started: %s. This may take a couple of minutes to complete." +msgid "Song ended abnormally: %s" msgstr "" -#. Message shown after starting a download that will be adding a song to the -#. queue. -#: app.py:438 -msgid "Song will be added to queue." +#. Message shown after the song is transposed, first is the semitones and then +#. the song name +#: karaoke.py:578 +#, python-format +msgid "Transposing by %s semitones: %s" msgstr "" -#. Message shown after after starting a download. -#: app.py:441 -msgid "Song will appear in the \"available songs\" list." +#: karaoke.py:611 +#, python-format +msgid "You reached the limit of %s song(s) from an user in queue!" msgstr "" -#. Message shown after trying to delete a song that is in the queue. -#: app.py:494 -msgid "Error: Can't delete this song because it is in the current queue" +#. Message shown after the song is added to the top of the queue +#: karaoke.py:623 +#, python-format +msgid "%s added to top of queue: %s" msgstr "" -#. Message shown after deleting a song. Followed by the song path -#: app.py:502 +#. Message shown after the song is added to the queue +#: karaoke.py:628 #, python-format -msgid "Song deleted: %s" +msgid "%s added to the queue: %s" msgstr "" -#. Message shown after trying to delete a song without specifying the song. -#: app.py:505 -msgid "Error: No song specified!" +#: karaoke.py:630 +#, python-format +msgid "Song added to the queue: %s" msgstr "" -#. Message shown after trying to edit a song that is in the queue. -#: app.py:512 -msgid "Error: Can't edit this song because it is in the current queue: " +#. Message shown after the queue is cleared +#: karaoke.py:654 +msgid "Clear queue" msgstr "" -#. Message shown after trying to rename a file to a name that already exists. -#: app.py:540 +#. Message shown after the song is skipped, will be followed by song name +#: karaoke.py:700 #, python-format -msgid "Error renaming file: '%s' to '%s', Filename already exists" +msgid "Skip: %s" msgstr "" -#. Message shown after renaming a file. -#: app.py:548 +#. Message shown after the song is resumed, will be followed by song name +#: karaoke.py:711 #, python-format -msgid "Renamed file: %s to %s" +msgid "Resume: %s" msgstr "" -#. Message shown after trying to edit a song without specifying the filename. -#: app.py:553 -msgid "Error: No filename parameters were specified!" +#. Message shown after the song is paused, will be followed by song name +#: karaoke.py:714 +msgid "Pause" msgstr "" -#: app.py:617 -msgid "CPU usage query unsupported" +#. Message shown after the volume is changed, will be followed by the volume +#. level +#: karaoke.py:724 +#, python-format +msgid "Volume: %s" msgstr "" #. Message shown after starting the youtube-dl update. -#: app.py:701 +#: routes/admin.py:58 msgid "Updating youtube-dl! Should take a minute or two... " msgstr "" #. Message shown after trying to update youtube-dl without admin permissions. -#: app.py:708 +#: routes/admin.py:65 msgid "You don't have permission to update youtube-dl" msgstr "" @@ -159,174 +140,193 @@ msgstr "" #. permissions. #. Message shown after trying to shut down the system without admin #. permissions. -#: app.py:718 app.py:748 +#: routes/admin.py:76 routes/admin.py:108 msgid "You don't have permission to shut down" msgstr "" #. Message shown after quitting pikaraoke. -#: app.py:726 +#: routes/admin.py:85 msgid "Exiting pikaraoke now!" msgstr "" #. Message shown after trying to quit pikaraoke without admin permissions. -#: app.py:733 +#: routes/admin.py:92 msgid "You don't have permission to quit" msgstr "" #. Message shown after shutting down the system. -#: app.py:741 +#: routes/admin.py:101 msgid "Shutting down system now!" msgstr "" #. Message shown after rebooting the system. -#: app.py:756 +#: routes/admin.py:117 msgid "Rebooting system now!" msgstr "" #. Message shown after trying to reboot the system without admin permissions. -#: app.py:763 +#: routes/admin.py:124 msgid "You don't have permission to Reboot" msgstr "" #. Message shown after expanding the filesystem. -#: app.py:771 +#: routes/admin.py:133 msgid "Expanding filesystem and rebooting system now!" msgstr "" #. Message shown after trying to expand the filesystem on a non-raspberry pi #. device. -#: app.py:776 +#: routes/admin.py:138 msgid "Cannot expand fs on non-raspberry pi devices!" msgstr "" #. Message shown after trying to expand the filesystem without admin #. permissions -#: app.py:779 +#: routes/admin.py:141 msgid "You don't have permission to resize the filesystem" msgstr "" -#. Message shown after trying to change preferences without admin permissions. -#: app.py:794 -msgid "You don't have permission to change preferences" +#. Message shown after logging in as admin successfully +#: routes/admin.py:156 +msgid "Admin mode granted!" msgstr "" -#. Message shown after trying to clear preferences without admin permissions. -#: app.py:808 -msgid "You don't have permission to clear preferences" +#. Message shown after failing to login as admin +#: routes/admin.py:160 +msgid "Incorrect admin password!" msgstr "" -#. Message shown after trying to stream a file that does not exist. -#: app.py:867 -msgid "File not found." +#. Message shown after logging out as admin successfully +#: routes/admin.py:174 +msgid "Logged out of admin mode!" msgstr "" -#: karaoke.py:276 -msgid "Your preferences were changed successfully" +#. Title of the files page. +#. Navigation link for the page where the user can add existing songs to the +#. queue. +#: routes/files.py:68 templates/base.html:201 +msgid "Browse" msgstr "" -#: karaoke.py:279 -msgid "Something went wrong! Your preferences were not changed" +#. Message shown after trying to delete a song that is in the queue. +#: routes/files.py:83 +msgid "Error: Can't delete this song because it is in the current queue" msgstr "" -#: karaoke.py:284 -msgid "Your preferences were cleared successfully" +#. Message shown after deleting a song. Followed by the song path +#: routes/files.py:91 +#, python-format +msgid "Song deleted: %s" msgstr "" -#: karaoke.py:286 -msgid "Something went wrong! Your preferences were not cleared" +#. Message shown after trying to delete a song without specifying the song. +#: routes/files.py:94 +msgid "Error: No song specified!" msgstr "" -#. Message shown after the download is started -#: karaoke.py:436 -#, python-format -msgid "Downloading video: %s" +#. Message shown after trying to edit a song that is in the queue. +#: routes/files.py:103 +msgid "Error: Can't edit this song because it is in the current queue: " msgstr "" -#. Message shown after the download is completed and queued -#: karaoke.py:452 +#. Message shown after trying to rename a file to a name that already exists. +#: routes/files.py:131 #, python-format -msgid "Downloaded and queued: %s" +msgid "Error renaming file: '%s' to '%s', Filename already exists" msgstr "" -#. Message shown after the download is completed but not queued -#: karaoke.py:455 +#. Message shown after renaming a file. +#: routes/files.py:139 #, python-format -msgid "Downloaded: %s" +msgid "Renamed file: %s to %s" msgstr "" -#. Message shown after the download is completed but the adding to queue fails -#: karaoke.py:464 -msgid "Error queueing song: " +#. Message shown after trying to edit a song without specifying the filename. +#: routes/files.py:144 +msgid "Error: No filename parameters were specified!" msgstr "" -#. Message shown after the download process is completed but the song is not -#. found -#: karaoke.py:467 -msgid "Error downloading song: " +#: routes/info.py:32 +msgid "CPU usage query unsupported" msgstr "" -#. Message shown when the song ends abnormally -#: karaoke.py:670 -#, python-format -msgid "Song ended abnormally: %s" +#. Message shown after trying to change preferences without admin permissions. +#: routes/preferences.py:23 +msgid "You don't have permission to change preferences" msgstr "" -#. Message shown after the song is transposed, first is the semitones and then -#. the song name -#: karaoke.py:678 -#, python-format -msgid "Transposing by %s semitones: %s" +#. Message shown after trying to clear preferences without admin permissions. +#: routes/preferences.py:38 +msgid "You don't have permission to clear preferences" msgstr "" -#: karaoke.py:711 +#. Message shown after adding random tracks +#: routes/queue.py:43 #, python-format -msgid "You reached the limit of %s song(s) from an user in queue!" +msgid "Added %s random tracks" msgstr "" -#. Message shown after the song is added to the top of the queue -#: karaoke.py:723 -#, python-format -msgid "%s added to top of queue: %s" +#. Message shown after running out songs to add during random track addition +#: routes/queue.py:46 +msgid "Ran out of songs!" msgstr "" -#. Message shown after the song is added to the queue -#: karaoke.py:728 -#, python-format -msgid "%s added to the queue: %s" +#. Message shown after clearing the queue +#: routes/queue.py:57 +msgid "Cleared the queue!" msgstr "" -#: karaoke.py:730 -#, python-format -msgid "Song added to the queue: %s" +#. Message shown after moving a song down in the queue +#: routes/queue.py:66 +msgid "Moved down in queue" msgstr "" -#. Message shown after the queue is cleared -#: karaoke.py:754 -msgid "Clear queue" +#. Message shown after failing to move a song down in the queue +#: routes/queue.py:69 +msgid "Error moving down in queue" msgstr "" -#. Message shown after the song is skipped, will be followed by song name -#: karaoke.py:800 -#, python-format -msgid "Skip: %s" +#. Message shown after moving a song up in the queue +#: routes/queue.py:74 +msgid "Moved up in queue" msgstr "" -#. Message shown after the song is resumed, will be followed by song name -#: karaoke.py:811 -#, python-format -msgid "Resume: %s" +#. Message shown after failing to move a song up in the queue +#: routes/queue.py:77 +msgid "Error moving up in queue" msgstr "" -#. Message shown after the song is paused, will be followed by song name -#: karaoke.py:814 -msgid "Pause" +#. Message shown after deleting a song from the queue +#: routes/queue.py:82 +msgid "Deleted from queue" msgstr "" -#. Message shown after the volume is changed, will be followed by the volume -#. level -#: karaoke.py:824 +#. Message shown after failing to delete a song from the queue +#: routes/queue.py:85 +msgid "Error deleting from queue" +msgstr "" + +#. Message shown after starting a download. Song title is displayed in the +#. message. +#: routes/search.py:79 #, python-format -msgid "Volume: %s%" +msgid "Download started: %s. This may take a couple of minutes to complete." +msgstr "" + +#. Message shown after starting a download that will be adding a song to the +#. queue. +#: routes/search.py:85 +msgid "Song will be added to queue." +msgstr "" + +#. Message shown after after starting a download. +#: routes/search.py:88 +msgid "Song will appear in the \"available songs\" list." +msgstr "" + +#. Message shown after trying to stream a file that does not exist. +#: routes/stream.py:72 +msgid "File not found." msgstr "" #. Prompt which asks the user their name when they first try to add to the diff --git a/pikaraoke/routes/admin.py b/pikaraoke/routes/admin.py new file mode 100644 index 00000000..77d607e4 --- /dev/null +++ b/pikaraoke/routes/admin.py @@ -0,0 +1,175 @@ +import datetime +import os +import subprocess +import sys +import threading +import time + +import cherrypy +import flask_babel +from flask import ( + Blueprint, + flash, + make_response, + redirect, + render_template, + request, + url_for, +) + +from pikaraoke.karaoke import Karaoke +from pikaraoke.lib.current_app import get_admin_password, get_karaoke_instance, is_admin + +_ = flask_babel.gettext + + +admin_bp = Blueprint("admin", __name__) + + +def delayed_halt(cmd: int, k: Karaoke): + 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") + + +@admin_bp.route("/update_ytdl") +def update_ytdl(): + k = get_karaoke_instance() + + def update_youtube_dl(): + time.sleep(3) + k.upgrade_youtubedl() + + if is_admin(): + flash( + # MSG: Message shown after starting the youtube-dl update. + _("Updating youtube-dl! Should take a minute or two... "), + "is-warning", + ) + th = threading.Thread(target=update_youtube_dl) + th.start() + else: + # MSG: Message shown after trying to update youtube-dl without admin permissions. + flash(_("You don't have permission to update youtube-dl"), "is-danger") + return redirect(url_for("home.home")) + + +@admin_bp.route("/refresh") +def refresh(): + k = get_karaoke_instance() + if is_admin(): + k.get_available_songs() + else: + # MSG: Message shown after trying to refresh the song list without admin permissions. + flash(_("You don't have permission to shut down"), "is-danger") + return redirect(url_for("files.browse")) + + +@admin_bp.route("/quit") +def quit(): + k = get_karaoke_instance() + if is_admin(): + # MSG: Message shown after quitting pikaraoke. + msg = _("Exiting pikaraoke now!") + flash(msg, "is-danger") + k.send_message_to_splash(msg, "danger") + th = threading.Thread(target=delayed_halt, args=[0, k]) + th.start() + else: + # MSG: Message shown after trying to quit pikaraoke without admin permissions. + flash(_("You don't have permission to quit"), "is-danger") + return redirect(url_for("home.home")) + + +@admin_bp.route("/shutdown") +def shutdown(): + k = get_karaoke_instance() + if is_admin(): + # MSG: Message shown after shutting down the system. + msg = _("Shutting down system now!") + flash(msg, "is-danger") + k.send_message_to_splash(msg, "danger") + th = threading.Thread(target=delayed_halt, args=[1, k]) + th.start() + else: + # MSG: Message shown after trying to shut down the system without admin permissions. + flash(_("You don't have permission to shut down"), "is-danger") + return redirect(url_for("home.home")) + + +@admin_bp.route("/reboot") +def reboot(): + k = get_karaoke_instance() + if is_admin(): + # MSG: Message shown after rebooting the system. + msg = _("Rebooting system now!") + flash(msg, "is-danger") + k.send_message_to_splash(msg, "danger") + th = threading.Thread(target=delayed_halt, args=[2, k]) + th.start() + else: + # MSG: Message shown after trying to reboot the system without admin permissions. + flash(_("You don't have permission to Reboot"), "is-danger") + return redirect(url_for("home.home")) + + +@admin_bp.route("/expand_fs") +def expand_fs(): + k = get_karaoke_instance() + if is_admin() and k.is_raspberry_pi: + # MSG: Message shown after expanding the filesystem. + flash(_("Expanding filesystem and rebooting system now!"), "is-danger") + th = threading.Thread(target=delayed_halt, args=[3, k]) + th.start() + elif not k.is_raspberry_pi: + # MSG: Message shown after trying to expand the filesystem on a non-raspberry pi device. + flash(_("Cannot expand fs on non-raspberry pi devices!"), "is-danger") + else: + # MSG: Message shown after trying to expand the filesystem without admin permissions + flash(_("You don't have permission to resize the filesystem"), "is-danger") + return redirect(url_for("home.home")) + + +@admin_bp.route("/auth", methods=["POST"]) +def auth(): + d = request.form.to_dict() + admin_password = get_admin_password() + 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 + + +@admin_bp.route("/login") +def login(): + return render_template("login.html") + + +@admin_bp.route("/logout") +def logout(): + resp = make_response(redirect("/")) + resp.set_cookie("admin", "") + # MSG: Message shown after logging out as admin successfully + flash(_("Logged out of admin mode!"), "is-success") + return resp diff --git a/pikaraoke/routes/background_music.py b/pikaraoke/routes/background_music.py new file mode 100644 index 00000000..91c2587b --- /dev/null +++ b/pikaraoke/routes/background_music.py @@ -0,0 +1,52 @@ +import os +import random +import urllib + +import flask_babel +from flask import Blueprint, jsonify, send_file + +from pikaraoke.lib.current_app import get_karaoke_instance + +background_music_bp = Blueprint("bg_music", __name__) + +_ = flask_babel.gettext + + +def create_randomized_playlist(input_directory, base_url, max_songs=50): + # Get all mp3 files in the given directory + files = [ + f + for f in os.listdir(input_directory) + if f.lower().endswith(".mp3") or f.lower().endswith(".mp4") + ] + + # Shuffle the list of mp3 files + random.shuffle(files) + files = files[:max_songs] + + # Create the playlist + playlist = [] + for mp3 in files: + mp3 = urllib.parse.quote(mp3.encode("utf8")) + url = f"{base_url}/{mp3}" + playlist.append(f"{url}") + + return playlist + + +# Routes for streaming background music +@background_music_bp.route("/bg_music/", methods=["GET"]) +def bg_music(file): + k = get_karaoke_instance() + mp3_path = os.path.join(k.bg_music_path, file) + return send_file(mp3_path, mimetype="audio/mpeg") + + +# Route for getting the randomized background music playlist +@background_music_bp.route("/bg_playlist", methods=["GET"]) +def bg_playlist(): + k = get_karaoke_instance() + if (k.bg_music_path == None) or (not os.path.exists(k.bg_music_path)): + return jsonify([]) + playlist = create_randomized_playlist(k.bg_music_path, "/bg_music", 50) + return jsonify(playlist) diff --git a/pikaraoke/routes/files.py b/pikaraoke/routes/files.py new file mode 100644 index 00000000..6999574f --- /dev/null +++ b/pikaraoke/routes/files.py @@ -0,0 +1,145 @@ +import os + +import flask_babel +from flask import Blueprint, flash, redirect, render_template, request, url_for +from flask_paginate import Pagination, get_page_parameter + +from pikaraoke.lib.current_app import get_karaoke_instance, get_site_name, is_admin + +_ = flask_babel.gettext + + +files_bp = Blueprint("files", __name__) + + +@files_bp.route("/browse", methods=["GET"]) +def browse(): + k = get_karaoke_instance() + site_name = get_site_name() + 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(), + ) + + +@files_bp.route("/files/delete", methods=["GET"]) +def delete_file(): + k = get_karaoke_instance() + if "song" in request.args: + song_path = request.args["song"] + exists = any(item.get("file") == song_path for item in k.queue) + if exists: + flash( + # MSG: Message shown after trying to delete a song that is in the queue. + _("Error: Can't delete this song because it is in the current queue") + + ": " + + song_path, + "is-danger", + ) + else: + k.delete(song_path) + # MSG: Message shown after deleting a song. Followed by the song path + flash(_("Song deleted: %s") % k.filename_from_path(song_path), "is-warning") + else: + # MSG: Message shown after trying to delete a song without specifying the song. + flash(_("Error: No song specified!"), "is-danger") + return redirect(url_for("files.browse")) + + +@files_bp.route("/files/edit", methods=["GET", "POST"]) +def edit_file(): + k = get_karaoke_instance() + site_name = get_site_name() + # MSG: Message shown after trying to edit a song that is in the queue. + 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("files.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 + old_name, "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( + # MSG: Message shown after trying to rename a file to a name that already exists. + _("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( + # MSG: Message shown after renaming a file. + _("Renamed file: %s to %s") % (old_name, new_name), + "is-warning", + ) + else: + # MSG: Message shown after trying to edit a song without specifying the filename. + flash(_("Error: No filename parameters were specified!"), "is-danger") + return redirect(url_for("files.browse")) diff --git a/pikaraoke/routes/home.py b/pikaraoke/routes/home.py new file mode 100644 index 00000000..2363698d --- /dev/null +++ b/pikaraoke/routes/home.py @@ -0,0 +1,23 @@ +import flask_babel +from flask import Blueprint, render_template + +from pikaraoke.lib.current_app import get_karaoke_instance, get_site_name, is_admin + +_ = flask_babel.gettext + + +home_bp = Blueprint("home", __name__) + + +@home_bp.route("/") +def home(): + k = get_karaoke_instance() + site_name = get_site_name() + return render_template( + "home.html", + site_title=site_name, + title="Home", + transpose_value=k.now_playing_transpose, + admin=is_admin(), + is_transpose_enabled=k.is_transpose_enabled, + ) diff --git a/pikaraoke/routes/images.py b/pikaraoke/routes/images.py new file mode 100644 index 00000000..e08a8ab5 --- /dev/null +++ b/pikaraoke/routes/images.py @@ -0,0 +1,20 @@ +import flask_babel +from flask import Blueprint, send_file + +from pikaraoke.lib.current_app import get_karaoke_instance + +_ = flask_babel.gettext + +images_bp = Blueprint("images", __name__) + + +@images_bp.route("/qrcode") +def qrcode(): + k = get_karaoke_instance() + return send_file(k.qr_code_path, mimetype="image/png") + + +@images_bp.route("/logo") +def logo(): + k = get_karaoke_instance() + return send_file(k.logo_path, mimetype="image/png") diff --git a/pikaraoke/routes/info.py b/pikaraoke/routes/info.py new file mode 100644 index 00000000..57866074 --- /dev/null +++ b/pikaraoke/routes/info.py @@ -0,0 +1,84 @@ +import flask_babel +import psutil +from flask import Blueprint, render_template + +from pikaraoke import VERSION +from pikaraoke.lib.current_app import ( + get_admin_password, + get_karaoke_instance, + get_site_name, + is_admin, +) +from pikaraoke.lib.get_platform import get_platform + +_ = flask_babel.gettext + + +info_bp = Blueprint("info", __name__) + + +@info_bp.route("/info") +def info(): + k = get_karaoke_instance() + site_name = get_site_name() + url = k.url + admin_password = get_admin_password() + is_linux = get_platform() == "linux" + + # cpu + try: + cpu = str(psutil.cpu_percent()) + "%" + except: + cpu = _("CPU usage query unsupported") + + # 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, + is_transpose_enabled=k.is_transpose_enabled, + youtubedl_version=youtubedl_version, + platform=k.platform, + os_version=k.os_version, + is_pi=k.is_raspberry_pi, + is_linux=is_linux, + pikaraoke_version=VERSION, + admin=is_admin(), + admin_enabled=admin_password != None, + disable_bg_music=k.disable_bg_music, + bg_music_volume=int(100 * k.bg_music_volume), + disable_score=k.disable_score, + hide_url=k.hide_url, + limit_user_songs_by=k.limit_user_songs_by, + hide_notifications=k.hide_notifications, + hide_overlay=k.hide_overlay, + normalize_audio=k.normalize_audio, + complete_transcode_before_play=k.complete_transcode_before_play, + high_quality_audio=k.high_quality, + splash_delay=k.splash_delay, + screensaver_timeout=k.screensaver_timeout, + volume=int(100 * k.volume), + buffer_size=k.buffer_size, + ) diff --git a/pikaraoke/routes/preferences.py b/pikaraoke/routes/preferences.py new file mode 100644 index 00000000..6d9b6978 --- /dev/null +++ b/pikaraoke/routes/preferences.py @@ -0,0 +1,39 @@ +import flask_babel +from flask import Blueprint, flash, jsonify, redirect, request, url_for + +from pikaraoke.lib.current_app import get_karaoke_instance, is_admin + +preferences_bp = Blueprint("preferences", __name__) + +_ = flask_babel.gettext + + +@preferences_bp.route("/change_preferences", methods=["GET"]) +def change_preferences(): + k = get_karaoke_instance() + if is_admin(): + preference = request.args["pref"] + val = request.args["val"] + + rc = k.change_preferences(preference, val) + + return jsonify(rc) + else: + # MSG: Message shown after trying to change preferences without admin permissions. + flash(_("You don't have permission to change preferences"), "is-danger") + return redirect(url_for("info.info")) + + +@preferences_bp.route("/clear_preferences", methods=["GET"]) +def clear_preferences(): + k = get_karaoke_instance() + if is_admin(): + rc = k.clear_preferences() + if rc[0]: + flash(rc[1], "is-success") + else: + flash(rc[1], "is-danger") + else: + # MSG: Message shown after trying to clear preferences without admin permissions. + flash(_("You don't have permission to clear preferences"), "is-danger") + return redirect(url_for("home.home")) diff --git a/pikaraoke/routes/queue.py b/pikaraoke/routes/queue.py new file mode 100644 index 00000000..b8337a25 --- /dev/null +++ b/pikaraoke/routes/queue.py @@ -0,0 +1,104 @@ +import json + +import flask_babel +from flask import Blueprint, flash, redirect, render_template, request, url_for + +from pikaraoke.lib.current_app import get_karaoke_instance, get_site_name, is_admin + +try: + from urllib.parse import unquote +except ImportError: + from urllib import unquote + +_ = flask_babel.gettext + +queue_bp = Blueprint("queue", __name__) + + +@queue_bp.route("/queue") +def queue(): + k = get_karaoke_instance() + site_name = get_site_name() + return render_template( + "queue.html", queue=k.queue, site_title=site_name, title="Queue", admin=is_admin() + ) + + +@queue_bp.route("/get_queue") +def get_queue(): + k = get_karaoke_instance() + if len(k.queue) >= 1: + return json.dumps(k.queue) + else: + return json.dumps([]) + + +@queue_bp.route("/queue/addrandom", methods=["GET"]) +def add_random(): + k = get_karaoke_instance() + amount = int(request.args["amount"]) + rc = k.queue_add_random(amount) + if rc: + # MSG: Message shown after adding random tracks + flash(_("Added %s random tracks") % amount, "is-success") + else: + # MSG: Message shown after running out songs to add during random track addition + flash(_("Ran out of songs!"), "is-warning") + return redirect(url_for("queue.queue")) + + +@queue_bp.route("/queue/edit", methods=["GET"]) +def queue_edit(): + k = get_karaoke_instance() + action = request.args["action"] + if action == "clear": + k.queue_clear() + # MSG: Message shown after clearing the queue + flash(_("Cleared the queue!"), "is-warning") + return redirect(url_for("queue.queue")) + else: + song = request.args["song"] + song = unquote(song) + if action == "down": + result = k.queue_edit(song, "down") + if result: + # MSG: Message shown after moving a song down in the queue + flash(_("Moved down in queue") + ": " + song, "is-success") + else: + # MSG: Message shown after failing to move a song down in the queue + flash(_("Error moving down in queue") + ": " + song, "is-danger") + elif action == "up": + result = k.queue_edit(song, "up") + if result: + # MSG: Message shown after moving a song up in the queue + flash(_("Moved up in queue") + ": " + song, "is-success") + else: + # MSG: Message shown after failing to move a song up in the queue + flash(_("Error moving up in queue") + ": " + song, "is-danger") + elif action == "delete": + result = k.queue_edit(song, "delete") + if result: + # MSG: Message shown after deleting a song from the queue + flash(_("Deleted from queue") + ": " + song, "is-success") + else: + # MSG: Message shown after failing to delete a song from the queue + flash(_("Error deleting from queue") + ": " + song, "is-danger") + return redirect(url_for("queue.queue")) + + +@queue_bp.route("/enqueue", methods=["POST", "GET"]) +def enqueue(): + k = get_karaoke_instance() + 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 = k.filename_from_path(song) + return json.dumps({"song": song_title, "success": rc}) diff --git a/pikaraoke/routes/search.py b/pikaraoke/routes/search.py new file mode 100644 index 00000000..3027e7c4 --- /dev/null +++ b/pikaraoke/routes/search.py @@ -0,0 +1,90 @@ +import json +import threading + +import flask_babel +from flask import ( + Blueprint, + current_app, + flash, + redirect, + render_template, + request, + url_for, +) + +from pikaraoke.lib.current_app import get_karaoke_instance, get_site_name + +_ = flask_babel.gettext + +search_bp = Blueprint("search", __name__) + + +@search_bp.route("/search", methods=["GET"]) +def search(): + k = get_karaoke_instance() + site_name = get_site_name() + 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, + ) + + +@search_bp.route("/autocomplete") +def autocomplete(): + k = get_karaoke_instance() + 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 = current_app.response_class(response=json.dumps(result), mimetype="application/json") + return response + + +@search_bp.route("/download", methods=["POST"]) +def download(): + k = get_karaoke_instance() + d = request.form.to_dict() + song = d["song-url"] + user = d["song-added-by"] + title = d["song-title"] + 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, title]) + t.daemon = True + t.start() + + displayed_title = title if title else song + flash_message = ( + # MSG: Message shown after starting a download. Song title is displayed in the message. + _("Download started: %s. This may take a couple of minutes to complete.") + % displayed_title + ) + + if queue: + # MSG: Message shown after starting a download that will be adding a song to the queue. + flash_message += _("Song will be added to queue.") + else: + # MSG: Message shown after after starting a download. + flash_message += _('Song will appear in the "available songs" list.') + flash(flash_message, "is-info") + return redirect(url_for("search.search")) diff --git a/pikaraoke/routes/splash.py b/pikaraoke/routes/splash.py new file mode 100644 index 00000000..6d8a169e --- /dev/null +++ b/pikaraoke/routes/splash.py @@ -0,0 +1,45 @@ +import subprocess + +import flask_babel +from flask import Blueprint, render_template + +from pikaraoke.lib.current_app import get_karaoke_instance +from pikaraoke.lib.raspi_wifi_config import get_raspi_wifi_text + +_ = flask_babel.gettext + + +splash_bp = Blueprint("splash", __name__) + + +@splash_bp.route("/splash") +def splash(): + k = get_karaoke_instance() + # Only do this on Raspberry Pis + if k.is_raspberry_pi: + status = subprocess.run(["iwconfig", "wlan0"], stdout=subprocess.PIPE).stdout.decode( + "utf-8" + ) + text = "" + if "Mode:Master" in status: + # handle raspiwifi connection mode + text = get_raspi_wifi_text() + 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, + disable_bg_music=k.disable_bg_music, + disable_score=k.disable_score, + bg_music_volume=k.bg_music_volume, + ) diff --git a/pikaraoke/routes/stream.py b/pikaraoke/routes/stream.py new file mode 100644 index 00000000..76f82648 --- /dev/null +++ b/pikaraoke/routes/stream.py @@ -0,0 +1,73 @@ +import os +import re +import time + +import flask_babel +from flask import Blueprint, Response, flash, redirect, request, url_for + +from pikaraoke.lib.current_app import get_karaoke_instance +from pikaraoke.lib.file_resolver import get_tmp_dir + +_ = flask_babel.gettext + +stream_bp = Blueprint("stream", __name__) + + +# Streams the file in chunks from the filesystem (chrome supports it, safari does not) +@stream_bp.route("/stream/") +def stream(id): + file_path = os.path.join(get_tmp_dir(), f"{id}.mp4") + k = get_karaoke_instance() + + def generate(): + position = 0 # Initialize the position variable + chunk_size = 10240 * 1000 * 25 # Read file in up to 25MB chunks + with open(file_path, "rb") as file: + # Keep yielding file chunks as long as ffmpeg process is transcoding + while k.ffmpeg_process.poll() is None: + file.seek(position) # Move to the last read position + chunk = file.read(chunk_size) + if chunk is not None and len(chunk) > 0: + yield chunk + position += len(chunk) # Update the position with the size of the chunk + time.sleep(1) # Wait a bit before checking the file size again + chunk = file.read(chunk_size) # Read the last chunk + yield chunk + position += len(chunk) # Update the position with the size of the chunk + + return Response(generate(), mimetype="video/mp4") + + +# Streams the file in full with proper range headers +# (Safari compatible, but requires the ffmpeg transcoding to be complete to know file size) +@stream_bp.route("/stream/full/") +def stream_full(id): + file_path = os.path.join(get_tmp_dir(), f"{id}.mp4") + try: + file_size = os.path.getsize(file_path) + range_header = request.headers.get("Range", None) + if not range_header: + with open(file_path, "rb") as file: + file_content = file.read() + return Response(file_content, mimetype="video/mp4") + # Extract range start and end from Range header (e.g., "bytes=0-499") + range_match = re.search(r"bytes=(\d+)-(\d*)", range_header) + start, end = range_match.groups() + start = int(start) + end = int(end) if end else file_size - 1 + # Generate response with part of file + with open(file_path, "rb") as file: + file.seek(start) + data = file.read(end - start + 1) + status_code = 206 # Partial content + headers = { + "Content-Type": "video/mp4", + "Accept-Ranges": "bytes", + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Content-Length": str(len(data)), + } + return Response(data, status=status_code, headers=headers) + except IOError: + # MSG: Message shown after trying to stream a file that does not exist. + flash(_("File not found."), "is-danger") + return redirect(url_for("home.home")) diff --git a/pikaraoke/templates/base.html b/pikaraoke/templates/base.html index 2f54f714..2afd2395 100644 --- a/pikaraoke/templates/base.html +++ b/pikaraoke/templates/base.html @@ -179,23 +179,23 @@