Check for SageTube updates, not YTSage's
The update check asked PyPI for the `ytsage` package's version, asked GitHub for oop7/YTSage's release notes, and pointed the download button at upstream's releases. None of that describes this program. Being reminded to install YTSage was the visible symptom; the cause was that the check had never been repointed when the fork was made. It now reads SageTube's own releases from git.houmeres.sk. Gitea's release API is shaped like GitHub's, so the dialog and the caller are unchanged -- the class keeps its name and signal signature, and only its body moved out to core/ytsage_app_update.py, which is fork-owned and will not conflict on the next merge from upstream. Upstream's inherited tags end in `b` (v5.3.0b and earlier). packaging reads that as a beta marker, so they sort below v5.4.0 and a stable instance cannot be handed one. Tag parsing is defensive anyway: one unparseable tag must not take the whole check down with it. Also: the check is rate-limited to once a day rather than every start, the dialog gained a "Skip this version" that survives a restart, the thread is now joined on close, and the About dialog says SageTube. The three binary updaters -- yt-dlp, Deno, ffmpeg -- legitimately track their own upstreams and are deliberately untouched; ytsage_app_update's docstring says so, because "update" is an overloaded word in this codebase. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,25 @@ records.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- **The update check now looks at SageTube's own releases.** It queried PyPI's
|
||||
`ytsage` package for the version and `oop7/YTSage` for the changelog, then
|
||||
linked to upstream's downloads — a different program's release stream. It now
|
||||
reads `git.houmeres.sk/Houmeres/SageTube` releases, anonymously, and the
|
||||
"Download update" button opens this repository's release page.
|
||||
- The beta channel follows SageTube pre-releases. Upstream's inherited `b` tags
|
||||
(`v5.3.0b` and earlier) read as pre-releases, so a stable instance cannot be
|
||||
offered one.
|
||||
- The check now runs at most once a day rather than on every start.
|
||||
- The About dialog and the update settings say SageTube rather than YTSage. The
|
||||
"Based on YTSage by oop7" attribution link stays — it is the MIT credit.
|
||||
|
||||
### Added
|
||||
|
||||
- **Skip this version** in the update dialog. The skipped version is never
|
||||
offered again; later ones still are.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The app no longer offers upstream YTSage's releases as its own updates.**
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
Application update checks against SageTube's own releases
|
||||
=========================================================
|
||||
|
||||
SageTube is a fork of oop7/YTSage. It keeps upstream's git history, its
|
||||
internal `ytsage` package name and its inherited tags -- but it is a different
|
||||
program with a different release stream, and this module is the line between
|
||||
them.
|
||||
|
||||
**This checks SageTube. Nothing here touches upstream.** The three other
|
||||
updaters in this codebase legitimately track their own upstreams and are
|
||||
deliberately left alone:
|
||||
|
||||
- yt-dlp binary -> gui/ytsage_gui_dialogs/ytsage_dialogs_update.py
|
||||
- Deno runtime -> core/ytsage_deno.py
|
||||
- ffmpeg -> utils/ytsage_constants.py
|
||||
|
||||
They share only the word "update".
|
||||
|
||||
Gitea's release API is shaped like GitHub's -- `tag_name`, `body`, `html_url`,
|
||||
`draft`, `prerelease` -- so the consuming code needs no special cases. The
|
||||
repository is anonymously readable, so no token is involved.
|
||||
|
||||
Tag notes
|
||||
---------
|
||||
The repository carries upstream's tags up to `v5.3.0b` alongside SageTube's
|
||||
own. `packaging` reads a trailing `b` as a beta marker (`5.3.0b` -> `5.3.0b0`),
|
||||
so those inherited tags are correctly treated as pre-releases and are invisible
|
||||
to a stable instance. Parsing is still defensive: one unparseable tag must not
|
||||
take the whole check down.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from packaging.version import InvalidVersion, Version, parse as parse_version
|
||||
|
||||
from .. import __version__
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
API_BASE: str = "https://git.houmeres.sk/api/v1/repos/Houmeres/SageTube"
|
||||
RELEASES_PAGE: str = "https://git.houmeres.sk/Houmeres/SageTube/releases"
|
||||
|
||||
DEFAULT_TIMEOUT: float = 8.0
|
||||
#: Don't ask the forge more than once a day, however often the app is started.
|
||||
CHECK_INTERVAL_SECONDS: int = 24 * 60 * 60
|
||||
#: How many releases to pull when the beta channel is on.
|
||||
BETA_PAGE_SIZE: int = 20
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Release:
|
||||
"""One published release, normalised."""
|
||||
|
||||
version: Version
|
||||
tag: str
|
||||
url: str
|
||||
body: str
|
||||
prerelease: bool
|
||||
|
||||
|
||||
def _headers() -> Dict[str, str]:
|
||||
return {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": f"SageTube/{__version__}",
|
||||
}
|
||||
|
||||
|
||||
def _to_release(payload: Dict[str, Any]) -> Optional[Release]:
|
||||
"""Normalise one API object, or None if it is unusable."""
|
||||
tag = (payload.get("tag_name") or "").strip()
|
||||
if not tag:
|
||||
return None
|
||||
try:
|
||||
version = parse_version(tag.lstrip("vV"))
|
||||
except (InvalidVersion, TypeError) as e:
|
||||
# A hand-made or inherited tag that isn't a version. Skip it rather
|
||||
# than letting it abort the whole check.
|
||||
logger.debug(f"Ignoring unparseable release tag {tag!r}: {e}")
|
||||
return None
|
||||
|
||||
return Release(
|
||||
version=version,
|
||||
tag=tag,
|
||||
url=payload.get("html_url") or f"{RELEASES_PAGE}/tag/{tag}",
|
||||
body=(payload.get("body") or "").strip(),
|
||||
prerelease=bool(payload.get("prerelease")) or version.is_prerelease,
|
||||
)
|
||||
|
||||
|
||||
def _get(path: str, timeout: float, **params: Any) -> Optional[Any]:
|
||||
"""GET a JSON endpoint. Returns None on any failure -- never raises."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{API_BASE}{path}",
|
||||
headers=_headers(),
|
||||
params=params or None,
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.debug(f"Update check request failed: {e}")
|
||||
return None
|
||||
|
||||
if response.status_code == 404:
|
||||
# No release matches (e.g. /releases/latest on a repo whose only
|
||||
# releases are pre-releases). That is "nothing to offer", not an error.
|
||||
logger.debug("Update check: no matching release published.")
|
||||
return None
|
||||
if response.status_code != 200:
|
||||
logger.debug(f"Update check: API returned {response.status_code}")
|
||||
return None
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as e:
|
||||
logger.debug(f"Update check: malformed JSON: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def fetch_latest(include_prerelease: bool = False, timeout: float = DEFAULT_TIMEOUT) -> Optional[Release]:
|
||||
"""
|
||||
The newest published release, or None if there is nothing or the check failed.
|
||||
|
||||
With `include_prerelease` the whole release list is pulled and filtered
|
||||
here rather than trusting server-side filters, because the highest
|
||||
*version* is wanted -- not the most recently published, which is what the
|
||||
API orders by.
|
||||
"""
|
||||
if not include_prerelease:
|
||||
payload = _get("/releases/latest", timeout)
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
release = _to_release(payload)
|
||||
# /releases/latest should never return a draft or pre-release, but a
|
||||
# stable instance must not be handed one even if it does.
|
||||
if release is None or release.prerelease:
|
||||
return None
|
||||
return release
|
||||
|
||||
payload = _get("/releases", timeout, limit=BETA_PAGE_SIZE, draft=False)
|
||||
if not isinstance(payload, list):
|
||||
return None
|
||||
|
||||
candidates: List[Release] = []
|
||||
for item in payload:
|
||||
if not isinstance(item, dict) or item.get("draft"):
|
||||
continue
|
||||
release = _to_release(item)
|
||||
if release is not None:
|
||||
candidates.append(release)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates, key=lambda r: r.version)
|
||||
|
||||
|
||||
def is_newer(release: Release, current_version: str) -> bool:
|
||||
"""
|
||||
Whether `release` should be offered over the running version.
|
||||
|
||||
Honours the version the user chose to skip, so "Skip this version" is not
|
||||
silently undone by the next start.
|
||||
"""
|
||||
try:
|
||||
current = parse_version(str(current_version).lstrip("vV"))
|
||||
except (InvalidVersion, TypeError):
|
||||
logger.debug(f"Running version {current_version!r} is unparseable; skipping update check.")
|
||||
return False
|
||||
|
||||
if release.version <= current:
|
||||
return False
|
||||
|
||||
skipped = ConfigManager.get("skipped_update_version")
|
||||
if skipped and str(skipped) == str(release.version):
|
||||
logger.debug(f"Update {release.tag} available but skipped by the user.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def should_check(now: Optional[float] = None) -> bool:
|
||||
"""Rate-limit to CHECK_INTERVAL_SECONDS, so a restart is not a new check."""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
try:
|
||||
last = float(ConfigManager.get("last_update_check") or 0)
|
||||
except (TypeError, ValueError):
|
||||
last = 0.0
|
||||
# A clock moved backwards must not lock the check out until it catches up.
|
||||
if last > now:
|
||||
return True
|
||||
return (now - last) >= CHECK_INTERVAL_SECONDS
|
||||
|
||||
|
||||
def mark_checked(now: Optional[float] = None) -> None:
|
||||
ConfigManager.set("last_update_check", now if now is not None else time.time())
|
||||
@@ -203,7 +203,7 @@ class AboutDialog(QDialog):
|
||||
|
||||
# Title and Version - more compact
|
||||
title_label = QLabel(
|
||||
"<span style='color: #c90000; font-size: 28px; font-weight: 300; letter-spacing: 2px;'>YTSage</span>"
|
||||
"<span style='color: #c90000; font-size: 28px; font-weight: 300; letter-spacing: 2px;'>SageTube</span>"
|
||||
)
|
||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
+53
-123
@@ -5,8 +5,6 @@ import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
import markdown
|
||||
import requests
|
||||
from packaging import version
|
||||
from PySide6.QtCore import Q_ARG, QMetaObject, Qt, QTimer, Slot, QThread, Signal, QUrl, QPropertyAnimation, QEasingCurve, QPoint
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
|
||||
@@ -35,6 +33,7 @@ from PySide6.QtWidgets import (
|
||||
from PySide6.QtGui import QIcon, QPixmap, QPainter, QBrush, QColor
|
||||
|
||||
from .. import __version__ as APP_VERSION
|
||||
from ..core import ytsage_app_update as app_update # SageTube's own release check
|
||||
from ..core.ytsage_downloader import DownloadThread, SignalManager # Import downloader related classes
|
||||
from ..core.ytsage_utils import check_ffmpeg, load_saved_path, parse_yt_dlp_error, save_path, should_check_for_auto_update, validate_video_url
|
||||
from ..core.ytsage_yt_dlp import get_yt_dlp_path, setup_ytdlp # Import the new yt-dlp functions
|
||||
@@ -68,141 +67,46 @@ from ..utils.ytsage_localization import LocalizationManager, _
|
||||
from ..utils.ytsage_history_manager import HistoryManager
|
||||
from .ytsage_stylesheet import StyleSheet
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
|
||||
class UpdateCheckThread(QThread):
|
||||
"""Background thread for checking application updates with parallel network requests."""
|
||||
"""
|
||||
Background check for a newer **SageTube** release.
|
||||
|
||||
This used to compare against PyPI's `ytsage` package and link to
|
||||
oop7/YTSage's releases -- upstream's release stream, not this program's.
|
||||
Combined with a package version that had been left at 0.1.0, it reported an
|
||||
update on every start and sent the user to another project's downloads.
|
||||
|
||||
The forge query lives in core/ytsage_app_update.py. This class is only the
|
||||
thread wrapper, and keeps its name and `update_available` signature so the
|
||||
dialog and the caller are untouched.
|
||||
"""
|
||||
|
||||
update_available = Signal(str, str, str) # version, url, changelog
|
||||
|
||||
# Reduced timeouts for faster failure detection
|
||||
PYPI_TIMEOUT = 8
|
||||
GITHUB_TIMEOUT = 5
|
||||
|
||||
def __init__(self, current_version):
|
||||
super().__init__()
|
||||
self.current_version = current_version
|
||||
|
||||
def _fetch_pypi_version(self) -> tuple[str | None, str | None]:
|
||||
"""Fetch latest version from PyPI. Returns (version, error)."""
|
||||
try:
|
||||
response = requests.get(
|
||||
"https://pypi.org/pypi/ytsage/json",
|
||||
timeout=self.PYPI_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
pypi_data = response.json()
|
||||
return pypi_data["info"]["version"], None
|
||||
except requests.Timeout:
|
||||
return None, "PyPI request timed out"
|
||||
except requests.RequestException as e:
|
||||
return None, f"PyPI request failed: {e}"
|
||||
except Exception as e:
|
||||
return None, f"Error parsing PyPI response: {e}"
|
||||
|
||||
def _fetch_github_changelog(self) -> str:
|
||||
"""Fetch changelog from GitHub. Returns changelog text or fallback message."""
|
||||
fallback = "View the full changelog on the [GitHub Releases](https://github.com/oop7/YTSage/releases) page."
|
||||
try:
|
||||
response = requests.get(
|
||||
"https://api.github.com/repos/oop7/YTSage/releases/latest",
|
||||
headers={"Accept": "application/vnd.github.v3+json"},
|
||||
timeout=self.GITHUB_TIMEOUT,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
gh_data = response.json()
|
||||
return gh_data.get("body", fallback) or fallback
|
||||
return fallback
|
||||
except Exception:
|
||||
# Silently fallback if GitHub API fails (rate limiting, network issues, etc.)
|
||||
return fallback
|
||||
|
||||
def _fetch_github_beta_version(self) -> tuple[str | None, str | None, str | None]:
|
||||
"""Fetch latest version code from GitHub releases (including betas). Returns (version, tag, changelog)."""
|
||||
try:
|
||||
response = requests.get(
|
||||
"https://api.github.com/repos/oop7/YTSage/releases",
|
||||
headers={"Accept": "application/vnd.github.v3+json"},
|
||||
timeout=self.GITHUB_TIMEOUT,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.debug(f"GitHub Releases API returned {response.status_code}")
|
||||
return None, None, None
|
||||
|
||||
releases = response.json()
|
||||
if not releases:
|
||||
return None, None, None
|
||||
|
||||
latest_release = None
|
||||
highest_ver = version.parse("0.0.0")
|
||||
|
||||
for rel in releases:
|
||||
tag = rel.get("tag_name", "")
|
||||
ver_str = tag.lstrip("v")
|
||||
try:
|
||||
v = version.parse(ver_str)
|
||||
if v > highest_ver:
|
||||
highest_ver = v
|
||||
latest_release = rel
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if latest_release:
|
||||
return str(highest_ver), latest_release.get("tag_name"), latest_release.get("body")
|
||||
return None, None, None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"GitHub beta check error: {e}")
|
||||
return None, None, None
|
||||
|
||||
def run(self):
|
||||
"""Check for updates using parallel network requests for better performance."""
|
||||
try:
|
||||
# Check for beta updates if enabled
|
||||
check_beta = ConfigManager.get("check_beta_updates")
|
||||
release = app_update.fetch_latest(
|
||||
include_prerelease=bool(ConfigManager.get("check_beta_updates"))
|
||||
)
|
||||
app_update.mark_checked()
|
||||
|
||||
if check_beta:
|
||||
latest_ver_str, tag, changelog = self._fetch_github_beta_version()
|
||||
|
||||
if latest_ver_str and version.parse(latest_ver_str) > version.parse(self.current_version):
|
||||
release_url = f"https://github.com/oop7/YTSage/releases/tag/{tag}"
|
||||
if not changelog:
|
||||
changelog = "View the full changelog on GitHub."
|
||||
self.update_available.emit(latest_ver_str, release_url, changelog)
|
||||
# Return if beta check completes (whether update found or not),
|
||||
# effectively skipping PyPI check if beta is enabled.
|
||||
# This ensures we don't downgrade or conflict.
|
||||
if release is None:
|
||||
logger.debug("Update check: nothing newer published.")
|
||||
return
|
||||
if not app_update.is_newer(release, self.current_version):
|
||||
logger.info(f"Update check: {self.current_version} is current (latest {release.tag}).")
|
||||
return
|
||||
|
||||
# Use ThreadPoolExecutor to make both requests in parallel
|
||||
# This reduces total wait time from potentially 15s to ~8s max
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
# Submit both tasks
|
||||
pypi_future = executor.submit(self._fetch_pypi_version)
|
||||
github_future = executor.submit(self._fetch_github_changelog)
|
||||
|
||||
# Get PyPI result (this is required)
|
||||
latest_version, error = pypi_future.result()
|
||||
|
||||
if error:
|
||||
logger.debug(f"Update check failed: {error}")
|
||||
return
|
||||
|
||||
if not latest_version:
|
||||
logger.debug("No version returned from PyPI")
|
||||
return
|
||||
|
||||
# Compare versions
|
||||
if version.parse(latest_version) > version.parse(self.current_version):
|
||||
release_url = "https://github.com/oop7/YTSage/releases/latest"
|
||||
|
||||
# Get GitHub changelog (may already be complete due to parallel execution)
|
||||
changelog = github_future.result()
|
||||
|
||||
self.update_available.emit(latest_version, release_url, changelog)
|
||||
|
||||
changelog = release.body or _("update_dialog.changelog_unavailable")
|
||||
logger.info(f"Update available: {release.tag}")
|
||||
self.update_available.emit(str(release.version), release.url, changelog)
|
||||
except Exception as e:
|
||||
# An update check is never worth taking the app down for.
|
||||
logger.debug(f"Failed to check for updates: {e}")
|
||||
|
||||
|
||||
@@ -1087,9 +991,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
|
||||
def check_for_updates(self) -> None:
|
||||
"""Starts the update check in a background thread."""
|
||||
if ConfigManager.get("check_app_updates") is False:
|
||||
if not ConfigManager.get("check_app_updates"):
|
||||
logger.info("App version checker is disabled in settings.")
|
||||
return
|
||||
if not app_update.should_check():
|
||||
logger.debug("App version checked recently; skipping.")
|
||||
return
|
||||
|
||||
self.update_thread = UpdateCheckThread(self.version)
|
||||
self.update_thread.update_available.connect(self.show_update_dialog)
|
||||
@@ -1181,9 +1088,15 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
remind_btn.clicked.connect(msg.close)
|
||||
remind_btn.setStyleSheet(StyleSheet.UPDATE_DIALOG_REMIND_BTN)
|
||||
|
||||
skip_btn = QPushButton(_('update_dialog.skip_version'))
|
||||
skip_btn.setToolTip(_('update_dialog.skip_version_tooltip', version=latest_version))
|
||||
skip_btn.clicked.connect(lambda: self._skip_update_version(latest_version, msg))
|
||||
skip_btn.setStyleSheet(StyleSheet.UPDATE_DIALOG_REMIND_BTN)
|
||||
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(download_btn)
|
||||
button_layout.addWidget(remind_btn)
|
||||
button_layout.addWidget(skip_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Style the dialog with improved theme matching
|
||||
@@ -1191,6 +1104,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
|
||||
self.run_dialog_with_blur(msg)
|
||||
|
||||
def _skip_update_version(self, latest_version: str, dialog: QDialog) -> None:
|
||||
"""Never offer this particular version again; later ones still appear."""
|
||||
ConfigManager.set("skipped_update_version", str(latest_version))
|
||||
logger.info(f"Update {latest_version} skipped by the user.")
|
||||
dialog.close()
|
||||
|
||||
def open_release_page(self, url):
|
||||
webbrowser.open(url)
|
||||
|
||||
@@ -1266,6 +1185,17 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
self._analysis_thread.terminate()
|
||||
self._analysis_thread.wait(1000)
|
||||
|
||||
# Stop the app version check if it's still waiting on the forge.
|
||||
# It holds no resources, but a QThread destroyed while running
|
||||
# aborts the process on the way out.
|
||||
if hasattr(self, "update_thread") and self.update_thread is not None and self.update_thread.isRunning():
|
||||
logger.info("Stopping app update check thread...")
|
||||
self.update_thread.quit()
|
||||
if not self.update_thread.wait(3000):
|
||||
logger.warning("Force terminating app update check thread...")
|
||||
self.update_thread.terminate()
|
||||
self.update_thread.wait(1000)
|
||||
|
||||
# Stop the auto-update thread if it's running
|
||||
if hasattr(self, "auto_update_thread") and self.auto_update_thread.isRunning():
|
||||
logger.info("Stopping auto-update thread...")
|
||||
|
||||
@@ -305,8 +305,8 @@
|
||||
"ytdlp_channel_switched": "✅ Successfully switched to {channel} channel!",
|
||||
"ytdlp_channel_switch_failed": "❌ Failed to switch channel: {error}",
|
||||
"ytdlp_current_channel": "Current channel: {channel}",
|
||||
"app_updates_title": "YTSage Updates",
|
||||
"check_app_updates": "Check for YTSage updates on startup",
|
||||
"app_updates_title": "SageTube Updates",
|
||||
"check_app_updates": "Check for SageTube updates on startup",
|
||||
"check_beta_updates": "Receive Beta Updates",
|
||||
"auto_update_title": "Auto-Update Settings",
|
||||
"auto_update_header": "🔄 Auto-Update Settings",
|
||||
@@ -466,12 +466,15 @@
|
||||
},
|
||||
"update_dialog": {
|
||||
"title": "Update Available",
|
||||
"new_version_available": "A new version of YTSage is available!",
|
||||
"new_version_available": "A new version of SageTube is available!",
|
||||
"current_version_label": "Current version:",
|
||||
"latest_version_label": "Latest version:",
|
||||
"changelog": "Changelog",
|
||||
"download_update": "Download Update",
|
||||
"remind_later": "Remind Me Later"
|
||||
"remind_later": "Remind Me Later",
|
||||
"skip_version": "Skip This Version",
|
||||
"skip_version_tooltip": "Never offer {version} again. Later versions will still be shown.",
|
||||
"changelog_unavailable": "No release notes were published for this version."
|
||||
},
|
||||
"playlist": {
|
||||
"unknown": "Unknown Playlist",
|
||||
|
||||
@@ -92,6 +92,7 @@ class ConfigManager:
|
||||
"check_app_updates": False, # fork updates come from Gitea, not the upstream PyPI package
|
||||
"check_beta_updates": False,
|
||||
"last_update_check": 0,
|
||||
"skipped_update_version": None, # set by the update dialog's "Skip this version"
|
||||
"concurrent_fragments": 1,
|
||||
"language": "en",
|
||||
"ytdlp_channel": "stable",
|
||||
|
||||
@@ -74,10 +74,18 @@ class LocalizationManager:
|
||||
"generic_mode": "Generic Mode",
|
||||
"enable_generic_mode": "Enable Generic Mode (support non-YouTube sites)",
|
||||
"generic_mode_help": "Allows downloading from Dailymotion, CBC Gem, and other sites supported by yt-dlp.",
|
||||
"app_updates_title": "YTSage Updates",
|
||||
"check_app_updates": "Check for YTSage updates on startup",
|
||||
"app_updates_title": "SageTube Updates",
|
||||
"check_app_updates": "Check for SageTube updates on startup",
|
||||
"check_beta_updates": "Receive Beta Updates"
|
||||
},
|
||||
# Only the keys added by SageTube live here; the rest of update_dialog
|
||||
# is in every language file already. Without this a non-English UI
|
||||
# would show the raw key for the buttons below.
|
||||
"update_dialog": {
|
||||
"skip_version": "Skip This Version",
|
||||
"skip_version_tooltip": "Never offer {version} again. Later versions will still be shown.",
|
||||
"changelog_unavailable": "No release notes were published for this version."
|
||||
},
|
||||
"tabs": {
|
||||
"cookies": "Login with Cookies",
|
||||
"custom_command": "Custom Command",
|
||||
|
||||
Reference in New Issue
Block a user