ytptube/app/library/UpdateChecker.py
2026-01-19 17:15:10 +03:00

214 lines
7.1 KiB
Python

import asyncio
import logging
import re
from typing import Any
from aiohttp import web
from .cache import Cache
from .config import Config
from .Events import EventBus, Events
from .httpx_client import async_client
from .Scheduler import Scheduler
from .Singleton import Singleton
from .version import APP_VERSION
LOG: logging.Logger = logging.getLogger("update_checker")
class UpdateChecker(metaclass=Singleton):
"""
Checks for application updates from GitHub releases.
"""
GITHUB_API_URL: str = "https://api.github.com/repos/arabcoders/ytptube/releases/latest"
"GitHub API endpoint for latest release"
CACHE_DURATION: int = 300
"Cache duration in seconds (5 minutes)"
CACHE_KEY: str = "update_checker:result"
"Cache key for storing check results"
def __init__(
self, config: Config | None = None, scheduler: Scheduler | None = None, notify: EventBus | None = None
) -> None:
self._config: Config = config or Config.get_instance()
"Instance of Config to use."
self._scheduler: Scheduler = scheduler or Scheduler.get_instance()
"Instance of Scheduler to use."
self._notify: EventBus = notify or EventBus.get_instance()
"Instance of EventBus for notifications."
self._cache: Cache = Cache.get_instance()
"Instance of Cache for caching check results."
self._job_id: str | None = None
"Scheduler job ID for update checks."
@staticmethod
def get_instance(
config: Config | None = None, scheduler: Scheduler | None = None, notify: EventBus | None = None
) -> "UpdateChecker":
"""
Get the singleton instance of UpdateChecker.
Args:
config (Config | None): Optional Config instance to use.
scheduler (Scheduler | None): Optional Scheduler instance to use.
notify (EventBus | None): Optional EventBus instance to use.
Returns:
UpdateChecker: The singleton instance of UpdateChecker.
"""
return UpdateChecker(config=config, scheduler=scheduler, notify=notify)
def attach(self, _: web.Application) -> None:
"""
Attach the UpdateChecker to the application.
Args:
_ (web.Application): The aiohttp web application instance.
"""
from .Services import Services
Services.get_instance().add("update_checker", self)
if not self._config.check_for_updates:
LOG.info("Update checking is disabled.")
return
if re.search(r"^v\d+", APP_VERSION) is None:
LOG.warning("Not on a release version, skipping update checker setup.")
return
async def event_handler(_, __):
await self.check_for_updates()
self._notify.subscribe(
event=Events.STARTED,
callback=event_handler,
name=f"{__class__.__name__}.{__class__.attach.__name__}",
)
self._schedule_check()
async def on_shutdown(self, _: web.Application) -> None:
"""
Handle application shutdown event.
Args:
_ (web.Application): The aiohttp web application instance.
"""
if not self._job_id:
return
self._scheduler.remove(self._job_id)
self._job_id = None
LOG.debug("Stopped update check scheduled task.")
def _schedule_check(self) -> None:
"""Schedule the update check task to run daily at 3 AM."""
if not self._config.check_for_updates:
LOG.debug("Update checking is disabled, skipping scheduling.")
return
# Run daily at 3 AM
timer: str = "0 3 * * *"
self._job_id = self._scheduler.add(
timer=timer,
func=lambda: asyncio.create_task(self.check_for_updates()),
id="update_checker",
)
LOG.info(f"Scheduled update check to run daily at 3 AM (cron: {timer}).")
async def check_for_updates(self) -> tuple[str, str | None]:
"""
Check for updates from GitHub releases.
Updates config.new_version if a newer version is available.
Stops the scheduled task if an update is found.
Returns:
tuple[str, str | None]: (status, new_version)
status: "disabled", "error", "up_to_date", or "update_available"
new_version: The new version tag if available, None otherwise
"""
if not self._config.check_for_updates:
LOG.debug("Update checking is disabled, skipping check.")
return ("disabled", None)
# Check cache
cached = await self._cache.aget(self.CACHE_KEY)
if cached:
ttl = await self._cache.attl(self.CACHE_KEY)
LOG.debug(f"Returning cached result (TTL: {ttl:.0f}s)")
return cached
try:
LOG.info("Checking for application updates...")
current_version: str = APP_VERSION.lstrip("v")
async with async_client(timeout=10.0) as client:
response = await client.get(
self.GITHUB_API_URL,
headers={"Accept": "application/vnd.github+json"},
)
if 200 != response.status_code:
LOG.warning(f"Failed to check for updates: HTTP {response.status_code}")
return ("error", None)
data: dict[str, Any] = response.json()
latest_tag: str = data.get("tag_name", "").lstrip("v")
if not latest_tag:
LOG.warning("No tag_name found in GitHub release data.")
return ("error", None)
if self._compare_versions(current_version, latest_tag):
LOG.warning(f"Update available: {current_version}{latest_tag}")
new_version_tag = data.get("tag_name", "")
self._config.new_version = new_version_tag
await self.on_shutdown(None)
result = ("update_available", new_version_tag)
await self._cache.aset(self.CACHE_KEY, result, self.CACHE_DURATION)
return result
LOG.info("No updates available.")
self._config.new_version = ""
result = ("up_to_date", None)
await self._cache.aset(self.CACHE_KEY, result, self.CACHE_DURATION)
return result
except Exception as e:
LOG.exception(e)
LOG.error(f"Error checking for updates: {e!s}")
return ("error", None)
def _compare_versions(self, current: str, latest: str) -> bool:
"""
Compare version strings to determine if an update is available.
Args:
current (str): Current version string
latest (str): Latest version string
Returns:
bool: True if latest > current, False otherwise
"""
try:
from packaging.version import parse as parse_version
return parse_version(latest) > parse_version(current)
except Exception as e:
LOG.warning(f"Error comparing versions '{current}' vs '{latest}': {e}")
return False