refactor: implement re-extraction logic and process-safe handling for non-picklable objects.

This commit is contained in:
arabcoders 2026-05-25 20:06:34 +03:00
parent 2e9684de5d
commit 64fdcac0f7
7 changed files with 715 additions and 500 deletions

View file

@ -17,6 +17,9 @@ from app.library.Singleton import Singleton
LOG: logging.Logger = logging.getLogger("downloads.extractor") LOG: logging.Logger = logging.getLogger("downloads.extractor")
LIVE_REEXTRACT_STATUSES: set[str] = {"is_live", "post_live"}
REEXTRACT_INFO_KEY = "_ytptube_reextract"
def _ytdlp_logger(target: logging.Logger): def _ytdlp_logger(target: logging.Logger):
return _YTDLPLogger(target) return _YTDLPLogger(target)
@ -247,6 +250,23 @@ def _sanitize_config(config: dict[str, Any]) -> dict[str, Any]:
return sanitized return sanitized
def needs_reextract(info: dict[str, Any]) -> bool:
return bool(info.get("is_live") or info.get("live_status") in LIVE_REEXTRACT_STATUSES)
def _process_safe_info(info: dict[str, Any]) -> dict[str, Any]:
if needs_reextract(info):
info = dict(info)
info[REEXTRACT_INFO_KEY] = True
for key in ("formats", "requested_formats", "requested_downloads", "fragments"):
info.pop(key, None)
if _is_picklable(info):
return info
return YTDLP.sanitize_info(info, remove_private_keys=False)
def extract_info_sync( def extract_info_sync(
config: dict[str, Any], config: dict[str, Any],
url: str, url: str,
@ -255,6 +275,7 @@ def extract_info_sync(
follow_redirect: bool = False, follow_redirect: bool = False,
sanitize_info: bool = False, sanitize_info: bool = False,
capture_logs: int | None = None, capture_logs: int | None = None,
process_safe: bool = False,
**kwargs, **kwargs,
) -> tuple[dict[str, Any] | None, list[str]]: ) -> tuple[dict[str, Any] | None, list[str]]:
""" """
@ -268,6 +289,7 @@ def extract_info_sync(
follow_redirect: Follow URL redirects follow_redirect: Follow URL redirects
sanitize_info: Sanitize the extracted information sanitize_info: Sanitize the extracted information
capture_logs: If provided (e.g., logging.WARNING), capture logs at this level. capture_logs: If provided (e.g., logging.WARNING), capture logs at this level.
process_safe: Strip non-pickleable data for safe inter-process communication.
**kwargs: Additional arguments **kwargs: Additional arguments
Returns: Returns:
@ -330,6 +352,7 @@ def extract_info_sync(
follow_redirect=follow_redirect, follow_redirect=follow_redirect,
sanitize_info=sanitize_info, sanitize_info=sanitize_info,
capture_logs=capture_logs, capture_logs=capture_logs,
process_safe=process_safe,
captured_logs=captured_logs, captured_logs=captured_logs,
**kwargs, **kwargs,
) )
@ -348,6 +371,9 @@ def extract_info_sync(
result = YTDLP.sanitize_info(data, remove_private_keys=True) if sanitize_info else data result = YTDLP.sanitize_info(data, remove_private_keys=True) if sanitize_info else data
if process_safe and isinstance(result, dict):
result = _process_safe_info(result)
return (result, captured_logs) return (result, captured_logs)
finally: finally:
logging.Logger.manager.loggerDict.pop(logger_name, None) logging.Logger.manager.loggerDict.pop(logger_name, None)
@ -404,6 +430,8 @@ async def fetch_info(
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
safe_config = _sanitize_config(config) safe_config = _sanitize_config(config)
safe_kwargs = _sanitize_picklable(kwargs)
safe_kwargs.pop("process_safe", None)
timeout = _sleep_timeout(safe_config, extractor_config.timeout, budget_sleep) timeout = _sleep_timeout(safe_config, extractor_config.timeout, budget_sleep)
try: try:
@ -422,7 +450,8 @@ async def fetch_info(
follow_redirect=follow_redirect, follow_redirect=follow_redirect,
sanitize_info=sanitize_info, sanitize_info=sanitize_info,
capture_logs=capture_logs, capture_logs=capture_logs,
**kwargs, process_safe=True,
**safe_kwargs,
), ),
), ),
timeout=timeout, timeout=timeout,

View file

@ -5,8 +5,10 @@ from unittest.mock import MagicMock, patch
from app.features.ytdlp.extractor import ( from app.features.ytdlp.extractor import (
ExtractorConfig, ExtractorConfig,
ExtractorPool, ExtractorPool,
REEXTRACT_INFO_KEY,
_LogCapture, _LogCapture,
_get_process_pool_kwargs, _get_process_pool_kwargs,
_process_safe_info,
_ytdlp_logger, _ytdlp_logger,
extract_info_sync, extract_info_sync,
) )
@ -158,6 +160,44 @@ class TestExtractInfo:
assert (logging.INFO, "[generic_browser] Using remote browser for https://example.com/video") in seen assert (logging.INFO, "[generic_browser] Using remote browser for https://example.com/video") in seen
assert (logging.WARNING, "[generic_browser] Browser fallback warning") in seen assert (logging.WARNING, "[generic_browser] Browser fallback warning") in seen
def test_process_safe_live(self) -> None:
data = {
"id": "live-id",
"is_live": True,
"formats": [{"format_id": "dash", "fragments": ({"url": "https://example.test/sq/1"} for _ in range(1))}],
"requested_formats": [{"format_id": "dash"}],
}
result = _process_safe_info(data)
assert result[REEXTRACT_INFO_KEY] is True
assert "formats" not in result
assert "requested_formats" not in result
pickle.dumps(result)
def test_process_safe_post_live(self) -> None:
data = {
"id": "post-live-id",
"live_status": "post_live",
"formats": [{"format_id": "dash", "fragments": ({"url": "https://example.test/sq/1"} for _ in range(1))}],
}
result = _process_safe_info(data)
assert result[REEXTRACT_INFO_KEY] is True
assert "formats" not in result
pickle.dumps(result)
def test_process_safe_lazy(self) -> None:
from yt_dlp.utils import LazyList
data = {"id": "video-id", "formats": LazyList({"format_id": str(i)} for i in range(2))}
result = _process_safe_info(data)
assert result["formats"] == [{"format_id": "0"}, {"format_id": "1"}]
pickle.dumps(result)
class TestYtdlpLogger: class TestYtdlpLogger:
def test_debug_prefix_uses_debug(self) -> None: def test_debug_prefix_uses_debug(self) -> None:

View file

@ -21,7 +21,7 @@ from app.library.config import Config
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.Utils import create_cookies_file from app.library.Utils import create_cookies_file
from ...features.ytdlp.extractor import extract_info_sync from ...features.ytdlp.extractor import REEXTRACT_INFO_KEY, extract_info_sync
from .hooks import HookHandlers, NestedLogger from .hooks import HookHandlers, NestedLogger
from .process_manager import ProcessManager from .process_manager import ProcessManager
from .status_tracker import StatusTracker from .status_tracker import StatusTracker
@ -147,11 +147,16 @@ class Download:
raise ValueError(err_msg) from e raise ValueError(err_msg) from e
if self.info_dict and isinstance(self.info_dict, dict): if self.info_dict and isinstance(self.info_dict, dict):
if self.info_dict.get("extractor_key") in GENERIC_EXTRACTORS and self.info.get_preset().default: if self.info_dict.get(REEXTRACT_INFO_KEY):
self.logger.info(
f"Info dict for '{self.info.url}' marked for re-extraction, ignoring pre-extracted info."
)
self.info_dict = None
elif self.info_dict.get("extractor_key") in GENERIC_EXTRACTORS and self.info.get_preset().default:
self.logger.debug(f"Removing 'download_archive' for generic extractor. url={self.info.url}") self.logger.debug(f"Removing 'download_archive' for generic extractor. url={self.info.url}")
params.pop("download_archive", None) params.pop("download_archive", None)
if self.download_info_expires > 0: if self.info_dict and self.download_info_expires > 0:
_ts: int | None = self.info_dict.get("epoch", self.info_dict.get("timestamp", None)) _ts: int | None = self.info_dict.get("epoch", self.info_dict.get("timestamp", None))
_ts_dt = datetime.fromtimestamp(_ts, tz=UTC) if _ts else None _ts_dt = datetime.fromtimestamp(_ts, tz=UTC) if _ts else None
if not _ts_dt or (datetime.now(tz=UTC) - _ts_dt).total_seconds() > self.download_info_expires: if not _ts_dt or (datetime.now(tz=UTC) - _ts_dt).total_seconds() > self.download_info_expires:

View file

@ -6,6 +6,7 @@ from datetime import UTC, datetime, timedelta
from email.utils import formatdate from email.utils import formatdate
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from app.features.ytdlp.extractor import REEXTRACT_INFO_KEY, needs_reextract
from app.features.ytdlp.utils import extract_ytdlp_logs, get_extras from app.features.ytdlp.utils import extract_ytdlp_logs, get_extras
from app.library.downloads import Download from app.library.downloads import Download
from app.library.Events import Events from app.library.Events import Events
@ -125,12 +126,13 @@ async def add_video(queue: "DownloadQueue", entry: dict, item: "Item", logs: lis
) )
try: try:
dlInfo: Download = Download(info=dl, info_dict=entry if item.auto_start else None, logs=logs) _reextract: bool = bool(entry.get(REEXTRACT_INFO_KEY) or needs_reextract(entry))
dlInfo: Download = Download(info=dl, info_dict=entry if item.auto_start and not _reextract else None, logs=logs)
nEvent: str | None = None nEvent: str | None = None
nTitle: str | None = None nTitle: str | None = None
nMessage: str | None = None nMessage: str | None = None
nStore: str = "queue" nStore: str = "queue"
hasFormats: bool = len(entry.get("formats", [])) > 0 or entry.get("url") hasFormats: bool = _reextract or bool(entry.get("formats") or entry.get("url"))
text_logs: str = "" text_logs: str = ""
if filtered_logs := extract_ytdlp_logs(logs): if filtered_logs := extract_ytdlp_logs(logs):

View file

@ -1,6 +1,7 @@
import logging import logging
import os import os
import signal import signal
import time
from multiprocessing.reduction import ForkingPickler from multiprocessing.reduction import ForkingPickler
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@ -17,6 +18,7 @@ from app.library.downloads.process_manager import ProcessManager
from app.library.downloads.queue_manager import DownloadQueue from app.library.downloads.queue_manager import DownloadQueue
from app.library.downloads.status_tracker import StatusTracker from app.library.downloads.status_tracker import StatusTracker
from app.library.downloads.temp_manager import TempManager from app.library.downloads.temp_manager import TempManager
from app.library.downloads.video_processor import add_video
from app.library.ItemDTO import ItemDTO from app.library.ItemDTO import ItemDTO
@ -292,7 +294,12 @@ class TestDownloadFlow:
download = Download( download = Download(
info=make_item(), info=make_item(),
info_dict={"id": "test-id", "url": "http://u", "formats": [{"format_id": "18"}]}, info_dict={
"id": "test-id",
"url": "http://u",
"formats": [{"format_id": "18"}],
"epoch": int(time.time()),
},
) )
download.status_queue = cast(Any, DummyQueue()) download.status_queue = cast(Any, DummyQueue())
download._hook_handlers = Mock( download._hook_handlers = Mock(
@ -326,6 +333,7 @@ class TestDownloadFlow:
queue = cast(DummyQueue, download.status_queue) queue = cast(DummyQueue, download.status_queue)
assert queue.items[0]["download_skipped"] is True assert queue.items[0]["download_skipped"] is True
assert queue.items[1]["status"] == "finished"
assert queue.items[1]["download_skipped"] is True assert queue.items[1]["download_skipped"] is True
def test_playlist_extras(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_playlist_extras(self, monkeypatch: pytest.MonkeyPatch) -> None:
@ -1205,6 +1213,88 @@ class TestStatusTracker:
class TestQueueManager: class TestQueueManager:
@staticmethod
def _video_queue() -> Mock:
async def put(item):
return item
queue_manager = Mock()
queue_manager.config = Mock(
download_path="/tmp",
temp_path="/tmp",
output_template="%(title)s.%(ext)s",
output_template_chapter="%(title)s.%(ext)s",
prevent_live_premiere=False,
)
queue_manager.done.get = AsyncMock(side_effect=KeyError)
queue_manager.queue.get = AsyncMock(side_effect=KeyError)
queue_manager.done.put = AsyncMock(side_effect=put)
queue_manager.queue.put = AsyncMock(side_effect=put)
queue_manager.pool.trigger_download = Mock()
queue_manager._notify.emit = Mock()
return queue_manager
@staticmethod
def _video_item() -> SimpleNamespace:
return SimpleNamespace(
extras={},
folder="",
preset="default",
cookies=None,
template=None,
cli=[],
auto_start=True,
)
@pytest.mark.asyncio
async def test_live_reextracts(self, monkeypatch: pytest.MonkeyPatch) -> None:
seen: list[dict | None] = []
def fake_download(*, info, info_dict, logs):
seen.append(info_dict)
return SimpleNamespace(info=info, info_dict=info_dict, logs=logs)
monkeypatch.setattr("app.library.downloads.video_processor.Download", fake_download)
result = await add_video(
queue=self._video_queue(),
item=self._video_item(),
entry={
"id": "live-id",
"title": "Live stream",
"webpage_url": "https://example.test/live",
"is_live": True,
"live_status": "is_live",
"_ytptube_reextract": True,
},
)
assert result == {"status": "ok"}
assert seen == [None]
@pytest.mark.asyncio
async def test_regular_reuses_info(self, monkeypatch: pytest.MonkeyPatch) -> None:
seen: list[dict | None] = []
def fake_download(*, info, info_dict, logs):
seen.append(info_dict)
return SimpleNamespace(info=info, info_dict=info_dict, logs=logs)
monkeypatch.setattr("app.library.downloads.video_processor.Download", fake_download)
entry = {
"id": "video-id",
"title": "Video",
"webpage_url": "https://example.test/video",
"live_status": "not_live",
"formats": [{"format_id": "18"}],
}
result = await add_video(queue=self._video_queue(), item=self._video_item(), entry=entry)
assert result == {"status": "ok"}
assert seen == [entry]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cleanup_thumbnails(self, tmp_path: Path) -> None: async def test_cleanup_thumbnails(self, tmp_path: Path) -> None:
from app.library.downloads.monitors import cleanup_thumbnails from app.library.downloads.monitors import cleanup_thumbnails

View file

@ -92,3 +92,30 @@ async def test_pool_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
assert loop.calls == [pool.executor, None] assert loop.calls == [pool.executor, None]
assert seen == [130, 130] assert seen == [130, 130]
assert not pool.semaphore.locked() assert not pool.semaphore.locked()
@pytest.mark.asyncio
async def test_pool_process_safe(monkeypatch: pytest.MonkeyPatch) -> None:
pool = _Pool()
loop = _Loop()
expected = ({"id": "ok"}, [])
sync = Mock(return_value=expected)
async def fake_wait_for(*, fut, timeout): # noqa: ARG001
return fut()
monkeypatch.setattr(extractor.ExtractorPool, "get_instance", classmethod(lambda cls: pool))
monkeypatch.setattr(extractor.asyncio, "get_running_loop", lambda: loop)
monkeypatch.setattr(extractor.asyncio, "wait_for", fake_wait_for)
monkeypatch.setattr(extractor, "extract_info_sync", sync)
result = await extractor.fetch_info(
config={},
url="https://example.com",
extractor_config=extractor.ExtractorConfig(concurrency=1, timeout=70),
)
assert result == expected
assert loop.calls == [pool.executor]
assert sync.call_args.kwargs["process_safe"] is True
assert not pool.semaphore.locked()

1008
uv.lock

File diff suppressed because it is too large Load diff