ytptube/app/features/ytdlp/tests/test_ytdlp_utils.py
2026-06-14 18:35:03 +03:00

592 lines
19 KiB
Python

import logging
from pathlib import Path
import re
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from app.features.ytdlp.utils import (
LogWrapper,
extract_ytdlp_logs,
get_archive_id,
ytdlp_reject,
parse_outtmpl,
get_extras,
get_thumbnail,
get_ytdlp,
archive_delete,
archive_add,
archive_read,
)
from app.tests.helpers import make_test_temp_dir
class CaptureHandler(logging.Handler):
def __init__(self) -> None:
super().__init__()
self.records: list[logging.LogRecord] = []
def emit(self, record: logging.LogRecord) -> None:
self.records.append(record)
def make_logger(name: str = "lw_test") -> tuple[logging.Logger, CaptureHandler]:
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
handler = CaptureHandler()
# Avoid duplicate handlers when tests run multiple times
for h in list(logger.handlers):
logger.removeHandler(h)
logger.addHandler(handler)
return logger, handler
class TestLogWrapper:
def test_add_target_type_validation(self) -> None:
lw = LogWrapper()
with pytest.raises(TypeError, match=r"Target must be a logging\.Logger instance or a callable"):
bad: Any = 123
lw.add_target(bad)
def test_add_target_names(self) -> None:
lw = LogWrapper()
logger, _ = make_logger("one")
# Name inferred from logger
lw.add_target(logger)
assert lw.targets[-1].name == "one"
assert lw.has_targets()
# Name inferred from callable
def sink(level: int, msg: str, *args: Any, **kwargs: Any) -> None: # noqa: ARG001
return None
lw.add_target(sink)
assert lw.targets[-1].name == "sink"
# Custom name overrides
lw.add_target(logger, name="custom")
assert lw.targets[-1].name == "custom"
def test_level_filtering_and_dispatch(self) -> None:
lw = LogWrapper()
logger, cap = make_logger("cap")
calls: list[tuple[int, str, tuple, dict]] = []
def sink(level: int, msg: str, *args: Any, **kwargs: Any) -> None:
calls.append((level, msg, args, kwargs))
# Logger target at INFO, callable target at WARNING
lw.add_target(logger, level=logging.INFO)
lw.add_target(sink, level=logging.WARNING)
# DEBUG should hit none
lw.debug("d1")
assert len(cap.records) == 0
assert len(calls) == 0
# INFO hits logger only
lw.info("hello %s", "X")
assert len(cap.records) == 1
assert cap.records[0].levelno == logging.INFO
assert cap.records[0].getMessage() == "hello X"
assert cap.records[0].funcName == "test_level_filtering_and_dispatch"
assert len(calls) == 0
# WARNING hits both
lw.warning("warn %s", "Y", extra={"k": 1})
assert len(cap.records) == 2
assert cap.records[1].levelno == logging.WARNING
assert cap.records[1].getMessage() == "warn Y"
assert len(calls) == 1
lvl, msg, args, kwargs = calls[0]
assert lvl == logging.WARNING
assert msg == "warn %s"
assert args == ("Y",)
assert "extra" in kwargs
assert kwargs["extra"] == {"k": 1}
# ERROR still hits both; CRITICAL too
lw.error("err")
lw.critical("boom")
assert any(r.levelno == logging.ERROR for r in cap.records)
assert any(r.levelno == logging.CRITICAL for r in cap.records)
assert any(c[0] == logging.ERROR for c in calls)
assert any(c[0] == logging.CRITICAL for c in calls)
class TestExtractYtdlpLogs:
"""Test YTDLP log extraction function."""
def test_extract_ytdlp_logs_basic(self):
"""Test basic log extraction."""
logs = ["This live event will begin soon", "ERROR: Failed", "WARNING: Deprecated"]
result = extract_ytdlp_logs(logs)
assert isinstance(result, list)
assert len(result) >= 1 # Should match "This live event will begin"
def test_extract_ytdlp_logs_with_filters(self):
"""Test log extraction with filters."""
logs = ["INFO: Downloading", "ERROR: Failed", "WARNING: Deprecated"]
filters: list[str | re.Pattern[str]] = [re.compile(r"ERROR")]
result = extract_ytdlp_logs(logs, filters)
assert result == ["ERROR: Failed"]
def test_extract_ytdlp_logs_empty(self):
"""Test with empty logs."""
result = extract_ytdlp_logs([])
assert result == []
class TestYtdlpReject:
"""Test the ytdlp_reject function."""
def test_ytdlp_reject_basic(self):
"""Test basic rejection logic."""
entry = {"title": "Test Video", "view_count": 1000}
yt_params = {}
passed, message = ytdlp_reject(entry, yt_params)
assert passed is True
assert message == ""
def test_ytdlp_reject_with_filters(self):
"""Test rejection with filters."""
entry = {"title": "Test Video", "upload_date": "20230101"}
yt_params = {"daterange": MagicMock()}
# Mock daterange to simulate rejection
yt_params["daterange"].__contains__ = MagicMock(return_value=False)
passed, message = ytdlp_reject(entry, yt_params)
assert passed is False
assert "20230101" in message
assert "not in range" in message
class TestParseOuttmpl:
"""Test the parse_outtmpl function."""
def setup_method(self):
"""Reset YTDLP singleton state before each test."""
from app.features.ytdlp.utils import _DATA
_DATA.YTDLP_INFO_CLS = None
def test_parse_outtmpl_basic(self):
"""Test basic template parsing with simple placeholders."""
template = "%(title)s.%(ext)s"
info_dict = {
"title": "Test Video",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert result == "Test Video.mp4"
def test_parse_outtmpl_with_id(self):
"""Test template parsing with video ID."""
template = "[%(id)s] %(title)s.%(ext)s"
info_dict = {
"id": "dQw4w9WgXcQ",
"title": "Never Gonna Give You Up",
"ext": "webm",
}
result = parse_outtmpl(template, info_dict)
assert result == "[dQw4w9WgXcQ] Never Gonna Give You Up.webm"
def test_parse_outtmpl_with_uploader(self):
"""Test template parsing with uploader information."""
template = "%(uploader)s - %(title)s.%(ext)s"
info_dict = {
"uploader": "Rick Astley",
"title": "Never Gonna Give You Up",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert result == "Rick Astley - Never Gonna Give You Up.mp4"
def test_parse_outtmpl_with_nested_path(self):
"""Test template parsing with nested directory structure."""
template = "%(uploader)s/%(title)s.%(ext)s"
info_dict = {
"uploader": "Test Channel",
"title": "Test Video",
"ext": "mkv",
}
result = parse_outtmpl(template, info_dict)
assert result == "Test Channel/Test Video.mkv"
def test_parse_outtmpl_with_missing_field(self):
"""Test template parsing with missing field defaults to NA."""
template = "%(title)s - %(upload_date)s.%(ext)s"
info_dict = {
"title": "Test Video",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert result == "Test Video - NA.mp4", "Missing field upload_date should default to NA"
def test_parse_outtmpl_complex(self):
"""Test complex template with multiple fields."""
template = "%(uploader)s/%(playlist_title)s/%(playlist_index)03d - %(title)s [%(id)s].%(ext)s"
info_dict = {
"uploader": "Test Channel",
"playlist_title": "Best Videos",
"playlist_index": 5,
"title": "Amazing Content",
"id": "abc123xyz",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert result == "Test Channel/Best Videos/005 - Amazing Content [abc123xyz].mp4"
def test_parse_outtmpl_special_chars(self):
"""Test template parsing handles special characters in values."""
template = "%(title)s.%(ext)s"
info_dict = {
"title": "Test: Video / With \\ Special | Characters",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert ".mp4" in result, "yt-dlp should sanitize special characters but preserve extension"
assert "Test" in result, "yt-dlp should preserve safe parts of title"
def test_parse_outtmpl_with_playlist_info(self):
"""Test template parsing with playlist information."""
template = "%(playlist)s/%(title)s.%(ext)s"
info_dict = {
"playlist": "My Playlist",
"title": "Video Title",
"ext": "webm",
}
result = parse_outtmpl(template, info_dict)
assert result == "My Playlist/Video Title.webm"
def test_parse_outtmpl_restrict(self):
"""Test template parsing with restrict_filename parameter."""
template = "%(uploader)s/%(title)s.%(ext)s"
info_dict = {
"uploader": "Foobar's Workshop",
"title": "Test Video",
"ext": "mp4",
}
result_unrestricted: str = parse_outtmpl(template, info_dict)
assert result_unrestricted == "Foobar's Workshop/Test Video.mp4"
result_restricted: str = parse_outtmpl(template, info_dict, params={"restrictfilenames": True})
assert result_restricted == "Foobar_s_Workshop/Test_Video.mp4"
class TestGetThumbnail:
def test_empty_list(self):
"""Test that None is returned for an empty thumbnail list."""
assert get_thumbnail([]) is None
def test_non_list(self):
"""Test that None is returned for non-list input."""
bad_none: Any = None
bad_str: Any = "not a list"
bad_dict: Any = {"not": "list"}
assert get_thumbnail(bad_none) is None
assert get_thumbnail(bad_str) is None
assert get_thumbnail(bad_dict) is None
def test_thumbnail_preference(self):
"""Test that the thumbnail with highest preference is returned."""
thumbnails = [
{"url": "low.jpg", "preference": 1, "width": 100, "height": 100},
{"url": "high.jpg", "preference": 10, "width": 200, "height": 200},
{"url": "medium.jpg", "preference": 5, "width": 150, "height": 150},
]
result = get_thumbnail(thumbnails)
assert result == {"url": "high.jpg", "preference": 10, "width": 200, "height": 200}
def test_thumbnail_width(self):
"""Test that the thumbnail with highest width is returned when preference is equal."""
thumbnails = [
{"url": "small.jpg", "preference": 1, "width": 100, "height": 100},
{"url": "large.jpg", "preference": 1, "width": 200, "height": 200},
{"url": "medium.jpg", "preference": 1, "width": 150, "height": 150},
]
result = get_thumbnail(thumbnails)
assert result == {"url": "large.jpg", "preference": 1, "width": 200, "height": 200}
def test_missing_attrs(self):
"""Test that thumbnails with missing attributes are handled correctly."""
thumbnails = [
{"url": "no_pref.jpg", "width": 100},
{"url": "with_pref.jpg", "preference": 5, "width": 50},
]
result = get_thumbnail(thumbnails)
assert result is not None
assert result["url"] == "with_pref.jpg"
def test_all_equal(self):
"""Test that any thumbnail is returned when all attributes are equal."""
thumbnails = [
{"url": "first.jpg"},
{"url": "second.jpg"},
]
assert get_thumbnail(thumbnails) == {"url": "second.jpg"}
class TestGetExtras:
def test_none(self):
"""Test that empty dict is returned for None input."""
bad: Any = None
assert get_extras(bad) == {}
def test_non_dict(self):
"""Test that empty dict is returned for non-dict input."""
bad_str: Any = "not a dict"
bad_list: Any = []
assert get_extras(bad_str) == {}
assert get_extras(bad_list) == {}
def test_extracts_video_information(self):
"""Test extracting information from a video entry."""
entry = {
"id": "test123",
"title": "Test Video",
"uploader": "Test Uploader",
"channel": "Test Channel",
"thumbnails": [{"url": "thumb.jpg", "preference": 1}],
"duration": 120,
}
result = get_extras(entry, kind="video")
assert result["uploader"] == "Test Uploader"
assert result["channel"] == "Test Channel"
assert result["thumbnail"] == "thumb.jpg"
assert result["duration"] == 120
assert result["is_premiere"] is False
def test_extracts_playlist_information(self):
"""Test extracting information from a playlist entry."""
entry = {
"id": "playlist123",
"title": "Test Playlist",
"uploader": "Playlist Owner",
"uploader_id": "owner123",
}
result = get_extras(entry, kind="playlist")
assert result["playlist_id"] == "playlist123"
assert result["playlist_title"] == "Test Playlist"
assert result["playlist_uploader"] == "Playlist Owner"
assert result["playlist_uploader_id"] == "owner123"
def test_release_timestamp(self):
"""Test handling of release_timestamp for upcoming content."""
entry = {
"release_timestamp": 1234567890,
}
result = get_extras(entry)
assert "release_in" in result
assert result["release_in"] == "Fri, 13 Feb 2009 23:31:30 GMT"
def test_upcoming_live(self):
"""Test handling of upcoming live stream."""
entry = {
"release_timestamp": 1234567890,
"live_status": "is_upcoming",
}
result = get_extras(entry)
assert result["is_live"] == 1234567890
assert "release_in" in result
def test_premiere_flag(self):
"""Test handling of is_premiere flag."""
entry = {
"is_premiere": True,
}
result = get_extras(entry)
assert result["is_premiere"] is True
entry2 = {"is_premiere": False}
result2 = get_extras(entry2)
assert result2["is_premiere"] is False
def test_youtube_fallback_thumbnail(self):
"""Test fallback thumbnail generation for YouTube videos."""
entry = {
"id": "dQw4w9WgXcQ",
"ie_key": "Youtube",
}
result = get_extras(entry)
assert result["thumbnail"] == "https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg"
def test_thumbnail_string_fallback(self):
"""Test fallback to thumbnail string when thumbnails list not available."""
entry = {
"thumbnail": "https://example.com/thumb.jpg",
}
result = get_extras(entry)
assert result["thumbnail"] == "https://example.com/thumb.jpg"
class TestGetStaticYtdlp:
"""Test the get_static_ytdlp function."""
def setup_method(self):
"""Reset YTDLP singleton state before each test."""
from app.features.ytdlp.utils import _DATA
_DATA.YTDLP_INFO_CLS = None
def test_instance(self):
"""Test that get_static_ytdlp returns a YTDLP instance."""
from app.features.ytdlp.ytdlp import YTDLP
# Get the cached instance
instance = get_ytdlp()
assert instance is not None
assert isinstance(instance, YTDLP)
def test_get_static_ytdlp_same(self):
"""Test that get_static_ytdlp returns the same cached instance."""
instance1 = get_ytdlp()
instance2 = get_ytdlp()
assert instance1 is instance2
def test_get_static_ytdlp_with_params(self):
"""Test that get_static_ytdlp returns a new instance when params are provided."""
instance1 = get_ytdlp()
instance2 = get_ytdlp(params={"quiet": False})
assert instance1 is not instance2
assert instance2 is not None
def test_get_static_ytdlp_params(self):
"""Test that get_static_ytdlp initializes with correct parameters."""
instance = get_ytdlp()
# Access the internal params
params = instance.params
assert params.get("color") == "no_color"
assert params.get("extract_flat") is True
assert params.get("skip_download") is True
assert params.get("ignoreerrors") is True
assert params.get("ignore_no_formats_error") is True
assert params.get("quiet") is True
class TestGetArchiveId:
"""Test the get_archive_id function."""
@patch("app.features.ytdlp.utils._DATA.YTDLP_INFO_CLS")
def test_get_archive_id_basic(self, mock_ytdlp):
"""Test basic archive ID extraction."""
mock_ytdlp._ies = {}
result = get_archive_id("https://youtube.com/watch?v=test123")
assert isinstance(result, dict)
assert "id" in result
assert "ie_key" in result
assert "archive_id" in result
def test_get_archive_id_invalid_url(self):
"""Test with invalid URL."""
result = get_archive_id("invalid-url")
assert isinstance(result, dict)
class TestArchiveFunctions:
"""Test archive-related functions."""
def setup_method(self):
"""Set up test archive file."""
self.temp_dir = str(make_test_temp_dir("ytdlp-archive"))
self.archive_file = Path(self.temp_dir) / "archive.txt"
def teardown_method(self):
"""Clean up after tests."""
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_archive_add_and_read(self):
"""Test adding and reading archive entries."""
ids = ["youtube id1", "youtube id2", "youtube id3"]
result = archive_add(self.archive_file, ids)
assert result is True
read_ids = archive_read(self.archive_file)
assert set(read_ids) == set(ids)
def test_archive_delete(self):
"""Test deleting archive entries."""
archive_add(self.archive_file, ["youtube id1", "youtube id2", "youtube id3"])
delete_ids = ["youtube id2"]
result = archive_delete(self.archive_file, delete_ids)
assert result is True
assert set(archive_read(self.archive_file)) == {"youtube id1", "youtube id3"}
def test_archive_read_nonexistent(self):
"""Test reading from non-existent archive."""
nonexistent = Path(self.temp_dir) / "nonexistent.txt"
result = archive_read(nonexistent)
assert result == []