Skip to content

Commit

Permalink
Feature/autoremove (#29)
Browse files Browse the repository at this point in the history
Add background task to clear expired downloads.
  • Loading branch information
deepaerial committed May 24, 2024
1 parent 39ff976 commit 2d16505
Show file tree
Hide file tree
Showing 13 changed files with 414 additions and 60 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.5.0] - 2024-05-11
### Added
- Background task for cleaning up expired downloads.

## [1.4.13] - 2024-04-21
### Fixed
- Catch exception when trying to download private video and return 403.
Expand Down
71 changes: 70 additions & 1 deletion poetry.lock

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

6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ profile = "black"

[tool.poetry]
name = "ytdl-api"
version = "1.4.14"
version = "1.5.0"
description = "API for web-based youtube-dl client"
authors = ["Nazar Oleksiuk <nazarii.oleksiuk@gmail.com>"]
license = "MIT"
Expand All @@ -81,13 +81,14 @@ fastapi = "^0.109.1"
uvicorn = "^0.18.2"
sse-starlette = "^0.6.1"
aiofiles = "^0.6.0"
deta = "^1.1.0"
deta = "1.2.0"
ffmpeg-python = "^0.2.0"
confz = "2.0.1"
pyhumps = "^3.7.2"
pytube = "^15.0.0"
yt-dlp = "^2023.12.30"
humanize = "^4.8.0"
croniter = "^2.0.5"

[tool.poetry.group.dev.dependencies]
pytest = "*"
Expand All @@ -104,6 +105,7 @@ pyclean = "^2.2.0"
pytest-mock = "^3.10.0"
httpx = "^0.23.3"
ruff = "^0.3.4"
pytest-asyncio = "^0.23.7"

[build-system]
requires = ["poetry-core>=1.0.0"]
Expand Down
75 changes: 23 additions & 52 deletions tests/storage/test_deta_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@

import pytest
from confz import EnvSource
from pydantic import parse_obj_as

from ytdl_api.config import REPO_PATH, Settings
from ytdl_api.constants import DownloadStatus, MediaFormat
from ytdl_api.schemas.models import Download
from ytdl_api.storage import DetaDriveStorage
from ytdl_api.utils import get_datetime_now

from ..utils import get_example_download_instance


@pytest.fixture()
def mocked_settings(
Expand Down Expand Up @@ -40,59 +41,16 @@ def clear_drive(fake_media_file_path: Path, deta_storage: DetaDriveStorage):
deta_storage.drive.delete(fake_media_file_path.name)


EXAMPLE_VIDEO_PREVIEW = {
"url": "https://www.youtube.com/watch?v=NcBjx_eyvxc",
"title": "Madeira | Cinematic FPV",
"duration": 224,
"thumbnailUrl": "https://i.ytimg.com/vi/NcBjx_eyvxc/sddefault.jpg",
"audioStreams": [
{"id": "251", "mimetype": "audio/webm", "bitrate": "160kbps"},
{"id": "250", "mimetype": "audio/webm", "bitrate": "70kbps"},
{"id": "249", "mimetype": "audio/webm", "bitrate": "50kbps"},
{"id": "140", "mimetype": "audio/mp4", "bitrate": "128kbps"},
{"id": "139", "mimetype": "audio/mp4", "bitrate": "48kbps"},
],
"videoStreams": [
{"id": "394", "mimetype": "video/mp4", "resolution": "144p"},
{"id": "278", "mimetype": "video/webm", "resolution": "144p"},
{"id": "160", "mimetype": "video/mp4", "resolution": "144p"},
{"id": "395", "mimetype": "video/mp4", "resolution": "240p"},
{"id": "242", "mimetype": "video/webm", "resolution": "240p"},
{"id": "133", "mimetype": "video/mp4", "resolution": "240p"},
{"id": "396", "mimetype": "video/mp4", "resolution": "360p"},
{"id": "243", "mimetype": "video/webm", "resolution": "360p"},
{"id": "134", "mimetype": "video/mp4", "resolution": "360p"},
{"id": "397", "mimetype": "video/mp4", "resolution": "480p"},
{"id": "244", "mimetype": "video/webm", "resolution": "480p"},
{"id": "135", "mimetype": "video/mp4", "resolution": "480p"},
{"id": "398", "mimetype": "video/mp4", "resolution": "720p"},
{"id": "247", "mimetype": "video/webm", "resolution": "720p"},
{"id": "136", "mimetype": "video/mp4", "resolution": "720p"},
{"id": "399", "mimetype": "video/mp4", "resolution": "1080p"},
{"id": "248", "mimetype": "video/webm", "resolution": "1080p"},
{"id": "137", "mimetype": "video/mp4", "resolution": "1080p"},
{"id": "400", "mimetype": "video/mp4", "resolution": "1440p"},
{"id": "271", "mimetype": "video/webm", "resolution": "1440p"},
{"id": "401", "mimetype": "video/mp4", "resolution": "2160p"},
{"id": "313", "mimetype": "video/webm", "resolution": "2160p"},
],
"mediaFormats": ["mp4", "mp3", "wav"],
}


@pytest.fixture()
def example_download() -> Download:
download_data = {
**EXAMPLE_VIDEO_PREVIEW,
"client_id": "xxxxxxx",
"media_format": MediaFormat.MP4,
"filesize": 1024,
"status": DownloadStatus.FINISHED,
"progress": 0,
"when_started_download": get_datetime_now(),
}
download = parse_obj_as(Download, download_data)
return download
return get_example_download_instance(
client_id="xxxxxxx",
media_format=MediaFormat.MP4,
filesize=1024,
status=DownloadStatus.FINISHED,
progress=0,
when_started_download=get_datetime_now(),
)


def test_deta_storage(
Expand All @@ -108,3 +66,16 @@ def test_deta_storage(
deta_storage.remove_download(storage_file_name)
file_bytes = deta_storage.get_download(storage_file_name)
assert file_bytes is None


def test_deta_storage_delete_many(
deta_storage: DetaDriveStorage,
example_download: Download,
fake_media_file_path: Path,
clear_drive: None,
):
storage_file_name = deta_storage.save_download_from_file(example_download, fake_media_file_path)
assert storage_file_name is not None
deta_storage.remove_download_batch([storage_file_name])
no_bytes = deta_storage.get_download(storage_file_name)
assert no_bytes is None
73 changes: 73 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import logging
from datetime import timedelta
from unittest.mock import Mock

import pytest

from ytdl_api.commands import remove_expired_downloads
from ytdl_api.datasource import IDataSource
from ytdl_api.schemas.models import Download
from ytdl_api.utils import get_datetime_now

from .utils import get_example_download_instance


@pytest.fixture
def mocked_logger():
mocked_logger = Mock(spec=logging.Logger)
mocked_logger.info.return_value = None # type: ignore
mocked_logger.error.return_value = None # type: ignore
return mocked_logger


@pytest.fixture
def example_expired_downloads(datasource: IDataSource):
dt_now = get_datetime_now()
expiration_delta = timedelta(days=7)
expired_downloads = [
get_example_download_instance(
client_id="test",
media_format="mp4",
status="downloaded",
when_submitted=dt_now - expiration_delta - timedelta(days=1),
),
get_example_download_instance(
client_id="test", media_format="mp4", status="downloaded", when_submitted=dt_now - expiration_delta
),
get_example_download_instance(
client_id="test",
media_format="mp4",
status="downloaded",
when_submitted=dt_now - timedelta(days=1),
),
get_example_download_instance(
client_id="test",
media_format="mp4",
status="downloaded",
when_submitted=dt_now,
),
]
for download in expired_downloads:
datasource.put_download(download)
yield expiration_delta, expired_downloads
datasource.clear_downloads()


def test_hard_remove_downloads(
fake_local_storage, datasource, mocked_logger, example_expired_downloads: tuple[timedelta, list[Download]]
):
expiration_delta, downloads = example_expired_downloads
client_id = downloads[0].client_id

assert len(datasource.fetch_downloads_by_client_id(client_id)) == len(downloads)

# Call the function
remove_expired_downloads(fake_local_storage, datasource, expiration_delta, mocked_logger)

mocked_logger.info.assert_called_with("Soft deleted expired downloads from database.")

assert len(datasource.fetch_downloads_by_client_id(client_id)) == 2
expired_download1 = downloads[0]
assert datasource.get_download(expired_download1.client_id, expired_download1.key) is None
expired_download2 = downloads[1]
assert datasource.get_download(expired_download1.client_id, expired_download2.key) is None
50 changes: 50 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import asyncio
import logging

import pytest
from _pytest.capture import CaptureFixture
from _pytest.logging import LogCaptureFixture

from ytdl_api.utils import repeat_at


@pytest.fixture
def logger():
return logging.getLogger("test")


@pytest.mark.asyncio
async def test_repeat_at(capsys: CaptureFixture[str]):
"""
Simple Test Case for repeat_at
"""

@repeat_at(cron="* * * * *", max_repetitions=3)
async def print_hello():
print("Hello")

print_hello()
await asyncio.sleep(1)
out, err = capsys.readouterr()
assert err == ""
assert out == ""


@pytest.mark.asyncio
async def test_repeat_at_with_logger(caplog: LogCaptureFixture, logger: logging.Logger):
"""
Test Case for repeat_at with logger and raising exception.
"""

@repeat_at(cron="* * * * *", logger=logger, max_repetitions=3)
async def print_hello():
raise Exception("Hello")

print_hello()
await asyncio.sleep(60)

captured_logs = caplog.records

assert len(captured_logs) == 1
assert hasattr(captured_logs[0], "exc_text")
assert 'raise Exception("Hello")' in captured_logs[0].exc_text
4 changes: 4 additions & 0 deletions tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def get_example_download_instance(
status: DownloadStatus = DownloadStatus.STARTED,
file_path: str | None = None,
progress: int = 0,
when_submitted: datetime | None = None,
when_started_download: datetime | None = None,
) -> Download:
download_data = {
Expand All @@ -58,4 +59,7 @@ def get_example_download_instance(
"progress": progress,
"when_started_download": when_started_download,
}
if when_submitted:
download_data["when_submitted"] = when_submitted
download_data["epoch"] = int(when_submitted.timestamp())
return parse_obj_as(Download, download_data)
Loading

0 comments on commit 2d16505

Please sign in to comment.