diff --git a/.gitignore b/.gitignore index 36b13f1..42cce65 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,9 @@ cover/ # Django stuff: *.log +app.log +logs/* +!logs/.gitkeep local_settings.py db.sqlite3 db.sqlite3-journal @@ -173,4 +176,4 @@ cython_debug/ # PyPI configuration file .pypirc - +cookies.txt diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2c22c7c --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +# Makefile for uv + ruff + mypy + deptry +UV := uv +RUFF := ruff +MYPY := mypy +DEPTRY := deptry + +.PHONY: full-lint clean-branches + +full-lint: + @echo "Running ruff check and fix..." + $(UV) run $(RUFF) check . --fix + @echo "Running mypy..." + $(UV) run $(MYPY) . --config-file ./pyproject.toml --no-incremental + @echo "Running deptry..." + $(UV) run $(DEPTRY) . --config ./pyproject.toml + @echo "All checks completed!" + + +clean-branches: +ifeq ($(OS),Windows_NT) + @git fetch --prune + @cmd /C "for /f \"tokens=1\" %%i in ('git branch -vv ^| findstr ": gone]"') do git branch -D %%i" || ver > nul +else + git fetch --prune + git branch -vv | grep ': gone]' | awk '{print $$1}' | xargs -r git branch -D +endif \ No newline at end of file diff --git a/README.md b/README.md index 65d0c9c..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,2 +0,0 @@ -# yt-shorts-downloader - diff --git a/main.py b/main.py new file mode 100644 index 0000000..7441573 --- /dev/null +++ b/main.py @@ -0,0 +1,4 @@ +from yt_shorts_downloader.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..af0379f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "yt-shorts-downloader" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [] diff --git a/src/yt_shorts_downloader/__init__.py b/src/yt_shorts_downloader/__init__.py new file mode 100644 index 0000000..53ccd1c --- /dev/null +++ b/src/yt_shorts_downloader/__init__.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from .api import download, download_short, validate_session_file +from .exceptions import ( + InvalidSessionError, + InvalidUrlError, + JsRuntimeUnavailableError, + VideoDownloadError, + YtShortsDownloaderError, +) +from .models import SessionValidation + +__all__ = [ + "InvalidSessionError", + "InvalidUrlError", + "JsRuntimeUnavailableError", + "SessionValidation", + "VideoDownloadError", + "YtShortsDownloaderError", + "download", + "download_short", + "validate_session_file", +] diff --git a/src/yt_shorts_downloader/__init__.pyi b/src/yt_shorts_downloader/__init__.pyi new file mode 100644 index 0000000..40b3dc9 --- /dev/null +++ b/src/yt_shorts_downloader/__init__.pyi @@ -0,0 +1,25 @@ +from pathlib import Path + +from .api import PathInput as PathInput +from .exceptions import InvalidSessionError as InvalidSessionError +from .exceptions import InvalidUrlError as InvalidUrlError +from .exceptions import JsRuntimeUnavailableError as JsRuntimeUnavailableError +from .exceptions import VideoDownloadError as VideoDownloadError +from .exceptions import YtShortsDownloaderError as YtShortsDownloaderError +from .models import SessionValidation as SessionValidation + +def download( + url: str, + session_path: PathInput, + *, + output_dir: PathInput | None = None, +) -> Path: ... +def download_short( + url: str, + session_path: PathInput, + *, + output_dir: PathInput | None = None, +) -> Path: ... +def validate_session_file(path: Path) -> SessionValidation: ... + +__all__: list[str] diff --git a/src/yt_shorts_downloader/__main__.py b/src/yt_shorts_downloader/__main__.py new file mode 100644 index 0000000..855590e --- /dev/null +++ b/src/yt_shorts_downloader/__main__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/yt_shorts_downloader/api.py b/src/yt_shorts_downloader/api.py new file mode 100644 index 0000000..8f033b6 --- /dev/null +++ b/src/yt_shorts_downloader/api.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from os import PathLike +from pathlib import Path +from urllib.parse import urlparse + +from .downloader import download_video +from .exceptions import InvalidSessionError, InvalidUrlError +from .session import validate_session_file + +PathInput = str | PathLike[str] + +_YOUTUBE_HOST_SUFFIXES = ("youtube.com", "youtu.be") + +__all__ = ["download", "download_short", "validate_session_file"] + + +def download( + url: str, + session_path: PathInput, + *, + output_dir: PathInput | None = None, +) -> Path: + _validate_youtube_url(url) + + normalized_session_path = Path(session_path).expanduser().resolve() + session_validation = validate_session_file(normalized_session_path) + if not session_validation.is_usable: + raise InvalidSessionError(session_validation.message) + + normalized_output_dir = _normalize_output_dir(output_dir) + return download_video( + url=url, + output_dir=normalized_output_dir, + session_path=normalized_session_path, + ) + + +def download_short( + url: str, + session_path: PathInput, + *, + output_dir: PathInput | None = None, +) -> Path: + return download(url=url, session_path=session_path, output_dir=output_dir) + + +def _validate_youtube_url(url: str) -> None: + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"}: + raise InvalidUrlError("URL должна начинаться с http:// или https://") + + host = parsed.netloc.lower() + if not host: + raise InvalidUrlError("Не удалось определить домен URL") + + if not any( + host == suffix or host.endswith(f".{suffix}") + for suffix in _YOUTUBE_HOST_SUFFIXES + ): + raise InvalidUrlError("Поддерживаются только ссылки YouTube") + + +def _normalize_output_dir(output_dir: PathInput | None) -> Path: + if output_dir is None: + normalized_output_dir = Path.cwd() + else: + normalized_output_dir = Path(output_dir).expanduser().resolve() + + normalized_output_dir.mkdir(parents=True, exist_ok=True) + return normalized_output_dir diff --git a/src/yt_shorts_downloader/api.pyi b/src/yt_shorts_downloader/api.pyi new file mode 100644 index 0000000..4c632a6 --- /dev/null +++ b/src/yt_shorts_downloader/api.pyi @@ -0,0 +1,22 @@ +from os import PathLike +from pathlib import Path + +from .models import SessionValidation + +type PathInput = str | PathLike[str] + +def download( + url: str, + session_path: PathInput, + *, + output_dir: PathInput | None = None, +) -> Path: ... +def download_short( + url: str, + session_path: PathInput, + *, + output_dir: PathInput | None = None, +) -> Path: ... +def validate_session_file(path: Path) -> SessionValidation: ... + +__all__: list[str] diff --git a/src/yt_shorts_downloader/cli.py b/src/yt_shorts_downloader/cli.py new file mode 100644 index 0000000..ee6d1a6 --- /dev/null +++ b/src/yt_shorts_downloader/cli.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from pathlib import Path + +from .api import download +from .exceptions import YtShortsDownloaderError + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Скачать YouTube Shorts через session file в Netscape cookie format." + ), + ) + parser.add_argument("url", help="Ссылка на YouTube Shorts") + parser.add_argument( + "session_path", + type=Path, + help="Путь до session file в Netscape cookie format", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("."), + help="Папка, куда сохранить скачанный файл", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + try: + downloaded_file = download( + url=args.url, + session_path=args.session_path, + output_dir=args.output_dir, + ) + except YtShortsDownloaderError as exc: + parser.exit(status=1, message=f"[error] {exc}\n") + + print(downloaded_file) + return 0 diff --git a/src/yt_shorts_downloader/downloader.py b/src/yt_shorts_downloader/downloader.py new file mode 100644 index 0000000..22fe8c2 --- /dev/null +++ b/src/yt_shorts_downloader/downloader.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from pathlib import Path +from shutil import which +from typing import cast + +from yt_dlp import YoutubeDL +from yt_dlp.utils import DownloadError as YtDlpDownloadError +from yt_dlp.utils import UnsupportedError + +from .exceptions import InvalidUrlError, JsRuntimeUnavailableError, VideoDownloadError +from .runtime import JsRuntimeOptions, find_supported_js_runtimes + +type Metadata = dict[str, object] +type YtDlpOptions = dict[str, object] + +_DEFAULT_OUTTMPL = "%(title).200B [%(id)s].%(ext)s" + + +def download_video(url: str, output_dir: Path, session_path: Path) -> Path: + options = _build_yt_dlp_options(output_dir=output_dir, session_path=session_path) + + try: + with YoutubeDL(options) as youtube_downloader: + extracted_info = cast( + object, + youtube_downloader.extract_info(url, download=True), + ) + except UnsupportedError as exc: + raise InvalidUrlError(f"yt-dlp не поддерживает эту ссылку: {exc}") from exc + except YtDlpDownloadError as exc: + raise VideoDownloadError(f"Не удалось скачать видео: {exc}") from exc + + if not isinstance(extracted_info, dict): + raise VideoDownloadError("yt-dlp вернул неожиданный формат метаданных") + + downloaded_file = _locate_downloaded_file( + info=cast(Metadata, extracted_info), + output_dir=output_dir, + ) + if downloaded_file is None: + raise VideoDownloadError( + "Скачивание завершено, но итоговый путь к файлу определить не удалось" + ) + + return downloaded_file + + +def _build_yt_dlp_options(output_dir: Path, session_path: Path) -> YtDlpOptions: + js_runtimes = _get_supported_js_runtimes() + ffmpeg_available = which("ffmpeg") is not None + format_selector = ( + "bv*[ext=mp4]+ba[ext=m4a]/bv*+ba/b[ext=mp4]/b" + if ffmpeg_available + else "b[ext=mp4]/best" + ) + + return { + "cookiefile": str(session_path), + "format": format_selector, + "js_runtimes": js_runtimes, + "merge_output_format": "mp4", + "no_warnings": True, + "noplaylist": True, + "noprogress": True, + "outtmpl": str(output_dir / _DEFAULT_OUTTMPL), + "overwrites": False, + "quiet": True, + } + + +def _get_supported_js_runtimes() -> JsRuntimeOptions: + js_runtimes = find_supported_js_runtimes() + if js_runtimes is None: + raise JsRuntimeUnavailableError( + "Не найден поддерживаемый JavaScript runtime. " + "Установите deno или Node.js 22+." + ) + return js_runtimes + + +def _unwrap_info(info: Metadata) -> Metadata: + if info.get("_type") != "playlist": + return info + + entries = info.get("entries") + if not isinstance(entries, list): + return info + + for entry in entries: + if isinstance(entry, dict): + return cast(Metadata, entry) + return info + + +def _locate_downloaded_file(info: Metadata, output_dir: Path) -> Path | None: + normalized_info = _unwrap_info(info) + + requested_downloads = normalized_info.get("requested_downloads") + if isinstance(requested_downloads, list): + for item in requested_downloads: + if not isinstance(item, dict): + continue + filepath = item.get("filepath") + if isinstance(filepath, str): + candidate = Path(filepath) + if candidate.exists(): + return candidate + + for key in ("filepath", "_filename"): + filepath = normalized_info.get(key) + if isinstance(filepath, str): + candidate = Path(filepath) + if candidate.exists(): + return candidate + + video_id = normalized_info.get("id") + if not isinstance(video_id, str): + return None + + matches = sorted( + ( + path + for path in output_dir.iterdir() + if path.is_file() and f"[{video_id}]" in path.name + ), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + if matches: + return matches[0] + + return None diff --git a/src/yt_shorts_downloader/exceptions.py b/src/yt_shorts_downloader/exceptions.py new file mode 100644 index 0000000..5c2e069 --- /dev/null +++ b/src/yt_shorts_downloader/exceptions.py @@ -0,0 +1,21 @@ +from __future__ import annotations + + +class YtShortsDownloaderError(Exception): + pass + + +class InvalidUrlError(YtShortsDownloaderError): + pass + + +class InvalidSessionError(YtShortsDownloaderError): + pass + + +class JsRuntimeUnavailableError(YtShortsDownloaderError): + pass + + +class VideoDownloadError(YtShortsDownloaderError): + pass diff --git a/src/yt_shorts_downloader/exceptions.pyi b/src/yt_shorts_downloader/exceptions.pyi new file mode 100644 index 0000000..1aadf6f --- /dev/null +++ b/src/yt_shorts_downloader/exceptions.pyi @@ -0,0 +1,5 @@ +class YtShortsDownloaderError(Exception): ... +class InvalidUrlError(YtShortsDownloaderError): ... +class InvalidSessionError(YtShortsDownloaderError): ... +class JsRuntimeUnavailableError(YtShortsDownloaderError): ... +class VideoDownloadError(YtShortsDownloaderError): ... diff --git a/src/yt_shorts_downloader/models.py b/src/yt_shorts_downloader/models.py new file mode 100644 index 0000000..70dca75 --- /dev/null +++ b/src/yt_shorts_downloader/models.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class SessionCookie: + domain: str + include_subdomains: bool + path: str + secure: bool + expires: int + name: str + value: str + + +@dataclass(frozen=True, slots=True) +class SessionValidation: + exists: bool + structurally_valid: bool + fresh: bool + is_usable: bool + message: str diff --git a/src/yt_shorts_downloader/models.pyi b/src/yt_shorts_downloader/models.pyi new file mode 100644 index 0000000..515c36f --- /dev/null +++ b/src/yt_shorts_downloader/models.pyi @@ -0,0 +1,19 @@ +from dataclasses import dataclass + +@dataclass(frozen=True, slots=True) +class SessionCookie: + domain: str + include_subdomains: bool + path: str + secure: bool + expires: int + name: str + value: str + +@dataclass(frozen=True, slots=True) +class SessionValidation: + exists: bool + structurally_valid: bool + fresh: bool + is_usable: bool + message: str diff --git a/src/yt_shorts_downloader/runtime.py b/src/yt_shorts_downloader/runtime.py new file mode 100644 index 0000000..3494d9b --- /dev/null +++ b/src/yt_shorts_downloader/runtime.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path +from shutil import which + +type JsRuntimeOptions = dict[str, dict[str, str]] + +_MIN_NODE_VERSION = (22, 0, 0) + + +def find_supported_js_runtimes() -> JsRuntimeOptions | None: + deno_path = which("deno") + if deno_path is not None: + return {"deno": {"path": deno_path}} + + node_candidates: dict[str, tuple[int, int, int]] = {} + node_on_path = which("node") + if node_on_path is not None: + version = _probe_executable_version(node_on_path) + if version is not None: + node_candidates[str(Path(node_on_path).resolve())] = version + + nvm_versions_dir = Path.home() / ".nvm" / "versions" / "node" + if nvm_versions_dir.exists(): + for candidate in nvm_versions_dir.glob("v*/bin/node"): + version = _probe_executable_version(str(candidate)) + if version is not None: + node_candidates[str(candidate.resolve())] = version + + supported_nodes = { + path: version + for path, version in node_candidates.items() + if version >= _MIN_NODE_VERSION + } + if not supported_nodes: + return None + + best_node_path = max(supported_nodes, key=lambda path: supported_nodes[path]) + return {"node": {"path": best_node_path}} + + +def _probe_executable_version(executable: str) -> tuple[int, int, int] | None: + try: + result = subprocess.run( + [executable, "--version"], + capture_output=True, + check=False, + text=True, + timeout=5, + ) + except (FileNotFoundError, OSError, subprocess.TimeoutExpired): + return None + + output = (result.stdout or result.stderr).strip() + if not output: + return None + + return _parse_semver(output.splitlines()[0]) + + +def _parse_semver(value: str) -> tuple[int, int, int] | None: + cleaned = value.strip().lstrip("vV") + parts = cleaned.split(".") + numbers: list[int] = [] + + for part in parts[:3]: + digits = "" + for character in part: + if not character.isdigit(): + break + digits += character + if not digits: + return None + numbers.append(int(digits)) + + while len(numbers) < 3: + numbers.append(0) + + return (numbers[0], numbers[1], numbers[2]) diff --git a/src/yt_shorts_downloader/session.py b/src/yt_shorts_downloader/session.py new file mode 100644 index 0000000..23a27ee --- /dev/null +++ b/src/yt_shorts_downloader/session.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import time +from pathlib import Path + +from .models import SessionCookie, SessionValidation + +_SESSION_DOMAIN_SUFFIXES = ("youtube.com", "google.com") +_AUTH_COOKIE_NAMES = { + "SID", + "HSID", + "SSID", + "APISID", + "SAPISID", + "__Secure-1PSID", + "__Secure-3PSID", + "LOGIN_INFO", +} +_AUTH_COOKIE_ANCHORS = { + "SID", + "SAPISID", + "__Secure-1PSID", + "__Secure-3PSID", + "LOGIN_INFO", +} + + +def validate_session_file(path: Path) -> SessionValidation: + normalized_path = path.expanduser().resolve() + if not normalized_path.exists(): + return SessionValidation( + exists=False, + structurally_valid=False, + fresh=False, + is_usable=False, + message=f"Файл сессии не найден: {normalized_path}", + ) + + try: + cookies = _parse_session_file(normalized_path) + except ValueError as exc: + return SessionValidation( + exists=True, + structurally_valid=False, + fresh=False, + is_usable=False, + message=f"Файл сессии не прошёл проверку формата: {exc}", + ) + + if not cookies: + return SessionValidation( + exists=True, + structurally_valid=False, + fresh=False, + is_usable=False, + message="Файл сессии пустой", + ) + + relevant_cookies = [ + cookie + for cookie in cookies + if _matches_domain(cookie.domain, _SESSION_DOMAIN_SUFFIXES) + ] + if not relevant_cookies: + return SessionValidation( + exists=True, + structurally_valid=False, + fresh=False, + is_usable=False, + message="В файле сессии нет записей для YouTube или Google", + ) + + now = int(time.time()) + fresh_auth_names: set[str] = set() + soonest_expiry: int | None = None + + for cookie in relevant_cookies: + if cookie.name not in _AUTH_COOKIE_NAMES: + continue + if cookie.expires != 0 and cookie.expires <= now: + continue + + fresh_auth_names.add(cookie.name) + if cookie.expires > 0 and ( + soonest_expiry is None or cookie.expires < soonest_expiry + ): + soonest_expiry = cookie.expires + + anchor_count = len(fresh_auth_names & _AUTH_COOKIE_ANCHORS) + fresh = len(fresh_auth_names) >= 3 and anchor_count >= 1 + if not fresh: + return SessionValidation( + exists=True, + structurally_valid=True, + fresh=False, + is_usable=False, + message=( + "Файл сессии в корректном формате, но в нём нет достаточного набора " + "актуальных YouTube auth-cookie" + ), + ) + + message = ( + "Файл сессии валиден и выглядит актуальным для YouTube: " + f"найдено {len(fresh_auth_names)} свежих auth-cookie" + ) + if soonest_expiry is not None: + hours_left = max((soonest_expiry - now) // 3600, 0) + message += f", ближайшее истечение примерно через {hours_left} ч" + + return SessionValidation( + exists=True, + structurally_valid=True, + fresh=True, + is_usable=True, + message=message, + ) + + +def _parse_session_file(path: Path) -> list[SessionCookie]: + cookies: list[SessionCookie] = [] + + with path.open("r", encoding="utf-8") as handle: + for line_number, raw_line in enumerate(handle, start=1): + stripped = raw_line.strip() + if not stripped: + continue + + is_httponly_cookie = stripped.startswith("#HttpOnly_") + if stripped.startswith("#") and not is_httponly_cookie: + continue + + if is_httponly_cookie: + stripped = stripped.removeprefix("#HttpOnly_") + + parts = stripped.split("\t") + if len(parts) != 7: + raise ValueError( + "Строка " + f"{line_number}: ожидалось 7 колонок Netscape cookie file, " + f"получено {len(parts)}" + ) + + ( + domain, + include_subdomains, + cookie_path, + secure, + expires, + name, + value, + ) = parts + + try: + expires_at = int(expires) + except ValueError as exc: + raise ValueError( + f"Строка {line_number}: expires должен быть UNIX timestamp" + ) from exc + + cookies.append( + SessionCookie( + domain=domain, + include_subdomains=include_subdomains.upper() == "TRUE", + path=cookie_path, + secure=secure.upper() == "TRUE", + expires=expires_at, + name=name, + value=value, + ) + ) + + return cookies + + +def _matches_domain(domain: str, suffixes: tuple[str, ...]) -> bool: + normalized_domain = _normalize_domain(domain) + return any( + normalized_domain == suffix or normalized_domain.endswith(f".{suffix}") + for suffix in suffixes + ) + + +def _normalize_domain(domain: str) -> str: + return domain.removeprefix("#HttpOnly_").lstrip(".").lower() diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..786efe4 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from yt_shorts_downloader import api +from yt_shorts_downloader.exceptions import InvalidSessionError, InvalidUrlError +from yt_shorts_downloader.models import SessionValidation + + +def test_download_returns_path_from_downloader( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + expected_path = tmp_path / "video.mp4" + + monkeypatch.setattr( + api, + "validate_session_file", + lambda path: SessionValidation(True, True, True, True, str(path)), + ) + monkeypatch.setattr( + api, + "download_video", + lambda *, url, output_dir, session_path: expected_path, + ) + + downloaded_path = api.download( + "https://youtube.com/shorts/example", + session_path="cookies.txt", + output_dir=tmp_path, + ) + + assert downloaded_path == expected_path + + +def test_download_rejects_invalid_url() -> None: + with pytest.raises(InvalidUrlError): + api.download("https://example.com/watch?v=1", session_path="cookies.txt") + + +def test_download_rejects_invalid_session(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + api, + "validate_session_file", + lambda path: SessionValidation(False, False, False, False, "bad session"), + ) + + with pytest.raises(InvalidSessionError, match="bad session"): + api.download("https://youtube.com/shorts/example", session_path="cookies.txt") diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..376c727 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from yt_shorts_downloader import cli +from yt_shorts_downloader.exceptions import InvalidSessionError + + +def test_cli_prints_downloaded_path( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + expected_path = tmp_path / "video.mp4" + monkeypatch.setattr(cli, "download", lambda **kwargs: expected_path) + + exit_code = cli.main( + [ + "https://youtube.com/shorts/example", + "cookies.txt", + "--output-dir", + str(tmp_path), + ] + ) + + captured = capsys.readouterr() + + assert exit_code == 0 + assert captured.out.strip() == str(expected_path) + + +def test_cli_exits_with_error(monkeypatch: pytest.MonkeyPatch) -> None: + def raise_error(**kwargs: object) -> Path: + raise InvalidSessionError("session error") + + monkeypatch.setattr(cli, "download", raise_error) + + with pytest.raises(SystemExit) as exc_info: + cli.main(["https://youtube.com/shorts/example", "cookies.txt"]) + + assert exc_info.value.code == 1 diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..5282d65 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from yt_shorts_downloader import runtime + + +def test_find_supported_js_runtimes_prefers_deno( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + runtime, + "which", + lambda executable: "/usr/bin/deno" if executable == "deno" else None, + ) + + runtimes = runtime.find_supported_js_runtimes() + + assert runtimes == {"deno": {"path": "/usr/bin/deno"}} + + +def test_find_supported_js_runtimes_uses_best_supported_node( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + first_node = tmp_path / ".nvm" / "versions" / "node" / "v22.1.0" / "bin" / "node" + second_node = tmp_path / ".nvm" / "versions" / "node" / "v22.9.0" / "bin" / "node" + second_node.parent.mkdir(parents=True) + first_node.parent.mkdir(parents=True) + first_node.write_text("", encoding="utf-8") + second_node.write_text("", encoding="utf-8") + + monkeypatch.setattr(runtime, "which", lambda executable: None) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setattr( + runtime, + "_probe_executable_version", + lambda executable: (22, 9, 0) + if executable.endswith("v22.9.0/bin/node") + else (22, 1, 0), + ) + + runtimes = runtime.find_supported_js_runtimes() + + assert runtimes == {"node": {"path": str(second_node.resolve())}} + + +def test_parse_semver_handles_prefixed_versions() -> None: + assert runtime._parse_semver("v22.12.1") == (22, 12, 1) + assert runtime._parse_semver("node") is None diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..2a20ec3 --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pathlib import Path + +from yt_shorts_downloader.session import validate_session_file + + +def test_validate_session_file_accepts_valid_youtube_session(tmp_path: Path) -> None: + session_file = tmp_path / "cookies.txt" + session_file.write_text( + "\n".join( + [ + "# Netscape HTTP Cookie File", + ".youtube.com\tTRUE\t/\tFALSE\t9999999999\tSID\tvalue1", + ".youtube.com\tTRUE\t/\tTRUE\t9999999999\tSAPISID\tvalue2", + ".youtube.com\tTRUE\t/\tTRUE\t9999999999\tLOGIN_INFO\tvalue3", + ] + ), + encoding="utf-8", + ) + + validation = validate_session_file(session_file) + + assert validation.exists is True + assert validation.structurally_valid is True + assert validation.fresh is True + assert validation.is_usable is True + + +def test_validate_session_file_rejects_invalid_format(tmp_path: Path) -> None: + session_file = tmp_path / "cookies.txt" + session_file.write_text("broken\trow", encoding="utf-8") + + validation = validate_session_file(session_file) + + assert validation.exists is True + assert validation.structurally_valid is False + assert validation.is_usable is False