From 9fe00f242cafdb018df50ca328c26914038de61e Mon Sep 17 00:00:00 2001 From: Vicwomg Date: Wed, 25 Dec 2024 15:07:32 -0800 Subject: [PATCH] feat: run pikaraoke on a single port --- docker/docker-compose.yml.example | 5 +- pikaraoke/app.py | 87 ++++++++++++--- pikaraoke/karaoke.py | 171 ++++++++++-------------------- pikaraoke/lib/ffmpeg.py | 97 +++++++++++++++++ pikaraoke/lib/file_resolver.py | 41 +++++-- scripts/README.md | 4 +- 6 files changed, 258 insertions(+), 147 deletions(-) create mode 100644 pikaraoke/lib/ffmpeg.py diff --git a/docker/docker-compose.yml.example b/docker/docker-compose.yml.example index 05e8e626..25590e30 100644 --- a/docker/docker-compose.yml.example +++ b/docker/docker-compose.yml.example @@ -5,10 +5,9 @@ services: # Below Host network mode may work better on some systems and replace manual IP configuration. Does not work on OSX # network_mode: host environment: - EXTRA_ARGS: --url http://:5555 # Replace with your LAN IP or DNS url, not necesary if using network_mode: host + EXTRA_ARGS: --url http:// # Replace with your LAN IP or DNS url to render proper QR code, may not be necesary if using network_mode: host volumes: - :/app/pikaraoke-songs # Replace with local dir. Insures your songs are persisted outside the container restart: unless-stopped ports: - - "5555:5555" # Forward the port for the web interface - - "5556:5556" # Forward the port for the ffmpeg video stream interface + - "80:5555" # Forward to the pikaraoke web interface on 5555, adjust host port as necessary diff --git a/pikaraoke/app.py b/pikaraoke/app.py index 64af2fd8..8c39a80a 100644 --- a/pikaraoke/app.py +++ b/pikaraoke/app.py @@ -4,6 +4,7 @@ import json import logging import os +import re import signal import subprocess import sys @@ -15,6 +16,7 @@ import psutil from flask import ( Flask, + Response, flash, make_response, redirect, @@ -36,6 +38,7 @@ from pikaraoke import VERSION, karaoke from pikaraoke.constants import LANGUAGES +from pikaraoke.lib.file_resolver import delete_tmp_dir, get_tmp_dir from pikaraoke.lib.get_platform import get_platform, is_raspberry_pi try: @@ -690,6 +693,72 @@ def expand_fs(): 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 = f"{get_tmp_dir()}/{id}.mp4" + + def generate(): + previous_size = -1 + current_size = 0 + position = 0 # Initialize the position variable + with open(file_path, "rb") as file: # Open the file outside the loop + while True: + current_size = os.path.getsize(file_path) + if current_size == previous_size: + # File size has stabilized, break the loop + break + file.seek(position) # Move to the last read position + while True: + chunk = file.read(10240 * 100 * 30) # Read in 3mb chunks + if not chunk: + break # End of file reached + yield chunk + position += len(chunk) # Update the position with the size of the chunk + previous_size = current_size + time.sleep(1) # Wait a bit before checking the file size again + + 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 = f"{get_tmp_dir()}/{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: + 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()) @@ -714,7 +783,6 @@ def get_default_dl_dir(platform): def main(): platform = get_platform() default_port = 5555 - default_ffmpeg_port = 5556 default_volume = 0.85 default_normalize_audio = False default_splash_delay = 3 @@ -741,13 +809,6 @@ def main(): default=0, required=False, ) - parser.add_argument( - "-f", - "--ffmpeg-port", - help=f"Desired ffmpeg port. This is where video stream URLs will be pointed (default: {default_ffmpeg_port})", - default=default_ffmpeg_port, - required=False, - ) parser.add_argument( "-d", "--download-path", @@ -849,13 +910,6 @@ def main(): default=None, required=False, ), - parser.add_argument( - "-m", - "--ffmpeg-url", - help="Override the ffmpeg address with a supplied URL.", - default=None, - required=False, - ), parser.add_argument( "--hide-overlay", action="store_true", @@ -898,7 +952,6 @@ def main(): global k k = karaoke.Karaoke( port=args.port, - ffmpeg_port=args.ffmpeg_port, download_path=dl_path, youtubedl_path=arg_path_parse(args.youtubedl_path), splash_delay=args.splash_delay, @@ -913,7 +966,6 @@ def main(): hide_overlay=args.hide_overlay, screensaver_timeout=args.screensaver_timeout, url=args.url, - ffmpeg_url=args.ffmpeg_url, prefer_hostname=args.prefer_hostname, ) k.upgrade_youtubedl() @@ -975,6 +1027,7 @@ def main(): driver.close() cherrypy.engine.exit() + delete_tmp_dir() sys.exit() diff --git a/pikaraoke/karaoke.py b/pikaraoke/karaoke.py index d1a1e95d..ce7449d2 100644 --- a/pikaraoke/karaoke.py +++ b/pikaraoke/karaoke.py @@ -7,22 +7,24 @@ import subprocess import time from pathlib import Path -from queue import Empty, Queue +from queue import Queue from subprocess import CalledProcessError, check_output from threading import Thread from urllib.parse import urlparse -import ffmpeg import qrcode from unidecode import unidecode -from pikaraoke.lib.file_resolver import FileResolver -from pikaraoke.lib.get_platform import ( +from pikaraoke.lib.ffmpeg import ( + build_ffmpeg_cmd, get_ffmpeg_version, + is_transpose_enabled, +) +from pikaraoke.lib.file_resolver import FileResolver, delete_tmp_dir +from pikaraoke.lib.get_platform import ( get_os_version, get_platform, is_raspberry_pi, - is_transpose_enabled, supports_hardware_h264_encoding, ) @@ -68,7 +70,6 @@ class Karaoke: ffmpeg_log = None ffmpeg_version = get_ffmpeg_version() is_transpose_enabled = is_transpose_enabled() - supports_hardware_h264_encoding = supports_hardware_h264_encoding() normalize_audio = False raspberry_pi = is_raspberry_pi() @@ -77,7 +78,6 @@ class Karaoke: def __init__( self, port=5555, - ffmpeg_port=5556, download_path="/usr/lib/pikaraoke/songs", hide_url=False, hide_raspiwifi_instructions=False, @@ -92,12 +92,10 @@ def __init__( hide_overlay=False, screensaver_timeout=300, url=None, - ffmpeg_url=None, prefer_hostname=True, ): # override with supplied constructor args if provided self.port = port - self.ffmpeg_port = ffmpeg_port self.hide_url = hide_url self.hide_raspiwifi_instructions = hide_raspiwifi_instructions self.hide_splash_screen = hide_splash_screen @@ -126,7 +124,6 @@ def __init__( logging.debug( f""" http port: {self.port} - ffmpeg port {self.ffmpeg_port} hide URL: {self.hide_url} prefer hostname: {self.prefer_hostname} url override: {self.url_override} @@ -147,7 +144,7 @@ def __init__( os version: {self.os_version} ffmpeg version: {self.ffmpeg_version} ffmpeg transpose support: {self.is_transpose_enabled} - hardware h264 encoding: {self.supports_hardware_h264_encoding} + hardware h264 encoding: {supports_hardware_h264_encoding()} youtubedl-version: {self.get_youtubedl_version()} """ ) @@ -178,12 +175,6 @@ def __init__( else: self.url = f"http://{self.ip}:{self.port}" self.url_parsed = urlparse(self.url) - if ffmpeg_url is None: - self.ffmpeg_url = ( - f"{self.url_parsed.scheme}://{self.url_parsed.hostname}:{self.ffmpeg_port}" - ) - else: - self.ffmpeg_url = ffmpeg_url # get songs from download_path self.get_available_songs() @@ -417,14 +408,6 @@ def log_ffmpeg_output(self): def play_file(self, file_path, semitones=0): logging.info(f"Playing file: {file_path} transposed {semitones} semitones") - stream_uid = int(time.time()) - stream_url = f"{self.ffmpeg_url}/{stream_uid}" - # pass a 0.0.0.0 IP to ffmpeg which will work for both hostnames and direct IP access - ffmpeg_url = f"http://0.0.0.0:{self.ffmpeg_port}/{stream_uid}" - - pitch = 2 ** ( - semitones / 12 - ) # The pitch value is (2^x/12), where x represents the number of semitones try: fr = FileResolver(file_path) @@ -433,72 +416,10 @@ def play_file(self, file_path, semitones=0): self.queue.pop(0) return False - # use h/w acceleration on pi - default_vcodec = "h264_v4l2m2m" if self.supports_hardware_h264_encoding else "libx264" - # just copy the video stream if it's an mp4 or webm file, since they are supported natively in html5 - # otherwise use the default h264 codec - vcodec = ( - "copy" - if fr.file_extension == ".mp4" or fr.file_extension == ".webm" - else default_vcodec - ) - vbitrate = "5M" # seems to yield best results w/ h264_v4l2m2m on pi, recommended for 720p. - - # copy the audio stream if no transposition/normalization, otherwise reincode with the aac codec - is_transposed = semitones != 0 - acodec = "aac" if is_transposed or self.normalize_audio else "copy" - input = ffmpeg.input(fr.file_path) - audio = input.audio.filter("rubberband", pitch=pitch) if is_transposed else input.audio - # normalize the audio - audio = audio.filter("loudnorm", i=-16, tp=-1.5, lra=11) if self.normalize_audio else audio - - # Ffmpeg outputs "Stream #0" when the stream is ready to consume - stream_ready_string = "Stream #" - - if fr.cdg_file_path != None: # handle CDG files - logging.info("Playing CDG/MP3 file: " + file_path) - # Ffmpeg outputs "Video: cdgraphics" when the stream is ready to consume - stream_ready_string = "Video: cdgraphics" - # copyts helps with sync issues, fps=25 prevents ffmpeg from needlessly encoding cdg at 300fps - cdg_input = ffmpeg.input(fr.cdg_file_path, copyts=None) - video = cdg_input.video.filter("fps", fps=25) - # cdg is very fussy about these flags. - # pi ffmpeg needs to encode to aac and cant just copy the mp3 stream - # It alse appears to have memory issues with hardware acceleration h264_v4l2m2m - output = ffmpeg.output( - audio, - video, - ffmpeg_url, - vcodec="libx264", - acodec="aac", - preset="ultrafast", - pix_fmt="yuv420p", - listen=1, - f="mp4", - video_bitrate="500k", - movflags="frag_keyframe+default_base_moof", - ) - else: - video = input.video - output = ffmpeg.output( - audio, - video, - ffmpeg_url, - vcodec=vcodec, - acodec=acodec, - preset="ultrafast", - listen=1, - f="mp4", - video_bitrate=vbitrate, - movflags="frag_keyframe+default_base_moof", - ) - - args = output.get_args() - logging.debug(f"COMMAND: ffmpeg " + " ".join(args)) - self.kill_ffmpeg() - self.ffmpeg_process = output.run_async(pipe_stderr=True, pipe_stdin=True) + ffmpeg_cmd = build_ffmpeg_cmd(fr, semitones, self.normalize_audio) + self.ffmpeg_process = ffmpeg_cmd.run_async(pipe_stderr=True, pipe_stdin=True) # ffmpeg outputs everything useful to stderr for some insane reason! # prevent reading stderr from being a blocking action @@ -507,37 +428,54 @@ def play_file(self, file_path, semitones=0): t.daemon = True t.start() + # Ffmpeg outputs "out#0" when the stream is done transcoding + stream_ready_string = "out#0/mp4" + output_file_size = 0 + buffering_threshold = 4000000 # raise this if pi3 struggles to keep up with transcoding + while self.ffmpeg_process.poll() is None: + is_transcoding_complete = False + is_buffering_complete = False try: output = self.ffmpeg_log.get_nowait() logging.debug("[FFMPEG] " + decode_ignore(output)) - except Empty: - pass - else: - if stream_ready_string in decode_ignore(output): - logging.debug("Stream ready!") - self.now_playing = self.filename_from_path(file_path) - self.now_playing_filename = file_path - self.now_playing_transpose = semitones - self.now_playing_url = stream_url - self.now_playing_user = self.queue[0]["user"] - self.is_paused = False - self.queue.pop(0) - - # Pause until the stream is playing - max_retries = 100 - while self.is_playing == False and max_retries > 0: - time.sleep(0.1) # prevents loop from trying to replay track - max_retries -= 1 - if self.is_playing: - logging.debug("Stream is playing") - break - else: - logging.error( - "Stream was not playable! Run with debug logging to see output. Skipping track" - ) - self.end_song() - break + is_transcoding_complete = stream_ready_string in decode_ignore(output) + if is_transcoding_complete: + logging.debug(f"Transcoding complete. File size: {output_file_size}") + except: + try: + output_file_size = os.path.getsize(fr.output_file) + is_buffering_complete = output_file_size > buffering_threshold + if is_buffering_complete: + logging.debug(f"Buffering complete. File size: {output_file_size}") + except: + pass + # Check if the stream is ready to play. Determined by: + # - completed transcoding + # - buffered file size being greater than a threshold + if is_transcoding_complete or is_buffering_complete: + logging.debug(f"Stream ready!") + self.now_playing = self.filename_from_path(file_path) + self.now_playing_filename = file_path + self.now_playing_transpose = semitones + self.now_playing_url = fr.stream_url_path + self.now_playing_user = self.queue[0]["user"] + self.is_paused = False + self.queue.pop(0) + # Pause until the stream is playing + max_retries = 100 + while self.is_playing == False and max_retries > 0: + time.sleep(0.1) # prevents loop from trying to replay track + max_retries -= 1 + if self.is_playing: + logging.debug("Stream is playing") + break + else: + logging.error( + "Stream was not playable! Run with debug logging to see output. Skipping track" + ) + self.end_song() + break def kill_ffmpeg(self): logging.debug("Killing ffmpeg process") @@ -552,6 +490,7 @@ def end_song(self): logging.info(f"Song ending: {self.now_playing}") self.reset_now_playing() self.kill_ffmpeg() + delete_tmp_dir() logging.debug("ffmpeg process killed") def transpose_current(self, semitones): diff --git a/pikaraoke/lib/ffmpeg.py b/pikaraoke/lib/ffmpeg.py new file mode 100644 index 00000000..e7d68572 --- /dev/null +++ b/pikaraoke/lib/ffmpeg.py @@ -0,0 +1,97 @@ +import logging +import subprocess + +import ffmpeg + +from pikaraoke.lib.get_platform import supports_hardware_h264_encoding + + +def build_ffmpeg_cmd(fr, semitones=0, normalize_audio=True): + # use h/w acceleration on pi + default_vcodec = "h264_v4l2m2m" if supports_hardware_h264_encoding() else "libx264" + # just copy the video stream if it's an mp4 or webm file, since they are supported natively in html5 + # otherwise use the default h264 codec + vcodec = ( + "copy" if fr.file_extension == ".mp4" or fr.file_extension == ".webm" else default_vcodec + ) + vbitrate = "5M" # seems to yield best results w/ h264_v4l2m2m on pi, recommended for 720p. + + # copy the audio stream if no transposition/normalization, otherwise reincode with the aac codec + is_transposed = semitones != 0 + acodec = "aac" if is_transposed or normalize_audio else "copy" + input = ffmpeg.input(fr.file_path) + + # The pitch value is (2^x/12), where x represents the number of semitones + pitch = 2 ** (semitones / 12) + + audio = input.audio.filter("rubberband", pitch=pitch) if is_transposed else input.audio + # normalize the audio + audio = audio.filter("loudnorm", i=-16, tp=-1.5, lra=11) if normalize_audio else audio + + if fr.cdg_file_path != None: # handle CDG files + logging.info("Playing CDG/MP3 file: " + fr.file_path) + # copyts helps with sync issues, fps=25 prevents ffmpeg from needlessly encoding cdg at 300fps + cdg_input = ffmpeg.input(fr.cdg_file_path, copyts=None) + video = cdg_input.video.filter("fps", fps=25) + # cdg is very fussy about these flags. + # pi ffmpeg needs to encode to aac and cant just copy the mp3 stream + # It alse appears to have memory issues with hardware acceleration h264_v4l2m2m + output = ffmpeg.output( + audio, + video, + fr.output_file, + vcodec="libx264", + acodec="aac", + preset="ultrafast", + pix_fmt="yuv420p", + listen=1, + f="mp4", + video_bitrate="500k", + movflags="frag_keyframe+default_base_moof", + ) + else: + video = input.video + output = ffmpeg.output( + audio, + video, + fr.output_file, + vcodec=vcodec, + acodec=acodec, + preset="ultrafast", + listen=1, + f="mp4", + video_bitrate=vbitrate, + movflags="frag_keyframe+default_base_moof", + ) + + args = output.get_args() + logging.debug(f"COMMAND: ffmpeg " + " ".join(args)) + return output + + +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() diff --git a/pikaraoke/lib/file_resolver.py b/pikaraoke/lib/file_resolver.py index 68c221fc..69e844ea 100644 --- a/pikaraoke/lib/file_resolver.py +++ b/pikaraoke/lib/file_resolver.py @@ -2,26 +2,51 @@ import re import shutil import zipfile +from sys import maxsize from pikaraoke.lib.get_platform import get_platform +def get_tmp_dir(): + # Determine tmp directories (for things like extracted cdg files) + pid = os.getpid() # for scoping tmp directories to this process + if get_platform() == "windows": + tmp_dir = os.path.expanduser(r"~\\AppData\\Local\\Temp\\pikaraoke\\" + str(pid) + r"\\") + else: + tmp_dir = f"/tmp/pikaraoke/{pid}" + return tmp_dir + + +def create_tmp_dir(): + tmp_dir = get_tmp_dir() + # create tmp_dir if it doesn't exist + if not os.path.exists(tmp_dir): + os.makedirs(tmp_dir) + + +def delete_tmp_dir(): + tmp_dir = get_tmp_dir() + if os.path.exists(tmp_dir): + shutil.rmtree(tmp_dir) + + +def string_to_hash(s): + return hash(s) % ((maxsize + 1) * 2) + + # Processes a given file path and determines the file format and file path, extracting zips into cdg + mp3 if necessary. class FileResolver: file_path = None cdg_file_path = None file_extension = None - pid = os.getpid() # for scoping tmp directories to this process def __init__(self, file_path): - # Determine tmp directories (for things like extracted cdg files) - if get_platform() == "windows": - self.tmp_dir = os.path.expanduser( - r"~\\AppData\\Local\\Temp\\pikaraoke\\" + str(self.pid) + r"\\" - ) - else: - self.tmp_dir = f"/tmp/pikaraoke/{self.pid}" + create_tmp_dir() + self.tmp_dir = get_tmp_dir() self.resolved_file_path = self.process_file(file_path) + self.stream_uid = string_to_hash(file_path) + self.output_file = f"{self.tmp_dir}/{self.stream_uid}.mp4" + self.stream_url_path = f"/stream/{self.stream_uid}" # Extract zipped cdg + mp3 files into a temporary directory, and set the paths to both files. def handle_zipped_cdg(self, file_path): diff --git a/scripts/README.md b/scripts/README.md index f53e750e..c2bbac9e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -85,15 +85,13 @@ Note that if your wifi/network is inactive pikaraoke will error out 10 seconds a May not be up to date, run `python3 app.py --help` for the latest: ``` -usage: app.py [-h] [-p PORT] [-f FFMPEG_PORT] [-d DOWNLOAD_PATH] [-y YOUTUBEDL_PATH] [-v VOLUME] [-s SPLASH_DELAY] [-t SCREENSAVER_TIMEOUT] +usage: app.py [-h] [-p PORT] [-d DOWNLOAD_PATH] [-y YOUTUBEDL_PATH] [-v VOLUME] [-s SPLASH_DELAY] [-t SCREENSAVER_TIMEOUT] [-l LOG_LEVEL] [--hide-url] [--prefer-ip] [--hide-raspiwifi-instructions] [--hide-splash-screen] [--dual-screen] [--high-quality] [--logo-path LOGO_PATH] [-u URL] [--hide-overlay] [--admin-password ADMIN_PASSWORD] [--window-size WIDTH,HEIGHT] options: -h, --help show this help message and exit -p PORT, --port PORT Desired http port (default: 5555) - -f FFMPEG_PORT, --ffmpeg-port FFMPEG_PORT - Desired ffmpeg port. This is where video stream URLs will be pointed (default: 5556) -d DOWNLOAD_PATH, --download-path DOWNLOAD_PATH Desired path for downloaded songs. (default: ~/pikaraoke-songs) -y YOUTUBEDL_PATH, --youtubedl-path YOUTUBEDL_PATH