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
+
+

-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,