Compare commits
30 Commits
c54574224a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
996d581a07
|
|||
|
ba05a4a7ef
|
|||
|
b07a56082a
|
|||
|
9c4749e49b
|
|||
|
fd744a1a85
|
|||
|
79a6f26c2f
|
|||
|
27933dd495
|
|||
|
3aca43b372
|
|||
|
5ae2817eea
|
|||
|
2420091a4e
|
|||
|
089b144626
|
|||
|
b3772ff484
|
|||
| b4ff618d41 | |||
| 8a05d5ff8f | |||
| a8ebe89cc1 | |||
| 3afe96a8cd | |||
| 8e935c6116 | |||
| 16be4b4afa | |||
| f886125857 | |||
| 8dd8a3c1ad | |||
| 1c885e3fc5 | |||
| 4cc48ae98f | |||
| 288d30ad8b | |||
| ebb9422591 | |||
| 852804ee5c | |||
| 5064b38315 | |||
| dff14ec3e8 | |||
| b40deb0d5f | |||
| 202dd9eaee | |||
| a1d1f87b65 |
+230
@@ -0,0 +1,230 @@
|
||||
# Changelog
|
||||
|
||||
Kept as the work happens, not assembled at release time. Format is
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
||||
[SemVer](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
**The `vX.Y.Z` tags already on this repository are upstream YTSage's**, inherited
|
||||
with its history. SageTube's own versions start below and are the ones this file
|
||||
records.
|
||||
|
||||
## 5.5.2 — 2026-08-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The account feed loaded but never appeared.** Refresh reported "60 videos in
|
||||
feed" above a "No subscriptions yet" message and an empty page: the empty-state
|
||||
hint hides the grid so it can sit centred, and the account path set its entries
|
||||
without re-running that check — so the grid stayed hidden from startup. The
|
||||
local feed was unaffected, which is why it went unnoticed.
|
||||
- The empty-state message was written for local subscriptions and was wrong
|
||||
advice in account mode, where local subscriptions are not used at all. It now
|
||||
says to press Refresh instead of telling you to go and subscribe to something.
|
||||
- The Subscriptions panel is hidden in account mode rather than sitting empty
|
||||
beside a full feed, implying something failed.
|
||||
|
||||
## 5.5.1 — 2026-08-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The app could start fullscreen with no way out.** Fullscreen became a state
|
||||
of the main window in 5.5.0, and the window's geometry is saved on exit — so
|
||||
quitting from fullscreen brought the app back fullscreen, with no title bar,
|
||||
no close button, and nothing bound to leave it (the player's F and Escape only
|
||||
work while the player has focus, and it need not even be the visible tab).
|
||||
Fullscreen is now never restored at startup, leaving it is done before the
|
||||
geometry is saved, and **F11 toggles fullscreen and Escape leaves it from
|
||||
anywhere in the window**.
|
||||
- **"Sign in with cookies" and the account button did nothing.** Both open the
|
||||
cookie dialog on its Cookies tab, which meant calling `setCurrentIndex` on
|
||||
the dialog's tab widget — a `SmoothTabWidget`, which despite standing in for
|
||||
a `QTabWidget` implemented only `set_current_index`. The call raised, so the
|
||||
dialog never appeared. `SmoothTabWidget` now provides the Qt-compatible API
|
||||
it claimed to have (`setCurrentIndex`, `count`, `widget`, `indexOf`,
|
||||
`tabText`, `setTabText`).
|
||||
|
||||
## 5.5.0 — 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
- **The YouTube account is findable.** Signing in with browser cookies was
|
||||
reachable only via Downloads → *Custom Options* → *Login with Cookies* — the
|
||||
last tab — while the features that need it (the Feed's account mode, Search,
|
||||
Browse) are other tabs with no route to it, and the method that opens that
|
||||
dialog on the right tab had no caller at all. There is now an account button
|
||||
in the corner of the tab bar, visible from everywhere, showing whether
|
||||
cookies are in use and which browser they came from. The Feed offers the same
|
||||
thing beside its account mode instead of just greying it out, and now says
|
||||
why it is unavailable.
|
||||
- **Cookie changes take effect immediately.** The player read them once, when
|
||||
it was built, so signing in only reached playback after restarting the app.
|
||||
It now reloads at the current position.
|
||||
- **The player responds to the keyboard and the mouse.** It had a key handler
|
||||
for Space, F and Escape that could never fire — nothing in the player set a
|
||||
focus policy, so focus always landed on a child button or slider, which
|
||||
swallowed the keys. Now: Space/K play-pause, ←/→ ∓5s, J/L ∓10s, Shift+←/→
|
||||
∓1s, `,`/`.` frame step, ↑/↓ volume, M mute, `[`/`]` speed with Backspace to
|
||||
reset, C subtitles, F fullscreen, Escape to leave, 0–9 to jump by tenths,
|
||||
Home/End, N/P for next and previous. Click pauses, double-click goes
|
||||
fullscreen, the wheel changes volume and Ctrl+wheel seeks, and right-click
|
||||
opens a menu built from the same table as the shortcuts. The bindings are
|
||||
scoped to the player, so typing in the Search box or the URL field is
|
||||
unaffected.
|
||||
- **Previous, next and mute buttons**, and a buffering indicator — a stalled
|
||||
stream previously showed a frozen frame for 25 seconds with nothing on
|
||||
screen before the automatic retry.
|
||||
- In fullscreen the controls and pointer fade out after a few idle seconds and
|
||||
return on any movement. In a window they never hide, so the layout does not
|
||||
jump.
|
||||
- **Real icons.** The app had no icon system: transport buttons used the
|
||||
platform style's dark monochrome glyphs painted on saturated red, and
|
||||
everything else called an icon was an emoji baked into the English strings.
|
||||
A drawn SVG set is now rendered through QtSvg and recoloured at load — Qt
|
||||
stylesheets cannot recolour a `QIcon`, which is exactly why the old ones were
|
||||
unreadable. Buttons across the Downloads tab gained icons too.
|
||||
- **Tooltips and accessible names on every button.** Three widgets in the whole
|
||||
application had a tooltip. An application-level polish filter now fills them
|
||||
in as each button is first shown, and swaps placeholder emoji for icons —
|
||||
which covers dialogs owned by upstream without editing them.
|
||||
- **Skip this version** in the update dialog. The skipped version is never
|
||||
offered again; later ones still are.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The tab order is Feed → Search → Browse → Downloads → Watch**, each with an
|
||||
icon, and the app opens on the Feed. Watch was first, which is where the
|
||||
other tabs send you rather than somewhere you start.
|
||||
- **Opening the Feed refreshes it**, but only channels not seen for 30 minutes
|
||||
(`feed.auto_refresh_on_open_minutes`, 0 to switch off), at most eight per
|
||||
visit, once per interval per session, and after a short delay. A refresh is
|
||||
one yt-dlp subprocess per subscribed channel, so an unthrottled one would be
|
||||
a lot of them. The cached feed still appears instantly; manual Refresh always
|
||||
does everything.
|
||||
- mpv is given an explicit `hwdec` list per platform (`player.hwdec`, default
|
||||
`auto`), each ending in software decoding, so a machine with broken GPU
|
||||
interop plays rather than showing a black frame. This does **not** silence
|
||||
`Cannot load libcuda.so.1` — that comes from the driver stack below mpv and
|
||||
appears with `hwdec=no` too.
|
||||
- **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.
|
||||
- Configs now carry `config_version`. A file written before 5.4.0 — including one
|
||||
inherited from an upstream YTSage install — has its stored `check_app_updates`
|
||||
cleared once, so SageTube's own default applies rather than a setting that
|
||||
pointed at another project's releases.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A non-English interface no longer shows raw keys.** Every string SageTube
|
||||
added — the tab names, the feed, cards, watch, browse, search, player and
|
||||
account text — exists only in `en.json`, because the thirteen translated files
|
||||
predate the fork. `get_text` returned the key itself, so the tabs literally
|
||||
read `main_tabs.watch`. Those namespaces are now in the embedded English
|
||||
fallback, so a missing translation reads as English rather than as a dotted
|
||||
key. Translating the language files remains a separate job.
|
||||
- The error log filled with hundreds of `after creating texture: OpenGL error
|
||||
INVALID_ENUM` lines per session. They come from the GL driver, playback
|
||||
continues regardless, and they buried the errors that do matter, so they are
|
||||
logged at debug level now.
|
||||
- The player's cookie and proxy options were joined with commas and never
|
||||
escaped, so a cookie path or a proxy URL containing one silently truncated
|
||||
the option and produced a bogus one. The geo-bypass proxy was honoured by the
|
||||
downloader but never passed to the player at all.
|
||||
- **The rest of the interface is themed.** `StyleSheet.MAIN` styled the window,
|
||||
inputs, buttons and tables and nothing else, so the main tab bar, combo
|
||||
boxes, sliders, lists, menus, splitters, tooltips and the horizontal
|
||||
scrollbar were drawn by the platform — the tab bar with white text forced
|
||||
onto system-coloured tabs, tooltips in the system's light style on a black
|
||||
app. All of them now match. `QFrame#tabContent` finally has the rule the tab
|
||||
widget's own comment promised.
|
||||
- Checkboxes were round, which reads as a radio button — "one of these" rather
|
||||
than "on or off". They are square with a tick.
|
||||
- Clicking any button shifted its label two pixels down and right, and clipped
|
||||
the artwork on fixed-width icon buttons: the pressed style changed the
|
||||
padding. The background change alone reads as pressed.
|
||||
- The window icon shipped only at 48px and was upscaled everywhere; the 256px
|
||||
master now ships beside it. Its fallback was the platform's download arrow
|
||||
standing in for the application's identity.
|
||||
- **The feed grid no longer flickers or loses your scroll position.** It was
|
||||
cleared and rebuilt from scratch after every channel finished, re-reading
|
||||
every thumbnail from disk each time; finished channels are now merged in and
|
||||
existing cards left alone. The docstring's claim that it "fills
|
||||
incrementally" is finally true.
|
||||
- Card layout items accumulated: each `Load more` re-added cards the layout
|
||||
already held, and the resize check compared against `columnCount()`, which
|
||||
never shrinks — so it relaid out on every single resize event.
|
||||
- Feed failures were invisible. The error was written into the same label the
|
||||
next successful channel immediately overwrote; the count is now reported once
|
||||
when the refresh ends.
|
||||
- Switching the feed to account mode left the local videos on screen, so one
|
||||
mode's contents appeared to be the other's.
|
||||
- Leaving the Feed tab or closing the window cancels a running refresh.
|
||||
`cancel()` existed and had no callers, so a sweep kept running and the window
|
||||
could be closed while its thread was live.
|
||||
- Browse's Subscribe button never said "Unsubscribe", although pressing it
|
||||
toggles; its only feedback was a status line on a different tab.
|
||||
- "Play all" quietly queued just the loaded page and claimed to be everything.
|
||||
- The feed sorted by publish time and fetch time in the same expression, so the
|
||||
most recently refreshed channel floated to the top regardless of how old its
|
||||
videos were.
|
||||
- **The player no longer crashes the app.** Qt destroys and recreates a
|
||||
widget's OpenGL context whenever it moves to another top-level window, and
|
||||
calls `initializeGL()` again. libmpv allows one render context per handle, so
|
||||
the second creation failed — and the failure path dropped the last reference
|
||||
to the *first* context while libmpv still held a function pointer into it.
|
||||
Python then collected the callback trampoline under libmpv's feet and the
|
||||
next frame notification jumped into freed memory. `initializeGL()` now tears
|
||||
down first so recreation is clean, teardown clears the callback and frees
|
||||
with the GL context current, and it is wired to
|
||||
`QOpenGLContext.aboutToBeDestroyed` so it runs before the context goes away
|
||||
rather than never. Verified over ten forced context destroy/recreate cycles.
|
||||
- **Fullscreen no longer reparents the video.** It took the player out of its
|
||||
layout and into a new top-level window, destroying the OpenGL context twice
|
||||
per toggle — the most direct route to the crash above — and on the way back
|
||||
re-added the panel at the end of the splitter, so the video reappeared beside
|
||||
the queue with the pane sizes lost. The main window now goes fullscreen and
|
||||
the chrome around the video is hidden instead. Nothing is reparented.
|
||||
- **Switching tabs no longer risks the player.** The cross-fade grabbed a
|
||||
screenshot of the outgoing page; on an OpenGL surface that forces a
|
||||
framebuffer readback and returns black, so the "fade" was a black slab and
|
||||
the readback endangered the context. Pages containing the video now switch
|
||||
without the fade.
|
||||
- Dialogs dimmed instead of blurred while the player exists, for the same
|
||||
reason — every dialog in the app screenshotted the whole window.
|
||||
- The saved volume never reached mpv: the slider set its restored value before
|
||||
its change signal was connected, so playback always started at 100.
|
||||
- **The app no longer offers upstream YTSage's releases as its own updates.**
|
||||
`__version__` and `pyproject.toml` had been left at the scaffolded `0.1.0`
|
||||
while the released tag was `v5.4.0`, so every comparison against upstream's
|
||||
published version reported an update. Both now read `5.4.0`, the single
|
||||
source of truth for the window title, the About dialog and the update check.
|
||||
- `ConfigManager` merged a stored config over the defaults **shallowly**, so a
|
||||
file written by an older build replaced whole nested objects: a stored
|
||||
`"player"` or `"feed"` containing only some keys silently discarded every
|
||||
default added since. The merge is now recursive. Keys present only in the
|
||||
stored file are preserved, so downgrading cannot destroy settings.
|
||||
- Opening *Custom Options* and clicking OK re-enabled the update checker, because
|
||||
the settings tab read a missing `check_app_updates` as enabled and then
|
||||
persisted that reading unconditionally.
|
||||
|
||||
## 5.4.0 — 2026-08-08
|
||||
|
||||
### Changed
|
||||
|
||||
- The documentation left the repository: `docs/BUILDING.md`, `docs/UPSTREAM.md`
|
||||
and `docs/UPSTREAM_README.md` are now the
|
||||
[wiki](https://git.houmeres.sk/Houmeres/SageTube/wiki).
|
||||
- **The history was deliberately not rewritten.** Every other repository in this
|
||||
org had its documentation removed from every commit on 2026-08-08. SageTube is
|
||||
a fork that still merges from `oop7/YTSage`, and a rewrite changes every commit
|
||||
id — which would end that. The files leave in an ordinary commit instead.
|
||||
- `.github/` and `readme-translations/` are upstream's and were left alone, so
|
||||
the next merge does not conflict over them.
|
||||
@@ -1,12 +1,45 @@
|
||||
# SageTube
|
||||
|
||||
A watch-first YouTube client for the desktop — search, browse channels and playlists, follow subscriptions, and stream videos in an embedded mpv player, with full yt-dlp download capability inherited from [YTSage](https://github.com/oop7/YTSage).
|
||||
A watch-first YouTube client for the desktop. Search, browse channels and
|
||||
playlists, follow subscriptions, and stream videos in an embedded mpv player —
|
||||
with the full yt-dlp download feature set inherited from
|
||||
[YTSage](https://github.com/oop7/YTSage).
|
||||
|
||||
SageTube is a fork of [YTSage](https://github.com/oop7/YTSage) by [oop7](https://github.com/oop7) (MIT). The original YTSage documentation is preserved at [docs/UPSTREAM_README.md](docs/UPSTREAM_README.md).
|
||||
## Features
|
||||
|
||||
## Status
|
||||
**Watching**
|
||||
- 📺 Embedded mpv player (Wayland/X11/Windows/macOS) with quality selector,
|
||||
speed, volume, subtitles, fullscreen
|
||||
- 🔍 In-app YouTube search with thumbnail card grid
|
||||
- 📂 Channel browsing (Videos / Shorts / Live) and playlist views
|
||||
- 🔔 Local subscriptions — no Google account needed — with an aggregated feed;
|
||||
optional real account feed via browser cookies
|
||||
- ⏯️ Watch history with resume positions and a persistent play queue
|
||||
- ⬇️ One click from any video card into the downloader
|
||||
|
||||
Under active development. See the upstream README for the downloader feature set, which remains fully functional.
|
||||
**Downloading** (from YTSage)
|
||||
- Format table, audio extraction, subtitles, SponsorBlock, chapters,
|
||||
playlists, trimming, speed limits, proxies, cookies, custom yt-dlp commands,
|
||||
download history — see the [upstream README](https://git.houmeres.sk/Houmeres/SageTube/wiki/Upstream-README)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
sagetube
|
||||
```
|
||||
|
||||
Requires **libmpv** for playback (Arch: `pacman -S mpv`) — see
|
||||
[Building](https://git.houmeres.sk/Houmeres/SageTube/wiki/Building). Without it the app works as a downloader.
|
||||
|
||||
## Fork notes
|
||||
|
||||
SageTube is a fork of [YTSage](https://github.com/oop7/YTSage) by
|
||||
[oop7](https://github.com/oop7) (MIT). The internal package keeps the
|
||||
`ytsage` name so upstream fixes remain mergeable — see
|
||||
[Tracking upstream](https://git.houmeres.sk/Houmeres/SageTube/wiki/Tracking-upstream). The fork also fixes a number of
|
||||
upstream bugs (unsafe partial-file cleanup, unverified binary updates,
|
||||
thread-safety issues, PATH corruption on Windows — see the git log).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,624 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="branding/svg/ytsage-wordmark.svg" width="400" alt="ytsage-wordmark">
|
||||
<img src="branding/screenshots/main.png" width="800" alt="YTSage Interface"/>
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://pepy.tech/project/ytsage)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://github.com/oop7/YTSage/stargazers)
|
||||
[](https://pypi.org/project/ytsage/)
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
**Modern YouTube downloader with a clean PySide6 interface.**
|
||||
Download videos in any quality, extract audio, fetch subtitles, and more.
|
||||
|
||||
### 🌍 README Languages
|
||||
|
||||
English: [EN](README.md)
|
||||
| Arabic: [AR](readme-translations/README.ar.md)
|
||||
| German: [DE](readme-translations/README.de.md)
|
||||
| Spanish: [ES](readme-translations/README.es.md)
|
||||
| French: [FR](readme-translations/README.fr.md)
|
||||
| Hindi: [HI](readme-translations/README.hi.md)
|
||||
| Indonesian: [ID](readme-translations/README.id.md)
|
||||
| Italian: [IT](readme-translations/README.it.md)
|
||||
| Japanese: [JA](readme-translations/README.ja.md)
|
||||
| Polish: [PL](readme-translations/README.pl.md)
|
||||
| Portuguese: [PT](readme-translations/README.pt.md)
|
||||
| Russian: [RU](readme-translations/README.ru.md)
|
||||
| Turkish: [TR](readme-translations/README.tr.md)
|
||||
| Chinese: [ZH](readme-translations/README.zh.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="#installation">Installation</a> •
|
||||
<a href="#features">Features</a> •
|
||||
<a href="#usage">Usage</a> •
|
||||
<a href="#screenshots">Screenshots</a> •
|
||||
<a href="#troubleshooting">Troubleshooting</a> •
|
||||
<a href="#sponsor">Sponsor</a> •
|
||||
<a href="#contributing">Contributing</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a id="why-ytsage"></a>
|
||||
## ❓ Why YTSage?
|
||||
|
||||
YTSage is designed for users who want a **simple yet powerful YouTube downloader**. Unlike other tools, it offers:
|
||||
|
||||
- A modern and clean PySide6 interface
|
||||
- One-click downloads for video, audio, and subtitles
|
||||
- Advanced features like SponsorBlock, subtitle merging, and playlist selection
|
||||
- Optional Generic Mode for sites supported by yt-dlp beyond YouTube
|
||||
- Cross-platform support and easy installation
|
||||
|
||||
<a id="features"></a>
|
||||
## ✨ Features
|
||||
|
||||
<div align="center">
|
||||
|
||||
| Core Features | Advanced Features | Extra Features |
|
||||
|-----------------------------------|-----------------------------------------|------------------------------------|
|
||||
| 🎥 Format Table | 🚫 SponsorBlock Integration | 🎞️ FPS/HDR Display |
|
||||
| 🎵 Audio Extraction | 📝 Subtitle Selection & Merging | 🔄 Auto Update yt-dlp |
|
||||
| ✨ Simple UI | 💾 Save Description & Thumbnail | 🛠️ FFmpeg/yt-dlp/Deno Detection |
|
||||
| 📋 Playlist Support & Selector | 🚀 Speed Limiter | ⚙️ Custom Commands |
|
||||
| 📑 Chapter Integration | ✂️ Video Section Trimming | 🍪 Login with Cookies |
|
||||
| 📜 Download History | 🔄 Version Channel Selection | 🌐 Proxy Support |
|
||||
| 🎚️ Audio Format Conversion | 🎬 Video Format Settings | 🆙 Built-in Updater Tab |
|
||||
| 🌍 Generic Mode | 🔊 Audio Normalization (EBU R128) | 🌍 Localized in 14 Languages |
|
||||
| 💾 Playlist Export | ⚙️ Default Quality & Subtitles | |
|
||||
</div>
|
||||
|
||||
<a id="installation"></a>
|
||||
## 🚀 Installation
|
||||
|
||||
### ⚡ Quick Install (Recommended)
|
||||
|
||||
Install YTSage via PyPI:
|
||||
|
||||
```bash
|
||||
pip install ytsage
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>🔄 Update existing installation</summary>
|
||||
|
||||
```bash
|
||||
pip install --upgrade ytsage
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Then launch the application:
|
||||
|
||||
```bash
|
||||
ytsage
|
||||
```
|
||||
|
||||
### 📦 Pre-built Executables
|
||||
|
||||
> [👉 Download Latest Release](https://github.com/oop7/YTSage/releases/latest)
|
||||
|
||||
#### 🪟 Windows
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
|  | Standard Installer |
|
||||
|  | With FFmpeg Included |
|
||||
|  | Portable version, no installation needed |
|
||||
|  | Portable with FFmpeg, zipped |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Installation Steps</summary>
|
||||
|
||||
1. **EXE Installer (`.exe`)**: Double-click the file and follow the setup wizard.
|
||||
2. **Portable Version (`.zip`)**: Extract the archive to your desired location and launch `ytsage.exe`.
|
||||
3. **FFmpeg Included**: Choose versions with FFmpeg included if you don't have FFmpeg installed on your system.
|
||||
</details>
|
||||
|
||||
#### 🐧 Linux
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
|  | Debian Package |
|
||||
|  | AppImage, Portable |
|
||||
|  | RPM Package |
|
||||
|  | Flatpak Bundle |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Installation Steps</summary>
|
||||
|
||||
- **DEB (`.deb`)**:
|
||||
```bash
|
||||
sudo dpkg -i ytsage_*.deb
|
||||
sudo apt-get install -f # Fix missing dependencies if needed
|
||||
```
|
||||
- **RPM (`.rpm`)**:
|
||||
```bash
|
||||
sudo rpm -i ytsage-*.rpm
|
||||
```
|
||||
- **AppImage (`.AppImage`)**:
|
||||
```bash
|
||||
chmod +x YTSage-*.AppImage
|
||||
./YTSage-*.AppImage
|
||||
```
|
||||
- **Flatpak**: Follow instructions on Flathub or run:
|
||||
```bash
|
||||
flatpak install flathub io.github.oop7.ytsage
|
||||
```
|
||||
</details>
|
||||
|
||||
#### 🍎 macOS
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
|  | Zipped Application for Apple Silicon |
|
||||
|  | Disk Image Installer for Apple Silicon |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Installation Steps</summary>
|
||||
|
||||
- **DMG Installer (`.dmg`)**: Double-click to mount, then drag `YTSage.app` to your Applications folder.
|
||||
- **Application Archive (`.zip`)**: Extract the zip and move `YTSage.app` to your Applications folder.
|
||||
|
||||
*Note: If you encounter an "Application is damaged" error, see the macOS troubleshooting section below.*
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>💻 Manual Source Installation</summary>
|
||||
|
||||
### 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oop7/YTSage.git
|
||||
cd YTSage
|
||||
```
|
||||
|
||||
### 2. Install dependencies
|
||||
|
||||
#### ⚡ Using uv
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
#### 📦 Or using standard pip
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
### 3. Run the application
|
||||
|
||||
```bash
|
||||
python -m ytsage.main
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="screenshots"></a>
|
||||
## 📸 Screenshots
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="branding/screenshots/Download-Settings.png" alt="Download Settings" width="400"/></td>
|
||||
<td><img src="branding/screenshots/playlist.png" alt="Playlist Download" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Download Settings</em></td>
|
||||
<td align="center"><em>Playlist Download</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="branding/screenshots/audio_format.png" alt="Audio Format Selection" width="400"/></td>
|
||||
<td><img src="branding/screenshots/Custom-Option.png" alt="Custom Options" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Audio Format</em></td>
|
||||
<td align="center"><em>Custom Options</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a id="usage"></a>
|
||||
## 📖 Usage
|
||||
|
||||
<details>
|
||||
<summary>🎯 Basic Usage</summary>
|
||||
|
||||
1. **Launch YTSage**
|
||||
2. **Paste YouTube URL** (or use "Paste URL" button)
|
||||
3. **Click "Analyze"**
|
||||
4. **Select Format:**
|
||||
- `Video` for video downloads
|
||||
- `Audio Only` for audio extraction
|
||||
5. **Choose Options:**
|
||||
- Enable Subtitles and select language
|
||||
- Enable Subtitle Merging
|
||||
- Save Thumbnail
|
||||
- Remove Sponsored Segments
|
||||
- Save Description
|
||||
- Embed Chapters
|
||||
6. **Select Output Directory**
|
||||
7. **Click "Download"**
|
||||
|
||||
> 💡 Default download directory is the user's "Downloads" folder.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 Playlist Download</summary>
|
||||
|
||||
1. **Paste Playlist URL**
|
||||
2. **Click "Analyze"**
|
||||
3. **Select videos from the playlist selector (optional, defaults to all)**
|
||||
4. **Choose desired format/quality**
|
||||
5. **Click "Download"**
|
||||
|
||||
> 💡 The application automatically handles the download queue, and you can export playlist entries as `.txt`, `.csv`, `.m3u`, or `.json`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Generic Mode for Non-YouTube Sites</summary>
|
||||
|
||||
Use Generic Mode when you want YTSage to accept URLs from sites supported by yt-dlp, such as Dailymotion, CBC Gem, TikTok, and others.
|
||||
|
||||
How to use it:
|
||||
|
||||
1. Open `Download Settings`.
|
||||
2. Toggle on `Generic Mode`.
|
||||
3. Paste a supported video or playlist URL that is not from YouTube.
|
||||
4. Click `Analyze`.
|
||||
5. Choose a format and download as usual.
|
||||
|
||||
Notes:
|
||||
|
||||
- Generic mode only changes the URL validation inside YTSage. The target site must still be supported by your installed version of yt-dlp.
|
||||
- Some sites require cookies, login sessions, proxy, or extra yt-dlp arguments depending on the extractor.
|
||||
- If a site fails, update yt-dlp from the built-in updater tab first before reporting an issue.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🧰 Media & Download Options</summary>
|
||||
|
||||
- **Subtitle Options:** Filter languages and embed subtitles into the video file.
|
||||
- **Subtitle Merging:** Merge subtitles into the video file for hardcoded/burned-in subtitles.
|
||||
- **Save Description:** Save the video description as a text file.
|
||||
- **Save Thumbnail:** Save the video thumbnail as an image file.
|
||||
- **Embed Chapters:** Embed chapter markers as metadata for compatible video players.
|
||||
- **Remove Sponsored Segments:** Remove sponsored segments from the video using SponsorBlock.
|
||||
- **Trim Video:** Download only specific parts of a video by specifying time ranges in `HH:MM:SS` format.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⚙️ Output & File Settings</summary>
|
||||
|
||||
- **Speed Limiter:** Limit download speed, e.g., `500K` for 500 KB/s.
|
||||
- **Save Download Path:** Saves the default download path for future downloads. Available in **Download Settings → Download Path**.
|
||||
- **Default Video Resolution:** Set your preferred default video resolution for auto-selection (e.g., 1080p, 720p). Available in **Download Settings → Default Video Resolution**.
|
||||
- **Default Subtitle Languages:** Set default subtitle languages for auto-selection (comma-separated, e.g., `en,es`). Available in **Download Settings → Default Subtitle Languages**.
|
||||
- **Output Filename Format:** Customize the output filename format using variables like `%(title)s`, `%(uploader)s`, `%(playlist_index)s`, and `%(resolution)s`. Available in **Download Settings → Filename Format**.
|
||||
- **Force Output Format:** Force video downloads into a specific container format like `mp4`, `webm`, or `mkv`. Available in **Download Settings → Output Format Settings**.
|
||||
- **Audio Format Conversion:** Convert audio-only downloads into preferred formats such as `AAC`, `MP3`, `FLAC`, `WAV`, `Opus`, `M4A`, `Vorbis`, or `Best`. Available in **Download Settings → Audio Format Settings**.
|
||||
- **Audio Normalization:** Standardize volume for audio-only downloads using EBU R128.
|
||||
- **Concurrent Connections:** Dramatically increase download speed by downloading files in multiple fragments simultaneously. Available in **Download Settings → General → Concurrent Connections** (Default is 1, maximum recommended is 8-10 to avoid IP throttling).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌐 Access & Network</summary>
|
||||
|
||||
- **Login with Cookies:** Log in to YouTube using cookies to access private content.
|
||||
How to use it:
|
||||
1. **Recommended:** Use the built-in `Extract cookies from browser` option in the app, then select your browser and optionally a profile.
|
||||
2. Alternatively, extract cookies manually:
|
||||
a. Export browser cookies using an extension like [cookie-editor](https://github.com/moustachauve/cookie-editor?tab=readme-ov-file)
|
||||
b. Copy cookies in Netscape format
|
||||
c. Create a file named `cookies.txt` and paste cookies
|
||||
d. Select the `cookies.txt` file in the app
|
||||
- **Proxy Support:** Use a proxy server for downloads, e.g., `http://<proxy-server>:<port>`
|
||||
- **Generic Mode:** Allows YTSage to analyze and download from non-YouTube sites supported by yt-dlp. Enable from **Download Settings → Generic Mode**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Tools & Maintenance</summary>
|
||||
|
||||
- **Custom Commands:** Access advanced yt-dlp features via command-line arguments.
|
||||
- **Updater Tab:** Manage built-in update tools from one place in Custom Options:
|
||||
- **yt-dlp Updates:** Check for updates and toggle between Stable and Nightly release channels.
|
||||
- **FFmpeg Version Checker:** Check your FFmpeg version and open installation guides.
|
||||
- **Deno Updates:** Check and update the Deno runtime.
|
||||
- **FFmpeg/yt-dlp/Deno Detection:** Automatically detects paths and versions for FFmpeg, yt-dlp, and Deno from the About dialog.
|
||||
- **Download History:** View past downloads with thumbnails and statuses from the **History** button.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Localization</summary>
|
||||
|
||||
YTSage supports **14 languages** for global accessibility. Select your preferred language in **Custom Options → Language**.
|
||||
|
||||
### Supported Languages
|
||||
|
||||
| Language | Code | Language | Code |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 English | `en` | 🇪🇸 Spanish | `es` |
|
||||
| 🇸🇦 Arabic | `ar` | 🇫🇷 French | `fr` |
|
||||
| 🇩🇪 German | `de` | 🇮🇳 Hindi | `hi` |
|
||||
| 🇮🇩 Indonesian | `id` | 🇮🇹 Italian | `it` |
|
||||
| 🇯🇵 Japanese | `ja` | 🇵🇱 Polish | `pl` |
|
||||
| 🇧🇷 Portuguese | `pt` | 🇷🇺 Russian | `ru` |
|
||||
| 🇹🇷 Turkish | `tr` | 🇨🇳 Chinese | `zh` |
|
||||
|
||||
### README Translations
|
||||
|
||||
| Language | File | Language | File |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 English | [README.md](README.md) | 🇪🇸 Spanish | [readme-translations/README.es.md](readme-translations/README.es.md) |
|
||||
| 🇸🇦 Arabic | [readme-translations/README.ar.md](readme-translations/README.ar.md) | 🇫🇷 French | [readme-translations/README.fr.md](readme-translations/README.fr.md) |
|
||||
| 🇩🇪 German | [readme-translations/README.de.md](readme-translations/README.de.md) | 🇮🇳 Hindi | [readme-translations/README.hi.md](readme-translations/README.hi.md) |
|
||||
| 🇮🇩 Indonesian | [readme-translations/README.id.md](readme-translations/README.id.md) | 🇮🇹 Italian | [readme-translations/README.it.md](readme-translations/README.it.md) |
|
||||
| 🇯🇵 Japanese | [readme-translations/README.ja.md](readme-translations/README.ja.md) | 🇵🇱 Polish | [readme-translations/README.pl.md](readme-translations/README.pl.md) |
|
||||
| 🇧🇷 Portuguese | [readme-translations/README.pt.md](readme-translations/README.pt.md) | 🇷🇺 Russian | [readme-translations/README.ru.md](readme-translations/README.ru.md) |
|
||||
| 🇹🇷 Turkish | [readme-translations/README.tr.md](readme-translations/README.tr.md) | 🇨🇳 Chinese | [readme-translations/README.zh.md](readme-translations/README.zh.md) |
|
||||
|
||||
> 💡 **Want to contribute a translation?** Check out the [Contributing](#contributing) section to help us add more languages!
|
||||
|
||||
</details>
|
||||
|
||||
<a id="troubleshooting"></a>
|
||||
## 🛠️ Troubleshooting
|
||||
|
||||
<details>
|
||||
<summary>Click to view common issues and solutions</summary>
|
||||
|
||||
- **Format table not appearing:** Update yt-dlp to latest version and switch to nightly yt-dlp.
|
||||
- **Download failed:** Check your internet connection and ensure the video is available.
|
||||
- **Specific Download Errors:**
|
||||
- **Private Videos:** Use cookie authentication to access private content.
|
||||
- **Age-Restricted Content:** Log in to your YouTube account to view age-restricted videos.
|
||||
- **Geo-Blocked Videos:** Consider using a VPN to bypass regional restrictions.
|
||||
- **Deleted Videos:** Video is no longer available on YouTube.
|
||||
- **Live Streams:** Live streams cannot be downloaded; wait for the broadcast to end.
|
||||
- **Network Errors:** Check your internet connection and try again.
|
||||
- **Invalid URLs:** Ensure the URL is correct and from a supported platform.
|
||||
- **Premium Content:** Requires a YouTube Premium subscription.
|
||||
- **Copyright Blocks:** Content is blocked due to copyright restrictions.
|
||||
- **Video and Audio Files separate after download:** This happens when FFmpeg is missing or not detected. YTSage requires FFmpeg to merge high-quality video and audio streams.
|
||||
- **Solution:** Ensure FFmpeg is installed and accessible in your system's PATH. For Windows users, the easiest option is to download the `YTSage-v<version>-ffmpeg.exe` file, which comes bundled with FFmpeg.
|
||||
|
||||
---
|
||||
|
||||
#### 🛡️ Windows Defender / Antivirus Warning
|
||||
|
||||
Some antivirus software may flag `.exe` files as false positives. This is a **known limitation** of packaged applications.
|
||||
|
||||
**Why this happens:**
|
||||
- Antivirus heuristics can mistakenly identify packaged executables as suspicious.
|
||||
|
||||
**Safe Alternatives:**
|
||||
- ✅ **Use pip install:** `pip install ytsage` (Recommended)
|
||||
- ✅ **Build from Source**: by following this [guide](.github/CI_CD_README.md)
|
||||
- ✅ **Whitelist the app** in your antivirus software.
|
||||
|
||||
#### 🍎 macOS: "Application is damaged and cannot be opened"
|
||||
If you see this error on macOS Sonoma or newer, you need to remove the quarantine attribute.
|
||||
|
||||
1. **Open Terminal** (you can find this using Spotlight).
|
||||
2. **Type the following command** but **do not** press Enter yet. Make sure to include the space at the end:
|
||||
```bash
|
||||
xattr -d com.apple.quarantine
|
||||
```
|
||||
3. **Drag the `YTSage.app` file** from your Finder window and drop it directly into the Terminal window. This will automatically paste the correct file path.
|
||||
4. **Press Enter** to run the command.
|
||||
5. **Try opening YTSage.app again.** It should now launch correctly.
|
||||
|
||||
---
|
||||
|
||||
#### **Config Locations (Advanced)**
|
||||
- **Windows:** `%LOCALAPPDATA%\YTSage`
|
||||
- **macOS:** `~/Library/Application Support/YTSage`
|
||||
- **Linux:** `~/.local/share/YTSage`
|
||||
|
||||
</details>
|
||||
|
||||
<a id="sponsor"></a>
|
||||
## 💖 Sponsor
|
||||
|
||||
If YTSage saves you time, please consider sponsoring the project. Sponsoring helps cover development time, testing across all platforms, and future improvements.
|
||||
|
||||
- GitHub Sponsors: https://github.com/sponsors/oop7
|
||||
- Sponsorship link is also available directly in the app via the About dialog.
|
||||
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
<a id="contributing"></a>
|
||||
## 👥 Contributing
|
||||
|
||||
We welcome contributions! Here’s how you can help:
|
||||
|
||||
1. 🍴 Fork the repository
|
||||
2. 🌿 Create your feature branch:
|
||||
```bash
|
||||
git checkout -b feature/AmazingFeature
|
||||
```
|
||||
3. 💾 Commit your changes:
|
||||
```bash
|
||||
git commit -m 'Add some AmazingFeature'
|
||||
```
|
||||
4. 📤 Push to the branch:
|
||||
```bash
|
||||
git push origin feature/AmazingFeature
|
||||
```
|
||||
5. 🔄 Open a Pull Request
|
||||
|
||||
### 🌍 Contributing Translations
|
||||
|
||||
- Update the relevant localized README file (e.g., `readme-translations/README.fr.md`)
|
||||
- Keep app strings synced by editing `ytsage/languages/<code>.json`
|
||||
- If your language is missing, start from `README.md` and create `readme-translations/README.<code>.md`
|
||||
|
||||
<details>
|
||||
<summary>📂 Project Structure</summary>
|
||||
|
||||
## YTSage - Project Structure
|
||||
|
||||
This document describes the organized folder structure of YTSage.
|
||||
|
||||
### 📁 Project Structure
|
||||
|
||||
```
|
||||
YTSage/
|
||||
├── 📁 .github/ # GitHub configuration
|
||||
│ ├── 📁 ISSUE_TEMPLATE/ # Issue templates
|
||||
│ │ └── 🐛-bug-report.md # Bug report template
|
||||
│ ├─── 📁 workflows/ # GitHub Actions workflows
|
||||
│ │ ├── build-linux.yml # Linux build workflow
|
||||
│ │ ├── build-macos.yml # macOS build workflow
|
||||
│ │ │── build-windows.yml # Windows build workflow
|
||||
| | └── release-all.yml # Release master workflow
|
||||
│ └── 📄 CI_CD_README.md # CI/CD documentation
|
||||
├── 📁 branding/ # Branding assets (Screenshots, SVGs)
|
||||
│ ├── 📁 icons/ # App icons
|
||||
│ ├── 📁 screenshots/ # Documentation screenshots
|
||||
│ └── 📁 svg/ # SVG assets
|
||||
├── 📄 LICENSE # License file
|
||||
├── 📄 pyproject.toml # Project metadata and dependencies
|
||||
├── 📄 README.md # Project documentation
|
||||
├── 📄 requirements.txt # Python dependencies (dev)
|
||||
└── 📁 ytsage/ # Source package
|
||||
├── 📁 assets/ # Runtime assets
|
||||
│ ├── 📁 Icon/ # App icons
|
||||
│ └── 📁 sound/ # Sound files
|
||||
├── 📁 languages/ # Localization files
|
||||
│ ├── 📄 ar.json # Arabic translation
|
||||
│ ├── 📄 de.json # German translation
|
||||
│ ├── 📄 en.json # English translation
|
||||
│ └── ... # Other languages
|
||||
├── 📁 core/ # Core business logic
|
||||
│ ├── 📄 __init__.py # Core package init
|
||||
│ ├── 📄 ytsage_deno.py # Deno integration
|
||||
│ ├── 📄 ytsage_downloader.py # Download functionality
|
||||
│ ├── 📄 ytsage_ffmpeg.py # FFmpeg integration
|
||||
│ ├── 📄 ytsage_utils.py # Utility functions
|
||||
│ └── 📄 ytsage_yt_dlp.py # yt-dlp integration
|
||||
├── 📁 gui/ # UI components
|
||||
│ ├── 📄 __init__.py # GUI package init
|
||||
│ ├── 📄 ytsage_gui_main.py # Main app window
|
||||
│ └── 📁 ytsage_gui_dialogs/ # Dialog classes
|
||||
├── 📁 utils/ # Utility modules
|
||||
│ ├── 📄 __init__.py # Utils package init
|
||||
│ ├── 📄 ytsage_config_manager.py # Config management
|
||||
│ └── 📄 ytsage_logger.py # Logging utilities
|
||||
├── 📄 __init__.py # Package entry point
|
||||
└── 📄 main.py # Main execution script
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ⭐️ Star History
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#oop7/YTSage&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
## 📜 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
<details>
|
||||
<summary>Show Acknowledgments</summary>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>A big thanks to everyone who contributed to this project by opening an issue to suggest an improvement or report a bug.</p>
|
||||
|
||||
<table>
|
||||
<tr class="section"><th colspan="2">Core Components</th></tr>
|
||||
<tr>
|
||||
<td width="35%"><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
||||
<td>Download Engine</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://ffmpeg.org/">FFmpeg</a></td>
|
||||
<td>Media Processing</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://deno.com/">Deno</a></td>
|
||||
<td>Runtime for yt-dlp plugins</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Libraries & Frameworks</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://wiki.qt.io/Qt_for_Python">PySide6</a></td>
|
||||
<td>GUI Framework</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-pillow.org/">Pillow</a></td>
|
||||
<td>Image Processing</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://requests.readthedocs.io/">requests</a></td>
|
||||
<td>HTTP Requests</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://packaging.python.org/">packaging</a></td>
|
||||
<td>Version/Package Management</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-markdown.github.io/">markdown</a></td>
|
||||
<td>Markdown Rendering</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/Delgan/loguru">loguru</a></td>
|
||||
<td>Logging</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Assets & Contributors</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://pixabay.com/sound-effects/new-notification-09-352705/">New Notification 09 by Universfield</a></td>
|
||||
<td>Notification Sound</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/viru185">viru185</a></td>
|
||||
<td>Code Contributor</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
This tool is for personal use only. Please respect YouTube's Terms of Service and content creator rights.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
Made with ❤️ by [oop7](https://github.com/oop7)
|
||||
|
||||
</div>
|
||||
+11
-7
@@ -3,11 +3,12 @@ requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ytsage"
|
||||
version = "5.2.0"
|
||||
description = "Modern YouTube downloader with a clean PySide6 interface."
|
||||
name = "sagetube"
|
||||
version = "5.5.2"
|
||||
description = "Watch-first YouTube client with embedded mpv playback and yt-dlp downloading. Fork of YTSage."
|
||||
authors = [
|
||||
{ name = "oop7", email = "oop7_support@proton.me" },
|
||||
{ name = "Houmeres", email = "admin@ecoposta.sk" },
|
||||
{ name = "oop7 (YTSage upstream)", email = "oop7_support@proton.me" },
|
||||
]
|
||||
dependencies = [
|
||||
"PySide6>=6.10.1",
|
||||
@@ -17,6 +18,7 @@ dependencies = [
|
||||
"markdown>=3.10",
|
||||
"loguru>=0.7.3",
|
||||
"setuptools>=80.9.0",
|
||||
"python-mpv>=1.0.7",
|
||||
]
|
||||
requires-python = ">=3.10,<3.15"
|
||||
readme = "README.md"
|
||||
@@ -47,12 +49,13 @@ keywords = ["youtube", "downloader", "video", "audio", "PySide6", "yt-dlp", "GUI
|
||||
|
||||
|
||||
[project.scripts]
|
||||
sagetube = "ytsage.main:main"
|
||||
ytsage = "ytsage.main:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/oop7/YTSage"
|
||||
Bug-Tracker = "https://github.com/oop7/YTSage/issues"
|
||||
Reddit = "https://www.reddit.com/r/NO-N_A_M_E/"
|
||||
Homepage = "https://git.houmeres.sk/Houmeres/SageTube"
|
||||
Bug-Tracker = "https://git.houmeres.sk/Houmeres/SageTube/issues"
|
||||
Upstream = "https://github.com/oop7/YTSage"
|
||||
|
||||
[tool.setuptools]
|
||||
include-package-data = true
|
||||
@@ -63,6 +66,7 @@ include = ["ytsage*"]
|
||||
[tool.setuptools.package-data]
|
||||
ytsage = [
|
||||
"assets/Icon/icon.png",
|
||||
"assets/Icon/icon-256.png",
|
||||
"assets/sound/notification.mp3",
|
||||
"languages/*.json",
|
||||
]
|
||||
+10
-4
@@ -1,8 +1,14 @@
|
||||
"""
|
||||
YTSage - YouTube Video Downloader
|
||||
SageTube - watch-first YouTube client
|
||||
|
||||
A modern, user-friendly YouTube video downloader built with PySide6.
|
||||
Search, browse, subscribe and stream in an embedded mpv player, with the full
|
||||
yt-dlp download feature set inherited from YTSage. Built with PySide6.
|
||||
|
||||
The version below is the single source of truth for the running application:
|
||||
the window title, the About dialog and the update checker all read it. It must
|
||||
match `version` in pyproject.toml and the repository's latest signed tag --
|
||||
this package's tag line continues YTSage's, which is why it starts at 5.x.
|
||||
"""
|
||||
|
||||
__version__ = "5.2.0"
|
||||
__author__ = "oop7"
|
||||
__version__ = "5.5.2"
|
||||
__author__ = "Houmeres"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -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())
|
||||
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
YtdlpClient - shared yt-dlp invocation layer
|
||||
============================================
|
||||
|
||||
Single place that knows how to build and run yt-dlp commands for metadata
|
||||
purposes (analysis, search, channel/playlist browsing, subscription feeds).
|
||||
Every caller previously built its own command list; new features should go
|
||||
through this client so cookies, proxies, timeouts and process-group cleanup
|
||||
behave identically everywhere.
|
||||
|
||||
Downloads keep using DownloadThread (streaming progress parsing); this client
|
||||
is for JSON-returning invocations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from .ytsage_yt_dlp import get_yt_dlp_path
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
from ..utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
# Flat-entry results are cached briefly so tab switches and pagination don't
|
||||
# hammer YouTube with identical requests
|
||||
_CACHE_TTL_SECONDS = 600
|
||||
_cache: Dict[Tuple[str, ...], Tuple[float, Any]] = {}
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
class YtdlpNotInstalledError(FileNotFoundError):
|
||||
"""Raised when neither the managed binary nor an opted-in system yt-dlp exists."""
|
||||
|
||||
|
||||
def run_ytdlp_capture(cmd: List[str], 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 on 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)
|
||||
|
||||
|
||||
class YtdlpClient:
|
||||
"""Builds and runs JSON-returning yt-dlp commands with the app's
|
||||
cookie/proxy configuration applied."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cookie_file_path: Optional[str] = None,
|
||||
browser_cookies: Optional[str] = None,
|
||||
use_config_auth: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
cookie_file_path / browser_cookies: explicit overrides for session
|
||||
state that differs from the persisted config.
|
||||
use_config_auth: when True (default) and no override is given,
|
||||
cookie and proxy options are read from ConfigManager.
|
||||
"""
|
||||
self._cookie_file_override = cookie_file_path
|
||||
self._browser_cookies_override = browser_cookies
|
||||
self._use_config_auth = use_config_auth
|
||||
|
||||
# ------------------------------------------------------------- commands
|
||||
|
||||
def resolve_binary(self) -> str:
|
||||
path = get_yt_dlp_path()
|
||||
if str(path) == "yt-dlp":
|
||||
raise YtdlpNotInstalledError("yt-dlp is not installed - run the yt-dlp setup first")
|
||||
return str(path)
|
||||
|
||||
def _auth_args(self) -> List[str]:
|
||||
args: List[str] = []
|
||||
cookie_file = self._cookie_file_override
|
||||
browser_cookies = self._browser_cookies_override
|
||||
|
||||
if cookie_file is None and browser_cookies is None and self._use_config_auth:
|
||||
if ConfigManager.get("cookie_active"):
|
||||
if ConfigManager.get("cookie_source") == "file":
|
||||
saved = ConfigManager.get("cookie_file_path")
|
||||
if saved and Path(saved).exists():
|
||||
cookie_file = str(saved)
|
||||
else:
|
||||
browser = ConfigManager.get("cookie_browser")
|
||||
profile = ConfigManager.get("cookie_browser_profile")
|
||||
if browser:
|
||||
browser_cookies = f"{browser}:{profile}" if profile else browser
|
||||
|
||||
if cookie_file:
|
||||
args.extend(["--cookies", str(cookie_file)])
|
||||
elif browser_cookies:
|
||||
args.extend(["--cookies-from-browser", browser_cookies])
|
||||
|
||||
if self._use_config_auth:
|
||||
proxy = ConfigManager.get("proxy_url")
|
||||
geo_proxy = ConfigManager.get("geo_proxy_url")
|
||||
if proxy:
|
||||
args.extend(["--proxy", str(proxy)])
|
||||
if geo_proxy:
|
||||
args.extend(["--geo-verification-proxy", str(geo_proxy)])
|
||||
return args
|
||||
|
||||
def build_base_cmd(self) -> List[str]:
|
||||
return [self.resolve_binary(), "--no-warnings", "--no-color"] + self._auth_args()
|
||||
|
||||
def run_json(self, extra_args: List[str], timeout: int = 60) -> Any:
|
||||
"""Run yt-dlp with --dump-single-json semantics and parse stdout."""
|
||||
cmd = self.build_base_cmd() + extra_args
|
||||
logger.debug(f"YtdlpClient executing: {cmd}")
|
||||
result = run_ytdlp_capture(cmd, timeout=timeout)
|
||||
if result.returncode != 0:
|
||||
stderr_tail = (result.stderr or "").strip()[-1000:]
|
||||
raise RuntimeError(stderr_tail or f"yt-dlp exited with code {result.returncode}")
|
||||
return json.loads(result.stdout)
|
||||
|
||||
# ------------------------------------------------------------- queries
|
||||
|
||||
def fetch_video_info(self, url: str, timeout: int = 300) -> Dict[str, Any]:
|
||||
"""Full info dict for a single video, including formats[].url."""
|
||||
return self.run_json(["--dump-single-json", url], timeout=timeout)
|
||||
|
||||
def fetch_flat_info(self, url: str, timeout: int = 300, items: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Flat info dict (playlist/channel entries without per-video formats).
|
||||
|
||||
items: optional -I range like "1:1" to limit entries when only the
|
||||
top-level metadata (channel title/id) is needed.
|
||||
"""
|
||||
args = ["--dump-single-json", "--flat-playlist"]
|
||||
if items:
|
||||
args.extend(["-I", items])
|
||||
args.append(url)
|
||||
return self.run_json(args, timeout=timeout)
|
||||
|
||||
def fetch_flat_entries(
|
||||
self, url: str, start: int = 1, end: int = 30, timeout: int = 120, use_cache: bool = True
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Flat entries start..end (1-based, inclusive) of a playlist/channel URL."""
|
||||
cache_key = ("flat", url, str(start), str(end), *self._auth_args())
|
||||
if use_cache:
|
||||
cached = _cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
info = self.run_json(
|
||||
["--dump-single-json", "--flat-playlist", "-I", f"{start}:{end}", url],
|
||||
timeout=timeout,
|
||||
)
|
||||
entries = [e for e in info.get("entries", []) if e] if isinstance(info, dict) else []
|
||||
_cache_put(cache_key, entries)
|
||||
return entries
|
||||
|
||||
def search(
|
||||
self, query: str, n: int = 25, offset: int = 0, timeout: int = 120, use_cache: bool = True
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""YouTube search returning flat entries n results at a time."""
|
||||
cache_key = ("search", query, str(n), str(offset), *self._auth_args())
|
||||
if use_cache:
|
||||
cached = _cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
total = offset + n
|
||||
info = self.run_json(
|
||||
[
|
||||
"--dump-single-json",
|
||||
"--flat-playlist",
|
||||
"-I",
|
||||
f"{offset + 1}:{total}",
|
||||
f"ytsearch{total}:{query}",
|
||||
],
|
||||
timeout=timeout,
|
||||
)
|
||||
entries = [e for e in info.get("entries", []) if e] if isinstance(info, dict) else []
|
||||
_cache_put(cache_key, entries)
|
||||
return entries
|
||||
|
||||
def fetch_account_feed(self, n: int = 50, timeout: int = 180) -> List[Dict[str, Any]]:
|
||||
"""The logged-in account's subscription feed. Requires active cookies."""
|
||||
return self.fetch_flat_entries(
|
||||
"https://www.youtube.com/feed/subscriptions", start=1, end=n,
|
||||
timeout=timeout, use_cache=False,
|
||||
)
|
||||
|
||||
|
||||
class YtdlpWorker(QThread):
|
||||
"""Generic worker running one YtdlpClient call off the GUI thread.
|
||||
|
||||
Usage:
|
||||
worker = YtdlpWorker(lambda c: c.search("query", 25))
|
||||
worker.result.connect(...); worker.error.connect(...)
|
||||
worker.start()
|
||||
"""
|
||||
|
||||
result = Signal(object)
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, fn: Callable[[YtdlpClient], Any], client: Optional[YtdlpClient] = None, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._fn = fn
|
||||
self._client = client or YtdlpClient()
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.result.emit(self._fn(self._client))
|
||||
except Exception as e:
|
||||
logger.exception(f"YtdlpWorker failed: {e}")
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ cache
|
||||
|
||||
def _cache_get(key: Tuple[str, ...]) -> Optional[Any]:
|
||||
with _cache_lock:
|
||||
hit = _cache.get(key)
|
||||
if hit and time.time() - hit[0] < _CACHE_TTL_SECONDS:
|
||||
return hit[1]
|
||||
if hit:
|
||||
del _cache[key]
|
||||
return None
|
||||
|
||||
|
||||
def _cache_put(key: Tuple[str, ...], value: Any) -> None:
|
||||
with _cache_lock:
|
||||
if len(_cache) > 256:
|
||||
_cache.clear()
|
||||
_cache[key] = (time.time(), value)
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
with _cache_lock:
|
||||
_cache.clear()
|
||||
@@ -112,18 +112,25 @@ class DownloadThread(QThread):
|
||||
self.last_file_path: Optional[str] = None # Initialize full file path storage
|
||||
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.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:
|
||||
"""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:
|
||||
pattern = re.compile(r"\.f\d+\.") # Pattern to match format codes like .f243.
|
||||
for file_path in self.path.iterdir():
|
||||
if file_path.suffix == ".part" or pattern.search(file_path.name):
|
||||
for dest in self.download_files:
|
||||
candidates = [dest.with_name(dest.name + ".part"), dest.with_name(dest.name + ".ytdl")]
|
||||
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:
|
||||
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:
|
||||
"""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")
|
||||
|
||||
# --- 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] = {
|
||||
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:
|
||||
deleted_count[1] += safe_delete(path=subtitle_file)
|
||||
@@ -232,8 +246,12 @@ class DownloadThread(QThread):
|
||||
def _build_yt_dlp_command(self) -> List[str]:
|
||||
"""Build the yt-dlp command line with all options for direct execution."""
|
||||
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
|
||||
cmd: List[str] = [yt_dlp_path]
|
||||
cmd: List[str] = [str(yt_dlp_path)]
|
||||
logger.debug(f"Using yt-dlp from: {yt_dlp_path}")
|
||||
|
||||
# Add concurrent fragments setting
|
||||
@@ -277,6 +295,7 @@ class DownloadThread(QThread):
|
||||
logger.debug(f"Using progressive format with bundled audio: {clean_format_id}")
|
||||
else:
|
||||
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")
|
||||
else:
|
||||
# If no specific format ID, use resolution-based sorting (-S)
|
||||
@@ -619,6 +638,9 @@ class DownloadThread(QThread):
|
||||
filepath = dest_match.group(1).strip()
|
||||
self.current_filename = Path(filepath).name
|
||||
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
|
||||
|
||||
# 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()
|
||||
self.current_filename = Path(dest_path).name
|
||||
self.last_file_path = dest_path
|
||||
self.download_files.add(Path(dest_path))
|
||||
logger.debug(f"Captured destination filename: {self.current_filename}")
|
||||
elif "Downloading API JSON" in line:
|
||||
self.status_signal.emit(_("download.processing_playlist"))
|
||||
@@ -737,6 +760,11 @@ class DownloadThread(QThread):
|
||||
if percent_match:
|
||||
try:
|
||||
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)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
@@ -771,6 +799,7 @@ class DownloadThread(QThread):
|
||||
merged_filepath = merger_match.group(1).strip()
|
||||
self.current_filename = Path(merged_filepath).name
|
||||
self.last_file_path = merged_filepath
|
||||
self.download_files.add(Path(merged_filepath))
|
||||
logger.debug(f"Updated to merged filename: {self.current_filename}")
|
||||
elif "SponsorBlock" in line:
|
||||
self.status_signal.emit(_("download.removing_sponsor_segments"))
|
||||
@@ -824,9 +853,24 @@ class DownloadThread(QThread):
|
||||
|
||||
def pause(self) -> None:
|
||||
self.paused = True
|
||||
self._signal_process_group(signal.SIGSTOP if sys.platform != "win32" else None)
|
||||
|
||||
def resume(self) -> None:
|
||||
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:
|
||||
self.cancelled = True
|
||||
|
||||
@@ -91,7 +91,7 @@ def get_ffmpeg_install_path() -> Path:
|
||||
For Windows, tries to find the latest essentials build dynamically.
|
||||
"""
|
||||
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 ffmpeg_base.exists():
|
||||
@@ -205,7 +205,7 @@ def install_ffmpeg_windows(progress_callback=None) -> bool:
|
||||
|
||||
try:
|
||||
# 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
|
||||
extract_dir.mkdir(exist_ok=True)
|
||||
@@ -282,9 +282,14 @@ def install_ffmpeg_windows(progress_callback=None) -> bool:
|
||||
if progress_callback:
|
||||
progress_callback("🔐 Verifying download integrity...")
|
||||
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:
|
||||
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...")
|
||||
if progress_callback:
|
||||
@@ -337,29 +342,35 @@ def install_ffmpeg_windows(progress_callback=None) -> bool:
|
||||
if progress_callback:
|
||||
progress_callback("🔧 Configuring system paths...")
|
||||
|
||||
# Add to System Path
|
||||
user_path = os.environ.get("PATH", "")
|
||||
path_parts = user_path.split(os.pathsep)
|
||||
|
||||
# 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)
|
||||
|
||||
# Persist to the user PATH via the registry. setx must not be used here:
|
||||
# it truncates values at 1024 characters, and os.environ["PATH"] is the
|
||||
# merged system+user PATH, so writing it back would permanently duplicate
|
||||
# every system entry into the user hive.
|
||||
try:
|
||||
subprocess.run(
|
||||
["setx", "PATH", new_path],
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||
timeout=30,
|
||||
check=True,
|
||||
)
|
||||
os.environ["PATH"] = new_path
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_READ | winreg.KEY_WRITE) as key:
|
||||
try:
|
||||
stored_path, value_type = winreg.QueryValueEx(key, "Path")
|
||||
except FileNotFoundError:
|
||||
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:
|
||||
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
|
||||
if progress_callback:
|
||||
@@ -395,9 +406,14 @@ def install_ffmpeg_macos() -> bool:
|
||||
timeout=5,
|
||||
)
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
logger.info("Installing Homebrew...")
|
||||
brew_install_cmd = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
||||
subprocess.run(brew_install_cmd, shell=True, check=True, timeout=300)
|
||||
# Never curl|bash a remote script without the user's say-so.
|
||||
# Homebrew installation is the user's decision to make in a
|
||||
# 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
|
||||
logger.info("Installing FFmpeg...")
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
libmpv availability probe
|
||||
=========================
|
||||
|
||||
python-mpv is a ctypes binding: importing it raises OSError when the libmpv
|
||||
shared library is missing from the system. All player code must import mpv
|
||||
through probe_player() so the rest of the app (search, browse, downloads)
|
||||
keeps working without libmpv installed.
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from ..utils.ytsage_constants import OS_NAME
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
_probe_result: Optional[Tuple[bool, str]] = None
|
||||
|
||||
|
||||
def probe_player() -> Tuple[bool, str]:
|
||||
"""Return (available, hint). hint explains how to install libmpv when absent."""
|
||||
global _probe_result
|
||||
if _probe_result is not None:
|
||||
return _probe_result
|
||||
|
||||
try:
|
||||
import mpv # noqa: F401
|
||||
|
||||
_probe_result = (True, "")
|
||||
except (ImportError, OSError, AttributeError) as e:
|
||||
logger.warning(f"libmpv unavailable, watch features disabled: {e}")
|
||||
if OS_NAME == "Windows":
|
||||
hint = "Place libmpv-2.dll next to the application, or install mpv and add it to PATH."
|
||||
elif OS_NAME == "Darwin":
|
||||
hint = "Install mpv with Homebrew: brew install mpv"
|
||||
else:
|
||||
hint = "Install mpv with your package manager, e.g. sudo pacman -S mpv or sudo apt install libmpv2"
|
||||
_probe_result = (False, hint)
|
||||
return _probe_result
|
||||
+22
-11
@@ -72,8 +72,11 @@ def should_refresh_cache(tool_name: str, current_path: Optional[str]) -> bool:
|
||||
if not cache.get("version"):
|
||||
return True
|
||||
|
||||
# Refresh if path changed
|
||||
if cache.get("path") != current_path:
|
||||
# Refresh if path changed (cache stores str; callers may pass 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
|
||||
|
||||
# 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:
|
||||
"""Check for yt-dlp updates and update if a newer version is available."""
|
||||
try:
|
||||
# Get the yt-dlp path
|
||||
yt_dlp_path: Path = get_yt_dlp_path()
|
||||
# Get the yt-dlp path (may be the bare "yt-dlp" sentinel string when
|
||||
# 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
|
||||
|
||||
@@ -463,7 +467,7 @@ def update_yt_dlp() -> bool:
|
||||
|
||||
# Download the latest version
|
||||
try:
|
||||
response = requests.get(YTDLP_DOWNLOAD_URL, stream=True)
|
||||
response = requests.get(YTDLP_DOWNLOAD_URL, stream=True, timeout=60)
|
||||
if response.status_code == 200:
|
||||
# Create a temporary file
|
||||
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):
|
||||
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
|
||||
if OS_NAME != "Windows":
|
||||
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:
|
||||
# On Windows, we need to remove the old file first
|
||||
if OS_NAME == "Windows" and yt_dlp_path.exists():
|
||||
yt_dlp_path.unlink(missing_ok=True)
|
||||
|
||||
Path(temp_file).rename(yt_dlp_path)
|
||||
os.replace(temp_file, yt_dlp_path)
|
||||
logger.info("yt-dlp binary successfully updated")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception(f"Error replacing yt-dlp binary: {e}")
|
||||
Path(temp_file).unlink(missing_ok=True)
|
||||
return False
|
||||
else:
|
||||
logger.info(f"Failed to download latest yt-dlp: HTTP {response.status_code}")
|
||||
|
||||
@@ -112,6 +112,10 @@ class DownloadYtdlpThread(QThread):
|
||||
try:
|
||||
# Extra logic moved to src\utils\ytsage_constants.py
|
||||
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
|
||||
logger.info(f"Downloading yt-dlp from: {YTDLP_DOWNLOAD_URL}")
|
||||
@@ -123,7 +127,7 @@ class DownloadYtdlpThread(QThread):
|
||||
if total_size == 0:
|
||||
self.progress_signal.emit(100)
|
||||
|
||||
with open(exe_path, "wb") as f:
|
||||
with open(part_path, "wb") as f:
|
||||
downloaded = 0
|
||||
for data in response.iter_content(block_size):
|
||||
f.write(data)
|
||||
@@ -135,11 +139,10 @@ class DownloadYtdlpThread(QThread):
|
||||
logger.info("Download complete, verifying 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
|
||||
logger.error("SHA256 verification failed! Removing downloaded file.")
|
||||
if Path(exe_path).exists():
|
||||
Path(exe_path).unlink()
|
||||
part_path.unlink(missing_ok=True)
|
||||
self.finished_signal.emit(
|
||||
False,
|
||||
"SHA256 verification failed. The downloaded file may be corrupted or tampered with."
|
||||
@@ -148,7 +151,9 @@ class DownloadYtdlpThread(QThread):
|
||||
|
||||
# Make executable on macOS and Linux
|
||||
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!")
|
||||
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
|
||||
)
|
||||
|
||||
@@ -607,19 +612,30 @@ def check_ytdlp_installed() -> bool:
|
||||
|
||||
def get_yt_dlp_path() -> Path:
|
||||
"""
|
||||
Get the yt-dlp path, either from the app's bin directory or system PATH.
|
||||
This replaces the function in ytsage_utils.py.
|
||||
Get the yt-dlp path. Prefers the app-managed, SHA256-verified binary;
|
||||
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:
|
||||
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()
|
||||
if ytdlp_path:
|
||||
logger.info(f"Using yt-dlp from: {ytdlp_path}")
|
||||
return ytdlp_path
|
||||
|
||||
# If not found anywhere, fall back to the command name as a last resort
|
||||
logger.info("yt-dlp not found in app directory or PATH, falling back to command name")
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
|
||||
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]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
The YouTube account indicator
|
||||
=============================
|
||||
|
||||
SageTube can use browser cookies to reach a signed-in YouTube: the account
|
||||
feed, age-restricted and members-only videos, and premium formats. All of that
|
||||
worked -- but the only way to switch it on was Downloads tab -> Custom Options
|
||||
-> the "Login with Cookies" tab, and Downloads is one tab among five. The three
|
||||
features that need it (the Feed's account mode, Search, Browse) had no route to
|
||||
it at all, and `show_cookie_login_dialog()` existed with no caller anywhere in
|
||||
the codebase.
|
||||
|
||||
So this is a button in the tab bar's corner, visible from every tab, that says
|
||||
whether cookies are active and opens the existing dialog. Nothing about the
|
||||
cookie handling itself changes; it just became findable.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QPushButton, QWidget
|
||||
|
||||
from . import ytsage_icons as icons
|
||||
from . import ytsage_theme as theme
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
from ..utils.ytsage_localization import _
|
||||
|
||||
|
||||
class AccountStatusButton(QPushButton):
|
||||
"""Shows the cookie state and opens the login dialog when pressed."""
|
||||
|
||||
loginRequested = Signal()
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("accountStatusButton")
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
# Not a call to action -- it sits in the chrome, so it should not
|
||||
# compete with the accent-coloured buttons in the pages.
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
QPushButton#accountStatusButton {{
|
||||
background-color: transparent;
|
||||
color: {theme.TEXT_MUTED};
|
||||
border: 1px solid {theme.BORDER};
|
||||
border-radius: 4px;
|
||||
padding: 5px 12px;
|
||||
margin: 4px 8px 4px 4px;
|
||||
font-weight: normal;
|
||||
}}
|
||||
QPushButton#accountStatusButton:hover {{
|
||||
color: {theme.TEXT};
|
||||
background-color: {theme.SURFACE_HOVER};
|
||||
border-color: {theme.BORDER_STRONG};
|
||||
}}
|
||||
QPushButton#accountStatusButton:pressed {{
|
||||
background-color: {theme.BORDER};
|
||||
}}
|
||||
"""
|
||||
)
|
||||
self.clicked.connect(self.loginRequested)
|
||||
self.refresh()
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Re-read the cookie config and restate what it says."""
|
||||
active = bool(ConfigManager.get("cookie_active"))
|
||||
if not active:
|
||||
self.setIcon(icons.icon("user", theme.TEXT_MUTED))
|
||||
self.setText(_("account.signed_out"))
|
||||
self.setToolTip(_("account.signed_out_tooltip"))
|
||||
return
|
||||
|
||||
if (ConfigManager.get("cookie_source") or "browser") == "file":
|
||||
source = _("account.source_file")
|
||||
else:
|
||||
browser = ConfigManager.get("cookie_browser") or "?"
|
||||
profile = ConfigManager.get("cookie_browser_profile")
|
||||
source = f"{browser}:{profile}" if profile else str(browser)
|
||||
|
||||
self.setIcon(icons.icon("user-check", theme.TEXT))
|
||||
self.setText(_("account.signed_in", source=source))
|
||||
self.setToolTip(_("account.signed_in_tooltip", source=source))
|
||||
@@ -5,9 +5,9 @@ import subprocess
|
||||
from PySide6.QtCore import QMetaObject, Qt, Q_ARG, QThread, Signal, QTimer
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from ..core.ytsage_client import run_ytdlp_capture
|
||||
from ..core.ytsage_utils import validate_video_url, parse_yt_dlp_error
|
||||
from ..core.ytsage_yt_dlp import get_yt_dlp_path
|
||||
from ..utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
@@ -95,13 +95,20 @@ class AnalysisThread(QThread):
|
||||
if 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 via the shared client runner (process-group-safe)."""
|
||||
return run_ytdlp_capture(cmd, timeout=timeout)
|
||||
|
||||
def _analyze_url_with_subprocess(self, url: str) -> None:
|
||||
"""Analyze URL using yt-dlp executable."""
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
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.")
|
||||
self.analysis_error.emit(_("errors.ytdlp_not_found"))
|
||||
self.playlist_info_visible.emit(False)
|
||||
@@ -118,12 +125,10 @@ class AnalysisThread(QThread):
|
||||
logger.debug(f"Executing yt-dlp command: {cmd}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=300,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Analysis timed out")
|
||||
result = self._run_ytdlp(cmd, timeout=300)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
stderr_tail = (e.stderr or "")[-500:] if isinstance(e.stderr, str) else ""
|
||||
logger.error(f"Analysis timed out. Partial stderr: {stderr_tail}")
|
||||
self.analysis_error.emit(_("errors.timeout"))
|
||||
return
|
||||
|
||||
@@ -181,7 +186,8 @@ class AnalysisThread(QThread):
|
||||
if first_info.get("_type") == "playlist":
|
||||
result_data["is_playlist"] = True
|
||||
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
|
||||
|
||||
if not playlist_entries:
|
||||
@@ -201,15 +207,12 @@ class AnalysisThread(QThread):
|
||||
self._add_auth_options(cmd_single)
|
||||
|
||||
try:
|
||||
result_single = subprocess.run(
|
||||
cmd_single, capture_output=True, text=True, timeout=60,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS
|
||||
)
|
||||
result_single = self._run_ytdlp(cmd_single, timeout=60)
|
||||
if result_single.returncode == 0:
|
||||
result_data["video_info"] = json.loads(result_single.stdout)
|
||||
else:
|
||||
result_data["video_info"] = first_video_entry
|
||||
except subprocess.TimeoutExpired:
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError):
|
||||
result_data["video_info"] = first_video_entry
|
||||
|
||||
if self._cancelled:
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Browse tab - channel and playlist browsing
|
||||
==========================================
|
||||
|
||||
Paste (or route) a channel/playlist URL. Channels get Videos / Shorts / Live
|
||||
sub-tabs backed by {channel_url}/videos|shorts|streams flat fetches with
|
||||
-I range pagination; playlists get a single grid with Play all and a
|
||||
Download deep-link into the Downloads tab.
|
||||
|
||||
The Subscribe button emits subscribeRequested; the Feed page owns the
|
||||
subscription store and completes the wiring.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .ytsage_gui_cards import VideoCardGrid
|
||||
from ..core.ytsage_client import YtdlpWorker
|
||||
from ..utils.ytsage_library_manager import LibraryManager
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
PAGE_SIZE = 24
|
||||
|
||||
CHANNEL_URL_RE = re.compile(r"(youtube\.com/(@[\w.-]+|channel/|c/|user/))", re.IGNORECASE)
|
||||
PLAYLIST_URL_RE = re.compile(r"(youtube\.com/playlist\?|[?&]list=)", re.IGNORECASE)
|
||||
|
||||
CHANNEL_TABS = [
|
||||
("browse.tab_videos", "videos"),
|
||||
("browse.tab_shorts", "shorts"),
|
||||
("browse.tab_live", "streams"),
|
||||
]
|
||||
|
||||
|
||||
def _normalize_channel_base(url: str) -> str:
|
||||
"""Strip a trailing /videos|/shorts|/streams|/featured segment."""
|
||||
return re.sub(r"/(videos|shorts|streams|featured|playlists|community|about)/?(\?.*)?$", "", url.rstrip("/"))
|
||||
|
||||
|
||||
class _EntriesSection(QWidget):
|
||||
"""One grid fed by a flat-entries URL with pagination."""
|
||||
|
||||
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._router = router
|
||||
self._worker: Optional[YtdlpWorker] = None
|
||||
self._url: Optional[str] = None
|
||||
self._offset = 0
|
||||
self._loaded_once = False
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 4, 0, 0)
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet("color: #9aa0a6; padding: 2px;")
|
||||
layout.addWidget(self.status_label)
|
||||
self.grid = VideoCardGrid(router, self)
|
||||
self.grid.loadMoreRequested.connect(self._load_more)
|
||||
layout.addWidget(self.grid, stretch=1)
|
||||
|
||||
def set_url(self, url: Optional[str]) -> None:
|
||||
self._url = url
|
||||
self._offset = 0
|
||||
self._loaded_once = False
|
||||
self.grid.clear()
|
||||
self.status_label.setText("")
|
||||
|
||||
def ensure_loaded(self) -> None:
|
||||
if not self._loaded_once and self._url and self._worker is None:
|
||||
self._loaded_once = True
|
||||
self._fetch(append=False)
|
||||
|
||||
def _load_more(self) -> None:
|
||||
if self._worker is None and self._url:
|
||||
self._offset += PAGE_SIZE
|
||||
self._fetch(append=True)
|
||||
|
||||
def _fetch(self, append: bool) -> None:
|
||||
self.status_label.setText(_("browse.loading"))
|
||||
url, start, end = self._url, self._offset + 1, self._offset + PAGE_SIZE
|
||||
self._worker = YtdlpWorker(lambda c: c.fetch_flat_entries(url, start=start, end=end), parent=self)
|
||||
self._worker.result.connect(lambda entries: self._on_results(entries, append))
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.finished.connect(self._on_finished)
|
||||
self._worker.start()
|
||||
|
||||
def _on_results(self, entries: List[Dict[str, Any]], append: bool) -> None:
|
||||
show_more = len(entries) >= PAGE_SIZE
|
||||
if append:
|
||||
self.grid.append_entries(entries, show_load_more=show_more)
|
||||
else:
|
||||
self.grid.set_entries(entries, show_load_more=show_more)
|
||||
self.status_label.setText(_("browse.entry_count", count=self.grid.card_count()))
|
||||
|
||||
def _on_error(self, message: str) -> None:
|
||||
logger.error(f"Browse fetch failed: {message}")
|
||||
self.status_label.setText(_("browse.failed", error=message[:200]))
|
||||
|
||||
def _on_finished(self) -> None:
|
||||
if self._worker is not None:
|
||||
self._worker.deleteLater()
|
||||
self._worker = None
|
||||
|
||||
|
||||
class BrowsePage(QWidget):
|
||||
subscribeRequested = Signal(dict) # {channel_id?, title?, url}
|
||||
|
||||
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._router = router
|
||||
self._current_url: Optional[str] = None
|
||||
self._is_channel = False
|
||||
self._channel_meta: Dict[str, Any] = {}
|
||||
self._meta_worker: Optional[YtdlpWorker] = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
bar = QHBoxLayout()
|
||||
self.url_input = QLineEdit()
|
||||
self.url_input.setPlaceholderText(_("browse.placeholder"))
|
||||
self.url_input.returnPressed.connect(self._open_from_input)
|
||||
self.url_input.setMinimumHeight(38)
|
||||
bar.addWidget(self.url_input, stretch=1)
|
||||
self.open_btn = QPushButton(_("browse.open"))
|
||||
self.open_btn.setToolTip(_("browse.open_tooltip"))
|
||||
self.open_btn.setMinimumHeight(38)
|
||||
self.open_btn.clicked.connect(self._open_from_input)
|
||||
bar.addWidget(self.open_btn)
|
||||
layout.addLayout(bar)
|
||||
|
||||
header = QHBoxLayout()
|
||||
self.title_label = QLabel("")
|
||||
self.title_label.setStyleSheet("font-size: 16px; font-weight: bold;")
|
||||
header.addWidget(self.title_label, stretch=1)
|
||||
self.subscribe_btn = QPushButton(_("browse.subscribe"))
|
||||
self.subscribe_btn.setToolTip(_("browse.subscribe_tooltip"))
|
||||
self.subscribe_btn.setVisible(False)
|
||||
self.subscribe_btn.clicked.connect(self._on_subscribe_clicked)
|
||||
header.addWidget(self.subscribe_btn)
|
||||
self.playall_btn = QPushButton(_("browse.play_all"))
|
||||
self.playall_btn.setToolTip(_("browse.play_all_tooltip"))
|
||||
self.playall_btn.setVisible(False)
|
||||
self.playall_btn.clicked.connect(self._on_play_all)
|
||||
header.addWidget(self.playall_btn)
|
||||
self.download_btn = QPushButton(_("browse.download_playlist"))
|
||||
self.download_btn.setToolTip(_("browse.download_playlist_tooltip"))
|
||||
self.download_btn.setVisible(False)
|
||||
self.download_btn.clicked.connect(self._on_download_playlist)
|
||||
header.addWidget(self.download_btn)
|
||||
layout.addLayout(header)
|
||||
|
||||
self.channel_tabs = QTabWidget()
|
||||
self._sections: List[_EntriesSection] = []
|
||||
for label_key, _suffix in CHANNEL_TABS:
|
||||
section = _EntriesSection(router, self)
|
||||
self._sections.append(section)
|
||||
self.channel_tabs.addTab(section, _(label_key))
|
||||
self.channel_tabs.currentChanged.connect(self._on_channel_tab_changed)
|
||||
self.channel_tabs.setVisible(False)
|
||||
layout.addWidget(self.channel_tabs, stretch=1)
|
||||
|
||||
self.playlist_section = _EntriesSection(router, self)
|
||||
self.playlist_section.setVisible(False)
|
||||
layout.addWidget(self.playlist_section, stretch=1)
|
||||
|
||||
self.hint_label = QLabel(_("browse.hint"))
|
||||
self.hint_label.setStyleSheet("color: #9aa0a6; padding: 40px;")
|
||||
layout.addWidget(self.hint_label, stretch=1)
|
||||
|
||||
# ------------------------------------------------------------ public API
|
||||
|
||||
def open_url(self, url: str) -> None:
|
||||
url = url.strip()
|
||||
if not url:
|
||||
return
|
||||
self.url_input.setText(url)
|
||||
self._current_url = url
|
||||
self._is_channel = bool(CHANNEL_URL_RE.search(url)) and not PLAYLIST_URL_RE.search(url)
|
||||
self.hint_label.setVisible(False)
|
||||
self._channel_meta = {}
|
||||
self.title_label.setText(url)
|
||||
|
||||
if self._is_channel:
|
||||
base = _normalize_channel_base(url)
|
||||
self._current_url = base
|
||||
self.playlist_section.setVisible(False)
|
||||
self.channel_tabs.setVisible(True)
|
||||
self.subscribe_btn.setVisible(True)
|
||||
self.playall_btn.setVisible(False)
|
||||
self.download_btn.setVisible(False)
|
||||
for section, (_k, suffix) in zip(self._sections, CHANNEL_TABS):
|
||||
section.set_url(f"{base}/{suffix}")
|
||||
self._sections[self.channel_tabs.currentIndex()].ensure_loaded()
|
||||
self._fetch_channel_meta(base)
|
||||
else:
|
||||
self.channel_tabs.setVisible(False)
|
||||
self.subscribe_btn.setVisible(False)
|
||||
self.playlist_section.setVisible(True)
|
||||
self.playall_btn.setVisible(True)
|
||||
self.download_btn.setVisible(True)
|
||||
self.playlist_section.set_url(url)
|
||||
self.playlist_section.ensure_loaded()
|
||||
|
||||
# --------------------------------------------------------------- internal
|
||||
|
||||
def _open_from_input(self) -> None:
|
||||
self.open_url(self.url_input.text())
|
||||
|
||||
def _on_channel_tab_changed(self, index: int) -> None:
|
||||
if 0 <= index < len(self._sections):
|
||||
self._sections[index].ensure_loaded()
|
||||
|
||||
def _fetch_channel_meta(self, base_url: str) -> None:
|
||||
"""Fetch channel title/id from the videos tab head (cheap, 1 entry)."""
|
||||
if self._meta_worker is not None:
|
||||
return
|
||||
self._meta_worker = YtdlpWorker(lambda c: c.fetch_flat_info(f"{base_url}/videos", items="1:1"), parent=self)
|
||||
self._meta_worker.result.connect(self._on_channel_meta)
|
||||
self._meta_worker.error.connect(lambda m: logger.debug(f"Channel meta fetch failed: {m}"))
|
||||
self._meta_worker.finished.connect(self._on_meta_finished)
|
||||
self._meta_worker.start()
|
||||
|
||||
def _on_channel_meta(self, info: Dict[str, Any]) -> None:
|
||||
self._channel_meta = {
|
||||
"channel_id": info.get("channel_id") or info.get("uploader_id"),
|
||||
"title": info.get("channel") or info.get("uploader") or info.get("title"),
|
||||
"url": self._current_url,
|
||||
"avatar_url": None,
|
||||
}
|
||||
if self._channel_meta.get("title"):
|
||||
self.title_label.setText(self._channel_meta["title"])
|
||||
# Now that the real channel id is known, the button can say whether
|
||||
# this channel is already followed.
|
||||
self.refresh_subscribe_state()
|
||||
|
||||
def _on_meta_finished(self) -> None:
|
||||
if self._meta_worker is not None:
|
||||
self._meta_worker.deleteLater()
|
||||
self._meta_worker = None
|
||||
|
||||
def _on_subscribe_clicked(self) -> None:
|
||||
meta = dict(self._channel_meta) if self._channel_meta.get("url") else {"url": self._current_url}
|
||||
if not meta.get("title"):
|
||||
meta["title"] = self.title_label.text()
|
||||
self.subscribeRequested.emit(meta)
|
||||
# The handler toggles, so reflect the new state here. Previously the
|
||||
# button always read "Subscribe" whichever way it had just gone, and
|
||||
# the only feedback was a status line on a different tab.
|
||||
self.refresh_subscribe_state()
|
||||
|
||||
def refresh_subscribe_state(self) -> None:
|
||||
"""Make the button say what pressing it will now do."""
|
||||
channel_id = self._channel_meta.get("channel_id") or self._channel_meta.get("url") or self._current_url
|
||||
subscribed = bool(channel_id) and LibraryManager.is_subscribed(str(channel_id))
|
||||
self.subscribe_btn.setText(_("browse.unsubscribe") if subscribed else _("browse.subscribe"))
|
||||
self.subscribe_btn.setToolTip(
|
||||
_("browse.unsubscribe_tooltip") if subscribed else _("browse.subscribe_tooltip")
|
||||
)
|
||||
|
||||
def _on_play_all(self) -> None:
|
||||
cards = list(self.playlist_section.grid._cards)
|
||||
for entry in [c.entry for c in cards]:
|
||||
e = dict(entry)
|
||||
if not e.get("url") and e.get("id"):
|
||||
e["url"] = f"https://www.youtube.com/watch?v={e['id']}"
|
||||
self._router.queueVideo.emit(e)
|
||||
# "Play all" queues what has been loaded, which is one page unless the
|
||||
# user pressed Load more. Say so rather than implying the whole list.
|
||||
self.playlist_section.status_label.setText(_("browse.queued_loaded", count=len(cards)))
|
||||
|
||||
def _on_download_playlist(self) -> None:
|
||||
if self._current_url:
|
||||
self._router.downloadVideo.emit(self._current_url)
|
||||
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
Video cards and card grid
|
||||
=========================
|
||||
|
||||
VideoCard renders one yt-dlp flat entry (thumbnail, title, channel, duration)
|
||||
with Play / Queue / Download / Channel actions wired to the AppRouter.
|
||||
VideoCardGrid lays cards out in a responsive grid inside a scroll area with
|
||||
an optional "Load more" button for pagination.
|
||||
|
||||
Thumbnails are fetched off-thread (shared QThreadPool) and cached on disk in
|
||||
APP_THUMBNAILS_DIR keyed by video id, following the history dialog's pattern.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, Signal, QSize
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..utils.ytsage_constants import APP_THUMBNAILS_DIR
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
CARD_WIDTH = 300
|
||||
THUMB_SIZE = QSize(284, 160)
|
||||
|
||||
_thumb_pool = QThreadPool()
|
||||
_thumb_pool.setMaxThreadCount(6)
|
||||
|
||||
|
||||
def _entry_video_id(entry: Dict[str, Any]) -> str:
|
||||
vid = entry.get("id") or entry.get("url") or entry.get("title") or "unknown"
|
||||
return hashlib.sha1(str(vid).encode("utf-8")).hexdigest()[:20]
|
||||
|
||||
|
||||
def _entry_thumbnail_url(entry: Dict[str, Any]) -> Optional[str]:
|
||||
if entry.get("thumbnail"):
|
||||
return entry["thumbnail"]
|
||||
thumbs = entry.get("thumbnails") or []
|
||||
if thumbs:
|
||||
# flat entries carry a list sorted small->large; prefer a medium one
|
||||
mid = thumbs[len(thumbs) // 2]
|
||||
return mid.get("url")
|
||||
if entry.get("id") and entry.get("ie_key", "Youtube") == "Youtube":
|
||||
return f"https://i.ytimg.com/vi/{entry['id']}/mqdefault.jpg"
|
||||
return None
|
||||
|
||||
|
||||
def format_duration(seconds: Optional[float]) -> str:
|
||||
if not seconds:
|
||||
return ""
|
||||
seconds = int(seconds)
|
||||
h, rem = divmod(seconds, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
|
||||
|
||||
|
||||
class _ThumbSignals(QObject):
|
||||
loaded = Signal(str, bytes) # cache_key, data
|
||||
|
||||
|
||||
class _ThumbFetchTask(QRunnable):
|
||||
"""Fetch a thumbnail (disk cache first) off the GUI thread."""
|
||||
|
||||
def __init__(self, cache_key: str, url: str, signals: _ThumbSignals) -> None:
|
||||
super().__init__()
|
||||
self._cache_key = cache_key
|
||||
self._url = url
|
||||
self._signals = signals
|
||||
|
||||
def run(self) -> None:
|
||||
cache_file = APP_THUMBNAILS_DIR / f"{self._cache_key}.jpg"
|
||||
try:
|
||||
if cache_file.exists():
|
||||
self._signals.loaded.emit(self._cache_key, cache_file.read_bytes())
|
||||
return
|
||||
response = requests.get(self._url, timeout=10)
|
||||
if response.status_code == 200 and response.content:
|
||||
try:
|
||||
APP_THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_bytes(response.content)
|
||||
except OSError as e:
|
||||
logger.debug(f"Could not cache thumbnail: {e}")
|
||||
self._signals.loaded.emit(self._cache_key, response.content)
|
||||
except Exception as e:
|
||||
logger.debug(f"Thumbnail fetch failed for {self._url}: {e}")
|
||||
|
||||
|
||||
class VideoCard(QFrame):
|
||||
"""One video entry with hover actions."""
|
||||
|
||||
def __init__(self, entry: Dict[str, Any], router, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.entry = entry
|
||||
self._router = router
|
||||
self._cache_key = _entry_video_id(entry)
|
||||
|
||||
self.setFixedWidth(CARD_WIDTH)
|
||||
self.setObjectName("videoCard")
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QFrame#videoCard {
|
||||
background-color: #1b2021;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QFrame#videoCard:hover { background-color: #24292b; }
|
||||
"""
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
layout.setSpacing(6)
|
||||
|
||||
self.thumb_label = QLabel()
|
||||
self.thumb_label.setFixedSize(THUMB_SIZE)
|
||||
self.thumb_label.setStyleSheet("background-color: #101314; border-radius: 4px;")
|
||||
self.thumb_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(self.thumb_label)
|
||||
|
||||
duration_text = format_duration(entry.get("duration"))
|
||||
title_text = entry.get("title") or entry.get("url") or ""
|
||||
title_label = QLabel(title_text)
|
||||
title_label.setWordWrap(True)
|
||||
title_label.setStyleSheet("font-weight: bold;")
|
||||
title_label.setMaximumHeight(44)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
meta_parts = [p for p in [entry.get("channel") or entry.get("uploader"), duration_text] if p]
|
||||
meta_label = QLabel(" • ".join(meta_parts))
|
||||
meta_label.setStyleSheet("color: #9aa0a6; font-size: 11px;")
|
||||
layout.addWidget(meta_label)
|
||||
|
||||
actions = QHBoxLayout()
|
||||
actions.setSpacing(6)
|
||||
|
||||
self.play_btn = QPushButton(_("cards.play"))
|
||||
self.play_btn.setToolTip(_("cards.play_tooltip"))
|
||||
self.play_btn.clicked.connect(lambda: self._router.playVideo.emit(self._routed_entry()))
|
||||
actions.addWidget(self.play_btn)
|
||||
|
||||
self.queue_btn = QPushButton(_("cards.queue"))
|
||||
self.queue_btn.setToolTip(_("cards.queue_tooltip"))
|
||||
self.queue_btn.clicked.connect(lambda: self._router.queueVideo.emit(self._routed_entry()))
|
||||
actions.addWidget(self.queue_btn)
|
||||
|
||||
self.download_btn = QPushButton(_("cards.download"))
|
||||
self.download_btn.setToolTip(_("cards.download_tooltip"))
|
||||
self.download_btn.clicked.connect(self._emit_download)
|
||||
actions.addWidget(self.download_btn)
|
||||
|
||||
layout.addLayout(actions)
|
||||
|
||||
self._thumb_signals = _ThumbSignals()
|
||||
self._thumb_signals.loaded.connect(self._on_thumb_loaded)
|
||||
thumb_url = _entry_thumbnail_url(entry)
|
||||
if thumb_url:
|
||||
_thumb_pool.start(_ThumbFetchTask(self._cache_key, thumb_url, self._thumb_signals))
|
||||
|
||||
def _routed_entry(self) -> Dict[str, Any]:
|
||||
e = dict(self.entry)
|
||||
if not e.get("url") and e.get("id"):
|
||||
e["url"] = f"https://www.youtube.com/watch?v={e['id']}"
|
||||
return e
|
||||
|
||||
def _emit_download(self) -> None:
|
||||
e = self._routed_entry()
|
||||
if e.get("url"):
|
||||
self._router.downloadVideo.emit(e["url"])
|
||||
|
||||
def _on_thumb_loaded(self, cache_key: str, data: bytes) -> None:
|
||||
if cache_key != self._cache_key:
|
||||
return
|
||||
pixmap = QPixmap()
|
||||
if pixmap.loadFromData(data):
|
||||
self.thumb_label.setPixmap(
|
||||
pixmap.scaled(
|
||||
THUMB_SIZE,
|
||||
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
)
|
||||
|
||||
def mouseDoubleClickEvent(self, event) -> None:
|
||||
self._router.playVideo.emit(self._routed_entry())
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
|
||||
class VideoCardGrid(QScrollArea):
|
||||
"""Responsive grid of VideoCards with optional Load more pagination."""
|
||||
|
||||
loadMoreRequested = Signal()
|
||||
|
||||
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._router = router
|
||||
self._cards: List[VideoCard] = []
|
||||
#: video id -> card, so merge_entries can tell new from existing
|
||||
self._by_key: Dict[str, "VideoCard"] = {}
|
||||
self._columns_in_use = 0
|
||||
|
||||
self.setWidgetResizable(True)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
|
||||
self._container = QWidget()
|
||||
self._container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
outer = QVBoxLayout(self._container)
|
||||
outer.setContentsMargins(4, 4, 4, 4)
|
||||
|
||||
self._grid_widget = QWidget()
|
||||
self._grid = QGridLayout(self._grid_widget)
|
||||
self._grid.setSpacing(10)
|
||||
self._grid.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
|
||||
outer.addWidget(self._grid_widget)
|
||||
|
||||
self.load_more_btn = QPushButton(_("cards.load_more"))
|
||||
self.load_more_btn.setToolTip(_("cards.load_more_tooltip"))
|
||||
self.load_more_btn.clicked.connect(self.loadMoreRequested.emit)
|
||||
self.load_more_btn.setVisible(False)
|
||||
outer.addWidget(self.load_more_btn, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.empty_label = QLabel(_("cards.empty"))
|
||||
self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.empty_label.setStyleSheet("color: #9aa0a6; padding: 40px;")
|
||||
outer.addWidget(self.empty_label)
|
||||
|
||||
outer.addStretch()
|
||||
self.setWidget(self._container)
|
||||
|
||||
# ---------------------------------------------------------------- data
|
||||
|
||||
def set_entries(self, entries: List[Dict[str, Any]], show_load_more: bool = False) -> None:
|
||||
self.clear()
|
||||
self.append_entries(entries, show_load_more=show_load_more)
|
||||
|
||||
def append_entries(self, entries: List[Dict[str, Any]], show_load_more: bool = False) -> None:
|
||||
for entry in entries:
|
||||
card = VideoCard(entry, self._router, self._grid_widget)
|
||||
self._cards.append(card)
|
||||
self._by_key[self._key(entry)] = card
|
||||
self._relayout()
|
||||
self.load_more_btn.setVisible(show_load_more)
|
||||
self.empty_label.setVisible(not self._cards)
|
||||
|
||||
@staticmethod
|
||||
def _key(entry: Dict[str, Any]) -> str:
|
||||
return str(entry.get("id") or entry.get("url") or id(entry))
|
||||
|
||||
def merge_entries(self, entries: List[Dict[str, Any]], prune: bool = False) -> None:
|
||||
"""
|
||||
Fold entries in, keeping cards that are already here.
|
||||
|
||||
set_entries() destroys and rebuilds every card, which during a feed
|
||||
refresh happened once per channel: the grid flickered, the scroll
|
||||
position was lost each time, and every thumbnail was re-read from
|
||||
disk. Existing cards are left alone here, so only genuinely new
|
||||
videos cost anything.
|
||||
"""
|
||||
incoming = {self._key(e): e for e in entries}
|
||||
scroll = self.verticalScrollBar().value()
|
||||
self.setUpdatesEnabled(False)
|
||||
try:
|
||||
for key, entry in incoming.items():
|
||||
if key in self._by_key:
|
||||
continue
|
||||
card = VideoCard(entry, self._router, self._grid_widget)
|
||||
self._cards.append(card)
|
||||
self._by_key[key] = card
|
||||
|
||||
if prune:
|
||||
for key in [k for k in self._by_key if k not in incoming]:
|
||||
card = self._by_key.pop(key)
|
||||
if card in self._cards:
|
||||
self._cards.remove(card)
|
||||
self._grid.removeWidget(card)
|
||||
card.deleteLater()
|
||||
|
||||
self._relayout()
|
||||
self.empty_label.setVisible(not self._cards)
|
||||
finally:
|
||||
self.setUpdatesEnabled(True)
|
||||
self.verticalScrollBar().setValue(scroll)
|
||||
|
||||
def clear(self) -> None:
|
||||
for card in self._cards:
|
||||
self._grid.removeWidget(card)
|
||||
card.deleteLater()
|
||||
self._cards = []
|
||||
self._by_key.clear()
|
||||
self.empty_label.setVisible(True)
|
||||
self.load_more_btn.setVisible(False)
|
||||
|
||||
def card_count(self) -> int:
|
||||
return len(self._cards)
|
||||
|
||||
# -------------------------------------------------------------- layout
|
||||
|
||||
def _columns(self) -> int:
|
||||
available = max(1, self.viewport().width() - 20)
|
||||
return max(1, available // (CARD_WIDTH + 10))
|
||||
|
||||
def _relayout(self) -> None:
|
||||
cols = self._columns()
|
||||
# Drain first. addWidget on a card the layout already owns adds a
|
||||
# second item for it, so the layout grew an extra entry per card on
|
||||
# every append. takeAt detaches without deleting the widget.
|
||||
while self._grid.count():
|
||||
self._grid.takeAt(0)
|
||||
for i, card in enumerate(self._cards):
|
||||
self._grid.addWidget(card, i // cols, i % cols)
|
||||
self._columns_in_use = cols
|
||||
|
||||
def resizeEvent(self, event) -> None:
|
||||
super().resizeEvent(event)
|
||||
# Compared against what was actually laid out, not columnCount():
|
||||
# QGridLayout never shrinks its column count, so that comparison
|
||||
# stayed true forever and relaid out on every resize event.
|
||||
if self._cards and self._columns() != self._columns_in_use:
|
||||
self._relayout()
|
||||
@@ -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)
|
||||
@@ -226,24 +226,27 @@ class AboutDialog(QDialog):
|
||||
info_layout = QHBoxLayout()
|
||||
info_layout.setSpacing(15)
|
||||
|
||||
author_link = '<a href="https://github.com/oop7/" style="color: #c90000; text-decoration: none;">oop7</a>'
|
||||
author_link = '<a href="https://git.houmeres.sk/Houmeres" style="color: #c90000; text-decoration: none;">Houmeres</a>'
|
||||
author_label = QLabel(
|
||||
f"{_('about.author', author=author_link)}"
|
||||
)
|
||||
author_label.setOpenExternalLinks(True)
|
||||
info_layout.addWidget(author_label)
|
||||
|
||||
repo_link = '<a href="https://github.com/oop7/YTSage/" style="color: #c90000; text-decoration: none;">YTSage</a>'
|
||||
repo_link = '<a href="https://git.houmeres.sk/Houmeres/SageTube" style="color: #c90000; text-decoration: none;">SageTube</a>'
|
||||
repo_label = QLabel(
|
||||
f"{_('about.github', repo=repo_link)}"
|
||||
)
|
||||
repo_label.setOpenExternalLinks(True)
|
||||
info_layout.addWidget(repo_label)
|
||||
|
||||
sponsor_link = '<a href="https://github.com/sponsors/oop7" style="color: #c90000; text-decoration: none;">❤️ Sponsor</a>'
|
||||
sponsor_label = QLabel(sponsor_link)
|
||||
sponsor_label.setOpenExternalLinks(True)
|
||||
info_layout.addWidget(sponsor_label)
|
||||
upstream_link = (
|
||||
'<a href="https://github.com/oop7/YTSage" style="color: #c90000; text-decoration: none;">'
|
||||
"Based on YTSage by oop7</a>"
|
||||
)
|
||||
upstream_label = QLabel(upstream_link)
|
||||
upstream_label.setOpenExternalLinks(True)
|
||||
info_layout.addWidget(upstream_label)
|
||||
|
||||
# Center the info layout
|
||||
info_container = QHBoxLayout()
|
||||
|
||||
@@ -3,6 +3,8 @@ Custom functionality dialogs for YTSage application.
|
||||
Contains dialogs for custom commands, cookies, time ranges, and other special features.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
@@ -32,7 +34,7 @@ from PySide6.QtWidgets import (
|
||||
from ..ytsage_smooth_tab_widget import SmoothTabWidget
|
||||
from ...core.ytsage_yt_dlp import get_yt_dlp_path
|
||||
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_localization import LocalizationManager, _
|
||||
from ...utils.ytsage_logger import logger
|
||||
@@ -55,16 +57,28 @@ class CommandWorker(QObject):
|
||||
self.command = command
|
||||
self.url = url
|
||||
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):
|
||||
"""Run the yt-dlp command and emit signals for output"""
|
||||
try:
|
||||
# Split command into arguments
|
||||
args = self.command.split()
|
||||
# Split command into arguments; posix=False on Windows so quoted
|
||||
# backslash paths like "C:\Users\..." survive intact
|
||||
args = shlex.split(self.command, posix=(os.name != "nt"))
|
||||
|
||||
# Build the full command
|
||||
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
|
||||
if self.path:
|
||||
@@ -78,17 +92,22 @@ class CommandWorker(QObject):
|
||||
self.output_received.emit("=" * 50)
|
||||
|
||||
# Run the command
|
||||
proc = subprocess.Popen(
|
||||
self._proc = subprocess.Popen(
|
||||
base_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||
)
|
||||
proc = self._proc
|
||||
|
||||
# Stream output
|
||||
with proc.stdout: # type: ignore[union-attr]
|
||||
for line in proc.stdout: # type: ignore[reportOptionalIterable]
|
||||
if self._cancelled:
|
||||
break
|
||||
if line.strip(): # Only show non-empty lines
|
||||
self.output_received.emit(line.rstrip())
|
||||
|
||||
|
||||
@@ -985,5 +985,5 @@ class AutoUpdateSettingsDialog(QDialog):
|
||||
msg_box.exec()
|
||||
except Exception as 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()
|
||||
|
||||
@@ -201,6 +201,10 @@ class DenoUpdateThread(QThread):
|
||||
class UpdaterTabWidget(QWidget):
|
||||
"""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:
|
||||
super().__init__(parent)
|
||||
self._parent: "CustomOptionsDialog" = cast("CustomOptionsDialog", self.parent())
|
||||
@@ -212,6 +216,7 @@ class UpdaterTabWidget(QWidget):
|
||||
|
||||
self._init_ui()
|
||||
self._load_auto_update_settings()
|
||||
self._channel_switch_finished.connect(self._on_channel_switch_finished)
|
||||
|
||||
def _init_ui(self) -> None:
|
||||
"""Initialize the UI components."""
|
||||
@@ -683,9 +688,11 @@ class UpdaterTabWidget(QWidget):
|
||||
beta_enabled = ConfigManager.get("check_beta_updates") or False
|
||||
self.beta_updates_checkbox.setChecked(beta_enabled)
|
||||
|
||||
# Load app update checker setting (default enabled for older configs)
|
||||
# Load app update checker setting. `is not False` would have read a
|
||||
# missing key as enabled, and since this dialog persists the value
|
||||
# unconditionally on OK, merely opening it re-enabled the checker.
|
||||
app_updates_enabled = ConfigManager.get("check_app_updates")
|
||||
self.app_updates_checkbox.setChecked(app_updates_enabled is not False)
|
||||
self.app_updates_checkbox.setChecked(bool(app_updates_enabled))
|
||||
|
||||
# Set current selection based on saved settings
|
||||
current_frequency = auto_settings["frequency"]
|
||||
@@ -816,67 +823,53 @@ class UpdaterTabWidget(QWidget):
|
||||
ConfigManager.set("ytdlp_channel", new_channel)
|
||||
logger.info(f"Successfully switched to {new_channel} channel")
|
||||
|
||||
# Update UI
|
||||
# Make executable on Unix systems
|
||||
if OS_NAME != "Windows":
|
||||
import os
|
||||
os.chmod(yt_dlp_path, 0o755)
|
||||
|
||||
self._channel_switch_finished.emit(True, new_channel, current_channel, "")
|
||||
else:
|
||||
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}")
|
||||
self._channel_switch_finished.emit(False, new_channel, current_channel, error_msg)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Channel switch timed out")
|
||||
self._channel_switch_finished.emit(False, new_channel, current_channel, "Timeout")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error switching channel: {e}")
|
||||
self._channel_switch_finished.emit(False, new_channel, current_channel, str(e))
|
||||
|
||||
# Start the thread
|
||||
thread = threading.Thread(target=switch_channel, daemon=True)
|
||||
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;"
|
||||
)
|
||||
|
||||
# Make executable on Unix systems
|
||||
if OS_NAME != "Windows":
|
||||
import os
|
||||
os.chmod(yt_dlp_path, 0o755)
|
||||
else:
|
||||
# Failed - revert radio button
|
||||
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}")
|
||||
|
||||
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:
|
||||
logger.error("Channel switch timed out")
|
||||
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error="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:
|
||||
logger.exception(f"Error switching channel: {e}")
|
||||
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error=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
|
||||
thread = threading.Thread(target=switch_channel, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _update_channel_status(self, channel: str) -> None:
|
||||
"""Update the channel status label."""
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
"""
|
||||
Feed tab - subscriptions and their video feed
|
||||
=============================================
|
||||
|
||||
Two modes (config feed.mode):
|
||||
- local: aggregate the most recent uploads of every locally-subscribed
|
||||
channel (no account needed). Channels refresh sequentially in one worker
|
||||
thread; the grid fills incrementally as each channel lands.
|
||||
- account: fetch youtube.com/feed/subscriptions with the user's cookies -
|
||||
the real logged-in feed. Enabled only while cookies are active.
|
||||
|
||||
Subscriptions are stored in LibraryManager (sagetube_library.db); the
|
||||
Browse page's Subscribe button routes here via the main window.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import QThread, QTimer, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .ytsage_gui_cards import VideoCardGrid
|
||||
from ..core.ytsage_client import YtdlpClient, YtdlpWorker
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
from ..utils.ytsage_library_manager import LibraryManager
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
|
||||
#: Only refresh a channel automatically if it is at least this stale.
|
||||
AUTO_REFRESH_DEFAULT_MINUTES = 30
|
||||
#: Channels touched per automatic refresh. A manual Refresh still does all of
|
||||
#: them; this only bounds what opening the tab can set off.
|
||||
AUTO_REFRESH_BATCH = 8
|
||||
#: Let the tab transition finish before spawning subprocesses.
|
||||
AUTO_REFRESH_DELAY_MS = 1500
|
||||
#: The automatic path gives up sooner than a manual refresh: nobody is
|
||||
#: watching it, and a stuck channel must not block the rest.
|
||||
AUTO_REFRESH_TIMEOUT = 45
|
||||
|
||||
|
||||
class FeedRefreshWorker(QThread):
|
||||
"""Sequentially refresh each subscription's recent uploads."""
|
||||
|
||||
channelDone = Signal(str) # channel_id
|
||||
channelFailed = Signal(str, str) # channel_id, error
|
||||
allDone = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
subscriptions: List[Dict[str, Any]],
|
||||
per_channel: int,
|
||||
parent=None,
|
||||
timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._subs = subscriptions
|
||||
self._per_channel = per_channel
|
||||
self._timeout = timeout
|
||||
self._cancelled = False
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancelled = True
|
||||
|
||||
def run(self) -> None:
|
||||
client = YtdlpClient()
|
||||
for sub in self._subs:
|
||||
if self._cancelled:
|
||||
break
|
||||
try:
|
||||
kwargs = {}
|
||||
if self._timeout is not None:
|
||||
kwargs["timeout"] = self._timeout
|
||||
entries = client.fetch_flat_entries(
|
||||
f"{sub['url'].rstrip('/')}/videos",
|
||||
start=1,
|
||||
end=self._per_channel,
|
||||
use_cache=False,
|
||||
**kwargs,
|
||||
)
|
||||
LibraryManager.upsert_feed_items(sub["channel_id"], entries)
|
||||
LibraryManager.mark_refreshed(sub["channel_id"])
|
||||
self.channelDone.emit(sub["channel_id"])
|
||||
except Exception as e:
|
||||
logger.warning(f"Feed refresh failed for {sub.get('title')}: {e}")
|
||||
self.channelFailed.emit(sub["channel_id"], str(e))
|
||||
self.allDone.emit()
|
||||
|
||||
|
||||
class FeedPage(QWidget):
|
||||
#: Emitted after subscribe/unsubscribe so other pages can re-read state.
|
||||
subscriptionsChanged = Signal()
|
||||
|
||||
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._router = router
|
||||
self._refresh_worker: Optional[FeedRefreshWorker] = None
|
||||
self._account_worker: Optional[YtdlpWorker] = None
|
||||
#: monotonic timestamp of the last automatic refresh this session
|
||||
self._last_auto_refresh: float = 0.0
|
||||
self._done_count = 0
|
||||
self._failed_count = 0
|
||||
self._total_count = 0
|
||||
self._last_error = ""
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
bar = QHBoxLayout()
|
||||
self.mode_combo = QComboBox()
|
||||
self.mode_combo.setToolTip(_("feed.mode_tooltip"))
|
||||
self.mode_combo.addItem(_("feed.mode_local"), "local")
|
||||
self.mode_combo.addItem(_("feed.mode_account"), "account")
|
||||
self.mode_combo.setCurrentIndex(0 if (ConfigManager.get("feed.mode") or "local") == "local" else 1)
|
||||
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
|
||||
bar.addWidget(self.mode_combo)
|
||||
|
||||
self.refresh_btn = QPushButton(_("feed.refresh"))
|
||||
self.refresh_btn.setToolTip(_("feed.refresh_tooltip"))
|
||||
self.refresh_btn.setProperty("sageIcon", "refresh")
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
bar.addWidget(self.refresh_btn)
|
||||
|
||||
# Account mode is disabled without cookies. Rather than leaving that
|
||||
# as a dead end, offer the way out right beside it.
|
||||
self.signin_btn = QPushButton(_("feed.sign_in"))
|
||||
self.signin_btn.setToolTip(_("feed.sign_in_tooltip"))
|
||||
self.signin_btn.setProperty("sageIcon", "user")
|
||||
self.signin_btn.clicked.connect(self._router.cookieSetupRequested)
|
||||
bar.addWidget(self.signin_btn)
|
||||
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet("color: #9aa0a6;")
|
||||
bar.addWidget(self.status_label, stretch=1)
|
||||
layout.addLayout(bar)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
||||
layout.addWidget(splitter, stretch=1)
|
||||
|
||||
# Kept as an attribute: account mode does not use local subscriptions,
|
||||
# so the panel is hidden there rather than sitting empty beside a full
|
||||
# feed and implying something is missing.
|
||||
self.subs_panel = side = QWidget()
|
||||
side_layout = QVBoxLayout(side)
|
||||
side_layout.setContentsMargins(0, 0, 4, 0)
|
||||
subs_label = QLabel(_("feed.subscriptions"))
|
||||
subs_label.setStyleSheet("font-weight: bold;")
|
||||
side_layout.addWidget(subs_label)
|
||||
self.subs_list = QListWidget()
|
||||
self.subs_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.subs_list.customContextMenuRequested.connect(self._on_subs_context_menu)
|
||||
self.subs_list.itemDoubleClicked.connect(self._on_sub_activated)
|
||||
side_layout.addWidget(self.subs_list)
|
||||
splitter.addWidget(side)
|
||||
|
||||
grid_host = QWidget()
|
||||
grid_layout = QVBoxLayout(grid_host)
|
||||
grid_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.grid = VideoCardGrid(router, grid_host)
|
||||
grid_layout.addWidget(self.grid, stretch=1)
|
||||
|
||||
# Feed is the first tab, so an empty feed is the first thing a new
|
||||
# install shows. Say what to do about it.
|
||||
self.empty_hint = QLabel("")
|
||||
self.empty_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.empty_hint.setWordWrap(True)
|
||||
self.empty_hint.setStyleSheet("color: #9aa0a6; padding: 24px; font-size: 13px;")
|
||||
self.empty_hint.hide()
|
||||
grid_layout.addWidget(self.empty_hint, stretch=1)
|
||||
# The grid carries its own bare "Nothing here yet"; two empty states
|
||||
# stacked on one page is one too many.
|
||||
self.grid.empty_label.hide()
|
||||
|
||||
splitter.addWidget(grid_host)
|
||||
splitter.setStretchFactor(0, 1)
|
||||
splitter.setStretchFactor(1, 4)
|
||||
splitter.setSizes([220, 900])
|
||||
|
||||
self.reload_subscriptions()
|
||||
self._load_cached_feed()
|
||||
self._update_mode_availability()
|
||||
|
||||
# ------------------------------------------------------------ public API
|
||||
|
||||
def subscribe_channel(self, meta: Dict[str, Any]) -> None:
|
||||
"""Wired to BrowsePage.subscribeRequested via the main window."""
|
||||
channel_id = meta.get("channel_id") or meta.get("url")
|
||||
if not channel_id or not meta.get("url"):
|
||||
logger.warning(f"Cannot subscribe, missing channel info: {meta}")
|
||||
return
|
||||
if LibraryManager.is_subscribed(str(channel_id)):
|
||||
LibraryManager.unsubscribe(str(channel_id))
|
||||
self.status_label.setText(_("feed.unsubscribed", title=meta.get("title") or channel_id))
|
||||
else:
|
||||
LibraryManager.subscribe(str(channel_id), meta.get("title") or str(channel_id), meta["url"], meta.get("avatar_url"))
|
||||
self.status_label.setText(_("feed.subscribed", title=meta.get("title") or channel_id))
|
||||
self.reload_subscriptions()
|
||||
self._update_empty_state()
|
||||
self.subscriptionsChanged.emit()
|
||||
|
||||
def reload_subscriptions(self) -> None:
|
||||
self.subs_list.clear()
|
||||
for sub in LibraryManager.subscriptions():
|
||||
item = QListWidgetItem(sub["title"])
|
||||
item.setData(Qt.ItemDataRole.UserRole, sub)
|
||||
self.subs_list.addItem(item)
|
||||
|
||||
def refresh(self) -> None:
|
||||
mode = self.mode_combo.currentData()
|
||||
ConfigManager.set("feed.mode", mode)
|
||||
if mode == "account":
|
||||
self._refresh_account()
|
||||
else:
|
||||
self._refresh_local()
|
||||
|
||||
# --------------------------------------------------- refresh on opening
|
||||
|
||||
def on_tab_activated(self) -> None:
|
||||
"""
|
||||
Called by the main window when the Feed tab becomes visible.
|
||||
|
||||
The cached grid is already on screen -- it is loaded in the
|
||||
constructor and kept up to date -- so this only decides whether to go
|
||||
and fetch. A refresh is one yt-dlp subprocess per subscribed channel,
|
||||
so it is braked four ways: only channels that are actually stale, at
|
||||
most one automatic run per interval per session, a batch cap, and a
|
||||
short delay so it does not race the tab transition or first-run setup.
|
||||
"""
|
||||
if self._refresh_worker is not None or self._account_worker is not None:
|
||||
return
|
||||
if (self.mode_combo.currentData() or "local") != "local":
|
||||
return
|
||||
|
||||
minutes = ConfigManager.get("feed.auto_refresh_on_open_minutes")
|
||||
minutes = AUTO_REFRESH_DEFAULT_MINUTES if minutes is None else int(minutes)
|
||||
if minutes <= 0:
|
||||
return # opted out
|
||||
|
||||
now = time.monotonic()
|
||||
if self._last_auto_refresh and (now - self._last_auto_refresh) < minutes * 60:
|
||||
return
|
||||
|
||||
cutoff = time.time() - minutes * 60
|
||||
stale = [s for s in LibraryManager.subscriptions() if not s.get("last_refreshed") or s["last_refreshed"] < cutoff]
|
||||
if not stale:
|
||||
return
|
||||
|
||||
self._last_auto_refresh = now
|
||||
batch = stale[:AUTO_REFRESH_BATCH]
|
||||
if len(stale) > len(batch):
|
||||
logger.info(f"Feed auto-refresh: {len(batch)} of {len(stale)} stale channels this time.")
|
||||
QTimer.singleShot(AUTO_REFRESH_DELAY_MS, lambda: self._refresh_local(batch, auto=True))
|
||||
|
||||
# --------------------------------------------------------------- local
|
||||
|
||||
def _refresh_local(self, subs: Optional[List[Dict[str, Any]]] = None, auto: bool = False) -> None:
|
||||
if self._refresh_worker is not None:
|
||||
return
|
||||
if subs is None:
|
||||
subs = LibraryManager.subscriptions()
|
||||
if not subs:
|
||||
if not auto:
|
||||
self.status_label.setText(_("feed.no_subscriptions"))
|
||||
return
|
||||
per_channel = int(ConfigManager.get("feed.per_channel_items") or 15)
|
||||
self.refresh_btn.setEnabled(False)
|
||||
self.status_label.setText(_("feed.refreshing", done=0, total=len(subs)))
|
||||
self._done_count = 0
|
||||
self._failed_count = 0
|
||||
self._last_error = ""
|
||||
self._total_count = len(subs)
|
||||
self._refresh_worker = FeedRefreshWorker(
|
||||
subs,
|
||||
per_channel,
|
||||
parent=self,
|
||||
timeout=AUTO_REFRESH_TIMEOUT if auto else None,
|
||||
)
|
||||
self._refresh_worker.channelDone.connect(self._on_channel_done)
|
||||
self._refresh_worker.channelFailed.connect(self._on_channel_failed)
|
||||
self._refresh_worker.allDone.connect(self._on_refresh_done)
|
||||
self._refresh_worker.start()
|
||||
|
||||
def _on_channel_done(self, channel_id: str) -> None:
|
||||
self._done_count += 1
|
||||
self.status_label.setText(_("feed.refreshing", done=self._done_count, total=self._total_count))
|
||||
# Merge only this channel's rows. Re-reading the whole feed here
|
||||
# rebuilt every card after every channel: visible flicker, the scroll
|
||||
# position lost each time, and thumbnails re-read from disk.
|
||||
self._merge_channel(channel_id)
|
||||
|
||||
def _on_channel_failed(self, channel_id: str, error: str) -> None:
|
||||
self._done_count += 1
|
||||
self._failed_count += 1
|
||||
# Do not write the error into the status line here: the next
|
||||
# channelDone overwrites it immediately, so failures were invisible.
|
||||
# It is reported once at the end instead.
|
||||
self._last_error = error
|
||||
|
||||
def _on_refresh_done(self) -> None:
|
||||
self.refresh_btn.setEnabled(True)
|
||||
if self._failed_count:
|
||||
self.status_label.setText(
|
||||
_("feed.refreshed_with_failures", count=self.grid.card_count(), failed=self._failed_count)
|
||||
)
|
||||
logger.warning(f"Feed refresh: {self._failed_count} channel(s) failed; last error: {self._last_error[:200]}")
|
||||
else:
|
||||
self.status_label.setText(_("feed.refreshed", count=self.grid.card_count()))
|
||||
self._update_empty_state()
|
||||
if self._refresh_worker is not None:
|
||||
self._refresh_worker.deleteLater()
|
||||
self._refresh_worker = None
|
||||
|
||||
@staticmethod
|
||||
def _to_entries(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": it["video_id"],
|
||||
"url": it["url"],
|
||||
"title": it["title"],
|
||||
"channel": it.get("channel"),
|
||||
"duration": it["duration"],
|
||||
"thumbnail": it["thumbnail_url"],
|
||||
}
|
||||
for it in items
|
||||
]
|
||||
|
||||
def _load_cached_feed(self) -> None:
|
||||
self.grid.set_entries(self._to_entries(LibraryManager.feed_items()))
|
||||
self._update_empty_state()
|
||||
|
||||
def _merge_channel(self, channel_id: str) -> None:
|
||||
"""Fold one channel's fresh rows into the grid without rebuilding it."""
|
||||
try:
|
||||
items = LibraryManager.feed_items(channel_id=channel_id)
|
||||
except TypeError:
|
||||
# Older signature without the filter: fall back to a full reload.
|
||||
self._load_cached_feed()
|
||||
return
|
||||
self.grid.merge_entries(self._to_entries(items))
|
||||
self._update_empty_state()
|
||||
|
||||
def _update_empty_state(self) -> None:
|
||||
"""
|
||||
Feed is the first tab now, so an empty one is the first thing a new
|
||||
user sees. Say what to do about it rather than showing a blank grid.
|
||||
"""
|
||||
account_mode = (self.mode_combo.currentData() or "local") == "account"
|
||||
self.subs_panel.setVisible(not account_mode)
|
||||
|
||||
self.grid.empty_label.hide()
|
||||
if self.grid.card_count() > 0:
|
||||
self.empty_hint.hide()
|
||||
self.grid.show()
|
||||
return
|
||||
|
||||
# The message depends on the mode: account mode does not use local
|
||||
# subscriptions at all, so telling someone to go and subscribe to
|
||||
# something would be wrong advice.
|
||||
if account_mode:
|
||||
message = _("feed.empty_account")
|
||||
elif not LibraryManager.subscriptions():
|
||||
message = _("feed.empty_no_subscriptions")
|
||||
else:
|
||||
message = _("feed.empty_not_refreshed")
|
||||
self.empty_hint.setText(message)
|
||||
|
||||
# Hide the empty grid so the hint sits in the middle of the page
|
||||
# rather than pinned under a large blank area.
|
||||
self.grid.hide()
|
||||
self.empty_hint.show()
|
||||
|
||||
# -------------------------------------------------------------- account
|
||||
|
||||
def _refresh_account(self) -> None:
|
||||
if self._account_worker is not None:
|
||||
return
|
||||
self.refresh_btn.setEnabled(False)
|
||||
self.status_label.setText(_("feed.fetching_account"))
|
||||
self._account_worker = YtdlpWorker(lambda c: c.fetch_account_feed(n=60), parent=self)
|
||||
self._account_worker.result.connect(self._on_account_feed)
|
||||
self._account_worker.error.connect(self._on_account_error)
|
||||
self._account_worker.finished.connect(self._on_account_finished)
|
||||
self._account_worker.start()
|
||||
|
||||
def _on_account_feed(self, entries: List[Dict[str, Any]]) -> None:
|
||||
self.grid.set_entries(entries)
|
||||
self.status_label.setText(_("feed.refreshed", count=len(entries)))
|
||||
# Without this the grid stays hidden behind the empty-state hint that
|
||||
# was shown at startup, so a successful refresh reported "60 videos in
|
||||
# feed" above a "No subscriptions yet" message and no videos.
|
||||
self._update_empty_state()
|
||||
|
||||
def _on_account_error(self, message: str) -> None:
|
||||
logger.error(f"Account feed failed: {message}")
|
||||
self.status_label.setText(_("feed.account_failed", error=message[:200]))
|
||||
self._update_empty_state()
|
||||
|
||||
def _on_account_finished(self) -> None:
|
||||
self.refresh_btn.setEnabled(True)
|
||||
if self._account_worker is not None:
|
||||
self._account_worker.deleteLater()
|
||||
self._account_worker = None
|
||||
|
||||
# ------------------------------------------------------------- internal
|
||||
|
||||
def _update_mode_availability(self) -> None:
|
||||
cookies_on = bool(ConfigManager.get("cookie_active"))
|
||||
account_index = self.mode_combo.findData("account")
|
||||
model_item = self.mode_combo.model().item(account_index)
|
||||
if model_item is not None:
|
||||
model_item.setEnabled(cookies_on)
|
||||
# Say *why* it is unavailable. It used to be greyed out with no
|
||||
# explanation and no way to do anything about it.
|
||||
model_item.setToolTip("" if cookies_on else _("feed.account_requires_cookies"))
|
||||
if not cookies_on and self.mode_combo.currentData() == "account":
|
||||
self.mode_combo.setCurrentIndex(self.mode_combo.findData("local"))
|
||||
# The way out of that dead end: offered only while it is one.
|
||||
self.signin_btn.setVisible(not cookies_on)
|
||||
|
||||
def on_cookies_changed(self) -> None:
|
||||
"""Cookies were applied elsewhere; account mode may now be usable."""
|
||||
self._update_mode_availability()
|
||||
|
||||
def showEvent(self, event) -> None:
|
||||
self._update_mode_availability()
|
||||
super().showEvent(event)
|
||||
|
||||
def hideEvent(self, event) -> None:
|
||||
# Leaving the tab cancels an automatic refresh. cancel() existed and
|
||||
# was never called, so a background sweep kept running -- and closing
|
||||
# the window destroyed a live QThread.
|
||||
self._cancel_refresh()
|
||||
super().hideEvent(event)
|
||||
|
||||
def _cancel_refresh(self) -> None:
|
||||
worker = self._refresh_worker
|
||||
if worker is None:
|
||||
return
|
||||
worker.cancel()
|
||||
if not worker.wait(3000):
|
||||
logger.warning("Feed refresh did not stop in time.")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Called on application close."""
|
||||
self._cancel_refresh()
|
||||
|
||||
def _on_mode_changed(self) -> None:
|
||||
mode = self.mode_combo.currentData()
|
||||
ConfigManager.set("feed.mode", mode)
|
||||
# Switching modes left the previous mode's items on screen, so the
|
||||
# local feed appeared to be the account feed. Show what the new mode
|
||||
# actually has.
|
||||
if mode == "local":
|
||||
self._load_cached_feed()
|
||||
else:
|
||||
self.grid.set_entries([])
|
||||
self.status_label.setText(_("feed.account_needs_refresh"))
|
||||
self._update_empty_state()
|
||||
|
||||
def _on_sub_activated(self, item: QListWidgetItem) -> None:
|
||||
sub = item.data(Qt.ItemDataRole.UserRole)
|
||||
if sub and sub.get("url"):
|
||||
self._router.openChannel.emit(sub["url"])
|
||||
|
||||
def _on_subs_context_menu(self, pos) -> None:
|
||||
item = self.subs_list.itemAt(pos)
|
||||
if item is None:
|
||||
return
|
||||
sub = item.data(Qt.ItemDataRole.UserRole)
|
||||
menu = QMenu(self)
|
||||
open_action = menu.addAction(_("feed.open_channel"))
|
||||
unsub_action = menu.addAction(_("browse.unsubscribe"))
|
||||
action = menu.exec(self.subs_list.mapToGlobal(pos))
|
||||
if action is open_action and sub.get("url"):
|
||||
self._router.openChannel.emit(sub["url"])
|
||||
elif action is unsub_action:
|
||||
LibraryManager.unsubscribe(sub["channel_id"])
|
||||
self.reload_subscriptions()
|
||||
self._load_cached_feed()
|
||||
@@ -346,7 +346,10 @@ class FormatTableMixin:
|
||||
checkbox.setStyleSheet("QCheckBox { margin-left: 8px; }")
|
||||
checkbox.format_id = f["format_id"]
|
||||
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))
|
||||
self.format_checkboxes.append(checkbox)
|
||||
|
||||
|
||||
+300
-152
@@ -5,11 +5,10 @@ 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
|
||||
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
@@ -32,9 +31,10 @@ from PySide6.QtWidgets import (
|
||||
QGraphicsScene,
|
||||
QGraphicsPixmapItem,
|
||||
)
|
||||
from PySide6.QtGui import QIcon, QPixmap, QPainter, QBrush, QColor
|
||||
from PySide6.QtGui import QIcon, QPixmap, QPainter, QBrush, QColor, QShortcut, QKeySequence
|
||||
|
||||
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
|
||||
@@ -53,8 +53,10 @@ from .ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.
|
||||
from .ytsage_gui_format_table import FormatTableMixin
|
||||
from .ytsage_gui_video_info import VideoInfoMixin
|
||||
from .ytsage_gui_analysis import AnalysisMixin
|
||||
from .ytsage_smooth_tab_widget import SmoothTabWidget
|
||||
from ..utils.ytsage_constants import (
|
||||
ICON_PATH,
|
||||
ICON_PATH_LARGE,
|
||||
SOUND_PATH,
|
||||
SUBPROCESS_CREATIONFLAGS,
|
||||
VIDEO_EXTENSIONS,
|
||||
@@ -66,142 +68,50 @@ from ..utils.ytsage_config_manager import ConfigManager
|
||||
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
|
||||
from .ytsage_theme import build_extra_qss
|
||||
from . import ytsage_icons as icons
|
||||
from . import ytsage_theme as theme
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
@@ -216,12 +126,18 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
|
||||
self.version = APP_VERSION
|
||||
load_saved_path(self)
|
||||
# Load custom icon
|
||||
if ICON_PATH.exists():
|
||||
self.setWindowIcon(QIcon(str(ICON_PATH)))
|
||||
else:
|
||||
logger.warning(f"Icon file not found at {ICON_PATH}. Using default icon.")
|
||||
self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) # Fallback
|
||||
# Load custom icon. Both sizes go in so the window manager and taskbar
|
||||
# pick rather than upscale the 48px one.
|
||||
app_icon = QIcon()
|
||||
for path in (ICON_PATH, ICON_PATH_LARGE):
|
||||
if path.exists():
|
||||
app_icon.addFile(str(path))
|
||||
if app_icon.isNull():
|
||||
logger.warning(f"Icon file not found at {ICON_PATH}. Using a drawn fallback.")
|
||||
# A drawn icon beats the platform's download arrow standing in for
|
||||
# the application's identity.
|
||||
app_icon = icons.icon("play-circle", theme.ACCENT, 64)
|
||||
self.setWindowIcon(app_icon)
|
||||
self.signals = SignalManager()
|
||||
self.download_paused = False
|
||||
self.current_download = None
|
||||
@@ -278,7 +194,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
# Defer heavy start-up tasks to ensure UI renders immediately
|
||||
QTimer.singleShot(100, self._perform_startup_checks)
|
||||
|
||||
self.setStyleSheet(StyleSheet.MAIN)
|
||||
# StyleSheet.MAIN is upstream's and covers the window, inputs, buttons
|
||||
# and tables. EXTRA_QSS adds everything it never styled -- the tab bar,
|
||||
# combos, sliders, lists, menus, splitters, tooltips and the horizontal
|
||||
# scrollbar -- and corrects two of its rules. Kept in a separate,
|
||||
# fork-owned module so this stays a one-line change here.
|
||||
self.setStyleSheet(StyleSheet.MAIN + build_extra_qss())
|
||||
self.signals.update_progress.connect(self.update_progress_bar)
|
||||
|
||||
# After adding format buttons
|
||||
@@ -309,6 +230,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to restore state: {e}")
|
||||
|
||||
# Never start fullscreen. restoreGeometry() replays the window state
|
||||
# it was saved with, and since fullscreen became a state of the *main*
|
||||
# window rather than a separate one, quitting from fullscreen used to
|
||||
# bring the app back with no title bar, no close button, and no way
|
||||
# out -- fullscreen is driven from the Watch tab, which may not even be
|
||||
# the visible tab at startup. Maximised is kept: it is escapable.
|
||||
if self.windowState() & Qt.WindowState.WindowFullScreen:
|
||||
logger.info("Ignoring saved fullscreen state; starting windowed.")
|
||||
self.setWindowState(self.windowState() & ~Qt.WindowState.WindowFullScreen)
|
||||
|
||||
def _perform_startup_checks(self):
|
||||
"""Perform potentially blocking startup checks after UI is shown."""
|
||||
|
||||
@@ -381,9 +312,10 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
self.setWindowTitle(f"{_('app.title')} {_('app.version', version=self.version)}")
|
||||
self.setMinimumSize(900, 750)
|
||||
|
||||
# Main widget and layout
|
||||
# Downloads page keeps the original single-page layout; the central
|
||||
# widget becomes a tab shell built at the end of init_ui
|
||||
main_widget = QWidget()
|
||||
self.setCentralWidget(main_widget)
|
||||
self.download_page = main_widget
|
||||
layout = QVBoxLayout(main_widget)
|
||||
layout.setSpacing(8)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
@@ -407,6 +339,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
|
||||
# Analyze button with app's red theme
|
||||
self.analyze_button = QPushButton(_("buttons.analyze"))
|
||||
self.analyze_button.setProperty("sageIcon", "search")
|
||||
self.analyze_button.clicked.connect(self.analyze_url)
|
||||
self.analyze_button.setEnabled(False) # Disabled until URL is entered
|
||||
self.analyze_button.setMinimumHeight(42)
|
||||
@@ -528,33 +461,41 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
|
||||
# Replace the two separate buttons with a single Custom Options button
|
||||
self.custom_options_btn = QPushButton(_("buttons.custom_options"))
|
||||
self.custom_options_btn.setProperty("sageIcon", "settings")
|
||||
self.custom_options_btn.clicked.connect(self.show_custom_options)
|
||||
|
||||
self.about_btn = QPushButton(_("buttons.about"))
|
||||
self.about_btn.setProperty("sageIcon", "info")
|
||||
self.about_btn.clicked.connect(self.show_about_dialog)
|
||||
|
||||
self.history_btn = QPushButton(_("buttons.history"))
|
||||
self.history_btn.setProperty("sageIcon", "clock")
|
||||
self.history_btn.clicked.connect(self.show_history_dialog)
|
||||
|
||||
# Add new Time Range button
|
||||
self.time_range_btn = QPushButton(_("buttons.trim_video"))
|
||||
self.time_range_btn.setProperty("sageIcon", "scissors")
|
||||
self.time_range_btn.clicked.connect(self.show_time_range_dialog)
|
||||
|
||||
# --- Rename Path Button to Settings Button ---
|
||||
self.settings_button = QPushButton(_("buttons.download_settings")) # Renamed button
|
||||
self.settings_button.setProperty("sageIcon", "settings")
|
||||
self.settings_button.clicked.connect(self.show_download_settings_dialog) # Renamed method
|
||||
self._update_settings_tooltip()
|
||||
# --- End Settings Button ---
|
||||
|
||||
self.download_btn = QPushButton(_("buttons.download"))
|
||||
self.download_btn.setProperty("sageIcon", "download")
|
||||
self.download_btn.clicked.connect(self.start_download)
|
||||
|
||||
# Add pause and cancel buttons
|
||||
self.pause_btn = QPushButton(_("buttons.pause"))
|
||||
self.pause_btn.setProperty("sageIcon", "pause")
|
||||
self.pause_btn.clicked.connect(self.toggle_pause)
|
||||
self.pause_btn.setVisible(False) # Hidden initially
|
||||
|
||||
self.cancel_btn = QPushButton(_("buttons.cancel"))
|
||||
self.cancel_btn.setProperty("sageIcon", "x")
|
||||
self.cancel_btn.clicked.connect(self.cancel_download)
|
||||
self.cancel_btn.setVisible(False) # Hidden initially
|
||||
|
||||
@@ -626,6 +567,147 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
# Disable analysis-dependent controls until video is analyzed
|
||||
self.toggle_analysis_dependent_controls(enabled=False)
|
||||
|
||||
self._setup_main_tabs()
|
||||
|
||||
def _setup_main_tabs(self) -> None:
|
||||
"""Wrap the pages in the watch-first tab shell (SageTube)."""
|
||||
from .ytsage_gui_browse import BrowsePage
|
||||
from .ytsage_gui_feed import FeedPage
|
||||
from .ytsage_gui_player import PlayerPanel, create_player_panel
|
||||
from .ytsage_gui_router import AppRouter
|
||||
from .ytsage_gui_search import SearchPage
|
||||
from .ytsage_gui_watch import WatchPage
|
||||
from .ytsage_gui_account import AccountStatusButton
|
||||
from .ytsage_player_fullscreen import FullscreenController
|
||||
|
||||
self.router = AppRouter(self)
|
||||
|
||||
self.watch_page = WatchPage(self.router, self)
|
||||
self.search_page = SearchPage(self.router, self)
|
||||
self.feed_page = FeedPage(self.router, self)
|
||||
self.browse_page = BrowsePage(self.router, self)
|
||||
|
||||
# Feed first, Watch last. The feed is what you open the app to see;
|
||||
# the player is where the other tabs send you, not somewhere you
|
||||
# start. _tab_index_of resolves by widget identity, so the routing
|
||||
# methods below are unaffected by this order.
|
||||
self.main_tabs = SmoothTabWidget(self)
|
||||
self.main_tabs.addTab(self.feed_page, _("main_tabs.feed"), icons.icon("rss", theme.ICON))
|
||||
self.main_tabs.addTab(self.search_page, _("main_tabs.search"), icons.icon("search", theme.ICON))
|
||||
self.main_tabs.addTab(self.browse_page, _("main_tabs.browse"), icons.icon("library", theme.ICON))
|
||||
self.main_tabs.addTab(self.download_page, _("main_tabs.downloads"), icons.icon("download", theme.ICON))
|
||||
self.main_tabs.addTab(self.watch_page, _("main_tabs.watch"), icons.icon("play", theme.ICON))
|
||||
self.main_tabs.currentChanged.connect(self._on_main_tab_changed)
|
||||
self.setCentralWidget(self.main_tabs)
|
||||
|
||||
# Fullscreen is driven from here because the chrome it hides -- the tab
|
||||
# bar and the queue panel -- belongs to the shell, not to the player.
|
||||
if isinstance(self.watch_page.player, PlayerPanel):
|
||||
self.fullscreen_controller = FullscreenController(
|
||||
self, self.main_tabs, self.watch_page, parent=self
|
||||
)
|
||||
self.watch_page.player.set_fullscreen_controller(self.fullscreen_controller)
|
||||
self.fullscreen_controller.changed.connect(self.watch_page.player.on_fullscreen_changed)
|
||||
self._install_fullscreen_shortcuts()
|
||||
|
||||
self.router.playVideo.connect(self._route_play_video)
|
||||
self.router.queueVideo.connect(self.watch_page.enqueue)
|
||||
self.router.downloadVideo.connect(self._route_download_video)
|
||||
self.router.openChannel.connect(self._route_open_channel)
|
||||
self.router.openPlaylist.connect(self._route_open_playlist)
|
||||
self.browse_page.subscribeRequested.connect(self.feed_page.subscribe_channel)
|
||||
# A new subscription changes what the Feed's empty state should say.
|
||||
self.feed_page.subscriptionsChanged.connect(self.browse_page.refresh_subscribe_state)
|
||||
|
||||
# The cookie login had exactly one entry point -- Downloads tab ->
|
||||
# Custom Options -> Login with Cookies -- while the features needing
|
||||
# it live on other tabs. A corner button makes it reachable from all
|
||||
# of them, and the Feed offers it where account mode is disabled.
|
||||
self.account_button = AccountStatusButton(self.main_tabs)
|
||||
self.account_button.loginRequested.connect(self.show_cookie_login_dialog)
|
||||
self.main_tabs.setCornerWidget(self.account_button)
|
||||
self.router.cookieSetupRequested.connect(self.show_cookie_login_dialog)
|
||||
self.router.cookiesChanged.connect(self.account_button.refresh)
|
||||
self.router.cookiesChanged.connect(self.feed_page.on_cookies_changed)
|
||||
if isinstance(self.watch_page.player, PlayerPanel):
|
||||
self.router.cookiesChanged.connect(self.watch_page.player.reload_auth)
|
||||
|
||||
def _install_fullscreen_shortcuts(self) -> None:
|
||||
"""
|
||||
Window-level F11 and Escape.
|
||||
|
||||
The player's own F and Escape are scoped to the player, so they only
|
||||
fire while it has focus. Fullscreen now belongs to the whole window, so
|
||||
there must be a way out of it from anywhere -- otherwise a window with
|
||||
no title bar and no close button has no exit at all.
|
||||
"""
|
||||
self._fullscreen_shortcuts = []
|
||||
for sequence, handler in (
|
||||
("F11", self._toggle_fullscreen_from_window),
|
||||
("Escape", self._escape_fullscreen),
|
||||
):
|
||||
shortcut = QShortcut(QKeySequence(sequence), self)
|
||||
shortcut.setContext(Qt.ShortcutContext.WindowShortcut)
|
||||
shortcut.activated.connect(handler)
|
||||
self._fullscreen_shortcuts.append(shortcut)
|
||||
|
||||
def _toggle_fullscreen_from_window(self) -> None:
|
||||
controller = getattr(self, "fullscreen_controller", None)
|
||||
if controller is None:
|
||||
# No player, so no controller -- still honour F11 as a plain
|
||||
# window toggle rather than doing nothing.
|
||||
self.showNormal() if self.isFullScreen() else self.showFullScreen()
|
||||
return
|
||||
controller.toggle()
|
||||
|
||||
def _escape_fullscreen(self) -> None:
|
||||
"""Escape leaves fullscreen and is otherwise ignored."""
|
||||
controller = getattr(self, "fullscreen_controller", None)
|
||||
if controller is not None and controller.is_active():
|
||||
controller.exit()
|
||||
elif self.isFullScreen():
|
||||
self.showNormal()
|
||||
|
||||
def _on_main_tab_changed(self, index: int) -> None:
|
||||
"""
|
||||
Tell a page it has become visible.
|
||||
|
||||
Duck-typed rather than isinstance-checked so a page opts in by simply
|
||||
defining the method. Preferred over showEvent, which also fires on
|
||||
window restore and on first show.
|
||||
"""
|
||||
page = self.main_tabs.stack.widget(index)
|
||||
hook = getattr(page, "on_tab_activated", None)
|
||||
if hook is None:
|
||||
return
|
||||
try:
|
||||
hook()
|
||||
except Exception as e:
|
||||
logger.debug(f"Tab activation hook failed: {e}")
|
||||
|
||||
def _tab_index_of(self, page) -> int:
|
||||
for i in range(self.main_tabs.stack.count()):
|
||||
if self.main_tabs.stack.widget(i) is page:
|
||||
return i
|
||||
return 0
|
||||
|
||||
def _route_play_video(self, entry: dict) -> None:
|
||||
self.main_tabs.set_current_index(self._tab_index_of(self.watch_page))
|
||||
self.watch_page.play_entry(entry)
|
||||
|
||||
def _route_download_video(self, url: str) -> None:
|
||||
self.main_tabs.set_current_index(self._tab_index_of(self.download_page))
|
||||
self.url_input.setText(url)
|
||||
self.analyze_url()
|
||||
|
||||
def _route_open_channel(self, url: str) -> None:
|
||||
self.main_tabs.set_current_index(self._tab_index_of(self.browse_page))
|
||||
self.browse_page.open_url(url)
|
||||
|
||||
def _route_open_playlist(self, url: str) -> None:
|
||||
self.main_tabs.set_current_index(self._tab_index_of(self.browse_page))
|
||||
self.browse_page.open_url(url)
|
||||
|
||||
def _on_url_text_changed(self, text: str) -> None:
|
||||
"""Enable or disable the Analyze button based on URL input content."""
|
||||
self.analyze_button.setEnabled(bool(text.strip()))
|
||||
@@ -1029,9 +1111,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)
|
||||
@@ -1123,9 +1208,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
|
||||
@@ -1133,6 +1224,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)
|
||||
|
||||
@@ -1176,18 +1273,36 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
|
||||
# Clean up the thread reference and ensure it's properly finished
|
||||
if hasattr(self, "auto_update_thread"):
|
||||
thread = self.auto_update_thread
|
||||
# Disconnect all signals to prevent further callbacks
|
||||
self.auto_update_thread.update_finished.disconnect()
|
||||
# Make sure thread is finished
|
||||
if self.auto_update_thread.isRunning():
|
||||
self.auto_update_thread.quit()
|
||||
self.auto_update_thread.wait(1000) # Wait up to 1 second
|
||||
thread.update_finished.disconnect()
|
||||
# Dropping the Python reference while run() is still unwinding
|
||||
# can destroy a live QThread; let Qt delete it once finished.
|
||||
if thread.isRunning():
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
thread.quit()
|
||||
else:
|
||||
thread.deleteLater()
|
||||
# Remove the reference
|
||||
delattr(self, "auto_update_thread")
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
"""Handle application close event to ensure proper cleanup of background threads."""
|
||||
try:
|
||||
# Persist watch position/queue and release libmpv
|
||||
if hasattr(self, "watch_page"):
|
||||
try:
|
||||
self.watch_page.shutdown()
|
||||
except Exception as e:
|
||||
logger.debug(f"Watch page shutdown error: {e}")
|
||||
|
||||
# Stop a feed refresh before its QThread is destroyed with it.
|
||||
if hasattr(self, "feed_page"):
|
||||
try:
|
||||
self.feed_page.shutdown()
|
||||
except Exception as e:
|
||||
logger.debug(f"Feed page shutdown error: {e}")
|
||||
|
||||
# Stop the analysis thread if it's running
|
||||
if hasattr(self, "_analysis_thread") and self._analysis_thread is not None and self._analysis_thread.isRunning():
|
||||
logger.info("Stopping analysis thread...")
|
||||
@@ -1197,6 +1312,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...")
|
||||
@@ -1215,7 +1341,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
self.current_download.terminate()
|
||||
self.current_download.wait(1000) # Wait for termination
|
||||
|
||||
# Save the window size and state
|
||||
# Save the window size and state. Leave fullscreen first, so what
|
||||
# gets stored is the windowed geometry -- saving while fullscreen
|
||||
# records a size covering the whole screen, which is not a useful
|
||||
# window to come back to.
|
||||
try:
|
||||
if getattr(self, "fullscreen_controller", None) is not None and self.fullscreen_controller.is_active():
|
||||
self.fullscreen_controller.exit()
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not leave fullscreen before saving geometry: {e}")
|
||||
|
||||
try:
|
||||
ConfigManager.set("window_geometry", self.saveGeometry().toBase64().data().decode("ascii"))
|
||||
ConfigManager.set("window_state", self.saveState().toBase64().data().decode("ascii"))
|
||||
@@ -1268,6 +1403,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
_("proxy.cleared_message"),
|
||||
)
|
||||
|
||||
# This dialog's Cookies tab persists on OK too, and proxy settings
|
||||
# reach the player by the same route, so announce both from here.
|
||||
if hasattr(self, "router"):
|
||||
self.router.cookiesChanged.emit()
|
||||
|
||||
def show_about_dialog(self) -> None: # ADDED METHOD HERE
|
||||
dialog = AboutDialog(self)
|
||||
self.run_dialog_with_blur(dialog)
|
||||
@@ -1646,6 +1786,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
self.cookie_file_path = None # Clear path if dialog accepted but no file selected
|
||||
self.browser_cookies_option = None # Clear browser cookies too
|
||||
|
||||
# The dialog has already written the config; tell the rest of the
|
||||
# app so signing in takes effect now rather than after a restart.
|
||||
if hasattr(self, "router"):
|
||||
self.router.cookiesChanged.emit()
|
||||
|
||||
def cancel_download(self) -> None:
|
||||
if self.current_download:
|
||||
self.current_download.cancelled = True
|
||||
@@ -1862,25 +2007,28 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
def run_dialog_with_blur(self, dialog: QDialog) -> int:
|
||||
"""Run a dialog with a static background screenshot blur to avoid QPainter conflicts."""
|
||||
|
||||
# 1. Capture the current state of the window (screenshot)
|
||||
pixmap = self.grab()
|
||||
|
||||
# 2. Create the blur using a Graphics Scene method (much safer than QGraphicsBlurEffect on live widget)
|
||||
# However, for simplicity and performance with PySide6, we can just apply a blur to the image
|
||||
# or use a simplified overlay.
|
||||
# Let's manually blur the pixmap or use a simpler transparent overlay if blur is too heavy manually.
|
||||
# Actually, using QGraphicsBlurEffect on a temporary QGraphicsScene rendering to a pixmap is a valid way
|
||||
# to generate a single blurred frame.
|
||||
|
||||
blurred_pixmap = self._apply_blur_to_pixmap(pixmap, radius=10)
|
||||
|
||||
# 3. Create an overlay widget that covers the Main Window
|
||||
# The blur is a screenshot of the window. grab() forces a framebuffer
|
||||
# readback on any QOpenGLWidget in the tree -- which returns black,
|
||||
# and risks the GL context the embedded mpv player renders into. Every
|
||||
# dialog in the app goes through here, so when a GL surface is present
|
||||
# dim instead of blurring: same effect, no screenshot, and faster.
|
||||
if self.findChild(QOpenGLWidget) is not None:
|
||||
overlay = QWidget(self)
|
||||
overlay.setAutoFillBackground(True)
|
||||
overlay.setStyleSheet("background-color: rgba(0, 0, 0, 150);")
|
||||
else:
|
||||
blurred_pixmap = self._apply_blur_to_pixmap(self.grab(), radius=10)
|
||||
overlay = QLabel(self)
|
||||
overlay.setPixmap(blurred_pixmap)
|
||||
|
||||
overlay.setGeometry(0, 0, self.width(), self.height())
|
||||
overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) # Block mouse
|
||||
overlay.show()
|
||||
|
||||
# The opacity effect below goes on the overlay, which is a *sibling*
|
||||
# of the video widget. A QGraphicsEffect on any ancestor of a
|
||||
# QOpenGLWidget is unsupported and renders it black -- do not move it.
|
||||
|
||||
# Animate overlay Fade In
|
||||
opacity_effect = QGraphicsOpacityEffect(overlay)
|
||||
overlay.setGraphicsEffect(opacity_effect)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
AppRouter - cross-tab navigation signals
|
||||
========================================
|
||||
|
||||
A tiny QObject signal hub connecting the watch-first pages (search, feed,
|
||||
browse) with the player and the downloader tab. Pages emit; the main window
|
||||
routes. Entry dicts are yt-dlp flat entries or the subset
|
||||
{id, url, title, channel, channel_url, duration, thumbnail}.
|
||||
"""
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
|
||||
class AppRouter(QObject):
|
||||
playVideo = Signal(dict) # play immediately in the Watch tab
|
||||
queueVideo = Signal(dict) # append to the play queue
|
||||
downloadVideo = Signal(str) # open Downloads tab with URL prefilled + analyzed
|
||||
openChannel = Signal(str) # open a channel URL in the Browse tab
|
||||
openPlaylist = Signal(str) # open a playlist URL in the Browse tab
|
||||
# Cookie settings were applied. YtdlpClient re-reads config per call so it
|
||||
# needs no telling, but the account button, the Feed's mode availability
|
||||
# and the live mpv handle all do -- otherwise signing in only takes effect
|
||||
# after a restart.
|
||||
cookiesChanged = Signal()
|
||||
# Somewhere in the UI asked for the cookie login dialog.
|
||||
cookieSetupRequested = Signal()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Search tab - in-app YouTube search via yt-dlp (ytsearchN:)
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from .ytsage_gui_cards import VideoCardGrid
|
||||
from ..core.ytsage_client import YtdlpWorker
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
PAGE_SIZE = 24
|
||||
|
||||
|
||||
class SearchPage(QWidget):
|
||||
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._router = router
|
||||
self._worker: Optional[YtdlpWorker] = None
|
||||
self._query = ""
|
||||
self._offset = 0
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
bar = QHBoxLayout()
|
||||
self.query_input = QLineEdit()
|
||||
self.query_input.setPlaceholderText(_("search.placeholder"))
|
||||
self.query_input.returnPressed.connect(self.start_search)
|
||||
self.query_input.setMinimumHeight(38)
|
||||
bar.addWidget(self.query_input, stretch=1)
|
||||
|
||||
self.search_btn = QPushButton(_("search.button"))
|
||||
self.search_btn.setToolTip(_("search.button_tooltip"))
|
||||
self.search_btn.clicked.connect(self.start_search)
|
||||
self.search_btn.setMinimumHeight(38)
|
||||
bar.addWidget(self.search_btn)
|
||||
layout.addLayout(bar)
|
||||
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet("color: #9aa0a6; padding: 2px;")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
self.grid = VideoCardGrid(router, self)
|
||||
self.grid.loadMoreRequested.connect(self.load_more)
|
||||
layout.addWidget(self.grid, stretch=1)
|
||||
|
||||
# ---------------------------------------------------------------- search
|
||||
|
||||
def start_search(self) -> None:
|
||||
query = self.query_input.text().strip()
|
||||
if not query or self._worker is not None:
|
||||
return
|
||||
self._query = query
|
||||
self._offset = 0
|
||||
self.grid.clear()
|
||||
self._fetch(append=False)
|
||||
|
||||
def load_more(self) -> None:
|
||||
if self._worker is None and self._query:
|
||||
self._offset += PAGE_SIZE
|
||||
self._fetch(append=True)
|
||||
|
||||
def _fetch(self, append: bool) -> None:
|
||||
self.status_label.setText(_("search.searching"))
|
||||
self.search_btn.setEnabled(False)
|
||||
query, offset = self._query, self._offset
|
||||
self._worker = YtdlpWorker(lambda c: c.search(query, n=PAGE_SIZE, offset=offset), parent=self)
|
||||
self._worker.result.connect(lambda entries: self._on_results(entries, append))
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.finished.connect(self._on_finished)
|
||||
self._worker.start()
|
||||
|
||||
def _on_results(self, entries: List[Dict[str, Any]], append: bool) -> None:
|
||||
show_more = len(entries) >= PAGE_SIZE
|
||||
if append:
|
||||
self.grid.append_entries(entries, show_load_more=show_more)
|
||||
else:
|
||||
self.grid.set_entries(entries, show_load_more=show_more)
|
||||
self.status_label.setText(_("search.results_count", count=self.grid.card_count()))
|
||||
|
||||
def _on_error(self, message: str) -> None:
|
||||
logger.error(f"Search failed: {message}")
|
||||
self.status_label.setText(_("search.failed", error=message[:200]))
|
||||
|
||||
def _on_finished(self) -> None:
|
||||
self.search_btn.setEnabled(True)
|
||||
if self._worker is not None:
|
||||
self._worker.deleteLater()
|
||||
self._worker = None
|
||||
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
Watch tab - player plus play queue
|
||||
==================================
|
||||
|
||||
Hosts the embedded mpv PlayerPanel (or the libmpv-missing hint) and a simple
|
||||
play queue. Entries arrive via AppRouter.playVideo / queueVideo.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .ytsage_gui_player import PlayerPanel, create_player_panel
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
from ..utils.ytsage_library_manager import LibraryManager
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
POSITION_SAVE_INTERVAL_MS = 5000
|
||||
|
||||
|
||||
class WatchPage(QWidget):
|
||||
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._router = router
|
||||
self._queue: List[Dict[str, Any]] = []
|
||||
# What has already played this session, so "previous" has somewhere
|
||||
# to go. Not persisted: it is session history, not the queue.
|
||||
self._played: List[Dict[str, Any]] = []
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
||||
layout.addWidget(splitter)
|
||||
|
||||
self.player = create_player_panel(self)
|
||||
splitter.addWidget(self.player)
|
||||
|
||||
# Kept as an attribute: FullscreenController hides it rather than
|
||||
# reparenting the player, which is what used to lose the GL context.
|
||||
self.queue_panel = queue_panel = QWidget(self)
|
||||
queue_layout = QVBoxLayout(queue_panel)
|
||||
queue_layout.setContentsMargins(4, 0, 0, 0)
|
||||
|
||||
queue_header = QHBoxLayout()
|
||||
queue_label = QLabel(_("player.queue"))
|
||||
queue_label.setStyleSheet("font-weight: bold;")
|
||||
queue_header.addWidget(queue_label)
|
||||
queue_header.addStretch()
|
||||
self.clear_queue_btn = QPushButton("✕")
|
||||
self.clear_queue_btn.setFixedWidth(28)
|
||||
self.clear_queue_btn.setToolTip(_("watch.clear_queue"))
|
||||
self.clear_queue_btn.clicked.connect(self.clear_queue)
|
||||
queue_header.addWidget(self.clear_queue_btn)
|
||||
queue_layout.addLayout(queue_header)
|
||||
|
||||
self.queue_list = QListWidget()
|
||||
self.queue_list.setDragDropMode(QListWidget.DragDropMode.InternalMove)
|
||||
self.queue_list.itemDoubleClicked.connect(self._on_queue_item_activated)
|
||||
self.queue_list.model().rowsMoved.connect(self._on_rows_moved)
|
||||
queue_layout.addWidget(self.queue_list)
|
||||
|
||||
splitter.addWidget(queue_panel)
|
||||
splitter.setStretchFactor(0, 4)
|
||||
splitter.setStretchFactor(1, 1)
|
||||
splitter.setSizes([900, 240])
|
||||
|
||||
if isinstance(self.player, PlayerPanel):
|
||||
self.player.playbackEnded.connect(self._on_playback_ended)
|
||||
self._duration = 0.0
|
||||
self.player.durationChanged.connect(self._on_duration_changed)
|
||||
self._position_timer = QTimer(self)
|
||||
self._position_timer.setInterval(POSITION_SAVE_INTERVAL_MS)
|
||||
self._position_timer.timeout.connect(self._save_position)
|
||||
self.player.nowPlayingChanged.connect(self._on_now_playing)
|
||||
# The panel asks; the queue lives here, so the queue answers.
|
||||
self.player.nextRequested.connect(self.play_next)
|
||||
self.player.previousRequested.connect(self.play_previous)
|
||||
|
||||
self._restore_queue()
|
||||
|
||||
# ------------------------------------------------------------ public API
|
||||
|
||||
def play_entry(self, entry: Dict[str, Any], resume_pos: float = 0.0) -> None:
|
||||
if isinstance(self.player, PlayerPanel):
|
||||
if resume_pos <= 0 and (ConfigManager.get("player.resume") or "auto") == "auto":
|
||||
video_id = entry.get("id") or entry.get("url")
|
||||
if video_id:
|
||||
resume_pos = LibraryManager.get_resume_position(str(video_id))
|
||||
self.player.play(entry, resume_pos=resume_pos)
|
||||
else:
|
||||
logger.warning("Play requested but libmpv is unavailable")
|
||||
|
||||
def enqueue(self, entry: Dict[str, Any]) -> None:
|
||||
self._queue.append(dict(entry))
|
||||
item = QListWidgetItem(entry.get("title") or entry.get("url") or "?")
|
||||
item.setData(Qt.ItemDataRole.UserRole, dict(entry))
|
||||
self.queue_list.addItem(item)
|
||||
self._persist_queue()
|
||||
# Start playing right away when nothing is on and this is the first item
|
||||
if isinstance(self.player, PlayerPanel) and not self.player.current_entry() and len(self._queue) == 1:
|
||||
self._play_next_from_queue()
|
||||
|
||||
def clear_queue(self) -> None:
|
||||
self._queue.clear()
|
||||
self.queue_list.clear()
|
||||
self._persist_queue()
|
||||
|
||||
def queue_entries(self) -> List[Dict[str, Any]]:
|
||||
return [self.queue_list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.queue_list.count())]
|
||||
|
||||
def play_next(self) -> None:
|
||||
"""Next in the queue. Bound to N and the player's next button."""
|
||||
self._play_next_from_queue()
|
||||
|
||||
def play_previous(self) -> None:
|
||||
"""
|
||||
Back to what was playing before.
|
||||
|
||||
Restarts the current item first if it is more than a few seconds in,
|
||||
which is what every other player does with a "previous" press.
|
||||
"""
|
||||
if not isinstance(self.player, PlayerPanel):
|
||||
return
|
||||
if self.player.current_position() > 5.0:
|
||||
self.player.seek_absolute(0.0)
|
||||
return
|
||||
# _played ends with what is playing now, so going back needs two.
|
||||
if len(self._played) < 2:
|
||||
return
|
||||
current = self._played.pop()
|
||||
previous = self._played[-1]
|
||||
# Put the current item back at the head of the queue rather than
|
||||
# dropping it on the floor.
|
||||
if current:
|
||||
item = QListWidgetItem(current.get("title") or current.get("url") or "?")
|
||||
item.setData(Qt.ItemDataRole.UserRole, dict(current))
|
||||
self.queue_list.insertItem(0, item)
|
||||
self._queue.insert(0, dict(current))
|
||||
self._persist_queue()
|
||||
# play_entry re-pushes `previous`, so drop it here to avoid a double.
|
||||
self._played.pop()
|
||||
self.play_entry(previous)
|
||||
|
||||
# --------------------------------------------------------------- internal
|
||||
|
||||
def _play_next_from_queue(self) -> None:
|
||||
if self.queue_list.count() == 0:
|
||||
return
|
||||
item = self.queue_list.takeItem(0)
|
||||
entry = item.data(Qt.ItemDataRole.UserRole)
|
||||
if entry in self._queue:
|
||||
self._queue.remove(entry)
|
||||
self._persist_queue()
|
||||
self.play_entry(entry)
|
||||
|
||||
def _on_playback_ended(self, reason: str) -> None:
|
||||
self._save_position(final=True)
|
||||
if reason in ("eof", "") and self.queue_list.count() > 0:
|
||||
self._play_next_from_queue()
|
||||
|
||||
def _on_queue_item_activated(self, item: QListWidgetItem) -> None:
|
||||
entry = item.data(Qt.ItemDataRole.UserRole)
|
||||
row = self.queue_list.row(item)
|
||||
self.queue_list.takeItem(row)
|
||||
if entry in self._queue:
|
||||
self._queue.remove(entry)
|
||||
self._persist_queue()
|
||||
self.play_entry(entry)
|
||||
|
||||
def _on_rows_moved(self, *args) -> None:
|
||||
self._queue = self.queue_entries()
|
||||
self._persist_queue()
|
||||
|
||||
# ------------------------------------------------- history & persistence
|
||||
|
||||
def _on_now_playing(self, entry: Dict[str, Any]) -> None:
|
||||
LibraryManager.upsert_watch(entry)
|
||||
# Record what we were on before this, so "previous" can return to it.
|
||||
# The retry path replays the same entry; don't stack duplicates.
|
||||
if self._played[-1:] != [entry] and entry:
|
||||
self._played.append(dict(entry))
|
||||
del self._played[:-50]
|
||||
self._duration = 0.0
|
||||
self._position_timer.start()
|
||||
|
||||
def _on_duration_changed(self, duration: float) -> None:
|
||||
self._duration = duration
|
||||
|
||||
def _save_position(self, final: bool = False) -> None:
|
||||
if not isinstance(self.player, PlayerPanel):
|
||||
return
|
||||
entry = self.player.current_entry()
|
||||
video_id = entry.get("id") or entry.get("url")
|
||||
if not video_id:
|
||||
return
|
||||
pos = self.player.current_position()
|
||||
if pos > 0:
|
||||
LibraryManager.update_position(str(video_id), pos, self._duration or entry.get("duration"))
|
||||
if final:
|
||||
self._position_timer.stop()
|
||||
|
||||
def _persist_queue(self) -> None:
|
||||
try:
|
||||
LibraryManager.save_queue(self.queue_entries())
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not persist queue: {e}")
|
||||
|
||||
def _restore_queue(self) -> None:
|
||||
try:
|
||||
for entry in LibraryManager.load_queue():
|
||||
self._queue.append(entry)
|
||||
item = QListWidgetItem(entry.get("title") or entry.get("url") or "?")
|
||||
item.setData(Qt.ItemDataRole.UserRole, entry)
|
||||
self.queue_list.addItem(item)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not restore queue: {e}")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if isinstance(self.player, PlayerPanel):
|
||||
self._save_position(final=True)
|
||||
self._persist_queue()
|
||||
self.player.shutdown()
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Icons
|
||||
=====
|
||||
|
||||
The app had no icon system. Transport buttons used
|
||||
`QStyle.standardIcon(SP_MediaPlay)`, which returns the platform style's dark
|
||||
monochrome glyph -- painted onto the app's saturated red buttons, at a fixed
|
||||
36px with no text and no tooltip, that reads as a black or empty square. On
|
||||
styles that return a null icon for some standard pixmaps it *was* an empty
|
||||
square. Everything else called an "icon" was an emoji baked into the English
|
||||
translation file, which renders as tofu wherever the emoji font is missing.
|
||||
|
||||
Approach: a small set of hand-drawn SVGs rendered through QtSvg (part of
|
||||
PySide6-Essentials, so no new dependency), recoloured at load. Qt stylesheets
|
||||
cannot recolour a QIcon, which is the whole reason the old icons were
|
||||
unreadable -- so the colour is an argument here.
|
||||
|
||||
The sources live in this module rather than as asset files on purpose: no
|
||||
package-data to keep in sync, no path resolution, and nothing to go missing
|
||||
from a wheel or a frozen build.
|
||||
|
||||
Drawing conventions: 24x24 viewBox, 2px round strokes, `%COLOR%` wherever the
|
||||
colour goes. Shapes that must read as solid at 16px (the play triangle) carry
|
||||
their own fill.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Dict, Optional
|
||||
|
||||
from PySide6.QtCore import QByteArray, QRectF, Qt
|
||||
from PySide6.QtGui import QIcon, QPainter, QPixmap
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
#: Default stroke colour. Overridden per call; kept in step with ytsage_theme.
|
||||
DEFAULT_COLOR = "#e8eaed"
|
||||
|
||||
_SVG: Dict[str, str] = {
|
||||
# --- transport -------------------------------------------------------
|
||||
"play": '<polygon points="7 4 20 12 7 20" fill="%COLOR%" stroke-linejoin="round"/>',
|
||||
"pause": '<rect x="6" y="4" width="4" height="16" rx="1" fill="%COLOR%"/>'
|
||||
'<rect x="14" y="4" width="4" height="16" rx="1" fill="%COLOR%"/>',
|
||||
"stop": '<rect x="5" y="5" width="14" height="14" rx="2" fill="%COLOR%"/>',
|
||||
"skip-back": '<polygon points="19 5 9 12 19 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||
'<line x1="6" y1="5" x2="6" y2="19"/>',
|
||||
"skip-forward": '<polygon points="5 5 15 12 5 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||
'<line x1="18" y1="5" x2="18" y2="19"/>',
|
||||
"rewind-10": '<path d="M11 20a8 8 0 1 0-8-8"/><polyline points="3 8 3 12 7 12"/>'
|
||||
'<text x="12" y="16" font-size="8" fill="%COLOR%" stroke="none"'
|
||||
' text-anchor="middle" font-family="sans-serif">10</text>',
|
||||
"forward-10": '<path d="M13 20a8 8 0 1 1 8-8"/><polyline points="21 8 21 12 17 12"/>'
|
||||
'<text x="12" y="16" font-size="8" fill="%COLOR%" stroke="none"'
|
||||
' text-anchor="middle" font-family="sans-serif">10</text>',
|
||||
# --- audio -----------------------------------------------------------
|
||||
"volume-high": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||
'<path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a10 10 0 0 1 0 14"/>',
|
||||
"volume-low": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||
'<path d="M15.5 8.5a5 5 0 0 1 0 7"/>',
|
||||
"volume-mute": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="%COLOR%" stroke-linejoin="round"/>'
|
||||
'<line x1="16" y1="9" x2="22" y2="15"/><line x1="22" y1="9" x2="16" y2="15"/>',
|
||||
"captions": '<rect x="2" y="5" width="20" height="14" rx="3"/>'
|
||||
'<path d="M10 10.2a2.6 2.6 0 1 0 0 3.6"/><path d="M17.5 10.2a2.6 2.6 0 1 0 0 3.6"/>',
|
||||
# --- window ----------------------------------------------------------
|
||||
"maximize": '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/>'
|
||||
'<path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>',
|
||||
"minimize": '<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/>'
|
||||
'<path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>',
|
||||
# --- tabs / navigation -----------------------------------------------
|
||||
"rss": '<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/>'
|
||||
'<circle cx="5" cy="19" r="1.6" fill="%COLOR%" stroke="none"/>',
|
||||
"search": '<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.6" y2="16.6"/>',
|
||||
"library": '<line x1="4" y1="4" x2="4" y2="20"/><line x1="9" y1="6" x2="9" y2="20"/>'
|
||||
'<path d="M14 6.5l4.5 13"/>',
|
||||
"download": '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>'
|
||||
'<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
||||
"user": '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
"user-check": '<path d="M15 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>'
|
||||
'<polyline points="16 11 18 13 22 9"/>',
|
||||
# --- actions ---------------------------------------------------------
|
||||
"queue": '<line x1="3" y1="6" x2="16" y2="6"/><line x1="3" y1="12" x2="11" y2="12"/>'
|
||||
'<line x1="3" y1="18" x2="11" y2="18"/><line x1="18" y1="9" x2="18" y2="15"/>'
|
||||
'<line x1="21" y1="12" x2="15" y2="12"/>',
|
||||
"refresh": '<path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><polyline points="21 3 21 9 15 9"/>',
|
||||
"folder-open": '<path d="M4 20a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4.2a2 2 0 0 1 1.6.8l1 1.4a2 2 0 0 0 1.6.8H18a2 2 0 0 1 2 2v1"/>'
|
||||
'<path d="M4 20l2.2-7a2 2 0 0 1 1.9-1.4h12a1.6 1.6 0 0 1 1.55 2l-1.7 5.4A2 2 0 0 1 18 20z"/>',
|
||||
"x": '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
"check": '<polyline points="20 6 9 17 4 12"/>',
|
||||
"plus": '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||
"trash": '<polyline points="3 6 21 6"/>'
|
||||
'<path d="M19 6v13a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>'
|
||||
'<path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/>',
|
||||
"settings": '<line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="17" x2="20" y2="17"/>'
|
||||
'<circle cx="10" cy="7" r="2.4" fill="%COLOR%" stroke="none"/>'
|
||||
'<circle cx="15" cy="17" r="2.4" fill="%COLOR%" stroke="none"/>',
|
||||
"info": '<circle cx="12" cy="12" r="9"/><line x1="12" y1="11" x2="12" y2="16"/>'
|
||||
'<circle cx="12" cy="7.8" r="1.1" fill="%COLOR%" stroke="none"/>',
|
||||
"clock": '<circle cx="12" cy="12" r="9"/><polyline points="12 6.8 12 12 15.5 14"/>',
|
||||
"scissors": '<circle cx="6" cy="6" r="2.6"/><circle cx="6" cy="18" r="2.6"/>'
|
||||
'<line x1="20" y1="4" x2="8.1" y2="15.9"/><line x1="14.5" y1="14.5" x2="20" y2="20"/>'
|
||||
'<line x1="8.1" y1="8.1" x2="12" y2="12"/>',
|
||||
"external-link": '<path d="M14 3h7v7"/><line x1="10" y1="14" x2="21" y2="3"/>'
|
||||
'<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
||||
"chevron-down": '<polyline points="6 9 12 15 18 9"/>',
|
||||
"chevron-right": '<polyline points="9 6 15 12 9 18"/>',
|
||||
"clipboard": '<rect x="8" y="3" width="8" height="4" rx="1"/>'
|
||||
'<path d="M16 5h2a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2"/>',
|
||||
"play-circle": '<circle cx="12" cy="12" r="9"/><polygon points="10 8.5 16 12 10 15.5" fill="%COLOR%" stroke="none"/>',
|
||||
}
|
||||
|
||||
_DOC = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" '
|
||||
'fill="none" stroke="%COLOR%" stroke-width="2" stroke-linecap="round" '
|
||||
'stroke-linejoin="round">%BODY%</svg>'
|
||||
)
|
||||
|
||||
|
||||
def available() -> list:
|
||||
"""Every icon name this module can draw. Useful when adding call sites."""
|
||||
return sorted(_SVG)
|
||||
|
||||
|
||||
def _document(name: str, color: str) -> Optional[bytes]:
|
||||
body = _SVG.get(name)
|
||||
if body is None:
|
||||
return None
|
||||
return _DOC.replace("%BODY%", body).replace("%COLOR%", color).encode("utf-8")
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def pixmap(name: str, color: str = DEFAULT_COLOR, size: int = 20, dpr: float = 1.0) -> QPixmap:
|
||||
"""One rendered pixmap. A missing name yields a transparent one, never an error."""
|
||||
px = QPixmap(max(1, int(size * dpr)), max(1, int(size * dpr)))
|
||||
px.setDevicePixelRatio(dpr)
|
||||
px.fill(Qt.GlobalColor.transparent)
|
||||
|
||||
document = _document(name, color)
|
||||
if document is None:
|
||||
# A typo in a call site should show a gap, not take a dialog down.
|
||||
logger.debug(f"Unknown icon name: {name!r}")
|
||||
return px
|
||||
|
||||
renderer = QSvgRenderer(QByteArray(document))
|
||||
if not renderer.isValid():
|
||||
logger.debug(f"Icon {name!r} failed to parse")
|
||||
return px
|
||||
|
||||
painter = QPainter(px)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
try:
|
||||
renderer.render(painter, QRectF(0, 0, size * dpr, size * dpr))
|
||||
finally:
|
||||
painter.end()
|
||||
return px
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def icon(name: str, color: str = DEFAULT_COLOR, size: int = 20, disabled_color: str = "#6b7075") -> QIcon:
|
||||
"""
|
||||
A QIcon with its own Normal and Disabled artwork.
|
||||
|
||||
Both 1x and 2x are added so the icon stays sharp on a HiDPI screen without
|
||||
Qt upscaling a small bitmap.
|
||||
"""
|
||||
result = QIcon()
|
||||
for dpr in (1.0, 2.0):
|
||||
result.addPixmap(pixmap(name, color, size, dpr), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
result.addPixmap(pixmap(name, disabled_color, size, dpr), QIcon.Mode.Disabled, QIcon.State.Off)
|
||||
return result
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Fullscreen without reparenting the video widget
|
||||
===============================================
|
||||
|
||||
The obvious implementation -- take the player out of its layout, make it a
|
||||
top-level window, `showFullScreen()` -- is what SageTube did, and it is a
|
||||
reliable way to crash. Reparenting a widget into a new top-level destroys and
|
||||
recreates the QOpenGLContext of every QOpenGLWidget beneath it, and each
|
||||
recreation is another chance to lose the libmpv render context. A single
|
||||
fullscreen toggle did it twice.
|
||||
|
||||
So nothing is reparented here. The **main window** goes fullscreen and the
|
||||
chrome around the video is hidden. QMainWindow.showFullScreen() reuses the
|
||||
same QWindow and native surface -- on Wayland it is an
|
||||
`xdg_toplevel.set_fullscreen` -- so the GL context is untouched and the video
|
||||
never blinks.
|
||||
|
||||
The same mechanism gives "cinema mode" (hide the chrome, stay windowed) for
|
||||
free, and it gives Escape one unambiguous owner.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, Signal
|
||||
from PySide6.QtWidgets import QMainWindow, QWidget
|
||||
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
|
||||
class FullscreenController(QObject):
|
||||
"""
|
||||
Drives fullscreen for the Watch page.
|
||||
|
||||
Owns no widgets and destroys nothing; it only toggles visibility and the
|
||||
main window's state, and can always put things back.
|
||||
"""
|
||||
|
||||
changed = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
window: QMainWindow,
|
||||
tabs: QWidget,
|
||||
watch_page: QWidget,
|
||||
parent: Optional[QObject] = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._window = window
|
||||
self._tabs = tabs
|
||||
self._watch_page = watch_page
|
||||
self._active = False
|
||||
self._prev_window_state: Optional[Qt.WindowState] = None
|
||||
self._prev_margins = None
|
||||
|
||||
# ------------------------------------------------------------------ state
|
||||
|
||||
def is_active(self) -> bool:
|
||||
return self._active
|
||||
|
||||
def toggle(self) -> None:
|
||||
# Trust the window, not just the flag. If something else put the
|
||||
# window into fullscreen -- a restored geometry, the window manager,
|
||||
# the user's own shortcut -- toggling must get *out* of it rather than
|
||||
# trying to enter a state it is already in and appearing to do nothing.
|
||||
if self._active or self._window.isFullScreen():
|
||||
self.exit()
|
||||
else:
|
||||
self.enter()
|
||||
|
||||
# ------------------------------------------------------------ transitions
|
||||
|
||||
def enter(self) -> None:
|
||||
if self._active:
|
||||
return
|
||||
try:
|
||||
# Fullscreening while another tab is showing would present an
|
||||
# empty screen, so make sure the video is the visible page first.
|
||||
self._show_watch_tab()
|
||||
|
||||
self._prev_window_state = self._window.windowState()
|
||||
|
||||
tab_bar = getattr(self._tabs, "tab_bar", None)
|
||||
if tab_bar is not None:
|
||||
tab_bar.hide()
|
||||
corner = getattr(self._tabs, "corner_widget", None)
|
||||
if corner is not None:
|
||||
corner.hide()
|
||||
|
||||
queue_panel = getattr(self._watch_page, "queue_panel", None)
|
||||
if queue_panel is not None:
|
||||
queue_panel.hide()
|
||||
|
||||
layout = self._watch_page.layout()
|
||||
if layout is not None:
|
||||
self._prev_margins = layout.contentsMargins()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self._window.showFullScreen()
|
||||
self._active = True
|
||||
self.changed.emit(True)
|
||||
except Exception as e:
|
||||
logger.exception(f"Entering fullscreen failed: {e}")
|
||||
# Never leave the UI half-hidden with no way back.
|
||||
self.exit()
|
||||
|
||||
def exit(self) -> None:
|
||||
# Also runs when the controller was never active but the window is
|
||||
# fullscreen anyway, so restoring the chrome is unconditional.
|
||||
if not self._active and self._prev_window_state is None and not self._window.isFullScreen():
|
||||
return
|
||||
try:
|
||||
tab_bar = getattr(self._tabs, "tab_bar", None)
|
||||
if tab_bar is not None:
|
||||
tab_bar.show()
|
||||
corner = getattr(self._tabs, "corner_widget", None)
|
||||
if corner is not None:
|
||||
corner.show()
|
||||
|
||||
queue_panel = getattr(self._watch_page, "queue_panel", None)
|
||||
if queue_panel is not None:
|
||||
queue_panel.show()
|
||||
|
||||
layout = self._watch_page.layout()
|
||||
if layout is not None and self._prev_margins is not None:
|
||||
layout.setContentsMargins(self._prev_margins)
|
||||
|
||||
if self._prev_window_state is not None:
|
||||
# Never restore *into* fullscreen: that is what we are leaving.
|
||||
self._window.setWindowState(self._prev_window_state & ~Qt.WindowState.WindowFullScreen)
|
||||
else:
|
||||
self._window.showNormal()
|
||||
if self._window.isFullScreen():
|
||||
self._window.showNormal()
|
||||
except Exception as e:
|
||||
logger.exception(f"Leaving fullscreen failed: {e}")
|
||||
finally:
|
||||
self._prev_window_state = None
|
||||
self._prev_margins = None
|
||||
self._active = False
|
||||
self.changed.emit(False)
|
||||
|
||||
# ---------------------------------------------------------------- helpers
|
||||
|
||||
def _show_watch_tab(self) -> None:
|
||||
stack = getattr(self._tabs, "stack", None)
|
||||
set_index = getattr(self._tabs, "set_current_index", None)
|
||||
if stack is None or set_index is None:
|
||||
return
|
||||
for i in range(stack.count()):
|
||||
if stack.widget(i) is self._watch_page:
|
||||
set_index(i)
|
||||
return
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Player keyboard and mouse bindings
|
||||
==================================
|
||||
|
||||
The player had one `keyPressEvent` handling Space, F and Escape -- and it never
|
||||
ran. Nothing in the player set a focus policy, so focus always landed on a
|
||||
child button, slider or combo box, which swallowed Space and the arrow keys.
|
||||
There were no mouse handlers at all and no `QShortcut` anywhere in the
|
||||
application.
|
||||
|
||||
Why Qt handlers rather than mpv's own bindings
|
||||
----------------------------------------------
|
||||
`input_default_bindings` stays off. With `vo=libmpv` there is no mpv-owned
|
||||
window, so mpv's input layer receives nothing -- it is fed by the video
|
||||
output's windowing backend, which does not exist in this embedding. Turning
|
||||
default bindings on would only matter if key events were hand-forwarded with
|
||||
`keypress`, which needs a full Qt-to-mpv key-name table, hands mpv ownership of
|
||||
the OSD and OSC (both disabled here), and lets `q` quit the core out from under
|
||||
the Qt UI.
|
||||
|
||||
Routing through Qt also means one action table drives the shortcuts, the
|
||||
context menu and the buttons, and the labels stay translatable.
|
||||
|
||||
Focus, and why the shortcuts are scoped
|
||||
---------------------------------------
|
||||
Bindings are `QShortcut`s with `WidgetWithChildrenShortcut` context on the
|
||||
panel. That fires when the panel *or any descendant* has focus, so it works
|
||||
whichever child holds it -- and it does not steal keys from the Search box or
|
||||
the Downloads URL field on other tabs, which an application-wide shortcut
|
||||
would.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QKeySequence, QShortcut
|
||||
from PySide6.QtWidgets import QMenu, QWidget
|
||||
|
||||
from ..utils.ytsage_localization import _
|
||||
|
||||
#: Seek steps, in seconds.
|
||||
SEEK_SMALL = 1.0
|
||||
SEEK_MEDIUM = 5.0
|
||||
SEEK_LARGE = 10.0
|
||||
VOLUME_STEP = 5
|
||||
SPEED_STEP = 0.25
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
"""One thing the player can do, and every way to ask for it."""
|
||||
|
||||
ident: str
|
||||
keys: List[str]
|
||||
label_key: str
|
||||
method: str
|
||||
icon: Optional[str] = None
|
||||
#: Whether it belongs in the right-click menu.
|
||||
in_menu: bool = True
|
||||
args: tuple = field(default_factory=tuple)
|
||||
|
||||
|
||||
ACTIONS: List[Action] = [
|
||||
Action("play_pause", ["Space", "K", "Media Play"], "player.act_play_pause", "toggle_pause", "play"),
|
||||
Action("seek_back_5", ["Left"], "player.act_seek_back_5", "seek_relative", None, False, (-SEEK_MEDIUM,)),
|
||||
Action("seek_fwd_5", ["Right"], "player.act_seek_fwd_5", "seek_relative", None, False, (SEEK_MEDIUM,)),
|
||||
Action("seek_back_10", ["J"], "player.act_seek_back_10", "seek_relative", None, True, (-SEEK_LARGE,)),
|
||||
Action("seek_fwd_10", ["L"], "player.act_seek_fwd_10", "seek_relative", None, True, (SEEK_LARGE,)),
|
||||
Action("seek_back_1", ["Shift+Left"], "player.act_seek_back_1", "seek_relative", None, False, (-SEEK_SMALL,)),
|
||||
Action("seek_fwd_1", ["Shift+Right"], "player.act_seek_fwd_1", "seek_relative", None, False, (SEEK_SMALL,)),
|
||||
Action("frame_back", [","], "player.act_frame_back", "frame_step", None, False, (-1,)),
|
||||
Action("frame_fwd", ["."], "player.act_frame_fwd", "frame_step", None, False, (1,)),
|
||||
Action("vol_up", ["Up"], "player.act_vol_up", "nudge_volume", None, False, (VOLUME_STEP,)),
|
||||
Action("vol_down", ["Down"], "player.act_vol_down", "nudge_volume", None, False, (-VOLUME_STEP,)),
|
||||
Action("mute", ["M"], "player.act_mute", "toggle_mute", "volume-mute"),
|
||||
Action("speed_up", ["]"], "player.act_speed_up", "nudge_speed", None, False, (SPEED_STEP,)),
|
||||
Action("speed_down", ["["], "player.act_speed_down", "nudge_speed", None, False, (-SPEED_STEP,)),
|
||||
Action("speed_reset", ["Backspace"], "player.act_speed_reset", "reset_speed", None, True),
|
||||
Action("subtitles", ["C"], "player.act_subtitles", "cycle_subtitles", "captions"),
|
||||
Action("fullscreen", ["F"], "player.act_fullscreen", "toggle_fullscreen", "maximize"),
|
||||
Action("leave_fullscreen", ["Escape"], "player.act_leave_fullscreen", "exit_fullscreen", None, False),
|
||||
Action("next", ["N", "Ctrl+Right", "Media Next"], "player.act_next", "request_next", "skip-forward"),
|
||||
Action("previous", ["P", "Ctrl+Left", "Media Previous"], "player.act_previous", "request_previous", "skip-back"),
|
||||
Action("start", ["Home"], "player.act_start", "seek_absolute", None, False, (0.0,)),
|
||||
Action("end", ["End"], "player.act_end", "seek_to_end", None, False),
|
||||
]
|
||||
|
||||
#: 0-9 jump to that tenth of the video, as YouTube does.
|
||||
PERCENT_KEYS = [(str(n), n / 10.0) for n in range(10)]
|
||||
|
||||
|
||||
def install_player_shortcuts(panel: QWidget) -> List[QShortcut]:
|
||||
"""
|
||||
Bind every action to the panel.
|
||||
|
||||
Returns the shortcuts so the caller can keep them alive -- a QShortcut
|
||||
with no reference is collected and silently stops working.
|
||||
"""
|
||||
shortcuts: List[QShortcut] = []
|
||||
|
||||
def bind(sequence: str, handler: Callable) -> None:
|
||||
shortcut = QShortcut(QKeySequence(sequence), panel)
|
||||
shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
||||
shortcut.activated.connect(handler)
|
||||
shortcuts.append(shortcut)
|
||||
|
||||
for action in ACTIONS:
|
||||
method = getattr(panel, action.method, None)
|
||||
if method is None:
|
||||
continue
|
||||
for key in action.keys:
|
||||
bind(key, (lambda m=method, a=action.args: m(*a)))
|
||||
|
||||
seek_percent = getattr(panel, "seek_percent", None)
|
||||
if seek_percent is not None:
|
||||
for key, fraction in PERCENT_KEYS:
|
||||
bind(key, (lambda f=fraction: seek_percent(f)))
|
||||
|
||||
return shortcuts
|
||||
|
||||
|
||||
def build_context_menu(panel: QWidget) -> QMenu:
|
||||
"""The right-click menu, from the same table as the shortcuts."""
|
||||
from . import ytsage_icons as icons
|
||||
from . import ytsage_theme as theme
|
||||
|
||||
menu = QMenu(panel)
|
||||
for action in ACTIONS:
|
||||
if not action.in_menu:
|
||||
continue
|
||||
method = getattr(panel, action.method, None)
|
||||
if method is None:
|
||||
continue
|
||||
label = _(action.label_key)
|
||||
if action.keys:
|
||||
label = f"{label}\t{action.keys[0]}"
|
||||
entry = menu.addAction(label)
|
||||
if action.icon:
|
||||
entry.setIcon(icons.icon(action.icon, theme.ICON))
|
||||
entry.triggered.connect(lambda _checked=False, m=method, a=action.args: m(*a))
|
||||
|
||||
menu.addSeparator()
|
||||
copy_url = getattr(panel, "copy_video_url", None)
|
||||
if copy_url is not None:
|
||||
entry = menu.addAction(_("player.act_copy_url"))
|
||||
entry.setIcon(icons.icon("clipboard", theme.ICON))
|
||||
entry.triggered.connect(lambda _checked=False: copy_url())
|
||||
open_browser = getattr(panel, "open_in_browser", None)
|
||||
if open_browser is not None:
|
||||
entry = menu.addAction(_("player.act_open_browser"))
|
||||
entry.setIcon(icons.icon("external-link", theme.ICON))
|
||||
entry.triggered.connect(lambda _checked=False: open_browser())
|
||||
return menu
|
||||
@@ -1,8 +1,10 @@
|
||||
from PySide6.QtCore import QEasingCurve, QPropertyAnimation, Qt
|
||||
from PySide6.QtCore import QEasingCurve, QPropertyAnimation, Qt, Signal
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QGraphicsOpacityEffect,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QStackedWidget,
|
||||
QTabBar,
|
||||
@@ -19,6 +21,13 @@ class FadingStackedWidget(QStackedWidget):
|
||||
self.fade_duration = 300
|
||||
self.fade_easing = QEasingCurve.Type.OutQuad
|
||||
|
||||
@staticmethod
|
||||
def _contains_gl(widget):
|
||||
"""Whether an OpenGL surface lives anywhere under this page."""
|
||||
if widget is None:
|
||||
return False
|
||||
return isinstance(widget, QOpenGLWidget) or widget.findChild(QOpenGLWidget) is not None
|
||||
|
||||
def setCurrentIndex(self, index):
|
||||
curr_index = self.currentIndex()
|
||||
if index == curr_index:
|
||||
@@ -32,6 +41,15 @@ class FadingStackedWidget(QStackedWidget):
|
||||
super().setCurrentIndex(index)
|
||||
return
|
||||
|
||||
# Never fade a page containing an OpenGL surface. grab() on one
|
||||
# returns a black rectangle -- so the "fade" was a black slab sliding
|
||||
# over the new tab -- and forcing a framebuffer readback plus a
|
||||
# QGraphicsEffect over that subtree is a way to lose the GL context,
|
||||
# which for the embedded mpv player means a crash.
|
||||
if self._contains_gl(widget) or self._contains_gl(curr_widget):
|
||||
super().setCurrentIndex(index)
|
||||
return
|
||||
|
||||
# 1. Capture the current view (the "old" tab)
|
||||
# Use grab() for simplicity and reliability in PySide6
|
||||
pixmap = self.grab()
|
||||
@@ -77,6 +95,11 @@ class SmoothTabWidget(QWidget):
|
||||
A unified Widget that behaves like a QTabWidget but uses smooth fading transitions.
|
||||
Includes a QTabBar and a FadingStackedWidget.
|
||||
"""
|
||||
#: Emitted after the visible page has actually changed. The QTabBar's own
|
||||
#: currentChanged is not usable for this: it fires before the stack has
|
||||
#: switched, and it re-enters through set_current_index.
|
||||
currentChanged = Signal(int)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
@@ -85,11 +108,24 @@ class SmoothTabWidget(QWidget):
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.layout.setSpacing(0)
|
||||
|
||||
# Tab Bar
|
||||
self.tab_bar = QTabBar(self)
|
||||
# Tab bar, plus a right-aligned slot for a corner widget. Wrapped in a
|
||||
# row so something (the account button) can sit opposite the tabs the
|
||||
# way QTabWidget::setCornerWidget would allow.
|
||||
self.tab_row = QWidget(self)
|
||||
tab_row_layout = QHBoxLayout(self.tab_row)
|
||||
tab_row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
tab_row_layout.setSpacing(0)
|
||||
|
||||
self.tab_bar = QTabBar(self.tab_row)
|
||||
self.tab_bar.setDrawBase(False) # We draw border on content instead
|
||||
self.tab_bar.currentChanged.connect(self.set_current_index)
|
||||
self.layout.addWidget(self.tab_bar)
|
||||
tab_row_layout.addWidget(self.tab_bar)
|
||||
tab_row_layout.addStretch(1)
|
||||
|
||||
self.corner_widget = None
|
||||
self._switching = False
|
||||
self._tab_row_layout = tab_row_layout
|
||||
self.layout.addWidget(self.tab_row)
|
||||
|
||||
# Content Area (Frame) - Mimics QTabWidget::pane
|
||||
self.content_frame = QFrame(self)
|
||||
@@ -106,18 +142,70 @@ class SmoothTabWidget(QWidget):
|
||||
|
||||
self.layout.addWidget(self.content_frame)
|
||||
|
||||
def addTab(self, widget, label):
|
||||
"""Add a tab with the given widget and label."""
|
||||
def addTab(self, widget, label, icon=None):
|
||||
"""Add a tab with the given widget, label and optional icon."""
|
||||
self.stack.addWidget(widget)
|
||||
if icon is not None:
|
||||
self.tab_bar.addTab(icon, label)
|
||||
else:
|
||||
self.tab_bar.addTab(label)
|
||||
|
||||
def setCornerWidget(self, widget):
|
||||
"""Place a widget at the right-hand end of the tab row."""
|
||||
if self.corner_widget is not None:
|
||||
self.corner_widget.setParent(None)
|
||||
self.corner_widget = widget
|
||||
if widget is not None:
|
||||
widget.setParent(self.tab_row)
|
||||
self._tab_row_layout.addWidget(widget)
|
||||
|
||||
def set_current_index(self, index):
|
||||
"""Slot to handle tab bar clicks."""
|
||||
if index == self.stack.currentIndex() and self.tab_bar.currentIndex() == index:
|
||||
return
|
||||
# setCurrentIndex on the bar emits its own currentChanged, which is
|
||||
# connected straight back here -- and at that moment the stack has not
|
||||
# moved yet, so a plain index comparison does not catch the re-entry.
|
||||
# Without this flag every switch emitted currentChanged twice and each
|
||||
# page's activation hook ran twice.
|
||||
if self._switching:
|
||||
return
|
||||
self._switching = True
|
||||
try:
|
||||
self.tab_bar.setCurrentIndex(index)
|
||||
self.stack.setCurrentIndex(index)
|
||||
finally:
|
||||
self._switching = False
|
||||
self.currentChanged.emit(index)
|
||||
|
||||
def currentWidget(self):
|
||||
return self.stack.currentWidget()
|
||||
|
||||
def currentIndex(self):
|
||||
return self.stack.currentIndex()
|
||||
|
||||
# --- QTabWidget-compatible API ---------------------------------------
|
||||
# This class claims to "behave like a QTabWidget" and is used as a drop-in
|
||||
# for one (CustomOptionsDialog builds its tabs on it), but it only ever
|
||||
# offered set_current_index. Anything calling the Qt spelling raised
|
||||
# AttributeError -- which is why "Sign in with cookies" appeared to do
|
||||
# nothing: show_cookie_login_dialog selects the Cookies tab by index
|
||||
# before showing the dialog, and died on that line.
|
||||
|
||||
def setCurrentIndex(self, index):
|
||||
self.set_current_index(index)
|
||||
|
||||
def count(self):
|
||||
return self.stack.count()
|
||||
|
||||
def widget(self, index):
|
||||
return self.stack.widget(index)
|
||||
|
||||
def indexOf(self, widget):
|
||||
return self.stack.indexOf(widget)
|
||||
|
||||
def tabText(self, index):
|
||||
return self.tab_bar.tabText(index)
|
||||
|
||||
def setTabText(self, index, text):
|
||||
self.tab_bar.setTabText(index, text)
|
||||
|
||||
@@ -224,20 +224,26 @@ class StyleSheet:
|
||||
padding: 5px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
/* Square, not round: a fully-rounded indicator reads as a radio
|
||||
button, i.e. "one of these", when these are independent
|
||||
on/off options. */
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #1d1e22;
|
||||
border-radius: 9px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked:hover {
|
||||
border-color: #9aa0a6;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
border-radius: 9px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox:disabled { color: #888888; }
|
||||
QCheckBox::indicator:disabled { border-color: #555555; background: #444444; }
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
Theme tokens and the stylesheet rules the app never had
|
||||
=======================================================
|
||||
|
||||
`StyleSheet.MAIN` (upstream's, in ytsage_stylesheet.py) styles the window,
|
||||
line edits, buttons, tables, progress bars and vertical scrollbars -- and
|
||||
nothing else. Everything it misses falls through to `QWidget { background:
|
||||
#15181b; color: #ffffff }` plus the platform style, which is why the app looked
|
||||
half-finished: the main tab bar was drawn by Fusion from the system palette
|
||||
with white text forced onto it, combo boxes and sliders were native, tooltips
|
||||
came out in the system's light style on a black app, and a native light
|
||||
horizontal scrollbar appeared under any wide view.
|
||||
|
||||
Notably `SmoothTabWidget` names its content frame `tabContent` with the comment
|
||||
"We draw border on content instead" -- and the only `QFrame#tabContent` rules
|
||||
in the codebase were inside two dialogs. The main window's tab bar got none of
|
||||
it.
|
||||
|
||||
This module holds the colour tokens and `EXTRA_QSS`, appended to
|
||||
`StyleSheet.MAIN` at the single application site. Keeping it separate means one
|
||||
changed line in an upstream-owned file, so the next merge from YTSage does not
|
||||
fight over it.
|
||||
|
||||
The two dialogs that define their own tab rules keep them. Unifying would cost
|
||||
more in merge conflicts than the duplication does.
|
||||
"""
|
||||
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
# --- colour tokens -------------------------------------------------------
|
||||
# Names, not hexes, at the call sites. These match what StyleSheet.MAIN
|
||||
# already uses so the two halves agree.
|
||||
BG = "#15181b" # window
|
||||
SURFACE = "#1b2021" # panels, inputs, list backgrounds
|
||||
SURFACE_ALT = "#1d2124" # menus, tooltips, elevated surfaces
|
||||
SURFACE_HOVER = "#252a2d"
|
||||
BORDER = "#2a2d2e"
|
||||
BORDER_STRONG = "#3d3d3d"
|
||||
TEXT = "#ffffff"
|
||||
TEXT_MUTED = "#9aa0a6"
|
||||
TEXT_DISABLED = "#6b7075"
|
||||
ACCENT = "#c90000"
|
||||
ACCENT_HOVER = "#a50000"
|
||||
ACCENT_PRESSED = "#800000"
|
||||
FOCUS = "#ff6b6b"
|
||||
|
||||
#: Default icon colour: near-white, deliberately not pure #ffffff so it does
|
||||
#: not out-glare the text beside it.
|
||||
ICON = "#e8eaed"
|
||||
#: Icons sitting on an accent-coloured button.
|
||||
ICON_ON_ACCENT = "#ffffff"
|
||||
|
||||
|
||||
def ensure_ui_assets() -> dict:
|
||||
"""
|
||||
Write the few bitmaps Qt stylesheets can only reference as files.
|
||||
|
||||
QSS cannot draw a shape: `::down-arrow` and `::indicator` need an actual
|
||||
`image:`. Everything else here is drawn with borders and radii, but a
|
||||
caret and a tick are not expressible that way -- restyling a combo box
|
||||
without supplying one leaves it with a blank square where its arrow was.
|
||||
|
||||
Rendered from the same SVG set as every other icon and cached next to the
|
||||
thumbnails, so nothing extra ships and nothing is fetched.
|
||||
"""
|
||||
from . import ytsage_icons as icons
|
||||
from ..utils.ytsage_constants import APP_DATA_DIR
|
||||
|
||||
out_dir = APP_DATA_DIR / "ui"
|
||||
# Rendered larger than the box they are drawn into (see the QSS below):
|
||||
# a 24-unit viewBox squeezed straight into 12px leaves a 1px stroke that
|
||||
# all but disappears on this background.
|
||||
wanted = {
|
||||
"chevron": ("chevron-down", TEXT_MUTED, 14),
|
||||
"chevron_disabled": ("chevron-down", TEXT_DISABLED, 14),
|
||||
"check": ("check", TEXT, 12),
|
||||
}
|
||||
paths = {}
|
||||
try:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
for key, (name, color, size) in wanted.items():
|
||||
target = out_dir / f"{key}.png"
|
||||
if not target.exists():
|
||||
# dpr 1.0 and the final pixel size: Qt draws a QSS `image:` at
|
||||
# its natural size, so anything else is scaled or clipped.
|
||||
icons.pixmap(name, color, size, 1.0).save(str(target), "PNG")
|
||||
# QSS wants forward slashes even on Windows.
|
||||
paths[key] = str(target).replace("\\", "/")
|
||||
except Exception as e: # pragma: no cover - cosmetic only
|
||||
logger.debug(f"Could not write UI assets, falling back to plain styling: {e}")
|
||||
return {}
|
||||
return paths
|
||||
|
||||
|
||||
def build_extra_qss() -> str:
|
||||
"""EXTRA_QSS plus the rules that need generated images, when available."""
|
||||
qss = EXTRA_QSS
|
||||
assets = ensure_ui_assets()
|
||||
if not assets:
|
||||
return qss
|
||||
return qss + f"""
|
||||
QComboBox::down-arrow {{
|
||||
image: url("{assets['chevron']}");
|
||||
border: none;
|
||||
}}
|
||||
QComboBox::down-arrow:disabled {{ image: url("{assets['chevron_disabled']}"); }}
|
||||
|
||||
/* Upstream styles checkboxes as filled circles (border-radius: 9px), which
|
||||
reads as a radio button -- a control that means "one of these" rather than
|
||||
"on or off". Square them off and show an actual tick. Widget-level
|
||||
stylesheets win over this one, so StyleSheet.CHECKBOX is corrected at
|
||||
source; these rules cover every checkbox that does not use it. */
|
||||
QCheckBox::indicator {{
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid {BORDER_STRONG};
|
||||
background-color: {SURFACE};
|
||||
}}
|
||||
QCheckBox::indicator:hover {{ border-color: {TEXT_MUTED}; }}
|
||||
QCheckBox::indicator:checked {{
|
||||
border-color: {ACCENT};
|
||||
background-color: {ACCENT};
|
||||
image: url("{assets['check']}");
|
||||
}}
|
||||
QCheckBox::indicator:checked:hover {{ border-color: {FOCUS}; }}
|
||||
QCheckBox::indicator:disabled {{ border-color: {BORDER}; background-color: {BG}; }}
|
||||
"""
|
||||
|
||||
|
||||
EXTRA_QSS = f"""
|
||||
/* ---- main tab bar (SmoothTabWidget) --------------------------------- */
|
||||
QTabBar {{
|
||||
background-color: {BG};
|
||||
border: none;
|
||||
}}
|
||||
QTabBar::tab {{
|
||||
background-color: transparent;
|
||||
color: {TEXT_MUTED};
|
||||
padding: 9px 18px;
|
||||
margin-right: 2px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 13px;
|
||||
}}
|
||||
QTabBar::tab:hover:!selected {{
|
||||
color: {TEXT};
|
||||
background-color: {SURFACE};
|
||||
}}
|
||||
QTabBar::tab:selected {{
|
||||
color: {TEXT};
|
||||
background-color: {SURFACE};
|
||||
border-bottom: 2px solid {ACCENT};
|
||||
}}
|
||||
QTabBar::tab:disabled {{
|
||||
color: {TEXT_DISABLED};
|
||||
}}
|
||||
QFrame#tabContent {{
|
||||
background-color: {BG};
|
||||
border: none;
|
||||
border-top: 1px solid {BORDER};
|
||||
}}
|
||||
|
||||
/* ---- combo boxes ---------------------------------------------------- */
|
||||
QComboBox {{
|
||||
background-color: {SURFACE};
|
||||
color: {TEXT};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
min-height: 18px;
|
||||
}}
|
||||
QComboBox:hover {{ border-color: {BORDER_STRONG}; }}
|
||||
QComboBox:focus {{ border-color: {FOCUS}; }}
|
||||
QComboBox:disabled {{ color: {TEXT_DISABLED}; background-color: {BG}; }}
|
||||
QComboBox::drop-down {{
|
||||
border: none;
|
||||
width: 22px;
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: center right;
|
||||
}}
|
||||
/* ::down-arrow is deliberately not styled here -- QSS cannot draw a caret,
|
||||
and setting only a border leaves a small square where the arrow was.
|
||||
build_extra_qss() supplies a real image; without it Qt's native arrow is
|
||||
the better fallback. */
|
||||
QComboBox QAbstractItemView {{
|
||||
background-color: {SURFACE_ALT};
|
||||
color: {TEXT};
|
||||
border: 1px solid {BORDER};
|
||||
selection-background-color: {ACCENT};
|
||||
selection-color: {TEXT};
|
||||
outline: none;
|
||||
padding: 2px;
|
||||
}}
|
||||
|
||||
/* ---- lists ---------------------------------------------------------- */
|
||||
QListWidget, QListView {{
|
||||
background-color: {SURFACE};
|
||||
color: {TEXT};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
}}
|
||||
QListWidget::item, QListView::item {{
|
||||
padding: 6px 8px;
|
||||
border-radius: 3px;
|
||||
}}
|
||||
QListWidget::item:hover, QListView::item:hover {{ background-color: {SURFACE_HOVER}; }}
|
||||
QListWidget::item:selected, QListView::item:selected {{
|
||||
background-color: {ACCENT};
|
||||
color: {TEXT};
|
||||
}}
|
||||
|
||||
/* ---- menus ---------------------------------------------------------- */
|
||||
QMenu {{
|
||||
background-color: {SURFACE_ALT};
|
||||
color: {TEXT};
|
||||
border: 1px solid {BORDER};
|
||||
padding: 4px;
|
||||
}}
|
||||
QMenu::item {{ padding: 6px 22px 6px 14px; border-radius: 3px; }}
|
||||
QMenu::item:selected {{ background-color: {ACCENT}; }}
|
||||
QMenu::item:disabled {{ color: {TEXT_DISABLED}; }}
|
||||
QMenu::separator {{ height: 1px; background-color: {BORDER}; margin: 4px 6px; }}
|
||||
|
||||
/* ---- sliders (seek and volume were fully native) --------------------- */
|
||||
QSlider::groove:horizontal {{
|
||||
height: 4px;
|
||||
background-color: {BORDER};
|
||||
border-radius: 2px;
|
||||
}}
|
||||
QSlider::sub-page:horizontal {{
|
||||
background-color: {ACCENT};
|
||||
border-radius: 2px;
|
||||
}}
|
||||
QSlider::handle:horizontal {{
|
||||
background-color: {TEXT};
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: -5px 0;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
QSlider::handle:horizontal:hover {{ background-color: {FOCUS}; }}
|
||||
QSlider::handle:horizontal:disabled {{ background-color: {TEXT_DISABLED}; }}
|
||||
QSlider::groove:vertical {{ width: 4px; background-color: {BORDER}; border-radius: 2px; }}
|
||||
QSlider::handle:vertical {{
|
||||
background-color: {TEXT}; height: 12px; margin: 0 -5px; border-radius: 6px;
|
||||
}}
|
||||
|
||||
/* ---- splitters ------------------------------------------------------- */
|
||||
QSplitter::handle {{ background-color: {BORDER}; }}
|
||||
QSplitter::handle:horizontal {{ width: 4px; }}
|
||||
QSplitter::handle:vertical {{ height: 4px; }}
|
||||
QSplitter::handle:hover {{ background-color: {ACCENT}; }}
|
||||
|
||||
/* ---- tooltips (were rendering in the system's light style) ----------- */
|
||||
QToolTip {{
|
||||
background-color: {SURFACE_ALT};
|
||||
color: {TEXT};
|
||||
border: 1px solid {BORDER};
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
|
||||
/* ---- horizontal scrollbar (only the vertical one was styled) --------- */
|
||||
QScrollBar:horizontal {{
|
||||
background-color: {BG};
|
||||
height: 12px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{
|
||||
background-color: {BORDER_STRONG};
|
||||
min-width: 24px;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal:hover {{ background-color: {ACCENT}; }}
|
||||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
|
||||
width: 0; border: none; background: none;
|
||||
}}
|
||||
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{ background: none; }}
|
||||
|
||||
/* ---- assorted controls left unstyled --------------------------------- */
|
||||
QRadioButton, QCheckBox {{ color: {TEXT}; spacing: 7px; }}
|
||||
QRadioButton:disabled, QCheckBox:disabled {{ color: {TEXT_DISABLED}; }}
|
||||
QGroupBox {{
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 5px;
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
color: {TEXT};
|
||||
}}
|
||||
QGroupBox::title {{
|
||||
subcontrol-origin: margin;
|
||||
left: 10px;
|
||||
padding: 0 5px;
|
||||
color: {TEXT_MUTED};
|
||||
}}
|
||||
QSpinBox, QDoubleSpinBox {{
|
||||
background-color: {SURFACE};
|
||||
color: {TEXT};
|
||||
border: 1px solid {BORDER};
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
}}
|
||||
|
||||
/* ---- corrections to rules that already exist ------------------------- */
|
||||
/* Upstream's QPushButton:pressed changes the padding, which shifts every
|
||||
label two pixels down-and-right on click and clips the artwork on the
|
||||
fixed-width icon buttons. The background change alone reads as pressed. */
|
||||
QPushButton:pressed {{
|
||||
padding: 8px 15px;
|
||||
}}
|
||||
/* One consistent icon size everywhere, since icons are now real artwork. */
|
||||
QPushButton, QToolButton {{
|
||||
qproperty-iconSize: 18px 18px;
|
||||
}}
|
||||
QToolButton {{
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
color: {TEXT};
|
||||
}}
|
||||
QToolButton:hover {{ background-color: {SURFACE_HOVER}; }}
|
||||
QToolButton:pressed {{ background-color: {BORDER}; }}
|
||||
"""
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Application-wide button polish
|
||||
==============================
|
||||
|
||||
Two problems, both spread across every file in the GUI:
|
||||
|
||||
- **Emoji standing in for icons.** `cards.download` is the single character
|
||||
"⬇" on a button with no text and no tooltip; the queue's clear button is
|
||||
"✕"; the downloads folder button is "📁". These depend entirely on a system
|
||||
emoji font, and render as tofu boxes without one.
|
||||
- **Almost nothing has a tooltip.** Three widgets in the whole application set
|
||||
one, and nothing sets an accessible name -- so icon-only buttons are
|
||||
unidentifiable to sighted users and invisible to a screen reader alike.
|
||||
|
||||
Fixing that at the call sites would mean editing well over a hundred of them,
|
||||
most in files owned by upstream YTSage, which would conflict on the next merge.
|
||||
|
||||
Instead this installs one event filter on the QApplication and handles
|
||||
`QEvent.Polish`, which Qt sends to every widget exactly once before it is first
|
||||
shown -- including widgets inside dialogs built by upstream code, and including
|
||||
any added later. One hook, whole tree, no call-site edits.
|
||||
|
||||
New code does not need the glyph map: give a button a `sageIcon` property and
|
||||
this will resolve it.
|
||||
|
||||
btn.setProperty("sageIcon", "download")
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject
|
||||
from PySide6.QtWidgets import QAbstractButton, QApplication
|
||||
|
||||
from . import ytsage_icons as icons
|
||||
from . import ytsage_theme as theme
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
#: Emoji that are standing in for icons, and what they actually mean.
|
||||
#: Matched as the whole label, or as a leading glyph followed by real text
|
||||
#: ("▶ Play"), never mid-string.
|
||||
GLYPH_ICONS: Dict[str, str] = {
|
||||
"⬇": "download",
|
||||
"▶": "play",
|
||||
"⏸": "pause",
|
||||
"⏹": "stop",
|
||||
"✕": "x",
|
||||
"✖": "x",
|
||||
"×": "x",
|
||||
"+": "plus",
|
||||
"+": "plus",
|
||||
"📁": "folder-open",
|
||||
"📂": "folder-open",
|
||||
"🔄": "refresh",
|
||||
"🗑": "trash",
|
||||
"⚙": "settings",
|
||||
"🔍": "search",
|
||||
}
|
||||
|
||||
#: Tooltips for buttons whose label is only artwork once the glyph is
|
||||
#: replaced, so they do not end up with no description at all.
|
||||
ICON_TOOLTIPS: Dict[str, str] = {
|
||||
"download": "Download",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"stop": "Stop",
|
||||
"x": "Clear",
|
||||
"plus": "Add",
|
||||
"folder-open": "Open folder",
|
||||
"refresh": "Refresh",
|
||||
"trash": "Delete",
|
||||
"settings": "Settings",
|
||||
"search": "Search",
|
||||
"maximize": "Fullscreen",
|
||||
"minimize": "Leave fullscreen",
|
||||
"volume-mute": "Mute",
|
||||
"skip-forward": "Next",
|
||||
"skip-back": "Previous",
|
||||
}
|
||||
|
||||
|
||||
def _strip_accelerator(text: str) -> str:
|
||||
""""&Yes" -> "Yes". Qt's mnemonic markers must not defeat matching."""
|
||||
return text.replace("&&", "\x00").replace("&", "").replace("\x00", "&")
|
||||
|
||||
|
||||
class ButtonPolisher(QObject):
|
||||
"""Turns placeholder glyphs into icons and fills in missing descriptions."""
|
||||
|
||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||
if event.type() == QEvent.Type.Polish and isinstance(obj, QAbstractButton):
|
||||
try:
|
||||
self._polish_button(obj)
|
||||
except Exception as e:
|
||||
# Cosmetics must never break a dialog from opening.
|
||||
logger.debug(f"Button polish skipped: {e}")
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _polish_button(self, btn: QAbstractButton) -> None:
|
||||
icon_name: Optional[str] = None
|
||||
|
||||
# 1. Explicit opt-in wins.
|
||||
declared = btn.property("sageIcon")
|
||||
if declared:
|
||||
icon_name = str(declared)
|
||||
|
||||
raw_text = btn.text() or ""
|
||||
text = _strip_accelerator(raw_text).strip()
|
||||
|
||||
# 2. Otherwise infer from a placeholder glyph. Buttons carrying a
|
||||
# mnemonic are left alone: those are standard dialog buttons.
|
||||
if icon_name is None and "&" not in raw_text and text:
|
||||
first = text[0]
|
||||
if text in GLYPH_ICONS:
|
||||
icon_name = GLYPH_ICONS[text]
|
||||
text = "" # the glyph *was* the whole label
|
||||
elif first in GLYPH_ICONS and len(text) > 1 and text[1] in " \t":
|
||||
icon_name = GLYPH_ICONS[first]
|
||||
text = text[1:].strip()
|
||||
|
||||
if icon_name is not None and btn.icon().isNull():
|
||||
# White reads on the accent-coloured buttons; the muted token
|
||||
# would disappear into them.
|
||||
btn.setIcon(icons.icon(icon_name, theme.ICON_ON_ACCENT))
|
||||
if text != _strip_accelerator(raw_text).strip():
|
||||
btn.setText(text)
|
||||
|
||||
# 3. Every button gets something to describe it.
|
||||
label = _strip_accelerator(btn.text() or "").strip()
|
||||
if not btn.toolTip():
|
||||
if label:
|
||||
btn.setToolTip(label)
|
||||
elif icon_name and icon_name in ICON_TOOLTIPS:
|
||||
btn.setToolTip(ICON_TOOLTIPS[icon_name])
|
||||
elif not btn.icon().isNull():
|
||||
# An icon-only button nobody has described. Name it so the
|
||||
# gap is findable rather than silent.
|
||||
logger.debug(f"Icon-only button without a tooltip: {btn.objectName() or btn!r}")
|
||||
|
||||
if not btn.accessibleName():
|
||||
btn.setAccessibleName(label or btn.toolTip())
|
||||
|
||||
|
||||
_polisher: Optional[ButtonPolisher] = None
|
||||
|
||||
|
||||
def install(app: Optional[QApplication] = None) -> None:
|
||||
"""Install once, before the main window is built."""
|
||||
global _polisher
|
||||
if _polisher is not None:
|
||||
return
|
||||
app = app or QApplication.instance()
|
||||
if app is None:
|
||||
logger.debug("UI polish not installed: no QApplication yet.")
|
||||
return
|
||||
_polisher = ButtonPolisher(app)
|
||||
app.installEventFilter(_polisher)
|
||||
logger.debug("Button polish filter installed.")
|
||||
+137
-7
@@ -22,7 +22,7 @@
|
||||
"restart_notice": "Language change will take effect after restarting the application."
|
||||
},
|
||||
"app": {
|
||||
"title": "YTSage",
|
||||
"title": "SageTube",
|
||||
"version": "v{version}",
|
||||
"ready": "Ready"
|
||||
},
|
||||
@@ -240,9 +240,9 @@
|
||||
"update_in_progress_message": "yt-dlp is currently updating. Please wait a moment."
|
||||
},
|
||||
"about": {
|
||||
"title": "About YTSage",
|
||||
"title": "About SageTube",
|
||||
"version": "Version {version}",
|
||||
"description": "Modern YouTube downloader with clean PySide6 interface.",
|
||||
"description": "Watch-first YouTube client with embedded mpv playback and yt-dlp downloading. Fork of YTSage by oop7 (MIT).",
|
||||
"author": "By: {author}",
|
||||
"github": "GitHub: {repo}",
|
||||
"system_info": "System Information",
|
||||
@@ -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",
|
||||
@@ -639,5 +642,132 @@
|
||||
"update_success": "✅ Deno has been successfully updated!",
|
||||
"update_failed": "❌ Update failed: {error}",
|
||||
"check_failed": "Failed to check for updates. Please check your internet connection."
|
||||
},
|
||||
"player": {
|
||||
"quality_auto": "Auto",
|
||||
"subtitles": "CC",
|
||||
"unavailable": "Video player unavailable",
|
||||
"now_playing": "Now Playing",
|
||||
"queue": "Queue",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"stream_stalled": "Stream failed to start after several attempts - YouTube may be throttling. Try again or pick a lower quality.",
|
||||
"play_pause": "Play / pause (Space)",
|
||||
"fullscreen": "Fullscreen (F)",
|
||||
"seek_tooltip": "Seek through the video",
|
||||
"quality_tooltip": "Maximum playback resolution",
|
||||
"speed_tooltip": "Playback speed",
|
||||
"volume_tooltip": "Volume",
|
||||
"subtitles_tooltip": "Toggle subtitles (C)",
|
||||
"previous": "Previous in queue (P)",
|
||||
"next": "Next in queue (N)",
|
||||
"buffering": "Buffering…",
|
||||
"act_play_pause": "Play / pause",
|
||||
"act_seek_back_5": "Back 5 seconds",
|
||||
"act_seek_fwd_5": "Forward 5 seconds",
|
||||
"act_seek_back_10": "Back 10 seconds",
|
||||
"act_seek_fwd_10": "Forward 10 seconds",
|
||||
"act_seek_back_1": "Back 1 second",
|
||||
"act_seek_fwd_1": "Forward 1 second",
|
||||
"act_frame_back": "Previous frame",
|
||||
"act_frame_fwd": "Next frame",
|
||||
"act_vol_up": "Volume up",
|
||||
"act_vol_down": "Volume down",
|
||||
"act_mute": "Mute",
|
||||
"act_unmute": "Unmute",
|
||||
"act_speed_up": "Speed up",
|
||||
"act_speed_down": "Slow down",
|
||||
"act_speed_reset": "Normal speed",
|
||||
"act_subtitles": "Subtitles on / off",
|
||||
"act_fullscreen": "Fullscreen",
|
||||
"act_leave_fullscreen": "Leave fullscreen",
|
||||
"act_next": "Next in queue",
|
||||
"act_previous": "Previous",
|
||||
"act_start": "Back to start",
|
||||
"act_end": "Jump to end",
|
||||
"act_copy_url": "Copy video link",
|
||||
"act_open_browser": "Open in browser"
|
||||
},
|
||||
"cards": {
|
||||
"play": "▶ Play",
|
||||
"queue": "+ Queue",
|
||||
"download": "⬇",
|
||||
"load_more": "Load more",
|
||||
"empty": "Nothing here yet",
|
||||
"play_tooltip": "Play this video now",
|
||||
"queue_tooltip": "Add to the play queue",
|
||||
"download_tooltip": "Open in Downloads with this video ready to analyse",
|
||||
"load_more_tooltip": "Load the next page of results"
|
||||
},
|
||||
"main_tabs": {
|
||||
"watch": "Watch",
|
||||
"search": "Search",
|
||||
"feed": "Feed",
|
||||
"browse": "Browse",
|
||||
"downloads": "Downloads"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search YouTube...",
|
||||
"button": "Search",
|
||||
"searching": "Searching...",
|
||||
"results_count": "{count} results",
|
||||
"failed": "Search failed: {error}",
|
||||
"button_tooltip": "Search YouTube"
|
||||
},
|
||||
"watch": {
|
||||
"clear_queue": "Clear queue"
|
||||
},
|
||||
"browse": {
|
||||
"placeholder": "Paste a channel or playlist URL...",
|
||||
"open": "Open",
|
||||
"subscribe": "Subscribe",
|
||||
"unsubscribe": "Unsubscribe",
|
||||
"play_all": "Play all",
|
||||
"download_playlist": "Download",
|
||||
"tab_videos": "Videos",
|
||||
"tab_shorts": "Shorts",
|
||||
"tab_live": "Live",
|
||||
"loading": "Loading...",
|
||||
"entry_count": "{count} videos",
|
||||
"failed": "Failed to load: {error}",
|
||||
"hint": "Open a channel (youtube.com/@name) or playlist URL, or navigate here from Search.",
|
||||
"open_tooltip": "Open this channel or playlist",
|
||||
"subscribe_tooltip": "Follow this channel so its uploads appear in your Feed",
|
||||
"unsubscribe_tooltip": "Stop following this channel",
|
||||
"play_all_tooltip": "Queue everything loaded here",
|
||||
"download_playlist_tooltip": "Open this playlist in Downloads",
|
||||
"queued_loaded": "Queued {count} loaded videos — press Load more first to queue the rest"
|
||||
},
|
||||
"feed": {
|
||||
"mode_local": "Local subscriptions",
|
||||
"mode_account": "YouTube account (cookies)",
|
||||
"refresh": "Refresh",
|
||||
"subscriptions": "Subscriptions",
|
||||
"no_subscriptions": "No subscriptions yet - subscribe from a channel page in Browse",
|
||||
"refreshing": "Refreshing... {done}/{total}",
|
||||
"refreshed": "{count} videos in feed",
|
||||
"channel_failed": "A channel failed to refresh: {error}",
|
||||
"fetching_account": "Fetching your subscription feed...",
|
||||
"account_failed": "Account feed failed (are cookies valid?): {error}",
|
||||
"subscribed": "Subscribed to {title}",
|
||||
"unsubscribed": "Unsubscribed from {title}",
|
||||
"open_channel": "Open channel",
|
||||
"refresh_tooltip": "Fetch the latest uploads from every subscribed channel",
|
||||
"mode_tooltip": "Where the feed comes from: your local subscriptions, or your signed-in YouTube account",
|
||||
"refreshed_with_failures": "{count} videos — {failed} channel(s) could not be reached",
|
||||
"account_needs_refresh": "Press Refresh to load your account feed",
|
||||
"empty_no_subscriptions": "No subscriptions yet.\n\nFind channels in Search or Browse, then use Subscribe to follow them — their new uploads will appear here.",
|
||||
"empty_not_refreshed": "Nothing here yet.\n\nPress Refresh to fetch the latest uploads from your subscriptions.",
|
||||
"sign_in": "Sign in with cookies…",
|
||||
"sign_in_tooltip": "Use your YouTube account via browser cookies, which enables the account feed",
|
||||
"account_requires_cookies": "Requires signing in with cookies",
|
||||
"empty_account": "Nothing loaded yet.\n\nPress Refresh to fetch your YouTube subscription feed."
|
||||
},
|
||||
"account": {
|
||||
"signed_out": "Not signed in",
|
||||
"signed_out_tooltip": "SageTube is not using a YouTube account. Sign in with browser cookies to use your subscription feed and reach age-restricted or members-only videos.",
|
||||
"signed_in": "Account: {source}",
|
||||
"signed_in_tooltip": "Using cookies from {source}. Click to change or clear them.",
|
||||
"source_file": "cookie file"
|
||||
}
|
||||
}
|
||||
+44
-1
@@ -1,12 +1,46 @@
|
||||
import sys
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, Qt
|
||||
from PySide6.QtGui import QSurfaceFormat
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
from .utils.ytsage_logger import logger
|
||||
from .gui.ytsage_gui_main import YTSageApp # Import the main application class from ytsage_gui_main
|
||||
|
||||
|
||||
def _configure_opengl() -> None:
|
||||
"""
|
||||
Must run before QApplication exists -- both settings are ignored afterwards.
|
||||
|
||||
AA_ShareOpenGLContexts puts every widget context in one sharing group, so
|
||||
a reparent no longer loses GL *resources*. It does not stop Qt destroying
|
||||
and recreating the widget's own context (MpvRenderWidget handles that);
|
||||
it removes a whole second class of failure around it.
|
||||
|
||||
The surface format is deliberately minimal. mpv renders into our FBO and
|
||||
needs no depth, stencil or alpha from the default framebuffer, and asking
|
||||
for them is what produced `OpenGL error INVALID_ENUM` on some drivers. No
|
||||
GL version or profile is requested on purpose: pinning a core profile
|
||||
breaks software and GLES stacks that libmpv would otherwise accept.
|
||||
"""
|
||||
QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True)
|
||||
|
||||
fmt = QSurfaceFormat()
|
||||
fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)
|
||||
fmt.setSwapInterval(1)
|
||||
fmt.setDepthBufferSize(0)
|
||||
fmt.setStencilBufferSize(0)
|
||||
fmt.setAlphaBufferSize(0)
|
||||
QSurfaceFormat.setDefaultFormat(fmt)
|
||||
|
||||
|
||||
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.setIcon(QMessageBox.Icon.Critical)
|
||||
error_dialog.setText("Application Error")
|
||||
@@ -17,8 +51,17 @@ def show_error_dialog(message):
|
||||
|
||||
def main():
|
||||
try:
|
||||
logger.info("Starting YTSage application")
|
||||
logger.info("Starting SageTube application")
|
||||
_configure_opengl()
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("SageTube")
|
||||
app.setDesktopFileName("sagetube")
|
||||
|
||||
# Before the main window: this retro-fits icons, tooltips and
|
||||
# accessible names onto every button as it is first polished.
|
||||
from .gui.ytsage_ui_polish import install as install_ui_polish
|
||||
|
||||
install_ui_polish(app)
|
||||
|
||||
window = YTSageApp() # Instantiate the main application class
|
||||
window.show()
|
||||
|
||||
@@ -49,7 +49,9 @@ Exceptions
|
||||
when possible.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
@@ -70,6 +72,9 @@ class ConfigManager:
|
||||
_config_file: Path = APP_CONFIG_FILE
|
||||
_settings: Dict[str, Any] = {}
|
||||
_default_config: Dict[str, Any] = {
|
||||
# Bumped whenever a stored config needs rewriting rather than merely
|
||||
# merging; see _migrate(). Absent means "written before 5.4.0".
|
||||
"config_version": 2,
|
||||
"download_path": str(USER_HOME_DIR / "Downloads"),
|
||||
"generic_mode": True,
|
||||
"speed_limit_value": None,
|
||||
@@ -84,9 +89,10 @@ class ConfigManager:
|
||||
"geo_proxy_url": None,
|
||||
"auto_update_ytdlp": True,
|
||||
"auto_update_frequency": "daily",
|
||||
"check_app_updates": True,
|
||||
"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",
|
||||
@@ -102,8 +108,84 @@ class ConfigManager:
|
||||
"ytdlp": {"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,
|
||||
},
|
||||
"player": {
|
||||
"default_quality": 1080, # max stream height; None = auto/best
|
||||
"volume": 100,
|
||||
"resume": "auto", # auto | off
|
||||
"source_mode": "ytdl", # ytdl (mpv ytdl_hook) | direct (raw stream URLs)
|
||||
# "auto" picks a platform list (vaapi on Linux, d3d11va on Windows,
|
||||
# videotoolbox on macOS), each ending in "no" so a broken interop
|
||||
# falls back to software rather than a black frame. Any other value
|
||||
# is passed to mpv verbatim; "no" forces software decoding.
|
||||
"hwdec": "auto",
|
||||
},
|
||||
"feed": {
|
||||
"mode": "local", # local (per-channel aggregation) | account (cookies)
|
||||
"per_channel_items": 15,
|
||||
"auto_refresh_minutes": 0, # 0 = manual refresh only
|
||||
# Opening the Feed tab refreshes channels not seen for this long.
|
||||
# 0 disables it. A separate key from auto_refresh_minutes above so
|
||||
# existing configs, which store that as 0, are not read as opting
|
||||
# out of a feature that did not exist when they were written.
|
||||
"auto_refresh_on_open_minutes": 30,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _deep_merge(cls, defaults: Dict[str, Any], stored: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Recursively layer `stored` on top of `defaults`.
|
||||
|
||||
A shallow `dict.update()` here would be a data-loss bug: a config file
|
||||
written by an older build contains a *partial* "player" or "feed"
|
||||
object, and updating shallowly replaces the whole nested default with
|
||||
it -- so every key added since would come back missing.
|
||||
|
||||
Rules:
|
||||
- dict + dict recurse
|
||||
- anything else (lists, scalars, type mismatches) replaces wholesale
|
||||
- keys present only in `stored` are kept, never pruned; a user who
|
||||
downgrades must not have their settings destroyed by the older build
|
||||
"""
|
||||
merged = copy.deepcopy(defaults)
|
||||
for key, value in stored.items():
|
||||
existing = merged.get(key)
|
||||
if isinstance(value, dict) and isinstance(existing, dict):
|
||||
merged[key] = cls._deep_merge(existing, value)
|
||||
else:
|
||||
merged[key] = copy.deepcopy(value)
|
||||
return merged
|
||||
|
||||
@classmethod
|
||||
def _migrate(cls, stored: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Bring a stored config up to CONFIG_VERSION in place.
|
||||
|
||||
Returns True when something changed, so the caller knows to re-save.
|
||||
Migrations must be idempotent and must never raise -- a config that
|
||||
cannot be migrated is still a config the app has to start with.
|
||||
"""
|
||||
changed = False
|
||||
version = stored.get("config_version")
|
||||
|
||||
if not isinstance(version, int) or version < 2:
|
||||
# Pre-5.4.0, and possibly inherited from an upstream YTSage
|
||||
# install. Such a file carries "check_app_updates": true, which
|
||||
# used to point at PyPI's `ytsage` package and oop7/YTSage's
|
||||
# releases -- neither of which is this application. Dropping the
|
||||
# stored value lets SageTube's own default decide.
|
||||
if stored.pop("check_app_updates", None) is not None:
|
||||
logger.info("Config migration: cleared inherited 'check_app_updates'.")
|
||||
stored["config_version"] = 2
|
||||
changed = True
|
||||
|
||||
return changed
|
||||
|
||||
@classmethod
|
||||
def _load(cls) -> None:
|
||||
"""
|
||||
@@ -115,13 +197,27 @@ class ConfigManager:
|
||||
if cls._config_file.exists():
|
||||
try:
|
||||
with open(cls._config_file, "r", encoding="utf-8") as f:
|
||||
cls._settings = json.load(f)
|
||||
stored = json.load(f)
|
||||
if not isinstance(stored, dict):
|
||||
raise json.JSONDecodeError("config root is not an object", "", 0)
|
||||
|
||||
migrated = False
|
||||
try:
|
||||
migrated = cls._migrate(stored)
|
||||
except Exception as e:
|
||||
logger.exception(f"Config migration failed, continuing unmigrated: {e}")
|
||||
|
||||
# Merge on top of defaults so keys added in newer versions
|
||||
# exist without call sites needing `or <default>` fallbacks
|
||||
cls._settings = cls._deep_merge(cls._default_config, stored)
|
||||
logger.info("Config loaded from file.")
|
||||
if migrated:
|
||||
cls._save()
|
||||
except json.JSONDecodeError:
|
||||
cls._settings = cls._default_config.copy()
|
||||
cls._settings = copy.deepcopy(cls._default_config)
|
||||
logger.warning("Config file corrupt, loaded defaults.")
|
||||
else:
|
||||
cls._settings = cls._default_config.copy()
|
||||
cls._settings = copy.deepcopy(cls._default_config)
|
||||
cls._save()
|
||||
logger.info("Config file not found, created default config.")
|
||||
|
||||
@@ -136,8 +232,13 @@ class ConfigManager:
|
||||
"""
|
||||
with cls._lock:
|
||||
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)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_file, cls._config_file)
|
||||
logger.debug("Config saved to file.")
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.exception(f"Failed to save config: {e}")
|
||||
|
||||
@@ -78,6 +78,8 @@ def get_asset_path(asset_relative_path: str) -> 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
|
||||
@@ -91,11 +93,11 @@ if OS_NAME == "Windows":
|
||||
|
||||
# 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")) / "YTSage"
|
||||
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 / "ytsage_config.json"
|
||||
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"
|
||||
|
||||
@@ -110,11 +112,11 @@ elif OS_NAME == "Darwin": # 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" / "YTSage"
|
||||
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 / "ytsage_config.json"
|
||||
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"
|
||||
|
||||
@@ -129,11 +131,11 @@ else: # Linux and other UNIX-like
|
||||
|
||||
# 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" / "YTSage"
|
||||
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 / "ytsage_config.json"
|
||||
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"
|
||||
|
||||
@@ -238,3 +240,13 @@ else:
|
||||
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", "")
|
||||
|
||||
@@ -72,6 +72,10 @@ class HistoryManager:
|
||||
if cls._connection is None:
|
||||
cls._connection = sqlite3.connect(cls._db_file, check_same_thread=False)
|
||||
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()
|
||||
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
LibraryManager - SageTube's watch-side persistence
|
||||
==================================================
|
||||
|
||||
Separate SQLite database (sagetube_library.db) holding local channel
|
||||
subscriptions, the aggregated feed cache, watch history with resume
|
||||
positions, and the persisted play queue. Kept apart from the upstream
|
||||
download-history DB so upstream merges never collide.
|
||||
|
||||
Same concurrency pattern as HistoryManager: one persistent connection,
|
||||
check_same_thread=False, an RLock around every operation, WAL journaling.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .ytsage_constants import APP_DATA_DIR
|
||||
from .ytsage_logger import logger
|
||||
|
||||
_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class LibraryManager:
|
||||
_lock = threading.RLock()
|
||||
_connection: Optional[sqlite3.Connection] = None
|
||||
_db_file = APP_DATA_DIR / "sagetube_library.db"
|
||||
|
||||
# ------------------------------------------------------------- plumbing
|
||||
|
||||
@classmethod
|
||||
def _conn(cls) -> sqlite3.Connection:
|
||||
with cls._lock:
|
||||
if cls._connection is None:
|
||||
cls._db_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cls._connection = sqlite3.connect(cls._db_file, check_same_thread=False)
|
||||
cls._connection.row_factory = sqlite3.Row
|
||||
cls._connection.execute("PRAGMA journal_mode=WAL")
|
||||
cls._connection.execute("PRAGMA busy_timeout=5000")
|
||||
cls._init_schema()
|
||||
return cls._connection
|
||||
|
||||
@classmethod
|
||||
def _init_schema(cls) -> None:
|
||||
assert cls._connection is not None
|
||||
cur = cls._connection.cursor()
|
||||
cur.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
channel_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
avatar_url TEXT,
|
||||
added_at INTEGER NOT NULL,
|
||||
last_refreshed INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS feed_items (
|
||||
video_id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
url TEXT NOT NULL,
|
||||
duration REAL,
|
||||
thumbnail_url TEXT,
|
||||
published_ts INTEGER,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_feed_channel ON feed_items(channel_id, fetched_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS watch_history (
|
||||
video_id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT,
|
||||
channel TEXT,
|
||||
channel_id TEXT,
|
||||
duration REAL,
|
||||
position REAL DEFAULT 0,
|
||||
completed INTEGER DEFAULT 0,
|
||||
watched_at INTEGER NOT NULL,
|
||||
thumbnail_url TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS play_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_id TEXT,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT,
|
||||
channel TEXT,
|
||||
duration REAL,
|
||||
thumbnail_url TEXT,
|
||||
sort_order INTEGER NOT NULL,
|
||||
added_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
cur.execute(f"PRAGMA user_version = {_SCHEMA_VERSION}")
|
||||
cls._connection.commit()
|
||||
|
||||
# -------------------------------------------------------- subscriptions
|
||||
|
||||
@classmethod
|
||||
def subscribe(cls, channel_id: str, title: str, url: str, avatar_url: Optional[str] = None) -> None:
|
||||
with cls._lock:
|
||||
cls._conn().execute(
|
||||
"INSERT INTO subscriptions (channel_id, title, url, avatar_url, added_at) VALUES (?,?,?,?,?) "
|
||||
"ON CONFLICT(channel_id) DO UPDATE SET title=excluded.title, url=excluded.url",
|
||||
(channel_id, title, url, avatar_url, int(time.time())),
|
||||
)
|
||||
cls._conn().commit()
|
||||
logger.info(f"Subscribed to {title} ({channel_id})")
|
||||
|
||||
@classmethod
|
||||
def unsubscribe(cls, channel_id: str) -> None:
|
||||
with cls._lock:
|
||||
cls._conn().execute("DELETE FROM subscriptions WHERE channel_id = ?", (channel_id,))
|
||||
cls._conn().execute("DELETE FROM feed_items WHERE channel_id = ?", (channel_id,))
|
||||
cls._conn().commit()
|
||||
|
||||
@classmethod
|
||||
def is_subscribed(cls, channel_id: str) -> bool:
|
||||
with cls._lock:
|
||||
row = cls._conn().execute("SELECT 1 FROM subscriptions WHERE channel_id = ?", (channel_id,)).fetchone()
|
||||
return row is not None
|
||||
|
||||
@classmethod
|
||||
def subscriptions(cls) -> List[Dict[str, Any]]:
|
||||
with cls._lock:
|
||||
rows = cls._conn().execute("SELECT * FROM subscriptions ORDER BY title COLLATE NOCASE").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@classmethod
|
||||
def mark_refreshed(cls, channel_id: str) -> None:
|
||||
with cls._lock:
|
||||
cls._conn().execute(
|
||||
"UPDATE subscriptions SET last_refreshed = ? WHERE channel_id = ?", (int(time.time()), channel_id)
|
||||
)
|
||||
cls._conn().commit()
|
||||
|
||||
# ---------------------------------------------------------------- feed
|
||||
|
||||
@classmethod
|
||||
def upsert_feed_items(cls, channel_id: str, entries: List[Dict[str, Any]]) -> None:
|
||||
now = int(time.time())
|
||||
with cls._lock:
|
||||
conn = cls._conn()
|
||||
for i, e in enumerate(entries):
|
||||
video_id = e.get("id")
|
||||
url = e.get("url") or (f"https://www.youtube.com/watch?v={video_id}" if video_id else None)
|
||||
if not video_id or not url:
|
||||
continue
|
||||
# fetched_at encodes per-channel recency order (newest first)
|
||||
conn.execute(
|
||||
"INSERT INTO feed_items (video_id, channel_id, title, url, duration, thumbnail_url, published_ts, fetched_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(video_id) DO UPDATE SET title=excluded.title, duration=excluded.duration",
|
||||
(
|
||||
video_id,
|
||||
channel_id,
|
||||
e.get("title"),
|
||||
url,
|
||||
e.get("duration"),
|
||||
e.get("thumbnail") or (e.get("thumbnails") or [{}])[-1].get("url"),
|
||||
e.get("timestamp") or e.get("release_timestamp"),
|
||||
now - i,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@classmethod
|
||||
def feed_items(cls, limit: int = 120, channel_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Feed rows, newest first. `channel_id` narrows to one channel, which is
|
||||
what lets the Feed merge a finished channel's videos into the grid
|
||||
instead of rebuilding every card.
|
||||
"""
|
||||
query = (
|
||||
"SELECT f.*, s.title AS channel FROM feed_items f "
|
||||
"LEFT JOIN subscriptions s ON s.channel_id = f.channel_id "
|
||||
)
|
||||
params: List[Any] = []
|
||||
if channel_id is not None:
|
||||
query += "WHERE f.channel_id = ? "
|
||||
params.append(str(channel_id))
|
||||
# published_ts first and only then fetched_at: mixing the two in a
|
||||
# single COALESCE sorted real publish times against wall-clock fetch
|
||||
# times, so whichever channel refreshed last floated to the top.
|
||||
query += "ORDER BY f.published_ts IS NULL, f.published_ts DESC, f.fetched_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
with cls._lock:
|
||||
rows = cls._conn().execute(query, tuple(params)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ------------------------------------------------------- watch history
|
||||
|
||||
@classmethod
|
||||
def upsert_watch(cls, entry: Dict[str, Any]) -> None:
|
||||
video_id = entry.get("id") or entry.get("url")
|
||||
url = entry.get("url") or entry.get("webpage_url")
|
||||
if not video_id or not url:
|
||||
return
|
||||
with cls._lock:
|
||||
cls._conn().execute(
|
||||
"INSERT INTO watch_history (video_id, url, title, channel, channel_id, duration, watched_at, thumbnail_url) "
|
||||
"VALUES (?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(video_id) DO UPDATE SET watched_at=excluded.watched_at, title=excluded.title, url=excluded.url",
|
||||
(
|
||||
str(video_id),
|
||||
url,
|
||||
entry.get("title"),
|
||||
entry.get("channel") or entry.get("uploader"),
|
||||
entry.get("channel_id"),
|
||||
entry.get("duration"),
|
||||
int(time.time()),
|
||||
entry.get("thumbnail"),
|
||||
),
|
||||
)
|
||||
cls._conn().commit()
|
||||
|
||||
@classmethod
|
||||
def update_position(cls, video_id: str, position: float, duration: Optional[float] = None) -> None:
|
||||
with cls._lock:
|
||||
completed = 0
|
||||
if duration and duration > 0 and position >= duration * 0.95:
|
||||
completed = 1
|
||||
cls._conn().execute(
|
||||
"UPDATE watch_history SET position = ?, completed = MAX(completed, ?), "
|
||||
"duration = COALESCE(?, duration) WHERE video_id = ?",
|
||||
(position, completed, duration, str(video_id)),
|
||||
)
|
||||
cls._conn().commit()
|
||||
|
||||
@classmethod
|
||||
def get_resume_position(cls, video_id: str) -> float:
|
||||
with cls._lock:
|
||||
row = cls._conn().execute(
|
||||
"SELECT position, duration, completed FROM watch_history WHERE video_id = ?", (str(video_id),)
|
||||
).fetchone()
|
||||
if row is None or row["completed"]:
|
||||
return 0.0
|
||||
position = row["position"] or 0.0
|
||||
duration = row["duration"] or 0.0
|
||||
if position < 30 or (duration and position >= duration * 0.95):
|
||||
return 0.0
|
||||
return float(position)
|
||||
|
||||
@classmethod
|
||||
def watch_history(cls, limit: int = 200) -> List[Dict[str, Any]]:
|
||||
with cls._lock:
|
||||
rows = cls._conn().execute(
|
||||
"SELECT * FROM watch_history ORDER BY watched_at DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ----------------------------------------------------------- play queue
|
||||
|
||||
@classmethod
|
||||
def save_queue(cls, entries: List[Dict[str, Any]]) -> None:
|
||||
now = int(time.time())
|
||||
with cls._lock:
|
||||
conn = cls._conn()
|
||||
conn.execute("DELETE FROM play_queue")
|
||||
for i, e in enumerate(entries or []):
|
||||
url = e.get("url") or e.get("webpage_url")
|
||||
if not url:
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO play_queue (video_id, url, title, channel, duration, thumbnail_url, sort_order, added_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?)",
|
||||
(e.get("id"), url, e.get("title"), e.get("channel") or e.get("uploader"),
|
||||
e.get("duration"), e.get("thumbnail"), i, now),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@classmethod
|
||||
def load_queue(cls) -> List[Dict[str, Any]]:
|
||||
with cls._lock:
|
||||
rows = cls._conn().execute("SELECT * FROM play_queue ORDER BY sort_order").fetchall()
|
||||
return [
|
||||
{"id": r["video_id"], "url": r["url"], "title": r["title"], "channel": r["channel"],
|
||||
"duration": r["duration"], "thumbnail": r["thumbnail_url"]}
|
||||
for r in rows
|
||||
]
|
||||
@@ -74,10 +74,149 @@ 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"
|
||||
},
|
||||
# --- SageTube's own strings -------------------------------------
|
||||
# These namespaces exist only in en.json: the fork added them and the
|
||||
# thirteen translated files predate them. get_text() falls back to this
|
||||
# dict before giving up and returning the key itself, so without these
|
||||
# a non-English UI literally reads "main_tabs.watch" on its tabs.
|
||||
# Keep in step with en.json when adding keys; translating the language
|
||||
# files is the separate, larger job.
|
||||
"main_tabs": {
|
||||
"watch": "Watch",
|
||||
"search": "Search",
|
||||
"feed": "Feed",
|
||||
"browse": "Browse",
|
||||
"downloads": "Downloads"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search YouTube...",
|
||||
"button": "Search",
|
||||
"searching": "Searching...",
|
||||
"results_count": "{count} results",
|
||||
"failed": "Search failed: {error}",
|
||||
"button_tooltip": "Search YouTube"
|
||||
},
|
||||
"browse": {
|
||||
"placeholder": "Paste a channel or playlist URL...",
|
||||
"open": "Open",
|
||||
"subscribe": "Subscribe",
|
||||
"unsubscribe": "Unsubscribe",
|
||||
"play_all": "Play all",
|
||||
"download_playlist": "Download",
|
||||
"tab_videos": "Videos",
|
||||
"tab_shorts": "Shorts",
|
||||
"tab_live": "Live",
|
||||
"loading": "Loading...",
|
||||
"entry_count": "{count} videos",
|
||||
"failed": "Failed to load: {error}",
|
||||
"hint": "Open a channel (youtube.com/@name) or playlist URL, or navigate here from Search.",
|
||||
"open_tooltip": "Open this channel or playlist",
|
||||
"subscribe_tooltip": "Follow this channel so its uploads appear in your Feed",
|
||||
"unsubscribe_tooltip": "Stop following this channel",
|
||||
"play_all_tooltip": "Queue everything loaded here",
|
||||
"download_playlist_tooltip": "Open this playlist in Downloads",
|
||||
"queued_loaded": "Queued {count} loaded videos — press Load more first to queue the rest"
|
||||
},
|
||||
"feed": {
|
||||
"mode_local": "Local subscriptions",
|
||||
"mode_account": "YouTube account (cookies)",
|
||||
"refresh": "Refresh",
|
||||
"subscriptions": "Subscriptions",
|
||||
"no_subscriptions": "No subscriptions yet - subscribe from a channel page in Browse",
|
||||
"refreshing": "Refreshing... {done}/{total}",
|
||||
"refreshed": "{count} videos in feed",
|
||||
"channel_failed": "A channel failed to refresh: {error}",
|
||||
"fetching_account": "Fetching your subscription feed...",
|
||||
"account_failed": "Account feed failed (are cookies valid?): {error}",
|
||||
"subscribed": "Subscribed to {title}",
|
||||
"unsubscribed": "Unsubscribed from {title}",
|
||||
"open_channel": "Open channel",
|
||||
"refresh_tooltip": "Fetch the latest uploads from every subscribed channel",
|
||||
"mode_tooltip": "Where the feed comes from: your local subscriptions, or your signed-in YouTube account",
|
||||
"refreshed_with_failures": "{count} videos — {failed} channel(s) could not be reached",
|
||||
"account_needs_refresh": "Press Refresh to load your account feed",
|
||||
"empty_no_subscriptions": "No subscriptions yet.\n\nFind channels in Search or Browse, then use Subscribe to follow them — their new uploads will appear here.",
|
||||
"empty_not_refreshed": "Nothing here yet.\n\nPress Refresh to fetch the latest uploads from your subscriptions.",
|
||||
"sign_in": "Sign in with cookies…",
|
||||
"sign_in_tooltip": "Use your YouTube account via browser cookies, which enables the account feed",
|
||||
"account_requires_cookies": "Requires signing in with cookies",
|
||||
"empty_account": "Nothing loaded yet.\n\nPress Refresh to fetch your YouTube subscription feed."
|
||||
},
|
||||
"cards": {
|
||||
"play": "▶ Play",
|
||||
"queue": "+ Queue",
|
||||
"download": "⬇",
|
||||
"load_more": "Load more",
|
||||
"empty": "Nothing here yet",
|
||||
"play_tooltip": "Play this video now",
|
||||
"queue_tooltip": "Add to the play queue",
|
||||
"download_tooltip": "Open in Downloads with this video ready to analyse",
|
||||
"load_more_tooltip": "Load the next page of results"
|
||||
},
|
||||
"watch": {
|
||||
"clear_queue": "Clear queue"
|
||||
},
|
||||
"player": {
|
||||
"quality_auto": "Auto",
|
||||
"subtitles": "CC",
|
||||
"unavailable": "Video player unavailable",
|
||||
"now_playing": "Now Playing",
|
||||
"queue": "Queue",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"stream_stalled": "Stream failed to start after several attempts - YouTube may be throttling. Try again or pick a lower quality.",
|
||||
"play_pause": "Play / pause (Space)",
|
||||
"fullscreen": "Fullscreen (F)",
|
||||
"seek_tooltip": "Seek through the video",
|
||||
"quality_tooltip": "Maximum playback resolution",
|
||||
"speed_tooltip": "Playback speed",
|
||||
"volume_tooltip": "Volume",
|
||||
"subtitles_tooltip": "Toggle subtitles (C)",
|
||||
"previous": "Previous in queue (P)",
|
||||
"next": "Next in queue (N)",
|
||||
"buffering": "Buffering…",
|
||||
"act_play_pause": "Play / pause",
|
||||
"act_seek_back_5": "Back 5 seconds",
|
||||
"act_seek_fwd_5": "Forward 5 seconds",
|
||||
"act_seek_back_10": "Back 10 seconds",
|
||||
"act_seek_fwd_10": "Forward 10 seconds",
|
||||
"act_seek_back_1": "Back 1 second",
|
||||
"act_seek_fwd_1": "Forward 1 second",
|
||||
"act_frame_back": "Previous frame",
|
||||
"act_frame_fwd": "Next frame",
|
||||
"act_vol_up": "Volume up",
|
||||
"act_vol_down": "Volume down",
|
||||
"act_mute": "Mute",
|
||||
"act_unmute": "Unmute",
|
||||
"act_speed_up": "Speed up",
|
||||
"act_speed_down": "Slow down",
|
||||
"act_speed_reset": "Normal speed",
|
||||
"act_subtitles": "Subtitles on / off",
|
||||
"act_fullscreen": "Fullscreen",
|
||||
"act_leave_fullscreen": "Leave fullscreen",
|
||||
"act_next": "Next in queue",
|
||||
"act_previous": "Previous",
|
||||
"act_start": "Back to start",
|
||||
"act_end": "Jump to end",
|
||||
"act_copy_url": "Copy video link",
|
||||
"act_open_browser": "Open in browser"
|
||||
},
|
||||
"account": {
|
||||
"signed_out": "Not signed in",
|
||||
"signed_out_tooltip": "SageTube is not using a YouTube account. Sign in with browser cookies to use your subscription feed and reach age-restricted or members-only videos.",
|
||||
"signed_in": "Account: {source}",
|
||||
"signed_in_tooltip": "Using cookies from {source}. Click to change or clear them.",
|
||||
"source_file": "cookie file"
|
||||
},
|
||||
"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