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 @@
-
+
-
-
- Websocket connection lost, $emit('reconnect')">Click here
- to reconnect.
+
+
+ Websocket connection lost, $emit('reconnect')">Click here
+ to reconnect.
+
+
+ Connecting to websocket server...
diff --git a/ui/app/layouts/default.vue b/ui/app/layouts/default.vue
index a7b482eb..c15cac81 100644
--- a/ui/app/layouts/default.vue
+++ b/ui/app/layouts/default.vue
@@ -182,31 +182,29 @@
-
-
-
-
- Update available:
-
- {{ config.app.new_version }}
-
-
-
+
+
+
+ Update available:
+
+ {{ config.app.new_version }}
+
+
+
-
-
+ {{ updateCheckMessage }}
+
+
+
@@ -230,6 +228,19 @@
{{ config?.app?.ytdlp_version || 'unknown' }}
+
+
+
+
+
+ Update available:
+
+
+ {{ config.app.yt_new_version }}
+
+
+
@@ -289,6 +300,7 @@ import Dialog from '~/components/Dialog.vue'
import Simple from '~/components/Simple.vue'
import Shutdown from '~/components/shutdown.vue'
import Markdown from '~/components/Markdown.vue'
+import type { version_check } from '~/types'
const selectedTheme = useStorage('theme', 'auto')
const socket = useSocketStore()
@@ -326,22 +338,30 @@ const checkForUpdates = async () => {
return
}
- const data = await response.json()
+ const data = await parse_api_response(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
+ if ('update_available' === data.app.status) {
+ config.app.new_version = data.app.new_version
}
+ if (data.ytdlp && 'update_available' === data.ytdlp.status) {
+ config.app.yt_new_version = data.ytdlp.new_version
+ }
+
+ // Only show "Update found!" if app has update (button is in app section)
+ if ('update_available' === data.app.status) {
+ updateCheckMessage.value = 'Update found!'
+ } else if ('up_to_date' === data.app.status && 'up_to_date' === data.ytdlp?.status) {
+ updateCheckMessage.value = 'Up to date ✓'
+ setTimeout(() => updateCheckMessage.value = msg, 3000)
+ } else if ('up_to_date' === data.app.status && 'update_available' === data.ytdlp?.status) {
+ // App is up to date, but yt-dlp has update (shows in yt-dlp section)
+ updateCheckMessage.value = 'Up to date ✓'
+ setTimeout(() => updateCheckMessage.value = msg, 3000)
+ } else {
+ updateCheckMessage.value = 'Check failed'
+ setTimeout(() => updateCheckMessage.value = msg, 3000)
+ }
+
} catch (e) {
console.error('Update check failed:', e)
updateCheckMessage.value = 'Check failed'
diff --git a/ui/app/stores/ConfigStore.ts b/ui/app/stores/ConfigStore.ts
index b48bf181..a597e3ac 100644
--- a/ui/app/stores/ConfigStore.ts
+++ b/ui/app/stores/ConfigStore.ts
@@ -32,6 +32,7 @@ export const useConfigStore = defineStore('config', () => {
default_pagination: 50,
check_for_updates: true,
new_version: '',
+ yt_new_version: '',
},
presets: [
{
diff --git a/ui/app/types/config.d.ts b/ui/app/types/config.d.ts
index 7bb6d32d..99bfca40 100644
--- a/ui/app/types/config.d.ts
+++ b/ui/app/types/config.d.ts
@@ -49,6 +49,8 @@ type AppConfig = {
check_for_updates: boolean
/** New version available, empty string if none */
new_version: string
+ /** New yt-dlp version available, empty string if none */
+ yt_new_version: string
}
type ConfigState = {
diff --git a/ui/app/types/index.d.ts b/ui/app/types/index.d.ts
index ce28ed7e..61bfe3ac 100644
--- a/ui/app/types/index.d.ts
+++ b/ui/app/types/index.d.ts
@@ -3,4 +3,17 @@ type ImportedItem = {
_version: string
}
-export { ImportedItem }
+type version_check = {
+ app: {
+ status: 'up_to_date' | 'update_available' | 'error',
+ current_version: string,
+ new_version: string
+ },
+ ytdlp: {
+ status: 'up_to_date' | 'update_available' | 'error',
+ current_version: string,
+ new_version: string
+ }
+}
+
+export { ImportedItem, version_check }