Feat: add yt-dlp version checker
This commit is contained in:
parent
042379035a
commit
00c3aefff1
10 changed files with 389 additions and 175 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||
default_pagination: 50,
|
||||
check_for_updates: true,
|
||||
new_version: '',
|
||||
yt_new_version: '',
|
||||
},
|
||||
presets: [
|
||||
{
|
||||
|
|
|
|||
2
ui/app/types/config.d.ts
vendored
2
ui/app/types/config.d.ts
vendored
|
|
@ -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 = {
|
||||
|
|
|
|||
15
ui/app/types/index.d.ts
vendored
15
ui/app/types/index.d.ts
vendored
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Reference in a new issue