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
45 changes: 45 additions & 0 deletions packages/hermes-api/app/api/v1/endpoints/downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,49 @@
logger = get_logger(__name__)


def _download_options_from_request(download_request: DownloadRequest) -> dict:
"""Map public download options onto task/yt-dlp keyword arguments."""
options = {}

if download_request.output_template:
options["output_template"] = download_request.output_template
if download_request.download_subtitles:
options["writesubtitles"] = True
if download_request.download_thumbnail:
options["writethumbnail"] = True
if download_request.subtitle_languages:
options["subtitleslangs"] = download_request.subtitle_languages
if download_request.cookie_file:
options["cookiefile"] = download_request.cookie_file
if download_request.cookies:
options["http_headers"] = {
"Cookie": "; ".join(
f"{name}={value}" for name, value in download_request.cookies.items()
)
}
if download_request.browser_cookies and download_request.browser_cookies.get(
"browser"
):
options["cookiesfrombrowser"] = tuple(
download_request.browser_cookies.get(key)
for key in ("browser", "profile", "keyring", "container")
)

return options


def _batch_download_options_from_request(batch_request: BatchDownloadRequest) -> dict:
"""Map public batch download options onto task/yt-dlp keyword arguments."""
options = {}

if batch_request.download_subtitles:
options["writesubtitles"] = True
if batch_request.download_thumbnail:
options["writethumbnail"] = True

return options


def get_repositories_from_session(db_session: AsyncSession):
"""Create repository instances using the provided database session."""
return {
Expand Down Expand Up @@ -108,6 +151,7 @@ async def start_download(
"url": download_request.url,
"format_spec": download_request.format,
"output_path": download_request.output_directory,
**_download_options_from_request(download_request),
},
queue="hermes.downloads",
)
Expand Down Expand Up @@ -305,6 +349,7 @@ async def start_batch_download(
"urls": batch_request.urls,
"format_spec": batch_request.format,
"output_directory": batch_request.output_directory,
**_batch_download_options_from_request(batch_request),
},
queue="hermes.downloads",
)
Expand Down
17 changes: 14 additions & 3 deletions packages/hermes-api/app/tasks/download_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ async def _download_video_task(
url: str,
format_spec: str = "best",
output_path: str = None,
output_template: str = None,
**kwargs,
) -> Dict[str, Any]:
"""Core download logic."""
Expand Down Expand Up @@ -333,15 +334,23 @@ async def _download_video_task(
result=result_data, # Include result for SSE
)

output_filename = (
os.path.basename(output_template)
if output_template
else f"{sanitized_title}.%(ext)s"
)

if not output_filename:
output_filename = f"{sanitized_title}.%(ext)s"

# Generate output path if not provided
if not output_path:
output_path = os.path.join(
os.getenv("HERMES_DOWNLOADS_DIR", "./downloads"),
f"{sanitized_title}.%(ext)s",
output_filename,
)
else:
# If custom path provided, still use sanitized title
output_path = os.path.join(output_path, f"{sanitized_title}.%(ext)s")
output_path = os.path.join(output_path, output_filename)

# Ensure output directory exists
os.makedirs(os.path.dirname(output_path), exist_ok=True)
Expand Down Expand Up @@ -715,6 +724,7 @@ def download_video_task(
url: str,
format_spec: str = "best",
output_path: str = None,
output_template: str = None,
**kwargs,
) -> Dict[str, Any]:
"""
Expand All @@ -728,6 +738,7 @@ def download_video_task(
url=url,
format_spec=format_spec,
output_path=output_path,
output_template=output_template,
**kwargs,
)
)
Expand Down
51 changes: 51 additions & 0 deletions packages/hermes-api/tests/test_api/test_downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,53 @@ async def test_start_download_creates_record_and_queues_task(
queue="hermes.downloads",
)

@pytest.mark.asyncio
async def test_start_download_forwards_supported_options(
self, client: AsyncClient, db_session: AsyncSession
):
with patch(
"app.api.v1.endpoints.downloads.download_video_task.apply_async"
) as apply_async:
response = await client.post(
"/api/v1/download/",
json={
"url": "https://example.test/watch",
"format": "best",
"outputDirectory": "/downloads/custom",
"outputTemplate": "%(title)s.custom.%(ext)s",
"downloadSubtitles": True,
"downloadThumbnail": True,
"subtitleLanguages": ["en", "es"],
"cookies": {"session": "abc", "pref": "dark"},
"cookieFile": "/cookies/browser.txt",
"browserCookies": {
"browser": "firefox",
"profile": "default",
},
},
)

assert response.status_code == 200
data = response.json()
download = await DownloadRepository(db_session).get_by_id(data["downloadId"])
assert download is not None

queued_kwargs = apply_async.call_args.kwargs["kwargs"]
assert queued_kwargs == {
"download_id": data["downloadId"],
"url": "https://example.test/watch",
"format_spec": "best",
"output_path": "/downloads/custom",
"output_template": "%(title)s.custom.%(ext)s",
"writesubtitles": True,
"writethumbnail": True,
"subtitleslangs": ["en", "es"],
"http_headers": {"Cookie": "session=abc; pref=dark"},
"cookiefile": "/cookies/browser.txt",
"cookiesfrombrowser": ("firefox", "default", None, None),
}
assert apply_async.call_args.kwargs["queue"] == "hermes.downloads"

@pytest.mark.asyncio
async def test_completed_download_persists_full_progress(
self, db_session: AsyncSession
Expand Down Expand Up @@ -195,6 +242,8 @@ async def test_start_batch_download_creates_records_and_schedules_batch(
],
"format": "best",
"outputDirectory": "/downloads/batch",
"downloadSubtitles": True,
"downloadThumbnail": True,
},
)

Expand All @@ -221,6 +270,8 @@ async def test_start_batch_download_creates_records_and_schedules_batch(
"urls": ["https://example.test/one", "https://example.test/two"],
"format_spec": "best",
"output_directory": "/downloads/batch",
"writesubtitles": True,
"writethumbnail": True,
},
queue="hermes.downloads",
)
50 changes: 50 additions & 0 deletions packages/hermes-api/tests/test_tasks/test_download_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,56 @@ async def test_download_video_task_extract_failure_marks_failed_and_cleans_up():
)


@pytest.mark.asyncio
async def test_download_video_task_uses_output_template_and_forwarded_options(tmp_path):
download_file = tmp_path / "custom-name.mp4"
yt_service = AsyncMock()
yt_service.extract_info.return_value = {
"title": "Template Video",
"duration": 12,
}

async def fake_download_video(
url, output_path, format_spec, progress_callback=None, **kwargs
):
assert output_path == os.path.join(str(tmp_path), "custom-name.%(ext)s")
assert kwargs == {
"writesubtitles": True,
"writethumbnail": True,
"subtitleslangs": ["en"],
}
download_file.write_bytes(b"video")
return str(download_file)

yt_service.download_video.side_effect = fake_download_video

update_status = AsyncMock()
create_history = AsyncMock()
trigger_webhooks = AsyncMock()
redis_progress_service = AsyncMock()
redis_progress_service.revoke_user_sse_tokens.return_value = 0

with (
patch.object(download_tasks, "yt_service", yt_service),
patch.object(download_tasks, "_update_download_status", update_status),
patch.object(download_tasks, "_create_download_history", create_history),
patch.object(download_tasks, "_trigger_webhooks", trigger_webhooks),
patch.object(download_tasks, "redis_progress_service", redis_progress_service),
):
result = await download_tasks._download_video_task(
download_id="download-123",
url="https://example.test/watch",
output_path=str(tmp_path),
output_template="../custom-name.%(ext)s",
writesubtitles=True,
writethumbnail=True,
subtitleslangs=["en"],
)

assert result["success"] is True
yt_service.download_video.assert_awaited_once()


@pytest.mark.asyncio
async def test_download_video_task_missing_file_marks_failed(tmp_path):
yt_service = AsyncMock()
Expand Down
2 changes: 1 addition & 1 deletion packages/hermes-api/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading