From a4038cee50d88f6f6636eb5819a88b223e7e4073 Mon Sep 17 00:00:00 2001 From: Vic Wong Date: Sun, 5 Jan 2025 02:24:46 -0800 Subject: [PATCH 1/6] refactor: simplify arg debug output, modularize ffmeg/ytdlp/raspiwifi utilities --- .gitignore | 2 +- pikaraoke/app.py | 44 ++---- pikaraoke/karaoke.py | 210 +++++++---------------------- pikaraoke/lib/args.py | 13 +- pikaraoke/lib/ffmpeg.py | 22 ++- pikaraoke/lib/file_resolver.py | 1 - pikaraoke/lib/get_platform.py | 55 -------- pikaraoke/lib/raspi_wifi_config.py | 54 ++++++++ pikaraoke/lib/selenium.py | 2 +- pikaraoke/lib/youtube_dl.py | 63 +++++++++ 10 files changed, 207 insertions(+), 259 deletions(-) create mode 100644 pikaraoke/lib/raspi_wifi_config.py create mode 100644 pikaraoke/lib/youtube_dl.py diff --git a/.gitignore b/.gitignore index 08c6429f..9e4e8b19 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,5 @@ dist/ songs/ qrcode.png .DS_Store -config.ini +*.ini docker-compose.yml diff --git a/pikaraoke/app.py b/pikaraoke/app.py index 69241255..48059b0e 100644 --- a/pikaraoke/app.py +++ b/pikaraoke/app.py @@ -28,21 +28,15 @@ ) from flask_babel import Babel from flask_paginate import Pagination, get_page_parameter -from selenium import webdriver -from selenium.common.exceptions import SessionNotCreatedException -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.constants import LANGUAGES from pikaraoke.lib.args import parse_pikaraoke_args from pikaraoke.lib.background_music import create_randomized_playlist +from pikaraoke.lib.ffmpeg import is_ffmpeg_installed from pikaraoke.lib.file_resolver import delete_tmp_dir, get_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 try: @@ -266,7 +260,7 @@ def enqueue(): d = request.form.to_dict() user = d["song-added-by"] rc = k.enqueue(song, user) - song_title = filename_from_path(song) + song_title = k.filename_from_path(song) return json.dumps({"song": song_title, "success": rc}) @@ -556,28 +550,8 @@ def splash(): ) 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]}"] + # handle raspiwifi connection mode + text = get_raspi_wifi_text() else: # You are connected to Wifi as a client text = "" @@ -850,6 +824,12 @@ def main(): args = parse_pikaraoke_args() + if not is_ffmpeg_installed(): + logging.error( + "ffmpeg is not installed, which is required to run PiKaraoke. See: https://www.ffmpeg.org/" + ) + sys.exit(1) + if args.admin_password: global admin_password admin_password = args.admin_password @@ -873,7 +853,6 @@ def main(): buffer_size=args.buffer_size, hide_url=args.hide_url, hide_notifications=args.hide_notifications, - hide_raspiwifi_instructions=args.hide_raspiwifi_instructions, hide_splash_screen=args.hide_splash_screen, high_quality=args.high_quality, logo_path=args.logo_path, @@ -886,6 +865,7 @@ def main(): bg_music_path=args.bg_music_path, disable_score=args.disable_score, limit_user_songs_by=args.limit_user_songs_by, + config_file_path=args.config_file_path, ) # Expose some functions to jinja templates diff --git a/pikaraoke/karaoke.py b/pikaraoke/karaoke.py index 2c0bda13..46cba8c1 100644 --- a/pikaraoke/karaoke.py +++ b/pikaraoke/karaoke.py @@ -11,9 +11,8 @@ import time from pathlib import Path from queue import Queue -from subprocess import CalledProcessError, check_output +from subprocess import check_output from threading import Thread -from urllib.parse import urlparse import qrcode from flask_babel import _ @@ -23,17 +22,19 @@ build_ffmpeg_cmd, get_ffmpeg_version, is_transpose_enabled, + supports_hardware_h264_encoding, ) from pikaraoke.lib.file_resolver import ( FileResolver, delete_tmp_dir, is_transcoding_required, ) -from pikaraoke.lib.get_platform import ( - get_os_version, - get_platform, - is_raspberry_pi, - supports_hardware_h264_encoding, +from pikaraoke.lib.get_platform import get_os_version, get_platform, is_raspberry_pi +from pikaraoke.lib.youtube_dl import ( + build_ytdl_download_command, + get_youtube_id_from_url, + get_youtubedl_version, + upgrade_youtubedl, ) @@ -44,15 +45,7 @@ def enqueue_output(out, queue): out.close() -def decode_ignore(input): - 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) - queue = [] available_songs = [] @@ -78,13 +71,8 @@ class Karaoke: ffmpeg_process = None ffmpeg_log = None - ffmpeg_version = get_ffmpeg_version() - is_transpose_enabled = is_transpose_enabled() normalize_audio = False - raspberry_pi = is_raspberry_pi() - os_version = get_os_version() - config_obj = configparser.ConfigParser() def __init__( @@ -93,7 +81,6 @@ def __init__( download_path="/usr/lib/pikaraoke/songs", hide_url=False, hide_notifications=False, - hide_raspiwifi_instructions=False, hide_splash_screen=False, high_quality=False, volume=0.85, @@ -113,6 +100,7 @@ def __init__( bg_music_path=None, disable_score=False, limit_user_songs_by=0, + config_file_path="config.ini", ): logging.basicConfig( format="[%(asctime)s] %(levelname)s: %(message)s", @@ -120,13 +108,22 @@ def __init__( level=int(log_level), ) - # override with supplied constructor args if provided + # Platform-specific initializations + self.platform = get_platform() + self.os_version = get_os_version() + self.ffmpeg_version = get_ffmpeg_version() + self.is_transpose_enabled = is_transpose_enabled() + self.supports_hardware_h264_encoding = supports_hardware_h264_encoding() + self.youtubedl_version = get_youtubedl_version(youtubedl_path) + self.is_raspberry_pi = is_raspberry_pi() + + # Initialize variables + self.config_file_path = config_file_path self.port = port self.hide_url = self.get_user_preference("hide_url") or hide_url self.hide_notifications = ( self.get_user_preference("hide_notifications") or hide_notifications ) - self.hide_raspiwifi_instructions = hide_raspiwifi_instructions self.hide_splash_screen = hide_splash_screen self.download_path = download_path self.high_quality = self.get_user_preference("high_quality") or high_quality @@ -137,6 +134,7 @@ def __init__( self.get_user_preference("complete_transcode_before_play") or complete_transcode_before_play ) + self.log_level = log_level self.buffer_size = self.get_user_preference("buffer_size") or buffer_size self.youtubedl_path = youtubedl_path self.logo_path = self.default_logo_path if logo_path == None else logo_path @@ -144,7 +142,6 @@ def __init__( self.screensaver_timeout = ( self.get_user_preference("screensaver_timeout") or screensaver_timeout ) - self.url_override = url self.prefer_hostname = prefer_hostname self.disable_bg_music = self.get_user_preference("disable_bg_music") or disable_bg_music self.bg_music_volume = self.get_user_preference("bg_music_volume") or bg_music_volume @@ -153,49 +150,19 @@ def __init__( self.limit_user_songs_by = ( self.get_user_preference("limit_user_songs_by") or limit_user_songs_by ) + self.url_override = url + self.url = self.get_url() - # other initializations - self.platform = get_platform() - self.screen = None - - logging.debug( - f""" - http port: {self.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} - complete transcode before play: {self.complete_transcode_before_play} - buffer size (kb): {self.buffer_size} - youtube-dl path: {self.youtubedl_path} - logo path: {self.logo_path} - log_level: {log_level} - hide overlay: {self.hide_overlay} - disable bg music: {self.disable_bg_music} - bg music volume: {self.bg_music_volume} - bg music path: {self.bg_music_path} - disable score: {self.disable_score} - limit user songs by: {self.limit_user_songs_by} - hide notifications: {self.hide_notifications} - - platform: {self.platform} - os version: {self.os_version} - ffmpeg version: {self.ffmpeg_version} - ffmpeg transpose support: {self.is_transpose_enabled} - hardware h264 encoding: {supports_hardware_h264_encoding()} - youtubedl-version: {self.get_youtubedl_version()} -""" - ) + # Log the settings to debug level + self.log_settings_to_debug() + + # get songs from download_path + self.get_available_songs() + + self.generate_qr_code() - # Generate connection URL and QR code, - if self.raspberry_pi: + def get_url(self): + if self.is_raspberry_pi: # 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 @@ -203,7 +170,7 @@ def __init__( addresses_str = check_output(["hostname", "-I"]).strip().decode("utf-8", "ignore") addresses = addresses_str.split(" ") self.ip = addresses[0] - if not self.is_network_connected(): + if len(self.ip) < 7: logging.debug("Couldn't get IP, retrying....") else: break @@ -214,26 +181,25 @@ def __init__( if self.url_override != None: logging.debug("Overriding URL with " + self.url_override) - self.url = self.url_override + url = self.url_override else: if self.prefer_hostname: - self.url = f"http://{socket.getfqdn().lower()}:{self.port}" + url = f"http://{socket.getfqdn().lower()}:{self.port}" else: - self.url = f"http://{self.ip}:{self.port}" - self.url_parsed = urlparse(self.url) - - # get songs from download_path - self.get_available_songs() + url = f"http://{self.ip}:{self.port}" + return url - self.get_youtubedl_version() - - self.generate_qr_code() + def log_settings_to_debug(self): + output = "" + for key, value in sorted(vars(self).items()): + output += f" {key}: {value}\n" + logging.debug("\n\n" + output) # def get_user_preferences(self, preference): def get_user_preference(self, preference, default_value=False): # Try to read the config file try: - self.config_obj.read("config.ini") + self.config_obj.read(self.config_file_path) except FileNotFoundError: return default_value @@ -270,7 +236,7 @@ def change_preferences(self, preference, val): userprefs = self.config_obj["USERPREFERENCES"] userprefs[preference] = str(val) setattr(self, preference, eval(str(val))) - with open("config.ini", "w") as conf: + with open(self.config_file_path, "w") as conf: self.config_obj.write(conf) self.changed_preferences = True return [True, _("Your preferences were changed successfully")] @@ -280,7 +246,7 @@ def change_preferences(self, preference, val): def clear_preferences(self): try: - os.remove("config.ini") + os.remove(self.config_file_path) return [True, _("Your preferences were cleared successfully")] except OSError: return [False, _("Something went wrong! Your preferences were not cleared")] @@ -311,67 +277,10 @@ def get_ip(self): s.close() return IP - def get_raspi_wifi_conf_vals(self): - """Extract values from the RaspiWiFi configuration file.""" - f = open(self.raspi_wifi_conf_file, "r") - - # Define default values. - # - # References: - # - https://github.com/jasbur/RaspiWiFi/blob/master/initial_setup.py (see defaults in input prompts) - # - https://github.com/jasbur/RaspiWiFi/blob/master/libs/reset_device/static_files/raspiwifi.conf - # - server_port = "80" - ssid_prefix = "RaspiWiFi Setup" - ssl_enabled = "0" - - # Override the default values according to the configuration file. - for line in f.readlines(): - if "server_port=" in line: - server_port = line.split("t=")[1].strip() - elif "ssid_prefix=" in line: - ssid_prefix = line.split("x=")[1].strip() - elif "ssl_enabled=" in line: - ssl_enabled = line.split("d=")[1].strip() - - 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: - # allow pip to break system packages (probably required if installed without venv) - args = ["install", "--upgrade", "yt-dlp", "--break-system-packages"] - try: - logging.info("Attempting youtube-dl upgrade via pip3...") - output = ( - check_output(["pip3"] + args, stderr=subprocess.STDOUT).decode("utf8").strip() - ) - except FileNotFoundError: - logging.info("Attempting youtube-dl upgrade via pip...") - output = ( - check_output(["pip"] + args, stderr=subprocess.STDOUT).decode("utf8").strip() - ) - self.get_youtubedl_version() - - logging.info("Done. New version: %s" % self.youtubedl_version) - - def is_network_connected(self): - return not len(self.ip) < 7 + self.youtubedl_version = upgrade_youtubedl(self.youtubedl_path) + logging.info("Done. Installed version: %s" % self.youtubedl_version) def generate_qr_code(self): logging.debug("Generating URL QR code") @@ -434,13 +343,9 @@ def download_video(self, video_url, enqueue=False, user="Pikaraoke", title=None) displayed_title = title if title else video_url # MSG: Message shown after the download is started self.log_and_send(_("Downloading video: %s" % displayed_title)) - dl_path = self.download_path + "%(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 = build_ytdl_download_command( + self.youtubedl_path, video_url, self.download_path, self.high_quality ) - 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: @@ -455,7 +360,7 @@ def download_video(self, video_url, enqueue=False, user="Pikaraoke", title=None) self.log_and_send(_("Downloaded: %s" % displayed_title), "success") self.get_available_songs() if enqueue: - y = self.get_youtube_id_from_url(video_url) + y = get_youtube_id_from_url(video_url) s = self.find_song_by_youtube_id(y) if s: self.enqueue(s, user, log_action=False) @@ -524,24 +429,11 @@ def find_song_by_youtube_id(self, youtube_id): logging.error("No available song found with youtube id: " + 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/") - 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) - 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)) + logging.debug("[FFMPEG] " + output.decode("utf-8", "ignore").strip()) def play_file(self, file_path, semitones=0): logging.info(f"Playing file: {file_path} transposed {semitones} semitones") diff --git a/pikaraoke/lib/args.py b/pikaraoke/lib/args.py index deb15a45..06ddb0a4 100644 --- a/pikaraoke/lib/args.py +++ b/pikaraoke/lib/args.py @@ -33,6 +33,7 @@ def parse_volume(volume, type): default_prefer_hostname = False default_bg_music_volume = 0.3 default_buffer_size = 150 +default_config_file_path = "config.ini" default_dl_dir = get_default_dl_dir(platform) default_youtubedl_path = "yt-dlp" @@ -132,12 +133,6 @@ def parse_pikaraoke_args(): help="Hide notifications from the splash screen.", 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", @@ -224,6 +219,12 @@ def parse_pikaraoke_args(): default="0", required=False, ), + parser.add_argument( + "--config-file-path", + help=f"Path to a config file to load settings from. Config file settings are set in the web interface or manually edited and will override command line arguments. Default {default_config_file_path}", + default=default_config_file_path, + required=False, + ), args = parser.parse_args() diff --git a/pikaraoke/lib/ffmpeg.py b/pikaraoke/lib/ffmpeg.py index b8f7a46b..397e37ed 100644 --- a/pikaraoke/lib/ffmpeg.py +++ b/pikaraoke/lib/ffmpeg.py @@ -3,8 +3,6 @@ import ffmpeg -from pikaraoke.lib.get_platform import supports_hardware_h264_encoding - def get_media_duration(file_path): try: @@ -101,9 +99,25 @@ def is_transpose_enabled(): try: filters = subprocess.run(["ffmpeg", "-filters"], capture_output=True) except FileNotFoundError: - # FFmpeg is not installed return False except IndexError: - # Unable to parse FFmpeg filters return False return "rubberband" in filters.stdout.decode() + + +def supports_hardware_h264_encoding(): + try: + codecs = subprocess.run(["ffmpeg", "-codecs"], capture_output=True) + except FileNotFoundError: + return False + except IndexError: + return False + return "h264_v4l2m2m" in codecs.stdout.decode() + + +def is_ffmpeg_installed(): + try: + subprocess.run(["ffmpeg", "-version"], capture_output=True) + except FileNotFoundError: + return False + return True diff --git a/pikaraoke/lib/file_resolver.py b/pikaraoke/lib/file_resolver.py index e28ff01d..7f65b475 100644 --- a/pikaraoke/lib/file_resolver.py +++ b/pikaraoke/lib/file_resolver.py @@ -12,7 +12,6 @@ def get_tmp_dir(): # Determine tmp directories (for things like extracted cdg files) pid = os.getpid() # for scoping tmp directories to this process - print(tempfile.gettempdir()) tmp_dir = os.path.join(tempfile.gettempdir(), f"{pid}") return tmp_dir diff --git a/pikaraoke/lib/get_platform.py b/pikaraoke/lib/get_platform.py index 4611ea85..e4611536 100644 --- a/pikaraoke/lib/get_platform.py +++ b/pikaraoke/lib/get_platform.py @@ -1,39 +1,9 @@ import io import os import platform -import re -import subprocess import sys -def get_ffmpeg_version(): - try: - # Execute the command 'ffmpeg -version' - result = subprocess.run( - ["ffmpeg", "-version"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True - ) - # Parse the first line to get the version - first_line = result.stdout.split("\n")[0] - version_info = first_line.split(" ")[2] # Assumes the version info is the third element - return version_info - except FileNotFoundError: - return "FFmpeg is not installed" - except IndexError: - return "Unable to parse FFmpeg version" - - -def is_transpose_enabled(): - try: - filters = subprocess.run(["ffmpeg", "-filters"], capture_output=True) - except FileNotFoundError: - # FFmpeg is not installed - return False - except IndexError: - # Unable to parse FFmpeg filters - return False - return "rubberband" in filters.stdout.decode() - - def is_raspberry_pi(): try: with io.open("/sys/firmware/devicetree/base/model", "r") as m: @@ -51,12 +21,6 @@ def is_android(): def get_platform(): if sys.platform == "darwin": return "osx" - # elif sys.platform.startswith("linux"): - # for key in os.environ: - # if key == "PREFIX": - # if "termux" in os.environ[key]: - # return "Termux on Android" - # return "linux" elif is_android(): return "android" elif is_raspberry_pi(): @@ -94,22 +58,3 @@ def get_default_dl_dir(platform): def get_os_version(): return platform.version() - - -def supports_hardware_h264_encoding(): - if is_raspberry_pi(): - platform = get_platform() - - # For other platform(OrangePI etc) - if platform is None: - return False - - # Raspberry Pi >= 5 no longer has hardware GPU decoding - match = re.search(r"Raspberry Pi (\d+)", platform) - if match: - model_number = int(match.group(1)) - if model_number >= 5: - return False - return True - else: - return False diff --git a/pikaraoke/lib/raspi_wifi_config.py b/pikaraoke/lib/raspi_wifi_config.py new file mode 100644 index 00000000..fb63eaa4 --- /dev/null +++ b/pikaraoke/lib/raspi_wifi_config.py @@ -0,0 +1,54 @@ +import os + +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) + + +def get_raspi_wifi_conf_vals(): + """Extract values from the RaspiWiFi configuration file.""" + f = open(raspi_wifi_conf_file, "r") + + # Define default values. + # + # References: + # - https://github.com/jasbur/RaspiWiFi/blob/master/initial_setup.py (see defaults in input prompts) + # - https://github.com/jasbur/RaspiWiFi/blob/master/libs/reset_device/static_files/raspiwifi.conf + # + server_port = "80" + ssid_prefix = "RaspiWiFi Setup" + ssl_enabled = "0" + + # Override the default values according to the configuration file. + for line in f.readlines(): + if "server_port=" in line: + server_port = line.split("t=")[1].strip() + elif "ssid_prefix=" in line: + ssid_prefix = line.split("x=")[1].strip() + elif "ssl_enabled=" in line: + ssl_enabled = line.split("d=")[1].strip() + elif "wpa_key=" in line: + wpa_key = line.split("wpa_key=")[1].strip() + + return (server_port, ssid_prefix, ssl_enabled, wpa_key) + + +def get_raspi_wifi_text(url): + # Wifi is setup as a Access Point + ap_name = "" + ap_password = "" + + if os.path.isfile(raspi_wifi_conf_file): + conf_vals = get_raspi_wifi_conf_vals() + ap_name = conf_vals[1] + ap_password = conf_vals[3] + + if len(ap_password) > 0: + text = [ + f"Wifi Network: {ap_name} Password: {ap_password}", + f"Configure Wifi: {url.rpartition(':')[0]}", + ] + else: + text = [f"Wifi Network: {ap_name}", f"Configure Wifi: {url.rpartition(':',1)[0]}"] + + return text diff --git a/pikaraoke/lib/selenium.py b/pikaraoke/lib/selenium.py index e10a0576..83c7feeb 100644 --- a/pikaraoke/lib/selenium.py +++ b/pikaraoke/lib/selenium.py @@ -11,7 +11,7 @@ def launch_splash_screen(karaoke, window_size=None): - if is_raspberry_pi(): + if karaoke.is_raspberry_pi: service = service(executable_path="/usr/bin/chromedriver") else: service = None diff --git a/pikaraoke/lib/youtube_dl.py b/pikaraoke/lib/youtube_dl.py new file mode 100644 index 00000000..47f76400 --- /dev/null +++ b/pikaraoke/lib/youtube_dl.py @@ -0,0 +1,63 @@ +import logging +import subprocess + + +def get_youtubedl_version(youtubedl_path): + return subprocess.check_output([youtubedl_path, "--version"]).strip().decode("utf8") + + +def get_youtube_id_from_url(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/") + 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) + return None + + +def upgrade_youtubedl(youtubedl_path): + try: + output = ( + subprocess.check_output([youtubedl_path, "-U"], stderr=subprocess.STDOUT) + .decode("utf8") + .strip() + ) + except subprocess.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: + # allow pip to break system packages (probably required if installed without venv) + args = ["install", "--upgrade", "yt-dlp", "--break-system-packages"] + try: + logging.info("Attempting youtube-dl upgrade via pip3...") + output = ( + subprocess.check_output(["pip3"] + args, stderr=subprocess.STDOUT) + .decode("utf8") + .strip() + ) + except FileNotFoundError: + logging.info("Attempting youtube-dl upgrade via pip...") + output = ( + subprocess.check_output(["pip"] + args, stderr=subprocess.STDOUT) + .decode("utf8") + .strip() + ) + youtubedl_version = get_youtubedl_version(youtubedl_path) + + return youtubedl_version + + +def build_ytdl_download_command(youtubedl_path, video_url, download_path, high_quality=False): + dl_path = download_path + "%(title)s---%(id)s.%(ext)s" + file_quality = ( + "bestvideo[ext!=webm][height<=1080]+bestaudio[ext!=webm]/best[ext!=webm]" + if high_quality + else "mp4" + ) + cmd = [youtubedl_path, "-f", file_quality, "-o", dl_path, video_url] + return cmd From 2f4897a4ec5ede18df1ea0dcc33110c06d08d9c8 Mon Sep 17 00:00:00 2001 From: Vic Wong Date: Sun, 5 Jan 2025 13:33:19 -0800 Subject: [PATCH 2/6] refactor: move app routes to separate files --- pikaraoke/app.py | 287 ++++++-------------------------- pikaraoke/karaoke.py | 2 + pikaraoke/lib/current_app.py | 60 +++++++ pikaraoke/lib/selenium.py | 4 - pikaraoke/routes/admin.py | 180 ++++++++++++++++++++ pikaraoke/routes/preferences.py | 43 +++++ pikaraoke/routes/stream.py | 77 +++++++++ pikaraoke/templates/info.html | 20 +-- pikaraoke/templates/login.html | 2 +- 9 files changed, 422 insertions(+), 253 deletions(-) create mode 100644 pikaraoke/lib/current_app.py create mode 100644 pikaraoke/routes/admin.py create mode 100644 pikaraoke/routes/preferences.py create mode 100644 pikaraoke/routes/stream.py diff --git a/pikaraoke/app.py b/pikaraoke/app.py index 48059b0e..5ed14861 100644 --- a/pikaraoke/app.py +++ b/pikaraoke/app.py @@ -1,21 +1,17 @@ -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, @@ -33,11 +29,15 @@ 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_admin_password, get_karaoke_instance, is_admin 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 get_admin_bp +from pikaraoke.routes.preferences import get_preferences_bp +from pikaraoke.routes.stream import get_stream_bp try: from urllib.parse import quote, unquote @@ -54,37 +54,14 @@ 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 additional routes +app.register_blueprint(get_stream_bp()) +app.register_blueprint(get_preferences_bp()) +app.register_blueprint(get_admin_bp()) @babel.localeselector @@ -100,6 +77,7 @@ def get_locale(): @app.route("/") def home(): + k = get_karaoke_instance() return render_template( "home.html", site_title=site_name, @@ -110,24 +88,6 @@ def home(): ) -@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") @@ -144,6 +104,7 @@ def logout(): @app.route("/nowplaying") def nowplaying(): + k = get_karaoke_instance() try: if len(k.queue) >= 1: next_song = k.queue[0]["title"] @@ -177,12 +138,14 @@ 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(): + k = get_karaoke_instance() return render_template( "queue.html", queue=k.queue, site_title=site_name, title="Queue", admin=is_admin() ) @@ -190,6 +153,7 @@ def queue(): @app.route("/get_queue") def get_queue(): + k = get_karaoke_instance() if len(k.queue) >= 1: return json.dumps(k.queue) else: @@ -198,6 +162,7 @@ def get_queue(): @app.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: @@ -211,6 +176,7 @@ def add_random(): @app.route("/queue/edit", methods=["GET"]) def queue_edit(): + k = get_karaoke_instance() action = request.args["action"] if action == "clear": k.queue_clear() @@ -249,6 +215,7 @@ def queue_edit(): @app.route("/enqueue", methods=["POST", "GET"]) def enqueue(): + k = get_karaoke_instance() if "song" in request.args: song = request.args["song"] else: @@ -266,48 +233,56 @@ def enqueue(): @app.route("/skip") def skip(): + k = get_karaoke_instance() k.skip() return redirect(url_for("home")) @app.route("/pause") def pause(): + k = get_karaoke_instance() k.pause() return redirect(url_for("home")) @app.route("/transpose/", methods=["GET"]) def transpose(semitones): + k = get_karaoke_instance() k.transpose_current(int(semitones)) return redirect(url_for("home")) @app.route("/restart") def restart(): + k = get_karaoke_instance() k.restart() return redirect(url_for("home")) @app.route("/volume/") def volume(volume): + k = get_karaoke_instance() k.volume_change(float(volume)) return redirect(url_for("home")) @app.route("/vol_up") def vol_up(): + k = get_karaoke_instance() k.vol_up() return redirect(url_for("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(): + k = get_karaoke_instance() if "search_string" in request.args: search_string = request.args["search_string"] if "non_karaoke" in request.args and request.args["non_karaoke"] == "true": @@ -329,6 +304,7 @@ def search(): @app.route("/autocomplete") def autocomplete(): + k = get_karaoke_instance() q = request.args.get("q").lower() result = [] for each in k.available_songs: @@ -342,6 +318,7 @@ def autocomplete(): @app.route("/browse", methods=["GET"]) def browse(): + k = get_karaoke_instance() search = False q = request.args.get("q") if q: @@ -399,6 +376,7 @@ def browse(): @app.route("/download", methods=["POST"]) def download(): + k = get_karaoke_instance() d = request.form.to_dict() song = d["song-url"] user = d["song-added-by"] @@ -432,17 +410,20 @@ def download(): @app.route("/qrcode") def qrcode(): + k = get_karaoke_instance() return send_file(k.qr_code_path, mimetype="image/png") @app.route("/logo") def logo(): + k = get_karaoke_instance() return send_file(k.logo_path, mimetype="image/png") # Routes for streaming background music @app.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") @@ -450,6 +431,7 @@ def bg_music(file): # Route for getting the randomized background music playlist @app.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) @@ -458,6 +440,7 @@ def bg_playlist(): @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,12 +449,14 @@ 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(): + 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) @@ -495,6 +480,7 @@ def delete_file(): @app.route("/files/edit", methods=["GET", "POST"]) def edit_file(): + k = get_karaoke_instance() # 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: @@ -543,6 +529,7 @@ def edit_file(): @app.route("/splash") def splash(): + k = get_karaoke_instance() # Only do this on Raspberry Pis if raspberry_pi: status = subprocess.run(["iwconfig", "wlan0"], stdout=subprocess.PIPE).stdout.decode( @@ -575,7 +562,9 @@ def splash(): @app.route("/info") def info(): + k = get_karaoke_instance() url = k.url + admin_password = get_admin_password() # cpu try: @@ -636,189 +625,6 @@ def info(): ) -@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 +636,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 +669,13 @@ 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 + # 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 +696,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/current_app.py b/pikaraoke/lib/current_app.py new file mode 100644 index 00000000..5f088e6f --- /dev/null +++ b/pikaraoke/lib/current_app.py @@ -0,0 +1,60 @@ +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 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..3ade5b74 100644 --- a/pikaraoke/lib/selenium.py +++ b/pikaraoke/lib/selenium.py @@ -1,14 +1,10 @@ from selenium import webdriver from selenium.common.exceptions import SessionNotCreatedException 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: diff --git a/pikaraoke/routes/admin.py b/pikaraoke/routes/admin.py new file mode 100644 index 00000000..d36a13f0 --- /dev/null +++ b/pikaraoke/routes/admin.py @@ -0,0 +1,180 @@ +import datetime +import os +import subprocess +import sys +import threading +import time + +import cherrypy +import flask_babel +from flask import ( + Blueprint, + current_app, + 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 get_admin_bp(): + return admin_bp + + +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")) + + +@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("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")) + + +@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")) + + +@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")) + + +@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")) + + +@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/preferences.py b/pikaraoke/routes/preferences.py new file mode 100644 index 00000000..61bce8db --- /dev/null +++ b/pikaraoke/routes/preferences.py @@ -0,0 +1,43 @@ +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 + + +def get_preferences_bp(): + return preferences_bp + + +@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")) + + +@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")) diff --git a/pikaraoke/routes/stream.py b/pikaraoke/routes/stream.py new file mode 100644 index 00000000..932e7fb1 --- /dev/null +++ b/pikaraoke/routes/stream.py @@ -0,0 +1,77 @@ +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__) + + +def get_stream_bp(): + return stream_bp + + +# 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")) diff --git a/pikaraoke/templates/info.html b/pikaraoke/templates/info.html index 61d1975f..fee1bd47 100644 --- a/pikaraoke/templates/info.html +++ b/pikaraoke/templates/info.html @@ -31,7 +31,7 @@ }) function changePreferences(pref, val) { - $.get("{{ url_for('change_preferences') }}", { + $.get("{{ url_for('preferences.change_preferences') }}", { "pref": pref, "val": val }) @@ -253,7 +253,7 @@

{# MSG: Title text for the server settings section
{# MSG: Text for the link where the user can clear all user preferences #} - {% trans %}Clear preferences{% endtrans %} + {% trans %}Clear preferences{% endtrans %}

@@ -264,7 +264,7 @@

{% trans %}Updates{% endtrans %}

{% trans %}Refresh the song list:{% endtrans %}

  • - {% trans %}Rescan song directory{% endtrans %} @@ -280,7 +280,7 @@

    {% trans %}Updates{% endtrans %}

    The current installed version is: "{{ youtubedl_version }}"{% endtrans %}

    • - {% trans %}Update youtube-dl{% endtrans %} @@ -304,13 +304,13 @@

      {% trans %}Shutdown{% endtrans %}

      @@ -318,7 +318,7 @@

      {% trans %}Shutdown{% endtrans %}

      {% if is_pi %} {# MSG: Title for section containing a few other options on the Info page. #}

      {% trans %}Other{% endtrans %}

      - + {# MSG: Text for button#} {% trans %}Expand Raspberry Pi filesystem{% endtrans %}
    • @@ -333,13 +333,13 @@

      {% trans %}Other{% endtrans %}


      {% if admin_enabled %} {# MSG: Link which will log out the user from admin mode. #} -

      {% trans logout_url=url_for('logout') -%} +

      {% trans logout_url=url_for('admin.logout') -%} Disable admin mode: Log out {%- endtrans %}

      {% endif %} {% else %} {# from if admin #} {# MSG: Link which will let the user log into admin mode. #} -

      {% trans login_url=url_for('login') %} +

      {% trans login_url=url_for('admin.login') %} Enable admin mode: Log in {% endtrans %}

      {% endif %} diff --git a/pikaraoke/templates/login.html b/pikaraoke/templates/login.html index 49c268fb..79468c6d 100644 --- a/pikaraoke/templates/login.html +++ b/pikaraoke/templates/login.html @@ -24,7 +24,7 @@

      {% block title %} {# MSG: Title fo the form to enter the administrator password. #}

      {% trans %}Enter the administrator password{% endtrans %}

      -
      +
      From c1c388b24c0a87a54b6bb26f5aeb27a2319c37e7 Mon Sep 17 00:00:00 2001 From: Vic Wong Date: Sun, 5 Jan 2025 16:36:17 -0800 Subject: [PATCH 3/6] fix: port background music routes to blueprint file --- pikaraoke/app.py | 37 ++---------------- pikaraoke/lib/background_music.py | 25 ------------- pikaraoke/routes/admin.py | 1 - pikaraoke/routes/background_music.py | 56 ++++++++++++++++++++++++++++ pikaraoke/templates/splash.html | 2 +- 5 files changed, 60 insertions(+), 61 deletions(-) delete mode 100644 pikaraoke/lib/background_music.py create mode 100644 pikaraoke/routes/background_music.py diff --git a/pikaraoke/app.py b/pikaraoke/app.py index 5ed14861..4383d4c3 100644 --- a/pikaraoke/app.py +++ b/pikaraoke/app.py @@ -28,7 +28,6 @@ from pikaraoke import VERSION, 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_admin_password, get_karaoke_instance, is_admin from pikaraoke.lib.ffmpeg import is_ffmpeg_installed from pikaraoke.lib.file_resolver import delete_tmp_dir @@ -36,6 +35,7 @@ from pikaraoke.lib.raspi_wifi_config import get_raspi_wifi_text from pikaraoke.lib.selenium import launch_splash_screen from pikaraoke.routes.admin import get_admin_bp +from pikaraoke.routes.background_music import get_background_music_bp from pikaraoke.routes.preferences import get_preferences_bp from pikaraoke.routes.stream import get_stream_bp @@ -62,6 +62,7 @@ app.register_blueprint(get_stream_bp()) app.register_blueprint(get_preferences_bp()) app.register_blueprint(get_admin_bp()) +app.register_blueprint(get_background_music_bp()) @babel.localeselector @@ -88,20 +89,6 @@ def home(): ) -@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() @@ -411,7 +398,7 @@ def download(): @app.route("/qrcode") def qrcode(): k = get_karaoke_instance() - return send_file(k.qr_code_path, mimetype="image/png") + return send_file(k.qr_codei9ew38905_path, mimetype="image/png") @app.route("/logo") @@ -420,24 +407,6 @@ 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): - 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 -@app.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) - - @app.route("/end_song", methods=["GET", "POST"]) def end_song(): k = get_karaoke_instance() 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/routes/admin.py b/pikaraoke/routes/admin.py index d36a13f0..e6f7cfc3 100644 --- a/pikaraoke/routes/admin.py +++ b/pikaraoke/routes/admin.py @@ -9,7 +9,6 @@ import flask_babel from flask import ( Blueprint, - current_app, flash, make_response, redirect, diff --git a/pikaraoke/routes/background_music.py b/pikaraoke/routes/background_music.py new file mode 100644 index 00000000..3ece4d79 --- /dev/null +++ b/pikaraoke/routes/background_music.py @@ -0,0 +1,56 @@ +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 get_background_music_bp(): + return background_music_bp + + +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/templates/splash.html b/pikaraoke/templates/splash.html index 040162e6..7d6c2445 100644 --- a/pikaraoke/templates/splash.html +++ b/pikaraoke/templates/splash.html @@ -384,7 +384,7 @@ }); // Load the background music playlist from server - $.get('{{ url_for("bg_playlist") }}', function (data) { + $.get('{{ url_for("bg_music.bg_playlist") }}', function (data) { if (data) bg_playlist = data; }); From 12f1d7e5918bf3153dbbdae567eb49052d246046 Mon Sep 17 00:00:00 2001 From: Vic Wong Date: Sun, 5 Jan 2025 17:03:44 -0800 Subject: [PATCH 4/6] fix: port queue routes to blueprint --- pikaraoke/app.py | 128 ++++++--------------------- pikaraoke/lib/current_app.py | 9 ++ pikaraoke/routes/admin.py | 4 - pikaraoke/routes/background_music.py | 4 - pikaraoke/routes/preferences.py | 4 - pikaraoke/routes/queue.py | 104 ++++++++++++++++++++++ pikaraoke/routes/stream.py | 4 - pikaraoke/templates/base.html | 2 +- pikaraoke/templates/files.html | 2 +- pikaraoke/templates/queue.html | 2 +- pikaraoke/templates/search.html | 2 +- 11 files changed, 142 insertions(+), 123 deletions(-) create mode 100644 pikaraoke/routes/queue.py diff --git a/pikaraoke/app.py b/pikaraoke/app.py index 4383d4c3..47553d19 100644 --- a/pikaraoke/app.py +++ b/pikaraoke/app.py @@ -13,8 +13,6 @@ from flask import ( Flask, flash, - jsonify, - make_response, redirect, render_template, request, @@ -28,21 +26,27 @@ from pikaraoke import VERSION, karaoke from pikaraoke.constants import LANGUAGES from pikaraoke.lib.args import parse_pikaraoke_args -from pikaraoke.lib.current_app import get_admin_password, get_karaoke_instance, is_admin +from pikaraoke.lib.current_app import ( + get_admin_password, + get_karaoke_instance, + get_site_name, + is_admin, +) from pikaraoke.lib.ffmpeg import is_ffmpeg_installed 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 get_admin_bp -from pikaraoke.routes.background_music import get_background_music_bp -from pikaraoke.routes.preferences import get_preferences_bp -from pikaraoke.routes.stream import get_stream_bp +from pikaraoke.routes.admin import admin_bp +from pikaraoke.routes.background_music import background_music_bp +from pikaraoke.routes.preferences import preferences_bp +from pikaraoke.routes.queue import queue_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,16 +57,16 @@ app.config["BABEL_TRANSLATION_DIRECTORIES"] = "translations" app.config["JSON_SORT_KEYS"] = False babel = Babel(app) -site_name = "PiKaraoke" raspberry_pi = is_raspberry_pi() linux = get_platform() == "linux" # Register blueprints additional routes -app.register_blueprint(get_stream_bp()) -app.register_blueprint(get_preferences_bp()) -app.register_blueprint(get_admin_bp()) -app.register_blueprint(get_background_music_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) @babel.localeselector @@ -79,6 +83,7 @@ def get_locale(): @app.route("/") def home(): k = get_karaoke_instance() + site_name = get_site_name() return render_template( "home.html", site_title=site_name, @@ -130,94 +135,6 @@ def clear_command(): return "" -@app.route("/queue") -def queue(): - k = get_karaoke_instance() - return render_template( - "queue.html", queue=k.queue, site_title=site_name, title="Queue", admin=is_admin() - ) - - -@app.route("/get_queue") -def get_queue(): - k = get_karaoke_instance() - if len(k.queue) >= 1: - return json.dumps(k.queue) - else: - return json.dumps([]) - - -@app.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")) - - -@app.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")) - 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(): - 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}) - - @app.route("/skip") def skip(): k = get_karaoke_instance() @@ -270,6 +187,7 @@ def vol_down(): @app.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": @@ -306,6 +224,7 @@ def autocomplete(): @app.route("/browse", methods=["GET"]) def browse(): k = get_karaoke_instance() + site_name = get_site_name() search = False q = request.args.get("q") if q: @@ -398,7 +317,7 @@ def download(): @app.route("/qrcode") def qrcode(): k = get_karaoke_instance() - return send_file(k.qr_codei9ew38905_path, mimetype="image/png") + return send_file(k.qr_code_path, mimetype="image/png") @app.route("/logo") @@ -450,6 +369,7 @@ def delete_file(): @app.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: @@ -532,6 +452,7 @@ def splash(): @app.route("/info") def info(): k = get_karaoke_instance() + site_name = get_site_name() url = k.url admin_password = get_admin_password() @@ -644,6 +565,7 @@ def main(): # 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) diff --git a/pikaraoke/lib/current_app.py b/pikaraoke/lib/current_app.py index 5f088e6f..8a0964ac 100644 --- a/pikaraoke/lib/current_app.py +++ b/pikaraoke/lib/current_app.py @@ -42,6 +42,15 @@ def get_admin_password() -> str: 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() diff --git a/pikaraoke/routes/admin.py b/pikaraoke/routes/admin.py index e6f7cfc3..6606c420 100644 --- a/pikaraoke/routes/admin.py +++ b/pikaraoke/routes/admin.py @@ -26,10 +26,6 @@ admin_bp = Blueprint("admin", __name__) -def get_admin_bp(): - return admin_bp - - def delayed_halt(cmd: int, k: Karaoke): time.sleep(1.5) k.queue_clear() diff --git a/pikaraoke/routes/background_music.py b/pikaraoke/routes/background_music.py index 3ece4d79..91c2587b 100644 --- a/pikaraoke/routes/background_music.py +++ b/pikaraoke/routes/background_music.py @@ -12,10 +12,6 @@ _ = flask_babel.gettext -def get_background_music_bp(): - return background_music_bp - - def create_randomized_playlist(input_directory, base_url, max_songs=50): # Get all mp3 files in the given directory files = [ diff --git a/pikaraoke/routes/preferences.py b/pikaraoke/routes/preferences.py index 61bce8db..1600b719 100644 --- a/pikaraoke/routes/preferences.py +++ b/pikaraoke/routes/preferences.py @@ -8,10 +8,6 @@ _ = flask_babel.gettext -def get_preferences_bp(): - return preferences_bp - - @preferences_bp.route("/change_preferences", methods=["GET"]) def change_preferences(): k = get_karaoke_instance() 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/stream.py b/pikaraoke/routes/stream.py index 932e7fb1..0d699aaf 100644 --- a/pikaraoke/routes/stream.py +++ b/pikaraoke/routes/stream.py @@ -13,10 +13,6 @@ stream_bp = Blueprint("stream", __name__) -def get_stream_bp(): - return stream_bp - - # Streams the file in chunks from the filesystem (chrome supports it, safari does not) @stream_bp.route("/stream/") def stream(id): diff --git a/pikaraoke/templates/base.html b/pikaraoke/templates/base.html index 2f54f714..7aebb4e8 100644 --- a/pikaraoke/templates/base.html +++ b/pikaraoke/templates/base.html @@ -185,7 +185,7 @@ {% trans %}Home{% endtrans %} - + {# MSG: Navigation link for the queue page. #} {% trans %}Queue{% endtrans %} diff --git a/pikaraoke/templates/files.html b/pikaraoke/templates/files.html index 9156a180..b1183c85 100644 --- a/pikaraoke/templates/files.html +++ b/pikaraoke/templates/files.html @@ -112,7 +112,7 @@

      diff --git a/pikaraoke/templates/queue.html b/pikaraoke/templates/queue.html index d3c38e19..6965b474 100644 --- a/pikaraoke/templates/queue.html +++ b/pikaraoke/templates/queue.html @@ -6,7 +6,7 @@ var previousQueue; function getQueue() { - $.get('{{ url_for("get_queue") }}', function (data) { + $.get('{{ url_for("queue.get_queue") }}', function (data) { newQueue = JSON.parse(data); if (!_.isEqual(newQueue, previousQueue)) { queue = newQueue; diff --git a/pikaraoke/templates/search.html b/pikaraoke/templates/search.html index a9610d5d..7162a51b 100644 --- a/pikaraoke/templates/search.html +++ b/pikaraoke/templates/search.html @@ -231,7 +231,7 @@ $("#add-queue-link").removeClass("is-loading"); }, 1000); $.ajax({ - url: "{{ url_for('enqueue') }}", + url: "{{ url_for('queue.enqueue') }}", type: "post", data: $("#queue-form").serialize(), success: function (data) { From b2130ce8a9a3a286170aacc54840ef4107f6236f Mon Sep 17 00:00:00 2001 From: Vic Wong Date: Sun, 5 Jan 2025 22:37:58 -0800 Subject: [PATCH 5/6] fix: port over all routes except commands --- pikaraoke/app.py | 383 ++------------------------------ pikaraoke/routes/admin.py | 12 +- pikaraoke/routes/files.py | 145 ++++++++++++ pikaraoke/routes/home.py | 23 ++ pikaraoke/routes/images.py | 20 ++ pikaraoke/routes/info.py | 84 +++++++ pikaraoke/routes/preferences.py | 4 +- pikaraoke/routes/search.py | 90 ++++++++ pikaraoke/routes/splash.py | 45 ++++ pikaraoke/routes/stream.py | 2 +- pikaraoke/templates/base.html | 8 +- pikaraoke/templates/edit.html | 4 +- pikaraoke/templates/files.html | 12 +- pikaraoke/templates/info.html | 2 +- pikaraoke/templates/search.html | 8 +- pikaraoke/templates/splash.html | 6 +- 16 files changed, 460 insertions(+), 388 deletions(-) create mode 100644 pikaraoke/routes/files.py create mode 100644 pikaraoke/routes/home.py create mode 100644 pikaraoke/routes/images.py create mode 100644 pikaraoke/routes/info.py create mode 100644 pikaraoke/routes/search.py create mode 100644 pikaraoke/routes/splash.py diff --git a/pikaraoke/app.py b/pikaraoke/app.py index 47553d19..0f279f62 100644 --- a/pikaraoke/app.py +++ b/pikaraoke/app.py @@ -3,44 +3,31 @@ import logging import os import signal -import subprocess import sys -import threading import cherrypy import flask_babel -import psutil -from flask import ( - Flask, - flash, - 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.current_app import ( - get_admin_password, - get_karaoke_instance, - get_site_name, - is_admin, -) +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 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: @@ -58,15 +45,20 @@ app.config["JSON_SORT_KEYS"] = False babel = Babel(app) raspberry_pi = is_raspberry_pi() -linux = get_platform() == "linux" -# Register blueprints additional routes +# 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 @@ -80,20 +72,6 @@ def get_locale(): return locale -@app.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, - ) - - @app.route("/nowplaying") def nowplaying(): k = get_karaoke_instance() @@ -139,191 +117,49 @@ def clear_command(): 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(): - 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, - ) - - -@app.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 = app.response_class(response=json.dumps(result), mimetype="application/json") - return response - - -@app.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(), - ) - - -@app.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")) - - -@app.route("/qrcode") -def qrcode(): - k = get_karaoke_instance() - return send_file(k.qr_code_path, mimetype="image/png") - - -@app.route("/logo") -def logo(): - k = get_karaoke_instance() - return send_file(k.logo_path, mimetype="image/png") + return redirect(url_for("home.home")) @app.route("/end_song", methods=["GET", "POST"]) @@ -342,179 +178,6 @@ def start_song(): return "ok" -@app.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("browse")) - - -@app.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("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(): - k = get_karaoke_instance() - # 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(): - k = get_karaoke_instance() - site_name = get_site_name() - url = k.url - admin_password = get_admin_password() - - # 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, - ) - - def main(): platform = get_platform() diff --git a/pikaraoke/routes/admin.py b/pikaraoke/routes/admin.py index 6606c420..77d607e4 100644 --- a/pikaraoke/routes/admin.py +++ b/pikaraoke/routes/admin.py @@ -63,7 +63,7 @@ def update_youtube_dl(): 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")) + return redirect(url_for("home.home")) @admin_bp.route("/refresh") @@ -74,7 +74,7 @@ def refresh(): 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")) + return redirect(url_for("files.browse")) @admin_bp.route("/quit") @@ -90,7 +90,7 @@ def quit(): 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")) + return redirect(url_for("home.home")) @admin_bp.route("/shutdown") @@ -106,7 +106,7 @@ def shutdown(): 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")) + return redirect(url_for("home.home")) @admin_bp.route("/reboot") @@ -122,7 +122,7 @@ def reboot(): 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")) + return redirect(url_for("home.home")) @admin_bp.route("/expand_fs") @@ -139,7 +139,7 @@ def expand_fs(): 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")) + return redirect(url_for("home.home")) @admin_bp.route("/auth", methods=["POST"]) 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 index 1600b719..6d9b6978 100644 --- a/pikaraoke/routes/preferences.py +++ b/pikaraoke/routes/preferences.py @@ -21,7 +21,7 @@ def change_preferences(): 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")) + return redirect(url_for("info.info")) @preferences_bp.route("/clear_preferences", methods=["GET"]) @@ -36,4 +36,4 @@ def clear_preferences(): 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")) + return redirect(url_for("home.home")) 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 index 0d699aaf..76f82648 100644 --- a/pikaraoke/routes/stream.py +++ b/pikaraoke/routes/stream.py @@ -70,4 +70,4 @@ def stream_full(id): 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")) + return redirect(url_for("home.home")) diff --git a/pikaraoke/templates/base.html b/pikaraoke/templates/base.html index 7aebb4e8..2afd2395 100644 --- a/pikaraoke/templates/base.html +++ b/pikaraoke/templates/base.html @@ -179,7 +179,7 @@