diff --git a/explorers/__main__.py b/explorers/__main__.py index dcba990..ca0d204 100644 --- a/explorers/__main__.py +++ b/explorers/__main__.py @@ -1,148 +1,9 @@ -import json -import requests -from explorers import __version__ -from explorers import constants -from explorers.utils import logger -from ffmpeg_progress_yield import FfmpegProgress -from subprocess import Popen, PIPE, STDOUT -from shutil import which -from tqdm import tqdm -from pathlib import Path +import sys +from explorers.webui import main as webui_main +from explorers.console import main as console_main -VIDEO_PATH = Path("videos") -VIDEO_PATH.mkdir(exist_ok=True) -ffmpeg_bin: str = None - -def download_m3u8(m3u8_url: str, output_path: Path): - ff = FfmpegProgress( - [ - ffmpeg_bin, - "-headers", - f"User-Agent: {constants.USER_AGENT}", - "-headers", - "Origin: https://mapstudy.vn", - "-headers", - "Referer: https://mapstudy.vn/", - "-i", - m3u8_url, - "-c", - "copy", - str(output_path), - ], - ) - try: - with tqdm(total=100, position=1, desc=output_path.name) as pbar: - for progress in ff.run_command_with_progress(): - pbar.update(progress - pbar.n) - except RuntimeError: - print("\r", end="") - logger.error(f"Failed to download {m3u8_url} to {output_path}: {ff.stderr}") - return - print("\r", end="") - logger.info(f"Downloaded {m3u8_url} to {output_path}.") - - -def read_config() -> dict: - try: - with open("config.json", "r") as f: - return json.load(f) - except FileNotFoundError: - logger.error( - "Config file not found, please see 'config.example.json' for an example." - ) - exit(1) - except json.JSONDecodeError: - logger.error( - "Config file is not a valid JSON file, please see 'config.example.json' for a valid example." - ) - exit(2) - - -def loop(config: dict): - token = config["token"] - client = requests.Session() - client.headers.update( - { - "User-Agent": constants.USER_AGENT, - "Origin": "https://mapstudy.vn", - "Referer": "https://mapstudy.vn/", - } - ) - try: - while True: - lesson_input = input("Type the lesson ID/URL to you want to download: ") - try: - if lesson_input.isdigit(): - lesson_id = int(lesson_input) - else: - lesson_id = int(lesson_input.split("/")[-2]) - except (ValueError, IndexError): - logger.error("Invalid lesson ID/URL.") - continue - print(f"Fetching lesson metadata for lesson ID {lesson_id}...") - rsp = client.get( - f"{constants.API_URL}/lessons/{lesson_id}", - headers={"Authorization": f"Bearer {token}"}, - ) - if rsp.status_code != 200: - logger.error(f"Failed to fetch lesson metadata: {rsp.text}") - continue - lesson = rsp.json()["data"]["lesson"] - for i, video in enumerate(lesson["videos"]): - logger.info(f"Found video {i + 1}: {video['name']}") - video_input = input( - "Type the video number you want to download (use ',' to specify multiple videos, or 'all' for all videos): " - ) - if video_input.lower() == "all": - video_indices = range(len(lesson["videos"])) - else: - video_indices = [ - int(x) - 1 for x in video_input.split(",") if x.isdigit() - ] - for i in video_indices: - video = lesson["videos"][i] - logger.info(f"Downloading video {i + 1}: {video['name']}") - rsp = client.get( - f"{constants.API_URL}/videos/{video['id']}", - headers={"Authorization": f"Bearer {token}"}, - ) - if rsp.status_code != 200: - logger.error(f"Failed to fetch video download URL: {rsp.text}") - continue - video_info = rsp.json()["data"]["video"] - m3u8_url = video_info["m3u8"] - download_m3u8( - m3u8_url=m3u8_url, - output_path=VIDEO_PATH.joinpath( - f"{video['name']} ({lesson_id}-{video['id']}).mp4" - ), - ) - except KeyboardInterrupt: - logger.info("\nExiting...") - exit(0) - - -def main(): - global ffmpeg_bin - print(f"Explorers v{__version__}\n") - logger.info( - "This program is free software, if you bought it then you were scammed." - ) - logger.info("This program comes with absolutely no warranty, use at your own risk.") - config = read_config() - if config.get("ffmpeg") and config["ffmpeg"] != "": - ffmpeg_bin = config["ffmpeg"] - else: - ffmpeg_bin = which("ffmpeg") - if not ffmpeg_bin: - logger.error("ffmpeg not found in PATH, please install ffmpeg and add it to your PATH.") - exit(3) - if not Path(ffmpeg_bin).is_file(): - logger.error(f"ffmpeg not found at {ffmpeg_bin}, please specify the correct path to ffmpeg in 'config.json'.") - exit(4) - logger.info("Config loaded.") - loop(config) - - -main() +if "--console" in sys.argv: + console_main() +else: + webui_main() diff --git a/explorers/api.py b/explorers/api.py new file mode 100644 index 0000000..2b7e22c --- /dev/null +++ b/explorers/api.py @@ -0,0 +1,31 @@ +import requests +from explorers import constants + + +class API: + def __init__(self, token: str | None = None) -> None: + self.token = token + self._client = requests.Session() + self._client.headers.update( + { + "User-Agent": constants.USER_AGENT, + "Origin": "https://mapstudy.vn", + "Referer": "https://mapstudy.vn/", + } + ) + + def get_lesson(self, lesson_id: int) -> dict: + rsp = self._client.get( + f"{constants.API_URL}/lessons/{lesson_id}", + headers={"Authorization": f"Bearer {self.token}"}, + ) + rsp.raise_for_status() + return rsp.json()["data"]["lesson"] + + def get_video(self, video_id: int) -> dict: + rsp = self._client.get( + f"{constants.API_URL}/videos/{video_id}", + headers={"Authorization": f"Bearer {self.token}"}, + ) + rsp.raise_for_status() + return rsp.json()["data"]["video"] diff --git a/explorers/console.py b/explorers/console.py new file mode 100644 index 0000000..468abdd --- /dev/null +++ b/explorers/console.py @@ -0,0 +1,136 @@ +import json +import requests +from explorers import __version__, constants +from explorers.api import API +from explorers.utils import logger +from ffmpeg_progress_yield import FfmpegProgress +from shutil import which +from tqdm import tqdm +from pathlib import Path + + +VIDEO_PATH = Path("videos") +VIDEO_PATH.mkdir(exist_ok=True) +api = API() +ffmpeg_bin: str | None = None + + +def download_m3u8(m3u8_url: str, output_path: Path): + ff = FfmpegProgress( + [ + ffmpeg_bin, # type: ignore because FFmpeg at this point should be a string + "-headers", + f"User-Agent: {constants.USER_AGENT}", + "-headers", + "Origin: https://mapstudy.vn", + "-headers", + "Referer: https://mapstudy.vn/", + "-i", + m3u8_url, + "-c", + "copy", + str(output_path), + ], + ) + try: + with tqdm(total=100, position=1, desc=output_path.name) as pbar: + for progress in ff.run_command_with_progress(): + pbar.update(progress - pbar.n) + except RuntimeError: + print("\r", end="") + logger.error(f"Failed to download {m3u8_url} to {output_path}: {ff.stderr}") + return + print("\r", end="") + logger.info(f"Downloaded {m3u8_url} to {output_path}.") + + +def read_config() -> dict: + try: + with open("config.json", "r") as f: + return json.load(f) + except FileNotFoundError: + logger.error( + "Config file not found, please see 'config.example.json' for an example." + ) + exit(1) + except json.JSONDecodeError: + logger.error( + "Config file is not a valid JSON file, please see 'config.example.json' for a valid example." + ) + exit(2) + + +def loop(config: dict): + api.token = config["token"] + try: + while True: + lesson_input = input("Type the lesson ID/URL to you want to download: ") + try: + if lesson_input.isdigit(): + lesson_id = int(lesson_input) + else: + lesson_id = int(lesson_input.split("/")[-2]) + except (ValueError, IndexError): + logger.error("Invalid lesson ID/URL.") + continue + print(f"Fetching lesson metadata for lesson ID {lesson_id}...") + try: + lesson = api.get_lesson(lesson_id) + except (requests.HTTPError, json.JSONDecodeError) as e: + logger.error(f"Failed to fetch lesson metadata: {e}") + continue + for i, video in enumerate(lesson["videos"]): + logger.info(f"Found video {i + 1}: {video['name']}") + video_input = input( + "Type the video number you want to download (use ',' to specify multiple videos, or 'all' for all videos): " + ) + if video_input.lower() == "all": + video_indices = range(len(lesson["videos"])) + else: + video_indices = [ + int(x) - 1 for x in video_input.split(",") if x.isdigit() + ] + for i in video_indices: + video = lesson["videos"][i] + logger.info(f"Downloading video {i + 1}: {video['name']}") + try: + video_info = api.get_video(video["id"]) + except (requests.HTTPError, json.JSONDecodeError) as e: + logger.error(f"Failed to fetch lesson metadata: {e}") + continue + m3u8_url = video_info["m3u8"] + download_m3u8( + m3u8_url=m3u8_url, + output_path=VIDEO_PATH.joinpath( + f"{video['name']} ({lesson_id}-{video['id']}).mp4" + ), + ) + except KeyboardInterrupt: + logger.info("\nExiting...") + exit(0) + + +def main(): + global ffmpeg_bin + print(f"Explorers v{__version__}\n") + logger.info( + "This program is free software, if you bought it then you were scammed." + ) + logger.info("This program comes with absolutely no warranty, use at your own risk.") + config = read_config() + if config.get("ffmpeg") and config["ffmpeg"] != "": + ffmpeg_bin = config["ffmpeg"] + else: + ffmpeg_bin = which("ffmpeg") + if not ffmpeg_bin: + logger.error( + "ffmpeg not found in PATH, please install ffmpeg and add it to your PATH." + ) + exit(3) + if not Path(ffmpeg_bin).is_file(): + logger.error( + f"ffmpeg not found at {ffmpeg_bin}, please specify the correct path to ffmpeg in 'config.json'." + ) + exit(4) + logger.info("Config loaded.") + loop(config) diff --git a/explorers/webui.py b/explorers/webui.py new file mode 100644 index 0000000..ca2510d --- /dev/null +++ b/explorers/webui.py @@ -0,0 +1,78 @@ +from shutil import which +from ffmpeg_progress_yield import FfmpegProgress +from explorers import constants +from explorers.api import API +from flask import Flask, send_from_directory, request +from urllib.parse import unquote +from werkzeug.utils import secure_filename + +api = API() +app = Flask(__name__) +ffmpeg_bin = which("ffmpeg") + + +@app.route("/api/v1/lessons/") +def get_lesson(lesson_id): + return api.get_lesson(lesson_id) + + +@app.route("/api/v1/download-video") +def download_video(): + video_url = request.args.get("url") + output_path = request.args.get("output") + if not video_url or not output_path: + return "Missing 'url' or 'output' query parameter.", 400 + video_url = unquote(video_url) + output_path = unquote(output_path) + ff = FfmpegProgress( + [ + ffmpeg_bin, # type: ignore because FFmpeg at this point should be a string + "-headers", + f"User-Agent: {constants.USER_AGENT}", + "-headers", + "Origin: https://mapstudy.vn", + "-headers", + "Referer: https://mapstudy.vn/", + "-i", + video_url, + "-c", + "copy", + str(output_path), + ], + ) + def generate(): + for progress in ff.run_command_with_progress(): + yield str(progress) + print(progress) + return generate() + + +@app.route("/api/v1/videos/") +def get_video(video_id): + return api.get_video(video_id) + + +@app.route("/api/v1/token", methods=["POST"]) +def set_token(): + # set token from query parameter + token = request.data.decode("utf-8") + api.token = token + return "OK" + + +@app.route("/assets/") +def get_assets(filename): + # Ensure the filename is secure to prevent potential security issues + filename = secure_filename(filename) + # Use send_from_directory to send the static file to the client + # as_attachment=False means the file will be displayed rather than downloaded + return send_from_directory("../web/assets/", filename, as_attachment=False) + + +@app.route("/") +def root(): + return send_from_directory("../web/", "index.html") + + +def main(): + app.run(host="0.0.0.0", port=8080) diff --git a/poetry.lock b/poetry.lock index 690416a..a187977 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,16 @@ # This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +[[package]] +name = "blinker" +version = "1.7.0" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.8" +files = [ + {file = "blinker-1.7.0-py3-none-any.whl", hash = "sha256:c3f865d4d54db7abc53758a01601cf343fe55b84c1de4e3fa910e420b438d5b9"}, + {file = "blinker-1.7.0.tar.gz", hash = "sha256:e6820ff6fa4e4d1d8e2747c2283749c3f547e4fee112b98555cdcdae32996182"}, +] + [[package]] name = "certifi" version = "2024.2.2" @@ -110,6 +121,20 @@ files = [ {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, ] +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + [[package]] name = "colorama" version = "0.4.6" @@ -149,6 +174,28 @@ files = [ {file = "ffmpeg_progress_yield-0.7.8-py2.py3-none-any.whl", hash = "sha256:732ef90d78f8b5c0b78be289050589299d128d03063be55a9deab80d67319a21"}, ] +[[package]] +name = "flask" +version = "3.0.2" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.8" +files = [ + {file = "flask-3.0.2-py3-none-any.whl", hash = "sha256:3232e0e9c850d781933cf0207523d1ece087eb8d87b23777ae38456e2fbe7c6e"}, + {file = "flask-3.0.2.tar.gz", hash = "sha256:822c03f4b799204250a7ee84b1eddc40665395333973dfb9deebfe425fefcb7d"}, +] + +[package.dependencies] +blinker = ">=1.6.2" +click = ">=8.1.3" +itsdangerous = ">=2.1.2" +Jinja2 = ">=3.1.2" +Werkzeug = ">=3.0.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + [[package]] name = "idna" version = "3.6" @@ -160,6 +207,103 @@ files = [ {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, ] +[[package]] +name = "itsdangerous" +version = "2.1.2" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.7" +files = [ + {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, + {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, +] + +[[package]] +name = "jinja2" +version = "3.1.3" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, + {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markupsafe" +version = "2.1.5" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + [[package]] name = "requests" version = "2.31.0" @@ -218,7 +362,24 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "werkzeug" +version = "3.0.1" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "werkzeug-3.0.1-py3-none-any.whl", hash = "sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10"}, + {file = "werkzeug-3.0.1.tar.gz", hash = "sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "1eea5ea5c4bb1723e6c81d524bbd7d4ba8d2c7e52de45ce26d6330791e291558" +content-hash = "e25ec01d4e3b486f870cbee97713b2237c87f402b50c47588adb2fad31fdd1b0" diff --git a/pyproject.toml b/pyproject.toml index 0e1f120..8bcf885 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ requests = "^2.31.0" colorlog = "^6.8.2" ffmpeg-progress-yield = "^0.7.8" tqdm = "^4.66.2" +flask = "^3.0.2" [build-system] diff --git a/web/assets/index.js b/web/assets/index.js new file mode 100644 index 0000000..cbd8f12 --- /dev/null +++ b/web/assets/index.js @@ -0,0 +1,123 @@ +const tokenInput = document.querySelector('#token'); +const rememberTokenCheckbox = document.querySelector('#remember-token'); +const setTokenButton = document.querySelector('#set-token'); +const lessonInput = document.querySelector('#lesson-query'); +const getLessonButton = document.querySelector('#get-lesson'); +let token = localStorage.getItem('token') || ''; + +if (token !== '') { + tokenInput.value = token; + rememberTokenCheckbox.checked = true; + fetch("/api/v1/token", { + method: 'POST', + body: token, + }).then(rsp => { + if (rsp.status !== 200) { + setTokenButton.innerHTML = `:( (${rsp.status})`; + } + }); +} + +function randomString(length) { + return `a${Math.random().toString(36).slice(2, length + 2)}`; +} + +// React simulator +function getResult(id) { + const element = document.createElement("div"); + element.className = "block"; + document.body.appendChild(document.createElement("br")); + document.body.appendChild(element); + return element; +} + +setTokenButton.addEventListener('click', async () => { + token = tokenInput.value; + const rsp = await fetch("/api/v1/token", { + method: 'POST', + body: token, + }); + if (rsp.status !== 200) { + setTokenButton.innerHTML = `:( (${rsp.status})`; + return; + } + if (rememberTokenCheckbox.checked) { + localStorage.setItem('token', token); + } else { + localStorage.removeItem('token'); + } +}); + +getLessonButton.addEventListener('click', async () => { + const input = lessonInput.value; + let id = Number.parseInt(input); + if (Number.isNaN(id)) { + const url = input.split('/'); + url.pop(); + id = Number.parseInt(url.pop()); + } + const rsp = await fetch(`/api/v1/lessons/${id}`); + const result = getResult("lesson"); + const data = await rsp.json(); + const ids = {}; + // React :nerd: + result.innerHTML = "

Lesson Information

\n"; + result.innerHTML += `Name: ${decodeURIComponent(data.name)}`; + result.innerHTML += "

Videos available

"; + for (const video of data.videos) { + const uniqueId = randomString(15); + ids[uniqueId] = video; + result.innerHTML += ` + + +
`; + } + let videoDlResult = null; + const downloadSelectedButton = document.createElement("button"); + downloadSelectedButton.innerHTML = "Download Selected"; + downloadSelectedButton.addEventListener('click', async () => { + for (const [id, video] of Object.entries(ids)) { + const checkbox = document.querySelector(`#${id}`); + if (!checkbox.checked) { + continue; + } + const videoRsp = await fetch(`/api/v1/videos/${video.id}`); + if (videoRsp.status !== 200) { + console.error(`Failed to get video ${video.name}`); + continue; + } + const videoData = await videoRsp.json(); + if (videoDlResult === null) { + result.appendChild(document.createElement("br")); + result.appendChild(document.createElement("br")); + videoDlResult = document.createElement("div"); + videoDlResult.className = "block"; + result.appendChild(videoDlResult); + } + const progressId = randomString(15); + const progressStrId = randomString(15); + videoDlResult.innerHTML += `${video.name}.mp4 0%` + const rsp = await fetch(`/api/v1/download-video?${new URLSearchParams({ + url: encodeURIComponent(videoData.m3u8), + output: encodeURIComponent(`videos/${video.name}.mp4`), + })}`); + const reader = rsp.body.getReader(); + while (true) { + // wait for next encoded chunk + const { done, value } = await reader.read(); + // check if stream is done + if (done) break; + // Decodes data chunk and yields it + const progress = Math.floor(Number.parseFloat(new TextDecoder().decode(value))); + document.querySelector(`#${progressId}`).value = progress; + document.querySelector(`#${progressStrId}`).innerText = `${progress}%`; + } + if (rsp.status !== 200) { + console.error(`Failed to download ${video.name}`); + } + } + }); + result.appendChild(downloadSelectedButton); + // result.innerHTML += ""; + +}); \ No newline at end of file diff --git a/web/assets/style.css b/web/assets/style.css new file mode 100644 index 0000000..b261c7e --- /dev/null +++ b/web/assets/style.css @@ -0,0 +1,61 @@ +* { + margin: 0; + padding: 0; + font-family: "Be Vietnam Pro", sans-serif; +} + +.theme-dark { + background-color: #303030; + color: #fff; +} + +header { + font-size: 32px; + padding: 10px; + background-color: #ffffff50; +} + +.block { + width: fit-content; + border: 1px solid #fff; + border-radius: 10px; + margin: auto; + padding: 10px; + justify-content: center; + align-items: center; +} + +button { + padding: 5px; + background-color: #303030; + color: #fff; + border: 1px solid #fff; + border-radius: 10px; + transition-duration: 0.2s; + cursor: pointer; +} + +button:hover { + background-color: #444; +} + +button:active { + background-color: #222; +} + +input[type="password"], +input[type="text"] { + padding: 5px; + margin: 5px; + background-color: #303030; + color: #fff; + border: 1px solid #fff; + border-radius: 10px; +} + +input[type="checkbox"] { + margin-bottom: 10px; + background-color: #303030; + color: #fff; + border: 1px solid #fff; +} \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..c2967a5 --- /dev/null +++ b/web/index.html @@ -0,0 +1,52 @@ + + + + + + + + + +
+ Explorers +
+
+
+
+ Token + + +
+ + +
+ +
+
+
+ Lesson ID/URL + + +
+ +
+ + \ No newline at end of file