Merge pull request #602 from arabcoders/dev
Refactor: do not log httpx related as exceptions but errors.
This commit is contained in:
commit
e7772b361e
18 changed files with 282 additions and 169 deletions
14
FAQ.md
14
FAQ.md
|
|
@ -665,30 +665,24 @@ YTP_BROWSER_URL=playwright+cdp://chrome:9222/
|
|||
|
||||
## yt-dlp usage
|
||||
|
||||
To enable the extractor for a preset or task, add the following to the `Command options for yt-dlp` field:
|
||||
|
||||
```bash
|
||||
--use-extractors "browser,default"
|
||||
```
|
||||
|
||||
If you want to set the browser extractor options directly on the yt-dlp side, you can also use `--extractor-args` with `browser:url=...`:
|
||||
If you want to set the browser extractor options directly on the yt-dlp side, you can also use `--extractor-args` with `generic:url=...`:
|
||||
|
||||
### Selenium example
|
||||
|
||||
```bash
|
||||
--use-extractors "browser,default" --extractor-args "browser:url=selenium+http://selenium:4444/wd/hub"
|
||||
--use-extractors "generic" --extractor-args "generic:url=selenium+http://selenium:4444/wd/hub"
|
||||
```
|
||||
|
||||
### Playwright example
|
||||
|
||||
```bash
|
||||
--use-extractors "browser,default" --extractor-args "browser:url=playwright+ws://playwright:3000/"
|
||||
--use-extractors "generic" --extractor-args "generic:url=playwright+ws://playwright:3000/"
|
||||
```
|
||||
|
||||
### Playwright CDP example
|
||||
|
||||
```bash
|
||||
--use-extractors "browser,default" --extractor-args "browser:url=playwright+cdp://chrome:9222/"
|
||||
--use-extractors "generic" --extractor-args "generic:url=playwright+cdp://chrome:9222/"
|
||||
```
|
||||
|
||||
The explicit `--extractor-args` value takes priority over `YTP_BROWSER_URL`.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from collections.abc import Mapping
|
|||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
import jmespath
|
||||
from parsel import Selector
|
||||
|
||||
|
|
@ -31,7 +32,6 @@ from ._base_handler import BaseHandler
|
|||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from parsel.selector import SelectorList
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("handlers.generic")
|
||||
|
|
@ -140,6 +140,8 @@ class GenericTaskHandler(BaseHandler):
|
|||
body_text, json_data = await GenericTaskHandler._fetch_content(
|
||||
url=target_url, definition=definition, ytdlp_opts=ytdlp_opts
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return TaskFailure(message="Failed to fetch target URL.", error=str(exc))
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
return TaskFailure(message="Failed to fetch target URL.", error=str(exc))
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import re
|
|||
from typing import TYPE_CHECKING, Any
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
import httpx
|
||||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.ytdlp.extractor import fetch_info
|
||||
from app.features.ytdlp.utils import get_archive_id
|
||||
|
|
@ -151,6 +153,8 @@ class RssGenericHandler(BaseHandler):
|
|||
|
||||
try:
|
||||
feed_url, items, real_count = await RssGenericHandler._get(task, params, parsed)
|
||||
except httpx.HTTPError as exc:
|
||||
return TaskFailure(message="Failed to fetch RSS/Atom feed.", error=str(exc))
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
return TaskFailure(message="Failed to fetch RSS/Atom feed.", error=str(exc))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.ytdlp.utils import get_archive_id
|
||||
|
||||
|
|
@ -151,6 +153,8 @@ class TverHandler(BaseHandler):
|
|||
|
||||
try:
|
||||
feed_url, items, has_items = await TverHandler._collect_feed(task, params, series_id)
|
||||
except httpx.HTTPError as exc:
|
||||
return TaskFailure(message="Failed to fetch Tver feed.", error=str(exc))
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
return TaskFailure(message="Failed to fetch Tver feed.", error=str(exc))
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import re
|
|||
from typing import TYPE_CHECKING
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
import httpx
|
||||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.ytdlp.utils import get_archive_id
|
||||
|
||||
|
|
@ -83,6 +85,8 @@ class TwitchHandler(BaseHandler):
|
|||
|
||||
try:
|
||||
feed_url, items, has_items = await TwitchHandler._collect_feed(task, params, handle_name)
|
||||
except httpx.HTTPError as exc:
|
||||
return TaskFailure(message="Failed to fetch Twitch feed.", error=str(exc))
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
return TaskFailure(message="Failed to fetch Twitch feed.", error=str(exc))
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import re
|
|||
from typing import TYPE_CHECKING, Any
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
import httpx
|
||||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.ytdlp.utils import get_archive_id
|
||||
|
||||
|
|
@ -98,6 +100,8 @@ class YoutubeHandler(BaseHandler):
|
|||
|
||||
try:
|
||||
feed_url, items, real_count = await YoutubeHandler._get(task, params, parsed)
|
||||
except httpx.HTTPError as exc:
|
||||
return TaskFailure(message="Failed to fetch YouTube feed.", error=str(exc))
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
return TaskFailure(message="Failed to fetch YouTube feed.", error=str(exc))
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ class TaskHandle:
|
|||
s: dict[str, list[str]] = {"h": [], "d": [], "u": [], "f": []}
|
||||
|
||||
handler_groups: dict[str, list[tuple[HandleTask, type]]] = {}
|
||||
dispatches: list[tuple[HandleTask, type, asyncio.Task[TaskResult | TaskFailure | None]]] = []
|
||||
|
||||
tasks: list[TaskModel] = await self._repo.list()
|
||||
|
||||
|
|
@ -95,7 +96,6 @@ class TaskHandle:
|
|||
if handler_name not in handler_groups:
|
||||
handler_groups[handler_name] = []
|
||||
handler_groups[handler_name].append((task, handler))
|
||||
s["h"].append(task.name)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to handle task '{task.name}'. '{e!s}'.")
|
||||
s["f"].append(task.name)
|
||||
|
|
@ -112,8 +112,27 @@ class TaskHandle:
|
|||
name=f"taskHandler-{task.id}",
|
||||
)
|
||||
t.add_done_callback(lambda fut, t=task: self._handle_exception(fut, t))
|
||||
dispatches.append((task, handler, t))
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to dispatch task '{task.name}'. '{e!s}'.")
|
||||
s["f"].append(task.name)
|
||||
|
||||
if dispatches:
|
||||
results = await asyncio.gather(*(t for _, _, t in dispatches), return_exceptions=True)
|
||||
|
||||
for (task, handler, _), result in zip(dispatches, results, strict=True):
|
||||
if isinstance(result, TaskResult):
|
||||
s["h"].append(task.name)
|
||||
continue
|
||||
|
||||
if isinstance(result, TaskFailure):
|
||||
s["f"].append(task.name)
|
||||
continue
|
||||
|
||||
if result is None:
|
||||
LOG.error(f"Handler '{handler.__name__}' returned no result for task '{task.name}'.")
|
||||
|
||||
s["f"].append(task.name)
|
||||
|
||||
if len(tasks) > 0:
|
||||
LOG.info(
|
||||
|
|
@ -193,7 +212,11 @@ class TaskHandle:
|
|||
raise
|
||||
|
||||
if isinstance(extraction, TaskFailure):
|
||||
LOG.error(f"Handler '{handler.__name__}' failed to extract items: {extraction.message}")
|
||||
msg: str = extraction.message
|
||||
if extraction.error and extraction.error != extraction.message:
|
||||
msg = f"{msg} {extraction.error}"
|
||||
|
||||
LOG.error(f"Handler '{handler.__name__}' failed to extract items for task '{task.name}': {msg}")
|
||||
return extraction
|
||||
|
||||
if not isinstance(extraction, TaskResult):
|
||||
|
|
@ -226,7 +249,7 @@ class TaskHandle:
|
|||
|
||||
for item in raw_items:
|
||||
if not isinstance(item, TaskItem):
|
||||
LOG.warning("Handler '{handler.__name__}' produced non-TaskItem entry: {item!r}")
|
||||
LOG.warning(f"Handler '{handler.__name__}' returned unexpected result: {item!r}")
|
||||
continue
|
||||
|
||||
url: str = item.url
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.tasks.definitions.handlers.rss import RssGenericHandler
|
||||
|
|
@ -21,6 +23,10 @@ class DummyOpts:
|
|||
return self._data
|
||||
|
||||
|
||||
def _opts(tmp_path: Path) -> DummyOpts:
|
||||
return DummyOpts({"download_archive": str(tmp_path / "archive.txt")})
|
||||
|
||||
|
||||
class TestRssHandlerParsing:
|
||||
"""Test URL parsing for RSS/Atom feeds using the tests() method."""
|
||||
|
||||
|
|
@ -47,7 +53,7 @@ class TestRssHandlerExtraction:
|
|||
"""Test RSS feed extraction and parsing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rss_atom_feed_extraction(self, monkeypatch):
|
||||
async def test_rss_atom_feed_extraction(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Test extraction from Atom feed."""
|
||||
atom_feed = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
|
|
@ -69,7 +75,7 @@ class TestRssHandlerExtraction:
|
|||
return DummyResponse(atom_feed)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
|
||||
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
|
|
@ -89,7 +95,7 @@ class TestRssHandlerExtraction:
|
|||
assert result.metadata["entry_count"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rss_feed_extraction(self, monkeypatch):
|
||||
async def test_rss_feed_extraction(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Test extraction from RSS feed."""
|
||||
rss_feed = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
|
|
@ -113,7 +119,7 @@ class TestRssHandlerExtraction:
|
|||
return DummyResponse(rss_feed)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
|
||||
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
|
|
@ -133,7 +139,7 @@ class TestRssHandlerExtraction:
|
|||
assert result.metadata["entry_count"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_can_handle(self, monkeypatch):
|
||||
async def test_can_handle(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Test can_handle method."""
|
||||
atom_feed = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
|
|
@ -147,7 +153,7 @@ class TestRssHandlerExtraction:
|
|||
return DummyResponse(atom_feed)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
|
||||
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
|
|
@ -172,7 +178,7 @@ class TestRssHandlerEdgeCases:
|
|||
"""Test edge cases in RSS handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_feed(self, monkeypatch):
|
||||
async def test_empty_feed(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Test handling of empty feed."""
|
||||
empty_feed = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
|
|
@ -186,7 +192,7 @@ class TestRssHandlerEdgeCases:
|
|||
return DummyResponse(empty_feed)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
|
||||
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
|
|
@ -202,7 +208,7 @@ class TestRssHandlerEdgeCases:
|
|||
assert result.metadata["entry_count"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_feed_url(self, monkeypatch):
|
||||
async def test_invalid_feed_url(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Test handling of invalid feed URL."""
|
||||
from app.features.tasks.definitions.results import TaskFailure
|
||||
|
||||
|
|
@ -211,7 +217,7 @@ class TestRssHandlerEdgeCases:
|
|||
raise Exception(msg)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
|
||||
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
|
|
@ -225,7 +231,7 @@ class TestRssHandlerEdgeCases:
|
|||
assert isinstance(result, TaskFailure)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_urls_in_feed(self, monkeypatch):
|
||||
async def test_missing_urls_in_feed(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Test handling of entries missing URLs."""
|
||||
feed = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
|
|
@ -245,7 +251,7 @@ class TestRssHandlerEdgeCases:
|
|||
return DummyResponse(feed)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: _opts(tmp_path)) # noqa: ARG005
|
||||
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.tasks.definitions.handlers.tver import TverHandler
|
||||
|
|
@ -25,7 +27,7 @@ class DummyOpts:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tver_handler_extract(monkeypatch):
|
||||
async def test_tver_handler_extract(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Test tver handler extraction of episodes from series."""
|
||||
session_response = {
|
||||
"result": {
|
||||
|
|
@ -118,8 +120,10 @@ async def test_tver_handler_extract(monkeypatch):
|
|||
msg = f"Unexpected URL: {url}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
archive_path = tmp_path / "archive.txt"
|
||||
|
||||
monkeypatch.setattr(TverHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda _: DummyOpts({"download_archive": "/tmp/archive"}))
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda _: DummyOpts({"download_archive": str(archive_path)}))
|
||||
|
||||
task = HandleTask(id=1, name="Test Tver Series", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
||||
|
||||
|
|
@ -142,7 +146,7 @@ async def test_tver_handler_extract(monkeypatch):
|
|||
|
||||
|
||||
@pytest.mark.parametrize(("url", "should_match"), TverHandler.tests())
|
||||
def test_parse(url: str, should_match: bool):
|
||||
def test_parse(url: str, should_match: bool) -> None:
|
||||
"""Test tver URL parsing."""
|
||||
result = TverHandler.parse(url)
|
||||
if should_match:
|
||||
|
|
@ -153,7 +157,7 @@ def test_parse(url: str, should_match: bool):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_can_handle():
|
||||
async def test_can_handle() -> None:
|
||||
"""Test tver handler can_handle method."""
|
||||
task_valid = HandleTask(id=1, name="Test", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
||||
task_invalid = HandleTask(id=2, name="Test", url="https://youtube.com/watch?v=123", preset="default")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.tasks.definitions.handlers.twitch import TwitchHandler
|
||||
|
|
@ -22,7 +24,7 @@ class DummyOpts:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_twitch_handler_inspect(monkeypatch):
|
||||
async def test_twitch_handler_inspect(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
feed = """
|
||||
<rss><channel>
|
||||
<item>
|
||||
|
|
@ -39,8 +41,10 @@ async def test_twitch_handler_inspect(monkeypatch):
|
|||
async def fake_request(**kwargs): # noqa: ARG001
|
||||
return DummyResponse(feed)
|
||||
|
||||
archive_path = tmp_path / "archive.txt"
|
||||
|
||||
monkeypatch.setattr(TwitchHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": str(archive_path)})) # noqa: ARG005
|
||||
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.tasks.definitions.handlers.youtube import YoutubeHandler
|
||||
|
|
@ -22,7 +24,7 @@ class DummyOpts:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_youtube_handler_inspect(monkeypatch):
|
||||
async def test_youtube_handler_inspect(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
feed = """
|
||||
<feed xmlns='http://www.w3.org/2005/Atom' xmlns:yt='http://www.youtube.com/xml/schemas/2015'>
|
||||
<entry>
|
||||
|
|
@ -41,8 +43,10 @@ async def test_youtube_handler_inspect(monkeypatch):
|
|||
async def fake_request(**kwargs): # noqa: ARG001
|
||||
return DummyResponse(feed)
|
||||
|
||||
archive_path = tmp_path / "archive.txt"
|
||||
|
||||
monkeypatch.setattr(YoutubeHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": str(archive_path)})) # noqa: ARG005
|
||||
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import importlib
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -10,8 +11,12 @@ from app.features.ytdlp.utils import _DATA
|
|||
from app.features.ytdlp.ytdlp import YTDLP, _ArchiveProxy, ytdlp_options
|
||||
|
||||
|
||||
def _archive_path(tmp_path: Path) -> str:
|
||||
return str(tmp_path / "archive.txt")
|
||||
|
||||
|
||||
class TestArchiveProxy:
|
||||
def test_bool_and_falsey_cases(self) -> None:
|
||||
def test_bool_and_falsey_cases(self, tmp_path: Path) -> None:
|
||||
# No file path means proxy is falsey and operations return False
|
||||
p = _ArchiveProxy(file=None)
|
||||
assert bool(p) is False
|
||||
|
|
@ -19,22 +24,24 @@ class TestArchiveProxy:
|
|||
assert p.add("id") is False
|
||||
|
||||
# Empty item also returns False
|
||||
p2 = _ArchiveProxy(file="/tmp/archive.txt")
|
||||
p2 = _ArchiveProxy(file=_archive_path(tmp_path))
|
||||
assert bool(p2) is True
|
||||
assert ("" in p2) is False
|
||||
assert p2.add("") is False
|
||||
|
||||
@patch("app.features.ytdlp.archiver.Archiver.get_instance")
|
||||
def test_delegates_to_archiver(self, mock_get_instance) -> None:
|
||||
def test_delegates_to_archiver(self, mock_get_instance, tmp_path: Path) -> None:
|
||||
arch = MagicMock()
|
||||
mock_get_instance.return_value = arch
|
||||
|
||||
p = _ArchiveProxy(file="/tmp/archive.txt")
|
||||
file = _archive_path(tmp_path)
|
||||
|
||||
p = _ArchiveProxy(file=file)
|
||||
|
||||
# contains -> read(file, [item]) and check membership
|
||||
arch.read.return_value = ["abc"]
|
||||
assert ("abc" in p) is True
|
||||
arch.read.assert_called_with("/tmp/archive.txt", ["abc"])
|
||||
arch.read.assert_called_with(file, ["abc"])
|
||||
|
||||
arch.read.return_value = []
|
||||
assert ("xyz" in p) is False
|
||||
|
|
@ -42,7 +49,7 @@ class TestArchiveProxy:
|
|||
# add -> add(file, [item]) returns boolean
|
||||
arch.add.return_value = True
|
||||
assert p.add("abc") is True
|
||||
arch.add.assert_called_with("/tmp/archive.txt", ["abc"])
|
||||
arch.add.assert_called_with(file, ["abc"])
|
||||
|
||||
arch.add.return_value = False
|
||||
assert p.add("xyz") is False
|
||||
|
|
@ -96,11 +103,12 @@ class TestYTDLP:
|
|||
return ytdlp
|
||||
|
||||
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
|
||||
def test_init_archive_param(self, mock_super_init) -> None:
|
||||
def test_init_archive_param(self, mock_super_init, tmp_path: Path) -> None:
|
||||
"""Test that __init__ removes download_archive before calling super, then restores it."""
|
||||
mock_super_init.return_value = None
|
||||
|
||||
params = {"download_archive": "/tmp/archive.txt", "quiet": True}
|
||||
file = _archive_path(tmp_path)
|
||||
params = {"download_archive": file, "quiet": True}
|
||||
|
||||
ytdlp = YTDLP(params=params, auto_init=True)
|
||||
# Set params as it would be after super().__init__
|
||||
|
|
@ -114,13 +122,13 @@ class TestYTDLP:
|
|||
assert call_kwargs["auto_init"] is True
|
||||
|
||||
# Our __init__ code manually sets these after super()
|
||||
ytdlp.params["download_archive"] = "/tmp/archive.txt"
|
||||
ytdlp.params["download_archive"] = file
|
||||
|
||||
assert ytdlp.params["download_archive"] == "/tmp/archive.txt", "Verify download_archive was restored to params"
|
||||
assert ytdlp.params["download_archive"] == file, "Verify download_archive was restored to params"
|
||||
|
||||
# Verify archive proxy was set up
|
||||
assert isinstance(ytdlp.archive, _ArchiveProxy)
|
||||
assert ytdlp.archive._file == "/tmp/archive.txt"
|
||||
assert ytdlp.archive._file == file
|
||||
|
||||
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
|
||||
def test_init_no_archive(self, mock_super_init) -> None:
|
||||
|
|
@ -251,9 +259,9 @@ class TestYTDLP:
|
|||
# Should not interact with archive
|
||||
ytdlp.archive.add.assert_not_called()
|
||||
|
||||
def test_record_archive_adds_id(self) -> None:
|
||||
def test_record_archive_adds_id(self, tmp_path: Path) -> None:
|
||||
"""Test record_download_archive adds the archive ID."""
|
||||
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
|
||||
ytdlp = self._create_ytdlp(params={"download_archive": _archive_path(tmp_path)})
|
||||
ytdlp.write_debug = Mock()
|
||||
ytdlp.archive = Mock()
|
||||
ytdlp._make_archive_id = Mock(return_value="youtube test123")
|
||||
|
|
@ -269,9 +277,9 @@ class TestYTDLP:
|
|||
ytdlp.archive.add.assert_called_once_with("youtube test123")
|
||||
ytdlp.write_debug.assert_called_with("Adding to archive: youtube test123")
|
||||
|
||||
def test_record_archive_old_ids(self) -> None:
|
||||
def test_record_archive_old_ids(self, tmp_path: Path) -> None:
|
||||
"""Test record_download_archive adds _old_archive_ids when present."""
|
||||
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
|
||||
ytdlp = self._create_ytdlp(params={"download_archive": _archive_path(tmp_path)})
|
||||
ytdlp.write_debug = Mock()
|
||||
ytdlp.archive = Mock()
|
||||
ytdlp._make_archive_id = Mock(return_value="youtube new123")
|
||||
|
|
@ -296,9 +304,9 @@ class TestYTDLP:
|
|||
# Should not add duplicate (youtube new123 appears only once)
|
||||
assert calls.count("youtube new123") == 1
|
||||
|
||||
def test_record_archive_empty_old_ids(self) -> None:
|
||||
def test_record_archive_empty_old_ids(self, tmp_path: Path) -> None:
|
||||
"""Test record_download_archive handles empty or invalid _old_archive_ids."""
|
||||
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
|
||||
ytdlp = self._create_ytdlp(params={"download_archive": _archive_path(tmp_path)})
|
||||
ytdlp.write_debug = Mock()
|
||||
ytdlp.archive = Mock()
|
||||
ytdlp._make_archive_id = Mock(return_value="youtube test123")
|
||||
|
|
@ -322,9 +330,9 @@ class TestYTDLP:
|
|||
ytdlp.record_download_archive(info_dict)
|
||||
assert ytdlp.archive.add.call_count == 1 # Only main ID
|
||||
|
||||
def test_record_archive_empty_id(self) -> None:
|
||||
def test_record_archive_empty_id(self, tmp_path: Path) -> None:
|
||||
"""Test record_download_archive returns early when _make_archive_id returns empty."""
|
||||
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
|
||||
ytdlp = self._create_ytdlp(params={"download_archive": _archive_path(tmp_path)})
|
||||
ytdlp.archive = Mock()
|
||||
ytdlp._make_archive_id = Mock(return_value=None)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Tests for YTDLPOpts class."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -912,7 +913,7 @@ class TestYTDLPCli:
|
|||
@patch("app.features.presets.service.Presets")
|
||||
@patch("app.features.ytdlp.ytdlp_opts.create_cookies_file")
|
||||
@patch("app.features.ytdlp.ytdlp_opts.Config")
|
||||
def test_build_with_cookies_from_user(self, mock_config, mock_create_cookies, mock_presets):
|
||||
def test_build_with_cookies_from_user(self, mock_config, mock_create_cookies, mock_presets, tmp_path: Path):
|
||||
"""Test build with cookies from user."""
|
||||
from app.library.ItemDTO import Item
|
||||
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
||||
|
|
@ -920,11 +921,13 @@ class TestYTDLPCli:
|
|||
mock_config_instance = Mock()
|
||||
mock_config_instance.download_path = "/downloads"
|
||||
mock_config_instance.output_template = "%(title)s.%(ext)s"
|
||||
mock_config_instance.temp_path = str(tmp_path)
|
||||
mock_config_instance.get_replacers.return_value = {}
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
|
||||
mock_cookie_path = Mock()
|
||||
mock_cookie_path.__str__ = Mock(return_value="/tmp/cookies.txt")
|
||||
cookie_path = tmp_path / "cookies.txt"
|
||||
mock_cookie_path.__str__ = Mock(return_value=str(cookie_path))
|
||||
mock_create_cookies.return_value = mock_cookie_path
|
||||
|
||||
mock_presets.get_instance.return_value.get.return_value = None
|
||||
|
|
@ -936,8 +939,8 @@ class TestYTDLPCli:
|
|||
|
||||
mock_create_cookies.assert_called_once_with("session_id=abc123")
|
||||
assert "--cookies" in command
|
||||
assert "/tmp/cookies.txt" in command
|
||||
assert info["merged"]["cookie_file"] == "/tmp/cookies.txt"
|
||||
assert str(cookie_path) in command
|
||||
assert info["merged"]["cookie_file"] == str(cookie_path)
|
||||
|
||||
@patch("app.features.presets.service.Presets")
|
||||
@patch("app.features.ytdlp.ytdlp_opts.Config")
|
||||
|
|
@ -950,6 +953,7 @@ class TestYTDLPCli:
|
|||
mock_config_instance = Mock()
|
||||
mock_config_instance.download_path = "/downloads"
|
||||
mock_config_instance.output_template = "%(title)s.%(ext)s"
|
||||
mock_config_instance.temp_path = "/tmp"
|
||||
mock_config_instance.get_replacers.return_value = {}
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
|
|
@ -67,9 +68,9 @@ class TestConditionIgnoreMatching:
|
|||
|
||||
class TestConditionIgnorePropagation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_passes_ignore(self) -> None:
|
||||
async def test_add_passes_ignore(self, tmp_path: Path) -> None:
|
||||
queue = Mock()
|
||||
queue.config = Mock(temp_path="/tmp", ignore_archived_items=False, ytdlp_debug=False)
|
||||
queue.config = Mock(temp_path=str(tmp_path), ignore_archived_items=False, ytdlp_debug=False)
|
||||
queue._notify = Mock()
|
||||
|
||||
item = Item(
|
||||
|
|
@ -102,9 +103,9 @@ class TestConditionIgnorePropagation:
|
|||
assert result == {"status": "ok"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_coerces_ignore(self) -> None:
|
||||
async def test_add_coerces_ignore(self, tmp_path: Path) -> None:
|
||||
queue = Mock()
|
||||
queue.config = Mock(temp_path="/tmp", ignore_archived_items=False, ytdlp_debug=False)
|
||||
queue.config = Mock(temp_path=str(tmp_path), ignore_archived_items=False, ytdlp_debug=False)
|
||||
queue._notify = Mock()
|
||||
|
||||
item = Item(
|
||||
|
|
|
|||
|
|
@ -44,6 +44,15 @@ def test_cfg_arg_wins(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
assert ie._get_config("url", "YTP_BROWSER_URL") == "selenium+http://arg:4444/wd/hub"
|
||||
|
||||
|
||||
def test_safe_url() -> None:
|
||||
ie = _make_ie()
|
||||
|
||||
assert (
|
||||
ie._safe_url("playwright+ws://user:pass@10.0.0.6:9222/chrome/playwright?token=abc#frag")
|
||||
== "playwright+ws://***:***@10.0.0.6:9222/chrome/playwright?***#***"
|
||||
)
|
||||
|
||||
|
||||
def test_real_extract_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ie = _make_ie()
|
||||
|
||||
|
|
@ -62,7 +71,7 @@ def test_real_extract_invalid_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
ie._real_extract("https://example.com/watch")
|
||||
|
||||
|
||||
def test_real_extract_logs_connect_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_log_connect_fail(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ie = _make_ie()
|
||||
ie.__wrapped__ = Mock()
|
||||
ie.__wrapped__._real_extract = Mock(return_value={"id": "fallback"})
|
||||
|
|
@ -87,9 +96,9 @@ def test_real_extract_logs_connect_failure(monkeypatch: pytest.MonkeyPatch) -> N
|
|||
ie.to_screen.assert_called_once_with("Using remote browser for https://example.com/watch")
|
||||
|
||||
|
||||
def test_real_extract_logs_session_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_log_session_fail(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ie = _make_ie()
|
||||
monkeypatch.setenv("YTP_BROWSER_URL", "playwright+ws://browser")
|
||||
monkeypatch.setenv("YTP_BROWSER_URL", "playwright+ws://browser/path?token=secret")
|
||||
|
||||
session = Mock()
|
||||
session.goto.side_effect = RuntimeError("page crashed")
|
||||
|
|
@ -110,15 +119,45 @@ def test_real_extract_logs_session_failure(monkeypatch: pytest.MonkeyPatch) -> N
|
|||
ie.report_warning.assert_called_once_with(
|
||||
"Browser extractor session failed for url=%r browser_url=%r driver=%s error=%s",
|
||||
"https://example.com/watch",
|
||||
"playwright+ws://browser",
|
||||
"playwright+ws://browser/path?***",
|
||||
"FakeDriver",
|
||||
session.goto.side_effect,
|
||||
)
|
||||
ie.write_debug.assert_any_call("Selected driver FakeDriver for playwright+ws://browser")
|
||||
ie.write_debug.assert_any_call("Selected driver FakeDriver for playwright+ws://browser/path?***")
|
||||
ie.write_debug.assert_any_call("Loading page https://example.com/watch")
|
||||
|
||||
|
||||
def test_real_extract_logs_non_html_page(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_log_close_fail(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ie = _make_ie()
|
||||
monkeypatch.setenv("YTP_BROWSER_URL", "playwright+ws://browser/path?token=secret")
|
||||
|
||||
session = Mock()
|
||||
session.content.return_value = ""
|
||||
session.get_requests.return_value = []
|
||||
session.get_media_requests.return_value = []
|
||||
session.close.side_effect = RuntimeError("close failed")
|
||||
|
||||
class FakeDriver:
|
||||
__name__ = "FakeDriver"
|
||||
|
||||
@staticmethod
|
||||
def connect(ws_url: str, timeout: int | None = None):
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(generic_browser.GenericBrowserIE, "_select_driver", lambda self, ws_url: FakeDriver)
|
||||
|
||||
ie._real_extract("https://example.com/watch")
|
||||
|
||||
ie.report_warning.assert_called_once_with(
|
||||
"Browser session close failed for url=%r browser_url=%r driver=%s error=%s",
|
||||
"https://example.com/watch",
|
||||
"playwright+ws://browser/path?***",
|
||||
"FakeDriver",
|
||||
session.close.side_effect,
|
||||
)
|
||||
|
||||
|
||||
def test_log_non_html(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ie = _make_ie()
|
||||
monkeypatch.setenv("YTP_BROWSER_URL", "playwright+ws://browser")
|
||||
|
||||
|
|
@ -145,7 +184,39 @@ def test_real_extract_logs_non_html_page(monkeypatch: pytest.MonkeyPatch) -> Non
|
|||
ie.write_debug.assert_any_call("plain text body")
|
||||
|
||||
|
||||
def test_extract_network_formats_logs_no_media() -> None:
|
||||
def test_html_meta(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ie = _make_ie()
|
||||
monkeypatch.setenv("YTP_BROWSER_URL", "playwright+ws://browser")
|
||||
ie._looks_like_html = lambda webpage: True
|
||||
ie._generic_title = generic_browser.GenericIE._generic_title.__get__(ie, generic_browser.GenericBrowserIE)
|
||||
|
||||
session = Mock()
|
||||
session.content.return_value = (
|
||||
'<html><head><meta property="og:title" content="OG Title">'
|
||||
'<meta name="description" content="Meta Desc">'
|
||||
'<meta property="og:image" content="https://img.example/thumb.jpg">'
|
||||
"</head><body><title>Page Title</title></body></html>"
|
||||
)
|
||||
session.get_requests.return_value = []
|
||||
session.get_media_requests.return_value = []
|
||||
|
||||
class FakeDriver:
|
||||
__name__ = "FakeDriver"
|
||||
|
||||
@staticmethod
|
||||
def connect(ws_url: str, timeout: int | None = None):
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(generic_browser.GenericBrowserIE, "_select_driver", lambda self, ws_url: FakeDriver)
|
||||
|
||||
result = ie._real_extract("https://example.com/watch")
|
||||
|
||||
assert result["title"] == "OG Title"
|
||||
assert result["description"] == "Meta Desc"
|
||||
assert result["thumbnail"] == "https://img.example/thumb.jpg"
|
||||
|
||||
|
||||
def test_no_media() -> None:
|
||||
ie = _make_ie()
|
||||
ie.__wrapped__ = Mock()
|
||||
ie.__wrapped__._real_extract = Mock(return_value={"id": "fallback"})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import pytest
|
|||
from app.library.ItemDTO import Item, ItemDTO
|
||||
|
||||
|
||||
def _archive_path(tmp_path: Path) -> str:
|
||||
return str(tmp_path / "archive.txt")
|
||||
|
||||
|
||||
class TestItemFormatAndBasics:
|
||||
@patch("app.features.presets.service.Presets.get_instance")
|
||||
def test_format_validates_and_normalizes(self, mock_presets_get):
|
||||
|
|
@ -71,8 +75,9 @@ class TestItemFormatAndBasics:
|
|||
assert json.loads(item.json())["url"] == "https://example.com"
|
||||
|
||||
@patch("app.library.ItemDTO.get_archive_id")
|
||||
def test_item_archive_id_and_is_archived(self, mock_get_id):
|
||||
def test_item_archive_id_and_is_archived(self, mock_get_id, tmp_path: Path):
|
||||
mock_get_id.return_value = {"archive_id": "x", "id": "x", "ie_key": "k"}
|
||||
file = _archive_path(tmp_path)
|
||||
|
||||
# get_archive_id
|
||||
item = Item(url="https://example.com")
|
||||
|
|
@ -85,7 +90,7 @@ class TestItemFormatAndBasics:
|
|||
):
|
||||
mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value
|
||||
mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value
|
||||
mock_opts.get_instance.return_value.get_all.return_value = {"download_archive": "/tmp/archive.txt"}
|
||||
mock_opts.get_instance.return_value.get_all.return_value = {"download_archive": file}
|
||||
mock_read.return_value = ["x"]
|
||||
|
||||
assert item.is_archived() is True
|
||||
|
|
@ -95,30 +100,32 @@ class TestItemDTO:
|
|||
@patch("app.library.ItemDTO.get_archive_id")
|
||||
@patch("app.library.ItemDTO.YTDLPOpts")
|
||||
@patch("app.library.ItemDTO.archive_read")
|
||||
def test_post_init_sets_archive_flags(self, mock_read, mock_opts, mock_get_id):
|
||||
def test_post_init_sets_archive_flags(self, mock_read, mock_opts, mock_get_id, tmp_path: Path):
|
||||
# Setup archive id and archive file
|
||||
mock_get_id.return_value = {"archive_id": "arch", "id": "arch", "ie_key": "YT"}
|
||||
mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value
|
||||
mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value
|
||||
mock_opts.get_instance.return_value.get_all.return_value = {"download_archive": "/tmp/a.txt"}
|
||||
file = _archive_path(tmp_path)
|
||||
mock_opts.get_instance.return_value.get_all.return_value = {"download_archive": file}
|
||||
mock_read.return_value = ["arch"]
|
||||
|
||||
dto = ItemDTO(id="vid", title="t", url="u", folder="f")
|
||||
|
||||
assert dto.archive_id == "arch"
|
||||
assert dto._archive_file == "/tmp/a.txt"
|
||||
assert dto._archive_file == file
|
||||
assert dto.is_archivable is True
|
||||
assert dto.is_archived is True
|
||||
|
||||
@patch("app.library.ItemDTO.get_archive_id")
|
||||
@patch("app.library.ItemDTO.YTDLPOpts")
|
||||
@patch("app.library.ItemDTO.archive_read")
|
||||
def test_post_init_keeps_skipped(self, mock_read, mock_opts, mock_get_id):
|
||||
def test_post_init_keeps_skipped(self, mock_read, mock_opts, mock_get_id, tmp_path: Path):
|
||||
mock_get_id.return_value = {"archive_id": "arch", "id": "arch", "ie_key": "YT"}
|
||||
mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value
|
||||
mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value
|
||||
file = _archive_path(tmp_path)
|
||||
mock_opts.get_instance.return_value.get_all.return_value = {
|
||||
"download_archive": "/tmp/a.txt",
|
||||
"download_archive": file,
|
||||
"skip_download": True,
|
||||
}
|
||||
mock_read.return_value = ["arch"]
|
||||
|
|
@ -128,11 +135,11 @@ class TestItemDTO:
|
|||
assert dto.download_skipped is False
|
||||
|
||||
@patch("app.library.ItemDTO.archive_read")
|
||||
def test_serialize_archives_finished(self, mock_read):
|
||||
def test_serialize_archives_finished(self, mock_read, tmp_path: Path):
|
||||
# Given a finished item with archive info
|
||||
dto = ItemDTO(id="vid", title="t", url="u", folder="f")
|
||||
dto.archive_id = "arch"
|
||||
dto._archive_file = "/tmp/a.txt"
|
||||
dto._archive_file = _archive_path(tmp_path)
|
||||
dto.status = "finished"
|
||||
mock_read.return_value = ["arch"]
|
||||
|
||||
|
|
@ -186,7 +193,7 @@ class TestItemDTO:
|
|||
|
||||
# Set up to allow add
|
||||
dto.archive_id = "arch"
|
||||
dto._archive_file = "/tmp/a.txt"
|
||||
dto._archive_file = "var/tests/archive.txt"
|
||||
dto.is_archivable = True
|
||||
dto.is_archived = False
|
||||
|
||||
|
|
|
|||
|
|
@ -4,18 +4,8 @@ from pathlib import Path
|
|||
|
||||
from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP
|
||||
|
||||
TEST_DIR = Path("var/tmp/tests_nfo")
|
||||
TEST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _clean_file(path: Path) -> None:
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def sample_info_tv() -> dict:
|
||||
def sample_info_tv(path: Path) -> dict:
|
||||
return {
|
||||
"id": "abc123",
|
||||
"title": "Test Show - S01E02",
|
||||
|
|
@ -25,11 +15,11 @@ def sample_info_tv() -> dict:
|
|||
"episode_number": 2,
|
||||
"description": "First line.\n#tag\n00:01:23 Intro",
|
||||
"extractor": "youtube",
|
||||
"filename": str(TEST_DIR / "test_show_s01e02.mkv"),
|
||||
"filename": str(path),
|
||||
}
|
||||
|
||||
|
||||
def sample_info_movie() -> dict:
|
||||
def sample_info_movie(path: Path) -> dict:
|
||||
return {
|
||||
"id": "mov001",
|
||||
"title": "Test Movie",
|
||||
|
|
@ -38,60 +28,46 @@ def sample_info_movie() -> dict:
|
|||
"duration": 7200,
|
||||
"thumbnail": "http://example.com/thumb.jpg",
|
||||
"webpage_url": "http://example.com/trailer",
|
||||
"filename": str(TEST_DIR / "test_movie.mp4"),
|
||||
"filename": str(path),
|
||||
}
|
||||
|
||||
|
||||
def test_generate_nfo_tv_mode(tmp_path):
|
||||
# arrange
|
||||
TEST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
info = sample_info_tv()
|
||||
media_file = Path(info["filename"])
|
||||
media_file.write_text("dummy")
|
||||
def test_generate_nfo_tv_mode(tmp_path: Path) -> None:
|
||||
media_file = tmp_path / "test_show_s01e02.mkv"
|
||||
info = sample_info_tv(media_file)
|
||||
media_file.write_text("dummy", encoding="utf-8")
|
||||
nfo_path = media_file.with_suffix(".nfo")
|
||||
_clean_file(nfo_path)
|
||||
nfo_path.unlink(missing_ok=True)
|
||||
|
||||
# act
|
||||
res = NFOMakerPP.generate_nfo(info_dict=info, filepath=media_file, mode="tv", overwrite=True, prefix=True)
|
||||
|
||||
# assert
|
||||
assert res["success"] is True
|
||||
assert nfo_path.exists()
|
||||
|
||||
content = nfo_path.read_text(encoding="utf-8")
|
||||
assert "<episodedetails>" in content
|
||||
assert "<title>Test Show - S01E02</title>" in content
|
||||
# description cleaned (no timestamps / hashtags)
|
||||
assert "Intro" not in content
|
||||
assert "#tag" not in content
|
||||
|
||||
# also ensure PostProcessor.run wrapper produces the same result and syncs mtime
|
||||
_clean_file(nfo_path)
|
||||
nfo_path.unlink()
|
||||
pp = NFOMakerPP(None, mode="tv", prefix=True)
|
||||
media_mtime = media_file.stat().st_mtime
|
||||
_, info_out = pp.run(info.copy())
|
||||
pp.run(info.copy())
|
||||
assert nfo_path.exists()
|
||||
nfo_mtime = nfo_path.stat().st_mtime
|
||||
assert abs(nfo_mtime - media_mtime) < 2.0
|
||||
|
||||
# cleanup
|
||||
media_file.unlink()
|
||||
nfo_path.unlink()
|
||||
|
||||
|
||||
def test_generate_nfo_movie_mode_and_run_wrapper(tmp_path):
|
||||
# arrange
|
||||
TEST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
info = sample_info_movie()
|
||||
media_file = Path(info["filename"])
|
||||
media_file.write_text("dummy-movie")
|
||||
def test_generate_nfo_movie_mode_and_run_wrapper(tmp_path: Path) -> None:
|
||||
media_file = tmp_path / "test_movie.mp4"
|
||||
info = sample_info_movie(media_file)
|
||||
media_file.write_text("dummy-movie", encoding="utf-8")
|
||||
nfo_path = media_file.with_suffix(".nfo")
|
||||
_clean_file(nfo_path)
|
||||
nfo_path.unlink(missing_ok=True)
|
||||
|
||||
# act: generate_nfo static
|
||||
res = NFOMakerPP.generate_nfo(info_dict=info, filepath=media_file, mode="movie", overwrite=True, prefix=False)
|
||||
|
||||
# assert static helper
|
||||
assert res["success"] is True
|
||||
assert nfo_path.exists()
|
||||
content = nfo_path.read_text(encoding="utf-8")
|
||||
|
|
@ -99,26 +75,16 @@ def test_generate_nfo_movie_mode_and_run_wrapper(tmp_path):
|
|||
assert "<title>Test Movie</title>" in content
|
||||
assert "<runtime>7200</runtime>" in content or "<runtime>7200" in content
|
||||
|
||||
# cleanup nfo and re-run via PP.run wrapper
|
||||
nfo_path.unlink()
|
||||
|
||||
# call through instance run() to ensure wrapper works (mtime sync path exercised)
|
||||
pp = NFOMakerPP(None, mode="movie", prefix=False)
|
||||
# ensure file exists
|
||||
assert media_file.exists()
|
||||
|
||||
# capture media file mtime before run
|
||||
media_mtime = media_file.stat().st_mtime
|
||||
|
||||
_, info_out = pp.run(info.copy())
|
||||
pp.run(info.copy())
|
||||
|
||||
# run() should not raise and should produce nfo file
|
||||
assert nfo_path.exists()
|
||||
|
||||
# verify mtime was synced (allow small rounding differences)
|
||||
nfo_mtime = nfo_path.stat().st_mtime
|
||||
assert abs(nfo_mtime - media_mtime) < 2.0
|
||||
|
||||
# cleanup
|
||||
media_file.unlink()
|
||||
nfo_path.unlink()
|
||||
|
|
|
|||
|
|
@ -65,7 +65,10 @@ REQUEST_RESOURCE_TYPES: set[str] = {
|
|||
|
||||
API_RESOURCE_TYPES: set[str] = {"fetch", "xhr"}
|
||||
|
||||
_MEDIA_CANDIDATE_EXTS = [
|
||||
POST_MEDIA_POLL_INTERVAL_MS = 250
|
||||
POST_MEDIA_POLL_ATTEMPTS = 8
|
||||
|
||||
MEDIA_CANDIDATE_EXTS: list[str] = [
|
||||
"m3u8",
|
||||
"mpd",
|
||||
"mp4",
|
||||
|
|
@ -78,7 +81,7 @@ _MEDIA_CANDIDATE_EXTS = [
|
|||
"ogg",
|
||||
]
|
||||
|
||||
_MEDIA_ELEMENT_JS = """() => {
|
||||
MEDIA_ELEMENT_JS: str = """() => {
|
||||
const mediaUrls = [];
|
||||
const seen = new Set();
|
||||
const addUrl = (url, type) => {
|
||||
|
|
@ -98,7 +101,7 @@ _MEDIA_ELEMENT_JS = """() => {
|
|||
return mediaUrls;
|
||||
}"""
|
||||
|
||||
_PLAYWRIGHT_PREFIXES: tuple[str, ...] = (
|
||||
PLAYWRIGHT_PREFIXES: tuple[str, ...] = (
|
||||
"playwright+ws://",
|
||||
"playwright+wss://",
|
||||
"playwright+cdp://",
|
||||
|
|
@ -112,7 +115,7 @@ _PLAYWRIGHT_PREFIXES: tuple[str, ...] = (
|
|||
def _has_possible_media(requests_list: list[dict]) -> bool:
|
||||
for req in requests_list:
|
||||
url_lower = req.get("url", "").lower()
|
||||
for ext in _MEDIA_CANDIDATE_EXTS:
|
||||
for ext in MEDIA_CANDIDATE_EXTS:
|
||||
if f".{ext}" in url_lower or f".{ext}?" in url_lower:
|
||||
return True
|
||||
ct = (req.get("response", {}).get("headers", {}).get("content-type", "")).lower()
|
||||
|
|
@ -141,6 +144,12 @@ def _wait_for_network_idle(
|
|||
raise Exception(msg)
|
||||
return max(1, min(requested_ms, remaining_ms))
|
||||
|
||||
def wait_for_late_media() -> None:
|
||||
for _ in range(POST_MEDIA_POLL_ATTEMPTS):
|
||||
if _has_possible_media(requests_list):
|
||||
return
|
||||
time.sleep(bounded_timeout_ms(POST_MEDIA_POLL_INTERVAL_MS) / 1000)
|
||||
|
||||
wait_fn(bounded_timeout_ms(idle_timeout))
|
||||
|
||||
for _ in range(api_poll_attempts):
|
||||
|
|
@ -156,6 +165,7 @@ def _wait_for_network_idle(
|
|||
return
|
||||
|
||||
wait_for_media_fn(bounded_timeout_ms(10000))
|
||||
wait_for_late_media()
|
||||
|
||||
|
||||
def _build_media_requests(requests_list: list[dict], media_elements: list[dict]) -> list[dict]:
|
||||
|
|
@ -313,7 +323,7 @@ class PlaywrightDriver:
|
|||
return list(requests_list)
|
||||
|
||||
def get_media_requests(self) -> list[dict]:
|
||||
return _build_media_requests(requests_list, page.evaluate(_MEDIA_ELEMENT_JS))
|
||||
return _build_media_requests(requests_list, page.evaluate(MEDIA_ELEMENT_JS))
|
||||
|
||||
def close(self):
|
||||
context.close()
|
||||
|
|
@ -416,7 +426,7 @@ class SeleniumDriver:
|
|||
|
||||
def get_media_requests(self) -> list[dict]:
|
||||
try:
|
||||
return _build_media_requests(requests_list, driver.execute_script(_MEDIA_ELEMENT_JS))
|
||||
return _build_media_requests(requests_list, driver.execute_script(MEDIA_ELEMENT_JS))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
|
@ -442,12 +452,31 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
|||
|
||||
return value
|
||||
|
||||
def _safe_url(self, browser_url: str) -> str:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(browser_url)
|
||||
except Exception:
|
||||
return browser_url
|
||||
|
||||
netloc = parsed.netloc
|
||||
if parsed.username or parsed.password:
|
||||
host = parsed.hostname or ""
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
netloc = f"***:***@{host}" if host else "***:***"
|
||||
|
||||
query = "***" if parsed.query else ""
|
||||
fragment = "***" if parsed.fragment else ""
|
||||
return urllib.parse.urlunsplit((parsed.scheme, netloc, parsed.path, query, fragment))
|
||||
|
||||
def _real_extract(self, url: str) -> dict[str, Any]:
|
||||
self._url = url
|
||||
|
||||
if not (browser_url := self._get_config("url", "YTP_BROWSER_URL")) or self._failed:
|
||||
return self.__wrapped__._real_extract(self, url)
|
||||
|
||||
safe_url = self._safe_url(browser_url)
|
||||
|
||||
video_id: str = self._generic_id(url)
|
||||
timeout: int | None = self._get_timeout_ms()
|
||||
self.to_screen(f"Using remote browser for {url}")
|
||||
|
|
@ -459,7 +488,7 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
|||
)
|
||||
raise ExtractorError(msg)
|
||||
|
||||
self.write_debug(f"Selected driver {driver.__name__} for {browser_url}")
|
||||
self.write_debug(f"Selected driver {driver.__name__} for {safe_url}")
|
||||
|
||||
try:
|
||||
session = driver.connect(browser_url, timeout)
|
||||
|
|
@ -513,8 +542,10 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
|||
}
|
||||
|
||||
if webpage and self._looks_like_html(webpage):
|
||||
info_dict["title"] = self._get_title(webpage) or self._generic_title(url, webpage)
|
||||
info_dict["description"] = self._get_desc(webpage)
|
||||
info_dict["title"] = self._generic_title(url, webpage)
|
||||
info_dict["description"] = self._og_search_description(webpage, default=None) or self._html_search_meta(
|
||||
"description", webpage, default=None
|
||||
)
|
||||
thumbnail = self._og_search_thumbnail(webpage, default=None)
|
||||
if thumbnail:
|
||||
info_dict["thumbnail"] = thumbnail
|
||||
|
|
@ -534,7 +565,7 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
|||
self.report_warning(
|
||||
"Browser extractor session failed for url=%r browser_url=%r driver=%s error=%s",
|
||||
url,
|
||||
browser_url,
|
||||
safe_url,
|
||||
driver.__name__,
|
||||
e,
|
||||
)
|
||||
|
|
@ -546,7 +577,7 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
|||
self.report_warning(
|
||||
"Browser session close failed for url=%r browser_url=%r driver=%s error=%s",
|
||||
url,
|
||||
browser_url,
|
||||
safe_url,
|
||||
driver.__name__,
|
||||
e,
|
||||
)
|
||||
|
|
@ -555,7 +586,7 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
|||
if ws_url.startswith(("selenium+http://", "selenium+https://")):
|
||||
return SeleniumDriver if SeleniumDriver.is_available() else None
|
||||
|
||||
if ws_url.startswith(_PLAYWRIGHT_PREFIXES):
|
||||
if ws_url.startswith(PLAYWRIGHT_PREFIXES):
|
||||
return PlaywrightDriver if PlaywrightDriver.is_available() else None
|
||||
|
||||
msg = (
|
||||
|
|
@ -747,34 +778,6 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
|||
)
|
||||
)
|
||||
|
||||
def _get_title(self, webpage: str) -> str | None:
|
||||
return self._extract_from_html(
|
||||
webpage,
|
||||
[
|
||||
r"<title>([^<]+)</title>",
|
||||
r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\']+)["\']',
|
||||
r'<meta[^>]+name=["\']title["\'][^>]+content=["\']([^"\']+)["\']',
|
||||
r"<h1[^>]*>([^<]+)</h1>",
|
||||
],
|
||||
)
|
||||
|
||||
def _get_desc(self, webpage: str) -> str | None:
|
||||
return self._extract_from_html(
|
||||
webpage,
|
||||
[
|
||||
r'<meta[^>]+property=["\']og:description["\'][^>]+content=["\']([^"\']+)["\']',
|
||||
r'<meta[^>]+name=["\']description["\'][^>]+content=["\']([^"\']+)["\']',
|
||||
r'<p[^>]*class=["\']description["\'][^>]*>([^<]+)</p>',
|
||||
],
|
||||
)
|
||||
|
||||
def _extract_from_html(self, webpage: str, patterns: list[str]) -> str | None:
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, webpage, re.IGNORECASE)
|
||||
if match and match.group(1):
|
||||
return match.group(1).strip()
|
||||
return None
|
||||
|
||||
def _get_timeout_ms(self) -> int | None:
|
||||
socket_timeout = self._downloader.params.get("socket_timeout")
|
||||
if isinstance(socket_timeout, (int, float)) and socket_timeout > 0:
|
||||
|
|
|
|||
Loading…
Reference in a new issue