Feat: add yt-dlp version checker

This commit is contained in:
arabcoders 2026-01-24 20:01:48 +03:00
parent 042379035a
commit 00c3aefff1
10 changed files with 389 additions and 175 deletions

View file

@ -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.

View file

@ -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."

View file

@ -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,

View file

@ -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"

View file

@ -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"

View file

@ -1,10 +1,14 @@
<template>
<div class="message is-warning" v-if="status === 'disconnected'">
<div class="message is-warning" v-if="status !== 'connected'">
<div class="message-body">
<span class="icon"><i class="fas fa-info-circle" /></span>
<span>
Websocket connection lost, <NuxtLink class="is-bold" @click.prevent="() => $emit('reconnect')">Click here</NuxtLink>
to reconnect.
<span class="icon"><i class="fas"
:class="{ 'fa-info-circle': status === 'disconnected', 'fa-spinner fa-pulse': status === 'connecting' }" /></span>
<span v-if="status === 'disconnected'">
Websocket connection lost, <NuxtLink class="is-bold" @click.prevent="() => $emit('reconnect')">Click here
</NuxtLink> to reconnect.
</span>
<span v-else-if="status === 'connecting'">
Connecting to websocket server...
</span>
</div>
</div>

View file

@ -182,31 +182,29 @@
</span>
</div>
<template v-if="config.app?.check_for_updates">
<p v-if="config.app?.new_version" class="is-size-7 mb-2" style="opacity: 0.8">
<span class="icon-text">
<span class="icon has-text-warning"><i class="fas fa-circle-info" /></span>
<span>Update available:</span>
<NuxtLink :href="`https://github.com/ArabCoders/ytptube/releases/tag/${config.app.new_version}`"
target="_blank" class="has-text-weight-semibold ml-1">
{{ config.app.new_version }}
</NuxtLink>
</span>
</p>
<p v-if="config.app?.new_version" class="is-size-7 mb-2" style="opacity: 0.8">
<span class="icon-text">
<span class="icon has-text-warning"><i class="fas fa-circle-info" /></span>
<span>Update available:</span>
<NuxtLink :href="`https://github.com/ArabCoders/ytptube/releases/tag/${config.app.new_version}`"
target="_blank" class="has-text-weight-semibold ml-1">
{{ config.app.new_version }}
</NuxtLink>
</span>
</p>
<p v-else class="is-size-7 mb-2" style="opacity: 0.6">
<button @click="checkForUpdates" :disabled="checkingUpdates" class="is-text is-small p-0 is-size-7"
:class="{ 'is-loading': checkingUpdates }"
style="opacity: 0.8; height: auto; vertical-align: baseline; border: none; text-decoration: none; background: none;">
<span class="icon-text">
<span class="icon">
<i class="fas" :class="checkingUpdates ? 'fa-spinner fa-spin' : 'fa-circle-check'" />
</span>
<span>{{ updateCheckMessage }}</span>
<p v-else class="is-size-7 mb-2" style="opacity: 0.6">
<button @click="checkForUpdates" :disabled="checkingUpdates" class="is-text is-small p-0 is-size-7"
:class="{ 'is-loading': checkingUpdates }"
style="opacity: 0.8; height: auto; vertical-align: baseline; border: none; text-decoration: none; background: none;">
<span class="icon-text">
<span class="icon">
<i class="fas" :class="checkingUpdates ? 'fa-spinner fa-spin' : 'fa-circle-check'" />
</span>
</button>
</p>
</template>
<span>{{ updateCheckMessage }}</span>
</span>
</button>
</p>
<p v-if="config.app?.started" class="is-size-7 mb-0" style="opacity: 0.6">
<span class="icon-text">
@ -230,6 +228,19 @@
</span>
</NuxtLink>
<span class="is-size-7 ml-1" style="opacity: 0.6">{{ config?.app?.ytdlp_version || 'unknown' }}</span>
<p v-if="config.app?.yt_new_version" class="is-size-7 mb-0 mt-2" style="opacity: 0.8">
<span class="icon-text">
<span class="has-tooltip" v-tooltip="`Restart container to update yt-dlp`">
<span class="icon has-text-warning"><i class="fas fa-circle-info" /></span>
<span>Update available:</span>
</span>
<NuxtLink :href="`https://github.com/yt-dlp/yt-dlp/releases/tag/${config.app.yt_new_version}`"
target="_blank" class="has-text-weight-semibold ml-1">
{{ config.app.yt_new_version }}
</NuxtLink>
</span>
</p>
</div>
</div>
@ -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<version_check>(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'

View file

@ -32,6 +32,7 @@ export const useConfigStore = defineStore('config', () => {
default_pagination: 50,
check_for_updates: true,
new_version: '',
yt_new_version: '',
},
presets: [
{

View file

@ -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 = {

View file

@ -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 }