Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c885e3fc5 | |||
| 4cc48ae98f | |||
| 288d30ad8b | |||
| ebb9422591 | |||
| 852804ee5c | |||
| 5064b38315 | |||
| dff14ec3e8 | |||
| b40deb0d5f | |||
| 202dd9eaee | |||
| a1d1f87b65 |
@@ -112,18 +112,25 @@ class DownloadThread(QThread):
|
|||||||
self.last_file_path: Optional[str] = None # Initialize full file path storage
|
self.last_file_path: Optional[str] = None # Initialize full file path storage
|
||||||
self.subtitle_files: List[str] = [] # Track subtitle files that are created
|
self.subtitle_files: List[str] = [] # Track subtitle files that are created
|
||||||
self.initial_subtitle_files: Set[Path] = set() # Track initial subtitle files before download
|
self.initial_subtitle_files: Set[Path] = set() # Track initial subtitle files before download
|
||||||
|
self.download_files: Set[Path] = set() # Every destination path this download wrote to
|
||||||
|
self.expected_phases: int = 1 # 2 when video and audio download separately before merge
|
||||||
|
self._media_phase: int = 0 # Index of the media stream currently downloading
|
||||||
|
|
||||||
def cleanup_partial_files(self) -> None:
|
def cleanup_partial_files(self) -> None:
|
||||||
"""Delete any partial files including .part and unmerged format-specific files"""
|
"""Delete partial files (.part/.ytdl and unmerged .fNNN. streams), but only
|
||||||
|
those belonging to destinations this download actually wrote — the download
|
||||||
|
directory may contain unrelated files from other applications."""
|
||||||
try:
|
try:
|
||||||
pattern = re.compile(r"\.f\d+\.") # Pattern to match format codes like .f243.
|
pattern = re.compile(r"\.f\d+\.") # Pattern to match format codes like .f243.
|
||||||
for file_path in self.path.iterdir():
|
for dest in self.download_files:
|
||||||
if file_path.suffix == ".part" or pattern.search(file_path.name):
|
candidates = [dest.with_name(dest.name + ".part"), dest.with_name(dest.name + ".ytdl")]
|
||||||
self._safe_delete_with_retry(file_path)
|
if dest.suffix == ".part" or pattern.search(dest.name):
|
||||||
|
candidates.append(dest)
|
||||||
|
for file_path in candidates:
|
||||||
|
if file_path.exists():
|
||||||
|
self._safe_delete_with_retry(file_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error cleaning partial files: {e}")
|
logger.exception(f"Error cleaning partial files: {e}")
|
||||||
# Don't emit error signal for cleanup issues to avoid crashing the thread
|
|
||||||
logger.error(f"Error cleaning partial files: {e}")
|
|
||||||
|
|
||||||
def _safe_delete_with_retry(self, file_path: Path, max_retries: int = 5, delay: float = 2.0) -> None:
|
def _safe_delete_with_retry(self, file_path: Path, max_retries: int = 5, delay: float = 2.0) -> None:
|
||||||
"""Safely delete a file with retry mechanism for file locking issues across platforms"""
|
"""Safely delete a file with retry mechanism for file locking issues across platforms"""
|
||||||
@@ -219,8 +226,15 @@ class DownloadThread(QThread):
|
|||||||
logger.debug(f"Deleted {deleted_count[0]} of {len(self.subtitle_files)} tracked subtitle files")
|
logger.debug(f"Deleted {deleted_count[0]} of {len(self.subtitle_files)} tracked subtitle files")
|
||||||
|
|
||||||
# --- Method 2: Delete new subtitle files not in initial set ---
|
# --- Method 2: Delete new subtitle files not in initial set ---
|
||||||
|
# Only touch subtitles belonging to files this download wrote; the
|
||||||
|
# directory may contain subtitle files from other processes.
|
||||||
|
download_stems = {p.stem for p in self.download_files} | {Path(f).stem for f in self.subtitle_files or []}
|
||||||
new_subtitle_files: Set[Path] = {
|
new_subtitle_files: Set[Path] = {
|
||||||
f for f in Path(self.path).rglob("*") if f.suffix in [".vtt", ".srt"] and f not in self.initial_subtitle_files
|
f
|
||||||
|
for f in Path(self.path).rglob("*")
|
||||||
|
if f.suffix in [".vtt", ".srt"]
|
||||||
|
and f not in self.initial_subtitle_files
|
||||||
|
and any(f.name.startswith(stem) for stem in download_stems if stem)
|
||||||
}
|
}
|
||||||
for subtitle_file in new_subtitle_files:
|
for subtitle_file in new_subtitle_files:
|
||||||
deleted_count[1] += safe_delete(path=subtitle_file)
|
deleted_count[1] += safe_delete(path=subtitle_file)
|
||||||
@@ -232,8 +246,12 @@ class DownloadThread(QThread):
|
|||||||
def _build_yt_dlp_command(self) -> List[str]:
|
def _build_yt_dlp_command(self) -> List[str]:
|
||||||
"""Build the yt-dlp command line with all options for direct execution."""
|
"""Build the yt-dlp command line with all options for direct execution."""
|
||||||
yt_dlp_path: str = get_yt_dlp_path()
|
yt_dlp_path: str = get_yt_dlp_path()
|
||||||
|
if str(yt_dlp_path) == "yt-dlp":
|
||||||
|
# Sentinel: no managed binary and no opted-in system binary.
|
||||||
|
# Never exec a bare command name from PATH.
|
||||||
|
raise FileNotFoundError("yt-dlp is not installed - run the yt-dlp setup first")
|
||||||
# Build the command line array
|
# Build the command line array
|
||||||
cmd: List[str] = [yt_dlp_path]
|
cmd: List[str] = [str(yt_dlp_path)]
|
||||||
logger.debug(f"Using yt-dlp from: {yt_dlp_path}")
|
logger.debug(f"Using yt-dlp from: {yt_dlp_path}")
|
||||||
|
|
||||||
# Add concurrent fragments setting
|
# Add concurrent fragments setting
|
||||||
@@ -277,6 +295,7 @@ class DownloadThread(QThread):
|
|||||||
logger.debug(f"Using progressive format with bundled audio: {clean_format_id}")
|
logger.debug(f"Using progressive format with bundled audio: {clean_format_id}")
|
||||||
else:
|
else:
|
||||||
cmd.extend(["-f", f"{clean_format_id}+bestaudio/best"])
|
cmd.extend(["-f", f"{clean_format_id}+bestaudio/best"])
|
||||||
|
self.expected_phases = 2 # separate video and audio downloads
|
||||||
logger.debug(f"Using video-only format merged with best audio: {clean_format_id}+bestaudio/best")
|
logger.debug(f"Using video-only format merged with best audio: {clean_format_id}+bestaudio/best")
|
||||||
else:
|
else:
|
||||||
# If no specific format ID, use resolution-based sorting (-S)
|
# If no specific format ID, use resolution-based sorting (-S)
|
||||||
@@ -619,6 +638,9 @@ class DownloadThread(QThread):
|
|||||||
filepath = dest_match.group(1).strip()
|
filepath = dest_match.group(1).strip()
|
||||||
self.current_filename = Path(filepath).name
|
self.current_filename = Path(filepath).name
|
||||||
self.last_file_path = filepath # Store the full path for later cleanup
|
self.last_file_path = filepath # Store the full path for later cleanup
|
||||||
|
if Path(filepath) not in self.download_files and Path(filepath).suffix.lower() not in SUBTITLE_EXTENSIONS:
|
||||||
|
self._media_phase += 1
|
||||||
|
self.download_files.add(Path(filepath))
|
||||||
logger.debug(f"Extracted filename: {self.current_filename}") # DEBUG
|
logger.debug(f"Extracted filename: {self.current_filename}") # DEBUG
|
||||||
|
|
||||||
# Check if this is an audio-only download by looking in the previous lines
|
# Check if this is an audio-only download by looking in the previous lines
|
||||||
@@ -711,6 +733,7 @@ class DownloadThread(QThread):
|
|||||||
dest_path = match.group(1).strip()
|
dest_path = match.group(1).strip()
|
||||||
self.current_filename = Path(dest_path).name
|
self.current_filename = Path(dest_path).name
|
||||||
self.last_file_path = dest_path
|
self.last_file_path = dest_path
|
||||||
|
self.download_files.add(Path(dest_path))
|
||||||
logger.debug(f"Captured destination filename: {self.current_filename}")
|
logger.debug(f"Captured destination filename: {self.current_filename}")
|
||||||
elif "Downloading API JSON" in line:
|
elif "Downloading API JSON" in line:
|
||||||
self.status_signal.emit(_("download.processing_playlist"))
|
self.status_signal.emit(_("download.processing_playlist"))
|
||||||
@@ -737,6 +760,11 @@ class DownloadThread(QThread):
|
|||||||
if percent_match:
|
if percent_match:
|
||||||
try:
|
try:
|
||||||
percent = float(percent_match.group(1))
|
percent = float(percent_match.group(1))
|
||||||
|
# When video and audio download as separate streams, scale each
|
||||||
|
# phase into its share of the bar instead of jumping 0-100 twice
|
||||||
|
if self.expected_phases > 1 and not self.is_playlist:
|
||||||
|
completed = max(0, min(self._media_phase - 1, self.expected_phases - 1))
|
||||||
|
percent = (completed * 100.0 + percent) / self.expected_phases
|
||||||
self.progress_signal.emit(percent)
|
self.progress_signal.emit(percent)
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
pass
|
pass
|
||||||
@@ -771,6 +799,7 @@ class DownloadThread(QThread):
|
|||||||
merged_filepath = merger_match.group(1).strip()
|
merged_filepath = merger_match.group(1).strip()
|
||||||
self.current_filename = Path(merged_filepath).name
|
self.current_filename = Path(merged_filepath).name
|
||||||
self.last_file_path = merged_filepath
|
self.last_file_path = merged_filepath
|
||||||
|
self.download_files.add(Path(merged_filepath))
|
||||||
logger.debug(f"Updated to merged filename: {self.current_filename}")
|
logger.debug(f"Updated to merged filename: {self.current_filename}")
|
||||||
elif "SponsorBlock" in line:
|
elif "SponsorBlock" in line:
|
||||||
self.status_signal.emit(_("download.removing_sponsor_segments"))
|
self.status_signal.emit(_("download.removing_sponsor_segments"))
|
||||||
@@ -824,9 +853,24 @@ class DownloadThread(QThread):
|
|||||||
|
|
||||||
def pause(self) -> None:
|
def pause(self) -> None:
|
||||||
self.paused = True
|
self.paused = True
|
||||||
|
self._signal_process_group(signal.SIGSTOP if sys.platform != "win32" else None)
|
||||||
|
|
||||||
def resume(self) -> None:
|
def resume(self) -> None:
|
||||||
self.paused = False
|
self.paused = False
|
||||||
|
self._signal_process_group(signal.SIGCONT if sys.platform != "win32" else None)
|
||||||
|
|
||||||
|
def _signal_process_group(self, sig: Optional[int]) -> None:
|
||||||
|
"""Send a signal to yt-dlp's whole process group (yt-dlp + ffmpeg children).
|
||||||
|
|
||||||
|
On Windows there is no SIGSTOP/SIGCONT; pausing there only stops output
|
||||||
|
consumption, which is a known limitation.
|
||||||
|
"""
|
||||||
|
if sig is None or not self.process:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(self.process.pid), sig)
|
||||||
|
except (ProcessLookupError, PermissionError, OSError) as e:
|
||||||
|
logger.debug(f"Could not signal process group: {e}")
|
||||||
|
|
||||||
def cancel(self) -> None:
|
def cancel(self) -> None:
|
||||||
self.cancelled = True
|
self.cancelled = True
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ def get_ffmpeg_install_path() -> Path:
|
|||||||
For Windows, tries to find the latest essentials build dynamically.
|
For Windows, tries to find the latest essentials build dynamically.
|
||||||
"""
|
"""
|
||||||
if OS_NAME == "Windows":
|
if OS_NAME == "Windows":
|
||||||
ffmpeg_base = Path(os.getenv("LOCALAPPDATA")) / "ffmpeg" # type: ignore
|
ffmpeg_base = Path(os.getenv("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))) / "ffmpeg"
|
||||||
|
|
||||||
# If the directory exists, look for any ffmpeg-*-essentials_build folder
|
# If the directory exists, look for any ffmpeg-*-essentials_build folder
|
||||||
if ffmpeg_base.exists():
|
if ffmpeg_base.exists():
|
||||||
@@ -205,7 +205,7 @@ def install_ffmpeg_windows(progress_callback=None) -> bool:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Define variables for essentials build
|
# Define variables for essentials build
|
||||||
extract_dir = Path(os.getenv("LOCALAPPDATA")) / "ffmpeg" # type: ignore
|
extract_dir = Path(os.getenv("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))) / "ffmpeg"
|
||||||
|
|
||||||
# Create extraction directory if it doesn't exist
|
# Create extraction directory if it doesn't exist
|
||||||
extract_dir.mkdir(exist_ok=True)
|
extract_dir.mkdir(exist_ok=True)
|
||||||
@@ -282,9 +282,14 @@ def install_ffmpeg_windows(progress_callback=None) -> bool:
|
|||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback("🔐 Verifying download integrity...")
|
progress_callback("🔐 Verifying download integrity...")
|
||||||
if not verify_sha256(temp_file, FFMPEG_ZIP_SHA256_URL):
|
if not verify_sha256(temp_file, FFMPEG_ZIP_SHA256_URL):
|
||||||
logger.warning("SHA-256 verification failed for zip file, proceeding anyway...")
|
logger.error("SHA-256 verification failed for zip file, aborting installation")
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback("⚠️ SHA-256 verification failed, proceeding anyway...")
|
progress_callback("❌ SHA-256 verification failed, aborting installation")
|
||||||
|
try:
|
||||||
|
Path(temp_file).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
logger.info("Extracting FFmpeg components from zip archive...")
|
logger.info("Extracting FFmpeg components from zip archive...")
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
@@ -337,29 +342,35 @@ def install_ffmpeg_windows(progress_callback=None) -> bool:
|
|||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback("🔧 Configuring system paths...")
|
progress_callback("🔧 Configuring system paths...")
|
||||||
|
|
||||||
# Add to System Path
|
# Persist to the user PATH via the registry. setx must not be used here:
|
||||||
user_path = os.environ.get("PATH", "")
|
# it truncates values at 1024 characters, and os.environ["PATH"] is the
|
||||||
path_parts = user_path.split(os.pathsep)
|
# merged system+user PATH, so writing it back would permanently duplicate
|
||||||
|
# every system entry into the user hive.
|
||||||
# Remove old FFmpeg paths and add new one
|
|
||||||
cleaned_paths = [p for p in path_parts if "ffmpeg" not in p.lower() or str(bin_dir) in p]
|
|
||||||
if str(bin_dir) not in cleaned_paths:
|
|
||||||
cleaned_paths.insert(0, str(bin_dir))
|
|
||||||
|
|
||||||
new_path = os.pathsep.join(cleaned_paths)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
import winreg
|
||||||
["setx", "PATH", new_path],
|
|
||||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_READ | winreg.KEY_WRITE) as key:
|
||||||
timeout=30,
|
try:
|
||||||
check=True,
|
stored_path, value_type = winreg.QueryValueEx(key, "Path")
|
||||||
)
|
except FileNotFoundError:
|
||||||
os.environ["PATH"] = new_path
|
stored_path, value_type = "", winreg.REG_EXPAND_SZ
|
||||||
|
user_parts = [p for p in stored_path.split(os.pathsep) if p]
|
||||||
|
# Drop stale ffmpeg entries we previously added, then prepend the new one
|
||||||
|
user_parts = [p for p in user_parts if "ffmpeg" not in p.lower() or p == str(bin_dir)]
|
||||||
|
if str(bin_dir) not in user_parts:
|
||||||
|
user_parts.insert(0, str(bin_dir))
|
||||||
|
winreg.SetValueEx(key, "Path", 0, value_type, os.pathsep.join(user_parts))
|
||||||
|
|
||||||
|
# Broadcast the change so new shells pick it up without relogin
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
ctypes.windll.user32.SendMessageTimeoutW(0xFFFF, 0x001A, 0, "Environment", 0x0002, 5000, None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to update PATH permanently: {e}")
|
logger.warning(f"Failed to update PATH permanently: {e}")
|
||||||
# Still update for current session
|
|
||||||
os.environ["PATH"] = new_path
|
# Update for the current session regardless
|
||||||
|
if str(bin_dir) not in os.environ.get("PATH", "").split(os.pathsep):
|
||||||
|
os.environ["PATH"] = str(bin_dir) + os.pathsep + os.environ.get("PATH", "")
|
||||||
|
|
||||||
# Verify installation
|
# Verify installation
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
@@ -395,9 +406,14 @@ def install_ffmpeg_macos() -> bool:
|
|||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
except (subprocess.SubprocessError, FileNotFoundError):
|
except (subprocess.SubprocessError, FileNotFoundError):
|
||||||
logger.info("Installing Homebrew...")
|
# Never curl|bash a remote script without the user's say-so.
|
||||||
brew_install_cmd = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
# Homebrew installation is the user's decision to make in a
|
||||||
subprocess.run(brew_install_cmd, shell=True, check=True, timeout=300)
|
# terminal, where the script can also prompt for sudo properly.
|
||||||
|
logger.error(
|
||||||
|
"Homebrew is not installed. Install it from https://brew.sh and retry, "
|
||||||
|
"or install ffmpeg another way."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
# Install FFmpeg
|
# Install FFmpeg
|
||||||
logger.info("Installing FFmpeg...")
|
logger.info("Installing FFmpeg...")
|
||||||
|
|||||||
+22
-11
@@ -72,8 +72,11 @@ def should_refresh_cache(tool_name: str, current_path: Optional[str]) -> bool:
|
|||||||
if not cache.get("version"):
|
if not cache.get("version"):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Refresh if path changed
|
# Refresh if path changed (cache stores str; callers may pass Path -
|
||||||
if cache.get("path") != current_path:
|
# compare normalized strings or the cache would never hit)
|
||||||
|
cached_path = cache.get("path")
|
||||||
|
normalized_current = str(current_path) if current_path is not None else None
|
||||||
|
if cached_path != normalized_current:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Refresh if file was modified
|
# Refresh if file was modified
|
||||||
@@ -432,8 +435,9 @@ def save_path(main_window_instance: Any, path: Union[str, Path]) -> bool:
|
|||||||
def update_yt_dlp() -> bool:
|
def update_yt_dlp() -> bool:
|
||||||
"""Check for yt-dlp updates and update if a newer version is available."""
|
"""Check for yt-dlp updates and update if a newer version is available."""
|
||||||
try:
|
try:
|
||||||
# Get the yt-dlp path
|
# Get the yt-dlp path (may be the bare "yt-dlp" sentinel string when
|
||||||
yt_dlp_path: Path = get_yt_dlp_path()
|
# not installed - normalize to Path so .exists()/.samefile() work)
|
||||||
|
yt_dlp_path: Path = Path(get_yt_dlp_path())
|
||||||
|
|
||||||
# Extra logic moved to src\utils\ytsage_constants.py
|
# Extra logic moved to src\utils\ytsage_constants.py
|
||||||
|
|
||||||
@@ -463,7 +467,7 @@ def update_yt_dlp() -> bool:
|
|||||||
|
|
||||||
# Download the latest version
|
# Download the latest version
|
||||||
try:
|
try:
|
||||||
response = requests.get(YTDLP_DOWNLOAD_URL, stream=True)
|
response = requests.get(YTDLP_DOWNLOAD_URL, stream=True, timeout=60)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
# Create a temporary file
|
# Create a temporary file
|
||||||
temp_file = f"{yt_dlp_path}.new"
|
temp_file = f"{yt_dlp_path}.new"
|
||||||
@@ -472,21 +476,28 @@ def update_yt_dlp() -> bool:
|
|||||||
for chunk in response.iter_content(chunk_size=8192):
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
|
# Verify against the official checksums before touching the
|
||||||
|
# trusted binary (same check as the first-install path)
|
||||||
|
from ytsage.core.ytsage_yt_dlp import verify_ytdlp_sha256
|
||||||
|
|
||||||
|
if not verify_ytdlp_sha256(Path(temp_file), YTDLP_DOWNLOAD_URL):
|
||||||
|
logger.error("SHA256 verification failed for yt-dlp update, keeping current binary")
|
||||||
|
Path(temp_file).unlink(missing_ok=True)
|
||||||
|
return False
|
||||||
|
|
||||||
# Make executable on Unix systems
|
# Make executable on Unix systems
|
||||||
if OS_NAME != "Windows":
|
if OS_NAME != "Windows":
|
||||||
os.chmod(temp_file, 0o755)
|
os.chmod(temp_file, 0o755)
|
||||||
|
|
||||||
# Replace the old file with the new one
|
# Replace the old file with the new one (os.replace is atomic
|
||||||
|
# and overwrites on Windows too)
|
||||||
try:
|
try:
|
||||||
# On Windows, we need to remove the old file first
|
os.replace(temp_file, yt_dlp_path)
|
||||||
if OS_NAME == "Windows" and yt_dlp_path.exists():
|
|
||||||
yt_dlp_path.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
Path(temp_file).rename(yt_dlp_path)
|
|
||||||
logger.info("yt-dlp binary successfully updated")
|
logger.info("yt-dlp binary successfully updated")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error replacing yt-dlp binary: {e}")
|
logger.exception(f"Error replacing yt-dlp binary: {e}")
|
||||||
|
Path(temp_file).unlink(missing_ok=True)
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
logger.info(f"Failed to download latest yt-dlp: HTTP {response.status_code}")
|
logger.info(f"Failed to download latest yt-dlp: HTTP {response.status_code}")
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ class DownloadYtdlpThread(QThread):
|
|||||||
try:
|
try:
|
||||||
# Extra logic moved to src\utils\ytsage_constants.py
|
# Extra logic moved to src\utils\ytsage_constants.py
|
||||||
exe_path = YTDLP_APP_BIN_PATH
|
exe_path = YTDLP_APP_BIN_PATH
|
||||||
|
# Download to a temp name and only move to the trusted path after
|
||||||
|
# the hash checks out, so a crash mid-download can never leave an
|
||||||
|
# unverified binary where the app will execute it.
|
||||||
|
part_path = exe_path.with_name(exe_path.name + ".part")
|
||||||
|
|
||||||
# Download with progress reporting
|
# Download with progress reporting
|
||||||
logger.info(f"Downloading yt-dlp from: {YTDLP_DOWNLOAD_URL}")
|
logger.info(f"Downloading yt-dlp from: {YTDLP_DOWNLOAD_URL}")
|
||||||
@@ -123,7 +127,7 @@ class DownloadYtdlpThread(QThread):
|
|||||||
if total_size == 0:
|
if total_size == 0:
|
||||||
self.progress_signal.emit(100)
|
self.progress_signal.emit(100)
|
||||||
|
|
||||||
with open(exe_path, "wb") as f:
|
with open(part_path, "wb") as f:
|
||||||
downloaded = 0
|
downloaded = 0
|
||||||
for data in response.iter_content(block_size):
|
for data in response.iter_content(block_size):
|
||||||
f.write(data)
|
f.write(data)
|
||||||
@@ -133,22 +137,23 @@ class DownloadYtdlpThread(QThread):
|
|||||||
self.progress_signal.emit(progress)
|
self.progress_signal.emit(progress)
|
||||||
|
|
||||||
logger.info("Download complete, verifying SHA256 hash...")
|
logger.info("Download complete, verifying SHA256 hash...")
|
||||||
|
|
||||||
# Verify SHA256 hash
|
# Verify SHA256 hash
|
||||||
if not verify_ytdlp_sha256(exe_path, YTDLP_DOWNLOAD_URL):
|
if not verify_ytdlp_sha256(part_path, YTDLP_DOWNLOAD_URL):
|
||||||
# Hash verification failed - delete the downloaded file
|
# Hash verification failed - delete the downloaded file
|
||||||
logger.error("SHA256 verification failed! Removing downloaded file.")
|
logger.error("SHA256 verification failed! Removing downloaded file.")
|
||||||
if Path(exe_path).exists():
|
part_path.unlink(missing_ok=True)
|
||||||
Path(exe_path).unlink()
|
|
||||||
self.finished_signal.emit(
|
self.finished_signal.emit(
|
||||||
False,
|
False,
|
||||||
"SHA256 verification failed. The downloaded file may be corrupted or tampered with."
|
"SHA256 verification failed. The downloaded file may be corrupted or tampered with."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Make executable on macOS and Linux
|
# Make executable on macOS and Linux
|
||||||
if OS_NAME != "Windows":
|
if OS_NAME != "Windows":
|
||||||
os.chmod(exe_path, 0o755)
|
os.chmod(part_path, 0o755)
|
||||||
|
|
||||||
|
os.replace(part_path, exe_path)
|
||||||
|
|
||||||
logger.info("yt-dlp downloaded and verified successfully!")
|
logger.info("yt-dlp downloaded and verified successfully!")
|
||||||
self.finished_signal.emit(True, str(exe_path))
|
self.finished_signal.emit(True, str(exe_path))
|
||||||
@@ -429,7 +434,7 @@ class YtdlpSetupDialog(QDialog):
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
file_path, _ = file_dialog.getOpenFileName(
|
file_path, _selected_filter = file_dialog.getOpenFileName(
|
||||||
self, _("ytdlp_setup.select_executable_title"), "", file_filter
|
self, _("ytdlp_setup.select_executable_title"), "", file_filter
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -607,19 +612,30 @@ def check_ytdlp_installed() -> bool:
|
|||||||
|
|
||||||
def get_yt_dlp_path() -> Path:
|
def get_yt_dlp_path() -> Path:
|
||||||
"""
|
"""
|
||||||
Get the yt-dlp path, either from the app's bin directory or system PATH.
|
Get the yt-dlp path. Prefers the app-managed, SHA256-verified binary;
|
||||||
This replaces the function in ytsage_utils.py.
|
a system-installed yt-dlp is only used when the user explicitly opts in
|
||||||
|
via the advanced.allow_system_ytdlp config key (resolved to an absolute
|
||||||
|
path so Windows' implicit CWD lookup can never pick up a planted binary).
|
||||||
Returns:
|
Returns:
|
||||||
str: Path to yt-dlp binary
|
Path to yt-dlp binary, or the bare string "yt-dlp" sentinel meaning
|
||||||
|
"not installed" (triggers the setup dialog).
|
||||||
"""
|
"""
|
||||||
# First check if we have yt-dlp in our app's bin directory or system PATH
|
|
||||||
ytdlp_path = check_ytdlp_binary()
|
ytdlp_path = check_ytdlp_binary()
|
||||||
if ytdlp_path:
|
if ytdlp_path:
|
||||||
logger.info(f"Using yt-dlp from: {ytdlp_path}")
|
logger.info(f"Using yt-dlp from: {ytdlp_path}")
|
||||||
return ytdlp_path
|
return ytdlp_path
|
||||||
|
|
||||||
# If not found anywhere, fall back to the command name as a last resort
|
from ..utils.ytsage_config_manager import ConfigManager
|
||||||
logger.info("yt-dlp not found in app directory or PATH, falling back to command name")
|
|
||||||
|
if ConfigManager.get("advanced.allow_system_ytdlp"):
|
||||||
|
system_ytdlp = shutil.which("yt-dlp")
|
||||||
|
if system_ytdlp:
|
||||||
|
resolved = Path(system_ytdlp).resolve()
|
||||||
|
logger.info(f"Using system yt-dlp (advanced.allow_system_ytdlp): {resolved}")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
# Not installed - return the sentinel that triggers the setup dialog
|
||||||
|
logger.info("yt-dlp not found in app directory, returning setup sentinel")
|
||||||
return "yt-dlp" # type: ignore[return-value]
|
return "yt-dlp" # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from PySide6.QtCore import QMetaObject, Qt, Q_ARG, QThread, Signal, QTimer
|
from PySide6.QtCore import QMetaObject, Qt, Q_ARG, QThread, Signal, QTimer
|
||||||
from PySide6.QtWidgets import QMessageBox
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
@@ -95,13 +98,56 @@ class AnalysisThread(QThread):
|
|||||||
if self.geo_proxy_url:
|
if self.geo_proxy_url:
|
||||||
cmd.extend(["--geo-verification-proxy", self.geo_proxy_url])
|
cmd.extend(["--geo-verification-proxy", self.geo_proxy_url])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_ytdlp(cmd: list, timeout: int) -> subprocess.CompletedProcess:
|
||||||
|
"""Run yt-dlp capturing output, killing the whole process group on timeout.
|
||||||
|
|
||||||
|
subprocess.run() only kills the direct child on TimeoutExpired, leaking
|
||||||
|
grandchildren (deno); it also loses any stderr produced before the
|
||||||
|
timeout. Use Popen with a new session so the entire group can be
|
||||||
|
reaped, and surface the partial stderr in the raised exception.
|
||||||
|
"""
|
||||||
|
popen_kwargs: Dict[str, Any] = {
|
||||||
|
"stdout": subprocess.PIPE,
|
||||||
|
"stderr": subprocess.PIPE,
|
||||||
|
"text": True,
|
||||||
|
"encoding": "utf-8",
|
||||||
|
"errors": "replace",
|
||||||
|
}
|
||||||
|
if sys.platform == "win32":
|
||||||
|
popen_kwargs["creationflags"] = SUBPROCESS_CREATIONFLAGS
|
||||||
|
else:
|
||||||
|
popen_kwargs["start_new_session"] = True
|
||||||
|
|
||||||
|
proc = subprocess.Popen(cmd, **popen_kwargs)
|
||||||
|
try:
|
||||||
|
stdout, stderr = proc.communicate(timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||||
|
except (ProcessLookupError, PermissionError, OSError):
|
||||||
|
pass
|
||||||
|
stdout, stderr = proc.communicate()
|
||||||
|
exc.stdout, exc.stderr = stdout, stderr
|
||||||
|
raise
|
||||||
|
return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
|
||||||
|
|
||||||
def _analyze_url_with_subprocess(self, url: str) -> None:
|
def _analyze_url_with_subprocess(self, url: str) -> None:
|
||||||
"""Analyze URL using yt-dlp executable."""
|
"""Analyze URL using yt-dlp executable."""
|
||||||
if self._cancelled:
|
if self._cancelled:
|
||||||
return
|
return
|
||||||
|
|
||||||
yt_dlp_path = get_yt_dlp_path()
|
yt_dlp_path = get_yt_dlp_path()
|
||||||
if not yt_dlp_path:
|
if not yt_dlp_path or str(yt_dlp_path) == "yt-dlp":
|
||||||
|
# Sentinel: no managed binary and no opted-in system binary.
|
||||||
|
# Never exec a bare command name from PATH.
|
||||||
logger.error("yt-dlp executable not found. Please install yt-dlp first.")
|
logger.error("yt-dlp executable not found. Please install yt-dlp first.")
|
||||||
self.analysis_error.emit(_("errors.ytdlp_not_found"))
|
self.analysis_error.emit(_("errors.ytdlp_not_found"))
|
||||||
self.playlist_info_visible.emit(False)
|
self.playlist_info_visible.emit(False)
|
||||||
@@ -118,12 +164,10 @@ class AnalysisThread(QThread):
|
|||||||
logger.debug(f"Executing yt-dlp command: {cmd}")
|
logger.debug(f"Executing yt-dlp command: {cmd}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = self._run_ytdlp(cmd, timeout=300)
|
||||||
cmd, capture_output=True, text=True, timeout=300,
|
except subprocess.TimeoutExpired as e:
|
||||||
creationflags=SUBPROCESS_CREATIONFLAGS
|
stderr_tail = (e.stderr or "")[-500:] if isinstance(e.stderr, str) else ""
|
||||||
)
|
logger.error(f"Analysis timed out. Partial stderr: {stderr_tail}")
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
logger.error("Analysis timed out")
|
|
||||||
self.analysis_error.emit(_("errors.timeout"))
|
self.analysis_error.emit(_("errors.timeout"))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -181,7 +225,8 @@ class AnalysisThread(QThread):
|
|||||||
if first_info.get("_type") == "playlist":
|
if first_info.get("_type") == "playlist":
|
||||||
result_data["is_playlist"] = True
|
result_data["is_playlist"] = True
|
||||||
result_data["playlist_info"] = first_info
|
result_data["playlist_info"] = first_info
|
||||||
playlist_entries = first_info.get("entries", [])
|
# Private/deleted videos can appear as None entries in flat playlists
|
||||||
|
playlist_entries = [e for e in first_info.get("entries", []) if e]
|
||||||
result_data["playlist_entries"] = playlist_entries
|
result_data["playlist_entries"] = playlist_entries
|
||||||
|
|
||||||
if not playlist_entries:
|
if not playlist_entries:
|
||||||
@@ -201,15 +246,12 @@ class AnalysisThread(QThread):
|
|||||||
self._add_auth_options(cmd_single)
|
self._add_auth_options(cmd_single)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result_single = subprocess.run(
|
result_single = self._run_ytdlp(cmd_single, timeout=60)
|
||||||
cmd_single, capture_output=True, text=True, timeout=60,
|
|
||||||
creationflags=SUBPROCESS_CREATIONFLAGS
|
|
||||||
)
|
|
||||||
if result_single.returncode == 0:
|
if result_single.returncode == 0:
|
||||||
result_data["video_info"] = json.loads(result_single.stdout)
|
result_data["video_info"] = json.loads(result_single.stdout)
|
||||||
else:
|
else:
|
||||||
result_data["video_info"] = first_video_entry
|
result_data["video_info"] = first_video_entry
|
||||||
except subprocess.TimeoutExpired:
|
except (subprocess.TimeoutExpired, json.JSONDecodeError):
|
||||||
result_data["video_info"] = first_video_entry
|
result_data["video_info"] = first_video_entry
|
||||||
|
|
||||||
if self._cancelled:
|
if self._cancelled:
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ Custom functionality dialogs for YTSage application.
|
|||||||
Contains dialogs for custom commands, cookies, time ranges, and other special features.
|
Contains dialogs for custom commands, cookies, time ranges, and other special features.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -32,7 +34,7 @@ from PySide6.QtWidgets import (
|
|||||||
from ..ytsage_smooth_tab_widget import SmoothTabWidget
|
from ..ytsage_smooth_tab_widget import SmoothTabWidget
|
||||||
from ...core.ytsage_yt_dlp import get_yt_dlp_path
|
from ...core.ytsage_yt_dlp import get_yt_dlp_path
|
||||||
from ...core.ytsage_utils import update_auto_update_settings
|
from ...core.ytsage_utils import update_auto_update_settings
|
||||||
from ...utils.ytsage_constants import YTDLP_DOCS_URL
|
from ...utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS, YTDLP_DOCS_URL
|
||||||
from ...utils.ytsage_config_manager import ConfigManager
|
from ...utils.ytsage_config_manager import ConfigManager
|
||||||
from ...utils.ytsage_localization import LocalizationManager, _
|
from ...utils.ytsage_localization import LocalizationManager, _
|
||||||
from ...utils.ytsage_logger import logger
|
from ...utils.ytsage_logger import logger
|
||||||
@@ -55,16 +57,28 @@ class CommandWorker(QObject):
|
|||||||
self.command = command
|
self.command = command
|
||||||
self.url = url
|
self.url = url
|
||||||
self.path = path
|
self.path = path
|
||||||
|
self._proc = None
|
||||||
|
self._cancelled = False
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
"""Terminate a running command."""
|
||||||
|
self._cancelled = True
|
||||||
|
if self._proc and self._proc.poll() is None:
|
||||||
|
try:
|
||||||
|
self._proc.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def run_command(self):
|
def run_command(self):
|
||||||
"""Run the yt-dlp command and emit signals for output"""
|
"""Run the yt-dlp command and emit signals for output"""
|
||||||
try:
|
try:
|
||||||
# Split command into arguments
|
# Split command into arguments; posix=False on Windows so quoted
|
||||||
args = self.command.split()
|
# backslash paths like "C:\Users\..." survive intact
|
||||||
|
args = shlex.split(self.command, posix=(os.name != "nt"))
|
||||||
|
|
||||||
# Build the full command
|
# Build the full command
|
||||||
yt_dlp_path = get_yt_dlp_path()
|
yt_dlp_path = get_yt_dlp_path()
|
||||||
base_cmd = [yt_dlp_path] + args
|
base_cmd = [str(yt_dlp_path)] + args
|
||||||
|
|
||||||
# Add download path if specified
|
# Add download path if specified
|
||||||
if self.path:
|
if self.path:
|
||||||
@@ -78,19 +92,24 @@ class CommandWorker(QObject):
|
|||||||
self.output_received.emit("=" * 50)
|
self.output_received.emit("=" * 50)
|
||||||
|
|
||||||
# Run the command
|
# Run the command
|
||||||
proc = subprocess.Popen(
|
self._proc = subprocess.Popen(
|
||||||
base_cmd,
|
base_cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
text=True,
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
errors="replace",
|
errors="replace",
|
||||||
|
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||||
)
|
)
|
||||||
|
proc = self._proc
|
||||||
|
|
||||||
# Stream output
|
# Stream output
|
||||||
for line in proc.stdout: # type: ignore[reportOptionalIterable]
|
with proc.stdout: # type: ignore[union-attr]
|
||||||
if line.strip(): # Only show non-empty lines
|
for line in proc.stdout: # type: ignore[reportOptionalIterable]
|
||||||
self.output_received.emit(line.rstrip())
|
if self._cancelled:
|
||||||
|
break
|
||||||
|
if line.strip(): # Only show non-empty lines
|
||||||
|
self.output_received.emit(line.rstrip())
|
||||||
|
|
||||||
ret = proc.wait()
|
ret = proc.wait()
|
||||||
self.output_received.emit("=" * 50)
|
self.output_received.emit("=" * 50)
|
||||||
|
|||||||
@@ -985,5 +985,5 @@ class AutoUpdateSettingsDialog(QDialog):
|
|||||||
msg_box.exec()
|
msg_box.exec()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error saving auto-update settings: {e}")
|
logger.exception(f"Error saving auto-update settings: {e}")
|
||||||
msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, _("settings.error_title"), _("settings", "error_saving", error=str(e)))
|
msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, _("settings.error_title"), _("settings.error_saving", error=str(e)))
|
||||||
msg_box.exec()
|
msg_box.exec()
|
||||||
|
|||||||
@@ -200,18 +200,23 @@ class DenoUpdateThread(QThread):
|
|||||||
|
|
||||||
class UpdaterTabWidget(QWidget):
|
class UpdaterTabWidget(QWidget):
|
||||||
"""Widget for the Updater tab in Custom Options dialog."""
|
"""Widget for the Updater tab in Custom Options dialog."""
|
||||||
|
|
||||||
|
# Emitted from the channel-switch worker thread; queued back to the GUI
|
||||||
|
# thread so widget updates never happen off-thread.
|
||||||
|
_channel_switch_finished = Signal(bool, str, str, str) # success, new_channel, current_channel, error
|
||||||
|
|
||||||
def __init__(self, parent=None) -> None:
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._parent: "CustomOptionsDialog" = cast("CustomOptionsDialog", self.parent())
|
self._parent: "CustomOptionsDialog" = cast("CustomOptionsDialog", self.parent())
|
||||||
|
|
||||||
# State variables
|
# State variables
|
||||||
self.current_version = "Unknown"
|
self.current_version = "Unknown"
|
||||||
self.latest_version = "Unknown"
|
self.latest_version = "Unknown"
|
||||||
self.update_available = False
|
self.update_available = False
|
||||||
|
|
||||||
self._init_ui()
|
self._init_ui()
|
||||||
self._load_auto_update_settings()
|
self._load_auto_update_settings()
|
||||||
|
self._channel_switch_finished.connect(self._on_channel_switch_finished)
|
||||||
|
|
||||||
def _init_ui(self) -> None:
|
def _init_ui(self) -> None:
|
||||||
"""Initialize the UI components."""
|
"""Initialize the UI components."""
|
||||||
@@ -815,69 +820,55 @@ class UpdaterTabWidget(QWidget):
|
|||||||
# Success - save the preference
|
# Success - save the preference
|
||||||
ConfigManager.set("ytdlp_channel", new_channel)
|
ConfigManager.set("ytdlp_channel", new_channel)
|
||||||
logger.info(f"Successfully switched to {new_channel} channel")
|
logger.info(f"Successfully switched to {new_channel} channel")
|
||||||
|
|
||||||
# Update UI
|
|
||||||
self.channel_status_label.setText(_("settings.ytdlp_channel_switched", channel=new_channel))
|
|
||||||
self.channel_status_label.setStyleSheet(
|
|
||||||
"color: #00cc00; font-size: 11px; padding: 5px; "
|
|
||||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Make executable on Unix systems
|
# Make executable on Unix systems
|
||||||
if OS_NAME != "Windows":
|
if OS_NAME != "Windows":
|
||||||
import os
|
import os
|
||||||
os.chmod(yt_dlp_path, 0o755)
|
os.chmod(yt_dlp_path, 0o755)
|
||||||
|
|
||||||
|
self._channel_switch_finished.emit(True, new_channel, current_channel, "")
|
||||||
else:
|
else:
|
||||||
# Failed - revert radio button
|
|
||||||
error_msg = result.stderr.strip() if result.stderr else result.stdout.strip() if result.stdout else "Unknown error"
|
error_msg = result.stderr.strip() if result.stderr else result.stdout.strip() if result.stdout else "Unknown error"
|
||||||
logger.error(f"Failed to switch channel: {error_msg}")
|
logger.error(f"Failed to switch channel: {error_msg}")
|
||||||
|
self._channel_switch_finished.emit(False, new_channel, current_channel, error_msg)
|
||||||
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error=error_msg))
|
|
||||||
self.channel_status_label.setStyleSheet(
|
|
||||||
"color: #ff6666; font-size: 11px; padding: 5px; "
|
|
||||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Revert radio selection
|
|
||||||
if current_channel == "nightly":
|
|
||||||
self.channel_nightly_radio.setChecked(True)
|
|
||||||
else:
|
|
||||||
self.channel_stable_radio.setChecked(True)
|
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
logger.error("Channel switch timed out")
|
logger.error("Channel switch timed out")
|
||||||
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error="Timeout"))
|
self._channel_switch_finished.emit(False, new_channel, current_channel, "Timeout")
|
||||||
self.channel_status_label.setStyleSheet(
|
|
||||||
"color: #ff6666; font-size: 11px; padding: 5px; "
|
|
||||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
|
||||||
)
|
|
||||||
# Revert radio selection
|
|
||||||
if current_channel == "nightly":
|
|
||||||
self.channel_nightly_radio.setChecked(True)
|
|
||||||
else:
|
|
||||||
self.channel_stable_radio.setChecked(True)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error switching channel: {e}")
|
logger.exception(f"Error switching channel: {e}")
|
||||||
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error=str(e)))
|
self._channel_switch_finished.emit(False, new_channel, current_channel, str(e))
|
||||||
self.channel_status_label.setStyleSheet(
|
|
||||||
"color: #ff6666; font-size: 11px; padding: 5px; "
|
|
||||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
|
||||||
)
|
|
||||||
# Revert radio selection
|
|
||||||
if current_channel == "nightly":
|
|
||||||
self.channel_nightly_radio.setChecked(True)
|
|
||||||
else:
|
|
||||||
self.channel_stable_radio.setChecked(True)
|
|
||||||
finally:
|
|
||||||
# Re-enable radio buttons
|
|
||||||
self.channel_stable_radio.setEnabled(True)
|
|
||||||
self.channel_nightly_radio.setEnabled(True)
|
|
||||||
|
|
||||||
# Start the thread
|
# Start the thread
|
||||||
thread = threading.Thread(target=switch_channel, daemon=True)
|
thread = threading.Thread(target=switch_channel, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
|
@Slot(bool, str, str, str)
|
||||||
|
def _on_channel_switch_finished(self, success: bool, new_channel: str, current_channel: str, error_msg: str) -> None:
|
||||||
|
"""Apply the channel-switch outcome to the UI (always on the GUI thread)."""
|
||||||
|
if success:
|
||||||
|
self.channel_status_label.setText(_("settings.ytdlp_channel_switched", channel=new_channel))
|
||||||
|
self.channel_status_label.setStyleSheet(
|
||||||
|
"color: #00cc00; font-size: 11px; padding: 5px; "
|
||||||
|
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error=error_msg))
|
||||||
|
self.channel_status_label.setStyleSheet(
|
||||||
|
"color: #ff6666; font-size: 11px; padding: 5px; "
|
||||||
|
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||||
|
)
|
||||||
|
# Revert radio selection
|
||||||
|
if current_channel == "nightly":
|
||||||
|
self.channel_nightly_radio.setChecked(True)
|
||||||
|
else:
|
||||||
|
self.channel_stable_radio.setChecked(True)
|
||||||
|
|
||||||
|
# Re-enable radio buttons
|
||||||
|
self.channel_stable_radio.setEnabled(True)
|
||||||
|
self.channel_nightly_radio.setEnabled(True)
|
||||||
|
|
||||||
|
|
||||||
def _update_channel_status(self, channel: str) -> None:
|
def _update_channel_status(self, channel: str) -> None:
|
||||||
"""Update the channel status label."""
|
"""Update the channel status label."""
|
||||||
self.channel_status_label.setText(_("settings.ytdlp_current_channel", channel=channel))
|
self.channel_status_label.setText(_("settings.ytdlp_current_channel", channel=channel))
|
||||||
|
|||||||
@@ -346,7 +346,10 @@ class FormatTableMixin:
|
|||||||
checkbox.setStyleSheet("QCheckBox { margin-left: 8px; }")
|
checkbox.setStyleSheet("QCheckBox { margin-left: 8px; }")
|
||||||
checkbox.format_id = f["format_id"]
|
checkbox.format_id = f["format_id"]
|
||||||
checkbox.is_audio_only = f.get("vcodec") == "none"
|
checkbox.is_audio_only = f.get("vcodec") == "none"
|
||||||
checkbox.has_audio = f.get("acodec") != "none"
|
# A missing acodec means unknown, not progressive - treating it as
|
||||||
|
# has-audio skips the +bestaudio merge and yields silent videos
|
||||||
|
acodec = f.get("acodec")
|
||||||
|
checkbox.has_audio = acodec is not None and acodec != "none"
|
||||||
checkbox.clicked.connect(lambda checked, cb=checkbox: self.handle_checkbox_click(cb))
|
checkbox.clicked.connect(lambda checked, cb=checkbox: self.handle_checkbox_click(cb))
|
||||||
self.format_checkboxes.append(checkbox)
|
self.format_checkboxes.append(checkbox)
|
||||||
|
|
||||||
|
|||||||
@@ -1176,12 +1176,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
|||||||
|
|
||||||
# Clean up the thread reference and ensure it's properly finished
|
# Clean up the thread reference and ensure it's properly finished
|
||||||
if hasattr(self, "auto_update_thread"):
|
if hasattr(self, "auto_update_thread"):
|
||||||
|
thread = self.auto_update_thread
|
||||||
# Disconnect all signals to prevent further callbacks
|
# Disconnect all signals to prevent further callbacks
|
||||||
self.auto_update_thread.update_finished.disconnect()
|
thread.update_finished.disconnect()
|
||||||
# Make sure thread is finished
|
# Dropping the Python reference while run() is still unwinding
|
||||||
if self.auto_update_thread.isRunning():
|
# can destroy a live QThread; let Qt delete it once finished.
|
||||||
self.auto_update_thread.quit()
|
if thread.isRunning():
|
||||||
self.auto_update_thread.wait(1000) # Wait up to 1 second
|
thread.finished.connect(thread.deleteLater)
|
||||||
|
thread.quit()
|
||||||
|
else:
|
||||||
|
thread.deleteLater()
|
||||||
# Remove the reference
|
# Remove the reference
|
||||||
delattr(self, "auto_update_thread")
|
delattr(self, "auto_update_thread")
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ from .gui.ytsage_gui_main import YTSageApp # Import the main application class
|
|||||||
|
|
||||||
|
|
||||||
def show_error_dialog(message):
|
def show_error_dialog(message):
|
||||||
|
# A QMessageBox needs a live QApplication; if startup failed before (or
|
||||||
|
# while) creating one, constructing the dialog would abort the process
|
||||||
|
# and swallow the real error.
|
||||||
|
if QApplication.instance() is None:
|
||||||
|
print(f"Application Error: {message}", file=sys.stderr)
|
||||||
|
return
|
||||||
error_dialog = QMessageBox()
|
error_dialog = QMessageBox()
|
||||||
error_dialog.setIcon(QMessageBox.Icon.Critical)
|
error_dialog.setIcon(QMessageBox.Icon.Critical)
|
||||||
error_dialog.setText("Application Error")
|
error_dialog.setText("Application Error")
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ Exceptions
|
|||||||
when possible.
|
when possible.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
@@ -102,6 +104,11 @@ class ConfigManager:
|
|||||||
"ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
"ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
||||||
"ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
"ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
||||||
},
|
},
|
||||||
|
"advanced": {
|
||||||
|
# Allow falling back to a system-installed yt-dlp from PATH when
|
||||||
|
# the app-managed, SHA256-verified binary is absent
|
||||||
|
"allow_system_ytdlp": False,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -115,13 +122,17 @@ class ConfigManager:
|
|||||||
if cls._config_file.exists():
|
if cls._config_file.exists():
|
||||||
try:
|
try:
|
||||||
with open(cls._config_file, "r", encoding="utf-8") as f:
|
with open(cls._config_file, "r", encoding="utf-8") as f:
|
||||||
cls._settings = json.load(f)
|
stored = json.load(f)
|
||||||
|
# Merge on top of defaults so keys added in newer versions
|
||||||
|
# exist without call sites needing `or <default>` fallbacks
|
||||||
|
cls._settings = copy.deepcopy(cls._default_config)
|
||||||
|
cls._settings.update(stored)
|
||||||
logger.info("Config loaded from file.")
|
logger.info("Config loaded from file.")
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
cls._settings = cls._default_config.copy()
|
cls._settings = copy.deepcopy(cls._default_config)
|
||||||
logger.warning("Config file corrupt, loaded defaults.")
|
logger.warning("Config file corrupt, loaded defaults.")
|
||||||
else:
|
else:
|
||||||
cls._settings = cls._default_config.copy()
|
cls._settings = copy.deepcopy(cls._default_config)
|
||||||
cls._save()
|
cls._save()
|
||||||
logger.info("Config file not found, created default config.")
|
logger.info("Config file not found, created default config.")
|
||||||
|
|
||||||
@@ -136,8 +147,13 @@ class ConfigManager:
|
|||||||
"""
|
"""
|
||||||
with cls._lock:
|
with cls._lock:
|
||||||
try:
|
try:
|
||||||
with open(cls._config_file, "w", encoding="utf-8") as f:
|
# Atomic write: a crash mid-save must not truncate the config
|
||||||
|
tmp_file = cls._config_file.with_suffix(".json.tmp")
|
||||||
|
with open(tmp_file, "w", encoding="utf-8") as f:
|
||||||
json.dump(cls._settings, f, indent=4)
|
json.dump(cls._settings, f, indent=4)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp_file, cls._config_file)
|
||||||
logger.debug("Config saved to file.")
|
logger.debug("Config saved to file.")
|
||||||
except (OSError, PermissionError) as e:
|
except (OSError, PermissionError) as e:
|
||||||
logger.exception(f"Failed to save config: {e}")
|
logger.exception(f"Failed to save config: {e}")
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ class HistoryManager:
|
|||||||
if cls._connection is None:
|
if cls._connection is None:
|
||||||
cls._connection = sqlite3.connect(cls._db_file, check_same_thread=False)
|
cls._connection = sqlite3.connect(cls._db_file, check_same_thread=False)
|
||||||
cls._connection.row_factory = sqlite3.Row
|
cls._connection.row_factory = sqlite3.Row
|
||||||
|
# WAL lets the download thread write while the history
|
||||||
|
# dialog reads; busy_timeout avoids "database is locked"
|
||||||
|
cls._connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cls._connection.execute("PRAGMA busy_timeout=5000")
|
||||||
|
|
||||||
cursor = cls._connection.cursor()
|
cursor = cls._connection.cursor()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user