592 lines
19 KiB
Python
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 == []
|