Refactor code style and improve documentation
- Standardized string quotes to single quotes across all files. - Added docstrings to several functions and classes for better clarity. - Updated mypy configuration in pyproject.toml for enhanced type checking. - Ignored specific linting rules for test files in ruff configuration. - Improved error messages in exception handling for better user feedback. - Cleaned up code formatting and structure for consistency.
This commit is contained in:
@@ -3,9 +3,9 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
|
sys.path.insert(0, str(Path(__file__).resolve().parent / 'src'))
|
||||||
|
|
||||||
from yt_shorts_downloader.cli import main
|
from yt_shorts_downloader.cli import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == '__main__':
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|||||||
+9
-1
@@ -47,6 +47,9 @@ indent-style = "space"
|
|||||||
select = ["E", "F", "I", "UP", "B", "Q", "ERA", "D", "SIM", "ARG", "RUF", "C4", "RET", "ASYNC", "PERF", "PL"]
|
select = ["E", "F", "I", "UP", "B", "Q", "ERA", "D", "SIM", "ARG", "RUF", "C4", "RET", "ASYNC", "PERF", "PL"]
|
||||||
ignore = ["D100", "RUF002", "D107", "RUF001", "D104", "D203", "D213", "ARG001"]
|
ignore = ["D100", "RUF002", "D107", "RUF001", "D104", "D203", "D213", "ARG001"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/**/*.py" = ["D"]
|
||||||
|
|
||||||
[tool.ruff.lint.flake8-quotes]
|
[tool.ruff.lint.flake8-quotes]
|
||||||
inline-quotes = "single"
|
inline-quotes = "single"
|
||||||
multiline-quotes = "double"
|
multiline-quotes = "double"
|
||||||
@@ -55,11 +58,16 @@ avoid-escape = true
|
|||||||
|
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.13"
|
python_version = "3.12"
|
||||||
strict = true
|
strict = true
|
||||||
warn_unused_ignores = true
|
warn_unused_ignores = true
|
||||||
warn_redundant_casts = true
|
warn_redundant_casts = true
|
||||||
warn_unreachable = true
|
warn_unreachable = true
|
||||||
|
show_error_codes = true
|
||||||
|
pretty = true
|
||||||
|
explicit_package_bases = true
|
||||||
|
files = ["src", "tests", "main.py"]
|
||||||
|
mypy_path = "$MYPY_CONFIG_FILE_DIR/src:$MYPY_CONFIG_FILE_DIR/stubs"
|
||||||
|
|
||||||
[tool.deptry.package_module_name_map]
|
[tool.deptry.package_module_name_map]
|
||||||
"yt-dlp" = "yt_dlp"
|
"yt-dlp" = "yt_dlp"
|
||||||
|
|||||||
@@ -11,16 +11,16 @@ from .exceptions import (
|
|||||||
from .models import DownloadedVideo, SessionValidation
|
from .models import DownloadedVideo, SessionValidation
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DownloadedVideo",
|
'DownloadedVideo',
|
||||||
"InvalidSessionError",
|
'InvalidSessionError',
|
||||||
"InvalidUrlError",
|
'InvalidUrlError',
|
||||||
"JsRuntimeUnavailableError",
|
'JsRuntimeUnavailableError',
|
||||||
"PathInput",
|
'PathInput',
|
||||||
"SessionValidation",
|
'SessionValidation',
|
||||||
"VideoDownloadError",
|
'VideoDownloadError',
|
||||||
"YtShortsDownloaderError",
|
'YtShortsDownloaderError',
|
||||||
"download",
|
'download',
|
||||||
"download_short",
|
'download_short',
|
||||||
"download_to_path",
|
'download_to_path',
|
||||||
"validate_session_file",
|
'validate_session_file',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from .cli import main
|
from .cli import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == '__main__':
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ from .services.session import validate_session_file
|
|||||||
PathInput = str | PathLike[str]
|
PathInput = str | PathLike[str]
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PathInput",
|
'PathInput',
|
||||||
"download",
|
'download',
|
||||||
"download_short",
|
'download_short',
|
||||||
"download_to_path",
|
'download_to_path',
|
||||||
"validate_session_file",
|
'validate_session_file',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -25,10 +25,11 @@ def download(
|
|||||||
url: str,
|
url: str,
|
||||||
session_path: PathInput,
|
session_path: PathInput,
|
||||||
) -> DownloadedVideo:
|
) -> DownloadedVideo:
|
||||||
|
"""Download a YouTube Short and return its MP4 bytes in memory."""
|
||||||
validate_youtube_url(url)
|
validate_youtube_url(url)
|
||||||
normalized_session_path = _normalize_session_path(session_path)
|
normalized_session_path = _normalize_session_path(session_path)
|
||||||
|
|
||||||
with TemporaryDirectory(prefix="yt-shorts-downloader-") as temporary_directory:
|
with TemporaryDirectory(prefix='yt-shorts-downloader-') as temporary_directory:
|
||||||
downloaded_file = download_video(
|
downloaded_file = download_video(
|
||||||
url=url,
|
url=url,
|
||||||
output_dir=Path(temporary_directory),
|
output_dir=Path(temporary_directory),
|
||||||
@@ -41,6 +42,7 @@ def download_short(
|
|||||||
url: str,
|
url: str,
|
||||||
session_path: PathInput,
|
session_path: PathInput,
|
||||||
) -> DownloadedVideo:
|
) -> DownloadedVideo:
|
||||||
|
"""Alias for download kept for API readability."""
|
||||||
return download(url=url, session_path=session_path)
|
return download(url=url, session_path=session_path)
|
||||||
|
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ def download_to_path(
|
|||||||
*,
|
*,
|
||||||
output_dir: PathInput | None = None,
|
output_dir: PathInput | None = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
|
"""Download a YouTube Short and persist the resulting MP4 to disk."""
|
||||||
validate_youtube_url(url)
|
validate_youtube_url(url)
|
||||||
normalized_session_path = _normalize_session_path(session_path)
|
normalized_session_path = _normalize_session_path(session_path)
|
||||||
normalized_output_dir = _normalize_output_dir(output_dir)
|
normalized_output_dir = _normalize_output_dir(output_dir)
|
||||||
@@ -69,10 +72,7 @@ def _normalize_session_path(session_path: PathInput) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_output_dir(output_dir: PathInput | None) -> Path:
|
def _normalize_output_dir(output_dir: PathInput | None) -> Path:
|
||||||
if output_dir is None:
|
normalized_output_dir = Path.cwd() if output_dir is None else Path(output_dir).expanduser().resolve()
|
||||||
normalized_output_dir = Path.cwd()
|
|
||||||
else:
|
|
||||||
normalized_output_dir = Path(output_dir).expanduser().resolve()
|
|
||||||
|
|
||||||
normalized_output_dir.mkdir(parents=True, exist_ok=True)
|
normalized_output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
return normalized_output_dir
|
return normalized_output_dir
|
||||||
@@ -81,9 +81,9 @@ def _normalize_output_dir(output_dir: PathInput | None) -> Path:
|
|||||||
def _read_downloaded_video(downloaded_file: Path) -> DownloadedVideo:
|
def _read_downloaded_video(downloaded_file: Path) -> DownloadedVideo:
|
||||||
normalized_downloaded_file = downloaded_file.expanduser().resolve()
|
normalized_downloaded_file = downloaded_file.expanduser().resolve()
|
||||||
if not normalized_downloaded_file.exists():
|
if not normalized_downloaded_file.exists():
|
||||||
raise VideoDownloadError("Скачивание завершилось без итогового файла на диске")
|
raise VideoDownloadError('Скачивание завершилось без итогового файла на диске')
|
||||||
if normalized_downloaded_file.suffix.lower() != ".mp4":
|
if normalized_downloaded_file.suffix.lower() != '.mp4':
|
||||||
raise VideoDownloadError("Публичный API поддерживает только итоговый MP4")
|
raise VideoDownloadError('Публичный API поддерживает только итоговый MP4')
|
||||||
|
|
||||||
return DownloadedVideo(
|
return DownloadedVideo(
|
||||||
filename=normalized_downloaded_file.name,
|
filename=normalized_downloaded_file.name,
|
||||||
|
|||||||
@@ -9,25 +9,27 @@ from .exceptions import YtShortsDownloaderError
|
|||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""Build the CLI argument parser."""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description=("Скачать YouTube Shorts через session file в Netscape cookie format."),
|
description=('Скачать YouTube Shorts через session file в Netscape cookie format.'),
|
||||||
)
|
)
|
||||||
parser.add_argument("url", help="Ссылка на YouTube Shorts")
|
parser.add_argument('url', help='Ссылка на YouTube Shorts')
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"session_path",
|
'session_path',
|
||||||
type=Path,
|
type=Path,
|
||||||
help="Путь до session file в Netscape cookie format",
|
help='Путь до session file в Netscape cookie format',
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output-dir",
|
'--output-dir',
|
||||||
type=Path,
|
type=Path,
|
||||||
default=Path("."),
|
default=Path('.'),
|
||||||
help="Папка, куда сохранить скачанный файл",
|
help='Папка, куда сохранить скачанный файл',
|
||||||
)
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Sequence[str] | None = None) -> int:
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
"""Run the CLI entrypoint and return the exit code."""
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
@@ -38,7 +40,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
output_dir=args.output_dir,
|
output_dir=args.output_dir,
|
||||||
)
|
)
|
||||||
except YtShortsDownloaderError as exc:
|
except YtShortsDownloaderError as exc:
|
||||||
parser.exit(status=1, message=f"[error] {exc}\n")
|
parser.exit(status=1, message=f'[error] {exc}\n')
|
||||||
|
|
||||||
print(downloaded_file)
|
print(downloaded_file)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from .runtime import JsRuntimeOptions, find_supported_js_runtimes
|
|||||||
from .urls import validate_youtube_url
|
from .urls import validate_youtube_url
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"JsRuntimeOptions",
|
'JsRuntimeOptions',
|
||||||
"download_video",
|
'download_video',
|
||||||
"find_supported_js_runtimes",
|
'find_supported_js_runtimes',
|
||||||
"validate_youtube_url",
|
'validate_youtube_url',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,70 +14,67 @@ from .runtime import JsRuntimeOptions, find_supported_js_runtimes
|
|||||||
type Metadata = dict[str, object]
|
type Metadata = dict[str, object]
|
||||||
type YtDlpOptions = dict[str, object]
|
type YtDlpOptions = dict[str, object]
|
||||||
|
|
||||||
_DEFAULT_OUTTMPL: Final[str] = "%(title).200B [%(id)s].%(ext)s"
|
_DEFAULT_OUTTMPL: Final[str] = '%(title).200B [%(id)s].%(ext)s'
|
||||||
|
|
||||||
|
|
||||||
def download_video(url: str, output_dir: Path, session_path: Path) -> Path:
|
def download_video(url: str, output_dir: Path, session_path: Path) -> Path:
|
||||||
|
"""Download a video with yt-dlp and return the final MP4 path."""
|
||||||
options = _build_yt_dlp_options(output_dir=output_dir, session_path=session_path)
|
options = _build_yt_dlp_options(output_dir=output_dir, session_path=session_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with YoutubeDL(options) as youtube_downloader:
|
with YoutubeDL(options) as youtube_downloader:
|
||||||
extracted_info = youtube_downloader.extract_info(url, download=True)
|
extracted_info = youtube_downloader.extract_info(url, download=True)
|
||||||
except UnsupportedError as exc:
|
except UnsupportedError as exc:
|
||||||
raise InvalidUrlError(f"yt-dlp не поддерживает эту ссылку: {exc}") from exc
|
raise InvalidUrlError(f'yt-dlp не поддерживает эту ссылку: {exc}') from exc
|
||||||
except YtDlpDownloadError as exc:
|
except YtDlpDownloadError as exc:
|
||||||
raise VideoDownloadError(f"Не удалось скачать видео: {exc}") from exc
|
raise VideoDownloadError(f'Не удалось скачать видео: {exc}') from exc
|
||||||
|
|
||||||
if not isinstance(extracted_info, dict):
|
if not isinstance(extracted_info, dict):
|
||||||
raise VideoDownloadError("yt-dlp вернул неожиданный формат метаданных")
|
raise VideoDownloadError('yt-dlp вернул неожиданный формат метаданных')
|
||||||
|
|
||||||
downloaded_file = _locate_downloaded_file(
|
downloaded_file = _locate_downloaded_file(
|
||||||
info=cast(Metadata, extracted_info),
|
info=cast(Metadata, extracted_info),
|
||||||
output_dir=output_dir,
|
output_dir=output_dir,
|
||||||
)
|
)
|
||||||
if downloaded_file is None:
|
if downloaded_file is None:
|
||||||
raise VideoDownloadError(
|
raise VideoDownloadError('Скачивание завершено, но итоговый путь к файлу определить не удалось')
|
||||||
"Скачивание завершено, но итоговый путь к файлу определить не удалось"
|
if downloaded_file.suffix.lower() != '.mp4':
|
||||||
)
|
raise VideoDownloadError('Итоговый файл не является MP4')
|
||||||
if downloaded_file.suffix.lower() != ".mp4":
|
|
||||||
raise VideoDownloadError("Итоговый файл не является MP4")
|
|
||||||
|
|
||||||
return downloaded_file
|
return downloaded_file
|
||||||
|
|
||||||
|
|
||||||
def _build_yt_dlp_options(output_dir: Path, session_path: Path) -> YtDlpOptions:
|
def _build_yt_dlp_options(output_dir: Path, session_path: Path) -> YtDlpOptions:
|
||||||
js_runtimes = _get_supported_js_runtimes()
|
js_runtimes = _get_supported_js_runtimes()
|
||||||
ffmpeg_available = which("ffmpeg") is not None
|
ffmpeg_available = which('ffmpeg') is not None
|
||||||
format_selector = "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]" if ffmpeg_available else "b[ext=mp4]"
|
format_selector = 'bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]' if ffmpeg_available else 'b[ext=mp4]'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"cookiefile": str(session_path),
|
'cookiefile': str(session_path),
|
||||||
"format": format_selector,
|
'format': format_selector,
|
||||||
"js_runtimes": js_runtimes,
|
'js_runtimes': js_runtimes,
|
||||||
"merge_output_format": "mp4",
|
'merge_output_format': 'mp4',
|
||||||
"no_warnings": True,
|
'no_warnings': True,
|
||||||
"noplaylist": True,
|
'noplaylist': True,
|
||||||
"noprogress": True,
|
'noprogress': True,
|
||||||
"outtmpl": str(output_dir / _DEFAULT_OUTTMPL),
|
'outtmpl': str(output_dir / _DEFAULT_OUTTMPL),
|
||||||
"overwrites": False,
|
'overwrites': False,
|
||||||
"quiet": True,
|
'quiet': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_supported_js_runtimes() -> JsRuntimeOptions:
|
def _get_supported_js_runtimes() -> JsRuntimeOptions:
|
||||||
js_runtimes = find_supported_js_runtimes()
|
js_runtimes = find_supported_js_runtimes()
|
||||||
if js_runtimes is None:
|
if js_runtimes is None:
|
||||||
raise JsRuntimeUnavailableError(
|
raise JsRuntimeUnavailableError('Не найден поддерживаемый JavaScript runtime. Установите deno или Node.js 22+.')
|
||||||
"Не найден поддерживаемый JavaScript runtime. Установите deno или Node.js 22+."
|
|
||||||
)
|
|
||||||
return js_runtimes
|
return js_runtimes
|
||||||
|
|
||||||
|
|
||||||
def _unwrap_info(info: Metadata) -> Metadata:
|
def _unwrap_info(info: Metadata) -> Metadata:
|
||||||
if info.get("_type") != "playlist":
|
if info.get('_type') != 'playlist':
|
||||||
return info
|
return info
|
||||||
|
|
||||||
entries = info.get("entries")
|
entries = info.get('entries')
|
||||||
if not isinstance(entries, list):
|
if not isinstance(entries, list):
|
||||||
return info
|
return info
|
||||||
|
|
||||||
@@ -90,30 +87,30 @@ def _unwrap_info(info: Metadata) -> Metadata:
|
|||||||
def _locate_downloaded_file(info: Metadata, output_dir: Path) -> Path | None:
|
def _locate_downloaded_file(info: Metadata, output_dir: Path) -> Path | None:
|
||||||
normalized_info = _unwrap_info(info)
|
normalized_info = _unwrap_info(info)
|
||||||
|
|
||||||
requested_downloads = normalized_info.get("requested_downloads")
|
requested_downloads = normalized_info.get('requested_downloads')
|
||||||
if isinstance(requested_downloads, list):
|
if isinstance(requested_downloads, list):
|
||||||
for item in requested_downloads:
|
for item in requested_downloads:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
filepath = item.get("filepath")
|
filepath = item.get('filepath')
|
||||||
if isinstance(filepath, str):
|
if isinstance(filepath, str):
|
||||||
candidate = Path(filepath)
|
candidate = Path(filepath)
|
||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
for key in ("filepath", "_filename"):
|
for key in ('filepath', '_filename'):
|
||||||
filepath = normalized_info.get(key)
|
filepath = normalized_info.get(key)
|
||||||
if isinstance(filepath, str):
|
if isinstance(filepath, str):
|
||||||
candidate = Path(filepath)
|
candidate = Path(filepath)
|
||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
video_id = normalized_info.get("id")
|
video_id = normalized_info.get('id')
|
||||||
if not isinstance(video_id, str):
|
if not isinstance(video_id, str):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
matches = sorted(
|
matches = sorted(
|
||||||
(path for path in output_dir.iterdir() if path.is_file() and f"[{video_id}]" in path.name),
|
(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,
|
key=lambda path: path.stat().st_mtime,
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,41 +7,41 @@ from shutil import which
|
|||||||
type JsRuntimeOptions = dict[str, dict[str, str]]
|
type JsRuntimeOptions = dict[str, dict[str, str]]
|
||||||
|
|
||||||
_MIN_NODE_VERSION = (22, 0, 0)
|
_MIN_NODE_VERSION = (22, 0, 0)
|
||||||
|
_SEMVER_PART_COUNT = 3
|
||||||
|
|
||||||
|
|
||||||
def find_supported_js_runtimes() -> JsRuntimeOptions | None:
|
def find_supported_js_runtimes() -> JsRuntimeOptions | None:
|
||||||
deno_path = which("deno")
|
"""Return the best supported JavaScript runtime for yt-dlp."""
|
||||||
|
deno_path = which('deno')
|
||||||
if deno_path is not None:
|
if deno_path is not None:
|
||||||
return {"deno": {"path": deno_path}}
|
return {'deno': {'path': deno_path}}
|
||||||
|
|
||||||
node_candidates: dict[str, tuple[int, int, int]] = {}
|
node_candidates: dict[str, tuple[int, int, int]] = {}
|
||||||
node_on_path = which("node")
|
node_on_path = which('node')
|
||||||
if node_on_path is not None:
|
if node_on_path is not None:
|
||||||
version = _probe_executable_version(node_on_path)
|
version = _probe_executable_version(node_on_path)
|
||||||
if version is not None:
|
if version is not None:
|
||||||
node_candidates[str(Path(node_on_path).resolve())] = version
|
node_candidates[str(Path(node_on_path).resolve())] = version
|
||||||
|
|
||||||
nvm_versions_dir = Path.home() / ".nvm" / "versions" / "node"
|
nvm_versions_dir = Path.home() / '.nvm' / 'versions' / 'node'
|
||||||
if nvm_versions_dir.exists():
|
if nvm_versions_dir.exists():
|
||||||
for candidate in nvm_versions_dir.glob("v*/bin/node"):
|
for candidate in nvm_versions_dir.glob('v*/bin/node'):
|
||||||
version = _probe_executable_version(str(candidate))
|
version = _probe_executable_version(str(candidate))
|
||||||
if version is not None:
|
if version is not None:
|
||||||
node_candidates[str(candidate.resolve())] = version
|
node_candidates[str(candidate.resolve())] = version
|
||||||
|
|
||||||
supported_nodes = {
|
supported_nodes = {path: version for path, version in node_candidates.items() if version >= _MIN_NODE_VERSION}
|
||||||
path: version for path, version in node_candidates.items() if version >= _MIN_NODE_VERSION
|
|
||||||
}
|
|
||||||
if not supported_nodes:
|
if not supported_nodes:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
best_node_path = max(supported_nodes, key=lambda path: supported_nodes[path])
|
best_node_path = max(supported_nodes, key=lambda path: supported_nodes[path])
|
||||||
return {"node": {"path": best_node_path}}
|
return {'node': {'path': best_node_path}}
|
||||||
|
|
||||||
|
|
||||||
def _probe_executable_version(executable: str) -> tuple[int, int, int] | None:
|
def _probe_executable_version(executable: str) -> tuple[int, int, int] | None:
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[executable, "--version"],
|
[executable, '--version'],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
check=False,
|
check=False,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -58,12 +58,12 @@ def _probe_executable_version(executable: str) -> tuple[int, int, int] | None:
|
|||||||
|
|
||||||
|
|
||||||
def _parse_semver(value: str) -> tuple[int, int, int] | None:
|
def _parse_semver(value: str) -> tuple[int, int, int] | None:
|
||||||
cleaned = value.strip().lstrip("vV")
|
cleaned = value.strip().lstrip('vV')
|
||||||
parts = cleaned.split(".")
|
parts = cleaned.split('.')
|
||||||
numbers: list[int] = []
|
numbers: list[int] = []
|
||||||
|
|
||||||
for part in parts[:3]:
|
for part in parts[:_SEMVER_PART_COUNT]:
|
||||||
digits = ""
|
digits = ''
|
||||||
for character in part:
|
for character in part:
|
||||||
if not character.isdigit():
|
if not character.isdigit():
|
||||||
break
|
break
|
||||||
@@ -72,7 +72,7 @@ def _parse_semver(value: str) -> tuple[int, int, int] | None:
|
|||||||
return None
|
return None
|
||||||
numbers.append(int(digits))
|
numbers.append(int(digits))
|
||||||
|
|
||||||
while len(numbers) < 3:
|
while len(numbers) < _SEMVER_PART_COUNT:
|
||||||
numbers.append(0)
|
numbers.append(0)
|
||||||
|
|
||||||
return (numbers[0], numbers[1], numbers[2])
|
return (numbers[0], numbers[1], numbers[2])
|
||||||
|
|||||||
@@ -4,17 +4,18 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from ..exceptions import InvalidUrlError
|
from ..exceptions import InvalidUrlError
|
||||||
|
|
||||||
_YOUTUBE_HOST_SUFFIXES = ("youtube.com", "youtu.be")
|
_YOUTUBE_HOST_SUFFIXES = ('youtube.com', 'youtu.be')
|
||||||
|
|
||||||
|
|
||||||
def validate_youtube_url(url: str) -> None:
|
def validate_youtube_url(url: str) -> None:
|
||||||
|
"""Validate that the input URL points to a supported YouTube host."""
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
if parsed.scheme not in {"http", "https"}:
|
if parsed.scheme not in {'http', 'https'}:
|
||||||
raise InvalidUrlError("URL должна начинаться с http:// или https://")
|
raise InvalidUrlError('URL должна начинаться с http:// или https://')
|
||||||
|
|
||||||
host = parsed.netloc.lower()
|
host = parsed.netloc.lower()
|
||||||
if not host:
|
if not host:
|
||||||
raise InvalidUrlError("Не удалось определить домен URL")
|
raise InvalidUrlError('Не удалось определить домен URL')
|
||||||
|
|
||||||
if not any(host == suffix or host.endswith(f".{suffix}") for suffix in _YOUTUBE_HOST_SUFFIXES):
|
if not any(host == suffix or host.endswith(f'.{suffix}') for suffix in _YOUTUBE_HOST_SUFFIXES):
|
||||||
raise InvalidUrlError("Поддерживаются только ссылки YouTube")
|
raise InvalidUrlError('Поддерживаются только ссылки YouTube')
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from .core.downloader import download_video
|
from .core.downloader import download_video
|
||||||
|
|
||||||
__all__ = ["download_video"]
|
__all__ = ['download_video']
|
||||||
|
|||||||
@@ -2,20 +2,30 @@ from __future__ import annotations
|
|||||||
|
|
||||||
|
|
||||||
class YtShortsDownloaderError(Exception):
|
class YtShortsDownloaderError(Exception):
|
||||||
|
"""Base exception for package-specific failures."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class InvalidUrlError(YtShortsDownloaderError):
|
class InvalidUrlError(YtShortsDownloaderError):
|
||||||
|
"""Raised when the provided URL is not a supported YouTube URL."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class InvalidSessionError(YtShortsDownloaderError):
|
class InvalidSessionError(YtShortsDownloaderError):
|
||||||
|
"""Raised when the provided session file cannot be used."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class JsRuntimeUnavailableError(YtShortsDownloaderError):
|
class JsRuntimeUnavailableError(YtShortsDownloaderError):
|
||||||
|
"""Raised when no supported JavaScript runtime is available."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class VideoDownloadError(YtShortsDownloaderError):
|
class VideoDownloadError(YtShortsDownloaderError):
|
||||||
|
"""Raised when yt-dlp cannot produce a valid MP4 result."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ from __future__ import annotations
|
|||||||
from .download import DownloadedVideo
|
from .download import DownloadedVideo
|
||||||
from .session import SessionCookie, SessionValidation
|
from .session import SessionCookie, SessionValidation
|
||||||
|
|
||||||
__all__ = ["DownloadedVideo", "SessionCookie", "SessionValidation"]
|
__all__ = ['DownloadedVideo', 'SessionCookie', 'SessionValidation']
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class DownloadedVideo:
|
class DownloadedVideo:
|
||||||
|
"""In-memory representation of a downloaded MP4 file."""
|
||||||
|
|
||||||
filename: str
|
filename: str
|
||||||
content: bytes
|
content: bytes
|
||||||
media_type: str = "video/mp4"
|
media_type: str = 'video/mp4'
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SessionCookie:
|
class SessionCookie:
|
||||||
|
"""Normalized cookie parsed from a Netscape session file."""
|
||||||
|
|
||||||
domain: str
|
domain: str
|
||||||
include_subdomains: bool
|
include_subdomains: bool
|
||||||
path: str
|
path: str
|
||||||
@@ -16,6 +18,8 @@ class SessionCookie:
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SessionValidation:
|
class SessionValidation:
|
||||||
|
"""Validation result for a candidate session cookie file."""
|
||||||
|
|
||||||
exists: bool
|
exists: bool
|
||||||
structurally_valid: bool
|
structurally_valid: bool
|
||||||
fresh: bool
|
fresh: bool
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from .core.runtime import JsRuntimeOptions, _parse_semver, find_supported_js_runtimes
|
from .core.runtime import JsRuntimeOptions, _parse_semver, find_supported_js_runtimes
|
||||||
|
|
||||||
__all__ = ["JsRuntimeOptions", "_parse_semver", "find_supported_js_runtimes"]
|
__all__ = ['JsRuntimeOptions', '_parse_semver', 'find_supported_js_runtimes']
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from .session import validate_session_file
|
from .session import validate_session_file
|
||||||
|
|
||||||
__all__ = ["validate_session_file"]
|
__all__ = ['validate_session_file']
|
||||||
|
|||||||
@@ -5,27 +5,30 @@ from pathlib import Path
|
|||||||
|
|
||||||
from ..models import SessionCookie, SessionValidation
|
from ..models import SessionCookie, SessionValidation
|
||||||
|
|
||||||
_SESSION_DOMAIN_SUFFIXES = ("youtube.com", "google.com")
|
_SESSION_DOMAIN_SUFFIXES = ('youtube.com', 'google.com')
|
||||||
_AUTH_COOKIE_NAMES = {
|
_AUTH_COOKIE_NAMES = {
|
||||||
"SID",
|
'SID',
|
||||||
"HSID",
|
'HSID',
|
||||||
"SSID",
|
'SSID',
|
||||||
"APISID",
|
'APISID',
|
||||||
"SAPISID",
|
'SAPISID',
|
||||||
"__Secure-1PSID",
|
'__Secure-1PSID',
|
||||||
"__Secure-3PSID",
|
'__Secure-3PSID',
|
||||||
"LOGIN_INFO",
|
'LOGIN_INFO',
|
||||||
}
|
}
|
||||||
_AUTH_COOKIE_ANCHORS = {
|
_AUTH_COOKIE_ANCHORS = {
|
||||||
"SID",
|
'SID',
|
||||||
"SAPISID",
|
'SAPISID',
|
||||||
"__Secure-1PSID",
|
'__Secure-1PSID',
|
||||||
"__Secure-3PSID",
|
'__Secure-3PSID',
|
||||||
"LOGIN_INFO",
|
'LOGIN_INFO',
|
||||||
}
|
}
|
||||||
|
_MIN_FRESH_AUTH_COOKIES = 3
|
||||||
|
_NETSCAPE_COOKIE_COLUMNS = 7
|
||||||
|
|
||||||
|
|
||||||
def validate_session_file(path: Path) -> SessionValidation:
|
def validate_session_file(path: Path) -> SessionValidation:
|
||||||
|
"""Validate that a Netscape cookie file is usable for YouTube downloads."""
|
||||||
normalized_path = path.expanduser().resolve()
|
normalized_path = path.expanduser().resolve()
|
||||||
if not normalized_path.exists():
|
if not normalized_path.exists():
|
||||||
return SessionValidation(
|
return SessionValidation(
|
||||||
@@ -33,7 +36,7 @@ def validate_session_file(path: Path) -> SessionValidation:
|
|||||||
structurally_valid=False,
|
structurally_valid=False,
|
||||||
fresh=False,
|
fresh=False,
|
||||||
is_usable=False,
|
is_usable=False,
|
||||||
message=f"Файл сессии не найден: {normalized_path}",
|
message=f'Файл сессии не найден: {normalized_path}',
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -44,7 +47,7 @@ def validate_session_file(path: Path) -> SessionValidation:
|
|||||||
structurally_valid=False,
|
structurally_valid=False,
|
||||||
fresh=False,
|
fresh=False,
|
||||||
is_usable=False,
|
is_usable=False,
|
||||||
message=f"Файл сессии не прошёл проверку формата: {exc}",
|
message=f'Файл сессии не прошёл проверку формата: {exc}',
|
||||||
)
|
)
|
||||||
|
|
||||||
if not cookies:
|
if not cookies:
|
||||||
@@ -53,19 +56,17 @@ def validate_session_file(path: Path) -> SessionValidation:
|
|||||||
structurally_valid=False,
|
structurally_valid=False,
|
||||||
fresh=False,
|
fresh=False,
|
||||||
is_usable=False,
|
is_usable=False,
|
||||||
message="Файл сессии пустой",
|
message='Файл сессии пустой',
|
||||||
)
|
)
|
||||||
|
|
||||||
relevant_cookies = [
|
relevant_cookies = [cookie for cookie in cookies if _matches_domain(cookie.domain, _SESSION_DOMAIN_SUFFIXES)]
|
||||||
cookie for cookie in cookies if _matches_domain(cookie.domain, _SESSION_DOMAIN_SUFFIXES)
|
|
||||||
]
|
|
||||||
if not relevant_cookies:
|
if not relevant_cookies:
|
||||||
return SessionValidation(
|
return SessionValidation(
|
||||||
exists=True,
|
exists=True,
|
||||||
structurally_valid=False,
|
structurally_valid=False,
|
||||||
fresh=False,
|
fresh=False,
|
||||||
is_usable=False,
|
is_usable=False,
|
||||||
message="В файле сессии нет записей для YouTube или Google",
|
message='В файле сессии нет записей для YouTube или Google',
|
||||||
)
|
)
|
||||||
|
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
@@ -83,26 +84,20 @@ def validate_session_file(path: Path) -> SessionValidation:
|
|||||||
soonest_expiry = cookie.expires
|
soonest_expiry = cookie.expires
|
||||||
|
|
||||||
anchor_count = len(fresh_auth_names & _AUTH_COOKIE_ANCHORS)
|
anchor_count = len(fresh_auth_names & _AUTH_COOKIE_ANCHORS)
|
||||||
fresh = len(fresh_auth_names) >= 3 and anchor_count >= 1
|
fresh = len(fresh_auth_names) >= _MIN_FRESH_AUTH_COOKIES and anchor_count >= 1
|
||||||
if not fresh:
|
if not fresh:
|
||||||
return SessionValidation(
|
return SessionValidation(
|
||||||
exists=True,
|
exists=True,
|
||||||
structurally_valid=True,
|
structurally_valid=True,
|
||||||
fresh=False,
|
fresh=False,
|
||||||
is_usable=False,
|
is_usable=False,
|
||||||
message=(
|
message=('Файл сессии в корректном формате, но в нём нет достаточного набора актуальных YouTube auth-cookie'),
|
||||||
"Файл сессии в корректном формате, но в нём нет достаточного набора "
|
|
||||||
"актуальных YouTube auth-cookie"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
message = (
|
message = f'Файл сессии валиден и выглядит актуальным для YouTube: найдено {len(fresh_auth_names)} свежих auth-cookie'
|
||||||
"Файл сессии валиден и выглядит актуальным для YouTube: "
|
|
||||||
f"найдено {len(fresh_auth_names)} свежих auth-cookie"
|
|
||||||
)
|
|
||||||
if soonest_expiry is not None:
|
if soonest_expiry is not None:
|
||||||
hours_left = max((soonest_expiry - now) // 3600, 0)
|
hours_left = max((soonest_expiry - now) // 3600, 0)
|
||||||
message += f", ближайшее истечение примерно через {hours_left} ч"
|
message += f', ближайшее истечение примерно через {hours_left} ч'
|
||||||
|
|
||||||
return SessionValidation(
|
return SessionValidation(
|
||||||
exists=True,
|
exists=True,
|
||||||
@@ -116,26 +111,22 @@ def validate_session_file(path: Path) -> SessionValidation:
|
|||||||
def _parse_session_file(path: Path) -> list[SessionCookie]:
|
def _parse_session_file(path: Path) -> list[SessionCookie]:
|
||||||
cookies: list[SessionCookie] = []
|
cookies: list[SessionCookie] = []
|
||||||
|
|
||||||
with path.open("r", encoding="utf-8") as handle:
|
with path.open('r', encoding='utf-8') as handle:
|
||||||
for line_number, raw_line in enumerate(handle, start=1):
|
for line_number, raw_line in enumerate(handle, start=1):
|
||||||
stripped = raw_line.strip()
|
stripped = raw_line.strip()
|
||||||
if not stripped:
|
if not stripped:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
is_httponly_cookie = stripped.startswith("#HttpOnly_")
|
is_httponly_cookie = stripped.startswith('#HttpOnly_')
|
||||||
if stripped.startswith("#") and not is_httponly_cookie:
|
if stripped.startswith('#') and not is_httponly_cookie:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if is_httponly_cookie:
|
if is_httponly_cookie:
|
||||||
stripped = stripped.removeprefix("#HttpOnly_")
|
stripped = stripped.removeprefix('#HttpOnly_')
|
||||||
|
|
||||||
parts = stripped.split("\t")
|
parts = stripped.split('\t')
|
||||||
if len(parts) != 7:
|
if len(parts) != _NETSCAPE_COOKIE_COLUMNS:
|
||||||
raise ValueError(
|
raise ValueError(f'Строка {line_number}: ожидалось {_NETSCAPE_COOKIE_COLUMNS} колонок Netscape cookie file, получено {len(parts)}')
|
||||||
"Строка "
|
|
||||||
f"{line_number}: ожидалось 7 колонок Netscape cookie file, "
|
|
||||||
f"получено {len(parts)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
(
|
(
|
||||||
domain,
|
domain,
|
||||||
@@ -150,16 +141,14 @@ def _parse_session_file(path: Path) -> list[SessionCookie]:
|
|||||||
try:
|
try:
|
||||||
expires_at = int(expires)
|
expires_at = int(expires)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError(
|
raise ValueError(f'Строка {line_number}: expires должен быть UNIX timestamp') from exc
|
||||||
f"Строка {line_number}: expires должен быть UNIX timestamp"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
cookies.append(
|
cookies.append(
|
||||||
SessionCookie(
|
SessionCookie(
|
||||||
domain=domain,
|
domain=domain,
|
||||||
include_subdomains=include_subdomains.upper() == "TRUE",
|
include_subdomains=include_subdomains.upper() == 'TRUE',
|
||||||
path=cookie_path,
|
path=cookie_path,
|
||||||
secure=secure.upper() == "TRUE",
|
secure=secure.upper() == 'TRUE',
|
||||||
expires=expires_at,
|
expires=expires_at,
|
||||||
name=name,
|
name=name,
|
||||||
value=value,
|
value=value,
|
||||||
@@ -171,11 +160,8 @@ def _parse_session_file(path: Path) -> list[SessionCookie]:
|
|||||||
|
|
||||||
def _matches_domain(domain: str, suffixes: tuple[str, ...]) -> bool:
|
def _matches_domain(domain: str, suffixes: tuple[str, ...]) -> bool:
|
||||||
normalized_domain = _normalize_domain(domain)
|
normalized_domain = _normalize_domain(domain)
|
||||||
return any(
|
return any(normalized_domain == suffix or normalized_domain.endswith(f'.{suffix}') for suffix in suffixes)
|
||||||
normalized_domain == suffix or normalized_domain.endswith(f".{suffix}")
|
|
||||||
for suffix in suffixes
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_domain(domain: str) -> str:
|
def _normalize_domain(domain: str) -> str:
|
||||||
return domain.removeprefix("#HttpOnly_").lstrip(".").lower()
|
return domain.removeprefix('#HttpOnly_').lstrip('.').lower()
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from .services.session import validate_session_file
|
from .services.session import validate_session_file
|
||||||
|
|
||||||
__all__ = ["validate_session_file"]
|
__all__ = ['validate_session_file']
|
||||||
|
|||||||
+19
-21
@@ -14,50 +14,48 @@ def _build_valid_session_validation(path: Path) -> SessionValidation:
|
|||||||
|
|
||||||
|
|
||||||
def test_download_returns_binary_mp4(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
def test_download_returns_binary_mp4(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
expected_content = b"mp4-binary-content"
|
expected_content = b'mp4-binary-content'
|
||||||
|
|
||||||
def fake_validate_session_file(path: Path) -> SessionValidation:
|
def fake_validate_session_file(path: Path) -> SessionValidation:
|
||||||
return _build_valid_session_validation(path)
|
return _build_valid_session_validation(path)
|
||||||
|
|
||||||
def fake_download_video(*, url: str, output_dir: Path, session_path: Path) -> Path:
|
def fake_download_video(*, url: str, output_dir: Path, session_path: Path) -> Path:
|
||||||
del url, session_path
|
del url, session_path
|
||||||
downloaded_file = output_dir / "video.mp4"
|
downloaded_file = output_dir / 'video.mp4'
|
||||||
downloaded_file.write_bytes(expected_content)
|
downloaded_file.write_bytes(expected_content)
|
||||||
return downloaded_file
|
return downloaded_file
|
||||||
|
|
||||||
monkeypatch.setattr(api, "validate_session_file", fake_validate_session_file)
|
monkeypatch.setattr(api, 'validate_session_file', fake_validate_session_file)
|
||||||
monkeypatch.setattr(api, "download_video", fake_download_video)
|
monkeypatch.setattr(api, 'download_video', fake_download_video)
|
||||||
|
|
||||||
downloaded_video = api.download(
|
downloaded_video = api.download(
|
||||||
"https://youtube.com/shorts/example",
|
'https://youtube.com/shorts/example',
|
||||||
session_path="cookies.txt",
|
session_path='cookies.txt',
|
||||||
)
|
)
|
||||||
|
|
||||||
assert downloaded_video == DownloadedVideo(
|
assert downloaded_video == DownloadedVideo(
|
||||||
filename="video.mp4",
|
filename='video.mp4',
|
||||||
content=expected_content,
|
content=expected_content,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_download_to_path_returns_path_from_downloader(
|
def test_download_to_path_returns_path_from_downloader(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
expected_path = tmp_path / 'video.mp4'
|
||||||
) -> None:
|
|
||||||
expected_path = tmp_path / "video.mp4"
|
|
||||||
|
|
||||||
def fake_validate_session_file(path: Path) -> SessionValidation:
|
def fake_validate_session_file(path: Path) -> SessionValidation:
|
||||||
return _build_valid_session_validation(path)
|
return _build_valid_session_validation(path)
|
||||||
|
|
||||||
def fake_download_video(*, url: str, output_dir: Path, session_path: Path) -> Path:
|
def fake_download_video(*, url: str, output_dir: Path, session_path: Path) -> Path:
|
||||||
del url, session_path
|
del url, session_path
|
||||||
expected_path.write_bytes(b"mp4")
|
expected_path.write_bytes(b'mp4')
|
||||||
return output_dir / expected_path.name
|
return output_dir / expected_path.name
|
||||||
|
|
||||||
monkeypatch.setattr(api, "validate_session_file", fake_validate_session_file)
|
monkeypatch.setattr(api, 'validate_session_file', fake_validate_session_file)
|
||||||
monkeypatch.setattr(api, "download_video", fake_download_video)
|
monkeypatch.setattr(api, 'download_video', fake_download_video)
|
||||||
|
|
||||||
downloaded_path = api.download_to_path(
|
downloaded_path = api.download_to_path(
|
||||||
"https://youtube.com/shorts/example",
|
'https://youtube.com/shorts/example',
|
||||||
session_path="cookies.txt",
|
session_path='cookies.txt',
|
||||||
output_dir=tmp_path,
|
output_dir=tmp_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,15 +64,15 @@ def test_download_to_path_returns_path_from_downloader(
|
|||||||
|
|
||||||
def test_download_rejects_invalid_url() -> None:
|
def test_download_rejects_invalid_url() -> None:
|
||||||
with pytest.raises(InvalidUrlError):
|
with pytest.raises(InvalidUrlError):
|
||||||
api.download("https://example.com/watch?v=1", session_path="cookies.txt")
|
api.download('https://example.com/watch?v=1', session_path='cookies.txt')
|
||||||
|
|
||||||
|
|
||||||
def test_download_rejects_invalid_session(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_download_rejects_invalid_session(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
def fake_validate_session_file(path: Path) -> SessionValidation:
|
def fake_validate_session_file(path: Path) -> SessionValidation:
|
||||||
del path
|
del path
|
||||||
return SessionValidation(False, False, False, False, "bad session")
|
return SessionValidation(False, False, False, False, 'bad session')
|
||||||
|
|
||||||
monkeypatch.setattr(api, "validate_session_file", fake_validate_session_file)
|
monkeypatch.setattr(api, 'validate_session_file', fake_validate_session_file)
|
||||||
|
|
||||||
with pytest.raises(InvalidSessionError, match="bad session"):
|
with pytest.raises(InvalidSessionError, match='bad session'):
|
||||||
api.download("https://youtube.com/shorts/example", session_path="cookies.txt")
|
api.download('https://youtube.com/shorts/example', session_path='cookies.txt')
|
||||||
|
|||||||
+8
-8
@@ -13,19 +13,19 @@ def test_cli_prints_downloaded_path(
|
|||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
expected_path = tmp_path / "video.mp4"
|
expected_path = tmp_path / 'video.mp4'
|
||||||
|
|
||||||
def fake_download_to_path(**kwargs: object) -> Path:
|
def fake_download_to_path(**kwargs: object) -> Path:
|
||||||
del kwargs
|
del kwargs
|
||||||
return expected_path
|
return expected_path
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "download_to_path", fake_download_to_path)
|
monkeypatch.setattr(cli, 'download_to_path', fake_download_to_path)
|
||||||
|
|
||||||
exit_code = cli.main(
|
exit_code = cli.main(
|
||||||
[
|
[
|
||||||
"https://youtube.com/shorts/example",
|
'https://youtube.com/shorts/example',
|
||||||
"cookies.txt",
|
'cookies.txt',
|
||||||
"--output-dir",
|
'--output-dir',
|
||||||
str(tmp_path),
|
str(tmp_path),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -39,11 +39,11 @@ def test_cli_prints_downloaded_path(
|
|||||||
def test_cli_exits_with_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_cli_exits_with_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
def raise_error(**kwargs: object) -> Path:
|
def raise_error(**kwargs: object) -> Path:
|
||||||
del kwargs
|
del kwargs
|
||||||
raise InvalidSessionError("session error")
|
raise InvalidSessionError('session error')
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "download_to_path", raise_error)
|
monkeypatch.setattr(cli, 'download_to_path', raise_error)
|
||||||
|
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
cli.main(["https://youtube.com/shorts/example", "cookies.txt"])
|
cli.main(['https://youtube.com/shorts/example', 'cookies.txt'])
|
||||||
|
|
||||||
assert exc_info.value.code == 1
|
assert exc_info.value.code == 1
|
||||||
|
|||||||
+17
-14
@@ -12,39 +12,42 @@ def test_find_supported_js_runtimes_prefers_deno(
|
|||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
runtime,
|
runtime,
|
||||||
"which",
|
'which',
|
||||||
lambda executable: "/usr/bin/deno" if executable == "deno" else None,
|
lambda executable: '/usr/bin/deno' if executable == 'deno' else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
runtimes = runtime.find_supported_js_runtimes()
|
runtimes = runtime.find_supported_js_runtimes()
|
||||||
|
|
||||||
assert runtimes == {"deno": {"path": "/usr/bin/deno"}}
|
assert runtimes == {'deno': {'path': '/usr/bin/deno'}}
|
||||||
|
|
||||||
|
|
||||||
def test_find_supported_js_runtimes_uses_best_supported_node(
|
def test_find_supported_js_runtimes_uses_best_supported_node(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
first_node = tmp_path / ".nvm" / "versions" / "node" / "v22.1.0" / "bin" / "node"
|
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 = tmp_path / '.nvm' / 'versions' / 'node' / 'v22.9.0' / 'bin' / 'node'
|
||||||
second_node.parent.mkdir(parents=True)
|
second_node.parent.mkdir(parents=True)
|
||||||
first_node.parent.mkdir(parents=True)
|
first_node.parent.mkdir(parents=True)
|
||||||
first_node.write_text("", encoding="utf-8")
|
first_node.write_text('', encoding='utf-8')
|
||||||
second_node.write_text("", encoding="utf-8")
|
second_node.write_text('', encoding='utf-8')
|
||||||
|
|
||||||
monkeypatch.setattr(runtime, "which", lambda executable: None)
|
def fake_which(_executable: str) -> None:
|
||||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(runtime, 'which', fake_which)
|
||||||
|
monkeypatch.setattr(Path, 'home', lambda: tmp_path)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
runtime,
|
runtime,
|
||||||
"_probe_executable_version",
|
'_probe_executable_version',
|
||||||
lambda executable: (22, 9, 0) if executable.endswith("v22.9.0/bin/node") else (22, 1, 0),
|
lambda executable: (22, 9, 0) if executable.endswith('v22.9.0/bin/node') else (22, 1, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
runtimes = runtime.find_supported_js_runtimes()
|
runtimes = runtime.find_supported_js_runtimes()
|
||||||
|
|
||||||
assert runtimes == {"node": {"path": str(second_node.resolve())}}
|
assert runtimes == {'node': {'path': str(second_node.resolve())}}
|
||||||
|
|
||||||
|
|
||||||
def test_parse_semver_handles_prefixed_versions() -> None:
|
def test_parse_semver_handles_prefixed_versions() -> None:
|
||||||
assert runtime._parse_semver("v22.12.1") == (22, 12, 1)
|
assert runtime._parse_semver('v22.12.1') == (22, 12, 1)
|
||||||
assert runtime._parse_semver("node") is None
|
assert runtime._parse_semver('node') is None
|
||||||
|
|||||||
@@ -6,17 +6,17 @@ from yt_shorts_downloader.session import validate_session_file
|
|||||||
|
|
||||||
|
|
||||||
def test_validate_session_file_accepts_valid_youtube_session(tmp_path: Path) -> None:
|
def test_validate_session_file_accepts_valid_youtube_session(tmp_path: Path) -> None:
|
||||||
session_file = tmp_path / "cookies.txt"
|
session_file = tmp_path / 'cookies.txt'
|
||||||
session_file.write_text(
|
session_file.write_text(
|
||||||
"\n".join(
|
'\n'.join(
|
||||||
[
|
[
|
||||||
"# Netscape HTTP Cookie File",
|
'# Netscape HTTP Cookie File',
|
||||||
".youtube.com\tTRUE\t/\tFALSE\t9999999999\tSID\tvalue1",
|
'.youtube.com\tTRUE\t/\tFALSE\t9999999999\tSID\tvalue1',
|
||||||
".youtube.com\tTRUE\t/\tTRUE\t9999999999\tSAPISID\tvalue2",
|
'.youtube.com\tTRUE\t/\tTRUE\t9999999999\tSAPISID\tvalue2',
|
||||||
".youtube.com\tTRUE\t/\tTRUE\t9999999999\tLOGIN_INFO\tvalue3",
|
'.youtube.com\tTRUE\t/\tTRUE\t9999999999\tLOGIN_INFO\tvalue3',
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
encoding="utf-8",
|
encoding='utf-8',
|
||||||
)
|
)
|
||||||
|
|
||||||
validation = validate_session_file(session_file)
|
validation = validate_session_file(session_file)
|
||||||
@@ -28,8 +28,8 @@ def test_validate_session_file_accepts_valid_youtube_session(tmp_path: Path) ->
|
|||||||
|
|
||||||
|
|
||||||
def test_validate_session_file_rejects_invalid_format(tmp_path: Path) -> None:
|
def test_validate_session_file_rejects_invalid_format(tmp_path: Path) -> None:
|
||||||
session_file = tmp_path / "cookies.txt"
|
session_file = tmp_path / 'cookies.txt'
|
||||||
session_file.write_text("broken\trow", encoding="utf-8")
|
session_file.write_text('broken\trow', encoding='utf-8')
|
||||||
|
|
||||||
validation = validate_session_file(session_file)
|
validation = validate_session_file(session_file)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user