From 00c3aefff1314d9fec795cdb826af73c4b5ba3ad Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sat, 24 Jan 2026 20:01:48 +0300 Subject: [PATCH] Feat: add yt-dlp version checker --- app/library/UpdateChecker.py | 149 ++++++++++++-------- app/library/config.py | 5 + app/routes/api/system.py | 39 +++--- app/tests/test_system_routes.py | 12 +- app/tests/test_update_checker.py | 233 ++++++++++++++++++++++++------- ui/app/components/Connection.vue | 14 +- ui/app/layouts/default.vue | 94 ++++++++----- ui/app/stores/ConfigStore.ts | 1 + ui/app/types/config.d.ts | 2 + ui/app/types/index.d.ts | 15 +- 10 files changed, 389 insertions(+), 175 deletions(-) diff --git a/app/library/UpdateChecker.py b/app/library/UpdateChecker.py index 57c8e785..72d3cdac 100644 --- a/app/library/UpdateChecker.py +++ b/app/library/UpdateChecker.py @@ -24,12 +24,18 @@ class UpdateChecker(metaclass=Singleton): GITHUB_API_URL: str = "https://api.github.com/repos/arabcoders/ytptube/releases/latest" "GitHub API endpoint for latest release" + YTDLP_API_URL: str = "https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest" + "GitHub API endpoint for yt-dlp latest release" + CACHE_DURATION: int = 300 "Cache duration in seconds (5 minutes)" CACHE_KEY: str = "update_checker:result" "Cache key for storing check results" + YTDLP_CACHE_KEY: str = "update_checker:ytdlp" + "Cache key for storing yt-dlp check results" + def __init__( self, config: Config | None = None, scheduler: Scheduler | None = None, notify: EventBus | None = None ) -> None: @@ -42,7 +48,7 @@ class UpdateChecker(metaclass=Singleton): self._notify: EventBus = notify or EventBus.get_instance() "Instance of EventBus for notifications." - self._cache: Cache = Cache.get_instance() + self._cache: Cache = Cache() "Instance of Cache for caching check results." self._job_id: str | None = None @@ -52,28 +58,9 @@ class UpdateChecker(metaclass=Singleton): 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) @@ -97,7 +84,7 @@ class UpdateChecker(metaclass=Singleton): self._schedule_check() - async def on_shutdown(self, _: web.Application) -> None: + async def on_shutdown(self, _: web.Application | None) -> None: """ Handle application shutdown event. @@ -110,89 +97,129 @@ class UpdateChecker(metaclass=Singleton): 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", + id=f"{__class__.__name__}.{self.check_for_updates.__name__}", ) - LOG.info(f"Scheduled update check to run daily at 3 AM (cron: {timer}).") - - async def check_for_updates(self) -> tuple[str, str | None]: + async def check_for_updates(self) -> tuple[tuple[str, str | None], 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. + Check for updates from GitHub releases for both the app and yt-dlp. + Updates config.new_version and config.yt_new_version if newer versions are available. + Stops the scheduled task if an app update is found. Returns: - tuple[str, str | None]: (status, new_version) + tuple[tuple[str, str | None], tuple[str, str | None]]: ((app_status, app_version), (ytdlp_status, ytdlp_version)) status: "disabled", "error", "up_to_date", or "update_available" - new_version: The new version tag if available, None otherwise + 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) + return (("disabled", None), ("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 + app_cached = await self._cache.aget(self.CACHE_KEY) + ytdlp_cached = await self._cache.aget(self.YTDLP_CACHE_KEY) + if app_cached and ytdlp_cached: + return (app_cached, ytdlp_cached) + + app_result = app_cached if app_cached else await self._check_app_version() + ytdlp_result = ytdlp_cached if ytdlp_cached else await self._check_ytdlp_version() + + return (app_result, ytdlp_result) + + async def _check_github_version( + self, + name: str, + api_url: str, + current_version: str, + cache_key: str, + strip_v_prefix: bool = False, + ) -> tuple[str, str | None]: try: - LOG.info("Checking for application updates...") - - current_version: str = APP_VERSION.lstrip("v") + LOG.info(f"Checking for {name} updates...") async with async_client(timeout=10.0) as client: response = await client.get( - self.GITHUB_API_URL, + 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}") + LOG.warning(f"Failed to check for {name} updates: HTTP {response.status_code}") return ("error", None) data: dict[str, Any] = response.json() - latest_tag: str = data.get("tag_name", "").lstrip("v") + latest_tag: str = data.get("tag_name", "") if not latest_tag: - LOG.warning("No tag_name found in GitHub release data.") + LOG.warning(f"No tag_name found in {name} 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) + compare_current: str = current_version.lstrip("v") if strip_v_prefix else current_version + compare_latest: str = latest_tag.lstrip("v") if strip_v_prefix else latest_tag + + if self._compare_versions(compare_current, compare_latest): + LOG.warning(f"{name} update available: {current_version} -> {latest_tag}") + result: tuple[str, str] = ("update_available", latest_tag) + await self._cache.aset(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) + LOG.info(f"No {name} updates available.") + result: tuple[str, None] = ("up_to_date", None) + await self._cache.aset(cache_key, result, self.CACHE_DURATION) return result except Exception as e: LOG.exception(e) - LOG.error(f"Error checking for updates: {e!s}") + LOG.error(f"Error checking for {name} updates: {e!s}") return ("error", None) + async def _check_app_version(self) -> tuple[str, str | None]: + status, new_version = await self._check_github_version( + name="application", + api_url=self.GITHUB_API_URL, + current_version=APP_VERSION, + cache_key=self.CACHE_KEY, + strip_v_prefix=True, + ) + + if "update_available" == status: + self._config.new_version = new_version or "" + await self.on_shutdown(None) + elif "up_to_date" == status: + self._config.new_version = "" + + return (status, new_version) + + async def _check_ytdlp_version(self) -> tuple[str, str | None]: + current_version: str = self._config._ytdlp_version() + if not current_version or "0.0.0" == current_version: + LOG.warning("Could not determine yt-dlp version, skipping yt-dlp update check.") + return ("error", None) + + status, new_version = await self._check_github_version( + name="yt-dlp", + api_url=self.YTDLP_API_URL, + current_version=current_version, + cache_key=self.YTDLP_CACHE_KEY, + strip_v_prefix=False, + ) + + if "update_available" == status: + self._config.yt_new_version = new_version or "" + elif "up_to_date" == status: + self._config.yt_new_version = "" + + return (status, new_version) + def _compare_versions(self, current: str, latest: str) -> bool: """ Compare version strings to determine if an update is available. diff --git a/app/library/config.py b/app/library/config.py index 18832e53..a34a9ca5 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -238,6 +238,9 @@ class Config(metaclass=Singleton): new_version: str = "" "The new version available." + yt_new_version: str = "" + "The new yt-dlp version available." + _manual_vars: tuple = ( "temp_path", "config_path", @@ -255,6 +258,7 @@ class Config(metaclass=Singleton): "app_build_date", "app_branch", "new_version", + "yt_new_version", ) "The variables that are immutable." @@ -327,6 +331,7 @@ class Config(metaclass=Singleton): "default_pagination", "check_for_updates", "new_version", + "yt_new_version", ) "The variables that are relevant to the frontend." diff --git a/app/routes/api/system.py b/app/routes/api/system.py index c44bffab..df83fcf5 100644 --- a/app/routes/api/system.py +++ b/app/routes/api/system.py @@ -193,33 +193,38 @@ async def check_updates(config: Config, encoder: Encoder, update_checker: Update 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: + if config.new_version or config.yt_new_version: return web.json_response( data={ - "status": "update_available", - "current_version": config.app_version, - "new_version": config.new_version, + "app": { + "status": "update_available" if config.new_version else "up_to_date", + "current_version": config.app_version, + "new_version": config.new_version if config.new_version else None, + }, + "ytdlp": { + "status": "update_available" if config.yt_new_version else "up_to_date", + "current_version": config._ytdlp_version(), + "new_version": config.yt_new_version if config.yt_new_version else None, + }, }, status=web.HTTPOk.status_code, dumps=encoder.encode, ) - # Run check and await result - status, new_version = await update_checker.check_for_updates() + (app_status, app_new_version), (ytdlp_status, ytdlp_new_version) = await update_checker.check_for_updates() return web.json_response( data={ - "status": status, - "current_version": config.app_version, - "new_version": new_version, + "app": { + "status": app_status, + "current_version": config.app_version, + "new_version": app_new_version, + }, + "ytdlp": { + "status": ytdlp_status, + "current_version": config._ytdlp_version(), + "new_version": ytdlp_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 index 4c9087c8..209ccd7c 100644 --- a/app/tests/test_system_routes.py +++ b/app/tests/test_system_routes.py @@ -25,7 +25,7 @@ class TestCheckUpdatesEndpoint: response = await check_updates(config, encoder, update_checker) - assert 400 == response.status, "Should return 400 when update checking is disabled" + assert 200 == response.status, "Should work even if disabled as it's manual check." body = response.body assert b"disabled" in body.lower(), "Response should mention update checking is disabled" @@ -39,7 +39,7 @@ class TestCheckUpdatesEndpoint: 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) + mock_check.return_value = (("up_to_date", None), ("up_to_date", None)) response = await check_updates(config, encoder, update_checker) assert 200 == response.status, "Should return 200" @@ -57,7 +57,7 @@ class TestCheckUpdatesEndpoint: 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) + mock_check.return_value = (("up_to_date", None), ("up_to_date", None)) response = await check_updates(config, encoder, update_checker) assert 200 == response.status, "Should return 200" @@ -74,7 +74,7 @@ class TestCheckUpdatesEndpoint: 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") + mock_check.return_value = (("update_available", "v1.0.5"), ("up_to_date", None)) response = await check_updates(config, encoder, update_checker) assert 200 == response.status, "Should return 200" @@ -92,7 +92,7 @@ class TestCheckUpdatesEndpoint: 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) + mock_check.return_value = (("up_to_date", None), ("up_to_date", None)) response = await check_updates(config, encoder, update_checker) assert 200 == response.status, "Should return 200" @@ -109,7 +109,7 @@ class TestCheckUpdatesEndpoint: 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) + mock_check.return_value = (("error", None), ("error", None)) response = await check_updates(config, encoder, update_checker) assert 200 == response.status, "Should return 200" diff --git a/app/tests/test_update_checker.py b/app/tests/test_update_checker.py index 7a820a22..f876b865 100644 --- a/app/tests/test_update_checker.py +++ b/app/tests/test_update_checker.py @@ -53,23 +53,19 @@ class TestUpdateChecker: 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'" + assert "UpdateChecker.check_for_updates" == checker._job_id, "Job ID should be 'update_checker'" finally: - # Clean up if checker._job_id: scheduler.remove(checker._job_id) loop.close() @@ -118,7 +114,6 @@ class TestUpdateChecker: 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() @@ -126,15 +121,11 @@ class TestUpdateChecker: 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() @@ -148,15 +139,18 @@ class TestUpdateChecker: config = Config.get_instance() config.check_for_updates = False config.new_version = "" + config.yt_new_version = "" checker = UpdateChecker.get_instance(config=config) - # Should return disabled status - status, new_version = await checker.check_for_updates() + (app_status, app_version), (ytdlp_status, ytdlp_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 "disabled" == app_status, "Should return disabled status for app" + assert app_version is None, "Should return None for app new_version when disabled" + assert "disabled" == ytdlp_status, "Should return disabled status for yt-dlp" + assert ytdlp_version is None, "Should return None for yt-dlp new_version when disabled" assert "" == config.new_version, "Should not update new_version when disabled" + assert "" == config.yt_new_version, "Should not update yt_new_version when disabled" @pytest.mark.asyncio @patch("app.library.UpdateChecker.async_client") @@ -168,25 +162,32 @@ class TestUpdateChecker: config = Config.get_instance() config.check_for_updates = True config.new_version = "" + config.yt_new_version = "" - # Mock HTTP response - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"tag_name": "v99.0.0"} + mock_app_response = MagicMock() + mock_app_response.status_code = 200 + mock_app_response.json.return_value = {"tag_name": "v99.0.0"} + mock_ytdlp_response = MagicMock() + mock_ytdlp_response.status_code = 200 + mock_ytdlp_response.json.return_value = {"tag_name": "9999.12.31"} + + mock_get = AsyncMock(side_effect=[mock_app_response, mock_ytdlp_response]) mock_context = AsyncMock() - mock_context.__aenter__.return_value.get = AsyncMock(return_value=mock_response) + mock_context.__aenter__.return_value.get = mock_get 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() + (app_status, app_version), (ytdlp_status, ytdlp_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" + assert "update_available" == app_status, "Should return update_available status for app" + assert "v99.0.0" == app_version, "Should return new app version tag" + assert "v99.0.0" == config.new_version, "Should store new app version tag" + assert "update_available" == ytdlp_status, "Should return update_available status for yt-dlp" + assert "9999.12.31" == ytdlp_version, "Should return new yt-dlp version tag" + assert "9999.12.31" == config.yt_new_version, "Should store new yt-dlp version tag" + assert checker._job_id is None, "Should stop scheduled task after finding app update" @pytest.mark.asyncio @patch("app.library.UpdateChecker.async_client") @@ -198,24 +199,32 @@ class TestUpdateChecker: config = Config.get_instance() config.check_for_updates = True config.new_version = "" + config.yt_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_app_response = MagicMock() + mock_app_response.status_code = 200 + mock_app_response.json.return_value = {"tag_name": "v1.0.0"} + mock_ytdlp_response = MagicMock() + mock_ytdlp_response.status_code = 200 + mock_ytdlp_response.json.return_value = {"tag_name": "2020.01.01"} + + mock_get = AsyncMock(side_effect=[mock_app_response, mock_ytdlp_response]) mock_context = AsyncMock() - mock_context.__aenter__.return_value.get = AsyncMock(return_value=mock_response) + mock_context.__aenter__.return_value.get = mock_get 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 + checker._job_id = "test-job" - status, new_version = await checker.check_for_updates() + (app_status, app_version), (ytdlp_status, ytdlp_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 "up_to_date" == app_status, "Should return up_to_date status for app" + assert app_version is None, "Should return None for app new_version" + assert "" == config.new_version, "Should clear new_version when no app update available" + assert "up_to_date" == ytdlp_status, "Should return up_to_date status for yt-dlp" + assert ytdlp_version is None, "Should return None for yt-dlp new_version" + assert "" == config.yt_new_version, "Should clear yt_new_version when no yt-dlp update available" assert "test-job" == checker._job_id, "Should keep scheduled task running" @pytest.mark.asyncio @@ -228,8 +237,8 @@ class TestUpdateChecker: config = Config.get_instance() config.check_for_updates = True config.new_version = "" + config.yt_new_version = "" - # Mock HTTP error mock_response = MagicMock() mock_response.status_code = 404 @@ -239,11 +248,14 @@ class TestUpdateChecker: checker = UpdateChecker.get_instance(config=config) - status, new_version = await checker.check_for_updates() + (app_status, app_version), (ytdlp_status, ytdlp_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 "error" == app_status, "Should return error status for app" + assert app_version is None, "Should return None for app new_version on error" assert "" == config.new_version, "Should not set new_version on HTTP error" + assert "error" == ytdlp_status, "Should return error status for yt-dlp" + assert ytdlp_version is None, "Should return None for yt-dlp new_version on error" + assert "" == config.yt_new_version, "Should not set yt_new_version on HTTP error" @pytest.mark.asyncio @patch("app.library.UpdateChecker.async_client") @@ -255,19 +267,22 @@ class TestUpdateChecker: config = Config.get_instance() config.check_for_updates = True config.new_version = "" + config.yt_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() + (app_status, app_version), (ytdlp_status, ytdlp_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 "error" == app_status, "Should return error status on exception for app" + assert app_version is None, "Should return None for app new_version on exception" assert "" == config.new_version, "Should not set new_version on exception" + assert "error" == ytdlp_status, "Should return error status on exception for yt-dlp" + assert ytdlp_version is None, "Should return None for yt-dlp new_version on exception" + assert "" == config.yt_new_version, "Should not set yt_new_version on exception" def test_compare_versions_newer_available(self): """Test version comparison detects newer version.""" @@ -311,13 +326,19 @@ class TestUpdateChecker: config = Config.get_instance() config.check_for_updates = True config.new_version = "" + config.yt_new_version = "" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"tag_name": "v2.0.0"} + mock_app_response = MagicMock() + mock_app_response.status_code = 200 + mock_app_response.json.return_value = {"tag_name": "v2.0.0"} + mock_ytdlp_response = MagicMock() + mock_ytdlp_response.status_code = 200 + mock_ytdlp_response.json.return_value = {"tag_name": "2026.01.01"} + + mock_get = AsyncMock(side_effect=[mock_app_response, mock_ytdlp_response]) mock_context = AsyncMock() - mock_context.__aenter__.return_value.get = AsyncMock(return_value=mock_response) + mock_context.__aenter__.return_value.get = mock_get with patch("app.library.UpdateChecker.async_client", return_value=mock_context): with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"): @@ -325,6 +346,7 @@ class TestUpdateChecker: await checker.check_for_updates() assert "v2.0.0" == config.new_version, "Should store full tag_name including 'v' prefix" + assert "2026.01.01" == config.yt_new_version, "Should store yt-dlp tag_name" def test_subscribe_to_started_event(self): """Test that attach subscribes to Events.STARTED.""" @@ -350,7 +372,6 @@ class TestUpdateChecker: 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), ( @@ -360,3 +381,119 @@ class TestUpdateChecker: if checker and checker._job_id: scheduler.remove(checker._job_id) loop.close() + + @pytest.mark.asyncio + @patch("app.library.UpdateChecker.async_client") + async def test_check_ytdlp_version_finds_newer_version(self, mock_client): + """Test that yt-dlp check 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.yt_new_version = "" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"tag_name": "9999.12.31"} + + 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_ytdlp_version() + + assert "update_available" == status, "Should return update_available status for yt-dlp" + assert "9999.12.31" == new_version, "Should return new yt-dlp version tag" + assert "9999.12.31" == config.yt_new_version, "Should store new yt-dlp version tag" + + @pytest.mark.asyncio + @patch("app.library.UpdateChecker.async_client") + async def test_check_ytdlp_version_no_update_available(self, mock_client): + """Test that yt-dlp check 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.yt_new_version = "" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"tag_name": "2020.01.01"} + + 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_ytdlp_version() + + assert "up_to_date" == status, "Should return up_to_date status for yt-dlp" + assert new_version is None, "Should return None for yt-dlp new_version" + assert "" == config.yt_new_version, "Should clear yt_new_version when no update available" + + @pytest.mark.asyncio + @patch("app.library.UpdateChecker.async_client") + async def test_check_ytdlp_version_handles_http_error(self, mock_client): + """Test that yt-dlp check 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.yt_new_version = "" + + mock_response = MagicMock() + mock_response.status_code = 500 + + 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_ytdlp_version() + + assert "error" == status, "Should return error status for yt-dlp on HTTP error" + assert new_version is None, "Should return None for yt-dlp new_version on error" + assert "" == config.yt_new_version, "Should not set yt_new_version on HTTP error" + + @pytest.mark.asyncio + @patch("app.library.UpdateChecker.async_client") + async def test_check_for_updates_caches_separately(self, mock_client): + """Test that app and yt-dlp checks are cached separately.""" + from app.library.config import Config + from app.library.UpdateChecker import UpdateChecker + + config = Config.get_instance() + config.check_for_updates = True + config.new_version = "" + config.yt_new_version = "" + + mock_app_response = MagicMock() + mock_app_response.status_code = 200 + mock_app_response.json.return_value = {"tag_name": "v2.0.0"} + + mock_ytdlp_response = MagicMock() + mock_ytdlp_response.status_code = 200 + mock_ytdlp_response.json.return_value = {"tag_name": "2026.01.01"} + + mock_get = AsyncMock(side_effect=[mock_app_response, mock_ytdlp_response]) + mock_context = AsyncMock() + mock_context.__aenter__.return_value.get = mock_get + mock_client.return_value = mock_context + + with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"): + checker = UpdateChecker.get_instance(config=config) + (app_status1, app_version1), (ytdlp_status1, ytdlp_version1) = await checker.check_for_updates() + + assert "update_available" == app_status1, "Should find app update on first call" + assert "update_available" == ytdlp_status1, "Should find yt-dlp update on first call" + + (app_status2, app_version2), (ytdlp_status2, ytdlp_version2) = await checker.check_for_updates() + + assert "update_available" == app_status2, "Should return cached app result" + assert "update_available" == ytdlp_status2, "Should return cached yt-dlp result" + assert app_version1 == app_version2, "App versions should match from cache" + assert ytdlp_version1 == ytdlp_version2, "yt-dlp versions should match from cache" diff --git a/ui/app/components/Connection.vue b/ui/app/components/Connection.vue index 4b027036..5ff63e0b 100644 --- a/ui/app/components/Connection.vue +++ b/ui/app/components/Connection.vue @@ -1,10 +1,14 @@