3aca43b372
There was no icon system at all. Transport buttons called QStyle.standardIcon(SP_MediaPlay), which returns the platform theme's dark monochrome glyph -- painted onto the app's saturated red buttons at a fixed 36px with no text, that is the black square. Everything else called an icon was an emoji baked into en.json, which is tofu wherever the emoji font is missing. Qt stylesheets cannot recolour a QIcon, so the colour is an argument to the new helper; that is the whole fix. The SVGs are drawn here rather than vendored, which keeps a third-party licence out of the tree, and they live in the module rather than as asset files, which keeps them out of package-data and safe in a frozen build. Tooltips were the other half: three widgets in the entire application had one and nothing set an accessible name. Filling that in at the call sites would have meant editing well over a hundred of them, most in upstream-owned files. Instead one application-level event filter handles QEvent.Polish, which Qt sends to every widget once before it is shown -- so it also reaches dialogs built by upstream code, and survives the next merge. It maps placeholder emoji to icons, fills empty tooltips from the button text, and logs icon-only buttons that still have none so the gaps are findable. StyleSheet.MAIN styles the window, inputs, buttons and tables and nothing else, so the main tab bar, combos, sliders, lists, menus, splitters, tooltips and the horizontal scrollbar fell through to the platform style. EXTRA_QSS covers them, in a fork-owned module appended at the one application site. SmoothTabWidget names its frame "tabContent" with the comment "We draw border on content instead" -- that rule existed only inside two dialogs, and now exists for the main window too. Two corrections to rules that were already there: the pressed style changed the padding, shifting every label two pixels and clipping fixed-width icon buttons, and checkboxes were fully rounded, which reads as a radio button rather than an on/off toggle. Verified by screenshot on the real display: tab bar, buttons, combo carets and checkboxes all render as intended, and all 35 icons rasterise non-empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
253 lines
11 KiB
Python
253 lines
11 KiB
Python
"""
|
|
This module defines centralized constants used across the YTSage application.
|
|
By storing shared values in one place, it improves consistency, readability,
|
|
and maintainability of the codebase.
|
|
|
|
Constants include:
|
|
- Asset paths for icons and notification sounds.
|
|
- OS detection and platform-specific directory paths for application data, binaries, logs, and configuration.
|
|
- Download URLs for yt-dlp and ffmpeg binaries.
|
|
- SUBPROCESS_CREATIONFLAGS: Used to specify subprocess creation flags (e.g., subprocess.CREATE_NO_WINDOW on Windows to hide the console window).
|
|
Directories are automatically created when the module is imported, ensuring the required structure exists for the application.
|
|
YTSage application constants.
|
|
|
|
"""
|
|
|
|
import os
|
|
import platform
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Handle resource paths for both development and installed package
|
|
def get_asset_path(asset_relative_path: str) -> Path:
|
|
"""
|
|
Get the absolute path to an asset file, works both in development and installed package.
|
|
|
|
Args:
|
|
asset_relative_path: Relative path to the asset (e.g., "assets/Icon/icon.png")
|
|
|
|
Returns:
|
|
Path: Absolute path to the asset file
|
|
"""
|
|
# Check if running as a frozen executable (PyInstaller, cx_Freeze, etc.)
|
|
if getattr(sys, "frozen", False):
|
|
# Running as a frozen executable
|
|
# Try sys._MEIPASS first (PyInstaller)
|
|
if hasattr(sys, "_MEIPASS"):
|
|
asset_path = Path(sys._MEIPASS) / asset_relative_path
|
|
if asset_path.exists():
|
|
return asset_path
|
|
|
|
# For cx_Freeze, assets are typically in lib/ directory next to the executable
|
|
executable_dir = Path(sys.executable).parent
|
|
|
|
# Try with lib/ prefix (cx_Freeze standard structure)
|
|
asset_path = executable_dir / "lib" / asset_relative_path
|
|
if asset_path.exists():
|
|
return asset_path
|
|
|
|
# Try directly in executable directory
|
|
asset_path = executable_dir / asset_relative_path
|
|
if asset_path.exists():
|
|
return asset_path
|
|
|
|
# Not frozen - try importlib.resources for installed packages
|
|
try:
|
|
# Use importlib.resources (standard in Python 3.9+)
|
|
import importlib.resources as resources
|
|
try:
|
|
# Navigate to the package root and then to the asset
|
|
package_path = resources.files('ytsage')
|
|
asset_path = package_path / asset_relative_path
|
|
if asset_path.is_file():
|
|
return Path(str(asset_path))
|
|
except (ImportError, AttributeError, FileNotFoundError):
|
|
pass
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback to relative path (for development environment)
|
|
current_file = Path(__file__)
|
|
# Go up from utils to ytsage root, then to asset
|
|
ytsage_root = current_file.parent.parent.parent
|
|
asset_path = ytsage_root / asset_relative_path
|
|
|
|
return asset_path
|
|
|
|
# Assets Constants
|
|
ICON_PATH: Path = get_asset_path("assets/Icon/icon.png")
|
|
# The 48px original is what the taskbar and window manager had to upscale.
|
|
ICON_PATH_LARGE: Path = get_asset_path("assets/Icon/icon-256.png")
|
|
SOUND_PATH: Path = get_asset_path("assets/sound/notification.mp3")
|
|
|
|
OS_NAME: str = platform.system() # Windows ; Darwin ; Linux
|
|
|
|
IS_FROZEN = getattr(sys, "frozen", False)
|
|
USER_HOME_DIR: Path = Path.home()
|
|
|
|
# OS Specific Constants
|
|
if OS_NAME == "Windows":
|
|
OS_FULL_NAME: str = f"{OS_NAME} {platform.release()}"
|
|
|
|
# Always use user data directory for app data, logs, config, and binaries
|
|
# Even when frozen, we don't want to create these folders next to the executable
|
|
APP_DIR: Path = Path(os.environ.get("LOCALAPPDATA", USER_HOME_DIR / "AppData" / "Local")) / "SageTube"
|
|
APP_BIN_DIR: Path = APP_DIR / "bin"
|
|
APP_DATA_DIR: Path = APP_DIR / "data"
|
|
APP_LOG_DIR: Path = APP_DIR / "logs"
|
|
APP_CONFIG_FILE: Path = APP_DATA_DIR / "sagetube_config.json"
|
|
APP_HISTORY_FILE: Path = APP_DATA_DIR / "ytsage_history.json"
|
|
APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails"
|
|
|
|
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
|
|
YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp.exe"
|
|
|
|
SUBPROCESS_CREATIONFLAGS: int = subprocess.CREATE_NO_WINDOW
|
|
|
|
elif OS_NAME == "Darwin": # macOS
|
|
_mac_version = platform.mac_ver()[0]
|
|
OS_FULL_NAME: str = f"macOS {_mac_version}" if _mac_version else "macOS"
|
|
|
|
# Always use user data directory for app data, logs, config, and binaries
|
|
# Even when frozen, we don't want to create these folders next to the executable
|
|
APP_DIR: Path = USER_HOME_DIR / "Library" / "Application Support" / "SageTube"
|
|
APP_BIN_DIR: Path = APP_DIR / "bin"
|
|
APP_DATA_DIR: Path = APP_DIR / "data"
|
|
APP_LOG_DIR: Path = APP_DIR / "logs"
|
|
APP_CONFIG_FILE: Path = APP_DATA_DIR / "sagetube_config.json"
|
|
APP_HISTORY_FILE: Path = APP_DATA_DIR / "ytsage_history.json"
|
|
APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails"
|
|
|
|
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
|
|
YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp"
|
|
|
|
SUBPROCESS_CREATIONFLAGS: int = 0
|
|
|
|
|
|
else: # Linux and other UNIX-like
|
|
OS_FULL_NAME: str = f"{OS_NAME} {platform.release()}"
|
|
|
|
# Always use user data directory for app data, logs, config, and binaries
|
|
# Even when frozen, we don't want to create these folders next to the executable
|
|
APP_DIR: Path = USER_HOME_DIR / ".local" / "share" / "SageTube"
|
|
APP_BIN_DIR: Path = APP_DIR / "bin"
|
|
APP_DATA_DIR: Path = APP_DIR / "data"
|
|
APP_LOG_DIR: Path = APP_DIR / "logs"
|
|
APP_CONFIG_FILE: Path = APP_DATA_DIR / "sagetube_config.json"
|
|
APP_HISTORY_FILE: Path = APP_DATA_DIR / "ytsage_history.json"
|
|
APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails"
|
|
|
|
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
|
|
|
|
# Check for environment variable override (critical for Flatpak support)
|
|
_ytdlp_env_path = os.environ.get("YTDLP_APP_BIN_PATH")
|
|
if _ytdlp_env_path:
|
|
YTDLP_APP_BIN_PATH: Path = Path(_ytdlp_env_path)
|
|
else:
|
|
YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp"
|
|
|
|
SUBPROCESS_CREATIONFLAGS: int = 0
|
|
|
|
# Documentation URLs
|
|
YTDLP_DOCS_URL: str = "https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#usage-and-options"
|
|
|
|
# yt-dlp SHA256 checksums URL
|
|
YTDLP_SHA256_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/SHA2-256SUMS"
|
|
|
|
# Deno download URLs and paths
|
|
if OS_NAME == "Windows":
|
|
DENO_DOWNLOAD_URL: str = "https://github.com/denoland/deno/releases/latest/download/deno-x86_64-pc-windows-msvc.zip"
|
|
DENO_SHA256_URL: str = "https://github.com/denoland/deno/releases/latest/download/deno-x86_64-pc-windows-msvc.zip.sha256sum"
|
|
DENO_APP_BIN_PATH: Path = APP_BIN_DIR / "deno.exe"
|
|
elif OS_NAME == "Darwin": # macOS
|
|
DENO_DOWNLOAD_URL: str = "https://github.com/denoland/deno/releases/latest/download/deno-x86_64-apple-darwin.zip"
|
|
DENO_SHA256_URL: str = "https://github.com/denoland/deno/releases/latest/download/deno-x86_64-apple-darwin.zip.sha256sum"
|
|
DENO_APP_BIN_PATH: Path = APP_BIN_DIR / "deno"
|
|
else: # Linux
|
|
DENO_DOWNLOAD_URL: str = "https://github.com/denoland/deno/releases/latest/download/deno-x86_64-unknown-linux-gnu.zip"
|
|
DENO_SHA256_URL: str = "https://github.com/denoland/deno/releases/latest/download/deno-x86_64-unknown-linux-gnu.zip.sha256sum"
|
|
|
|
# Check for environment variable override (critical for Flatpak support)
|
|
_deno_env_path = os.environ.get("DENO_APP_BIN_PATH")
|
|
if _deno_env_path:
|
|
DENO_APP_BIN_PATH: Path = Path(_deno_env_path)
|
|
else:
|
|
DENO_APP_BIN_PATH: Path = APP_BIN_DIR / "deno"
|
|
|
|
# FFmpeg download links (Essentials build - always latest version)
|
|
FFMPEG_7Z_DOWNLOAD_URL = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.7z"
|
|
FFMPEG_ZIP_DOWNLOAD_URL = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"
|
|
FFMPEG_7Z_SHA256_URL = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.7z.sha256"
|
|
FFMPEG_ZIP_SHA256_URL = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip.sha256"
|
|
FFMPEG_7Z_VERSION_URL = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.7z.ver"
|
|
FFMPEG_ZIP_VERSION_URL = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip.ver"
|
|
|
|
# =============================================================================
|
|
# File Extension Constants
|
|
# =============================================================================
|
|
# Centralized file extension definitions to avoid duplication across modules
|
|
# Use these constants for file type detection throughout the application
|
|
|
|
# Video file extensions (container formats that typically contain video)
|
|
VIDEO_EXTENSIONS: frozenset[str] = frozenset({
|
|
".mp4", ".webm", ".mkv", ".avi", ".mov", ".flv"
|
|
})
|
|
|
|
# Audio file extensions (audio-only formats)
|
|
AUDIO_EXTENSIONS: frozenset[str] = frozenset({
|
|
".mp3", ".m4a", ".aac", ".wav", ".ogg", ".opus", ".flac"
|
|
})
|
|
|
|
# Subtitle file extensions
|
|
SUBTITLE_EXTENSIONS: frozenset[str] = frozenset({
|
|
".vtt", ".srt", ".ass", ".ssa"
|
|
})
|
|
|
|
# Combined video and audio extensions (for file search operations)
|
|
MEDIA_EXTENSIONS: frozenset[str] = VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
|
|
|
|
if __name__ == "__main__":
|
|
# If this file is run directly, print directory information; if imported, create the necessary directories for the application.
|
|
# for debug, to check os specific variable which can be different based on os.
|
|
info = {
|
|
"OS_NAME": OS_NAME,
|
|
"OS_FULL_NAME": OS_FULL_NAME,
|
|
"USER_HOME_DIR": str(USER_HOME_DIR),
|
|
"APP_DIR": str(APP_DIR),
|
|
"APP_BIN_DIR": str(APP_BIN_DIR),
|
|
"APP_DATA_DIR": str(APP_DATA_DIR),
|
|
"APP_LOG_DIR": str(APP_LOG_DIR),
|
|
"APP_CONFIG_FILE": str(APP_CONFIG_FILE),
|
|
"YTDLP_DOWNLOAD_URL": YTDLP_DOWNLOAD_URL,
|
|
"YTDLP_APP_BIN_PATH": YTDLP_APP_BIN_PATH,
|
|
"SUBPROCESS_CREATIONFLAGS": SUBPROCESS_CREATIONFLAGS,
|
|
}
|
|
for key, value in info.items():
|
|
print(f"{key}: {value}")
|
|
else:
|
|
APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
APP_BIN_DIR.mkdir(parents=True, exist_ok=True)
|
|
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
APP_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
APP_THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Ensure custom yt-dlp directory exists if set
|
|
if OS_NAME not in ["Windows", "Darwin"]:
|
|
# Ensure parent directories exist for custom paths
|
|
if "YTDLP_APP_BIN_PATH" in globals():
|
|
YTDLP_APP_BIN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
if "DENO_APP_BIN_PATH" in globals():
|
|
DENO_APP_BIN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Put the managed-binaries dir on PATH for this process and all children:
|
|
# yt-dlp locates the Deno JS runtime (nsig/PO-token challenges) via PATH,
|
|
# and nothing else ever exports APP_BIN_DIR.
|
|
_bin_dirs = {str(APP_BIN_DIR)}
|
|
if "DENO_APP_BIN_PATH" in globals():
|
|
_bin_dirs.add(str(DENO_APP_BIN_PATH.parent))
|
|
for _bin_dir in _bin_dirs:
|
|
if _bin_dir not in os.environ.get("PATH", "").split(os.pathsep):
|
|
os.environ["PATH"] = _bin_dir + os.pathsep + os.environ.get("PATH", "")
|