Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/native-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ jobs:
shell: bash
run: |
pip install uv
uv venv --system-site-packages --relocatable
if [ ! -d .venv ]; then
uv venv --system-site-packages --relocatable
fi
uv sync --link-mode=copy --active --extra installer

- name: Cache bun installation
Expand Down
124 changes: 121 additions & 3 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ This document describes the available endpoints and their usage. All endpoints r
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
- [GET /api/system/configuration](#get-apisystemconfiguration)
- [POST /api/system/terminal](#post-apisystemterminal)
- [GET /api/system/terminal/active](#get-apisystemterminalactive)
- [GET /api/system/terminal/{session\_id}](#get-apisystemterminalsession_id)
- [DELETE /api/system/terminal/{session\_id}](#delete-apisystemterminalsession_id)
- [GET /api/system/terminal/{session\_id}/stream](#get-apisystemterminalsession_idstream)
- [POST /api/system/pause](#post-apisystempause)
- [POST /api/system/resume](#post-apisystemresume)
- [POST /api/system/shutdown](#post-apisystemshutdown)
Expand Down Expand Up @@ -2476,7 +2480,7 @@ or an error:
---

### POST /api/system/terminal
**Purpose**: Stream yt-dlp CLI output via Server-Sent Events (SSE). Requires `YTP_CONSOLE_ENABLED=true`.
**Purpose**: Start a yt-dlp terminal session. Requires `YTP_CONSOLE_ENABLED=true`.

**Body**:
```json
Expand All @@ -2485,9 +2489,118 @@ or an error:
}
```

**Response**:
```json
{
"session_id": "3a8c5f7e2d3b4a8f9c0d1e2f3a4b5c6d",
"command": "--help",
"status": "starting",
"created_at": 1713000000.0,
"started_at": 1713000000.0,
"finished_at": null,
"expires_at": null,
"exit_code": null,
"last_sequence": 0
}
```

**Notes**:
- Starts the command in an application-owned background task.
- Only one active terminal session is allowed at a time.
- The command continues running if the frontend disconnects or reloads.

- `403 Forbidden` if console is disabled.
- `400 Bad Request` if the request body is invalid.
- `409 Conflict` if another terminal session is already active.

---

### GET /api/system/terminal/active
**Purpose**: Return the currently active terminal session metadata, or `null` if no session is active.

**Response**:
```json
null
```
or
```json
{
"session_id": "3a8c5f7e2d3b4a8f9c0d1e2f3a4b5c6d",
"command": "--help",
"status": "running",
"created_at": 1713000000.0,
"started_at": 1713000000.0,
"finished_at": null,
"expires_at": null,
"exit_code": null,
"last_sequence": 12
}
```

- `403 Forbidden` if console is disabled.

---

### GET /api/system/terminal/{session_id}
**Purpose**: Return metadata for a specific terminal session while it is active or still within the replay/drain window.

**Response**:
```json
{
"session_id": "3a8c5f7e2d3b4a8f9c0d1e2f3a4b5c6d",
"command": "--help",
"status": "completed",
"created_at": 1713000000.0,
"started_at": 1713000000.0,
"finished_at": 1713000004.0,
"expires_at": 1713000034.0,
"exit_code": 0,
"last_sequence": 15
}
```

- `403 Forbidden` if console is disabled.
- `404 Not Found` if the session does not exist or has already expired.

---

### DELETE /api/system/terminal/{session_id}
**Purpose**: Request cancellation for the active terminal session.

**Response**:
```json
{
"message": "Terminal session cancellation requested.",
"session_id": "3a8c5f7e2d3b4a8f9c0d1e2f3a4b5c6d"
}
```

**Notes**:
- This only applies to the currently active session.
- The client should stay attached to the stream to receive the final `close` event and refreshed terminal status.
- Cancelled sessions finalize as `interrupted` and remain replayable until the drain window expires.

- `403 Forbidden` if console is disabled.
- `404 Not Found` if the session does not exist or has already expired.
- `409 Conflict` if the session exists but is no longer active.

---

### GET /api/system/terminal/{session_id}/stream
**Purpose**: Replay a terminal session transcript over SSE and tail live output when the session is still running.

**Query Parameters**:
- `since` (optional): Resume after the provided integer event id.

**Headers**:
- `Last-Event-ID` (optional): Resume after the provided integer event id.

If both `since` and `Last-Event-ID` are present, the larger value is used.

**Response**:
- `Content-Type: text/event-stream`
- Emits `output` events for stdout/stderr and a final `close` event when the process exits.
- Replays transcript events with monotonic integer SSE `id` values.
- Emits `output` events for stdout/stderr and a final `close` event when available.

**Event Payloads**:
```json
Expand All @@ -2497,8 +2610,13 @@ or an error:
{ "exitcode": 0 }
```

**Notes**:
- Replay/restore works while the session is still running or until the finished session expires.
- Finished sessions are removed lazily after the transcript drain window elapses.

- `403 Forbidden` if console is disabled.
- `400 Bad Request` if the request body is invalid.
- `400 Bad Request` if the replay cursor is invalid.
- `404 Not Found` if the session does not exist or has already expired.

---

Expand Down
98 changes: 98 additions & 0 deletions app/features/ytdlp/patches.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import logging
import subprocess
import sys
from typing import Any

LOG: logging.Logger = logging.getLogger("ytdlp.utils")


def patch_metadataparser() -> None:
"""
Patches yt_dlp MetadataParserPP action to handle subprocess pickling issues.
"""
try:
from yt_dlp.postprocessor.metadataparser import MetadataParserPP
from yt_dlp.utils import Namespace
except Exception as exc:
LOG.warning(f"Unable to import yt_dlp metadata parser for patching: {exc!s}")
return

if getattr(MetadataParserPP.Actions, "_ytptube_patched", False):
return

class _ActionNS(Namespace):
_ACTIONS_STR: list[str] = []

@staticmethod
def _get_name(func) -> str | None:
if not callable(func):
return None

target = getattr(func, "__func__", func)
module_name = getattr(target, "__module__", None)
qual_name = getattr(target, "__qualname__", getattr(target, "__name__", None))

return f"{module_name}.{qual_name}" if module_name and qual_name else None

def __contains__(self, candidate: object) -> bool:
if candidate in self.__dict__.values():
return True

if func_name := _ActionNS._get_name(candidate):
if len(_ActionNS._ACTIONS_STR) < 1:
_ActionNS._ACTIONS_STR.extend(
[value for value in (_ActionNS._get_name(value) for value in self.__dict__.values()) if value]
)

return func_name in _ActionNS._ACTIONS_STR

return False

actions_dict: dict[str, Any] = dict(MetadataParserPP.Actions.items_)
MetadataParserPP.Actions = _ActionNS(**actions_dict)
MetadataParserPP.Actions._ytptube_patched = True
LOG.debug("MetadataParserPP action namespace patch applied successfully.")


def patch_windows_popen_wait() -> None:
if sys.platform != "win32":
return

try:
from yt_dlp.utils import Popen
except Exception as exc:
LOG.warning(f"Unable to import yt_dlp Popen for patching: {exc!s}")
return

if getattr(Popen, "_ytptube_wait_patched", False):
return

original_wait = Popen.wait

# Windows subprocess waits can swallow the synthetic interrupt we use to
# stop live downloads, especially while yt-dlp is blocked on ffmpeg.
def interruptible_wait(self, timeout=None):
if timeout is not None:
return original_wait(self, timeout=timeout)

while True:
try:
return original_wait(self, timeout=0.1)
except subprocess.TimeoutExpired:
continue

Popen.wait = interruptible_wait
Popen._ytptube_wait_patched = True
LOG.debug("yt_dlp Popen.wait Windows patch applied successfully.")


def apply_ytdlp_patches() -> None:
try:
patch_metadataparser()
except Exception as exc:
LOG.debug("Metadata parser patch failed to apply: %s", exc)

try:
patch_windows_popen_wait()
except Exception as exc:
LOG.debug("Windows Popen wait patch failed to apply: %s", exc)
56 changes: 56 additions & 0 deletions app/features/ytdlp/tests/test_ytdlp_module.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from unittest.mock import MagicMock, Mock, patch

from app.features.ytdlp.patches import patch_windows_popen_wait
from app.features.ytdlp.utils import _DATA
from app.features.ytdlp.ytdlp import YTDLP, _ArchiveProxy, ytdlp_options

Expand Down Expand Up @@ -145,6 +146,61 @@ def test_init_handles_none_params(self, mock_super_init) -> None:
assert isinstance(ytdlp.archive, _ArchiveProxy)
assert not ytdlp.archive

@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
def test_init_patches_windows_popen_wait_once(self, mock_super_init) -> None:
mock_super_init.return_value = None

class FakePopen:
def wait(self, timeout=None):
return timeout

with patch("app.features.ytdlp.patches.sys.platform", "win32"):
with patch("yt_dlp.utils.Popen", FakePopen):
YTDLP(params={})

assert getattr(FakePopen, "_ytptube_wait_patched", False) is True

def test_windows_wait_patch_uses_polling_for_blocking_wait(self) -> None:
calls: list[float | None] = []

class FakePopen:
_ytptube_wait_patched = False

def wait(self, timeout=None):
calls.append(timeout)
if len(calls) < 3:
raise TimeoutError
return 0

with patch("app.features.ytdlp.patches.sys.platform", "win32"):
with (
patch("yt_dlp.utils.Popen", FakePopen),
patch("app.features.ytdlp.patches.subprocess.TimeoutExpired", TimeoutError),
):
patch_windows_popen_wait()
result = FakePopen().wait()

assert result == 0
assert calls == [0.1, 0.1, 0.1]

def test_windows_wait_patch_preserves_explicit_timeout(self) -> None:
calls: list[float | None] = []

class FakePopen:
_ytptube_wait_patched = False

def wait(self, timeout=None):
calls.append(timeout)
return 0

with patch("app.features.ytdlp.patches.sys.platform", "win32"):
with patch("yt_dlp.utils.Popen", FakePopen):
patch_windows_popen_wait()
result = FakePopen().wait(timeout=5)

assert result == 0
assert calls == [5]

@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files")
def test_delete_downloaded_files_skips_when_interrupted(self, mock_super_delete) -> None:
"""Test _delete_downloaded_files skips cleanup when _interrupted is True."""
Expand Down
Loading
Loading