diff --git a/Makefile b/Makefile index c80fe8d..26a0af4 100644 --- a/Makefile +++ b/Makefile @@ -13,19 +13,15 @@ else endif endif -# APP_FILE = app -APP_FILE = app_gui -APP_NAME = tubic - -ENTRY_POINT = ./$(APP_NAME)/$(APP_FILE).py +PACKAGE_NAME = tubic all: build pyui: poetry run python -m PyQt6.uic.pyuic \ - -o ./$(APP_NAME)/qt_wrap/pyui/main_window.py \ - -x ./$(APP_NAME)/qt_wrap/ui/main_window.ui + -o ./$(PACKAGE_NAME)/qt_wrap/pyui/main_window.py \ + -x ./$(PACKAGE_NAME)/qt_wrap/ui/main_window.ui build: pyui poetry run pyinstaller \ @@ -34,17 +30,23 @@ build: pyui --specpath ./.pyinstaller \ --noconsole \ --onefile \ - --name $(APP_NAME) \ - --icon ../rec/ico/file-video.ico \ - --add-data ../rec/ico/*;./rec/ico \ - $(ENTRY_POINT) + --name $(PACKAGE_NAME) \ + --icon ../$(PACKAGE_NAME)/rec/ico/file-video.ico \ + --add-data ../$(PACKAGE_NAME)/rec/ico/*;./$(PACKAGE_NAME)/rec/ico \ + $(PACKAGE_NAME)/__main__.py run: - ./bin/$(APP_NAME) + ./bin/$(PACKAGE_NAME) runpy: pyui - poetry run python $(ENTRY_POINT) + poetry run python -m $(PACKAGE_NAME) clean: $(RM) $(call FixPath,./bin/*) $(RM) $(call FixPath,./.pyinstaller/*) + +test: pyui + poetry run python -m pytest -m "not slow" --verbosity=2 --showlocals --log-level=DEBUG + +test-full: pyui + poetry run python -m pytest --verbosity=2 --showlocals --log-level=DEBUG \ No newline at end of file diff --git a/README.md b/README.md index 3982e7e..686941e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,103 @@ # tubic +
+ ![Screenshot](tubic.webp) -A simple graphical interface for the [yt-dlp](https://github.com/yt-dlp/yt-dlp) tool. +
+A simple lightweight portable single-file program to download videos from YouTube. + +--- + +- [Installation](#installation) +- [Backstory](#backstory) +- [Building](#building) + - [Software requirements](#software-requirements) + - [First run](#first-run) + - [GUI editing](#gui-editing) + - [Basic `make` targets](#basic-make-targets) +- [Licensing](#licensing) + +## Installation + +(non required) + +1. Download the executable named `tubic.exe`, from the [latest release](https://github.com/sentenzo/tubic/releases/latest) +2. Run the executable + +## Backstory + +Why making another YouTube downloader, when we already have `%YOUR_FAVORITE_APP_NAME%`, which is a lot better, than this stuff will ever be? + +Well... + +~~Just for fun~~ + +~~Because I can~~ + +I've been trying out several tools of that kind, and each one of them had some flaw critical for me. Such as: + + - overweight — installing TarTube once took me something about half an hour + - console-only — so I was unable to recomend it to my elderly relatives and non-IT friends + - comercial + - server-side component — so at some point you find your tool turned into a pumpkin because the servers are down (that's exactly what happened with me once) + - overly complex GUI (again elderly relatives won't appriciate) + +I feel an unfulfilled demand for a very simple tool, which would: +- -be able to download both video and audio by a YouTube link +- -have a simple GUI (the less buttons — the better) +- -be portable (ideally: packed in a single executable) + +The features I (subjectively) consider redundant: +- sequential download from a list of links +- post-processing (`video: 1080p → 780p` or `audio: 320 kbps → 96 kbps`) +- converting formats (`mp4 → mkv` or `webm → mp3`) -The application is packed in a single executable file with no dependencies. +So that's how I've decided to make this stuff. + +It uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) (and utilizes probably less then 5% of it's full functionality). + +The UI is implemented with [PyQt6](https://pypi.org/project/PyQt6/). + +The application is packed in a single executable file with no dependencies (thanks to [pyinstaller](https://pypi.org/project/pyinstaller/)). The target OS is currently Windows ≥ 10. Though, there's no OS-dependent features or libs used, so you're free to try and build it on Linux. ---- +## Building + +### Software requirements +- [**python3.10**](https://www.python.org/downloads/) + +- [**make**](https://en.wikipedia.org/wiki/Make_(software)) tool — for build-automation. To install it on Windows, one can try [GNU make 4.3](https://community.chocolatey.org/packages/make) package from the [Chocolatey](https://github.com/chocolatey/choco) package manager. + +- [**Poetry**](https://python-poetry.org/) — to administer Python dependencies. + +- **Qt Designer** — to edit `*.ui` files (to draw windows and GUI elements in WYSIWYG editor). Versions `5.*` and `6.*` are equaly suitable. To install it there are several options: + - a [third party standalone installer](https://build-system.fman.io/qt-designer-download) + - a [PySide6 pip package](https://pypi.org/project/PySide6/) (`pip install PySide6` or `poetry add -D PySide6`, and then run `pyside6-designer`) + - the [official site](https://www.qt.io/) — ironicaly, not the best way, cause it is not distributed separately from all the over Qt tools and requires registration + +### First run +In the project directory execute: + +```bash +poetry install +``` +\- to download all the Python packages required. + +That's all. + +### GUI editing + +Qt Designer related files can be found at `tubic/qt_wrap/ui`. + +### Basic `make` targets +- `make test` — run unit tests +- `make runpy` — run app with Python +- `make build` — create an executable in a `bin` folder -## License +## Licensing - Current repository is licensed under [MIT License](https://github.com/sentenzo/tubic/blob/master/LICENSE) - The icons for this application were taken from paomedia's [small-n-flat](https://github.com/paomedia/small-n-flat) set and licensed under [CC0 1.0 Universal](https://github.com/paomedia/small-n-flat/blob/master/LICENSE) diff --git a/poetry.lock b/poetry.lock index df5e6ae..c10dc36 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6,6 +6,20 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "attrs" +version = "22.1.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] +docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] +tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] +tests_no_zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] + [[package]] name = "black" version = "22.8.0" @@ -92,6 +106,14 @@ category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "macholib" version = "1.16" @@ -119,6 +141,17 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + [[package]] name = "pathspec" version = "0.10.1" @@ -150,6 +183,26 @@ python-versions = ">=3.7" docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx (>=4)", "sphinx-autodoc-typehints (>=1.12)"] test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + [[package]] name = "pycparser" version = "2.21" @@ -194,6 +247,17 @@ category = "dev" optional = false python-versions = ">=3.7" +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "dev" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + [[package]] name = "PyQt6" version = "6.3.1" @@ -222,6 +286,26 @@ category = "main" optional = false python-versions = ">=3.7" +[[package]] +name = "pytest" +version = "7.1.3" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +tomli = ">=1.0.0" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + [[package]] name = "pywin32-ctypes" version = "0.2.0" @@ -278,13 +362,17 @@ websockets = "*" [metadata] lock-version = "1.1" python-versions = ">=3.10,<3.11" -content-hash = "9d4428022090855d4329c3b4546e3441d77886e5e63ba435164c366a3cc6f338" +content-hash = "544d43d8c48d5660cda3bb135d72a761401bd1b5dd5f0998605186d1aa08a297" [metadata.files] altgraph = [ {file = "altgraph-0.17.2-py2.py3-none-any.whl", hash = "sha256:743628f2ac6a7c26f5d9223c91ed8ecbba535f506f4b6f558885a8a56a105857"}, {file = "altgraph-0.17.2.tar.gz", hash = "sha256:ebf2269361b47d97b3b88e696439f6e4cbc607c17c51feb1754f90fb79839158"}, ] +attrs = [ + {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, + {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, +] black = [ {file = "black-22.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce957f1d6b78a8a231b18e0dd2d94a33d2ba738cd88a7fe64f53f659eea49fdd"}, {file = "black-22.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5107ea36b2b61917956d018bd25129baf9ad1125e39324a9b18248d362156a27"}, @@ -487,6 +575,10 @@ colorama = [ future = [ {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"}, ] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] macholib = [ {file = "macholib-1.16-py2.py3-none-any.whl", hash = "sha256:5a0742b587e6e57bfade1ab90651d4877185bf66fd4a176a488116de36878229"}, {file = "macholib-1.16.tar.gz", hash = "sha256:001bf281279b986a66d7821790d734e61150d52f40c080899df8fefae056e9f7"}, @@ -499,6 +591,10 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] pathspec = [ {file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"}, {file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"}, @@ -510,6 +606,14 @@ platformdirs = [ {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, ] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] pycparser = [ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, @@ -563,6 +667,10 @@ pyinstaller-hooks-contrib = [ {file = "pyinstaller-hooks-contrib-2022.10.tar.gz", hash = "sha256:e5edd4094175e78c178ef987b61be19efff6caa23d266ade456fc753e847f62e"}, {file = "pyinstaller_hooks_contrib-2022.10-py2.py3-none-any.whl", hash = "sha256:d1dd6ea059dc30e77813cc12a5efa8b1d228e7da8f5b884fe11775f946db1784"}, ] +pyparsing = [ + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, +] PyQt6 = [ {file = "PyQt6-6.3.1-cp37-abi3-macosx_10_14_universal2.whl", hash = "sha256:f7ad13b44959b72c8d40fa1856470015fab3368983dd2c1c781d4061c45d96b3"}, {file = "PyQt6-6.3.1-cp37-abi3-manylinux1_x86_64.whl", hash = "sha256:c87c909eeafc44ea911a94490d55055058f51a27ec5ca0e439a9feb943a73fd1"}, @@ -590,6 +698,10 @@ PyQt6-sip = [ {file = "PyQt6_sip-13.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:3486914137f5336cff6e10a5e9d52c1e60ff883473938b45f267f794daeacb2f"}, {file = "PyQt6_sip-13.4.0.tar.gz", hash = "sha256:6d87a3ee5872d7511b76957d68a32109352caf3b7a42a01d9ee20032b350d979"}, ] +pytest = [ + {file = "pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7"}, + {file = "pytest-7.1.3.tar.gz", hash = "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39"}, +] pywin32-ctypes = [ {file = "pywin32-ctypes-0.2.0.tar.gz", hash = "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942"}, {file = "pywin32_ctypes-0.2.0-py2.py3-none-any.whl", hash = "sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"}, diff --git a/pyproject.toml b/pyproject.toml index 11542bc..ac40607 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,13 @@ PyQt6 = "6.3.1" [tool.poetry.group.dev.dependencies] black = "^22.8.0" pyinstaller = "4.10" +pytest = "^7.1.3" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", +] \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/config.py b/tests/config.py new file mode 100644 index 0000000..60c5807 --- /dev/null +++ b/tests/config.py @@ -0,0 +1,145 @@ +class _DUMMY: + pass + + +def _video_id_to_link(video_id): + return f"https://www.youtube.com/watch?v={video_id}" + + +##################################################################################### + +YT_LINKS_POOL = _DUMMY() +YT_LINKS_POOL.SHORT_VIDEOS = [ + # youtube official channel on youtube (sic!) + # - won't ever be blocked (?) + "https://www.youtube.com/watch?v=iCkYw3cRwLo", # Rewind YouTube Style 2012 + "https://www.youtube.com/watch?v=_GuOjXYl5ew", # YouTube Rewind: The Ultimate 2016 Challenge | #YouTubeRewind + "https://www.youtube.com/watch?v=B3MDJsggfDg", # Nate Boyer's Story: From Green Beret to Starting Lineup + # pwnisher + "https://www.youtube.com/watch?v=EdCvwmebWN0", # 125 Artists Create Unique Renders From a Simple Prompt | PARALLEL DIMENSIONS + # WEHImovies + "https://www.youtube.com/watch?v=7Hk9jct2ozY", # DNA animation (2002-2014) by Drew Berry and Etsuko Uno wehi.tv #ScienceArt + # Blender Studio + "https://www.youtube.com/watch?v=YE7VzlLtp-4", # Big Buck Bunny +] + +YT_LINKS_POOL.LONG_VIDEOS = [ + # suckerpinch + "https://www.youtube.com/watch?v=DpXy041BIlA", # (42:35) 30 Weird Chess Algorithms: Elo World + # Josh Gad + "https://www.youtube.com/watch?v=l_U0S6x_kCs", # (50:00) One Zoom to Rule Them All | Reunited Apart LORD OF THE RINGS Edition +] + +YT_LINKS_POOL.VARIOUS_LINK_FORMATS = [ + "https://www.youtube.com/watch?v=QPXU59boiUA", # default + "https://www.youtube.com/watch?v=QPXU59boiUA&t=56s", # default with time + "https://www.youtube.com/watch?v=QPXU59boiUA&t=99999999s", # default with incorrect time + "https://www.youtube.com/watch?v=QPXU59boiUA&t=-9999999s", # default with incorrect time + "https://youtu.be/QPXU59boiUA", # short + "https://youtu.be/QPXU59boiUA?t=56", # short with time + "https://youtu.be/QPXU59boiUA?sxsrf=ALiiNpYirt-Q%3A166357211&ei=zXMXY6XDDMiurgTVuJ_wCA&gs_lcp=Cgdnd3Mtd2l6EAMYA&sclient=gws-wiz", # short with garbage params + "https://www.youtube.com/embed/QPXU59boiUA", # embed + # + "https://www.youtube.com/watch?v=QPXU59boiUA__anything_longer_then_11_simbols_will_be_cut_off", + "https://youtu.be/QPXU59boiUA__anything_longer_then_11_simbols_will_be_cut_off", + # "https://www.youtube.com/embed/QPXU59boiUA__and_this_one_will_break", +] + +YT_LINKS_POOL.VARIOUS_MAX_RESOLUTION = [ + "https://www.youtube.com/watch?v=CsGYh8AacgY", # 144p + "https://www.youtube.com/watch?v=fqApb5YT-GM", # 240p + "https://www.youtube.com/watch?v=fjMh6e_wxbY", # 480p + "https://www.youtube.com/watch?v=PxWgWW85sM8", # 720p + "https://www.youtube.com/watch?v=52Gg9CqhbP8", # 720p and Video resolution: 1280x534 + "https://www.youtube.com/watch?v=9vncG0IP9qU", # 1080p +] + +YT_LINKS_POOL.PARTIALLY_RESTRICTED = [ + "https://www.youtube.com/watch?v=6QFwo57WKwg", # Sign in to confirm your age + "https://www.youtube.com/watch?v=egcXvqiho4w", # Sign in to confirm your age +] + +##################################################################################### + +YT_LINKS_POOL.INCORRECT_VIDEO_IDS = _DUMMY() +YT_LINKS_POOL.INCORRECT_VIDEO_IDS.NON_STRINGS = [ + 12, + 0, + -1, + float("+inf"), + ["https://youtu.be/dQw4w9WgXcQ"], + {"https://youtu.be/dQw4w9WgXcQ"}, + ("https://youtu.be/dQw4w9WgXcQ",), + object(), + (lambda a, b, c: a + b + c), +] +YT_LINKS_POOL.INCORRECT_VIDEO_IDS.BAD_STRINGS = [ + "", # the string is empty + "QPXU59boiU", # video id is too short + "QPXU59b*iUA", # wrong format +] +YT_LINKS_POOL.INCORRECT_VIDEO_IDS.NOT_EXIST = [ + "QX5biAPU9oU", # video with such id doesn't exist +] +YT_LINKS_POOL.INCORRECT_VIDEO_IDS.DELETED = [ + "0kZ_2hxPTTo", + "-Xb-wJ4-Op8", +] + +YT_LINKS_POOL.INCORRECT_VIDEO_IDS.PRIVATE = [ + "yyDXi0nxIhk", + "Tk3hLVoI4iM", +] + +##################################################################################### + +YT_LINKS_POOL.INCORRECT_LINKS = _DUMMY() +YT_LINKS_POOL.INCORRECT_LINKS.NON_STRINGS = ( + YT_LINKS_POOL.INCORRECT_VIDEO_IDS.NON_STRINGS + + [ + "dQw4w9WgXcQ", # a correct video id is not a url + "https://www.youtube.com/embed/QPXU59boiUA__this_one_will_break", + ] +) + +some_working_link = "http://www.youtube.com/watch?v=QPXU59boiUA" +YT_LINKS_POOL.INCORRECT_LINKS.BAD_STRINGS = [ + *map(_video_id_to_link, YT_LINKS_POOL.INCORRECT_VIDEO_IDS.BAD_STRINGS), + *[ # one symbol is missing + some_working_link[:i] + some_working_link[i + 1 :] + for i in range(len(some_working_link)) + ], +] + +YT_LINKS_POOL.INCORRECT_LINKS.NOT_EXIST = [ + *map(_video_id_to_link, YT_LINKS_POOL.INCORRECT_VIDEO_IDS.NOT_EXIST), +] +YT_LINKS_POOL.INCORRECT_LINKS.DELETED = [ + *map(_video_id_to_link, YT_LINKS_POOL.INCORRECT_VIDEO_IDS.DELETED), +] +YT_LINKS_POOL.INCORRECT_LINKS.PRIVATE = [ + *map(_video_id_to_link, YT_LINKS_POOL.INCORRECT_VIDEO_IDS.PRIVATE), +] + +##################################################################################### + +YT_LINKS_POOL.FULL_LIST = list( + set( + YT_LINKS_POOL.SHORT_VIDEOS + + YT_LINKS_POOL.LONG_VIDEOS + + YT_LINKS_POOL.VARIOUS_LINK_FORMATS + + YT_LINKS_POOL.VARIOUS_MAX_RESOLUTION + ) +) + +YT_LINKS_POOL.FOR_SHORT_TESTS = [ + YT_LINKS_POOL.SHORT_VIDEOS[0], + YT_LINKS_POOL.LONG_VIDEOS[0], + YT_LINKS_POOL.VARIOUS_LINK_FORMATS[0], + YT_LINKS_POOL.VARIOUS_MAX_RESOLUTION[0], + YT_LINKS_POOL.VARIOUS_MAX_RESOLUTION[-1], +] + +YT_LINKS_POOL.FOR_SLOW_TESTS = list( + set(YT_LINKS_POOL.FULL_LIST) - set(YT_LINKS_POOL.FOR_SHORT_TESTS) +) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0ab3ead --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +import os +import tempfile + +import pytest + + +@pytest.fixture +def temp_dir() -> str: + with tempfile.TemporaryDirectory() as temp_dir_path_str: + yield temp_dir_path_str + + +@pytest.fixture +def change_test_working_dir(temp_dir: str): + cur_dir = os.getcwd() + os.chdir(temp_dir) + yield + os.chdir(cur_dir) diff --git a/tests/test_yt_dlp_wrap/test_link_wrapper.py b/tests/test_yt_dlp_wrap/test_link_wrapper.py new file mode 100644 index 0000000..5c4a752 --- /dev/null +++ b/tests/test_yt_dlp_wrap/test_link_wrapper.py @@ -0,0 +1,118 @@ +import urllib.request, urllib.error +import os + +import pytest + +import tubic.yt_dlp_wrap.link_wrapper as ydlw + +from tests.config import YT_LINKS_POOL + + +def ping(url, timeout=1): + """ + This is not a ping call per se. It presumes communication by HTTP. + """ + try: + with urllib.request.urlopen(url, timeout=timeout): + return True + except urllib.error.URLError: + return False + + +@pytest.mark.parametrize("url", ["https://www.google.com/", "https://www.youtube.com/"]) +def test_ping(url): + assert ping(url) + + +def test_init(): + for yt_link in YT_LINKS_POOL.FULL_LIST: + lw = ydlw.LinkWrapper(youtube_link=yt_link) + assert len(lw.video_id) == 11 + lw = ydlw.LinkWrapper(video_id=lw.video_id) + assert len(lw.video_id) == 11 + + with pytest.raises(ydlw.NotEnoughParametersToInitLinkWrapper): + lw = ydlw.LinkWrapper() + + for bad_video_id in ( + YT_LINKS_POOL.INCORRECT_VIDEO_IDS.NON_STRINGS + + YT_LINKS_POOL.INCORRECT_VIDEO_IDS.BAD_STRINGS + ): + with pytest.raises(ydlw.InvalidYoutubeVideoIdFormat): + lw = ydlw.LinkWrapper(video_id=bad_video_id) + + for bad_yt_link in ( + YT_LINKS_POOL.INCORRECT_LINKS.NON_STRINGS + + YT_LINKS_POOL.INCORRECT_LINKS.BAD_STRINGS + ): + with pytest.raises(ydlw.InvalidYoutubeLinkFormat): + lw = ydlw.LinkWrapper(youtube_link=bad_yt_link) + + +def _check_info(yt_link): + lw = ydlw.LinkWrapper(youtube_link=yt_link) + info = lw.info + assert isinstance(info, dict) + assert info.get("id", None) == lw.video_id + + +def test_info(): + for yt_link in YT_LINKS_POOL.FOR_SHORT_TESTS: + _check_info(yt_link) + + +@pytest.mark.slow +def test_info_slow(): + for yt_link in YT_LINKS_POOL.FOR_SLOW_TESTS: + _check_info(yt_link) + + +def _check_thumbnail(yt_link): + lw = ydlw.LinkWrapper(youtube_link=yt_link) + assert isinstance(lw.thumbnail_url, str) + assert len(lw.thumbnail_url) > 0 + + assert len(lw.get_thumbnail_bytes()) > 0 + + +def test_thumbnail(): + for yt_link in YT_LINKS_POOL.FOR_SHORT_TESTS: + _check_thumbnail(yt_link) + + +@pytest.mark.slow +def test_thumbnail_slow(): + for yt_link in YT_LINKS_POOL.FOR_SLOW_TESTS: + _check_thumbnail(yt_link) + + +def test_params_to(temp_dir): + for yt_link in YT_LINKS_POOL.FOR_SHORT_TESTS: + lw = ydlw.LinkWrapper(youtube_link=yt_link) + lw_to = lw.to(temp_dir) + assert id(lw) != id(lw_to) + assert lw_to.ydl_params["paths"]["home"] == temp_dir + assert not lw.ydl_params + + with pytest.raises(ydlw.InvalidYdlParamsFormat): + bad_dir = os.path.join(temp_dir, "this one does not exist") + lw_to = lw.to(bad_dir) + + +def test_params_audio(): + for yt_link in YT_LINKS_POOL.FOR_SHORT_TESTS: + lw = ydlw.LinkWrapper(youtube_link=yt_link) + lw_to = lw.audio() + assert id(lw) != id(lw_to) + assert lw_to.ydl_params["format"] == "bestaudio" + assert not lw.ydl_params + + +# def _check_simple_download(yt_link): +# lw = ydlw.LinkWrapper(youtube_link=yt_link) +# lw.download() + + +# def test_simple_download(change_test_working_dir): +# for yt_link in YT_LINKS_POOL.FOR_SHORT_TESTS: +# _check_simple_download(yt_link) diff --git a/tubic.webp b/tubic.webp index 4d52960..037dc25 100644 Binary files a/tubic.webp and b/tubic.webp differ diff --git a/tubic/__init__.py b/tubic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tubic/__main__.py b/tubic/__main__.py new file mode 100644 index 0000000..1134ae4 --- /dev/null +++ b/tubic/__main__.py @@ -0,0 +1,4 @@ +if __name__ == "__main__": + import tubic.app_gui as app + + app.run() diff --git a/tubic/app.py b/tubic/app.py index e88ae1d..6eae9bf 100644 --- a/tubic/app.py +++ b/tubic/app.py @@ -1,5 +1,5 @@ import sys -from yt_dlp_wrap.link_wrapper import LinkWrapper, InvalidYoutubeLinkFormat +from tubic.yt_dlp_wrap.link_wrapper import LinkWrapper, InvalidYoutubeLinkFormat if __name__ == "__main__": for link in sys.argv[1:]: diff --git a/tubic/app_gui.py b/tubic/app_gui.py index e965e7e..b57d7a0 100644 --- a/tubic/app_gui.py +++ b/tubic/app_gui.py @@ -1,10 +1,15 @@ import sys import PyQt6.QtWidgets as qtw -from qt_wrap.tubic_main_window import MainWindow +from tubic.qt_wrap.tubic_main_window import MainWindow -if __name__ == "__main__": + +def run(): app = qtw.QApplication(sys.argv) window = MainWindow() window.show() app.exec() + + +if __name__ == "__main__": + run() diff --git a/tubic/qt_wrap/__init__.py b/tubic/qt_wrap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tubic/qt_wrap/helpers.py b/tubic/qt_wrap/helpers.py index bb7cda8..b920c27 100644 --- a/tubic/qt_wrap/helpers.py +++ b/tubic/qt_wrap/helpers.py @@ -7,7 +7,7 @@ def getWindowIcon() -> qtg.QIcon: - rec_file = "rec/ico/file-video-{0}.png" + rec_file = "tubic/rec/ico/file-video-{0}.png" if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): # _MEIPASS - the env-var pyinstaller sets when the packed application launches # - it contains the path to the temp directory with the distribution diff --git a/tubic/qt_wrap/py/main_window.py b/tubic/qt_wrap/py/main_window.py index 96b903a..673a7b2 100644 --- a/tubic/qt_wrap/py/main_window.py +++ b/tubic/qt_wrap/py/main_window.py @@ -2,8 +2,8 @@ import PyQt6.QtGui as qtg import PyQt6.QtCore as qtc -from qt_wrap.pyui.main_window import Ui_MainWindow -import qt_wrap.helpers as helpers +from tubic.qt_wrap.pyui.main_window import Ui_MainWindow +import tubic.qt_wrap.helpers as helpers class MainWindowBase(qtw.QMainWindow): @@ -31,10 +31,16 @@ def __init__(self, parent=None) -> None: self.pb_download_audio: qtw.QPushButton = _f( qtw.QPushButton, "pb_download_audio" ) + self.pb_abort_download: qtw.QPushButton = _f( + qtw.QPushButton, "pb_abort_download" + ) + self.pb_abort_download.setVisible(False) + self.l_thumbnail: qtw.QLabel = _f(qtw.QLabel, "l_thumbnail") self.l_status: qtw.QLabel = _f(qtw.QLabel, "l_status") - self.thread_pool = set() + self.thread_pool: set[qtc.QThread] = set() + self._abort_one_worker = False def _set_lock_input(self, locked) -> None: self.pb_download_video.setEnabled(locked == False) @@ -57,3 +63,6 @@ def set_status_line(self, status_line: str, descriptor: str = None) -> None: descriptor = descriptor or self.status_line_descriptor text = f"[{descriptor}]: {status_line}" self.l_status.setText(text) + + def abort_one_worker(self): + self._abort_one_worker = True diff --git a/tubic/qt_wrap/pyui/main_window.py b/tubic/qt_wrap/pyui/main_window.py index 7a02e6d..88b8e37 100644 --- a/tubic/qt_wrap/pyui/main_window.py +++ b/tubic/qt_wrap/pyui/main_window.py @@ -50,6 +50,13 @@ def setupUi(self, MainWindow): self.pb_download_video.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) self.pb_download_video.setObjectName("pb_download_video") self.horizontalLayout.addWidget(self.pb_download_video) + self.pb_abort_download = QtWidgets.QPushButton(self.centralwidget) + self.pb_abort_download.setEnabled(True) + self.pb_abort_download.setCursor(QtGui.QCursor(QtCore.Qt.CursorShape.PointingHandCursor)) + self.pb_abort_download.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.NoContextMenu) + self.pb_abort_download.setToolTipDuration(1) + self.pb_abort_download.setObjectName("pb_abort_download") + self.horizontalLayout.addWidget(self.pb_abort_download) self.pb_download_audio = QtWidgets.QPushButton(self.centralwidget) self.pb_download_audio.setEnabled(False) self.pb_download_audio.setCursor(QtGui.QCursor(QtCore.Qt.CursorShape.PointingHandCursor)) @@ -85,6 +92,7 @@ def retranslateUi(self, MainWindow): MainWindow.setWindowTitle(_translate("MainWindow", "tubic")) self.l_thumbnail.setText(_translate("MainWindow", "(thumbnail)")) self.pb_download_video.setText(_translate("MainWindow", "Download video")) + self.pb_abort_download.setText(_translate("MainWindow", "Abort download")) self.pb_download_audio.setText(_translate("MainWindow", "Download audio")) self.l_status.setText(_translate("MainWindow", "[----------]: ready")) self.le_youtube_link.setPlaceholderText(_translate("MainWindow", "Copy-paste the youtube video link")) diff --git a/tubic/qt_wrap/tubic_main_window.py b/tubic/qt_wrap/tubic_main_window.py index 48e8123..2337fa9 100644 --- a/tubic/qt_wrap/tubic_main_window.py +++ b/tubic/qt_wrap/tubic_main_window.py @@ -3,9 +3,9 @@ import PyQt6.QtGui as qtg import PyQt6.QtCore as qtc -from qt_wrap.py.main_window import MainWindowBase -from yt_dlp_wrap.link_wrapper import LinkWrapper, InvalidYoutubeLinkFormat -from qt_wrap.workers import DownloadVideoWorker, DownloadThumbnailWorker +from tubic.qt_wrap.py.main_window import MainWindowBase +from tubic.qt_wrap.workers import DownloadVideoWorker, DownloadThumbnailWorker +from tubic.yt_dlp_wrap.link_wrapper import LinkWrapper, InvalidYoutubeLinkFormat class MainWindow(MainWindowBase): @@ -15,21 +15,37 @@ def __init__(self, parent=None) -> None: self.yt_link_wrap: LinkWrapper = LinkWrapper.get_dummy() self.pb_download_video.clicked.connect( - lambda: self.try_download(self.yt_link_wrap) + lambda: self.try_download(self.yt_link_wrap, hide=self.pb_download_video) ) self.pb_download_audio.clicked.connect( - lambda: self.try_download(self.yt_link_wrap.audio()) + lambda: self.try_download( + self.yt_link_wrap.audio(), hide=self.pb_download_audio + ) ) + self.pb_abort_download.clicked.connect(self.abort_one_worker) self.set_status_line("ready") - def try_download(self, yt_link_wrap_obj: LinkWrapper): + def try_download( + self, yt_link_wrap_obj: LinkWrapper, hide: qtw.QWidget | None = None + ): download_folder = qtw.QFileDialog.getExistingDirectory(self, "Select Directory") if download_folder: yt_link_wrap_obj = yt_link_wrap_obj.to(download_folder) + else: + return self.set_status_line("preparations") thread = DownloadVideoWorker.create_thread(self, yt_link_wrap_obj) + if hide and hide.isVisible(): + hide.setVisible(False) + self.pb_abort_download.setVisible(True) + thread.finished.connect( + lambda: [ + hide.setVisible(True), + self.pb_abort_download.setVisible(False), + ] # a hack to run two lines in a lambda + ) thread.start() def focusInEvent(self, event) -> None: diff --git a/tubic/qt_wrap/ui/main_window.ui b/tubic/qt_wrap/ui/main_window.ui index 5443228..f612f72 100644 --- a/tubic/qt_wrap/ui/main_window.ui +++ b/tubic/qt_wrap/ui/main_window.ui @@ -91,6 +91,25 @@ + + + + true + + + PointingHandCursor + + + Qt::NoContextMenu + + + 1 + + + Abort download + + + diff --git a/tubic/qt_wrap/workers.py b/tubic/qt_wrap/workers.py index dd4d4e2..b682157 100644 --- a/tubic/qt_wrap/workers.py +++ b/tubic/qt_wrap/workers.py @@ -1,13 +1,18 @@ # from abc import ABC, abstractmethod import PyQt6.QtCore as qtc import PyQt6.QtGui as qtg -from qt_wrap.py.main_window import MainWindowBase -from yt_dlp_wrap.link_wrapper import LinkWrapper +from tubic.qt_wrap.py.main_window import MainWindowBase +from tubic.yt_dlp_wrap.link_wrapper import LinkWrapper + + +class WorkerAborted(Exception): + pass class Worker(qtc.QObject): finished = qtc.pyqtSignal() + aborted = qtc.pyqtSignal() def __init__(self) -> None: super().__init__() @@ -15,6 +20,9 @@ def __init__(self) -> None: def run(self): pass + def abort(self): + pass + @classmethod def create_thread(cls, window: MainWindowBase, *args) -> qtc.QThread: thread = qtc.QThread() @@ -31,6 +39,7 @@ def create_thread(cls, window: MainWindowBase, *args) -> qtc.QThread: worker.finished.connect(thread.quit) worker.finished.connect(worker.deleteLater) + worker.aborted.connect(worker.abort) thread.finished.connect(lambda: window.thread_pool.remove(thread)) thread.finished.connect(window.unlock_input) thread.finished.connect(thread.deleteLater) @@ -44,9 +53,15 @@ def __init__(self, link_wrap_obj: LinkWrapper): self.link_wrap = link_wrap_obj def run(self): - self.link_wrap.download() + try: + self.link_wrap.download() + except WorkerAborted as ex: + self.aborted.emit() self.finished.emit() + def abort(self): + pass + @classmethod def create_thread(cls, window: MainWindowBase, link_wrap_obj: LinkWrapper): def progress_bar_pseudo_graphic(value: int, total: int) -> str: @@ -59,6 +74,11 @@ def progress_bar_pseudo_graphic(value: int, total: int) -> str: return full * full_count + empty * empty_count def progress_hook(msg): + nonlocal window + if window._abort_one_worker: + window._abort_one_worker = False + window.set_status_line(f"download was aborted") + raise WorkerAborted("Download was aborted") status = msg["status"] if status == "downloading": downloaded = msg["downloaded_bytes"] diff --git a/rec/ico/file-video-24.png b/tubic/rec/ico/file-video-24.png similarity index 100% rename from rec/ico/file-video-24.png rename to tubic/rec/ico/file-video-24.png diff --git a/rec/ico/file-video-48.png b/tubic/rec/ico/file-video-48.png similarity index 100% rename from rec/ico/file-video-48.png rename to tubic/rec/ico/file-video-48.png diff --git a/rec/ico/file-video-72.png b/tubic/rec/ico/file-video-72.png similarity index 100% rename from rec/ico/file-video-72.png rename to tubic/rec/ico/file-video-72.png diff --git a/rec/ico/file-video-96.png b/tubic/rec/ico/file-video-96.png similarity index 100% rename from rec/ico/file-video-96.png rename to tubic/rec/ico/file-video-96.png diff --git a/rec/ico/file-video.ico b/tubic/rec/ico/file-video.ico similarity index 100% rename from rec/ico/file-video.ico rename to tubic/rec/ico/file-video.ico diff --git a/tubic/yt_dlp_wrap/__init__.py b/tubic/yt_dlp_wrap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tubic/yt_dlp_wrap/config.py b/tubic/yt_dlp_wrap/config.py index 8af8e83..40632c5 100644 --- a/tubic/yt_dlp_wrap/config.py +++ b/tubic/yt_dlp_wrap/config.py @@ -1,10 +1,15 @@ -YOUTUBE_RE_PATTERNS = ( +YOUTUBE_RE_LINK = ( # https://www.youtube.com/watch?v=cmb6pTj67Nk - r"https\://www\.youtube\.com/watch\?v=([\w\d-]+).*", + r"https\://www\.youtube\.com/watch\?v=([\w\d-]{11})", # https://www.youtube.com/embed/cmb6pTj67Nk - r"https\://www\.youtube\.com/embed/([\w\d-]+).*", + r"https\://www\.youtube\.com/embed/([\w\d-]{11})(\?.*)?$", # https://youtu.be/cmb6pTj67Nk - r"https\://youtu\.be/([\w\d-]+).*", + r"https\://youtu\.be/([\w\d-]{11})", +) + +YOUTUBE_RE_VIDEO_ID = ( + # cmb6pTj67Nk + r"([\w\d-]{11})", ) YOUTUBE_LINK_TEMPLATE = "https://youtu.be/{video_id}" diff --git a/tubic/yt_dlp_wrap/link_wrapper.py b/tubic/yt_dlp_wrap/link_wrapper.py index 3771a21..f160c97 100644 --- a/tubic/yt_dlp_wrap/link_wrapper.py +++ b/tubic/yt_dlp_wrap/link_wrapper.py @@ -1,43 +1,63 @@ from __future__ import annotations +from genericpath import isdir import re from typing import Any, Callable import urllib.request as ur +import os from yt_dlp import YoutubeDL -from yt_dlp_wrap.config import * + +from tubic.yt_dlp_wrap.config import * class InvalidYoutubeLinkFormat(ValueError): pass +class InvalidYoutubeVideoIdFormat(ValueError): + pass + + +class InvalidYdlParamsFormat(ValueError): + pass + + class NotEnoughParametersToInitLinkWrapper(TypeError): pass class BaseLinkWrapper: @staticmethod - def _retrieve_video_id(youtube_link: str) -> str: - video_id = None - for re_pattern in YOUTUBE_RE_PATTERNS: - match = re.match(re_pattern, youtube_link) + def _try_fetch_any_re( + re_patterns_collection: list[str], text: str, exception: Exception | None = None + ) -> str | None: + if not isinstance(text, str): + if exception: + raise exception + return None + for re_pattern in re_patterns_collection: + match = re.match(re_pattern, text) if match and match.groups(): - video_id = match.groups()[0] - break - else: - raise InvalidYoutubeLinkFormat(youtube_link) - return video_id + return match.groups()[0] + if exception: + raise exception + return None @classmethod def get_dummy(cls): - return cls(video_id=YOUTUBE_DUMMY_LINK) + return cls(youtube_link=YOUTUBE_DUMMY_LINK) def __init__(self, *, youtube_link=None, video_id=None, ydl_params=None) -> None: + self.video_id = None self.ydl_params = ydl_params or {} - if video_id: - self.video_id = video_id - elif youtube_link: - self.video_id = LinkWrapper._retrieve_video_id(youtube_link) + if video_id != None: + self.video_id = BaseLinkWrapper._try_fetch_any_re( + YOUTUBE_RE_VIDEO_ID, video_id, InvalidYoutubeVideoIdFormat(video_id) + ) + elif youtube_link != None: + self.video_id = BaseLinkWrapper._try_fetch_any_re( + YOUTUBE_RE_LINK, youtube_link, InvalidYoutubeLinkFormat(youtube_link) + ) else: raise NotEnoughParametersToInitLinkWrapper( "Both youtube_link and video_id fields are empty" @@ -48,6 +68,9 @@ def download(self): with YoutubeDL(params=self.ydl_params) as ydl: ydl.download(self.video_id) + def __repr__(self) -> str: + return f"{type(self).__name__}(video_id={self.video_id.__repr__()})" + class LinkWrapper(BaseLinkWrapper): def __init__(self, *, youtube_link=None, video_id=None, ydl_params=None) -> None: @@ -75,7 +98,7 @@ def info(self) -> Any: return self.cache["info"] @property - def thumbnail_url(self): + def thumbnail_url(self) -> str: if not "thumbnail_url" in self.cache: info = self.info thumbnails = [ @@ -117,7 +140,12 @@ def to(self, download_dir: str) -> LinkWrapper: Allows doing: link.to("/home/user/yt_downloads/").download() """ - return self._merge_ydl_params({"paths": {"home": download_dir}}) + if os.path.isdir(download_dir): + return self._merge_ydl_params({"paths": {"home": download_dir}}) + else: + raise InvalidYdlParamsFormat( + f'Trying to set ydl_params["paths"]["home"]: "{download_dir}" - is not a directory' + ) def progress_hook( self,