Merge pull request #512 from arabcoders/dev
Feat: set_preset key for condition to switch preset based on yt-dlp data
This commit is contained in:
commit
2214bc66de
16 changed files with 813 additions and 397 deletions
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
|
|
@ -117,10 +117,12 @@ jobs:
|
|||
working-directory: ui
|
||||
env:
|
||||
NODE_ENV: production
|
||||
run: pnpm install --frozen-lockfile --prod
|
||||
run: pnpm install --frozen-lockfile --prod --ignore-scripts
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: ui
|
||||
env:
|
||||
NODE_ENV: production
|
||||
run: pnpm run generate
|
||||
|
||||
- name: Install GitPython
|
||||
|
|
|
|||
73
API.md
73
API.md
|
|
@ -37,7 +37,7 @@ This document describes the available endpoints and their usage. All endpoints r
|
|||
- [POST /api/tasks/{id}/mark](#post-apitasksidmark)
|
||||
- [DELETE /api/tasks/{id}/mark](#delete-apitasksidmark)
|
||||
- [POST /api/tasks/{id}/metadata](#post-apitasksidmetadata)
|
||||
- [POST /api/tasks/{id}/toggle](#post-apitasksidtoggle)
|
||||
- [PATCH /api/tasks/{id}](#patch-apitasksid)
|
||||
- [GET /api/task\_definitions/](#get-apitask_definitions)
|
||||
- [GET /api/task\_definitions/{identifier}](#get-apitask_definitionsidentifier)
|
||||
- [POST /api/task\_definitions/](#post-apitask_definitions)
|
||||
|
|
@ -783,11 +783,29 @@ or on error
|
|||
{
|
||||
"url": "https://example.com/feed", // required, validated URL
|
||||
"preset": "news-preset", // optional preset override - In real scenarios, the preset come from the task.
|
||||
"handler": "GenericTaskHandler" // optional explicit handler class name, in real scenarios, the handler is resolved automatically.
|
||||
"handler": "GenericTaskHandler", // optional explicit handler class name, in real scenarios, the handler is resolved automatically.
|
||||
"static_only": false // optional, if true performs only can_handle check without extracting items (faster, lightweight)
|
||||
}
|
||||
```
|
||||
|
||||
**Response (success)**:
|
||||
**Notes**:
|
||||
- When `static_only` is `true`, the endpoint only checks if a handler can process the URL using the `can_handle` method
|
||||
- This is much faster and lighter than the full inspection which calls the handler's `extract` method
|
||||
- Use `static_only: true` for quick validation checks (e.g., UI indicators)
|
||||
- Use `static_only: false` (default) for full inspection with item extraction
|
||||
|
||||
**Response (success with static_only: true)**:
|
||||
```json
|
||||
{
|
||||
"items": [],
|
||||
"metadata": {
|
||||
"matched": true,
|
||||
"handler": "GenericTaskHandler"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response (success with static_only: false)**:
|
||||
```json
|
||||
{
|
||||
"matched": true,
|
||||
|
|
@ -924,42 +942,63 @@ or
|
|||
|
||||
---
|
||||
|
||||
### POST /api/tasks/{id}/toggle
|
||||
**Purpose**: Toggle the enabled/disabled status of a scheduled task.
|
||||
### PATCH /api/tasks/{id}
|
||||
**Purpose**: Update specific fields of a scheduled task.
|
||||
|
||||
**Path Parameter**:
|
||||
- `id`: Task ID.
|
||||
|
||||
**Request Body**:
|
||||
JSON object with fields to update:
|
||||
```json
|
||||
{
|
||||
"enabled": false,
|
||||
"handler_enabled": true,
|
||||
"timer": "0 */6 * * *",
|
||||
"preset": "audio",
|
||||
"folder": "downloads/music",
|
||||
"template": "%(title)s.%(ext)s",
|
||||
"cli": "--extract-audio",
|
||||
"auto_start": true
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
- Only include fields you want to update
|
||||
- All fields are optional
|
||||
|
||||
**Response**:
|
||||
Returns the updated task object:
|
||||
Returns the updated task
|
||||
```json
|
||||
{
|
||||
"id": "task_id",
|
||||
"name": "Task Name",
|
||||
"url": "https://example.com/playlist",
|
||||
"preset": "default",
|
||||
"folder": "",
|
||||
"template": "",
|
||||
"cli": "",
|
||||
"preset": "audio",
|
||||
"folder": "downloads/music",
|
||||
"template": "%(title)s.%(ext)s",
|
||||
"cli": "--extract-audio",
|
||||
"timer": "0 */6 * * *",
|
||||
"auto_start": true,
|
||||
"handler_enabled": true,
|
||||
"enabled": false
|
||||
}
|
||||
```
|
||||
or
|
||||
|
||||
**Error Responses**:
|
||||
```json
|
||||
{ "error": "Task 'task_id' does not exist." }
|
||||
{ "error": "Invalid JSON in request body.", "message": "..." }
|
||||
{ "error": "Request body must be a JSON object." }
|
||||
{ "error": "No valid fields to update." }
|
||||
{ "error": "Validation failed: Invalid timer format." }
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
- When a task is disabled (`enabled: false`), it will not be executed by the scheduler
|
||||
|
||||
**Status Codes**:
|
||||
- `200 OK` - Task status toggled successfully, returns updated task object
|
||||
- `400 Bad Request` - Missing task ID
|
||||
- `200 OK` - Task updated successfully
|
||||
- `400 Bad Request` - Missing task ID, invalid JSON, no valid fields, or validation error
|
||||
- `404 Not Found` - Task does not exist
|
||||
- `500 Internal Server Error` - Failed to update task status
|
||||
- `500 Internal Server Error` - Failed to update task
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
10
Dockerfile
10
Dockerfile
|
|
@ -3,7 +3,12 @@ FROM node:lts-alpine AS node_builder
|
|||
|
||||
WORKDIR /app
|
||||
COPY ui ./
|
||||
RUN if [ ! -f "/app/exported/index.html" ]; then npm install --production --prefer-offline --frozen-lockfile && npm run generate; else echo "Skipping UI build, already built."; fi
|
||||
ENV NODE_ENV=production
|
||||
RUN if [ ! -f "/app/exported/index.html" ]; then \
|
||||
npm install -g pnpm && \
|
||||
NODE_ENV=production pnpm install --frozen-lockfile --prod --ignore-scripts && \
|
||||
pnpm run generate; \
|
||||
else echo "Skipping UI build, already built."; fi
|
||||
|
||||
FROM python:3.13-bookworm AS python_builder
|
||||
|
||||
|
|
@ -17,8 +22,7 @@ ENV UV_CACHE_DIR=/root/.cache/uv
|
|||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV UV_INSTALL_DIR=/usr/bin
|
||||
|
||||
SHELL ["/bin/bash","-lc"]
|
||||
RUN echo 1 && curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
COPY --from=astral/uv:latest /uv /usr/bin/
|
||||
|
||||
WORKDIR /opt/
|
||||
|
||||
|
|
|
|||
|
|
@ -828,6 +828,17 @@ class DownloadQueue(metaclass=Singleton):
|
|||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
if condition.extras.get("set_preset") and (target_preset := condition.extras.get("set_preset")):
|
||||
if Presets.get_instance().has(target_preset):
|
||||
log_message: str = f"Switching preset from '{item.preset}' to '{target_preset}' for '{item_title}' as per condition '{condition.name}'."
|
||||
LOG.info(log_message)
|
||||
self._notify.emit(Events.LOG_INFO, data={}, title="Preset Switched", message=log_message)
|
||||
item = item.new_with(preset=target_preset)
|
||||
else:
|
||||
LOG.warning(
|
||||
f"Preset '{target_preset}' specified in condition '{condition.name}' does not exist. Ignoring set_preset."
|
||||
)
|
||||
|
||||
return await self.add(item=item.new_with(requeued=True, cli=condition.cli), already=already)
|
||||
|
||||
_status, _msg = ytdlp_reject(entry=entry, yt_params=yt_conf)
|
||||
|
|
|
|||
|
|
@ -884,6 +884,7 @@ class HandleTask:
|
|||
url: str,
|
||||
preset: str | None = None,
|
||||
handler_name: str | None = None,
|
||||
static_only: bool = False,
|
||||
) -> TaskResult | TaskFailure:
|
||||
if not self._handlers:
|
||||
self._handlers = self._discover()
|
||||
|
|
@ -937,6 +938,9 @@ class HandleTask:
|
|||
|
||||
base_metadata: dict[str, Any] = {"matched": True, "handler": handler_cls.__name__}
|
||||
|
||||
if static_only:
|
||||
return TaskResult(items=[], metadata=base_metadata)
|
||||
|
||||
try:
|
||||
extraction: TaskResult | TaskFailure = await services.handle_async(
|
||||
handler=handler_cls.extract, task=task, config=self._config
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ class YTDLPCli:
|
|||
|
||||
if self.preset:
|
||||
if self.preset.cookies and cookie_file is None:
|
||||
cookie_file = self.preset.get_cookie_file(config=self._config)
|
||||
cookie_file = self.preset.get_cookies_file(config=self._config)
|
||||
|
||||
if self.preset.folder and save_path is None:
|
||||
save_path = str(Path(self._config.download_path) / self.preset.folder.lstrip("/"))
|
||||
|
|
|
|||
|
|
@ -20,22 +20,38 @@ LOG: logging.Logger = logging.getLogger(__name__)
|
|||
|
||||
@route("POST", "api/tasks/inspect", "task_handler_inspect")
|
||||
async def task_handler_inspect(request: Request, tasks: Tasks, encoder: Encoder, config: Config) -> Response:
|
||||
"""
|
||||
Check if handler can process the given URL.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
tasks (Tasks): The tasks instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
config (Config): The config instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
data = await request.json()
|
||||
|
||||
url: str | None = data.get("url") if isinstance(data, dict) else None
|
||||
if not url:
|
||||
return web.json_response({"error": "url is required."}, status=web.HTTPBadRequest.status_code)
|
||||
try:
|
||||
validate_url(url, allow_internal=config.allow_internal_urls)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
static_only: bool = data.get("static_only", False) if isinstance(data, dict) else False
|
||||
if not static_only:
|
||||
try:
|
||||
validate_url(url, allow_internal=config.allow_internal_urls)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
preset: str = data.get("preset", "") if isinstance(data, dict) else ""
|
||||
handler_name: str | None = data.get("handler") if isinstance(data, dict) else None
|
||||
|
||||
try:
|
||||
result: TaskResult | TaskFailure = await tasks.get_handler().inspect(
|
||||
url=url, preset=preset, handler_name=handler_name
|
||||
url=url, preset=preset, handler_name=handler_name, static_only=static_only
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
|
|
@ -345,10 +361,10 @@ async def task_metadata(request: Request, config: Config, encoder: Encoder) -> R
|
|||
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
|
||||
@route("POST", "api/tasks/{id}/toggle", "tasks_toggle_enabled")
|
||||
async def task_toggle_enabled(request: Request, encoder: Encoder) -> Response:
|
||||
@route("PATCH", "api/tasks/{id}", "tasks_update")
|
||||
async def task_update(request: Request, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Toggle the enabled status of a task.
|
||||
Update specific fields of a task.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
|
|
@ -358,11 +374,23 @@ async def task_toggle_enabled(request: Request, encoder: Encoder) -> Response:
|
|||
Response: The response object
|
||||
|
||||
"""
|
||||
task_id: str = request.match_info.get("id", None)
|
||||
|
||||
if not task_id:
|
||||
if not (task_id := request.match_info.get("id", None)):
|
||||
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception as e:
|
||||
return web.json_response(
|
||||
data={"error": "Invalid JSON in request body.", "message": str(e)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Request body must be a JSON object."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
tasks: Tasks = Tasks.get_instance()
|
||||
try:
|
||||
task: Task | None = tasks.get(task_id)
|
||||
|
|
@ -371,13 +399,34 @@ async def task_toggle_enabled(request: Request, encoder: Encoder) -> Response:
|
|||
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
|
||||
)
|
||||
|
||||
task.enabled = not task.enabled
|
||||
task_dict: dict = task.serialize()
|
||||
protected_fields: set[str] = {"id"}
|
||||
updated: bool = False
|
||||
|
||||
for key, value in data.items():
|
||||
if key in protected_fields or key not in task_dict:
|
||||
continue
|
||||
|
||||
setattr(task, key, value)
|
||||
updated = True
|
||||
|
||||
if not updated:
|
||||
return web.json_response(
|
||||
data={"error": "No valid fields to update."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
Tasks.validate(task)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": f"Validation failed: {e!s}"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
tasks.save(tasks=tasks.get_all()).load()
|
||||
|
||||
return web.json_response(data=tasks.get(task_id), status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
return web.json_response(data=task, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to toggle task status.", "message": str(e)},
|
||||
data={"error": "Failed to update task.", "message": str(e)},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,18 +38,20 @@
|
|||
},
|
||||
"examples": [
|
||||
{
|
||||
"id": "c1e2d3f4-5678-1234-9abc-def012345678",
|
||||
"name": "Audio Only",
|
||||
"filter": "type=audio",
|
||||
"cli": "--extract-audio",
|
||||
"id": "a2b3c4d5-6789-2345-0bcd-ef1234567890",
|
||||
"name": "Long Videos",
|
||||
"filter": "duration>3600",
|
||||
"extras": {
|
||||
"priority": "high"
|
||||
"ignore_download": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a2b3c4d5-6789-2345-0bcd-ef1234567890",
|
||||
"name": "Long Videos",
|
||||
"filter": "duration>3600"
|
||||
"id": "b3c4d5e6-7890-3456-1cde-f23456789012",
|
||||
"name": "Use Audio Preset for Music",
|
||||
"filter": "categories~='Music'",
|
||||
"extras": {
|
||||
"set_preset": "audio-only"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -921,3 +921,156 @@ class TestTasks:
|
|||
|
||||
# Verify download queue was NOT called (task was skipped)
|
||||
mock_download_queue_instance.add.assert_not_called()
|
||||
|
||||
|
||||
class TestHandleTaskInspect:
|
||||
"""Test the HandleTask inspect method with static_only parameter."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
@patch("app.library.Tasks.Services")
|
||||
async def test_inspect_with_static_only_true(
|
||||
self, mock_services, mock_download_queue, mock_scheduler, mock_eventbus, mock_config
|
||||
):
|
||||
"""Test inspect with static_only=True returns immediately after can_handle check."""
|
||||
mock_config_instance = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
mock_scheduler.get_instance.return_value = Mock()
|
||||
mock_download_queue.get_instance.return_value = Mock()
|
||||
|
||||
# Mock Services to simulate successful can_handle
|
||||
mock_services_instance = Mock()
|
||||
mock_services_instance.handle_sync = Mock(return_value=True)
|
||||
mock_services_instance.handle_async = AsyncMock() # Should NOT be called with static_only=True
|
||||
mock_services.get_instance.return_value = mock_services_instance
|
||||
|
||||
# Create a mock handler
|
||||
mock_handler = Mock()
|
||||
mock_handler.__name__ = "TestHandler"
|
||||
mock_handler.can_handle = Mock(return_value=True)
|
||||
mock_handler.extract = AsyncMock() # Should NOT be called with static_only=True
|
||||
|
||||
tasks = Tasks(file=None, config=mock_config_instance)
|
||||
handler = tasks.get_handler()
|
||||
handler._handlers = [mock_handler]
|
||||
|
||||
# Call inspect with static_only=True
|
||||
result = await handler.inspect(
|
||||
url="https://example.com/feed", preset="default", handler_name=None, static_only=True
|
||||
)
|
||||
|
||||
# Verify result structure
|
||||
assert hasattr(result, "items")
|
||||
assert hasattr(result, "metadata")
|
||||
assert result.items == []
|
||||
assert result.metadata["matched"] is True
|
||||
assert result.metadata["handler"] == "TestHandler"
|
||||
|
||||
# Verify handle_async (extract) was NOT called
|
||||
mock_services_instance.handle_async.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
@patch("app.library.Tasks.Services")
|
||||
async def test_inspect_with_static_only_false(
|
||||
self, mock_services, mock_download_queue, mock_scheduler, mock_eventbus, mock_config
|
||||
):
|
||||
"""Test inspect with static_only=False performs full extraction."""
|
||||
from app.library.Tasks import TaskResult
|
||||
|
||||
mock_config_instance = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
mock_scheduler.get_instance.return_value = Mock()
|
||||
mock_download_queue.get_instance.return_value = Mock()
|
||||
|
||||
# Mock Services
|
||||
mock_services_instance = Mock()
|
||||
mock_services_instance.handle_sync = Mock(return_value=True)
|
||||
|
||||
# Mock successful extract result
|
||||
mock_extract_result = TaskResult(
|
||||
items=[{"url": "https://example.com/video1", "title": "Video 1"}],
|
||||
metadata={"raw_count": 1},
|
||||
)
|
||||
mock_services_instance.handle_async = AsyncMock(return_value=mock_extract_result)
|
||||
mock_services.get_instance.return_value = mock_services_instance
|
||||
|
||||
# Create a mock handler
|
||||
mock_handler = Mock()
|
||||
mock_handler.__name__ = "TestHandler"
|
||||
mock_handler.can_handle = Mock(return_value=True)
|
||||
mock_handler.extract = AsyncMock(return_value=mock_extract_result)
|
||||
|
||||
tasks = Tasks(file=None, config=mock_config_instance)
|
||||
handler = tasks.get_handler()
|
||||
handler._handlers = [mock_handler]
|
||||
|
||||
# Call inspect with static_only=False (default)
|
||||
result = await handler.inspect(url="https://example.com/feed", preset="default", handler_name=None)
|
||||
|
||||
# Verify result structure includes extracted items
|
||||
assert hasattr(result, "items")
|
||||
assert hasattr(result, "metadata")
|
||||
assert len(result.items) > 0
|
||||
assert result.metadata["matched"] is True
|
||||
assert result.metadata["handler"] == "TestHandler"
|
||||
assert result.metadata["supported"] is True
|
||||
|
||||
# Verify handle_async (extract) WAS called
|
||||
mock_services_instance.handle_async.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
@patch("app.library.Tasks.Services")
|
||||
async def test_inspect_static_only_with_no_handler_match(
|
||||
self, mock_services, mock_download_queue, mock_scheduler, mock_eventbus, mock_config
|
||||
):
|
||||
"""Test inspect with static_only=True when no handler matches."""
|
||||
from app.library.Tasks import TaskFailure
|
||||
|
||||
mock_config_instance = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
mock_scheduler.get_instance.return_value = Mock()
|
||||
mock_download_queue.get_instance.return_value = Mock()
|
||||
|
||||
# Mock Services to simulate failed can_handle
|
||||
mock_services_instance = Mock()
|
||||
mock_services_instance.handle_sync = Mock(return_value=False)
|
||||
mock_services.get_instance.return_value = mock_services_instance
|
||||
|
||||
# Create a mock handler that won't match
|
||||
mock_handler = Mock()
|
||||
mock_handler.__name__ = "TestHandler"
|
||||
mock_handler.can_handle = Mock(return_value=False)
|
||||
|
||||
tasks = Tasks(file=None, config=mock_config_instance)
|
||||
handler = tasks.get_handler()
|
||||
handler._handlers = [mock_handler]
|
||||
|
||||
# Call inspect with static_only=True
|
||||
result = await handler.inspect(
|
||||
url="https://unsupported.com/feed", preset="default", handler_name=None, static_only=True
|
||||
)
|
||||
|
||||
# Verify result is a TaskFailure
|
||||
assert isinstance(result, TaskFailure)
|
||||
assert result.metadata["matched"] is False
|
||||
assert result.metadata["handler"] is None
|
||||
|
|
|
|||
|
|
@ -960,7 +960,7 @@ class TestYTDLPCli:
|
|||
preset = Mock(spec=Preset)
|
||||
preset.name = "test_preset"
|
||||
preset.cookies = "preset_cookies"
|
||||
preset.get_cookie_file = Mock(return_value=mock_cookie_path)
|
||||
preset.get_cookies_file = Mock(return_value=mock_cookie_path)
|
||||
preset.folder = None
|
||||
preset.template = None
|
||||
preset.cli = None
|
||||
|
|
@ -972,7 +972,7 @@ class TestYTDLPCli:
|
|||
cli = YTDLPCli(item=item)
|
||||
command, _ = cli.build()
|
||||
|
||||
preset.get_cookie_file.assert_called_once_with(config=mock_config_instance)
|
||||
preset.get_cookies_file.assert_called_once_with(config=mock_config_instance)
|
||||
assert "--cookies" in command
|
||||
assert "/preset/cookies.txt" in command
|
||||
|
||||
|
|
|
|||
|
|
@ -164,6 +164,10 @@
|
|||
<b>YTPTube</b> to ignore the download and directly mark the item as archived. this is useful
|
||||
to skip certain kind of downloads.
|
||||
</li>
|
||||
<li>The key <code>set_preset</code> with the name of an existing preset will instruct
|
||||
<b>YTPTube</b> to switch the download to use the specified preset. This is useful
|
||||
to apply different download settings based on content type or source.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -170,9 +170,13 @@
|
|||
</span>
|
||||
</span>
|
||||
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-rss" /></span>
|
||||
<span>{{ item.handler_enabled ? 'Enabled' : 'Disabled' }}</span>
|
||||
<span class="icon-text is-clickable" @click="toggleHandlerEnabled(item)"
|
||||
v-tooltip="'Click to ' + (item.handler_enabled !== false ? 'disable' : 'enable') + ' task handler'">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-rss"
|
||||
:class="{ 'has-text-success': item.handler_enabled !== false, 'has-text-danger': item.handler_enabled === false }" />
|
||||
</span>
|
||||
<span>{{ item.handler_enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
||||
</span>
|
||||
|
||||
<span class="icon-text" v-if="item.preset">
|
||||
|
|
@ -185,9 +189,13 @@
|
|||
<span v-if="item.timer" class="has-tooltip" v-tooltip="item.timer">
|
||||
{{ tryParse(item.timer) }}
|
||||
</span>
|
||||
<span v-else class="has-text-danger">
|
||||
<span v-else-if="!willTaskBeProcessed(item)" class="has-text-danger">
|
||||
<span class="icon"> <i class="fa-solid fa-exclamation" /> </span>
|
||||
No timer is set
|
||||
No timer or handler
|
||||
</span>
|
||||
<span v-else>
|
||||
<span class="icon"> <i class="fa-solid fa-rss" /> </span>
|
||||
Handler only
|
||||
</span>
|
||||
</td>
|
||||
<td class="is-vcentered is-items-center">
|
||||
|
|
@ -278,10 +286,11 @@
|
|||
:class="{ 'fa-circle-pause has-text-success': item.auto_start, 'fa-circle-play has-text-danger': !item.auto_start }" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="control">
|
||||
<span class="icon" v-tooltip="`Task handler is ${item.handler_enabled ? 'enabled' : 'disabled'}`">
|
||||
<div class="control is-clickable" @click="toggleHandlerEnabled(item)">
|
||||
<span class="icon"
|
||||
v-tooltip="`Task handler is ${item.handler_enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
|
||||
<i class="fa-solid fa-rss"
|
||||
:class="{ 'has-text-success': item.handler_enabled, 'has-text-danger': !item.handler_enabled }" />
|
||||
:class="{ 'has-text-success': item.handler_enabled !== false, 'has-text-danger': item.handler_enabled === false }" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="control is-clickable" @click="toggleEnabled(item)">
|
||||
|
|
@ -308,15 +317,19 @@
|
|||
<div class="content">
|
||||
<p class="is-text-overflow">
|
||||
<span class="icon">
|
||||
<i class="fa-solid"
|
||||
:class="{ 'fa-clock': item.timer, 'has-text-danger fa-exclamation': !item.timer }" />
|
||||
<i class="fa-solid" :class="{
|
||||
'fa-clock': item.timer,
|
||||
'fa-rss': !item.timer && willTaskBeProcessed(item),
|
||||
'has-text-danger fa-exclamation': !willTaskBeProcessed(item)
|
||||
}" />
|
||||
</span>
|
||||
<span v-if="item.timer">
|
||||
<NuxtLink target="_blank" :to="`https://crontab.guru/#${item.timer.replace(/ /g, '_')}`">
|
||||
{{ item.timer }} - {{ tryParse(item.timer) }}
|
||||
</NuxtLink>
|
||||
</span>
|
||||
<span class="has-text-danger" v-else>No timer is set</span>
|
||||
<span v-else-if="willTaskBeProcessed(item)">Handler only</span>
|
||||
<span v-else class="has-text-danger">No timer or handler</span>
|
||||
</p>
|
||||
<p class="is-text-overflow" v-if="item.folder">
|
||||
<span class="icon"><i class="fa-solid fa-folder" /></span>
|
||||
|
|
@ -458,12 +471,14 @@ import { useConfirm } from '~/composables/useConfirm'
|
|||
import TaskInspect from '~/components/TaskInspect.vue'
|
||||
import type { task_item, exported_task, error_response } from '~/types/tasks'
|
||||
import { sleep } from '~/utils'
|
||||
import { useSessionCache } from '~/utils/cache'
|
||||
|
||||
const box = useConfirm()
|
||||
const toast = useNotification()
|
||||
const config = useConfigStore()
|
||||
const socket = useSocketStore()
|
||||
const { confirmDialog: cDialog } = useDialog()
|
||||
const sessionCache = useSessionCache()
|
||||
const display_style = useStorage<string>("tasks_display_style", "cards")
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||
|
||||
|
|
@ -527,6 +542,66 @@ watch(() => socket.isConnected, async () => {
|
|||
}
|
||||
})
|
||||
|
||||
const CACHE_KEY = 'tasks:handler_support'
|
||||
const taskHandlerSupport = ref<Record<string, boolean>>(sessionCache.get(CACHE_KEY) || {})
|
||||
watch(taskHandlerSupport, (newValue) => sessionCache.set(CACHE_KEY, newValue), { deep: true })
|
||||
|
||||
const getCacheKey = (task: task_item): string => `${task.id}:${task.url}`
|
||||
|
||||
const cleanStaleCache = (currentTasks: Array<task_item>) => {
|
||||
const validKeys = new Set(currentTasks.map(task => getCacheKey(task)))
|
||||
const cacheKeys = Object.keys(taskHandlerSupport.value)
|
||||
|
||||
for (const key of cacheKeys) {
|
||||
if (!validKeys.has(key)) {
|
||||
taskHandlerSupport.value[key] = undefined as any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const recheckHandlerSupport = async (updatedTasks: Array<task_item>) => {
|
||||
for (const task of updatedTasks) {
|
||||
if (!task.timer && false !== task.handler_enabled) {
|
||||
await checkHandlerSupport(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkHandlerSupport = async (task: task_item): Promise<boolean> => {
|
||||
const cacheKey = getCacheKey(task)
|
||||
|
||||
if (undefined !== taskHandlerSupport.value[cacheKey]) {
|
||||
return taskHandlerSupport.value[cacheKey] as boolean
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request('/api/tasks/inspect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: task.url, static_only: true })
|
||||
})
|
||||
const data = await response.json()
|
||||
const supported = true === data?.matched
|
||||
taskHandlerSupport.value[cacheKey] = supported
|
||||
return supported
|
||||
} catch {
|
||||
taskHandlerSupport.value[cacheKey] = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const willTaskBeProcessed = (task: task_item): boolean => {
|
||||
if (false === task.enabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
const hasTimer = !!(task.timer && task.timer.trim())
|
||||
const cacheKey = getCacheKey(task)
|
||||
const hasHandler = false !== task.handler_enabled && true === taskHandlerSupport.value[cacheKey]
|
||||
|
||||
return hasTimer || hasHandler
|
||||
}
|
||||
|
||||
const filteredTasks = computed<task_item[]>(() => {
|
||||
const q = query.value?.toLowerCase();
|
||||
if (!q) return tasks.value;
|
||||
|
|
@ -551,6 +626,8 @@ const reloadContent = async (fromMounted: boolean = false) => {
|
|||
}
|
||||
|
||||
tasks.value = data
|
||||
cleanStaleCache(data)
|
||||
await recheckHandlerSupport(data)
|
||||
} catch (e) {
|
||||
if (fromMounted) {
|
||||
return
|
||||
|
|
@ -602,6 +679,8 @@ const updateTasks = async (items: Array<task_item>) => {
|
|||
}
|
||||
|
||||
tasks.value = data
|
||||
cleanStaleCache(data)
|
||||
await recheckHandlerSupport(data)
|
||||
resetForm(true)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
|
|
@ -686,7 +765,11 @@ const toggleEnabled = async (item: task_item) => {
|
|||
const actionText = newStatus ? 'enable' : 'disable'
|
||||
|
||||
try {
|
||||
const response = await request(`/api/tasks/${item.id}/toggle`, { method: 'POST' })
|
||||
const response = await request(`/api/tasks/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: newStatus })
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (data?.error) {
|
||||
|
|
@ -702,6 +785,31 @@ const toggleEnabled = async (item: task_item) => {
|
|||
}
|
||||
}
|
||||
|
||||
const toggleHandlerEnabled = async (item: task_item) => {
|
||||
const newStatus = !item.handler_enabled
|
||||
const actionText = newStatus ? 'enable' : 'disable'
|
||||
|
||||
try {
|
||||
const response = await request(`/api/tasks/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ handler_enabled: newStatus })
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (data?.error) {
|
||||
toast.error(data.error)
|
||||
return
|
||||
}
|
||||
|
||||
item.handler_enabled = data.handler_enabled
|
||||
const func = data.handler_enabled ? 'success' : 'warning'
|
||||
toast[func](`Task handler for '${item.name}' ${data.handler_enabled ? 'enabled' : 'disabled'}.`)
|
||||
} catch (e: any) {
|
||||
toast.error(`Failed to ${actionText} task handler. ${e.message || 'Unknown error.'}`)
|
||||
}
|
||||
}
|
||||
|
||||
const updateItem = async ({ reference, task, archive_all }: { reference?: string | null | undefined, task: task_item, archive_all?: boolean }) => {
|
||||
if (reference) {
|
||||
// -- find the task index.
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export default defineNuxtConfig({
|
|||
'@vueuse/nuxt',
|
||||
'floating-vue/nuxt',
|
||||
process.env.NODE_ENV === 'development' ? '@nuxt/eslint' : '',
|
||||
],
|
||||
].filter(Boolean),
|
||||
|
||||
nitro: {
|
||||
output: {
|
||||
|
|
|
|||
|
|
@ -54,10 +54,10 @@
|
|||
"devDependencies": {
|
||||
"@nuxt/eslint": "^1.11.0",
|
||||
"@nuxt/eslint-config": "^1.11.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.1",
|
||||
"eslint": "^9.39.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.14",
|
||||
"vitest": "^4.0.15",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.1.5"
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
42
uv.lock
42
uv.lock
|
|
@ -128,7 +128,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "apprise"
|
||||
version = "1.9.5"
|
||||
version = "1.9.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
|
|
@ -139,9 +139,9 @@ dependencies = [
|
|||
{ name = "requests-oauthlib" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/16/e39338b8310af9466fab6f4482b542e24cb1fcbb7e36bf00c089c4e015e7/apprise-1.9.5.tar.gz", hash = "sha256:8f3be318bb429c2017470e33928a2e313cbf7600fc74b8184782a37060db366a", size = 1877134 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/a7/bb182d81f35c3fe405505f0976da4b74f942cfdd53c7193b0fe50412aa27/apprise-1.9.6.tar.gz", hash = "sha256:4206be9cb5694a3d08dd8e0393bbb9b36212ac3a7769c2633620055e75c6caef", size = 1921714 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f1/318762320d966e528dfb9e6be5953fe7df2952156f15ba857cbccafb630c/apprise-1.9.5-py3-none-any.whl", hash = "sha256:1873a8a1b8cf9e44fcbefe0486ed260b590652aea12427f545b37c8566142961", size = 1421011 },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/df/343d125241f8cd3c9af58fd09688cf2bf59cc1edfd609adafef3556ce8ec/apprise-1.9.6-py3-none-any.whl", hash = "sha256:2fd18e8a5251b6a12f6f9d169f1d895d458d1de36a5faee4db149cedcce51674", size = 1452059 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -866,11 +866,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.5.0"
|
||||
version = "4.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1167,7 +1167,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.1"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
|
|
@ -1176,9 +1176,9 @@ dependencies = [
|
|||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1510,7 +1510,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "selenium"
|
||||
version = "4.38.0"
|
||||
version = "4.39.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
|
|
@ -1520,9 +1520,9 @@ dependencies = [
|
|||
{ name = "urllib3", extra = ["socks"] },
|
||||
{ name = "websocket-client" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/a0/60a5e7e946420786d57816f64536e21a29f0554706b36f3cba348107024c/selenium-4.38.0.tar.gz", hash = "sha256:c117af6727859d50f622d6d0785b945c5db3e28a45ec12ad85cee2e7cc84fc4c", size = 924101 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/19/27c1bf9eb1f7025632d35a956b50746efb4b10aa87f961b263fa7081f4c5/selenium-4.39.0.tar.gz", hash = "sha256:12f3325f02d43b6c24030fc9602b34a3c6865abbb1db9406641d13d108aa1889", size = 928575 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/d3/76c8f4a8d99b9f1ebcf9a611b4dd992bf5ee082a6093cfc649af3d10f35b/selenium-4.38.0-py3-none-any.whl", hash = "sha256:ed47563f188130a6fd486b327ca7ba48c5b11fb900e07d6457befdde320e35fd", size = 9694571 },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/d0/55a6b7c6f35aad4c8a54be0eb7a52c1ff29a59542fc3e655f0ecbb14456d/selenium-4.39.0-py3-none-any.whl", hash = "sha256:c85f65d5610642ca0f47dae9d5cc117cd9e831f74038bc09fe1af126288200f9", size = 9655249 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1645,11 +1645,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.5.0"
|
||||
version = "2.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/43/554c2569b62f49350597348fc3ac70f786e3c32e7f19d266e19817812dd3/urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1", size = 432585 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/1a/9ffe814d317c5224166b23e7c47f606d6e473712a2fad0f704ea9b99f246/urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f", size = 131083 },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -1787,11 +1787,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "yt-dlp"
|
||||
version = "2025.11.12"
|
||||
version = "2025.12.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cf/41/53ad8c6e74d6627bd598dfbb8ad7c19d5405e438210ad0bbaf1b288387e7/yt_dlp-2025.11.12.tar.gz", hash = "sha256:5f0795a6b8fc57a5c23332d67d6c6acf819a0b46b91a6324bae29414fa97f052", size = 3076928 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/77/db924ebbd99d0b2b571c184cb08ed232cf4906c6f9b76eed763cd2c84170/yt_dlp-2025.12.8.tar.gz", hash = "sha256:b773c81bb6b71cb2c111cfb859f453c7a71cf2ef44eff234ff155877184c3e4f", size = 3088947 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/16/fdebbee6473473a1c0576bd165a50e4a70762484d638c1d59fa9074e175b/yt_dlp-2025.11.12-py3-none-any.whl", hash = "sha256:b47af37bbb16b08efebb36825a280ea25a507c051f93bf413a6e4a0e586c6e79", size = 3279151 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/2f/98c3596ad923f8efd32c90dca62e241e8ad9efcebf20831173c357042ba0/yt_dlp-2025.12.8-py3-none-any.whl", hash = "sha256:36e2584342e409cfbfa0b5e61448a1c5189e345cf4564294456ee509e7d3e065", size = 3291464 },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -1809,11 +1809,11 @@ default = [
|
|||
|
||||
[[package]]
|
||||
name = "yt-dlp-ejs"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/02/58b16dee54ad7f9f8c4b5b490960478dbbd31a27da4be2c876d8c09ac8e3/yt_dlp_ejs-0.3.1.tar.gz", hash = "sha256:7f2119eb02864800f651fa33825ddfe13d152a1f730fa103d9864f091df24227", size = 33805 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/72/57d02cf78eb45126bd171298d6a58a5bd48ce1a398b6b7ff00fc904f1f0c/yt_dlp_ejs-0.3.2.tar.gz", hash = "sha256:31a41292799992bdc913e03c9fac2a8c90c82a5cbbc792b2e3373b01da841e3e", size = 34678 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/fd/34fbdaf0d53386c47e219c532a479766cd9336fde34c00834c8e0123df7a/yt_dlp_ejs-0.3.1-py3-none-any.whl", hash = "sha256:a6e3548874db7c774388931752bb46c7f4642c044b2a189e56968f3d5ecab622", size = 53155 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/0d/1f0d7a735ca60b87953271b15d00eff5eef05f6118390ddf6f81982526ed/yt_dlp_ejs-0.3.2-py3-none-any.whl", hash = "sha256:f2dc6b3d1b909af1f13e021621b0af048056fca5fb07c4db6aa9bbb37a4f66a9", size = 53252 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Reference in a new issue