Feat: add version checker and refactor the page footer.
This commit is contained in:
parent
b912186502
commit
81a4ef0f36
11 changed files with 950 additions and 27 deletions
25
API.md
25
API.md
|
|
@ -1805,6 +1805,31 @@ or an error:
|
|||
|
||||
---
|
||||
|
||||
### POST /api/system/check-updates
|
||||
**Purpose**: Manually trigger a check for application updates from GitHub releases.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "update_available",
|
||||
"current_version": "v1.0.14",
|
||||
"new_version": "v1.0.15"
|
||||
}
|
||||
```
|
||||
|
||||
**Status values**:
|
||||
- `disabled`: Update checking is disabled in configuration
|
||||
- `up_to_date`: No updates available
|
||||
- `update_available`: New version is available
|
||||
- `error`: Error occurred during check (HTTP error, network issue, etc.)
|
||||
|
||||
- `400 Bad Request` if update checking is disabled in configuration (`check_for_updates: false`).
|
||||
- `new_version` will be `null` when status is `up_to_date`, `disabled`, or `error`.
|
||||
- The check runs synchronously and returns the result immediately.
|
||||
- Results are also stored in config and pushed to frontend via WebSocket.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/dev/loop
|
||||
**Purpose**: Development-only. Show event loop details and running tasks.
|
||||
|
||||
|
|
|
|||
1
FAQ.md
1
FAQ.md
|
|
@ -56,6 +56,7 @@ or the `environment:` section in `compose.yaml` file.
|
|||
| YTP_DEFAULT_PAGINATION | The default number of items per page for history. | `50` |
|
||||
| YTP_TASK_HANDLER_RANDOM_DELAY | The maximum random delay in seconds before starting a task handler. | `60` |
|
||||
| YTP_IGNORE_ARCHIVED_ITEMS | Don't report archived items in the download history. | `false` |
|
||||
| YTP_CHECK_FOR_UPDATES | Whether to check for application updates. | `true` |
|
||||
|
||||
> [!NOTE]
|
||||
> To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`.
|
||||
|
|
|
|||
218
app/library/UpdateChecker.py
Normal file
218
app/library/UpdateChecker.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .cache import Cache
|
||||
from .config import Config
|
||||
from .Events import EventBus, Events
|
||||
from .httpx_client import async_client
|
||||
from .Scheduler import Scheduler
|
||||
from .Singleton import Singleton
|
||||
from .version import APP_VERSION
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.library.dl_fields import Any
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("update_checker")
|
||||
|
||||
|
||||
class UpdateChecker(metaclass=Singleton):
|
||||
"""
|
||||
Checks for application updates from GitHub releases.
|
||||
"""
|
||||
|
||||
GITHUB_API_URL: str = "https://api.github.com/repos/arabcoders/ytptube/releases/latest"
|
||||
"GitHub API endpoint for latest release"
|
||||
|
||||
CACHE_DURATION: int = 300
|
||||
"Cache duration in seconds (5 minutes)"
|
||||
|
||||
CACHE_KEY: str = "update_checker:result"
|
||||
"Cache key for storing check results"
|
||||
|
||||
def __init__(
|
||||
self, config: Config | None = None, scheduler: Scheduler | None = None, notify: EventBus | None = None
|
||||
) -> None:
|
||||
self._config: Config = config or Config.get_instance()
|
||||
"Instance of Config to use."
|
||||
|
||||
self._scheduler: Scheduler = scheduler or Scheduler.get_instance()
|
||||
"Instance of Scheduler to use."
|
||||
|
||||
self._notify: EventBus = notify or EventBus.get_instance()
|
||||
"Instance of EventBus for notifications."
|
||||
|
||||
self._cache: Cache = Cache()
|
||||
"Instance of Cache for caching check results."
|
||||
|
||||
self._job_id: str | None = None
|
||||
"Scheduler job ID for update checks."
|
||||
|
||||
@staticmethod
|
||||
def get_instance(
|
||||
config: Config | None = None, scheduler: Scheduler | None = None, notify: EventBus | None = None
|
||||
) -> "UpdateChecker":
|
||||
"""
|
||||
Get the singleton instance of UpdateChecker.
|
||||
|
||||
Args:
|
||||
config (Config | None): Optional Config instance to use.
|
||||
scheduler (Scheduler | None): Optional Scheduler instance to use.
|
||||
notify (EventBus | None): Optional EventBus instance to use.
|
||||
|
||||
Returns:
|
||||
UpdateChecker: The singleton instance of UpdateChecker.
|
||||
|
||||
"""
|
||||
return UpdateChecker(config=config, scheduler=scheduler, notify=notify)
|
||||
|
||||
def attach(self, _: web.Application) -> None:
|
||||
"""
|
||||
Attach the UpdateChecker to the application.
|
||||
|
||||
Args:
|
||||
_ (web.Application): The aiohttp web application instance.
|
||||
|
||||
"""
|
||||
from .Services import Services
|
||||
|
||||
Services.get_instance().add("update_checker", self)
|
||||
|
||||
if not self._config.check_for_updates:
|
||||
LOG.info("Update checking is disabled.")
|
||||
return
|
||||
|
||||
if re.search(r"^v\d+", APP_VERSION) is None:
|
||||
LOG.warning("Not on a release version, skipping update checker setup.")
|
||||
return
|
||||
|
||||
async def event_handler(_, __):
|
||||
await self.check_for_updates()
|
||||
|
||||
self._notify.subscribe(
|
||||
event=Events.STARTED,
|
||||
callback=event_handler,
|
||||
name=f"{__class__.__name__}.{__class__.attach.__name__}",
|
||||
)
|
||||
|
||||
self._schedule_check()
|
||||
|
||||
async def on_shutdown(self, _: web.Application) -> None:
|
||||
"""
|
||||
Handle application shutdown event.
|
||||
|
||||
Args:
|
||||
_ (web.Application): The aiohttp web application instance.
|
||||
|
||||
"""
|
||||
if not self._job_id:
|
||||
return
|
||||
|
||||
self._scheduler.remove(self._job_id)
|
||||
self._job_id = None
|
||||
LOG.debug("Stopped update check scheduled task.")
|
||||
|
||||
def _schedule_check(self) -> None:
|
||||
"""Schedule the update check task to run daily at 3 AM."""
|
||||
if not self._config.check_for_updates:
|
||||
LOG.debug("Update checking is disabled, skipping scheduling.")
|
||||
return
|
||||
|
||||
# Run daily at 3 AM
|
||||
timer: str = "0 3 * * *"
|
||||
|
||||
self._job_id = self._scheduler.add(
|
||||
timer=timer,
|
||||
func=lambda: asyncio.create_task(self.check_for_updates()),
|
||||
id="update_checker",
|
||||
)
|
||||
|
||||
LOG.info(f"Scheduled update check to run daily at 3 AM (cron: {timer}).")
|
||||
|
||||
async def check_for_updates(self) -> tuple[str, str | None]:
|
||||
"""
|
||||
Check for updates from GitHub releases.
|
||||
Updates config.new_version if a newer version is available.
|
||||
Stops the scheduled task if an update is found.
|
||||
|
||||
Returns:
|
||||
tuple[str, str | None]: (status, new_version)
|
||||
status: "disabled", "error", "up_to_date", or "update_available"
|
||||
new_version: The new version tag if available, None otherwise
|
||||
|
||||
"""
|
||||
if not self._config.check_for_updates:
|
||||
LOG.debug("Update checking is disabled, skipping check.")
|
||||
return ("disabled", None)
|
||||
|
||||
# Check cache
|
||||
cached = await self._cache.aget(self.CACHE_KEY)
|
||||
if cached:
|
||||
ttl = await self._cache.attl(self.CACHE_KEY)
|
||||
LOG.debug(f"Returning cached result (TTL: {ttl:.0f}s)")
|
||||
return cached
|
||||
|
||||
try:
|
||||
LOG.info("Checking for application updates...")
|
||||
|
||||
current_version: str = APP_VERSION.lstrip("v")
|
||||
current_version: str = "1.0.14"
|
||||
|
||||
async with async_client(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
self.GITHUB_API_URL,
|
||||
headers={"Accept": "application/vnd.github+json"},
|
||||
)
|
||||
|
||||
if 200 != response.status_code:
|
||||
LOG.warning(f"Failed to check for updates: HTTP {response.status_code}")
|
||||
return ("error", None)
|
||||
|
||||
data: dict[str, Any] = response.json()
|
||||
|
||||
latest_tag: str = data.get("tag_name", "").lstrip("v")
|
||||
if not latest_tag:
|
||||
LOG.warning("No tag_name found in GitHub release data.")
|
||||
return ("error", None)
|
||||
|
||||
if self._compare_versions(current_version, latest_tag):
|
||||
LOG.warning(f"Update available: {current_version} → {latest_tag}")
|
||||
new_version_tag = data.get("tag_name", "")
|
||||
self._config.new_version = new_version_tag
|
||||
await self.on_shutdown(None)
|
||||
result = ("update_available", new_version_tag)
|
||||
await self._cache.aset(self.CACHE_KEY, result, self.CACHE_DURATION)
|
||||
return result
|
||||
|
||||
LOG.info("No updates available.")
|
||||
self._config.new_version = ""
|
||||
result = ("up_to_date", None)
|
||||
await self._cache.aset(self.CACHE_KEY, result, self.CACHE_DURATION)
|
||||
return result
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Error checking for updates: {e!s}")
|
||||
return ("error", None)
|
||||
|
||||
def _compare_versions(self, current: str, latest: str) -> bool:
|
||||
"""
|
||||
Compare version strings to determine if an update is available.
|
||||
|
||||
Args:
|
||||
current (str): Current version string
|
||||
latest (str): Latest version string
|
||||
|
||||
Returns:
|
||||
bool: True if latest > current, False otherwise
|
||||
|
||||
"""
|
||||
try:
|
||||
from packaging.version import parse as parse_version
|
||||
|
||||
return parse_version(latest) > parse_version(current)
|
||||
except Exception as e:
|
||||
LOG.warning(f"Error comparing versions '{current}' vs '{latest}': {e}")
|
||||
return False
|
||||
|
|
@ -229,6 +229,12 @@ class Config(metaclass=Singleton):
|
|||
static_ui_path: str = ""
|
||||
"The path to the static UI files."
|
||||
|
||||
check_for_updates: bool = False
|
||||
"Check for application updates."
|
||||
|
||||
new_version: str = ""
|
||||
"The new version available."
|
||||
|
||||
_manual_vars: tuple = (
|
||||
"temp_path",
|
||||
"config_path",
|
||||
|
|
@ -245,6 +251,7 @@ class Config(metaclass=Singleton):
|
|||
"app_commit_sha",
|
||||
"app_build_date",
|
||||
"app_branch",
|
||||
"new_version",
|
||||
)
|
||||
"The variables that are immutable."
|
||||
|
||||
|
|
@ -284,6 +291,7 @@ class Config(metaclass=Singleton):
|
|||
"allow_internal_urls",
|
||||
"simple_mode",
|
||||
"ignore_archived_items",
|
||||
"check_for_updates",
|
||||
)
|
||||
"The variables that are booleans."
|
||||
|
||||
|
|
@ -314,6 +322,8 @@ class Config(metaclass=Singleton):
|
|||
"app_build_date",
|
||||
"app_branch",
|
||||
"default_pagination",
|
||||
"check_for_updates",
|
||||
"new_version",
|
||||
)
|
||||
"The variables that are relevant to the frontend."
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from app.library.Services import Services
|
|||
from app.library.sqlite_store import SqliteStore
|
||||
from app.library.TaskDefinitions import TaskDefinitions
|
||||
from app.library.Tasks import Tasks
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
LOG = logging.getLogger("app")
|
||||
MIME = magic.Magic(mime=True)
|
||||
|
|
@ -120,6 +121,7 @@ class Main:
|
|||
DLFields.get_instance().attach(self._app)
|
||||
TaskDefinitions.get_instance().attach(self._app)
|
||||
DownloadQueue.get_instance().attach(self._app)
|
||||
UpdateChecker.get_instance().attach(self._app)
|
||||
|
||||
EventBus.get_instance().emit(
|
||||
Events.LOADED,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from app.library.DownloadQueue import DownloadQueue
|
|||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.router import route
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -130,3 +131,50 @@ async def shutdown_system(request: Request, config: Config, encoder: Encoder, no
|
|||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("POST", "api/system/check-updates", "system.check_updates")
|
||||
async def check_updates(config: Config, encoder: Encoder, update_checker: UpdateChecker) -> Response:
|
||||
"""
|
||||
Manually trigger update check.
|
||||
|
||||
Args:
|
||||
config (Config): The config instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
update_checker (UpdateChecker): The update checker instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not config.check_for_updates:
|
||||
return web.json_response(
|
||||
{"error": "Update checking is disabled in configuration."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
# If update already found, return cached result
|
||||
if config.new_version:
|
||||
return web.json_response(
|
||||
data={
|
||||
"status": "update_available",
|
||||
"current_version": config.app_version,
|
||||
"new_version": config.new_version,
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
# Run check and await result
|
||||
status, new_version = await update_checker.check_for_updates()
|
||||
|
||||
return web.json_response(
|
||||
data={
|
||||
"status": status,
|
||||
"current_version": config.app_version,
|
||||
"new_version": new_version,
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
|
|
|||
117
app/tests/test_system_routes.py
Normal file
117
app/tests/test_system_routes.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
from app.routes.api.system import check_updates
|
||||
|
||||
|
||||
class TestCheckUpdatesEndpoint:
|
||||
"""Tests for the check updates endpoint."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset singletons before each test."""
|
||||
Config._reset_singleton()
|
||||
UpdateChecker._reset_singleton()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_updates_disabled(self):
|
||||
"""Test check updates returns error when disabled in config."""
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = False
|
||||
encoder = Encoder()
|
||||
update_checker = UpdateChecker.get_instance()
|
||||
|
||||
response = await check_updates(config, encoder, update_checker)
|
||||
|
||||
assert 400 == response.status, "Should return 400 when update checking is disabled"
|
||||
body = response.body
|
||||
assert b"disabled" in body.lower(), "Response should mention update checking is disabled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_updates_up_to_date(self):
|
||||
"""Test check updates returns up_to_date status."""
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
config.app_version = "v1.0.0"
|
||||
encoder = Encoder()
|
||||
update_checker = UpdateChecker.get_instance()
|
||||
|
||||
with patch.object(update_checker, "check_for_updates", new_callable=AsyncMock) as mock_check:
|
||||
mock_check.return_value = ("up_to_date", None)
|
||||
response = await check_updates(config, encoder, update_checker)
|
||||
|
||||
assert 200 == response.status, "Should return 200"
|
||||
body = response.body.decode("utf-8")
|
||||
assert "up_to_date" in body, "Response should include up_to_date status"
|
||||
assert mock_check.called, "Should have called check_for_updates"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_updates_returns_current_version(self):
|
||||
"""Test check updates includes current version in response."""
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
config.app_version = "v1.2.3"
|
||||
encoder = Encoder()
|
||||
update_checker = UpdateChecker.get_instance()
|
||||
|
||||
with patch.object(update_checker, "check_for_updates", new_callable=AsyncMock) as mock_check:
|
||||
mock_check.return_value = ("up_to_date", None)
|
||||
response = await check_updates(config, encoder, update_checker)
|
||||
|
||||
assert 200 == response.status, "Should return 200"
|
||||
body = response.body.decode("utf-8")
|
||||
assert "1.2.3" in body, "Response should include current version"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_updates_update_available(self):
|
||||
"""Test check updates returns update_available status with new version."""
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
config.app_version = "v1.0.0"
|
||||
encoder = Encoder()
|
||||
update_checker = UpdateChecker.get_instance()
|
||||
|
||||
with patch.object(update_checker, "check_for_updates", new_callable=AsyncMock) as mock_check:
|
||||
mock_check.return_value = ("update_available", "v1.0.5")
|
||||
response = await check_updates(config, encoder, update_checker)
|
||||
|
||||
assert 200 == response.status, "Should return 200"
|
||||
body = response.body.decode("utf-8")
|
||||
assert "v1.0.5" in body, "Response should include new version"
|
||||
assert "update_available" in body, "Response should include update_available status"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_updates_null_when_no_new_version(self):
|
||||
"""Test check updates returns null for new_version when none available."""
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
config.app_version = "v1.0.0"
|
||||
encoder = Encoder()
|
||||
update_checker = UpdateChecker.get_instance()
|
||||
|
||||
with patch.object(update_checker, "check_for_updates", new_callable=AsyncMock) as mock_check:
|
||||
mock_check.return_value = ("up_to_date", None)
|
||||
response = await check_updates(config, encoder, update_checker)
|
||||
|
||||
assert 200 == response.status, "Should return 200"
|
||||
body = response.body.decode("utf-8")
|
||||
assert "null" in body.lower(), "Response should include null for new_version when not available"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_updates_error_status(self):
|
||||
"""Test check updates handles error status correctly."""
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
config.app_version = "v1.0.0"
|
||||
encoder = Encoder()
|
||||
update_checker = UpdateChecker.get_instance()
|
||||
|
||||
with patch.object(update_checker, "check_for_updates", new_callable=AsyncMock) as mock_check:
|
||||
mock_check.return_value = ("error", None)
|
||||
response = await check_updates(config, encoder, update_checker)
|
||||
|
||||
assert 200 == response.status, "Should return 200"
|
||||
body = response.body.decode("utf-8")
|
||||
assert "error" in body, "Response should include error status"
|
||||
362
app/tests/test_update_checker.py
Normal file
362
app/tests/test_update_checker.py
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestUpdateChecker:
|
||||
"""Test UpdateChecker functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
from app.library.cache import Cache
|
||||
from app.library.config import Config
|
||||
from app.library.Events import EventBus
|
||||
from app.library.Scheduler import Scheduler
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
Config._reset_singleton()
|
||||
Scheduler._reset_singleton()
|
||||
UpdateChecker._reset_singleton()
|
||||
EventBus._reset_singleton()
|
||||
Cache._reset_singleton()
|
||||
|
||||
def test_singleton_pattern(self):
|
||||
"""Test that UpdateChecker follows singleton pattern."""
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
instance1 = UpdateChecker.get_instance()
|
||||
instance2 = UpdateChecker.get_instance()
|
||||
|
||||
assert instance1 is instance2, "Should return same instance"
|
||||
|
||||
def test_initialization_with_defaults(self):
|
||||
"""Test UpdateChecker initializes with default config and scheduler."""
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
checker = UpdateChecker.get_instance()
|
||||
|
||||
assert checker._config is not None, "Should have config instance"
|
||||
assert checker._scheduler is not None, "Should have scheduler instance"
|
||||
assert checker._notify is not None, "Should have EventBus instance"
|
||||
assert checker._job_id is None, "Should have no job ID initially"
|
||||
|
||||
def test_attach_schedules_check_when_enabled(self):
|
||||
"""Test that attach schedules update check when config.check_for_updates is True."""
|
||||
import asyncio
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.Events import EventBus
|
||||
from app.library.Scheduler import Scheduler
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
|
||||
# Create scheduler with a fresh event loop
|
||||
loop = asyncio.new_event_loop()
|
||||
scheduler = Scheduler.get_instance(loop=loop)
|
||||
notify = EventBus.get_instance()
|
||||
|
||||
# Mock APP_VERSION to be a release version
|
||||
with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"):
|
||||
checker = UpdateChecker.get_instance(config=config, scheduler=scheduler)
|
||||
app_mock = MagicMock()
|
||||
|
||||
try:
|
||||
checker.attach(app_mock)
|
||||
|
||||
assert checker._job_id is not None, "Should have scheduled a job"
|
||||
assert "update_checker" == checker._job_id, "Job ID should be 'update_checker'"
|
||||
finally:
|
||||
# Clean up
|
||||
if checker._job_id:
|
||||
scheduler.remove(checker._job_id)
|
||||
loop.close()
|
||||
|
||||
def test_attach_skips_scheduling_when_disabled(self):
|
||||
"""Test that attach skips scheduling when config.check_for_updates is False."""
|
||||
from app.library.config import Config
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = False
|
||||
|
||||
checker = UpdateChecker.get_instance(config=config)
|
||||
app_mock = MagicMock()
|
||||
|
||||
checker.attach(app_mock)
|
||||
|
||||
assert checker._job_id is None, "Should not have scheduled a job when disabled"
|
||||
|
||||
def test_attach_skips_scheduling_for_dev_version(self):
|
||||
"""Test that attach skips scheduling when running dev version."""
|
||||
from app.library.config import Config
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
|
||||
with patch("app.library.UpdateChecker.APP_VERSION", "dev-master"):
|
||||
checker = UpdateChecker.get_instance(config=config)
|
||||
app_mock = MagicMock()
|
||||
|
||||
checker.attach(app_mock)
|
||||
|
||||
assert checker._job_id is None, "Should not schedule job for dev version"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_shutdown_removes_scheduled_job(self):
|
||||
"""Test that on_shutdown removes the scheduled job."""
|
||||
import asyncio
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.Events import EventBus
|
||||
from app.library.Scheduler import Scheduler
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
|
||||
# Create scheduler with a fresh event loop
|
||||
loop = asyncio.new_event_loop()
|
||||
scheduler = Scheduler.get_instance(loop=loop)
|
||||
notify = EventBus.get_instance()
|
||||
|
||||
with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"):
|
||||
checker = UpdateChecker.get_instance(config=config, scheduler=scheduler)
|
||||
app_mock = MagicMock()
|
||||
|
||||
try:
|
||||
checker.attach(app_mock)
|
||||
initial_job_id = checker._job_id
|
||||
|
||||
assert initial_job_id is not None, "Should have job ID before shutdown"
|
||||
|
||||
await checker.on_shutdown(app_mock)
|
||||
|
||||
assert checker._job_id is None, "Should clear job ID after shutdown"
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_for_updates_skips_when_disabled(self):
|
||||
"""Test that check_for_updates skips when config.check_for_updates is False."""
|
||||
from app.library.config import Config
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = False
|
||||
config.new_version = ""
|
||||
|
||||
checker = UpdateChecker.get_instance(config=config)
|
||||
|
||||
# Should return disabled status
|
||||
status, new_version = await checker.check_for_updates()
|
||||
|
||||
assert "disabled" == status, "Should return disabled status"
|
||||
assert new_version is None, "Should return None for new_version when disabled"
|
||||
assert "" == config.new_version, "Should not update new_version when disabled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.library.UpdateChecker.async_client")
|
||||
async def test_check_for_updates_finds_newer_version(self, mock_client):
|
||||
"""Test that check_for_updates detects when a newer version is available."""
|
||||
from app.library.config import Config
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
config.new_version = ""
|
||||
|
||||
# Mock HTTP response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"tag_name": "v99.0.0"}
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.return_value = mock_context
|
||||
|
||||
# Mock current version to be older
|
||||
with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"):
|
||||
checker = UpdateChecker.get_instance(config=config)
|
||||
status, new_version = await checker.check_for_updates()
|
||||
|
||||
assert "update_available" == status, "Should return update_available status"
|
||||
assert "v99.0.0" == new_version, "Should return new version tag"
|
||||
assert "v99.0.0" == config.new_version, "Should store new version tag"
|
||||
assert checker._job_id is None, "Should stop scheduled task after finding update"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.library.UpdateChecker.async_client")
|
||||
async def test_check_for_updates_no_update_available(self, mock_client):
|
||||
"""Test that check_for_updates correctly handles when no update is available."""
|
||||
from app.library.config import Config
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
config.new_version = ""
|
||||
|
||||
# Mock HTTP response with older version (current is hardcoded as 1.0.14 in the code)
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"tag_name": "v1.0.0"}
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.return_value = mock_context
|
||||
|
||||
checker = UpdateChecker.get_instance(config=config)
|
||||
checker._job_id = "test-job" # Set a job ID to verify it's not removed
|
||||
|
||||
status, new_version = await checker.check_for_updates()
|
||||
|
||||
assert "up_to_date" == status, "Should return up_to_date status"
|
||||
assert new_version is None, "Should return None for new_version"
|
||||
assert "" == config.new_version, "Should clear new_version when no update available"
|
||||
assert "test-job" == checker._job_id, "Should keep scheduled task running"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.library.UpdateChecker.async_client")
|
||||
async def test_check_for_updates_handles_http_error(self, mock_client):
|
||||
"""Test that check_for_updates handles HTTP errors gracefully."""
|
||||
from app.library.config import Config
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
config.new_version = ""
|
||||
|
||||
# Mock HTTP error
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.return_value = mock_context
|
||||
|
||||
checker = UpdateChecker.get_instance(config=config)
|
||||
|
||||
status, new_version = await checker.check_for_updates()
|
||||
|
||||
assert "error" == status, "Should return error status"
|
||||
assert new_version is None, "Should return None for new_version on error"
|
||||
assert "" == config.new_version, "Should not set new_version on HTTP error"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.library.UpdateChecker.async_client")
|
||||
async def test_check_for_updates_handles_exception(self, mock_client):
|
||||
"""Test that check_for_updates handles exceptions gracefully."""
|
||||
from app.library.config import Config
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
config.new_version = ""
|
||||
|
||||
# Mock exception during HTTP request
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value.get = AsyncMock(side_effect=Exception("Network error"))
|
||||
mock_client.return_value = mock_context
|
||||
|
||||
checker = UpdateChecker.get_instance(config=config)
|
||||
|
||||
status, new_version = await checker.check_for_updates()
|
||||
|
||||
assert "error" == status, "Should return error status on exception"
|
||||
assert new_version is None, "Should return None for new_version on exception"
|
||||
assert "" == config.new_version, "Should not set new_version on exception"
|
||||
|
||||
def test_compare_versions_newer_available(self):
|
||||
"""Test version comparison detects newer version."""
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
checker = UpdateChecker.get_instance()
|
||||
|
||||
assert checker._compare_versions("1.0.0", "2.0.0") is True, "Should detect 2.0.0 > 1.0.0"
|
||||
assert checker._compare_versions("1.0.0", "1.1.0") is True, "Should detect 1.1.0 > 1.0.0"
|
||||
assert checker._compare_versions("1.0.0", "1.0.1") is True, "Should detect 1.0.1 > 1.0.0"
|
||||
|
||||
def test_compare_versions_same_version(self):
|
||||
"""Test version comparison with same version."""
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
checker = UpdateChecker.get_instance()
|
||||
|
||||
assert checker._compare_versions("1.0.0", "1.0.0") is False, "Should detect versions are equal"
|
||||
|
||||
def test_compare_versions_older_version(self):
|
||||
"""Test version comparison with older version."""
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
checker = UpdateChecker.get_instance()
|
||||
|
||||
assert checker._compare_versions("2.0.0", "1.0.0") is False, "Should detect 1.0.0 is not newer than 2.0.0"
|
||||
|
||||
def test_github_api_url_constant(self):
|
||||
"""Test that GitHub API URL is correctly defined."""
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
expected_url = "https://api.github.com/repos/arabcoders/ytptube/releases/latest"
|
||||
assert UpdateChecker.GITHUB_API_URL == expected_url, "GitHub API URL should be correct"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_for_updates_stores_tag_name(self):
|
||||
"""Test that check_for_updates stores the tag_name when update found."""
|
||||
from app.library.config import Config
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
config.new_version = ""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"tag_name": "v2.0.0"}
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("app.library.UpdateChecker.async_client", return_value=mock_context):
|
||||
with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"):
|
||||
checker = UpdateChecker.get_instance(config=config)
|
||||
await checker.check_for_updates()
|
||||
|
||||
assert "v2.0.0" == config.new_version, "Should store full tag_name including 'v' prefix"
|
||||
|
||||
def test_subscribe_to_started_event(self):
|
||||
"""Test that attach subscribes to Events.STARTED."""
|
||||
import asyncio
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.Events import EventBus
|
||||
from app.library.Scheduler import Scheduler
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
scheduler = Scheduler.get_instance(loop=loop)
|
||||
notify = EventBus.get_instance()
|
||||
|
||||
checker = None
|
||||
try:
|
||||
with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"):
|
||||
checker = UpdateChecker.get_instance(config=config, scheduler=scheduler)
|
||||
app_mock = MagicMock()
|
||||
|
||||
checker.attach(app_mock)
|
||||
|
||||
# Verify subscription was created
|
||||
subscriptions = notify._listeners.get("started", {})
|
||||
assert len(subscriptions) > 0, "Should have subscribed to STARTED event"
|
||||
assert any("UpdateChecker.attach" in name for name in subscriptions.keys()), (
|
||||
"Should have UpdateChecker.attach subscription"
|
||||
)
|
||||
finally:
|
||||
if checker and checker._job_id:
|
||||
scheduler.remove(checker._job_id)
|
||||
loop.close()
|
||||
|
|
@ -157,34 +157,113 @@
|
|||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<div class="columns mt-3 is-mobile">
|
||||
<div class="column">
|
||||
<div class="has-text-left" v-if="config.app?.app_version">
|
||||
© {{ Year }} - <NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank">YTPTube</NuxtLink>
|
||||
(<span class="has-tooltip"
|
||||
v-tooltip="`Build Date: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, commit: ${config.app?.app_commit_sha}`">
|
||||
{{ config?.app?.app_version || 'unknown' }}</span>)
|
||||
- <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink>
|
||||
<span> ({{ config?.app?.ytdlp_version || 'unknown' }})</span>
|
||||
- <NuxtLink to="/changelog">CHANGELOG</NuxtLink>
|
||||
- <NuxtLink @click="doc.file = '/api/docs/FAQ.md'">FAQ</NuxtLink>
|
||||
- <NuxtLink @click="doc.file = '/api/docs/README.md'">README</NuxtLink>
|
||||
- <NuxtLink @click="doc.file = '/api/docs/API.md'">API</NuxtLink>
|
||||
- <NuxtLink @click="scrollToTop">
|
||||
<span class="icon"><i class="fas fa-arrow-up" /></span>
|
||||
<span>Top</span>
|
||||
</NuxtLink>
|
||||
<footer class="footer py-5 mt-6 is-unselectable" v-if="socket.isConnected">
|
||||
<div class="columns is-multiline is-variable is-8">
|
||||
<div class="column is-12-mobile is-6-tablet">
|
||||
<div class="mb-3">
|
||||
<NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank"
|
||||
class="has-text-weight-semibold is-size-6">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fab fa-github" /></span>
|
||||
<span>YTPTube</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<span class="is-size-7 ml-2 has-tooltip" style="opacity: 0.7"
|
||||
v-tooltip="`Build: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, SHA: ${config.app?.app_commit_sha}`">
|
||||
{{ config?.app?.app_version || 'unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="config.app?.check_for_updates">
|
||||
<p v-if="config.app?.new_version" class="is-size-7 mb-2" style="opacity: 0.8">
|
||||
<span class="icon-text">
|
||||
<span class="icon has-text-warning"><i class="fas fa-circle-info" /></span>
|
||||
<span>Update available:</span>
|
||||
<NuxtLink :href="`https://github.com/ArabCoders/ytptube/releases/tag/${config.app.new_version}`"
|
||||
target="_blank" class="has-text-weight-semibold ml-1">
|
||||
{{ config.app.new_version }}
|
||||
</NuxtLink>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p v-else class="is-size-7 mb-2" style="opacity: 0.6">
|
||||
<button @click="checkForUpdates" :disabled="checkingUpdates" class="is-text is-small p-0 is-size-7"
|
||||
:class="{ 'is-loading': checkingUpdates }"
|
||||
style="opacity: 0.8; height: auto; vertical-align: baseline; border: none; text-decoration: none; background: none;">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fas" :class="checkingUpdates ? 'fa-spinner fa-spin' : 'fa-circle-check'" />
|
||||
</span>
|
||||
<span>{{ updateCheckMessage }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<p v-if="config.app?.started" class="is-size-7 mb-0" style="opacity: 0.6">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fas fa-clock" /></span>
|
||||
<span class="has-tooltip"
|
||||
v-tooltip="'Started: ' + moment.unix(config.app?.started).format('YYYY-MM-DD HH:mm Z')">
|
||||
{{ moment.unix(config.app?.started).fromNow() }}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="column is-12-mobile is-3-tablet">
|
||||
<div class="footer-divider"
|
||||
style="border-left: 1px solid rgba(128,128,128,0.2); border-right: 1px solid rgba(128,128,128,0.2); padding: 0 2rem;">
|
||||
<p class="is-size-7 mb-2" style="opacity: 0.7">Powered by</p>
|
||||
<NuxtLink href="https://github.com/yt-dlp/yt-dlp" target="_blank" class="has-text-weight-semibold">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fab fa-github" /></span>
|
||||
<span>yt-dlp</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<span class="is-size-7 ml-1" style="opacity: 0.6">{{ config?.app?.ytdlp_version || 'unknown' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12-mobile is-3-tablet">
|
||||
<p class="is-size-7 mb-2" style="opacity: 0.7">Quick Links</p>
|
||||
<div
|
||||
class="is-flex is-flex-direction-row is-flex-wrap-wrap is-justify-content-flex-start is-justify-content-flex-end-tablet"
|
||||
style="gap: 0.75rem;">
|
||||
<NuxtLink to="/changelog" class="is-size-7">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fas fa-list" /></span>
|
||||
<span>Changelog</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink @click="doc.file = '/api/docs/FAQ.md'" class="is-size-7">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fas fa-question-circle" /></span>
|
||||
<span>FAQ</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink @click="doc.file = '/api/docs/README.md'" class="is-size-7">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fas fa-book" /></span>
|
||||
<span>Docs</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink @click="doc.file = '/api/docs/API.md'" class="is-size-7">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fas fa-code" /></span>
|
||||
<span>API</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<button @click="scrollToTop" class="is-size-7 button is-text is-small p-0">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fas fa-arrow-up" /></span>
|
||||
<span>Top</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-narrow" v-if="config.app?.started">
|
||||
<div class="has-text-right">
|
||||
<span class="has-tooltip"
|
||||
v-tooltip="'App Started: ' + moment.unix(config.app?.started).format('YYYY-M-DD H:mm Z')">
|
||||
{{ moment.unix(config.app?.started).fromNow() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -204,7 +283,6 @@ import Shutdown from '~/components/shutdown.vue'
|
|||
import Markdown from '~/components/Markdown.vue'
|
||||
import Connection from '~/components/Connection.vue'
|
||||
|
||||
const Year = new Date().getFullYear()
|
||||
const selectedTheme = useStorage('theme', 'auto')
|
||||
const socket = useSocketStore()
|
||||
const config = useConfigStore()
|
||||
|
|
@ -218,6 +296,53 @@ const app_shutdown = ref<boolean>(false)
|
|||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
||||
const show_settings = ref(false)
|
||||
const doc = ref<{ file: string }>({ file: '' })
|
||||
const checkingUpdates = ref(false)
|
||||
const updateCheckMessage = ref('Up to date - Click to check')
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
if (checkingUpdates.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const msg = 'Up to date - Click to check'
|
||||
|
||||
try {
|
||||
checkingUpdates.value = true
|
||||
updateCheckMessage.value = 'Checking...'
|
||||
|
||||
const response = await fetch('/api/system/check-updates', { method: 'POST' })
|
||||
|
||||
if (!response.ok) {
|
||||
await response.json()
|
||||
updateCheckMessage.value = 'Check failed'
|
||||
setTimeout(() => updateCheckMessage.value = msg, 3000)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
switch (data.status) {
|
||||
case 'update_available':
|
||||
updateCheckMessage.value = 'Update found!'
|
||||
config.app.new_version = data.new_version
|
||||
break
|
||||
case 'up_to_date':
|
||||
updateCheckMessage.value = 'Up to date ✓'
|
||||
setTimeout(() => updateCheckMessage.value = msg, 3000)
|
||||
break
|
||||
case 'error':
|
||||
updateCheckMessage.value = 'Check failed'
|
||||
setTimeout(() => updateCheckMessage.value = msg, 3000)
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Update check failed:', e)
|
||||
updateCheckMessage.value = 'Check failed'
|
||||
setTimeout(() => updateCheckMessage.value = msg, 3000)
|
||||
} finally {
|
||||
checkingUpdates.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyPreferredColorScheme = (scheme: string) => {
|
||||
if (!scheme || scheme === 'auto') {
|
||||
|
|
@ -515,5 +640,14 @@ const scrollToTop = () => document.getElementById('top')?.scrollIntoView({ behav
|
|||
.basic-wrapper.settings-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.footer .footer-divider {
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
padding: 0 !important;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.2);
|
||||
padding-top: 1.5rem !important;
|
||||
margin-top: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ export const useConfigStore = defineStore('config', () => {
|
|||
app_env: 'production',
|
||||
simple_mode: false,
|
||||
default_pagination: 50,
|
||||
check_for_updates: true,
|
||||
new_version: '',
|
||||
},
|
||||
presets: [
|
||||
{
|
||||
|
|
|
|||
4
ui/app/types/config.d.ts
vendored
4
ui/app/types/config.d.ts
vendored
|
|
@ -45,6 +45,10 @@ type AppConfig = {
|
|||
app_env: "production" | "development"
|
||||
/** Default number of items per page for pagination */
|
||||
default_pagination: number
|
||||
/** Indicates if the app should check for updates */
|
||||
check_for_updates: boolean
|
||||
/** New version available, empty string if none */
|
||||
new_version: string
|
||||
}
|
||||
|
||||
type ConfigState = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue