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
|
## yt-dlp usage
|
||||||
|
|
||||||
To enable the extractor for a preset or task, add the following to the `Command options for yt-dlp` field:
|
If you want to set the browser extractor options directly on the yt-dlp side, you can also use `--extractor-args` with `generic:url=...`:
|
||||||
|
|
||||||
```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=...`:
|
|
||||||
|
|
||||||
### Selenium example
|
### Selenium example
|
||||||
|
|
||||||
```bash
|
```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
|
### Playwright example
|
||||||
|
|
||||||
```bash
|
```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
|
### Playwright CDP example
|
||||||
|
|
||||||
```bash
|
```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`.
|
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 typing import TYPE_CHECKING, Any
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import httpx
|
||||||
import jmespath
|
import jmespath
|
||||||
from parsel import Selector
|
from parsel import Selector
|
||||||
|
|
||||||
|
|
@ -31,7 +32,6 @@ from ._base_handler import BaseHandler
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
|
||||||
from parsel.selector import SelectorList
|
from parsel.selector import SelectorList
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger("handlers.generic")
|
LOG: logging.Logger = logging.getLogger("handlers.generic")
|
||||||
|
|
@ -140,6 +140,8 @@ class GenericTaskHandler(BaseHandler):
|
||||||
body_text, json_data = await GenericTaskHandler._fetch_content(
|
body_text, json_data = await GenericTaskHandler._fetch_content(
|
||||||
url=target_url, definition=definition, ytdlp_opts=ytdlp_opts
|
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:
|
except Exception as exc:
|
||||||
LOG.exception(exc)
|
LOG.exception(exc)
|
||||||
return TaskFailure(message="Failed to fetch target URL.", error=str(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 typing import TYPE_CHECKING, Any
|
||||||
from xml.etree.ElementTree import Element
|
from xml.etree.ElementTree import Element
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||||
from app.features.ytdlp.extractor import fetch_info
|
from app.features.ytdlp.extractor import fetch_info
|
||||||
from app.features.ytdlp.utils import get_archive_id
|
from app.features.ytdlp.utils import get_archive_id
|
||||||
|
|
@ -151,6 +153,8 @@ class RssGenericHandler(BaseHandler):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
feed_url, items, real_count = await RssGenericHandler._get(task, params, parsed)
|
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:
|
except Exception as exc:
|
||||||
LOG.exception(exc)
|
LOG.exception(exc)
|
||||||
return TaskFailure(message="Failed to fetch RSS/Atom feed.", error=str(exc))
|
return TaskFailure(message="Failed to fetch RSS/Atom feed.", error=str(exc))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||||
from app.features.ytdlp.utils import get_archive_id
|
from app.features.ytdlp.utils import get_archive_id
|
||||||
|
|
||||||
|
|
@ -151,6 +153,8 @@ class TverHandler(BaseHandler):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
feed_url, items, has_items = await TverHandler._collect_feed(task, params, series_id)
|
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:
|
except Exception as exc:
|
||||||
LOG.exception(exc)
|
LOG.exception(exc)
|
||||||
return TaskFailure(message="Failed to fetch Tver feed.", error=str(exc))
|
return TaskFailure(message="Failed to fetch Tver feed.", error=str(exc))
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import re
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from xml.etree.ElementTree import Element
|
from xml.etree.ElementTree import Element
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||||
from app.features.ytdlp.utils import get_archive_id
|
from app.features.ytdlp.utils import get_archive_id
|
||||||
|
|
||||||
|
|
@ -83,6 +85,8 @@ class TwitchHandler(BaseHandler):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
feed_url, items, has_items = await TwitchHandler._collect_feed(task, params, handle_name)
|
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:
|
except Exception as exc:
|
||||||
LOG.exception(exc)
|
LOG.exception(exc)
|
||||||
return TaskFailure(message="Failed to fetch Twitch feed.", error=str(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 typing import TYPE_CHECKING, Any
|
||||||
from xml.etree.ElementTree import Element
|
from xml.etree.ElementTree import Element
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||||
from app.features.ytdlp.utils import get_archive_id
|
from app.features.ytdlp.utils import get_archive_id
|
||||||
|
|
||||||
|
|
@ -98,6 +100,8 @@ class YoutubeHandler(BaseHandler):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
feed_url, items, real_count = await YoutubeHandler._get(task, params, parsed)
|
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:
|
except Exception as exc:
|
||||||
LOG.exception(exc)
|
LOG.exception(exc)
|
||||||
return TaskFailure(message="Failed to fetch YouTube feed.", error=str(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": []}
|
s: dict[str, list[str]] = {"h": [], "d": [], "u": [], "f": []}
|
||||||
|
|
||||||
handler_groups: dict[str, list[tuple[HandleTask, type]]] = {}
|
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()
|
tasks: list[TaskModel] = await self._repo.list()
|
||||||
|
|
||||||
|
|
@ -95,7 +96,6 @@ class TaskHandle:
|
||||||
if handler_name not in handler_groups:
|
if handler_name not in handler_groups:
|
||||||
handler_groups[handler_name] = []
|
handler_groups[handler_name] = []
|
||||||
handler_groups[handler_name].append((task, handler))
|
handler_groups[handler_name].append((task, handler))
|
||||||
s["h"].append(task.name)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Failed to handle task '{task.name}'. '{e!s}'.")
|
LOG.error(f"Failed to handle task '{task.name}'. '{e!s}'.")
|
||||||
s["f"].append(task.name)
|
s["f"].append(task.name)
|
||||||
|
|
@ -112,8 +112,27 @@ class TaskHandle:
|
||||||
name=f"taskHandler-{task.id}",
|
name=f"taskHandler-{task.id}",
|
||||||
)
|
)
|
||||||
t.add_done_callback(lambda fut, t=task: self._handle_exception(fut, t))
|
t.add_done_callback(lambda fut, t=task: self._handle_exception(fut, t))
|
||||||
|
dispatches.append((task, handler, t))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Failed to dispatch task '{task.name}'. '{e!s}'.")
|
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:
|
if len(tasks) > 0:
|
||||||
LOG.info(
|
LOG.info(
|
||||||
|
|
@ -193,7 +212,11 @@ class TaskHandle:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if isinstance(extraction, TaskFailure):
|
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
|
return extraction
|
||||||
|
|
||||||
if not isinstance(extraction, TaskResult):
|
if not isinstance(extraction, TaskResult):
|
||||||
|
|
@ -226,7 +249,7 @@ class TaskHandle:
|
||||||
|
|
||||||
for item in raw_items:
|
for item in raw_items:
|
||||||
if not isinstance(item, TaskItem):
|
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
|
continue
|
||||||
|
|
||||||
url: str = item.url
|
url: str = item.url
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.features.tasks.definitions.handlers.rss import RssGenericHandler
|
from app.features.tasks.definitions.handlers.rss import RssGenericHandler
|
||||||
|
|
@ -21,6 +23,10 @@ class DummyOpts:
|
||||||
return self._data
|
return self._data
|
||||||
|
|
||||||
|
|
||||||
|
def _opts(tmp_path: Path) -> DummyOpts:
|
||||||
|
return DummyOpts({"download_archive": str(tmp_path / "archive.txt")})
|
||||||
|
|
||||||
|
|
||||||
class TestRssHandlerParsing:
|
class TestRssHandlerParsing:
|
||||||
"""Test URL parsing for RSS/Atom feeds using the tests() method."""
|
"""Test URL parsing for RSS/Atom feeds using the tests() method."""
|
||||||
|
|
||||||
|
|
@ -47,7 +53,7 @@ class TestRssHandlerExtraction:
|
||||||
"""Test RSS feed extraction and parsing."""
|
"""Test RSS feed extraction and parsing."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test extraction from Atom feed."""
|
||||||
atom_feed = """<?xml version="1.0" encoding="UTF-8"?>
|
atom_feed = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
|
@ -69,7 +75,7 @@ class TestRssHandlerExtraction:
|
||||||
return DummyResponse(atom_feed)
|
return DummyResponse(atom_feed)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
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(
|
task = HandleTask(
|
||||||
id=1,
|
id=1,
|
||||||
|
|
@ -89,7 +95,7 @@ class TestRssHandlerExtraction:
|
||||||
assert result.metadata["entry_count"] == 2
|
assert result.metadata["entry_count"] == 2
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test extraction from RSS feed."""
|
||||||
rss_feed = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_feed = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
|
|
@ -113,7 +119,7 @@ class TestRssHandlerExtraction:
|
||||||
return DummyResponse(rss_feed)
|
return DummyResponse(rss_feed)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
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(
|
task = HandleTask(
|
||||||
id=1,
|
id=1,
|
||||||
|
|
@ -133,7 +139,7 @@ class TestRssHandlerExtraction:
|
||||||
assert result.metadata["entry_count"] == 2
|
assert result.metadata["entry_count"] == 2
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test can_handle method."""
|
||||||
atom_feed = """<?xml version="1.0" encoding="UTF-8"?>
|
atom_feed = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
|
@ -147,7 +153,7 @@ class TestRssHandlerExtraction:
|
||||||
return DummyResponse(atom_feed)
|
return DummyResponse(atom_feed)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
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(
|
task = HandleTask(
|
||||||
id=1,
|
id=1,
|
||||||
|
|
@ -172,7 +178,7 @@ class TestRssHandlerEdgeCases:
|
||||||
"""Test edge cases in RSS handling."""
|
"""Test edge cases in RSS handling."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test handling of empty feed."""
|
||||||
empty_feed = """<?xml version="1.0" encoding="UTF-8"?>
|
empty_feed = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
|
|
@ -186,7 +192,7 @@ class TestRssHandlerEdgeCases:
|
||||||
return DummyResponse(empty_feed)
|
return DummyResponse(empty_feed)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
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(
|
task = HandleTask(
|
||||||
id=1,
|
id=1,
|
||||||
|
|
@ -202,7 +208,7 @@ class TestRssHandlerEdgeCases:
|
||||||
assert result.metadata["entry_count"] == 0
|
assert result.metadata["entry_count"] == 0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test handling of invalid feed URL."""
|
||||||
from app.features.tasks.definitions.results import TaskFailure
|
from app.features.tasks.definitions.results import TaskFailure
|
||||||
|
|
||||||
|
|
@ -211,7 +217,7 @@ class TestRssHandlerEdgeCases:
|
||||||
raise Exception(msg)
|
raise Exception(msg)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
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(
|
task = HandleTask(
|
||||||
id=1,
|
id=1,
|
||||||
|
|
@ -225,7 +231,7 @@ class TestRssHandlerEdgeCases:
|
||||||
assert isinstance(result, TaskFailure)
|
assert isinstance(result, TaskFailure)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test handling of entries missing URLs."""
|
||||||
feed = """<?xml version="1.0" encoding="UTF-8"?>
|
feed = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
|
|
@ -245,7 +251,7 @@ class TestRssHandlerEdgeCases:
|
||||||
return DummyResponse(feed)
|
return DummyResponse(feed)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
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(
|
task = HandleTask(
|
||||||
id=1,
|
id=1,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.features.tasks.definitions.handlers.tver import TverHandler
|
from app.features.tasks.definitions.handlers.tver import TverHandler
|
||||||
|
|
@ -25,7 +27,7 @@ class DummyOpts:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test tver handler extraction of episodes from series."""
|
||||||
session_response = {
|
session_response = {
|
||||||
"result": {
|
"result": {
|
||||||
|
|
@ -118,8 +120,10 @@ async def test_tver_handler_extract(monkeypatch):
|
||||||
msg = f"Unexpected URL: {url}"
|
msg = f"Unexpected URL: {url}"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
archive_path = tmp_path / "archive.txt"
|
||||||
|
|
||||||
monkeypatch.setattr(TverHandler, "request", staticmethod(fake_request))
|
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")
|
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())
|
@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."""
|
"""Test tver URL parsing."""
|
||||||
result = TverHandler.parse(url)
|
result = TverHandler.parse(url)
|
||||||
if should_match:
|
if should_match:
|
||||||
|
|
@ -153,7 +157,7 @@ def test_parse(url: str, should_match: bool):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_can_handle():
|
async def test_can_handle() -> None:
|
||||||
"""Test tver handler can_handle method."""
|
"""Test tver handler can_handle method."""
|
||||||
task_valid = HandleTask(id=1, name="Test", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
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")
|
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
|
import pytest
|
||||||
|
|
||||||
from app.features.tasks.definitions.handlers.twitch import TwitchHandler
|
from app.features.tasks.definitions.handlers.twitch import TwitchHandler
|
||||||
|
|
@ -22,7 +24,7 @@ class DummyOpts:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_twitch_handler_inspect(monkeypatch):
|
async def test_twitch_handler_inspect(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
feed = """
|
feed = """
|
||||||
<rss><channel>
|
<rss><channel>
|
||||||
<item>
|
<item>
|
||||||
|
|
@ -39,8 +41,10 @@ async def test_twitch_handler_inspect(monkeypatch):
|
||||||
async def fake_request(**kwargs): # noqa: ARG001
|
async def fake_request(**kwargs): # noqa: ARG001
|
||||||
return DummyResponse(feed)
|
return DummyResponse(feed)
|
||||||
|
|
||||||
|
archive_path = tmp_path / "archive.txt"
|
||||||
|
|
||||||
monkeypatch.setattr(TwitchHandler, "request", staticmethod(fake_request))
|
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(
|
task = HandleTask(
|
||||||
id=1,
|
id=1,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.features.tasks.definitions.handlers.youtube import YoutubeHandler
|
from app.features.tasks.definitions.handlers.youtube import YoutubeHandler
|
||||||
|
|
@ -22,7 +24,7 @@ class DummyOpts:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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 = """
|
||||||
<feed xmlns='http://www.w3.org/2005/Atom' xmlns:yt='http://www.youtube.com/xml/schemas/2015'>
|
<feed xmlns='http://www.w3.org/2005/Atom' xmlns:yt='http://www.youtube.com/xml/schemas/2015'>
|
||||||
<entry>
|
<entry>
|
||||||
|
|
@ -41,8 +43,10 @@ async def test_youtube_handler_inspect(monkeypatch):
|
||||||
async def fake_request(**kwargs): # noqa: ARG001
|
async def fake_request(**kwargs): # noqa: ARG001
|
||||||
return DummyResponse(feed)
|
return DummyResponse(feed)
|
||||||
|
|
||||||
|
archive_path = tmp_path / "archive.txt"
|
||||||
|
|
||||||
monkeypatch.setattr(YoutubeHandler, "request", staticmethod(fake_request))
|
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(
|
task = HandleTask(
|
||||||
id=1,
|
id=1,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import importlib
|
import importlib
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -10,8 +11,12 @@ from app.features.ytdlp.utils import _DATA
|
||||||
from app.features.ytdlp.ytdlp import YTDLP, _ArchiveProxy, ytdlp_options
|
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:
|
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
|
# No file path means proxy is falsey and operations return False
|
||||||
p = _ArchiveProxy(file=None)
|
p = _ArchiveProxy(file=None)
|
||||||
assert bool(p) is False
|
assert bool(p) is False
|
||||||
|
|
@ -19,22 +24,24 @@ class TestArchiveProxy:
|
||||||
assert p.add("id") is False
|
assert p.add("id") is False
|
||||||
|
|
||||||
# Empty item also returns 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 bool(p2) is True
|
||||||
assert ("" in p2) is False
|
assert ("" in p2) is False
|
||||||
assert p2.add("") is False
|
assert p2.add("") is False
|
||||||
|
|
||||||
@patch("app.features.ytdlp.archiver.Archiver.get_instance")
|
@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()
|
arch = MagicMock()
|
||||||
mock_get_instance.return_value = arch
|
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
|
# contains -> read(file, [item]) and check membership
|
||||||
arch.read.return_value = ["abc"]
|
arch.read.return_value = ["abc"]
|
||||||
assert ("abc" in p) is True
|
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 = []
|
arch.read.return_value = []
|
||||||
assert ("xyz" in p) is False
|
assert ("xyz" in p) is False
|
||||||
|
|
@ -42,7 +49,7 @@ class TestArchiveProxy:
|
||||||
# add -> add(file, [item]) returns boolean
|
# add -> add(file, [item]) returns boolean
|
||||||
arch.add.return_value = True
|
arch.add.return_value = True
|
||||||
assert p.add("abc") is 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
|
arch.add.return_value = False
|
||||||
assert p.add("xyz") is False
|
assert p.add("xyz") is False
|
||||||
|
|
@ -96,11 +103,12 @@ class TestYTDLP:
|
||||||
return ytdlp
|
return ytdlp
|
||||||
|
|
||||||
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
|
@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."""
|
"""Test that __init__ removes download_archive before calling super, then restores it."""
|
||||||
mock_super_init.return_value = None
|
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)
|
ytdlp = YTDLP(params=params, auto_init=True)
|
||||||
# Set params as it would be after super().__init__
|
# Set params as it would be after super().__init__
|
||||||
|
|
@ -114,13 +122,13 @@ class TestYTDLP:
|
||||||
assert call_kwargs["auto_init"] is True
|
assert call_kwargs["auto_init"] is True
|
||||||
|
|
||||||
# Our __init__ code manually sets these after super()
|
# 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
|
# Verify archive proxy was set up
|
||||||
assert isinstance(ytdlp.archive, _ArchiveProxy)
|
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__")
|
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
|
||||||
def test_init_no_archive(self, mock_super_init) -> None:
|
def test_init_no_archive(self, mock_super_init) -> None:
|
||||||
|
|
@ -251,9 +259,9 @@ class TestYTDLP:
|
||||||
# Should not interact with archive
|
# Should not interact with archive
|
||||||
ytdlp.archive.add.assert_not_called()
|
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."""
|
"""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.write_debug = Mock()
|
||||||
ytdlp.archive = Mock()
|
ytdlp.archive = Mock()
|
||||||
ytdlp._make_archive_id = Mock(return_value="youtube test123")
|
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.archive.add.assert_called_once_with("youtube test123")
|
||||||
ytdlp.write_debug.assert_called_with("Adding to archive: 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."""
|
"""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.write_debug = Mock()
|
||||||
ytdlp.archive = Mock()
|
ytdlp.archive = Mock()
|
||||||
ytdlp._make_archive_id = Mock(return_value="youtube new123")
|
ytdlp._make_archive_id = Mock(return_value="youtube new123")
|
||||||
|
|
@ -296,9 +304,9 @@ class TestYTDLP:
|
||||||
# Should not add duplicate (youtube new123 appears only once)
|
# Should not add duplicate (youtube new123 appears only once)
|
||||||
assert calls.count("youtube new123") == 1
|
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."""
|
"""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.write_debug = Mock()
|
||||||
ytdlp.archive = Mock()
|
ytdlp.archive = Mock()
|
||||||
ytdlp._make_archive_id = Mock(return_value="youtube test123")
|
ytdlp._make_archive_id = Mock(return_value="youtube test123")
|
||||||
|
|
@ -322,9 +330,9 @@ class TestYTDLP:
|
||||||
ytdlp.record_download_archive(info_dict)
|
ytdlp.record_download_archive(info_dict)
|
||||||
assert ytdlp.archive.add.call_count == 1 # Only main ID
|
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."""
|
"""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.archive = Mock()
|
||||||
ytdlp._make_archive_id = Mock(return_value=None)
|
ytdlp._make_archive_id = Mock(return_value=None)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Tests for YTDLPOpts class."""
|
"""Tests for YTDLPOpts class."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -912,7 +913,7 @@ class TestYTDLPCli:
|
||||||
@patch("app.features.presets.service.Presets")
|
@patch("app.features.presets.service.Presets")
|
||||||
@patch("app.features.ytdlp.ytdlp_opts.create_cookies_file")
|
@patch("app.features.ytdlp.ytdlp_opts.create_cookies_file")
|
||||||
@patch("app.features.ytdlp.ytdlp_opts.Config")
|
@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."""
|
"""Test build with cookies from user."""
|
||||||
from app.library.ItemDTO import Item
|
from app.library.ItemDTO import Item
|
||||||
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
||||||
|
|
@ -920,11 +921,13 @@ class TestYTDLPCli:
|
||||||
mock_config_instance = Mock()
|
mock_config_instance = Mock()
|
||||||
mock_config_instance.download_path = "/downloads"
|
mock_config_instance.download_path = "/downloads"
|
||||||
mock_config_instance.output_template = "%(title)s.%(ext)s"
|
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_instance.get_replacers.return_value = {}
|
||||||
mock_config.get_instance.return_value = mock_config_instance
|
mock_config.get_instance.return_value = mock_config_instance
|
||||||
|
|
||||||
mock_cookie_path = Mock()
|
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_create_cookies.return_value = mock_cookie_path
|
||||||
|
|
||||||
mock_presets.get_instance.return_value.get.return_value = None
|
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")
|
mock_create_cookies.assert_called_once_with("session_id=abc123")
|
||||||
assert "--cookies" in command
|
assert "--cookies" in command
|
||||||
assert "/tmp/cookies.txt" in command
|
assert str(cookie_path) in command
|
||||||
assert info["merged"]["cookie_file"] == "/tmp/cookies.txt"
|
assert info["merged"]["cookie_file"] == str(cookie_path)
|
||||||
|
|
||||||
@patch("app.features.presets.service.Presets")
|
@patch("app.features.presets.service.Presets")
|
||||||
@patch("app.features.ytdlp.ytdlp_opts.Config")
|
@patch("app.features.ytdlp.ytdlp_opts.Config")
|
||||||
|
|
@ -950,6 +953,7 @@ class TestYTDLPCli:
|
||||||
mock_config_instance = Mock()
|
mock_config_instance = Mock()
|
||||||
mock_config_instance.download_path = "/downloads"
|
mock_config_instance.download_path = "/downloads"
|
||||||
mock_config_instance.output_template = "%(title)s.%(ext)s"
|
mock_config_instance.output_template = "%(title)s.%(ext)s"
|
||||||
|
mock_config_instance.temp_path = "/tmp"
|
||||||
mock_config_instance.get_replacers.return_value = {}
|
mock_config_instance.get_replacers.return_value = {}
|
||||||
mock_config.get_instance.return_value = mock_config_instance
|
mock_config.get_instance.return_value = mock_config_instance
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
|
@ -67,9 +68,9 @@ class TestConditionIgnoreMatching:
|
||||||
|
|
||||||
class TestConditionIgnorePropagation:
|
class TestConditionIgnorePropagation:
|
||||||
@pytest.mark.asyncio
|
@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 = 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()
|
queue._notify = Mock()
|
||||||
|
|
||||||
item = Item(
|
item = Item(
|
||||||
|
|
@ -102,9 +103,9 @@ class TestConditionIgnorePropagation:
|
||||||
assert result == {"status": "ok"}
|
assert result == {"status": "ok"}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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 = 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()
|
queue._notify = Mock()
|
||||||
|
|
||||||
item = Item(
|
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"
|
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:
|
def test_real_extract_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
ie = _make_ie()
|
ie = _make_ie()
|
||||||
|
|
||||||
|
|
@ -62,7 +71,7 @@ def test_real_extract_invalid_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
ie._real_extract("https://example.com/watch")
|
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 = _make_ie()
|
||||||
ie.__wrapped__ = Mock()
|
ie.__wrapped__ = Mock()
|
||||||
ie.__wrapped__._real_extract = Mock(return_value={"id": "fallback"})
|
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")
|
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()
|
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 = Mock()
|
||||||
session.goto.side_effect = RuntimeError("page crashed")
|
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(
|
ie.report_warning.assert_called_once_with(
|
||||||
"Browser extractor session failed for url=%r browser_url=%r driver=%s error=%s",
|
"Browser extractor session failed for url=%r browser_url=%r driver=%s error=%s",
|
||||||
"https://example.com/watch",
|
"https://example.com/watch",
|
||||||
"playwright+ws://browser",
|
"playwright+ws://browser/path?***",
|
||||||
"FakeDriver",
|
"FakeDriver",
|
||||||
session.goto.side_effect,
|
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")
|
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()
|
ie = _make_ie()
|
||||||
monkeypatch.setenv("YTP_BROWSER_URL", "playwright+ws://browser")
|
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")
|
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 = _make_ie()
|
||||||
ie.__wrapped__ = Mock()
|
ie.__wrapped__ = Mock()
|
||||||
ie.__wrapped__._real_extract = Mock(return_value={"id": "fallback"})
|
ie.__wrapped__._real_extract = Mock(return_value={"id": "fallback"})
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ import pytest
|
||||||
from app.library.ItemDTO import Item, ItemDTO
|
from app.library.ItemDTO import Item, ItemDTO
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_path(tmp_path: Path) -> str:
|
||||||
|
return str(tmp_path / "archive.txt")
|
||||||
|
|
||||||
|
|
||||||
class TestItemFormatAndBasics:
|
class TestItemFormatAndBasics:
|
||||||
@patch("app.features.presets.service.Presets.get_instance")
|
@patch("app.features.presets.service.Presets.get_instance")
|
||||||
def test_format_validates_and_normalizes(self, mock_presets_get):
|
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"
|
assert json.loads(item.json())["url"] == "https://example.com"
|
||||||
|
|
||||||
@patch("app.library.ItemDTO.get_archive_id")
|
@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"}
|
mock_get_id.return_value = {"archive_id": "x", "id": "x", "ie_key": "k"}
|
||||||
|
file = _archive_path(tmp_path)
|
||||||
|
|
||||||
# get_archive_id
|
# get_archive_id
|
||||||
item = Item(url="https://example.com")
|
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.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.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"]
|
mock_read.return_value = ["x"]
|
||||||
|
|
||||||
assert item.is_archived() is True
|
assert item.is_archived() is True
|
||||||
|
|
@ -95,30 +100,32 @@ class TestItemDTO:
|
||||||
@patch("app.library.ItemDTO.get_archive_id")
|
@patch("app.library.ItemDTO.get_archive_id")
|
||||||
@patch("app.library.ItemDTO.YTDLPOpts")
|
@patch("app.library.ItemDTO.YTDLPOpts")
|
||||||
@patch("app.library.ItemDTO.archive_read")
|
@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
|
# Setup archive id and archive file
|
||||||
mock_get_id.return_value = {"archive_id": "arch", "id": "arch", "ie_key": "YT"}
|
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.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.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"]
|
mock_read.return_value = ["arch"]
|
||||||
|
|
||||||
dto = ItemDTO(id="vid", title="t", url="u", folder="f")
|
dto = ItemDTO(id="vid", title="t", url="u", folder="f")
|
||||||
|
|
||||||
assert dto.archive_id == "arch"
|
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_archivable is True
|
||||||
assert dto.is_archived is True
|
assert dto.is_archived is True
|
||||||
|
|
||||||
@patch("app.library.ItemDTO.get_archive_id")
|
@patch("app.library.ItemDTO.get_archive_id")
|
||||||
@patch("app.library.ItemDTO.YTDLPOpts")
|
@patch("app.library.ItemDTO.YTDLPOpts")
|
||||||
@patch("app.library.ItemDTO.archive_read")
|
@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_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.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.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 = {
|
mock_opts.get_instance.return_value.get_all.return_value = {
|
||||||
"download_archive": "/tmp/a.txt",
|
"download_archive": file,
|
||||||
"skip_download": True,
|
"skip_download": True,
|
||||||
}
|
}
|
||||||
mock_read.return_value = ["arch"]
|
mock_read.return_value = ["arch"]
|
||||||
|
|
@ -128,11 +135,11 @@ class TestItemDTO:
|
||||||
assert dto.download_skipped is False
|
assert dto.download_skipped is False
|
||||||
|
|
||||||
@patch("app.library.ItemDTO.archive_read")
|
@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
|
# Given a finished item with archive info
|
||||||
dto = ItemDTO(id="vid", title="t", url="u", folder="f")
|
dto = ItemDTO(id="vid", title="t", url="u", folder="f")
|
||||||
dto.archive_id = "arch"
|
dto.archive_id = "arch"
|
||||||
dto._archive_file = "/tmp/a.txt"
|
dto._archive_file = _archive_path(tmp_path)
|
||||||
dto.status = "finished"
|
dto.status = "finished"
|
||||||
mock_read.return_value = ["arch"]
|
mock_read.return_value = ["arch"]
|
||||||
|
|
||||||
|
|
@ -186,7 +193,7 @@ class TestItemDTO:
|
||||||
|
|
||||||
# Set up to allow add
|
# Set up to allow add
|
||||||
dto.archive_id = "arch"
|
dto.archive_id = "arch"
|
||||||
dto._archive_file = "/tmp/a.txt"
|
dto._archive_file = "var/tests/archive.txt"
|
||||||
dto.is_archivable = True
|
dto.is_archivable = True
|
||||||
dto.is_archived = False
|
dto.is_archived = False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,8 @@ from pathlib import Path
|
||||||
|
|
||||||
from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP
|
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 sample_info_tv(path: Path) -> dict:
|
||||||
def _clean_file(path: Path) -> None:
|
|
||||||
try:
|
|
||||||
path.unlink()
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def sample_info_tv() -> dict:
|
|
||||||
return {
|
return {
|
||||||
"id": "abc123",
|
"id": "abc123",
|
||||||
"title": "Test Show - S01E02",
|
"title": "Test Show - S01E02",
|
||||||
|
|
@ -25,11 +15,11 @@ def sample_info_tv() -> dict:
|
||||||
"episode_number": 2,
|
"episode_number": 2,
|
||||||
"description": "First line.\n#tag\n00:01:23 Intro",
|
"description": "First line.\n#tag\n00:01:23 Intro",
|
||||||
"extractor": "youtube",
|
"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 {
|
return {
|
||||||
"id": "mov001",
|
"id": "mov001",
|
||||||
"title": "Test Movie",
|
"title": "Test Movie",
|
||||||
|
|
@ -38,60 +28,46 @@ def sample_info_movie() -> dict:
|
||||||
"duration": 7200,
|
"duration": 7200,
|
||||||
"thumbnail": "http://example.com/thumb.jpg",
|
"thumbnail": "http://example.com/thumb.jpg",
|
||||||
"webpage_url": "http://example.com/trailer",
|
"webpage_url": "http://example.com/trailer",
|
||||||
"filename": str(TEST_DIR / "test_movie.mp4"),
|
"filename": str(path),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_generate_nfo_tv_mode(tmp_path):
|
def test_generate_nfo_tv_mode(tmp_path: Path) -> None:
|
||||||
# arrange
|
media_file = tmp_path / "test_show_s01e02.mkv"
|
||||||
TEST_DIR.mkdir(parents=True, exist_ok=True)
|
info = sample_info_tv(media_file)
|
||||||
info = sample_info_tv()
|
media_file.write_text("dummy", encoding="utf-8")
|
||||||
media_file = Path(info["filename"])
|
|
||||||
media_file.write_text("dummy")
|
|
||||||
nfo_path = media_file.with_suffix(".nfo")
|
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)
|
res = NFOMakerPP.generate_nfo(info_dict=info, filepath=media_file, mode="tv", overwrite=True, prefix=True)
|
||||||
|
|
||||||
# assert
|
|
||||||
assert res["success"] is True
|
assert res["success"] is True
|
||||||
assert nfo_path.exists()
|
assert nfo_path.exists()
|
||||||
|
|
||||||
content = nfo_path.read_text(encoding="utf-8")
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
assert "<episodedetails>" in content
|
assert "<episodedetails>" in content
|
||||||
assert "<title>Test Show - S01E02</title>" in content
|
assert "<title>Test Show - S01E02</title>" in content
|
||||||
# description cleaned (no timestamps / hashtags)
|
|
||||||
assert "Intro" not in content
|
assert "Intro" not in content
|
||||||
assert "#tag" not in content
|
assert "#tag" not in content
|
||||||
|
|
||||||
# also ensure PostProcessor.run wrapper produces the same result and syncs mtime
|
nfo_path.unlink()
|
||||||
_clean_file(nfo_path)
|
|
||||||
pp = NFOMakerPP(None, mode="tv", prefix=True)
|
pp = NFOMakerPP(None, mode="tv", prefix=True)
|
||||||
media_mtime = media_file.stat().st_mtime
|
media_mtime = media_file.stat().st_mtime
|
||||||
_, info_out = pp.run(info.copy())
|
pp.run(info.copy())
|
||||||
assert nfo_path.exists()
|
assert nfo_path.exists()
|
||||||
nfo_mtime = nfo_path.stat().st_mtime
|
nfo_mtime = nfo_path.stat().st_mtime
|
||||||
assert abs(nfo_mtime - media_mtime) < 2.0
|
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: Path) -> None:
|
||||||
def test_generate_nfo_movie_mode_and_run_wrapper(tmp_path):
|
media_file = tmp_path / "test_movie.mp4"
|
||||||
# arrange
|
info = sample_info_movie(media_file)
|
||||||
TEST_DIR.mkdir(parents=True, exist_ok=True)
|
media_file.write_text("dummy-movie", encoding="utf-8")
|
||||||
info = sample_info_movie()
|
|
||||||
media_file = Path(info["filename"])
|
|
||||||
media_file.write_text("dummy-movie")
|
|
||||||
nfo_path = media_file.with_suffix(".nfo")
|
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)
|
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 res["success"] is True
|
||||||
assert nfo_path.exists()
|
assert nfo_path.exists()
|
||||||
content = nfo_path.read_text(encoding="utf-8")
|
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 "<title>Test Movie</title>" in content
|
||||||
assert "<runtime>7200</runtime>" in content or "<runtime>7200" 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()
|
nfo_path.unlink()
|
||||||
|
|
||||||
# call through instance run() to ensure wrapper works (mtime sync path exercised)
|
|
||||||
pp = NFOMakerPP(None, mode="movie", prefix=False)
|
pp = NFOMakerPP(None, mode="movie", prefix=False)
|
||||||
# ensure file exists
|
|
||||||
assert media_file.exists()
|
assert media_file.exists()
|
||||||
|
|
||||||
# capture media file mtime before run
|
|
||||||
media_mtime = media_file.stat().st_mtime
|
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()
|
assert nfo_path.exists()
|
||||||
|
|
||||||
# verify mtime was synced (allow small rounding differences)
|
|
||||||
nfo_mtime = nfo_path.stat().st_mtime
|
nfo_mtime = nfo_path.stat().st_mtime
|
||||||
assert abs(nfo_mtime - media_mtime) < 2.0
|
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"}
|
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",
|
"m3u8",
|
||||||
"mpd",
|
"mpd",
|
||||||
"mp4",
|
"mp4",
|
||||||
|
|
@ -78,7 +81,7 @@ _MEDIA_CANDIDATE_EXTS = [
|
||||||
"ogg",
|
"ogg",
|
||||||
]
|
]
|
||||||
|
|
||||||
_MEDIA_ELEMENT_JS = """() => {
|
MEDIA_ELEMENT_JS: str = """() => {
|
||||||
const mediaUrls = [];
|
const mediaUrls = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const addUrl = (url, type) => {
|
const addUrl = (url, type) => {
|
||||||
|
|
@ -98,7 +101,7 @@ _MEDIA_ELEMENT_JS = """() => {
|
||||||
return mediaUrls;
|
return mediaUrls;
|
||||||
}"""
|
}"""
|
||||||
|
|
||||||
_PLAYWRIGHT_PREFIXES: tuple[str, ...] = (
|
PLAYWRIGHT_PREFIXES: tuple[str, ...] = (
|
||||||
"playwright+ws://",
|
"playwright+ws://",
|
||||||
"playwright+wss://",
|
"playwright+wss://",
|
||||||
"playwright+cdp://",
|
"playwright+cdp://",
|
||||||
|
|
@ -112,7 +115,7 @@ _PLAYWRIGHT_PREFIXES: tuple[str, ...] = (
|
||||||
def _has_possible_media(requests_list: list[dict]) -> bool:
|
def _has_possible_media(requests_list: list[dict]) -> bool:
|
||||||
for req in requests_list:
|
for req in requests_list:
|
||||||
url_lower = req.get("url", "").lower()
|
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:
|
if f".{ext}" in url_lower or f".{ext}?" in url_lower:
|
||||||
return True
|
return True
|
||||||
ct = (req.get("response", {}).get("headers", {}).get("content-type", "")).lower()
|
ct = (req.get("response", {}).get("headers", {}).get("content-type", "")).lower()
|
||||||
|
|
@ -141,6 +144,12 @@ def _wait_for_network_idle(
|
||||||
raise Exception(msg)
|
raise Exception(msg)
|
||||||
return max(1, min(requested_ms, remaining_ms))
|
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))
|
wait_fn(bounded_timeout_ms(idle_timeout))
|
||||||
|
|
||||||
for _ in range(api_poll_attempts):
|
for _ in range(api_poll_attempts):
|
||||||
|
|
@ -156,6 +165,7 @@ def _wait_for_network_idle(
|
||||||
return
|
return
|
||||||
|
|
||||||
wait_for_media_fn(bounded_timeout_ms(10000))
|
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]:
|
def _build_media_requests(requests_list: list[dict], media_elements: list[dict]) -> list[dict]:
|
||||||
|
|
@ -313,7 +323,7 @@ class PlaywrightDriver:
|
||||||
return list(requests_list)
|
return list(requests_list)
|
||||||
|
|
||||||
def get_media_requests(self) -> list[dict]:
|
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):
|
def close(self):
|
||||||
context.close()
|
context.close()
|
||||||
|
|
@ -416,7 +426,7 @@ class SeleniumDriver:
|
||||||
|
|
||||||
def get_media_requests(self) -> list[dict]:
|
def get_media_requests(self) -> list[dict]:
|
||||||
try:
|
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:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -442,12 +452,31 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
||||||
|
|
||||||
return value
|
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]:
|
def _real_extract(self, url: str) -> dict[str, Any]:
|
||||||
self._url = url
|
self._url = url
|
||||||
|
|
||||||
if not (browser_url := self._get_config("url", "YTP_BROWSER_URL")) or self._failed:
|
if not (browser_url := self._get_config("url", "YTP_BROWSER_URL")) or self._failed:
|
||||||
return self.__wrapped__._real_extract(self, url)
|
return self.__wrapped__._real_extract(self, url)
|
||||||
|
|
||||||
|
safe_url = self._safe_url(browser_url)
|
||||||
|
|
||||||
video_id: str = self._generic_id(url)
|
video_id: str = self._generic_id(url)
|
||||||
timeout: int | None = self._get_timeout_ms()
|
timeout: int | None = self._get_timeout_ms()
|
||||||
self.to_screen(f"Using remote browser for {url}")
|
self.to_screen(f"Using remote browser for {url}")
|
||||||
|
|
@ -459,7 +488,7 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
||||||
)
|
)
|
||||||
raise ExtractorError(msg)
|
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:
|
try:
|
||||||
session = driver.connect(browser_url, timeout)
|
session = driver.connect(browser_url, timeout)
|
||||||
|
|
@ -513,8 +542,10 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
||||||
}
|
}
|
||||||
|
|
||||||
if webpage and self._looks_like_html(webpage):
|
if webpage and self._looks_like_html(webpage):
|
||||||
info_dict["title"] = self._get_title(webpage) or self._generic_title(url, webpage)
|
info_dict["title"] = self._generic_title(url, webpage)
|
||||||
info_dict["description"] = self._get_desc(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)
|
thumbnail = self._og_search_thumbnail(webpage, default=None)
|
||||||
if thumbnail:
|
if thumbnail:
|
||||||
info_dict["thumbnail"] = thumbnail
|
info_dict["thumbnail"] = thumbnail
|
||||||
|
|
@ -534,7 +565,7 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
||||||
self.report_warning(
|
self.report_warning(
|
||||||
"Browser extractor session failed for url=%r browser_url=%r driver=%s error=%s",
|
"Browser extractor session failed for url=%r browser_url=%r driver=%s error=%s",
|
||||||
url,
|
url,
|
||||||
browser_url,
|
safe_url,
|
||||||
driver.__name__,
|
driver.__name__,
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
|
|
@ -546,7 +577,7 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
||||||
self.report_warning(
|
self.report_warning(
|
||||||
"Browser session close failed for url=%r browser_url=%r driver=%s error=%s",
|
"Browser session close failed for url=%r browser_url=%r driver=%s error=%s",
|
||||||
url,
|
url,
|
||||||
browser_url,
|
safe_url,
|
||||||
driver.__name__,
|
driver.__name__,
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
|
|
@ -555,7 +586,7 @@ class GenericBrowserIE(GenericIE, plugin_name="browser"):
|
||||||
if ws_url.startswith(("selenium+http://", "selenium+https://")):
|
if ws_url.startswith(("selenium+http://", "selenium+https://")):
|
||||||
return SeleniumDriver if SeleniumDriver.is_available() else None
|
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
|
return PlaywrightDriver if PlaywrightDriver.is_available() else None
|
||||||
|
|
||||||
msg = (
|
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:
|
def _get_timeout_ms(self) -> int | None:
|
||||||
socket_timeout = self._downloader.params.get("socket_timeout")
|
socket_timeout = self._downloader.params.get("socket_timeout")
|
||||||
if isinstance(socket_timeout, (int, float)) and socket_timeout > 0:
|
if isinstance(socket_timeout, (int, float)) and socket_timeout > 0:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue