diff --git a/API.md b/API.md index 9c6486df..9fac609e 100644 --- a/API.md +++ b/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. diff --git a/FAQ.md b/FAQ.md index c3bed594..5e2ae000 100644 --- a/FAQ.md +++ b/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_`. diff --git a/app/library/UpdateChecker.py b/app/library/UpdateChecker.py new file mode 100644 index 00000000..89cd9b33 --- /dev/null +++ b/app/library/UpdateChecker.py @@ -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 diff --git a/app/library/config.py b/app/library/config.py index c936416c..25761dcd 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -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." diff --git a/app/main.py b/app/main.py index 822e9cbe..6fb6130e 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/app/routes/api/system.py b/app/routes/api/system.py index 79a538b8..c0a5b3a6 100644 --- a/app/routes/api/system.py +++ b/app/routes/api/system.py @@ -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, + ) diff --git a/app/tests/test_system_routes.py b/app/tests/test_system_routes.py new file mode 100644 index 00000000..4c9087c8 --- /dev/null +++ b/app/tests/test_system_routes.py @@ -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" diff --git a/app/tests/test_update_checker.py b/app/tests/test_update_checker.py new file mode 100644 index 00000000..18cebf34 --- /dev/null +++ b/app/tests/test_update_checker.py @@ -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() diff --git a/ui/app/layouts/default.vue b/ui/app/layouts/default.vue index fcf977f2..41e5845e 100644 --- a/ui/app/layouts/default.vue +++ b/ui/app/layouts/default.vue @@ -157,34 +157,113 @@ -
-
-
- © {{ Year }} - YTPTube - ( - {{ config?.app?.app_version || 'unknown' }}) - - yt-dlp -  ({{ config?.app?.ytdlp_version || 'unknown' }}) - - CHANGELOG - - FAQ - - README - - API - - - - Top - +
+
+
+
+ + + + YTPTube + + + + {{ config?.app?.app_version || 'unknown' }} + +
+ + + +

+ + + + {{ moment.unix(config.app?.started).fromNow() }} + + +

+
+ +
+ +
+ +
+

Quick Links

+
+ + + + Changelog + + + + + + FAQ + + + + + + Docs + + + + + + API + + + +
-
-
- - {{ moment.unix(config.app?.started).fromNow() }} - -
-
-
+
@@ -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(false) const simpleMode = useStorage('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; + } } diff --git a/ui/app/stores/ConfigStore.ts b/ui/app/stores/ConfigStore.ts index 44b1b64d..2e874e47 100644 --- a/ui/app/stores/ConfigStore.ts +++ b/ui/app/stores/ConfigStore.ts @@ -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: [ { diff --git a/ui/app/types/config.d.ts b/ui/app/types/config.d.ts index 6da002b3..90be9755 100644 --- a/ui/app/types/config.d.ts +++ b/ui/app/types/config.d.ts @@ -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 = {