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>
The segfault: Qt destroys and recreates a widget's QOpenGLContext whenever it
moves to another top-level window, then calls initializeGL() again. libmpv
permits one render context per handle, so the second creation failed with
"There is already a mpv_render_context set" -- and the except branch assigned
self._render_ctx = None, dropping the last Python reference to the first
context, which libmpv was still holding a function pointer into. python-mpv's
MpvRenderContext has no __del__ and free() does not unregister the callback,
so the ctypes trampoline was collected while registered and the next frame
notification jumped into freed memory.
Two invariants fix it. initializeGL() now tears down any existing render
context first, so a second call is an ordinary recreation. Teardown clears
update_cb, calls free() with the GL context current, and only then drops the
reference -- and it is connected to QOpenGLContext.aboutToBeDestroyed, so it
runs before the GL context dies instead of never. The local reference during
teardown is load-bearing: it is what keeps the trampoline alive until free()
returns.
Three things were destroying that context. Fullscreen reparented the panel
into a new top-level window (twice per toggle) and put it back at the end of
the splitter, losing the pane layout; it now fullscreens the main window and
hides the chrome, reparenting nothing. The tab cross-fade and the dialog blur
both grab() the widget tree, which on an OpenGL surface forces a framebuffer
readback and returns black -- the fade is skipped for pages holding the video,
and dialogs dim rather than blur.
Verified on a real Wayland GL context: ten forced context destroy/recreate
cycles re-establish the render context every time, and the full app survives
tab switching, six fullscreen toggles and resizes with no render-context
error and a clean exit. Before this, the same startup dumped core.
Also here because they are one-line consequences of touching _create_mpv: an
explicit per-platform hwdec list ending in software decoding, and the restored
volume actually reaching mpv -- the slider set its value before connecting its
signal, so playback always started at 100.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
WatchPage now records every played video in watch_history, saves the
playback position every 5 seconds and on stop/end (completed at >=95%),
and seeks back on replay when player.resume is "auto" - resume kicks in
between 30 seconds and 95% of the duration. The play queue persists
across restarts and reorders/removals write through immediately; the
main window's closeEvent flushes position and queue and releases libmpv.
PlayerPanel gains an automatic stall-retry: YouTube's CDN intermittently
serves stalled streams to non-browser clients (reproduced ~1/3 of
attempts headless with identical code), and a reload re-resolves onto a
healthy node - two retries after 25s of no playback, then a user-facing
error.
ytsage_constants now prepends the managed-binaries dir to PATH so
yt-dlp subprocesses (including mpv's ytdl_hook) can find the managed
Deno runtime - previously nothing exported APP_BIN_DIR, so the deno
integration silently never worked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New ytsage/utils/ytsage_library_manager.py: sagetube_library.db
(separate from the upstream download-history DB) with WAL from day one,
holding subscriptions, cached feed_items, watch_history with resume
positions, and the persisted play_queue. Watch history and queue tables
are wired up by the next commit.
FeedPage:
- Local mode: FeedRefreshWorker refreshes each subscribed channel
sequentially (feed.per_channel_items, default 15) and the grid fills
incrementally per channel; results are cached so the feed is
populated instantly on startup.
- Account mode: fetches youtube.com/feed/subscriptions with the user's
browser cookies - the real logged-in feed; the option is enabled only
while cookies are active and errors surface as a status banner.
- Sidebar lists subscriptions (double-click opens the channel in
Browse; context menu unsubscribes). Browse's Subscribe button now
toggles subscription state through the Feed page.
Verified live: subscribe -> refresh -> 15 videos cached in SQLite and
rendered as cards.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The single-page downloader layout becomes the Downloads tab of a
SmoothTabWidget with Watch / Search / Feed / Browse / Downloads pages.
The init_ui edit is deliberately small (the old central widget is now
self.download_page); all new behavior lives in new modules:
- ytsage_gui_router.py: AppRouter signal hub (playVideo, queueVideo,
downloadVideo, openChannel, openPlaylist). A card's Download button
deep-links into the Downloads tab with the URL prefilled and analysis
started automatically.
- ytsage_gui_cards.py: VideoCard (thumbnail with disk cache under
APP_THUMBNAILS_DIR, title/channel/duration, Play/Queue/Download
actions, double-click to play) and VideoCardGrid (responsive grid,
Load more pagination).
- ytsage_gui_watch.py: WatchPage hosting the mpv PlayerPanel and a
drag-reorderable play queue with auto-advance on end of file.
- ytsage_gui_search.py: SearchPage running YtdlpClient.search off the
GUI thread with Load more pagination.
- Browse and Feed pages are placeholders, implemented next.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- show_error_dialog() aborted when QApplication construction itself was
the failure; fall back to stderr so the real error is visible.
- Auto-update QThread cleanup dropped the last Python reference while
run() could still be unwinding ("QThread: Destroyed while thread is
still running"); defer destruction to deleteLater on finished.
- macOS ffmpeg install no longer curl|bash-es the Homebrew bootstrap
script unattended; it now asks the user to install Homebrew
themselves and fails cleanly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Speed limit settings (value and unit) are now loaded from config on app initialization and saved to config when updated. This ensures the user's speed limit preferences persist across app restarts.
Restore window geometry/state on startup and save them on exit. Adds YTSageApp._load_window_state() and calls it during UI init; saves Base64-encoded geometry/state via ConfigManager when closing. Adds default config keys "window_geometry" and "window_state". Includes error handling and debug logging to avoid failures if stored values are invalid.
Set the default generic_mode to True in the config manager and update GUI initialization to distinguish between a missing config and an explicit False. Replace usages of `ConfigManager.get(... ) or False` with a None check so that an explicit False value is respected. Changes made in ytsage/utils/ytsage_config_manager.py and GUI initializers in ytsage/gui/ytsage_gui_main.py and ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py.
Enhance playlist export output for Text and CSV formats. Text files now include a numeric index, a title fallback ("Video N"), and a human-readable duration ([H:MM:SS] or [MM:SS]) when available before the URL. CSV export adds a "Playlist Index" column and writes a formatted title with index and duration. Duration parsing is protected against non-integer values. M3U and JSON export behavior unchanged.
Restore cookie configuration on startup when previously active and the user opted to remember them. If config indicates a file source and the path exists, set cookie_file_path; if browser source, set browser_cookies_option. Treat missing "cookie_remember" as true by default. If the user did not opt to remember cookies, reset cookie_active to false. Added logging for restored settings and initialization state.
Introduce a Save Playlist UI flow: import QFileDialog, group playlist action buttons into a horizontal layout, add a new "Save Playlist As" button with styling and visibility control, and connect its click to a new save_playlist_to_file method. The new method prompts for a save location and supports saving playlist_entries in TXT, M3U, CSV, or JSON formats, shows success/error message boxes, and logs exceptions. Also wire the existing playlist visibility signal to control the save button and adjust layout placement for the playlist buttons.
Format table: when in playlist mode and the format has a video codec, prefix the quality and (when available) resolution with "≤ " to indicate an upper-bound. Resolution is only prefixed if it's not "N/A".
Main UI: replace the old checkbox-list lookup with scanning the format_table rows to find the checked checkbox widget, pick the correct resolution column depending on playlist mode, strip any leading "≤ " and use that as the selected resolution. This fixes resolution selection for playlist entries and ensures the displayed "≤" semantics are handled when extracting values.
Read concurrent_fragments from ConfigManager (defaulting to 1) and forward it into the DownloadThread constructor so the download fragment concurrency setting is propagated to the downloader.
Introduce an "audio_normalization" option across the app and implement normalization for audio-only downloads. Updates include: add config default (ConfigManager), new language strings, settings UI checkbox + help text and logic to auto-enable force-audio-format when normalization is enabled, and persist the setting. Pass the setting from main app to DownloadThread, and in the downloader force re-encoding to mp3 when necessary and append --postprocessor-args ExtractAudio:-af loudnorm=... to apply EBU R128 normalization. This ensures normalization works reliably (avoids ffmpeg stream-copy errors) and keeps the behavior scoped to ExtractAudio postprocessor.
Introduce a new "check_app_updates" configuration (default: true) to allow users to enable/disable automatic YTSage update checks. Adds a styled checkbox to the Updater tab with getter, loads the setting (defaults to enabled for older configs), and saves it from the custom options dialog. The main update check now respects this setting and will skip checking when disabled. Also include English localization entries for the new UI strings.
Introduce a "generic_mode" option to allow validating/downloading from non-YouTube sites and wire it through the UI, config, and validation logic. Key changes:
- Add generic_mode default to ConfigManager and persist setting from DownloadSettingsDialog (checkbox + help text).
- Extend validate_video_url to accept a generic_mode flag and allow any http/https URL with a domain when enabled; pass this flag from Analysis and Download flows.
- Update YTSageApp to load/save generic_mode, update URL placeholder and settings tooltip behavior, and refresh tooltip when settings change.
- Improve robustness in FormatTableMixin: handle None/incorrect types for format_note, abr, resolution, ext, and codec values to avoid type errors and ensure consistent display.
- Add localization entries for generic mode, placeholder, and related help text across multiple language files and update the in-app default localization strings.
These changes enable broader site support via yt-dlp while hardening UI format handling and keeping user settings persistent.
Introduce an opt-in beta updates feature: add a styled "Receive Beta Updates" checkbox in the Updater UI with i18n strings, load/save logic in the custom options dialog, and a default config key (check_beta_updates=false).
Add _fetch_github_beta_version to the update thread to query GitHub releases (selecting the highest tag) and emit update_available for newer beta builds. When beta checking is enabled, the thread will perform the GitHub beta check and return early to avoid conflicting with the PyPI check. This enables users to opt into preview releases safely.
Introduce a new filename_format setting and UI to control yt-dlp output templates. ConfigManager now includes a default filename_format (%(title)s_%(resolution)s.%(ext)s). The DownloadSettingsDialog exposes a text input and help text for the format and saves the value to ConfigManager. DownloadThread now accepts a filename_format argument and uses it when building output templates for single videos and playlists. YTSageApp reads the config and passes the filename format into the download thread. Added corresponding English language strings.
Add a safe blurred-overlay flow for modal dialogs by implementing run_dialog_with_blur and _apply_blur_to_pixmap on YTSageApp. The new flow captures a window screenshot, generates a blurred/dimmed pixmap via a QGraphicsScene + QGraphicsBlurEffect, shows it in an overlay QLabel with a fade-in, runs the dialog, and then removes the overlay.
Replace many direct dialog.exec() calls with run_dialog_with_blur(...) across the main GUI (download settings, about, history, playlist selection, custom options, cookie login, ffmpeg check, time range, setup success dialogs, etc.). Also add imports required for pixmap/graphics handling.
Make animate_widget_fade_in usage safer by checking for the method before calling it and falling back to setVisible(True) for format table and thumbnails to avoid QPainter/graphics-effect conflicts. Small comment/cleanup in fade-in/out methods.
Introduce a shake animation to visually indicate invalid inputs and wire it into validation/error flows. Changes: import QPoint; add animate_widget_shake(QWidget) implementation that uses QPropertyAnimation on widget.pos with keyframes; call the animation when URL/path/format validations fail and replace some direct status_label updates with set_status_message_animated. Updated files: ytsage/gui/ytsage_gui_main.py and ytsage/gui/ytsage_gui_analysis.py.
Use animated show/hide for playlist widgets and harden fade animations. Replace direct setVisible signal handlers with lambdas that call set_widget_visible_animated, and add set_widget_visible_animated to route to fade-in/out. Fade methods now stop opposing animations, reuse existing QGraphicsOpacityEffect when present, preserve current opacity as start values, and set effect opacity when creating a new effect. Also catch possible RuntimeError when stopping animations and keep animation references on widgets to avoid premature GC.
Introduce smooth UI transitions by adding opacity-based animations. Import QGraphicsOpacityEffect and add animate_widget_fade_in, animate_widget_fade_out, and set_status_message_animated to cross-fade status text and fade buttons in/out. Update signal handlers and download flow to use the new animated status setter and fade methods instead of direct setText/setVisible calls. Keep animation references on widgets to avoid premature GC and remove effects after fade-out.
Moved all files from src/gui/ to ytsage/gui/ and updated import statements to use relative imports within the new package structure. This improves modularity and prepares the codebase for distribution as a proper Python package.