diff --git a/app.py b/app.py index 7724c1d2..f7da0d45 100644 --- a/app.py +++ b/app.py @@ -4,11 +4,12 @@ import signal, subprocess, threading, time, traceback, webbrowser from functools import wraps -import cherrypy -import psutil +import psutil, tempfile from unidecode import unidecode from flask import * from flask.logging import logging +from pydub import AudioSegment as AudSeg +from flask_sock import Sock from flask_paginate import Pagination, get_page_parameter import karaoke @@ -16,6 +17,7 @@ from collections import defaultdict from lib.get_platform import * from lib.vlcclient import get_default_vlc_path +from lib.NLP import * try: from urllib.parse import quote, unquote @@ -23,14 +25,70 @@ from urllib import quote, unquote app = Flask(__name__) +app.config['TEMPLATES_AUTO_RELOAD'] = True ##DEBUG app.secret_key = os.urandom(24) -site_name = "PiKaraoke" -admin_password = K = None +admin_password = K = args = None +sock = Sock(app) os.texts = defaultdict(lambda: "") getString = lambda ii: os.texts[ii] getString1 = lambda lang, ii: os.langs[lang].get(ii, os.langs['en_US'][ii]) +getString2 = lambda ii: getString1(request.client_lang, ii) +ip2websock, ip2pane = {}, {} +# Websocket handler +@sock.route('/ws_init') +def ws_init(sock): + key = sock.sock.getpeername()[0] + ip2websock[key] = sock + while sock.connected: + try: + cmd = sock.receive() + wscmd(key, cmd) + except: + traceback.print_exc() + ip2websock.pop(key) + +def wscmd(client_ip, cmd): + if cmd.startswith('pop_from_queue '): + name = cmd.split(' ', 1)[1] + +def flash(message: str, category: str = "message"): + ip2websock[request.remote_addr].send(f'showNotification("{message}", "{category}")') + +def status_thread(): + cached_status = '' + while True: + K.event_dirty.wait(1) + + status = nowplaying(False) + if not status: continue + + status_full = json.dumps(status) + status.pop('seektrack_value', None) + status_str = json.dumps(status) + if status_str != cached_status: + K.status_dirty = True + cached_status = status_str + + if not K.status_dirty: + if not K.is_file_playing(): + continue + tm = K.get_state().get('time', None) + if tm is None: + continue + for ip, ws in ip2websock.items(): + if ip2pane.get(ip, '') == 'home': + ws.send(f"seektrack.value={tm};$('#seektrack-val').text(getHHMMSS({tm}));") + continue + + for ip, ws in ip2websock.items(): + if ip2pane.get(ip, '') == 'home': + ws.send(f"update('{status_full}')") + elif ip2pane.get(ip, '') == 'queue': + ws.send(f"update('{K.queue_json}')") + K.status_dirty = False + # Define global symbols for Jinja templates @app.context_processor def inject_stage_and_region(): @@ -76,13 +134,45 @@ def is_admin(): @app.route("/") +def root(): + s = K.get_state() + return render_template( + "index.html", + getString1 = lambda ii: getString1(request.client_lang, ii), + show_transpose = K.use_vlc, + transpose_value = K.now_playing_transpose, + volume = s['volume'], + admin = is_admin(), + seektrack_value = s['time'], + seektrack_max = s['length'], + audio_delay = s['audiodelay'], + play_speed = s['rate'], + vocal_info = K.get_vocal_info(), + ) + +@app.route("/home") def home(): s = K.get_state() return render_template( "home.html", getString1 = lambda ii: getString1(request.client_lang, ii), - site_title = site_name, - title = "Home", + show_transpose = K.use_vlc, + transpose_value = K.now_playing_transpose, + volume = s['volume'], + admin = is_admin(), + seektrack_value = s['time'], + seektrack_max = s['length'], + audio_delay = s['audiodelay'], + play_speed = s['rate'], + vocal_info = K.get_vocal_info(), + ) +@app.route("/f_home") +def f_home(): + ip2pane[request.remote_addr] = 'home' + s = K.get_state() + return render_template( + "f_home.html", + getString1 = lambda ii: getString1(request.client_lang, ii), show_transpose = K.use_vlc, transpose_value = K.now_playing_transpose, volume = s['volume'], @@ -96,10 +186,10 @@ def home(): @app.route("/nowplaying") -def nowplaying(): +def nowplaying(return_json=True): try: if K.switchingSong: - return "" + return "" if return_json else {} next_song = K.queue[0]["title"] if K.queue else None next_user = K.queue[0]["user"] if K.queue else None s = K.get_state() @@ -108,7 +198,7 @@ def nowplaying(): "now_playing_user": K.now_playing_user, "up_next": next_song, "next_user": next_user, - "is_paused": s['state'] == 'paused', + "is_paused": s.get('state', 'paused') == 'paused', "volume": s['volume'], "transpose_value": K.now_playing_transpose, "seektrack_value": s['time'], @@ -121,10 +211,10 @@ def nowplaying(): if K.has_subtitle: rc['subtitle_delay'] = s['subtitledelay'] rc['show_subtitle'] = K.show_subtitle - return json.dumps(rc) + return json.dumps(rc) if return_json else rc except Exception as e: logging.error(f"Problem loading /nowplaying, pikaraoke may still be starting up: {e}\n{traceback.print_exc()}") - return "" + return "" if return_json else {} @app.route("/get_lang_list") @@ -132,6 +222,11 @@ def get_lang_list(): return json.dumps({k: v[1] for k, v in os.langs.items()}, sort_keys = False) +@app.route("/auto_username") +def auto_username(): + return f'user-{len(ip2websock)+1}' + + @app.route("/change_language/") def change_language(language): try: @@ -170,6 +265,18 @@ def logout(): return resp +@app.route("/user_rename//") +def user_rename(old_name, new_name): + dirty = False + for q in K.queue: + if q['user'] == old_name: + q['user'] = new_name + dirty = True + if dirty: + K.update_queue() + return '' + + @app.route("/get_vocal_todo_list/") def get_vocal_todo_list(vocal_device): K.vocal_device = vocal_device @@ -202,12 +309,16 @@ def norm_vol(mode): @app.route("/queue") def queue(): - return render_template("queue.html", getString1 = lambda ii: getString1(request.client_lang, ii), queue = K.queue, site_title = site_name, title = "Queue", admin = is_admin()) + return render_template("queue.html", getString1 = lambda ii: getString1(request.client_lang, ii), queue = K.queue, admin = is_admin()) +@app.route("/f_queue") +def f_queue(): + ip2pane[request.remote_addr] = 'queue' + return render_template("f_queue.html", getString1 = lambda ii: getString1(request.client_lang, ii), queue = K.queue, admin = is_admin()) -@app.route("/get_queue/", methods = ["GET"]) -def get_queue(last_hash): - return json.dumps([K.queue, K.queue_hash]) if last_hash != K.queue_hash else '' +@app.route("/get_queue", methods = ["GET"]) +def get_queue(): + return K.queue_json @app.route("/queue/addrandom", methods = ["GET"]) @@ -218,7 +329,7 @@ def add_random(): flash(getString(4) % amount, "is-success") else: flash(getString(5), "is-warning") - return redirect(url_for("queue")) + return '' @app.route("/queue/edit", methods = ["GET"]) @@ -370,8 +481,26 @@ def search(): return render_template( "search.html", getString1 = lambda ii: getString1(request.client_lang, ii), - site_title = site_name, - title = "Search", + songs = K.available_songs, + high_quality = K.high_quality, + search_results = search_results, + search_string = search_string, + search_karaoke = search_karaoke + ) +@app.route("/f_search", methods = ["GET"]) +def f_search(): + ip2pane[request.remote_addr] = 'search' + if "search_string" in request.args: + search_string = request.args["search_string"] + search_karaoke = request.args.get('non_karaoke', 'false') == "true" + search_results = K.get_search_results(search_string + (" karaoke" if search_karaoke else "")) + else: + search_string = None + search_results = None + search_karaoke = False + return render_template( + "f_search.html", + getString1 = lambda ii: getString1(request.client_lang, ii), songs = K.available_songs, high_quality = K.high_quality, search_results = search_results, @@ -408,8 +537,6 @@ def browse(): else: available_songs = [k for k,v in K.songname_trans.items() if v.startswith(letter)] - getString2 = lambda ii: getString1(request.client_lang, ii) - if "sort" in request.args and request.args["sort"] == "date": songs = sorted(available_songs, key = lambda x: os.path.getctime(x)) songs.reverse() @@ -430,7 +557,46 @@ def browse(): pagination = pagination, sort_order = sort_order, sort_order_text = sort_order_text, - site_title = site_name, + letter = letter, + title = getString2(98), + songs = songs[start_index:start_index + results_per_page], + admin = is_admin() + ) +@app.route("/f_browse", methods = ["GET"]) +def f_browse(): + ip2pane[request.remote_addr] = 'browse' + search = bool(request.args.get('q')) + page = request.args.get(get_page_parameter(), type = int, default = 1) + + letter = request.args.get('letter') + + available_songs = K.available_songs + if letter: + if (letter == "numeric"): + available_songs = [k for k,v in K.songname_trans.items() if not v[0].islower()] + else: + available_songs = [k for k,v in K.songname_trans.items() if v.startswith(letter)] + + if request.cookies.get("sort") == "date": + songs = sorted(available_songs, key = lambda x: os.path.getctime(x)) + songs.reverse() + sort_order = "Date" + sort_order_text = getString2(99) + else: + songs = available_songs + sort_order = "Alphabetical" + sort_order_text = getString2(100) + + results_per_page = 500 + pagination = Pagination(css_framework = 'bulma', page = page, total = len(songs), search = search, search_msg = getString2(103), + record_name = getString2(101), display_msg = getString2(102), per_page = results_per_page) + start_index = (page - 1) * (results_per_page - 1) + return render_template( + "f_browse.html", + getString1 = getString2, + pagination = pagination, + sort_order = sort_order, + sort_order_text = sort_order_text, letter = letter, title = getString2(98), songs = songs[start_index:start_index + results_per_page], @@ -488,7 +654,7 @@ def edit_file(): flash(queue_error_msg + song_path, "is-danger") return redirect(url_for("browse")) else: - return render_template("edit.html", getString1 = lambda ii: getString1(request.client_lang, ii), site_title = site_name, title = getString(23), song = song_path.encode("utf-8")) + return render_template("edit.html", getString1 = lambda ii: getString1(request.client_lang, ii), title = getString(23), song = song_path.encode("utf-8")) else: d = request.form.to_dict() if "new_file_name" in d and "old_file_name" in d: @@ -508,13 +674,46 @@ def edit_file(): flash(getString(26), "is-danger") return redirect(url_for("browse")) +@app.route("/f_edit", methods = ["GET", "POST"]) +def f_edit(): + song_path = request.args["song"] + if song_path == K.now_playing_filename: + flash(getString2(22) + song_path, "is-danger") + return '', 400 + else: + song = AudSeg.from_file(song_path) + return render_template("f_edit.html", getString1 = lambda ii: getString1(request.client_lang, ii), dBFS = '%.2f'%song.dBFS, + hhmmss = sec2hhmmss(song.duration_seconds), title = getString(23), song = song_path.encode("utf-8")) + +@app.route("/edit_song", methods = ["POST"]) +def rename(): + d = request.form + 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 old_name == K.now_playing_filename: + flash(getString2(22) + old_name, "is-danger") + elif new_name != old_name: + # 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(getString2(24) % (old_name, new_name + file_extension), "is-danger") + else: + K.rename(old_name, new_name) + flash(getString2(25) % (old_name, new_name), "is-info") + return f_browse() + else: + flash(getString2(220), "is-danger") + return '', 400 + + @app.route("/splash") def splash(): return render_template( "splash.html", getString1 = lambda ii: getString1(request.client_lang, ii), blank_page=True, - url="http://" + request.host + url=request.url_root ) @app.route("/info") @@ -538,7 +737,6 @@ def info(): disk = str(free) + "GB free / " + str(total) + "GB total ( " + str(disk.percent) + "% )" # whether screencapture.sh and vocal_splitter.py is running - getString2 = lambda ii: getString1(request.client_lang, ii) get_status = lambda t: getString2(27) if t is None else (getString2(28) if t else getString2(29)) screencapture = K.streamer_alive() vocalsplitter = K.vocal_alive() @@ -555,9 +753,63 @@ def info(): "info.html", getString1 = getString2, langs = os.langs, lang = os.lang, - site_title = site_name, ostype = sys.platform.upper(), - title = "Info", + url = url, + memory = memory, + cpu = cpu, + disk = disk, + youtubedl_version = youtubedl_version, + is_pi = is_pi, + use_DNN = K.use_DNN_vocal, + norm_vol = K.normalize_vol, + pikaraoke_version = VERSION, + download_path = K.download_path, + num_of_songs = len(K.available_songs), + screencapture = get_status(screencapture), + vocalsplitter = get_status(vocalsplitter) + vocal_extra, + platform = K.platform, + save_delays = bool(K.save_delays), + admin = is_admin(), + admin_enabled = admin_password != None + ) +@app.route("/f_info") +def f_info(): + url = K.url + + # cpu + cpu = str(psutil.cpu_percent()) + "%" + + # mem + memory = psutil.virtual_memory() + available = round(memory.available / 1024.0 / 1024.0, 1) + total = round(memory.total / 1024.0 / 1024.0, 1) + memory = str(available) + "MB free / " + str(total) + "MB total ( " + str(memory.percent) + "% )" + + # disk + disk = psutil.disk_usage("/") + # Divide from Bytes -> KB -> MB -> GB + free = round(disk.free / 1024.0 / 1024.0 / 1024.0, 1) + total = round(disk.total / 1024.0 / 1024.0 / 1024.0, 1) + disk = str(free) + "GB free / " + str(total) + "GB total ( " + str(disk.percent) + "% )" + + # whether screencapture.sh and vocal_splitter.py is running + get_status = lambda t: getString2(27) if t is None else (getString2(28) if t else getString2(29)) + screencapture = K.streamer_alive() + vocalsplitter = K.vocal_alive() + vocal_extra = '' + if vocalsplitter: + vocal_extra = getString2(30) if K.vocal_device == 'cpu' else getString2(31) + + # youtube-dl + youtubedl_version = K.youtubedl_version + + is_pi = get_platform() == "raspberry_pi" + + return render_template( + "f_info.html", + getString1 = getString2, + langs = os.langs, lang = os.lang, + ostype = sys.platform.upper(), url = url, memory = memory, cpu = cpu, @@ -577,6 +829,37 @@ def info(): admin_enabled = admin_password != None ) +def _add_spoken(client_ip, user, getString): + global args + with open(f'{K.tmp_dir}/rec.mp3', 'rb') as f: + r = requests.post(args.cloud+'/run_asr/base', files={'file': f}, timeout=8) + asr_output = json.loads(r.text) if r.status_code==200 else {} + + print(f'ASR result: {asr_output}', file=sys.stderr) + if asr_output=={} or type(asr_output)==str: + return logging.error(f'ASR error: HTTP status code = {r.status_code} ()') + elif not asr_output['text']: + return logging.error(f'Warning: ASR output is empty') + + res = findMedia(K.download_path, asr_output['text'], lang=asr_output['language']) + ws = ip2websock.get(client_ip, '') + if not res: + return ws.send(f"showNotification('{getString(226)%asr_output['text']}', 'is-info')") + res_titles = [filename_from_path(s) for s in res] + if len(res)==1: + add_res = K.enqueue(res[0], user) + return ws.send(f'add1song("{res_titles[0]}","{res[0]}")' if add_res else f"showNotification('{getString(116)+res_titles[0]}', 'is-info')") + return ws.send(f"addSongs('{json.dumps([res_titles, res])}')") + + +@app.route('/add_spoken/', methods=['POST']) +def add_spoken(user): + with open(f'{K.tmp_dir}/rec.webm', 'wb') as fp: + fp.write(request.data) + AudSeg.from_file(f'{K.tmp_dir}/rec.webm', format='webm').export(f'{K.tmp_dir}/rec.mp3', 'mp3') + threading.Thread(target=_add_spoken, args=(request.remote_addr, user, getString)).start() + return 'OK' + # Delay system commands to allow redirect to render first def delayed_halt(cmd): @@ -584,8 +867,6 @@ def delayed_halt(cmd): if K.vocal_process is not None and K.vocal_process.is_alive(): K.vocal_process.terminate() K.queue_clear() # stop all pending omxplayer processes - cherrypy.engine.stop() - cherrypy.engine.exit() K.stop() if cmd == 0: os.system('(sleep 2 && tmux kill-session -t PiKaraoke) &') @@ -600,21 +881,6 @@ def delayed_halt(cmd): os.system("reboot") -def update_youtube_dl(): - K.upgrade_youtubedl() - - -@app.route("/update_ytdl") -def update_ytdl(): - if (is_admin()): - flash(getString(32), "is-warning") - th = threading.Thread(target = update_youtube_dl) - th.start() - else: - flash(getString(33), "is-danger") - return redirect(url_for("home")) - - @app.route("/refresh") def refresh(): if (is_admin()): @@ -641,58 +907,63 @@ def bg_process(cmd): def quit(): if (is_admin()): flash(getString(35), "is-warning") - th = threading.Thread(target = delayed_halt, args = [0]) - th.start() + threading.Thread(target = delayed_halt, args = [0]).start() else: - flash(getString(36), "is-danger") - return redirect(url_for("home")) + flash(getString(36), "is-danger") + return '' @app.route("/shutdown") def shutdown(): if (is_admin()): flash(getString(37), "is-danger") - th = threading.Thread(target = delayed_halt, args = [1]) - th.start() + threading.Thread(target = delayed_halt, args = [1]).start() else: flash(getString(38), "is-danger") - return redirect(url_for("home")) + return '' @app.route("/reboot") def reboot(): if (is_admin()): flash(getString(39), "is-danger") - th = threading.Thread(target = delayed_halt, args = [2]) - th.start() + threading.Thread(target = delayed_halt, args = [2]).start() else: flash(getString(40), "is-danger") - return redirect(url_for("home")) + return '' + + +@app.route("/update_ytdl") +def update_ytdl(): + if (is_admin()): + flash(getString(32), "is-warning") + threading.Thread(target=lambda: K.upgrade_youtubedl()).start() + else: + flash(getString(33), "is-danger") + return '' @app.route("/expand_fs") def expand_fs(): if (is_admin() and platform == "raspberry_pi"): flash(getString(41), "is-danger") - th = threading.Thread(target = delayed_halt, args = [3]) - th.start() + threading.Thread(target = delayed_halt, args = [3]).start() elif (platform != "raspberry_pi"): flash(getString(42), "is-danger") else: flash(getString(43), "is-danger") - return redirect(url_for("home")) + return '' -# Handle sigterm, apparently cherrypy won't shut down without explicit handling +# Handle sigterm signal.signal(signal.SIGTERM, lambda signum, stack_frame: K.stop()) -def get_default_dl_dir(platform): - if platform == "raspberry_pi": - return "/usr/lib/pikaraoke/songs" - else: - legacy_directory = os.path.expanduser("~/pikaraoke/songs") - return legacy_directory if os.path.exists(legacy_directory) else os.path.expanduser("~/pikaraoke-songs") +def get_default_dl_dir(): + return os.path.expanduser("~/pikaraoke-songs") + +def get_default_tmp_dir(): + return '/dev/shm' if os.path.isdir('/dev/shm') else tempfile.gettempdir() def get_default_browser_cookie(platform): @@ -731,7 +1002,7 @@ def get_default_browser_cookie(platform): default_splash_delay = 3 default_log_level = logging.INFO - default_dl_dir = get_default_dl_dir(platform) + default_dl_dir = get_default_dl_dir() default_omxplayer_path = "/usr/bin/omxplayer" default_adev = "both" default_vlc_path = get_default_vlc_path(platform) @@ -746,8 +1017,8 @@ def get_default_browser_cookie(platform): default = None, ) parser.add_argument( - "-p", "--port", - help = f"Desired http port (default: {default_port})", + "-p", "--port", type=int, + help = f"Desired http/https port (default: {default_port})", default = default_port, ) parser.add_argument( @@ -880,13 +1151,19 @@ def get_default_browser_cookie(platform): default = None, ) parser.add_argument( - "--developer-mode", - help = "Run in flask developer mode. Only useful for tweaking the web UI in real time. Will disable the splash screen due to pygame main thread conflicts and may require FLASK_ENV=development env variable for full dev mode features.", - action = "store_true", + "--ssl", "-ssl", + help = "Use HTTPS instead of HTTP (browser microphone access requires HTTPS)", + action = 'store_true' + ) + parser.add_argument( + "--temp", "-tp", + default = None, + help = "Temporary folder location", ) - parser.add_argument('--cloud', '-C', + parser.add_argument( + '--cloud', '-C', default='', - help='full URL for cloud decoding', + help='cloud URL for DNN-based vocal split and speech recognition', ) args = parser.parse_args() @@ -898,6 +1175,9 @@ def get_default_browser_cookie(platform): app.jinja_env.globals.update(filename_from_path = filename_from_path) app.jinja_env.globals.update(url_escape = quote) + args.tmp_dir = os.path.expanduser(args.temp or get_default_tmp_dir()) + args.cloud = args.cloud.rstrip('/') + # Set browser cookies location for YouTube downloader if args.browser_cookies.lower() == 'none': args.cookies_opt = [] @@ -933,9 +1213,7 @@ def get_default_browser_cookie(platform): sys.exit(1) # setup/create download directory if necessary - args.dl_path = os.path.expanduser(args.download_path) - if not args.dl_path.endswith("/"): - args.dl_path += "/" + args.dl_path = os.path.expanduser(args.download_path).rstrip('/')+'/' if platform == 'windows': # on Windows, VLC cannot open filenames containing '/' args.dl_path = escape_win_filename(args.dl_path) if not os.path.exists(args.dl_path): @@ -951,31 +1229,14 @@ def get_default_browser_cookie(platform): elif args.save_delays == 'no': args.save_delays = None - if args.developer_mode: - logging.warning("Splash screen is disabled in developer mode due to main thread conflicts") - args.hide_splash_screen = True - # Configure karaoke process os.K = K = karaoke.Karaoke(args) - if (args.developer_mode): - th = threading.Thread(target = K.run) - th.start() - app.run(debug = True, port = args.port) - else: - # Start the CherryPy WSGI web server - cherrypy.tree.graft(app, "/") - # Set the configuration of the web server - cherrypy.config.update( - { - "engine.autoreload.on": False, - "log.screen": True, - "server.socket_port": int(args.port), - "server.socket_host": "0.0.0.0", - } - ) - cherrypy.engine.start() - K.run() - cherrypy.engine.exit() - - sys.exit() + if not args.ssl: + threading.Thread(target=lambda:app.run(host='0.0.0.0', port=args.port+1, threaded = True, ssl_context=('cert.pem', 'key.pem'))).start() + threading.Thread(target=lambda:app.run(host='0.0.0.0', port=args.port, threaded = True, ssl_context=('cert.pem', 'key.pem') if args.ssl else None)).start() + + threading.Thread(target=status_thread).start() + + K.run() + os._exit(0) # force-stop all flask threads and exit diff --git a/cert.pem b/cert.pem new file mode 100644 index 00000000..4f93a674 --- /dev/null +++ b/cert.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDrTCCApWgAwIBAgIUCYazL4hmpC1STPDhDnGm1gmjdhowDQYJKoZIhvcNAQEL +BQAwZjELMAkGA1UEBhMCU0cxEjAQBgNVBAgMCVNpbmdhcG9yZTESMBAGA1UEBwwJ +U2luZ2Fwb3JlMQ0wCwYDVQQKDARNT0hUMQwwCgYDVQQLDANEU1QxEjAQBgNVBAMM +CWxvY2FsaG9zdDAeFw0yNDAyMjAwMjM0NDNaFw0zNDAyMTcwMjM0NDNaMGYxCzAJ +BgNVBAYTAlNHMRIwEAYDVQQIDAlTaW5nYXBvcmUxEjAQBgNVBAcMCVNpbmdhcG9y +ZTENMAsGA1UECgwETU9IVDEMMAoGA1UECwwDRFNUMRIwEAYDVQQDDAlsb2NhbGhv +c3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDIbkCFUznTbnoyVcnl +BBVo59KRxX88fmwzz5ZKs77UMXsgXIyr2hM12qISpSRq94HoQNCTC2apkb03iizd +ZQ6uF/lnGxstykCnhmDEh7nAGbAWCCeGRKi2WtKVmlamfF9pfm4Pxn2NWi9HBWyq +fYw9ipbg4BfGyStEW8JDqBSW0MoNdmoBSjQiCRy1yQvVk4JeidVyNoufA5WNu5gj +3UlZDBvLxh682kDNj93BKFFIY3Xf5oisAXfMZx97TvdznBim3y+z1pqbno0cyrYC +c9auCx3FYP3NQVJDt6I4gYTZBxq0cVza28rEeOb0vEJn06aerMbTelNLxARjLXwE +Lj/pAgMBAAGjUzBRMB0GA1UdDgQWBBS5CF9MG9pNxwqesiVU3Es/IxviWTAfBgNV +HSMEGDAWgBS5CF9MG9pNxwqesiVU3Es/IxviWTAPBgNVHRMBAf8EBTADAQH/MA0G +CSqGSIb3DQEBCwUAA4IBAQBU+cha1J8iW0JaT/5a3J9vLlRid1esk65X9zRmRWXA +KqKiO2+WHEWUx+PQD3yCCbm01brodDnnRCMZtWizr81WMTF4q/E/m/azy3i6Sqaq +rDvWkjWw+FVUvFZpwxLq+4+9HesXZHVu9ut1mhBShvzzEFu5dK4ukr5STl1ZJCzV +F2/v/XjN0zkik3/q++P1KbMta6cztJ5d2Y5EhJpf7CPteB/SWPxXp6CC7caxMsHS +pIc+ycofhwOWMjom1Q5gSpVU8/KvFM2GU8SCJhfAd0Yo1VQyPQDqA5QkM4R9aqee +HVFwdWyAF+0MgX+mmzXprkyc43DX22Lxp4+oXZJDhgJv +-----END CERTIFICATE----- diff --git a/karaoke.py b/karaoke.py index cf3a8575..8945f329 100644 --- a/karaoke.py +++ b/karaoke.py @@ -1,4 +1,4 @@ -import os, sys, io, random, time, json, hashlib +import os, sys, io, random, time, json import logging, socket, subprocess, threading import multiprocessing as mp import shutil, psutil, traceback, tarfile, requests @@ -19,7 +19,6 @@ from app import getString STD_VOL = 65536/8/np.sqrt(2) -TMP_DIR = '/dev/shm' if get_platform() != "windows": from signal import SIGALRM, alarm, signal @@ -36,13 +35,10 @@ def cleanse_modules(name): 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) ref_W, ref_H = 1920, 1080 # reference screen size, control drawing scale queue = [] - queue_hash = None + queue_json = '' available_songs = [] rename_history = {} songname_trans = {} # transliteration is used for sorting and initial letter search @@ -71,6 +67,8 @@ class Karaoke: volume_offset = 0 default_logo_path = os.path.join(base_path, "logo.png") logical_volume = None # for normalized volume + status_dirty = True + event_dirty = threading.Event() def __init__(self, args): @@ -119,7 +117,7 @@ def __init__(self, args): logging.debug("IP address (for QR code and splash screen): " + self.ip) - self.url = "http://%s:%s" % (self.ip, self.port) + self.url = "%s://%s:%s" % (('https' if self.ssl else 'http'), self.ip, self.port) # get songs from download_path self.get_available_songs() @@ -176,12 +174,12 @@ def _cloud_thread(self): bn, dn = os.path.basename(fn), os.path.dirname(fn) if os.path.isfile(f'{self.download_path}nonvocal/{bn}.m4a') and os.path.isfile(f'{self.download_path}vocal/{bn}.m4a'): continue - os.system(f'ffmpeg -y -i "{fn}" -vn -c copy {TMP_DIR}/input.m4a') - with open(f'{TMP_DIR}/input.m4a', 'rb') as f: - r = requests.post(self.cloud, files={'file': f}) - with open(f'{TMP_DIR}/output.tar.gz', 'wb') as f: + os.system(f'ffmpeg -y -i "{fn}" -vn -c copy {self.tmp_dir}/input.m4a') + with open(f'{self.tmp_dir}/input.m4a', 'rb') as f: + r = requests.post(self.cloud+'/split_vocal', files={'file': f}) + with open(f'{self.tmp_dir}/output.tar.gz', 'wb') as f: f.write(r.content) - with tarfile.open(f'{TMP_DIR}/output.tar.gz') as tar: + with tarfile.open(f'{self.tmp_dir}/output.tar.gz') as tar: tar.extract('nonvocal.m4a', f'{self.download_path}nonvocal') os.rename(f'{self.download_path}nonvocal/nonvocal.m4a', f'{self.download_path}nonvocal/{bn}.m4a') tar.extract('vocal.m4a', f'{self.download_path}vocal') @@ -196,7 +194,7 @@ def get_ip(self): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: # doesn't even have to be reachable - s.connect(("10.255.255.255", 1)) + s.connect(("8.8.8.8", 1)) IP = s.getsockname()[0] except Exception: IP = "127.0.0.1" @@ -204,31 +202,6 @@ 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 = self.call_yt_dlp(['--version'], True).strip() return self.youtubedl_version @@ -359,28 +332,12 @@ def render_splash_screen(self): if self.streamer_alive(): text = self.render_font(sysfont_size, getString(50) + self.url.rsplit(":", 1)[0] + ":4000", (255, 255, 255)) self.screen.blit(text[0], self.normalize((qr_size + 35, blitY - 40))) - if not self.firstSongStarted and self.platform != 'osx': + if not self.firstSongStarted: text = self.render_font(sysfont_size, getString(51), (255, 255, 255)) self.screen.blit(text[0], self.normalize((qr_size + 35, blitY - 120))) text = self.render_font(sysfont_size, getString(52), (255, 255, 255)) self.screen.blit(text[0], self.normalize((qr_size + 35, blitY - 80))) - if not self.hide_raspiwifi_instructions and self.raspi_wifi_config_installed and self.raspi_wifi_config_ip in self.url: - server_port, ssid_prefix, ssl_enabled = self.get_raspi_wifi_conf_vals() - - text1 = self.render_font(sysfont_size, getString(53), (255, 255, 255)) - text2 = self.render_font(sysfont_size, getString(54) % ssid_prefix, (255, 255, 255)) - text3 = self.render_font(sysfont_size, - getString(55) - % ("https" if ssl_enabled == "1" else "http", - self.raspi_wifi_config_ip, - ":%s" % server_port if server_port != "80" else ""), - (255, 255, 255), - ) - self.screen.blit(text1[0], self.normalize((10, 10))) - self.screen.blit(text2[0], self.normalize((10, 50))) - self.screen.blit(text3[0], self.normalize((10, 90))) - blitY = 10 if not self.has_video: logging.debug("Rendering current song to splash screen") @@ -681,6 +638,7 @@ def play_file(self, file_path, extra_params = []): self.omxclient.play_file(file_path) self.switchingSong = False + self.status_dirty = True self.render_splash_screen() # remove old previous track def play_transposed(self, semitones): @@ -713,7 +671,7 @@ def enqueue(self, song_path, user = "Pikaraoke"): else: logging.info("'%s' is adding song to queue: %s" % (user, song_path)) self.queue.append({"user": user, "file": song_path, "title": self.filename_from_path(song_path)}) - self.update_queue_hash() + self.update_queue() return True def queue_add_random(self, amount): @@ -732,19 +690,20 @@ def queue_add_random(self, amount): i += 1 songs.pop(r) if len(songs) == 0: - self.update_queue_hash() + self.update_queue() logging.warn("Ran out of songs!") return False - self.update_queue_hash() + self.update_queue() return True - def update_queue_hash(self): - self.queue_hash = hashlib.md5(json.dumps(self.queue).encode('utf-8')).hexdigest() + def update_queue(self): + self.queue_json = json.dumps(self.queue) + self.status_dirty = True def queue_clear(self): logging.info("Clearing queue!") self.queue = [] - self.update_queue_hash() + self.update_queue() self.skip() def queue_edit(self, song_name, action, **kwargs): @@ -795,7 +754,7 @@ def queue_edit(self, song_name, action, **kwargs): else: logging.error("Unrecognized direction: " + action) return False - self.update_queue_hash() + self.update_queue() return True def skip(self): @@ -855,6 +814,7 @@ def set_audio_delay(self, delay): self.vlcclient.command(f"audiodelay&val={self.audio_delay}") else: logging.warning("OMXplayer cannot set audio delay!") + self.status_dirty = True return self.audio_delay logging.warning("Tried to set audio delay, but no file is playing!") return False @@ -881,6 +841,7 @@ def set_subtitle_delay(self, delay): self.vlcclient.command(f"subdelay&val={self.subtitle_delay}") else: logging.warning("OMXplayer cannot set subtitle delay!") + self.status_dirty = True return self.subtitle_delay logging.warning("Tried to set subtitle delay, but no file is playing!") return False @@ -908,6 +869,7 @@ def pause(self): else: self.omxclient.play() self.is_paused = False + self.status_dirty = True return True else: logging.warning("Tried to pause, but no file is playing!") @@ -1230,7 +1192,7 @@ def run(self): self.streamer_restart(1) self.firstSongStarted = True self.now_playing_user = head["user"] - self.update_queue_hash() + self.update_queue() elif (self.full_screen and not pygame.display.get_active()) and not self.is_file_playing(): self.pygame_reset_screen() self.handle_run_loop() @@ -1241,7 +1203,8 @@ def run(self): # Clean up before quit self.streamer_stop() self.vocal_stop() - (self.vlcclient if self.use_vlc else self.omxclient).stop() + vplayer = self.vlcclient if self.use_vlc else self.omxclient + if vplayer is not None: vplayer.stop() self.auto_save_delays() time.sleep(1) - (self.vlcclient if self.use_vlc else self.omxclient).kill() + if vplayer is not None: vplayer.kill() diff --git a/key.pem b/key.pem new file mode 100644 index 00000000..8ac25a0e --- /dev/null +++ b/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDIbkCFUznTbnoy +VcnlBBVo59KRxX88fmwzz5ZKs77UMXsgXIyr2hM12qISpSRq94HoQNCTC2apkb03 +iizdZQ6uF/lnGxstykCnhmDEh7nAGbAWCCeGRKi2WtKVmlamfF9pfm4Pxn2NWi9H +BWyqfYw9ipbg4BfGyStEW8JDqBSW0MoNdmoBSjQiCRy1yQvVk4JeidVyNoufA5WN +u5gj3UlZDBvLxh682kDNj93BKFFIY3Xf5oisAXfMZx97TvdznBim3y+z1pqbno0c +yrYCc9auCx3FYP3NQVJDt6I4gYTZBxq0cVza28rEeOb0vEJn06aerMbTelNLxARj +LXwELj/pAgMBAAECggEAAqOpB9hrt4QJ6eXFTdW67nG3DSJbpZFInXoLUZqCVcr9 +AxUU6SJrBFeHsNUp4Jq3xDYuSPFqfaskobmZE6VEBzjbmOb7eaPjLBGR7qSW5pew +AOpEkvkOe7BAe6XXpEOZAgufKWgEuTrJypagsrIKibjsebOZvI1jm3fV3Aw3kTDS +r3+gdhf9IaxrAnRJb/ofZO2H6Nl0mTf2MYW9u9SCGro3qQPeTlimKqbGyD7SBY0t +UVo263/gtyhkEQriVgH/OT7DP6v2mpEDUJex0idnEFbUTMQPcHB8jO2QdisJOMSF +qXRItvYwQ8OnHjheFO63pdlpUNBjeRvqz5jPKou3LQKBgQDtGYR5qpDRknbG8RMZ +K5TRoWZVICc+KiufObFRpn0G2xS2+1V1bVrevlYWhVUDEZJP+w02UZwJmUJLZ3mi +7rcmBeoE0asSje4jGwRvF2LzU8PsALBcgnNEPxCgaTZVa8RXxt7qfR686B/hyJVl +dFbj+ymdBZ/N7xoqXA1IKDDZLwKBgQDYaG6KFLyUywh92kcJbVBz6aglu44hpoNM +Nc1jZBcCWRZtDRgq4N1EAnywrr4qNd49K+JvziVK45zmox/4l8fO9cz5gy62d//y +3/RuHp1uQbBP2VkPfnE1pRmw6BBVwGVXQT2OxUtfv00whP/p6g3xCwWixCb4A45J +tV2tYFOCZwKBgEknz20uyIIxO77EUU052jAYKZfoVNNkdZMqXODBiuAbpreSstqJ +hDkn5y4Js6I/wLI21uvEoG2BwSe85JX2K8Jqnrsf6jXOXwSr6GFSiPBlDxV5XPEe +Oux6vOmRrPcMSkd/D/av5lUJFhU3K4ljLRAoxb0yKA2FUcByKJLz+6Z3AoGBAKez +zi29QiLAcFUvKSuaBeW9pauP++YB5u/RJdgnAVm1dhaONjNMTMvebYhx8jY9xnp7 +Hp/sDaRKXRWyRh1cad/tRan6SeHnfnZVrVME8JU33I0Ubtr97ndW+WDJhZceYKpM +iYqxQ+bwFb5K4xL/aUxpcTWgcl5ySvq9zNmFs1FzAoGAJ9tqRpjbhnYb/AKDDJsg +OYVLq35xP0XBKr5qy+TOb9+b43DkOwkZsVM2g15tA14EsR6TmAq4TkZNC0OJo3hR +Bjc0KnvQMWtnZ30GdLZcZnTSuenVavynDU81wTQI7nGwwna7UY+9VmSfbUck+3fY +x3YUiPfkWTaXQJ/g6yOm2ko= +-----END PRIVATE KEY----- diff --git a/lang/en_US b/lang/en_US index 2cefa4ca..61fcfa37 100644 --- a/lang/en_US +++ b/lang/en_US @@ -105,7 +105,7 @@ Edit song Move up in queue Move down in queue Are you sure you want to delete from the queue -Are you sure you want to clear the ENTIRE queue? Type 'OK' to continue +Are you sure you want to clear the ENTIRE queue? Add 3 random songs Clear all The queue is empty @@ -209,3 +209,21 @@ Download subtitles (only embedded) in all languages if available Downloading high-quality videos might take very long time Save Play Settings Save play settings for each song, this includes: audio delay, subtitle delay, and whether to show subtitles. +Add song by speaking the song title +Please speak the song title and click OK! +Only HTTPS websites can have access to microphone, do you want to redirect to HTTPS website? +Please grant the permission to access microphone! +Edit Song Name: +Save +Delete this song +Are you sure you want to delete this song from the library? +Request parameter error! +Trim the song: +Adjust song volume: +Begin time: +End time: +Original Volume (dB): +Cannot find any song matching "%s". +Added "%s" into queue. +Please choose the song(s) to add: +UNDO diff --git a/lang/zh_CN b/lang/zh_CN index f5945af4..a458fe1a 100644 --- a/lang/zh_CN +++ b/lang/zh_CN @@ -101,11 +101,11 @@ Wifi/网络未连接,10秒后关机... 首歌曲 显示 {start} - {end}{record_name},共计 {total}{record_name} 找到 {found}{record_name},显示 {start} - {end}{record_name} -修改歌曲文件 +修改歌曲 在队列中向上移 在队列中向下移 您确定要从点歌队列中删除 -您确定要清空整个队列吗? 输入“OK”继续 +您确定要清空整个队列吗? 随机添加3首歌曲 全部清除 队列为空 @@ -209,3 +209,21 @@ Pi卡拉OK版本 下载高清版本可能需要很长时间 保存播放设置 保存每一首歌曲的播放设置,包括:音频延迟,字幕延迟,以及是否显示字幕。 +用语音识别来添加歌曲 +请说出歌曲名称然后点击确定! +只有HTTPS的网页才能获取麦克风权限,是否切换到HTTPS网页? +请开启麦克风权限! +编辑歌曲名: +保存 +删除此歌曲 +确定从歌曲库中删除此歌曲? +请求参数错误! +剪辑该歌曲: +调整歌曲音量: +起始时间: +截止时间: +歌曲原始音量(分贝): +找不到匹配歌曲:"%s"。 +“%s”已被加入点歌列表。 +请选择想要添加的歌曲: +撤销 diff --git a/lib/ChineseNumber.py b/lib/ChineseNumber.py new file mode 100644 index 00000000..c2f714a4 --- /dev/null +++ b/lib/ChineseNumber.py @@ -0,0 +1,417 @@ +from typing import Union +from warnings import warn + +NUMBER_CN2AN = { + "零": 0, + "〇": 0, + "一": 1, + "壹": 1, + "幺": 1, + "二": 2, + "贰": 2, + "两": 2, + "三": 3, + "叁": 3, + "四": 4, + "肆": 4, + "五": 5, + "伍": 5, + "六": 6, + "陆": 6, + "七": 7, + "柒": 7, + "八": 8, + "捌": 8, + "九": 9, + "玖": 9, +} +UNIT_CN2AN = { + "十": 10, + "拾": 10, + "百": 100, + "佰": 100, + "千": 1000, + "仟": 1000, + "万": 10000, + "亿": 100000000, +} +UNIT_LOW_AN2CN = { + 10: "十", + 100: "百", + 1000: "千", + 10000: "万", + 100000000: "亿", +} +NUMBER_LOW_AN2CN = { + 0: "零", + 1: "一", + 2: "二", + 3: "三", + 4: "四", + 5: "五", + 6: "六", + 7: "七", + 8: "八", + 9: "九", +} +NUMBER_UP_AN2CN = { + 0: "零", + 1: "壹", + 2: "贰", + 3: "叁", + 4: "肆", + 5: "伍", + 6: "陆", + 7: "柒", + 8: "捌", + 9: "玖", +} +UNIT_LOW_ORDER_AN2CN = [ + "", + "十", + "百", + "千", + "万", + "十", + "百", + "千", + "亿", + "十", + "百", + "千", + "万", + "十", + "百", + "千", +] +UNIT_UP_ORDER_AN2CN = [ + "", + "拾", + "佰", + "仟", + "万", + "拾", + "佰", + "仟", + "亿", + "拾", + "佰", + "仟", + "万", + "拾", + "佰", + "仟", +] +STRICT_CN_NUMBER = { + "零": "零", + "一": "一壹", + "二": "二贰", + "三": "三叁", + "四": "四肆", + "五": "五伍", + "六": "六陆", + "七": "七柒", + "八": "八捌", + "九": "九玖", + "十": "十拾", + "百": "百佰", + "千": "千仟", + "万": "万", + "亿": "亿", +} +NORMAL_CN_NUMBER = { + "零": "零〇", + "一": "一壹幺", + "二": "二贰两", + "三": "三叁仨", + "四": "四肆", + "五": "五伍", + "六": "六陆", + "七": "七柒", + "八": "八捌", + "九": "九玖", + "十": "十拾", + "百": "百佰", + "千": "千仟", + "万": "万", + "亿": "亿", +} + +class An2Cn(object): + all_num = "0123456789" + def __init__(self) -> None: + self.number_low = NUMBER_LOW_AN2CN + self.number_up = NUMBER_UP_AN2CN + self.mode_list = ["low", "up", "rmb", "direct"] + + def an2cn(self, inputs: Union[str, int, float] = None, mode: str = "low") -> str: + """阿拉伯数字转中文数字 + + :param inputs: 阿拉伯数字 + :param mode: low 小写数字,up 大写数字,rmb 人民币大写,direct 直接转化 + :return: 中文数字 + """ + if inputs is not None and inputs != "": + if mode not in self.mode_list: + raise ValueError(f"mode 仅支持 {str(self.mode_list)} !") + + # 将数字转化为字符串,这里会有Python会自动做转化 + # 1. -> 1.0 1.00 -> 1.0 -0 -> 0 + if not isinstance(inputs, str): + inputs = self.__number_to_string(inputs) + + # 检查数据是否有效 + self.__check_inputs_is_valid(inputs) + + # 判断正负 + if inputs[0] == "-": + sign = "负" + inputs = inputs[1:] + else: + sign = "" + + if mode == "direct": + output = self.__direct_convert(inputs) + else: + # 切割整数部分和小数部分 + split_result = inputs.split(".") + len_split_result = len(split_result) + if len_split_result == 1: + # 不包含小数的输入 + integer_data = split_result[0] + if mode == "rmb": + output = self.__integer_convert(integer_data, "up") + "元整" + else: + output = self.__integer_convert(integer_data, mode) + elif len_split_result == 2: + # 包含小数的输入 + integer_data, decimal_data = split_result + if mode == "rmb": + int_data = self.__integer_convert(integer_data, "up") + dec_data = self.__decimal_convert(decimal_data, "up") + len_dec_data = len(dec_data) + + if len_dec_data == 0: + output = int_data + "元整" + elif len_dec_data == 1: + raise ValueError(f"异常输出:{dec_data}") + elif len_dec_data == 2: + if dec_data[1] != "零": + if int_data == "零": + output = dec_data[1] + "角" + else: + output = int_data + "元" + dec_data[1] + "角" + else: + output = int_data + "元整" + else: + if dec_data[1] != "零": + if dec_data[2] != "零": + if int_data == "零": + output = dec_data[1] + "角" + dec_data[2] + "分" + else: + output = int_data + "元" + dec_data[1] + "角" + dec_data[2] + "分" + else: + if int_data == "零": + output = dec_data[1] + "角" + else: + output = int_data + "元" + dec_data[1] + "角" + else: + if dec_data[2] != "零": + if int_data == "零": + output = dec_data[2] + "分" + else: + output = int_data + "元" + "零" + dec_data[2] + "分" + else: + output = int_data + "元整" + else: + output = self.__integer_convert(integer_data, mode) + self.__decimal_convert(decimal_data, mode) + else: + raise ValueError(f"输入格式错误:{inputs}!") + else: + raise ValueError("输入数据为空!") + + return sign + output + + def __direct_convert(self, inputs: str) -> str: + _output = "" + for d in inputs: + if d == ".": + _output += "点" + else: + _output += self.number_low[int(d)] + return _output + + @staticmethod + def __number_to_string(number_data: Union[int, float]) -> str: + # 小数处理:python 会自动把 0.00005 转化成 5e-05,因此 str(0.00005) != "0.00005" + string_data = str(number_data) + if "e" in string_data: + string_data_list = string_data.split("e") + string_key = string_data_list[0] + string_value = string_data_list[1] + if string_value[0] == "-": + string_data = "0." + "0" * (int(string_value[1:]) - 1) + string_key + else: + string_data = string_key + "0" * int(string_value) + return string_data + + def __check_inputs_is_valid(self, check_data: str) -> None: + # 检查输入数据是否在规定的字典中 + all_check_keys = self.all_num + ".-" + for data in check_data: + if data not in all_check_keys: + raise ValueError(f"输入的数据不在转化范围内:{data}!") + + def __integer_convert(self, integer_data: str, mode: str) -> str: + if mode == "low": + numeral_list = NUMBER_LOW_AN2CN + unit_list = UNIT_LOW_ORDER_AN2CN + elif mode == "up": + numeral_list = NUMBER_UP_AN2CN + unit_list = UNIT_UP_ORDER_AN2CN + else: + raise ValueError(f"error mode: {mode}") + + # 去除前面的 0,比如 007 => 7 + integer_data = str(int(integer_data)) + + len_integer_data = len(integer_data) + if len_integer_data > len(unit_list): + raise ValueError(f"超出数据范围,最长支持 {len(unit_list)} 位") + + output_an = "" + for i, d in enumerate(integer_data): + if int(d): + output_an += numeral_list[int(d)] + unit_list[len_integer_data - i - 1] + else: + if not (len_integer_data - i - 1) % 4: + output_an += numeral_list[int(d)] + unit_list[len_integer_data - i - 1] + + if i > 0 and not output_an[-1] == "零": + output_an += numeral_list[int(d)] + + output_an = output_an.replace("零零", "零").replace("零万", "万").replace("零亿", "亿").replace("亿万", "亿") \ + .strip("零") + + # 解决「一十几」问题 + if output_an[:2] in ["一十"]: + output_an = output_an[1:] + + # 0 - 1 之间的小数 + if not output_an: + output_an = "零" + + return output_an + + def __decimal_convert(self, decimal_data: str, o_mode: str) -> str: + len_decimal_data = len(decimal_data) + + if len_decimal_data > 16: + warn(f"注意:小数部分长度为 {len_decimal_data} ,将自动截取前 16 位有效精度!") + decimal_data = decimal_data[:16] + + if len_decimal_data: + output_an = "点" + else: + output_an = "" + + if o_mode == "low": + numeral_list = NUMBER_LOW_AN2CN + elif o_mode == "up": + numeral_list = NUMBER_UP_AN2CN + else: + raise ValueError(f"error mode: {o_mode}") + + for data in decimal_data: + output_an += numeral_list[int(data)] + return output_an + + +g_An2Cn = An2Cn() +digit2low = { + '.': "点", + '0': "零", + '1': "一", + '2': "二", + '3': "三", + '4': "四", + '5': "五", + '6': "六", + '7': "七", + '8': "八", + '9': "九", +} +def num2zh(txt): + # find all numerical segments + bm_numerical = [(c in digit2low) for c in txt] + posi = 0 + segs = [] + while posi1: + out += ''.join([digit2low[c] for c in cks[1]]) + return out + + +### Convert Chinese numerals to arabic number string, e.g. +# '七百六十五万两千三百二十四' => '7652324' +# '三百八十五万点五零四二' => '3850000.5042' +# '零〇七' => '007' +fst_zh2num = { + "〇": "零", + "两": "二", + "点": lambda t: t+'.', + "零": lambda t: t+'0' if ('.' in t or set(t)=={'0'} or t=='') else t, + "一": lambda t: t+'1' if ('.' in t or set(t)=={'0'}) else t[:-1]+'1', + "二": lambda t: t+'2' if ('.' in t or set(t)=={'0'}) else t[:-1]+'2', + "三": lambda t: t+'3' if ('.' in t or set(t)=={'0'}) else t[:-1]+'3', + "四": lambda t: t+'4' if ('.' in t or set(t)=={'0'}) else t[:-1]+'4', + "五": lambda t: t+'5' if ('.' in t or set(t)=={'0'}) else t[:-1]+'5', + "六": lambda t: t+'6' if ('.' in t or set(t)=={'0'}) else t[:-1]+'6', + "七": lambda t: t+'7' if ('.' in t or set(t)=={'0'}) else t[:-1]+'7', + "八": lambda t: t+'8' if ('.' in t or set(t)=={'0'}) else t[:-1]+'8', + "九": lambda t: t+'9' if ('.' in t or set(t)=={'0'}) else t[:-1]+'9', + "十": lambda t: t[:-2]+(t[-1] if t else '1')+'0', + "百": lambda t: t[:-3]+t[-1]+'00', + "千": lambda t: t[:-4]+t[-1]+'000', + "万": lambda t: t[:-8]+t[-4:]+'0000', + "亿": lambda t: t[:-16]+t[-8:]+'00000000', +} + +def zh2num(txt): + out = '' + for c in txt: + if c not in fst_zh2num: + return txt + tgt = fst_zh2num[c] + out = fst_zh2num[tgt](out) if type(tgt)==str else tgt(out) + return out + diff --git a/lib/NLP.py b/lib/NLP.py new file mode 100644 index 00000000..654ec820 --- /dev/null +++ b/lib/NLP.py @@ -0,0 +1,180 @@ +import os, sys, io, string, json, subprocess, yt_dlp +import pykakasi, pinyin, logging, requests, shutil +from unidecode import unidecode +from urllib.parse import unquote + +from lib.ChineseNumber import * +from lib.settings import * + +KKS = pykakasi.kakasi() +filelist, cookies_opt = [], [] +downloading_songs = {} +to_pinyin = lambda t: pinyin.get(t, format='numerical') +translit = lambda t: unidecode(t).lower() +get_alpha = lambda t: ''.join([c for c in t if c in string.ascii_letters]) +get_alnum = lambda t: ''.join([c for c in t if c in string.ascii_letters+string.digits]) +to_romaji = lambda t: ' '.join([its['hepburn'] for its in KKS.convert(t)]) +ls_media_files = lambda fullpath: sorted([f'{fullpath}/{f}'.replace('//','/') for f in os.listdir(fullpath) if not f.startswith('.') and '.'+f.split('.')[-1] in media_file_exts]) +ls_subdir = lambda fullpath: sorted([g.rstrip('/') for f in os.listdir(fullpath) for g in [f'{fullpath}/{f}'.replace('//','/')] if not f.startswith('.') and os.path.isdir(g)]) + + +def sec2hhmmss(sec): + s = float(sec) + return f'{int(s//3600)}:{int((s%3600)//60)}:{"%.2f"%(s%60)}' + +def hhmmss2sec(hms): + hh, mm, ss = [float(i) for i in (['0', '0']+hms.split(':'))[-3:]] + return hh*3600 + mm*60 + ss + + +def Try(fn, default=None): + try: + return fn() + except Exception as e: + return str(e) if default=='ERROR_MSG' else default + +get_filesize = lambda fn: Try(lambda: os.path.getsize(fn), 0) + +def fuzzy(txt, dct=FUZZY_PINYIN): + for src, tgt in dct.items(): + txt = txt.replace(src, tgt) + return txt + + +def str_search(name, name_list): + # 1. exact full match + if name in name_list: + return [name_list.index(name)] + + # 2. exact substring match + res = [[ii, -abs(len(it)-len(name))] for ii,it in enumerate(name_list) if name in it or it in name] + return [it[0] for it in sorted(res, key=lambda t:t[1])] if res else [] + + +def filepath2songtitle(fn): + s = os.path.basename(unquote(fn).rstrip('/')).split('.')[0].strip() + return os.path.basename(os.path.dirname(unquote(fn).rstrip('/')))+s if s.isdigit() else s + + +def findSong(name, lang=None, flist=filelist): + name = name.lower().strip() + name_list = [filepath2songtitle(fn).lower() for fn in flist] + + # 0. pre-transform + if lang == 'el': + name = fuzzy(name, FUZZY_GREEK) + name_list = [fuzzy(n, FUZZY_GREEK) for n in name_list] + + # 1. exact full match of original form + if name in name_list: + return [name_list.index(name)] + + # 2. match by pinyin if Chinese or unknown + if lang in [None, 'zh']: + # 3. pinyin full match + pinyin_list = [get_alnum(fuzzy(to_pinyin(num2zh(n)))) for n in name_list] + pinyin_name = get_alnum(fuzzy(to_pinyin(num2zh(name)))) + res = str_search(pinyin_name, pinyin_list) + if pinyin_name and res: + return res + pinyin_list = [get_alpha(fuzzy(to_pinyin(num2zh(n)))) for n in name_list] + pinyin_name = get_alpha(fuzzy(to_pinyin(num2zh(name)))) + if pinyin_name and res: + return res + + # 3. match by romaji if Japanese or unknown + if lang in [None, 'ja']: + # 5. romaji full match + romaji_list = [get_alpha(to_romaji(n)) for n in name_list] + romaji_name = get_alpha(to_romaji(name)) + res = str_search(romaji_name, romaji_list) + if romaji_name and res: + return res + + # 4. substring match + res = str_search(name, name_list) + if res: + return res + + # 5. match by transliteration + translit_list = [get_alpha(fuzzy(translit(n))) for n in name_list] + translit_name = get_alpha(fuzzy(translit(name))) + res = str_search(translit_name, translit_list) + if translit_name and res: + return res + + return [] + + +def findMedia(base_path, name, lang=None, stack=0): + f_lst = ls_media_files(base_path) + res = findSong(name, lang, f_lst) + if res: + return [f_lst[i] for i in res] + if stackfontello font demo
icon-logout0xe825
-
- icon-left-circled0xf0a8 +
+ icon-adult0xe826
+
+ icon-left-circled0xf0a8 +
icon-right-circled0xf0a9
@@ -388,11 +391,11 @@

fontello font demo

icon-down-circled0xf0ab
+
+
icon-list-bullet0xf0ca
-
-
icon-list-numbered0xf0cb
@@ -402,11 +405,11 @@

fontello font demo

icon-sort-down0xf0dd
+
+
icon-sort-up0xf0de
-
-
icon-folder-open-empty0xf115
@@ -416,10 +419,13 @@

fontello font demo

📂 icon-volume-up0x1f4c2
+
+
🔍 icon-volume-down0x1f50d
+ diff --git a/static/fontello/font/fontello.eot b/static/fontello/font/fontello.eot index c3b616dd..6870679e 100644 Binary files a/static/fontello/font/fontello.eot and b/static/fontello/font/fontello.eot differ diff --git a/static/fontello/font/fontello.svg b/static/fontello/font/fontello.svg index cefe6f7c..86205106 100644 --- a/static/fontello/font/fontello.svg +++ b/static/fontello/font/fontello.svg @@ -1,7 +1,7 @@ -Copyright (C) 2022 by original authors @ fontello.com +Copyright (C) 2024 by original authors @ fontello.com @@ -100,6 +100,8 @@ + + diff --git a/static/fontello/font/fontello.ttf b/static/fontello/font/fontello.ttf index 4d92c8e5..0ef8a26c 100644 Binary files a/static/fontello/font/fontello.ttf and b/static/fontello/font/fontello.ttf differ diff --git a/static/fontello/font/fontello.woff b/static/fontello/font/fontello.woff index b215b10a..a27cb15f 100644 Binary files a/static/fontello/font/fontello.woff and b/static/fontello/font/fontello.woff differ diff --git a/static/fontello/font/fontello.woff2 b/static/fontello/font/fontello.woff2 index d121ac8b..ee9b4452 100644 Binary files a/static/fontello/font/fontello.woff2 and b/static/fontello/font/fontello.woff2 differ diff --git a/static/siriwave.umd.min.js b/static/siriwave.umd.min.js new file mode 100644 index 00000000..9bebc8e8 --- /dev/null +++ b/static/siriwave.umd.min.js @@ -0,0 +1,15 @@ +!function(t,i){"object"==typeof exports&&"undefined"!=typeof module?module.exports=i():"function"==typeof define&&define.amd?define(i):(t="undefined"!=typeof globalThis?globalThis:t||self).SiriWave=i()}(this,(function(){"use strict"; +/*! ***************************************************************************** + Copyright (c) Microsoft Corporation. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + PERFORMANCE OF THIS SOFTWARE. + ***************************************************************************** */var t=function(){return t=Object.assign||function(t){for(var i,s=1,e=arguments.length;ss&&(this.spawnAt=0),this.prevMaxY=s,null},t.getDefinition=function(){return[{color:"255,255,255",supportLine:!0},{color:"15, 82, 169"},{color:"173, 57, 76"},{color:"48, 220, 155"}]},t}();return function(){function e(e){var o=this,n=e.container,h=function(t,i){var s={};for(var e in t)Object.prototype.hasOwnProperty.call(t,e)&&i.indexOf(e)<0&&(s[e]=t[e]);if(null!=t&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(e=Object.getOwnPropertySymbols(t);o
`; - } - var popUpList = $(`
${popup_html}
`) - popUpList.dialog(); - }); - }); + if (currentUser) { + $("#current-user span").text(currentUser) + $("#current-user").removeClass("is-hidden"); + }; + + $('#current-user').click(function(e) { + e.preventDefault(); + let name = window.prompt( + "{{getString1(133)}}" + currentUser + ); + if (name) { + Cookies.set("user", name, { expires: 3650 }) + } + location.reload(); + }); + + // handle browser language + var currentLang = Cookies.get("lang"); + if(!currentLang){ + currentLang = window.navigator.language; + setLanguage(currentLang, false); + } + + $('#current-lang').click(function(e) { + e.preventDefault(); + $.get('/get_lang_list').done(function(lang_json){ + var lang_dict = JSON.parse(lang_json); + var popup_html = ''; + for(var lang1 in lang_dict){ + popup_html += ` ${lang_dict[lang1]}
`; + } + var popUpList = $(`
${popup_html}
`) + popUpList.dialog(); }); - + }); +}); + {% block scripts %} {% endblock %} - +
+
+

{{getString1(213)}}

+ +

  + ✅︎

+
{% if not blank_page %}