9 Commits

Author SHA1 Message Date
Homer b3772ff484 The fork's documentation on the wiki, and the history left alone
Building, upstream tracking and YTSage's own README moved to the wiki; README
points there.

The history is not rewritten, unlike every other repository here. This one
still merges from oop7/YTSage and a rewrite would change every commit id, which
costs more than a clean history is worth in a fork. .github/ and
readme-translations/ are upstream's files and stay where upstream put them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:37:46 +02:00
Jaroslav Beneš b4ff618d41 Add fork documentation and rewrite README
- README: watch-first feature overview, install + libmpv requirement,
  fork attribution.
- docs/UPSTREAM.md: upstream merge procedure, expected conflict zones,
  list of SageTube-only modules.
- docs/BUILDING.md: per-OS libmpv install, managed-binary locations,
  playback reliability notes, warning that inherited GitHub release
  workflows download appimagetool/ffmpeg without checksum verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 02:46:59 +02:00
Jaroslav Beneš 8a05d5ff8f Rebrand user-visible surfaces to SageTube
The internal ytsage package name is kept deliberately - renaming it
would touch every file and destroy the ability to merge upstream
YTSage changes.

- pyproject: distribution name sagetube v0.1.0, sagetube entrypoint
  (ytsage alias retained), URLs point at the Gitea repo with an
  Upstream link to YTSage.
- Data dirs move to SageTube/ (fresh fork, fresh state) and the config
  file becomes sagetube_config.json; QApplication name and window
  title read SageTube.
- About dialog credits Houmeres and links "Based on YTSage by oop7".
- App self-update check against the upstream PyPI package is disabled
  by default; yt-dlp/deno/ffmpeg update flows are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 02:45:58 +02:00
Jaroslav Beneš a8ebe89cc1 Wire watch history, resume positions and persistent queue
WatchPage now records every played video in watch_history, saves the
playback position every 5 seconds and on stop/end (completed at >=95%),
and seeks back on replay when player.resume is "auto" - resume kicks in
between 30 seconds and 95% of the duration. The play queue persists
across restarts and reorders/removals write through immediately; the
main window's closeEvent flushes position and queue and releases libmpv.

PlayerPanel gains an automatic stall-retry: YouTube's CDN intermittently
serves stalled streams to non-browser clients (reproduced ~1/3 of
attempts headless with identical code), and a reload re-resolves onto a
healthy node - two retries after 25s of no playback, then a user-facing
error.

ytsage_constants now prepends the managed-binaries dir to PATH so
yt-dlp subprocesses (including mpv's ytdl_hook) can find the managed
Deno runtime - previously nothing exported APP_BIN_DIR, so the deno
integration silently never worked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 02:42:38 +02:00
Jaroslav Beneš 3afe96a8cd Add local subscriptions and the Feed tab
New ytsage/utils/ytsage_library_manager.py: sagetube_library.db
(separate from the upstream download-history DB) with WAL from day one,
holding subscriptions, cached feed_items, watch_history with resume
positions, and the persisted play_queue. Watch history and queue tables
are wired up by the next commit.

FeedPage:
- Local mode: FeedRefreshWorker refreshes each subscribed channel
  sequentially (feed.per_channel_items, default 15) and the grid fills
  incrementally per channel; results are cached so the feed is
  populated instantly on startup.
- Account mode: fetches youtube.com/feed/subscriptions with the user's
  browser cookies - the real logged-in feed; the option is enabled only
  while cookies are active and errors surface as a status banner.
- Sidebar lists subscriptions (double-click opens the channel in
  Browse; context menu unsubscribes). Browse's Subscribe button now
  toggles subscription state through the Feed page.

Verified live: subscribe -> refresh -> 15 videos cached in SQLite and
rendered as cards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:56:12 +02:00
Jaroslav Beneš 8e935c6116 Implement channel and playlist browsing
BrowsePage accepts pasted or routed channel/playlist URLs:
- Channels get Videos / Shorts / Live sub-tabs mapped to the channel's
  /videos, /shorts and /streams listings, lazily fetched 24 at a time
  with -I range pagination; channel title and id resolve from a cheap
  -I 1:1 metadata fetch. Subscribe emits subscribeRequested for the
  Feed page to wire up.
- Playlists get a single grid with Play all (bulk-enqueues into the
  Watch queue) and a Download button that deep-links the playlist into
  the Downloads tab.

Workers are parented to their pages and fetch errors surface as status
text instead of crashing (verified against a channel with no videos
tab). fetch_flat_info gains an items="1:1" limiter so metadata probes
no longer enumerate whole channels.

Verified live: kurzgesagt channel (24 cards, title+id resolved) and a
17-video playlist with Play-all queueing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:53:44 +02:00
Jaroslav Beneš 16be4b4afa Restructure main window into a watch-first tab shell
The single-page downloader layout becomes the Downloads tab of a
SmoothTabWidget with Watch / Search / Feed / Browse / Downloads pages.
The init_ui edit is deliberately small (the old central widget is now
self.download_page); all new behavior lives in new modules:

- ytsage_gui_router.py: AppRouter signal hub (playVideo, queueVideo,
  downloadVideo, openChannel, openPlaylist). A card's Download button
  deep-links into the Downloads tab with the URL prefilled and analysis
  started automatically.
- ytsage_gui_cards.py: VideoCard (thumbnail with disk cache under
  APP_THUMBNAILS_DIR, title/channel/duration, Play/Queue/Download
  actions, double-click to play) and VideoCardGrid (responsive grid,
  Load more pagination).
- ytsage_gui_watch.py: WatchPage hosting the mpv PlayerPanel and a
  drag-reorderable play queue with auto-advance on end of file.
- ytsage_gui_search.py: SearchPage running YtdlpClient.search off the
  GUI thread with Load more pagination.
- Browse and Feed pages are placeholders, implemented next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:48:39 +02:00
Jaroslav Beneš f886125857 Add embedded mpv player (render API + QOpenGLWidget)
New ytsage/gui/ytsage_gui_player.py:
- MpvRenderWidget hosts libmpv through MpvRenderContext in a
  QOpenGLWidget - works on native Wayland where wid-embedding cannot -
  with all mpv-thread callbacks marshalled to the GUI thread via queued
  signals only.
- PlayerPanel adds transport controls: play/pause, seek slider, time
  display, quality selector (caps ytdl-format height and reloads in
  place), speed, volume (persisted), subtitle toggle, fullscreen
  (reparent to top-level window), and Space/F/Escape keys.
- Playback resolves watch URLs through mpv's ytdl_hook pointed at the
  app-managed SHA256-verified yt-dlp binary (script-opts
  ytdl_hook-ytdl_path), inheriting cookies and proxy settings via
  ytdl-raw-options - stream freshness, DASH muxing and nsig handling
  stay in yt-dlp's hands.

New ytsage/core/ytsage_mpv.py probes libmpv availability; without it
the Watch UI shows a per-OS install hint and everything else works.
python-mpv added to dependencies (libmpv itself is a system package).
Config gains player.* and feed.* defaults.

Verified on Wayland: real YouTube video streams with position/duration
signals flowing and no thread-safety errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:44:25 +02:00
Jaroslav Beneš 8dd8a3c1ad Add YtdlpClient: shared JSON invocation layer for yt-dlp
Every metadata call site previously built its own command list. The new
ytsage/core/ytsage_client.py centralizes binary resolution (managed,
verified binary only), cookie/proxy args from ConfigManager (with
session-state overrides), utf-8 output handling, process-group-safe
timeouts, and a short-TTL cache for flat/search results.

Provides fetch_video_info, fetch_flat_info, fetch_flat_entries (with
-I range pagination), search (ytsearchN:), and fetch_account_feed
(youtube.com/feed/subscriptions with cookies) plus a generic YtdlpWorker
QThread. AnalysisThread now delegates its subprocess execution to the
shared runner; its signal surface is unchanged.

Groundwork for the SageTube watch/search/browse/feed features.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:39:20 +02:00
22 changed files with 2460 additions and 696 deletions
+23
View File
@@ -0,0 +1,23 @@
# 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.
## Unreleased
### 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.
+37 -4
View File
@@ -1,12 +1,45 @@
# SageTube # 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 ## License
-624
View File
@@ -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"/>
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-1f2937?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/downloads/)
[![PyPI Downloads](https://img.shields.io/pepy/dt/ytsage?color=1f2937&style=for-the-badge&label=downloads&logo=python&logoColor=white)](https://pepy.tech/project/ytsage)
[![GitHub Downloads](https://img.shields.io/github/downloads/oop7/YTSage/total?color=1f2937&style=for-the-badge&label=downloads&logo=github&logoColor=white)](https://github.com/oop7/YTSage/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-1f2937?style=for-the-badge&logo=opensource&logoColor=white)](https://opensource.org/licenses/MIT)
[![Supported Platforms](https://img.shields.io/badge/platform-cross--platform-1f2937?style=for-the-badge&logo=github&logoColor=white)](https://github.com/oop7/YTSage/releases)
[![GitHub Stars](https://img.shields.io/github/stars/oop7/YTSage?color=c90000&style=for-the-badge&logo=github&logoColor=white)](https://github.com/oop7/YTSage/stargazers)
[![PyPI version](https://img.shields.io/pypi/v/ytsage?color=c90000&style=for-the-badge&logo=pypi&logoColor=white)](https://pypi.org/project/ytsage/)
[![GitHub Sponsors](https://img.shields.io/github/sponsors/oop7?color=c90000&style=for-the-badge&logo=githubsponsors&logoColor=white)](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 |
|--------|-------------|
| ![Windows EXE](https://img.shields.io/badge/Windows-EXE-0078D6?style=for-the-badge&logo=windows&logoColor=white) | Standard Installer |
| ![Windows FFmpeg](https://img.shields.io/badge/Windows-FFmpeg-0078D6?style=for-the-badge&logo=windows&logoColor=white) | With FFmpeg Included |
| ![Windows Portable](https://img.shields.io/badge/Windows-Portable-0078D6?style=for-the-badge&logo=windows&logoColor=white) | Portable version, no installation needed |
| ![Windows Portable FFmpeg](https://img.shields.io/badge/Windows-Portable%20FFmpeg-0078D6?style=for-the-badge&logo=windows&logoColor=white) | 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 |
|--------|-------------|
| ![Linux DEB](https://img.shields.io/badge/Linux-DEB-FCC624?style=for-the-badge&logo=linux&logoColor=black) | Debian Package |
| ![Linux AppImage](https://img.shields.io/badge/Linux-AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black) | AppImage, Portable |
| ![Linux RPM](https://img.shields.io/badge/Linux-RPM-FCC624?style=for-the-badge&logo=linux&logoColor=black) | RPM Package |
| ![Flathub](https://img.shields.io/badge/Linux-Flatpak-FCC624?style=for-the-badge&logo=flathub&logoColor=black) | 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 |
|--------|-------------|
| ![macOS ARM64 APP](https://img.shields.io/badge/macOS-ARM64%20APP-000000?style=for-the-badge&logo=apple&logoColor=white) | Zipped Application for Apple Silicon |
| ![macOS ARM64 DMG](https://img.shields.io/badge/macOS-ARM64%20DMG-000000?style=for-the-badge&logo=apple&logoColor=white) | 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.
[![Sponsor YTSage](https://img.shields.io/badge/Sponsor-YTSage-EA4AAA?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/oop7)
<a id="contributing"></a>
## 👥 Contributing
We welcome contributions! Heres 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>
+10 -7
View File
@@ -3,11 +3,12 @@ requires = ["setuptools"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "ytsage" name = "sagetube"
version = "5.2.0" version = "0.1.0"
description = "Modern YouTube downloader with a clean PySide6 interface." description = "Watch-first YouTube client with embedded mpv playback and yt-dlp downloading. Fork of YTSage."
authors = [ authors = [
{ name = "oop7", email = "oop7_support@proton.me" }, { name = "Houmeres", email = "admin@ecoposta.sk" },
{ name = "oop7 (YTSage upstream)", email = "oop7_support@proton.me" },
] ]
dependencies = [ dependencies = [
"PySide6>=6.10.1", "PySide6>=6.10.1",
@@ -17,6 +18,7 @@ dependencies = [
"markdown>=3.10", "markdown>=3.10",
"loguru>=0.7.3", "loguru>=0.7.3",
"setuptools>=80.9.0", "setuptools>=80.9.0",
"python-mpv>=1.0.7",
] ]
requires-python = ">=3.10,<3.15" requires-python = ">=3.10,<3.15"
readme = "README.md" readme = "README.md"
@@ -47,12 +49,13 @@ keywords = ["youtube", "downloader", "video", "audio", "PySide6", "yt-dlp", "GUI
[project.scripts] [project.scripts]
sagetube = "ytsage.main:main"
ytsage = "ytsage.main:main" ytsage = "ytsage.main:main"
[project.urls] [project.urls]
Homepage = "https://github.com/oop7/YTSage" Homepage = "https://git.houmeres.sk/Houmeres/SageTube"
Bug-Tracker = "https://github.com/oop7/YTSage/issues" Bug-Tracker = "https://git.houmeres.sk/Houmeres/SageTube/issues"
Reddit = "https://www.reddit.com/r/NO-N_A_M_E/" Upstream = "https://github.com/oop7/YTSage"
[tool.setuptools] [tool.setuptools]
include-package-data = true include-package-data = true
+1 -1
View File
@@ -4,5 +4,5 @@ YTSage - YouTube Video Downloader
A modern, user-friendly YouTube video downloader built with PySide6. A modern, user-friendly YouTube video downloader built with PySide6.
""" """
__version__ = "5.2.0" __version__ = "0.1.0"
__author__ = "oop7" __author__ = "oop7"
+270
View File
@@ -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()
+38
View File
@@ -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
+3 -42
View File
@@ -1,16 +1,13 @@
from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast
import json import json
import os
import signal
import subprocess import subprocess
import sys
from PySide6.QtCore import QMetaObject, Qt, Q_ARG, QThread, Signal, QTimer from PySide6.QtCore import QMetaObject, Qt, Q_ARG, QThread, Signal, QTimer
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
from ..core.ytsage_client import run_ytdlp_capture
from ..core.ytsage_utils import validate_video_url, parse_yt_dlp_error from ..core.ytsage_utils import validate_video_url, parse_yt_dlp_error
from ..core.ytsage_yt_dlp import get_yt_dlp_path 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_localization import _
from ..utils.ytsage_logger import logger from ..utils.ytsage_logger import logger
@@ -100,44 +97,8 @@ class AnalysisThread(QThread):
@staticmethod @staticmethod
def _run_ytdlp(cmd: list, timeout: int) -> subprocess.CompletedProcess: def _run_ytdlp(cmd: list, timeout: int) -> subprocess.CompletedProcess:
"""Run yt-dlp capturing output, killing the whole process group on timeout. """Run yt-dlp via the shared client runner (process-group-safe)."""
return run_ytdlp_capture(cmd, timeout=timeout)
subprocess.run() only kills the direct child on TimeoutExpired, leaking
grandchildren (deno); it also loses any stderr produced before the
timeout. Use Popen with a new session so the entire group can be
reaped, and surface the partial stderr in the raised exception.
"""
popen_kwargs: Dict[str, Any] = {
"stdout": subprocess.PIPE,
"stderr": subprocess.PIPE,
"text": True,
"encoding": "utf-8",
"errors": "replace",
}
if sys.platform == "win32":
popen_kwargs["creationflags"] = SUBPROCESS_CREATIONFLAGS
else:
popen_kwargs["start_new_session"] = True
proc = subprocess.Popen(cmd, **popen_kwargs)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired as exc:
if sys.platform == "win32":
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=SUBPROCESS_CREATIONFLAGS,
)
else:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
pass
stdout, stderr = proc.communicate()
exc.stdout, exc.stderr = stdout, stderr
raise
return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
def _analyze_url_with_subprocess(self, url: str) -> None: def _analyze_url_with_subprocess(self, url: str) -> None:
"""Analyze URL using yt-dlp executable.""" """Analyze URL using yt-dlp executable."""
+259
View File
@@ -0,0 +1,259 @@
"""
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_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.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.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.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.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"])
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)
def _on_play_all(self) -> None:
for entry in [c.entry for c in self.playlist_section.grid._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)
def _on_download_playlist(self) -> None:
if self._current_url:
self._router.downloadVideo.emit(self._current_url)
+274
View File
@@ -0,0 +1,274 @@
"""
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.clicked.connect(lambda: self._router.playVideo.emit(self._routed_entry()))
actions.addWidget(self.play_btn)
self.queue_btn = QPushButton(_("cards.queue"))
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.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] = []
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.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._relayout()
self.load_more_btn.setVisible(show_load_more)
self.empty_label.setVisible(not self._cards)
def clear(self) -> None:
for card in self._cards:
self._grid.removeWidget(card)
card.deleteLater()
self._cards = []
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()
for i, card in enumerate(self._cards):
self._grid.addWidget(card, i // cols, i % cols)
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
if self._cards and self._columns() != self._grid.columnCount():
self._relayout()
@@ -226,24 +226,27 @@ class AboutDialog(QDialog):
info_layout = QHBoxLayout() info_layout = QHBoxLayout()
info_layout.setSpacing(15) 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( author_label = QLabel(
f"{_('about.author', author=author_link)}" f"{_('about.author', author=author_link)}"
) )
author_label.setOpenExternalLinks(True) author_label.setOpenExternalLinks(True)
info_layout.addWidget(author_label) 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( repo_label = QLabel(
f"{_('about.github', repo=repo_link)}" f"{_('about.github', repo=repo_link)}"
) )
repo_label.setOpenExternalLinks(True) repo_label.setOpenExternalLinks(True)
info_layout.addWidget(repo_label) info_layout.addWidget(repo_label)
sponsor_link = '<a href="https://github.com/sponsors/oop7" style="color: #c90000; text-decoration: none;">❤️ Sponsor</a>' upstream_link = (
sponsor_label = QLabel(sponsor_link) '<a href="https://github.com/oop7/YTSage" style="color: #c90000; text-decoration: none;">'
sponsor_label.setOpenExternalLinks(True) "Based on YTSage by oop7</a>"
info_layout.addWidget(sponsor_label) )
upstream_label = QLabel(upstream_link)
upstream_label.setOpenExternalLinks(True)
info_layout.addWidget(upstream_label)
# Center the info layout # Center the info layout
info_container = QHBoxLayout() info_container = QHBoxLayout()
+274
View File
@@ -0,0 +1,274 @@
"""
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, 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
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) -> None:
super().__init__(parent)
self._subs = subscriptions
self._per_channel = per_channel
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:
entries = client.fetch_flat_entries(
f"{sub['url'].rstrip('/')}/videos", start=1, end=self._per_channel, use_cache=False
)
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):
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
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
bar = QHBoxLayout()
self.mode_combo = QComboBox()
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.clicked.connect(self.refresh)
bar.addWidget(self.refresh_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)
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)
self.grid = VideoCardGrid(router, self)
splitter.addWidget(self.grid)
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()
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()
# --------------------------------------------------------------- local
def _refresh_local(self) -> None:
if self._refresh_worker is not None:
return
subs = LibraryManager.subscriptions()
if not subs:
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._total_count = len(subs)
self._refresh_worker = FeedRefreshWorker(subs, per_channel, parent=self)
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))
self._load_cached_feed()
def _on_channel_failed(self, channel_id: str, error: str) -> None:
self._done_count += 1
self.status_label.setText(_("feed.channel_failed", error=error[:120]))
def _on_refresh_done(self) -> None:
self.refresh_btn.setEnabled(True)
self.status_label.setText(_("feed.refreshed", count=self.grid.card_count()))
if self._refresh_worker is not None:
self._refresh_worker.deleteLater()
self._refresh_worker = None
def _load_cached_feed(self) -> None:
items = LibraryManager.feed_items()
entries = [
{
"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
]
self.grid.set_entries(entries)
# -------------------------------------------------------------- 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)))
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]))
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)
if not cookies_on and self.mode_combo.currentData() == "account":
self.mode_combo.setCurrentIndex(self.mode_combo.findData("local"))
def showEvent(self, event) -> None:
self._update_mode_availability()
super().showEvent(event)
def _on_mode_changed(self) -> None:
ConfigManager.set("feed.mode", self.mode_combo.currentData())
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()
+67 -2
View File
@@ -53,6 +53,7 @@ from .ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.
from .ytsage_gui_format_table import FormatTableMixin from .ytsage_gui_format_table import FormatTableMixin
from .ytsage_gui_video_info import VideoInfoMixin from .ytsage_gui_video_info import VideoInfoMixin
from .ytsage_gui_analysis import AnalysisMixin from .ytsage_gui_analysis import AnalysisMixin
from .ytsage_smooth_tab_widget import SmoothTabWidget
from ..utils.ytsage_constants import ( from ..utils.ytsage_constants import (
ICON_PATH, ICON_PATH,
SOUND_PATH, SOUND_PATH,
@@ -381,9 +382,10 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self.setWindowTitle(f"{_('app.title')} {_('app.version', version=self.version)}") self.setWindowTitle(f"{_('app.title')} {_('app.version', version=self.version)}")
self.setMinimumSize(900, 750) 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() main_widget = QWidget()
self.setCentralWidget(main_widget) self.download_page = main_widget
layout = QVBoxLayout(main_widget) layout = QVBoxLayout(main_widget)
layout.setSpacing(8) layout.setSpacing(8)
layout.setContentsMargins(20, 20, 20, 20) layout.setContentsMargins(20, 20, 20, 20)
@@ -626,6 +628,62 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
# Disable analysis-dependent controls until video is analyzed # Disable analysis-dependent controls until video is analyzed
self.toggle_analysis_dependent_controls(enabled=False) 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
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)
self.main_tabs = SmoothTabWidget(self)
self.main_tabs.addTab(self.watch_page, _("main_tabs.watch"))
self.main_tabs.addTab(self.search_page, _("main_tabs.search"))
self.main_tabs.addTab(self.feed_page, _("main_tabs.feed"))
self.main_tabs.addTab(self.browse_page, _("main_tabs.browse"))
self.main_tabs.addTab(self.download_page, _("main_tabs.downloads"))
self.setCentralWidget(self.main_tabs)
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)
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: def _on_url_text_changed(self, text: str) -> None:
"""Enable or disable the Analyze button based on URL input content.""" """Enable or disable the Analyze button based on URL input content."""
self.analyze_button.setEnabled(bool(text.strip())) self.analyze_button.setEnabled(bool(text.strip()))
@@ -1192,6 +1250,13 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
def closeEvent(self, event) -> None: def closeEvent(self, event) -> None:
"""Handle application close event to ensure proper cleanup of background threads.""" """Handle application close event to ensure proper cleanup of background threads."""
try: 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 the analysis thread if it's running # 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(): if hasattr(self, "_analysis_thread") and self._analysis_thread is not None and self._analysis_thread.isRunning():
logger.info("Stopping analysis thread...") logger.info("Stopping analysis thread...")
+533
View File
@@ -0,0 +1,533 @@
"""
Embedded mpv player
===================
MpvRenderWidget renders libmpv into a QOpenGLWidget via the mpv render API
(the wid=winId() embedding path is broken on native Wayland, the render API
works on X11/Wayland/Windows/macOS alike).
PlayerPanel wraps the render widget with transport controls and resolves
YouTube URLs through mpv's ytdl_hook, pointed at the app-managed yt-dlp
binary, so stream URL freshness, DASH muxing, subtitles and PO-token/nsig
handling are all handled by the same yt-dlp the downloader uses.
Threading rule: libmpv fires property observers and the render-update
callback on its own threads. Nothing in those callbacks may touch Qt
widgets - they only emit queued Qt signals.
"""
from typing import Any, Dict, Optional
from PySide6.QtCore import Qt, QTimer, Signal, Slot
from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtGui import QOpenGLContext
from PySide6.QtWidgets import (
QComboBox,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSlider,
QStyle,
QVBoxLayout,
QWidget,
)
from ..core.ytsage_mpv import probe_player
from ..core.ytsage_yt_dlp import get_yt_dlp_path
from ..utils.ytsage_config_manager import ConfigManager
from ..utils.ytsage_localization import _
from ..utils.ytsage_logger import logger
QUALITY_CHOICES = [
("player.quality_auto", None),
("2160p", 2160),
("1440p", 1440),
("1080p", 1080),
("720p", 720),
("480p", 480),
("360p", 360),
]
SPEED_CHOICES = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
def _ytdl_format_for(height: Optional[int]) -> str:
if height is None:
return "bestvideo+bestaudio/best"
return f"bestvideo[height<=?{height}]+bestaudio/best[height<=?{height}]"
def _build_ytdl_raw_options() -> str:
"""Mirror the app's cookie/proxy config into ytdl_hook raw options."""
opts = []
if ConfigManager.get("cookie_active"):
if ConfigManager.get("cookie_source") == "file":
path = ConfigManager.get("cookie_file_path")
if path:
opts.append(f"cookies={path}")
else:
browser = ConfigManager.get("cookie_browser")
profile = ConfigManager.get("cookie_browser_profile")
if browser:
value = f"{browser}:{profile}" if profile else browser
opts.append(f"cookies-from-browser={value}")
proxy = ConfigManager.get("proxy_url")
if proxy:
opts.append(f"proxy={proxy}")
return ",".join(opts)
class MpvRenderWidget(QOpenGLWidget):
"""QOpenGLWidget hosting a libmpv render context."""
# Emitted from mpv threads; connected queued to GUI-thread slots
mpvPositionChanged = Signal(float)
mpvDurationChanged = Signal(float)
mpvPausedChanged = Signal(bool)
mpvEndReached = Signal(str) # end-file reason
mpvError = Signal(str)
_renderUpdateRequested = Signal()
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.setMinimumHeight(240)
self._mpv = None
self._render_ctx = None
self._renderUpdateRequested.connect(self.update, Qt.ConnectionType.QueuedConnection)
self._create_mpv()
# ------------------------------------------------------------------ mpv
def _create_mpv(self) -> None:
import mpv
kwargs: Dict[str, Any] = {
"vo": "libmpv",
"ytdl": True,
"keep_open": "yes",
"idle": "yes",
"osc": False,
"input_default_bindings": False,
}
ytdlp_path = get_yt_dlp_path()
if str(ytdlp_path) != "yt-dlp":
kwargs["script_opts"] = f"ytdl_hook-ytdl_path={ytdlp_path}"
self._mpv = mpv.MPV(log_handler=self._on_mpv_log, **kwargs)
self._mpv["ytdl-format"] = _ytdl_format_for(ConfigManager.get("player.default_quality"))
raw_opts = _build_ytdl_raw_options()
if raw_opts:
self._mpv["ytdl-raw-options"] = raw_opts
self._mpv.observe_property("time-pos", self._on_time_pos)
self._mpv.observe_property("duration", self._on_duration)
self._mpv.observe_property("pause", self._on_pause)
@self._mpv.event_callback("end-file")
def _on_end_file(event): # mpv thread
try:
reason = str(getattr(event.data, "reason", ""))
except Exception:
reason = ""
self.mpvEndReached.emit(reason)
# mpv-thread callbacks: signals only, no widget access
def _on_time_pos(self, _name, value) -> None:
if value is not None:
self.mpvPositionChanged.emit(float(value))
def _on_duration(self, _name, value) -> None:
if value is not None:
self.mpvDurationChanged.emit(float(value))
def _on_pause(self, _name, value) -> None:
if value is not None:
self.mpvPausedChanged.emit(bool(value))
def _on_mpv_log(self, level: str, prefix: str, text: str) -> None:
if level in ("error", "fatal"):
logger.error(f"mpv [{prefix}] {text.strip()}")
if "ytdl" in prefix or level == "fatal":
self.mpvError.emit(text.strip())
else:
logger.debug(f"mpv [{prefix}] {text.strip()}")
# --------------------------------------------------------------- OpenGL
def initializeGL(self) -> None:
from mpv import MpvGlGetProcAddressFn, MpvRenderContext
def get_proc_address(_ctx, name):
glctx = QOpenGLContext.currentContext()
if glctx is None:
return 0
address = glctx.getProcAddress(name if isinstance(name, bytes) else name.encode("utf-8"))
return int(address) if address else 0
self._get_proc_address = MpvGlGetProcAddressFn(get_proc_address)
try:
self._render_ctx = MpvRenderContext(
self._mpv,
"opengl",
opengl_init_params={"get_proc_address": self._get_proc_address},
)
self._render_ctx.update_cb = self._renderUpdateRequested.emit # mpv thread
except Exception as e:
# Leave the widget black but keep the app alive (e.g. software GL
# contexts that libmpv rejects)
logger.error(f"Failed to create mpv render context: {e}")
self._render_ctx = None
self.mpvError.emit(f"Video output initialization failed: {e}")
def paintGL(self) -> None:
if self._render_ctx is None:
return
ratio = self.devicePixelRatioF()
w = int(self.width() * ratio)
h = int(self.height() * ratio)
self._render_ctx.render(
flip_y=True,
opengl_fbo={"fbo": self.defaultFramebufferObject(), "w": w, "h": h},
)
def shutdown(self) -> None:
try:
if self._render_ctx is not None:
self._render_ctx.free()
self._render_ctx = None
except Exception as e:
logger.debug(f"Error freeing mpv render context: {e}")
try:
if self._mpv is not None:
self._mpv.terminate()
self._mpv = None
except Exception as e:
logger.debug(f"Error terminating mpv: {e}")
# ------------------------------------------------------------- controls
@property
def mpv(self):
return self._mpv
class PlayerPanel(QWidget):
"""Video area + transport controls. Public API: play/enqueue-agnostic."""
positionChanged = Signal(float)
durationChanged = Signal(float)
playbackEnded = Signal(str) # end-file reason ("eof", "error", ...)
playerError = Signal(str)
nowPlayingChanged = Signal(dict) # entry dict of the current item
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._current_entry: Dict[str, Any] = {}
self._duration: float = 0.0
self._slider_down = False
self._fullscreen_holder: Optional[QWidget] = None
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
self.video = MpvRenderWidget(self)
layout.addWidget(self.video, stretch=1)
self.title_label = QLabel("")
self.title_label.setStyleSheet("font-weight: bold; padding: 2px 6px;")
self.title_label.setWordWrap(True)
layout.addWidget(self.title_label)
controls = QHBoxLayout()
controls.setSpacing(8)
controls.setContentsMargins(6, 0, 6, 4)
self.play_btn = QPushButton()
self.play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
self.play_btn.setFixedWidth(36)
self.play_btn.clicked.connect(self.toggle_pause)
controls.addWidget(self.play_btn)
self.time_label = QLabel("0:00 / 0:00")
controls.addWidget(self.time_label)
self.seek_slider = QSlider(Qt.Orientation.Horizontal)
self.seek_slider.setRange(0, 1000)
self.seek_slider.sliderPressed.connect(self._on_slider_pressed)
self.seek_slider.sliderReleased.connect(self._on_slider_released)
controls.addWidget(self.seek_slider, stretch=1)
self.quality_combo = QComboBox()
for label, height in QUALITY_CHOICES:
self.quality_combo.addItem(_(label) if label.startswith("player.") else label, height)
default_q = ConfigManager.get("player.default_quality")
idx = next((i for i, (_l, h) in enumerate(QUALITY_CHOICES) if h == default_q), 0)
self.quality_combo.setCurrentIndex(idx)
self.quality_combo.currentIndexChanged.connect(self._on_quality_changed)
controls.addWidget(self.quality_combo)
self.speed_combo = QComboBox()
for s in SPEED_CHOICES:
self.speed_combo.addItem(f"{s:g}x", s)
self.speed_combo.setCurrentIndex(SPEED_CHOICES.index(1.0))
self.speed_combo.currentIndexChanged.connect(self._on_speed_changed)
controls.addWidget(self.speed_combo)
self.volume_slider = QSlider(Qt.Orientation.Horizontal)
self.volume_slider.setRange(0, 100)
self.volume_slider.setFixedWidth(90)
self.volume_slider.setValue(int(ConfigManager.get("player.volume") or 100))
self.volume_slider.valueChanged.connect(self._on_volume_changed)
controls.addWidget(self.volume_slider)
self.subs_btn = QPushButton(_("player.subtitles"))
self.subs_btn.setCheckable(True)
self.subs_btn.toggled.connect(self._on_subs_toggled)
controls.addWidget(self.subs_btn)
self.fullscreen_btn = QPushButton()
self.fullscreen_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TitleBarMaxButton))
self.fullscreen_btn.setFixedWidth(36)
self.fullscreen_btn.clicked.connect(self.toggle_fullscreen)
controls.addWidget(self.fullscreen_btn)
layout.addLayout(controls)
# mpv-thread signals arrive queued on the GUI thread
self.video.mpvPositionChanged.connect(self._on_position, Qt.ConnectionType.QueuedConnection)
self.video.mpvDurationChanged.connect(self._on_duration, Qt.ConnectionType.QueuedConnection)
self.video.mpvPausedChanged.connect(self._on_paused_changed, Qt.ConnectionType.QueuedConnection)
self.video.mpvEndReached.connect(self._on_end_reached, Qt.ConnectionType.QueuedConnection)
self.video.mpvError.connect(self.playerError, Qt.ConnectionType.QueuedConnection)
self._volume_apply_timer = QTimer(self)
self._volume_apply_timer.setSingleShot(True)
self._volume_apply_timer.setInterval(400)
self._volume_apply_timer.timeout.connect(self._persist_volume)
# YouTube's CDN intermittently serves stalled/poisoned streams to
# non-browser clients; a reload re-resolves the URLs and usually
# lands on a healthy node. Retry automatically on startup stall.
self._stall_timer = QTimer(self)
self._stall_timer.setSingleShot(True)
self._stall_timer.setInterval(25000)
self._stall_timer.timeout.connect(self._on_startup_stall)
self._stall_retries = 0
self._playback_started = False
# ------------------------------------------------------------ public API
def play(self, entry: Dict[str, Any], resume_pos: float = 0.0) -> None:
"""Play a video. entry needs at least {"url": ...}; extra keys
(id/title/channel/duration/thumbnail) travel to nowPlayingChanged."""
url = entry.get("url") or entry.get("webpage_url")
if not url:
self.playerError.emit("No playable URL in entry")
return
self._current_entry = dict(entry)
title = entry.get("title") or url
self.title_label.setText(title)
mpv_inst = self.video.mpv
if mpv_inst is None:
return
options = {}
if resume_pos and resume_pos > 0:
options["start"] = f"+{max(0.0, resume_pos - 5.0):.1f}"
try:
mpv_inst.loadfile(url, **options)
mpv_inst["pause"] = False
except Exception as e:
logger.exception(f"mpv loadfile failed: {e}")
self.playerError.emit(str(e))
return
self._playback_started = False
self._stall_timer.start()
self.nowPlayingChanged.emit(self._current_entry)
def _on_startup_stall(self) -> None:
if self._playback_started or not self._current_entry:
return
if self._stall_retries < 2:
self._stall_retries += 1
logger.warning(f"Stream stalled before starting; retrying ({self._stall_retries}/2)")
entry = self._current_entry
self._current_entry = {}
self.play(entry)
else:
self._stall_retries = 0
self.playerError.emit(_("player.stream_stalled"))
def stop(self) -> None:
mpv_inst = self.video.mpv
if mpv_inst is not None:
try:
mpv_inst.command("stop")
except Exception:
pass
def toggle_pause(self) -> None:
mpv_inst = self.video.mpv
if mpv_inst is not None:
try:
mpv_inst["pause"] = not mpv_inst["pause"]
except Exception:
pass
def current_entry(self) -> Dict[str, Any]:
return dict(self._current_entry)
def current_position(self) -> float:
mpv_inst = self.video.mpv
try:
return float(mpv_inst["time-pos"] or 0.0) if mpv_inst else 0.0
except Exception:
return 0.0
def shutdown(self) -> None:
self.video.shutdown()
# ----------------------------------------------------------- slots (GUI)
@Slot(float)
def _on_position(self, pos: float) -> None:
if pos > 0 and not self._playback_started:
self._playback_started = True
self._stall_retries = 0
self._stall_timer.stop()
if not self._slider_down and self._duration > 0:
self.seek_slider.blockSignals(True)
self.seek_slider.setValue(int(pos / self._duration * 1000))
self.seek_slider.blockSignals(False)
self.time_label.setText(f"{_format_time(pos)} / {_format_time(self._duration)}")
self.positionChanged.emit(pos)
@Slot(float)
def _on_duration(self, duration: float) -> None:
self._duration = duration
self.durationChanged.emit(duration)
@Slot(bool)
def _on_paused_changed(self, paused: bool) -> None:
icon = QStyle.StandardPixmap.SP_MediaPlay if paused else QStyle.StandardPixmap.SP_MediaPause
self.play_btn.setIcon(self.style().standardIcon(icon))
@Slot(str)
def _on_end_reached(self, reason: str) -> None:
self.playbackEnded.emit(reason)
def _on_slider_pressed(self) -> None:
self._slider_down = True
def _on_slider_released(self) -> None:
self._slider_down = False
if self._duration > 0:
target = self.seek_slider.value() / 1000 * self._duration
mpv_inst = self.video.mpv
if mpv_inst is not None:
try:
mpv_inst.seek(target, reference="absolute")
except Exception as e:
logger.debug(f"Seek failed: {e}")
def _on_quality_changed(self, index: int) -> None:
height = self.quality_combo.itemData(index)
ConfigManager.set("player.default_quality", height)
mpv_inst = self.video.mpv
if mpv_inst is None:
return
mpv_inst["ytdl-format"] = _ytdl_format_for(height)
# Reload the current item at the new quality, keeping position
if self._current_entry:
pos = self.current_position()
self.play(self._current_entry, resume_pos=pos + 5.0 if pos else 0.0)
def _on_speed_changed(self, index: int) -> None:
mpv_inst = self.video.mpv
if mpv_inst is not None:
try:
mpv_inst["speed"] = self.speed_combo.itemData(index)
except Exception:
pass
def _on_volume_changed(self, value: int) -> None:
mpv_inst = self.video.mpv
if mpv_inst is not None:
try:
mpv_inst["volume"] = value
except Exception:
pass
self._volume_apply_timer.start()
def _persist_volume(self) -> None:
ConfigManager.set("player.volume", self.volume_slider.value())
def _on_subs_toggled(self, checked: bool) -> None:
mpv_inst = self.video.mpv
if mpv_inst is not None:
try:
mpv_inst["sid"] = "auto" if checked else "no"
except Exception:
pass
def toggle_fullscreen(self) -> None:
if self._fullscreen_holder is None:
self._fullscreen_parent_layout = self.parentWidget().layout() if self.parentWidget() else None
self._fullscreen_holder = self.parentWidget()
self.setParent(None)
self.setWindowFlags(Qt.WindowType.Window)
self.showFullScreen()
else:
self.setWindowFlags(Qt.WindowType.Widget)
if self._fullscreen_parent_layout is not None:
self._fullscreen_parent_layout.addWidget(self)
else:
self.setParent(self._fullscreen_holder)
self.showNormal()
self.show()
self._fullscreen_holder = None
def keyPressEvent(self, event) -> None:
if event.key() == Qt.Key.Key_Escape and self._fullscreen_holder is not None:
self.toggle_fullscreen()
elif event.key() == Qt.Key.Key_Space:
self.toggle_pause()
elif event.key() == Qt.Key.Key_F:
self.toggle_fullscreen()
else:
super().keyPressEvent(event)
class PlayerUnavailablePanel(QWidget):
"""Placeholder shown when libmpv is missing; the rest of the app works."""
def __init__(self, hint: str, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
layout = QVBoxLayout(self)
layout.addStretch()
msg = QLabel(_("player.unavailable"))
msg.setAlignment(Qt.AlignmentFlag.AlignCenter)
msg.setStyleSheet("font-size: 16px; font-weight: bold;")
layout.addWidget(msg)
hint_label = QLabel(hint)
hint_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
hint_label.setWordWrap(True)
layout.addWidget(hint_label)
layout.addStretch()
def create_player_panel(parent: Optional[QWidget] = None) -> QWidget:
"""PlayerPanel when libmpv is available, otherwise the hint placeholder."""
available, hint = probe_player()
if available:
return PlayerPanel(parent)
return PlayerUnavailablePanel(hint, parent)
def _format_time(seconds: float) -> str:
seconds = int(max(0, 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}"
+19
View File
@@ -0,0 +1,19 @@
"""
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
+91
View File
@@ -0,0 +1,91 @@
"""
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.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
+188
View File
@@ -0,0 +1,188 @@
"""
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]] = []
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)
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)
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())]
# --------------------------------------------------------------- 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)
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()
+67 -3
View File
@@ -22,7 +22,7 @@
"restart_notice": "Language change will take effect after restarting the application." "restart_notice": "Language change will take effect after restarting the application."
}, },
"app": { "app": {
"title": "YTSage", "title": "SageTube",
"version": "v{version}", "version": "v{version}",
"ready": "Ready" "ready": "Ready"
}, },
@@ -240,9 +240,9 @@
"update_in_progress_message": "yt-dlp is currently updating. Please wait a moment." "update_in_progress_message": "yt-dlp is currently updating. Please wait a moment."
}, },
"about": { "about": {
"title": "About YTSage", "title": "About SageTube",
"version": "Version {version}", "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}", "author": "By: {author}",
"github": "GitHub: {repo}", "github": "GitHub: {repo}",
"system_info": "System Information", "system_info": "System Information",
@@ -639,5 +639,69 @@
"update_success": "✅ Deno has been successfully updated!", "update_success": "✅ Deno has been successfully updated!",
"update_failed": "❌ Update failed: {error}", "update_failed": "❌ Update failed: {error}",
"check_failed": "Failed to check for updates. Please check your internet connection." "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."
},
"cards": {
"play": "▶ Play",
"queue": "+ Queue",
"download": "⬇",
"load_more": "Load more",
"empty": "Nothing here yet"
},
"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}"
},
"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."
},
"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"
} }
} }
+2
View File
@@ -25,6 +25,8 @@ def main():
try: try:
logger.info("Starting YTSage application") logger.info("Starting YTSage application")
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName("SageTube")
app.setDesktopFileName("sagetube")
window = YTSageApp() # Instantiate the main application class window = YTSageApp() # Instantiate the main application class
window.show() window.show()
+12 -1
View File
@@ -86,7 +86,7 @@ class ConfigManager:
"geo_proxy_url": None, "geo_proxy_url": None,
"auto_update_ytdlp": True, "auto_update_ytdlp": True,
"auto_update_frequency": "daily", "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, "check_beta_updates": False,
"last_update_check": 0, "last_update_check": 0,
"concurrent_fragments": 1, "concurrent_fragments": 1,
@@ -109,6 +109,17 @@ class ConfigManager:
# the app-managed, SHA256-verified binary is absent # the app-managed, SHA256-verified binary is absent
"allow_system_ytdlp": False, "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)
},
"feed": {
"mode": "local", # local (per-channel aggregation) | account (cookies)
"per_channel_items": 15,
"auto_refresh_minutes": 0, # 0 = manual refresh only
},
} }
@classmethod @classmethod
+16 -6
View File
@@ -91,11 +91,11 @@ if OS_NAME == "Windows":
# Always use user data directory for app data, logs, config, and binaries # 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 # 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_BIN_DIR: Path = APP_DIR / "bin"
APP_DATA_DIR: Path = APP_DIR / "data" APP_DATA_DIR: Path = APP_DIR / "data"
APP_LOG_DIR: Path = APP_DIR / "logs" 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_HISTORY_FILE: Path = APP_DATA_DIR / "ytsage_history.json"
APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails" APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails"
@@ -110,11 +110,11 @@ elif OS_NAME == "Darwin": # macOS
# Always use user data directory for app data, logs, config, and binaries # 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 # 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_BIN_DIR: Path = APP_DIR / "bin"
APP_DATA_DIR: Path = APP_DIR / "data" APP_DATA_DIR: Path = APP_DIR / "data"
APP_LOG_DIR: Path = APP_DIR / "logs" 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_HISTORY_FILE: Path = APP_DATA_DIR / "ytsage_history.json"
APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails" APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails"
@@ -129,11 +129,11 @@ else: # Linux and other UNIX-like
# Always use user data directory for app data, logs, config, and binaries # 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 # 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_BIN_DIR: Path = APP_DIR / "bin"
APP_DATA_DIR: Path = APP_DIR / "data" APP_DATA_DIR: Path = APP_DIR / "data"
APP_LOG_DIR: Path = APP_DIR / "logs" 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_HISTORY_FILE: Path = APP_DATA_DIR / "ytsage_history.json"
APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails" APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails"
@@ -238,3 +238,13 @@ else:
YTDLP_APP_BIN_PATH.parent.mkdir(parents=True, exist_ok=True) YTDLP_APP_BIN_PATH.parent.mkdir(parents=True, exist_ok=True)
if "DENO_APP_BIN_PATH" in globals(): if "DENO_APP_BIN_PATH" in globals():
DENO_APP_BIN_PATH.parent.mkdir(parents=True, exist_ok=True) 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", "")
+267
View File
@@ -0,0 +1,267 @@
"""
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) -> List[Dict[str, Any]]:
with cls._lock:
rows = cls._conn().execute(
"SELECT f.*, s.title AS channel FROM feed_items f "
"LEFT JOIN subscriptions s ON s.channel_id = f.channel_id "
"ORDER BY COALESCE(f.published_ts, f.fetched_at) DESC LIMIT ?",
(limit,),
).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
]