Compare commits
9 Commits
1c885e3fc5
...
v5.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
b3772ff484
|
|||
| b4ff618d41 | |||
| 8a05d5ff8f | |||
| a8ebe89cc1 | |||
| 3afe96a8cd | |||
| 8e935c6116 | |||
| 16be4b4afa | |||
| f886125857 | |||
| 8dd8a3c1ad |
@@ -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.
|
||||
@@ -1,12 +1,45 @@
|
||||
# SageTube
|
||||
|
||||
A watch-first YouTube client for the desktop — search, browse channels and playlists, follow subscriptions, and stream videos in an embedded mpv player, with full yt-dlp download capability inherited from [YTSage](https://github.com/oop7/YTSage).
|
||||
A watch-first YouTube client for the desktop. Search, browse channels and
|
||||
playlists, follow subscriptions, and stream videos in an embedded mpv player —
|
||||
with the full yt-dlp download feature set inherited from
|
||||
[YTSage](https://github.com/oop7/YTSage).
|
||||
|
||||
SageTube is a fork of [YTSage](https://github.com/oop7/YTSage) by [oop7](https://github.com/oop7) (MIT). The original YTSage documentation is preserved at [docs/UPSTREAM_README.md](docs/UPSTREAM_README.md).
|
||||
## Features
|
||||
|
||||
## Status
|
||||
**Watching**
|
||||
- 📺 Embedded mpv player (Wayland/X11/Windows/macOS) with quality selector,
|
||||
speed, volume, subtitles, fullscreen
|
||||
- 🔍 In-app YouTube search with thumbnail card grid
|
||||
- 📂 Channel browsing (Videos / Shorts / Live) and playlist views
|
||||
- 🔔 Local subscriptions — no Google account needed — with an aggregated feed;
|
||||
optional real account feed via browser cookies
|
||||
- ⏯️ Watch history with resume positions and a persistent play queue
|
||||
- ⬇️ One click from any video card into the downloader
|
||||
|
||||
Under active development. See the upstream README for the downloader feature set, which remains fully functional.
|
||||
**Downloading** (from YTSage)
|
||||
- Format table, audio extraction, subtitles, SponsorBlock, chapters,
|
||||
playlists, trimming, speed limits, proxies, cookies, custom yt-dlp commands,
|
||||
download history — see the [upstream README](https://git.houmeres.sk/Houmeres/SageTube/wiki/Upstream-README)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
sagetube
|
||||
```
|
||||
|
||||
Requires **libmpv** for playback (Arch: `pacman -S mpv`) — see
|
||||
[Building](https://git.houmeres.sk/Houmeres/SageTube/wiki/Building). Without it the app works as a downloader.
|
||||
|
||||
## Fork notes
|
||||
|
||||
SageTube is a fork of [YTSage](https://github.com/oop7/YTSage) by
|
||||
[oop7](https://github.com/oop7) (MIT). The internal package keeps the
|
||||
`ytsage` name so upstream fixes remain mergeable — see
|
||||
[Tracking upstream](https://git.houmeres.sk/Houmeres/SageTube/wiki/Tracking-upstream). The fork also fixes a number of
|
||||
upstream bugs (unsafe partial-file cleanup, unverified binary updates,
|
||||
thread-safety issues, PATH corruption on Windows — see the git log).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,624 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="branding/svg/ytsage-wordmark.svg" width="400" alt="ytsage-wordmark">
|
||||
<img src="branding/screenshots/main.png" width="800" alt="YTSage Interface"/>
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://pepy.tech/project/ytsage)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/oop7/YTSage/releases)
|
||||
[](https://github.com/oop7/YTSage/stargazers)
|
||||
[](https://pypi.org/project/ytsage/)
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
**Modern YouTube downloader with a clean PySide6 interface.**
|
||||
Download videos in any quality, extract audio, fetch subtitles, and more.
|
||||
|
||||
### 🌍 README Languages
|
||||
|
||||
English: [EN](README.md)
|
||||
| Arabic: [AR](readme-translations/README.ar.md)
|
||||
| German: [DE](readme-translations/README.de.md)
|
||||
| Spanish: [ES](readme-translations/README.es.md)
|
||||
| French: [FR](readme-translations/README.fr.md)
|
||||
| Hindi: [HI](readme-translations/README.hi.md)
|
||||
| Indonesian: [ID](readme-translations/README.id.md)
|
||||
| Italian: [IT](readme-translations/README.it.md)
|
||||
| Japanese: [JA](readme-translations/README.ja.md)
|
||||
| Polish: [PL](readme-translations/README.pl.md)
|
||||
| Portuguese: [PT](readme-translations/README.pt.md)
|
||||
| Russian: [RU](readme-translations/README.ru.md)
|
||||
| Turkish: [TR](readme-translations/README.tr.md)
|
||||
| Chinese: [ZH](readme-translations/README.zh.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="#installation">Installation</a> •
|
||||
<a href="#features">Features</a> •
|
||||
<a href="#usage">Usage</a> •
|
||||
<a href="#screenshots">Screenshots</a> •
|
||||
<a href="#troubleshooting">Troubleshooting</a> •
|
||||
<a href="#sponsor">Sponsor</a> •
|
||||
<a href="#contributing">Contributing</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a id="why-ytsage"></a>
|
||||
## ❓ Why YTSage?
|
||||
|
||||
YTSage is designed for users who want a **simple yet powerful YouTube downloader**. Unlike other tools, it offers:
|
||||
|
||||
- A modern and clean PySide6 interface
|
||||
- One-click downloads for video, audio, and subtitles
|
||||
- Advanced features like SponsorBlock, subtitle merging, and playlist selection
|
||||
- Optional Generic Mode for sites supported by yt-dlp beyond YouTube
|
||||
- Cross-platform support and easy installation
|
||||
|
||||
<a id="features"></a>
|
||||
## ✨ Features
|
||||
|
||||
<div align="center">
|
||||
|
||||
| Core Features | Advanced Features | Extra Features |
|
||||
|-----------------------------------|-----------------------------------------|------------------------------------|
|
||||
| 🎥 Format Table | 🚫 SponsorBlock Integration | 🎞️ FPS/HDR Display |
|
||||
| 🎵 Audio Extraction | 📝 Subtitle Selection & Merging | 🔄 Auto Update yt-dlp |
|
||||
| ✨ Simple UI | 💾 Save Description & Thumbnail | 🛠️ FFmpeg/yt-dlp/Deno Detection |
|
||||
| 📋 Playlist Support & Selector | 🚀 Speed Limiter | ⚙️ Custom Commands |
|
||||
| 📑 Chapter Integration | ✂️ Video Section Trimming | 🍪 Login with Cookies |
|
||||
| 📜 Download History | 🔄 Version Channel Selection | 🌐 Proxy Support |
|
||||
| 🎚️ Audio Format Conversion | 🎬 Video Format Settings | 🆙 Built-in Updater Tab |
|
||||
| 🌍 Generic Mode | 🔊 Audio Normalization (EBU R128) | 🌍 Localized in 14 Languages |
|
||||
| 💾 Playlist Export | ⚙️ Default Quality & Subtitles | |
|
||||
</div>
|
||||
|
||||
<a id="installation"></a>
|
||||
## 🚀 Installation
|
||||
|
||||
### ⚡ Quick Install (Recommended)
|
||||
|
||||
Install YTSage via PyPI:
|
||||
|
||||
```bash
|
||||
pip install ytsage
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>🔄 Update existing installation</summary>
|
||||
|
||||
```bash
|
||||
pip install --upgrade ytsage
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Then launch the application:
|
||||
|
||||
```bash
|
||||
ytsage
|
||||
```
|
||||
|
||||
### 📦 Pre-built Executables
|
||||
|
||||
> [👉 Download Latest Release](https://github.com/oop7/YTSage/releases/latest)
|
||||
|
||||
#### 🪟 Windows
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
|  | Standard Installer |
|
||||
|  | With FFmpeg Included |
|
||||
|  | Portable version, no installation needed |
|
||||
|  | Portable with FFmpeg, zipped |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Installation Steps</summary>
|
||||
|
||||
1. **EXE Installer (`.exe`)**: Double-click the file and follow the setup wizard.
|
||||
2. **Portable Version (`.zip`)**: Extract the archive to your desired location and launch `ytsage.exe`.
|
||||
3. **FFmpeg Included**: Choose versions with FFmpeg included if you don't have FFmpeg installed on your system.
|
||||
</details>
|
||||
|
||||
#### 🐧 Linux
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
|  | Debian Package |
|
||||
|  | AppImage, Portable |
|
||||
|  | RPM Package |
|
||||
|  | Flatpak Bundle |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Installation Steps</summary>
|
||||
|
||||
- **DEB (`.deb`)**:
|
||||
```bash
|
||||
sudo dpkg -i ytsage_*.deb
|
||||
sudo apt-get install -f # Fix missing dependencies if needed
|
||||
```
|
||||
- **RPM (`.rpm`)**:
|
||||
```bash
|
||||
sudo rpm -i ytsage-*.rpm
|
||||
```
|
||||
- **AppImage (`.AppImage`)**:
|
||||
```bash
|
||||
chmod +x YTSage-*.AppImage
|
||||
./YTSage-*.AppImage
|
||||
```
|
||||
- **Flatpak**: Follow instructions on Flathub or run:
|
||||
```bash
|
||||
flatpak install flathub io.github.oop7.ytsage
|
||||
```
|
||||
</details>
|
||||
|
||||
#### 🍎 macOS
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
|  | Zipped Application for Apple Silicon |
|
||||
|  | Disk Image Installer for Apple Silicon |
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Installation Steps</summary>
|
||||
|
||||
- **DMG Installer (`.dmg`)**: Double-click to mount, then drag `YTSage.app` to your Applications folder.
|
||||
- **Application Archive (`.zip`)**: Extract the zip and move `YTSage.app` to your Applications folder.
|
||||
|
||||
*Note: If you encounter an "Application is damaged" error, see the macOS troubleshooting section below.*
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>💻 Manual Source Installation</summary>
|
||||
|
||||
### 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oop7/YTSage.git
|
||||
cd YTSage
|
||||
```
|
||||
|
||||
### 2. Install dependencies
|
||||
|
||||
#### ⚡ Using uv
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
#### 📦 Or using standard pip
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
### 3. Run the application
|
||||
|
||||
```bash
|
||||
python -m ytsage.main
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<a id="screenshots"></a>
|
||||
## 📸 Screenshots
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="branding/screenshots/Download-Settings.png" alt="Download Settings" width="400"/></td>
|
||||
<td><img src="branding/screenshots/playlist.png" alt="Playlist Download" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Download Settings</em></td>
|
||||
<td align="center"><em>Playlist Download</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="branding/screenshots/audio_format.png" alt="Audio Format Selection" width="400"/></td>
|
||||
<td><img src="branding/screenshots/Custom-Option.png" alt="Custom Options" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Audio Format</em></td>
|
||||
<td align="center"><em>Custom Options</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a id="usage"></a>
|
||||
## 📖 Usage
|
||||
|
||||
<details>
|
||||
<summary>🎯 Basic Usage</summary>
|
||||
|
||||
1. **Launch YTSage**
|
||||
2. **Paste YouTube URL** (or use "Paste URL" button)
|
||||
3. **Click "Analyze"**
|
||||
4. **Select Format:**
|
||||
- `Video` for video downloads
|
||||
- `Audio Only` for audio extraction
|
||||
5. **Choose Options:**
|
||||
- Enable Subtitles and select language
|
||||
- Enable Subtitle Merging
|
||||
- Save Thumbnail
|
||||
- Remove Sponsored Segments
|
||||
- Save Description
|
||||
- Embed Chapters
|
||||
6. **Select Output Directory**
|
||||
7. **Click "Download"**
|
||||
|
||||
> 💡 Default download directory is the user's "Downloads" folder.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 Playlist Download</summary>
|
||||
|
||||
1. **Paste Playlist URL**
|
||||
2. **Click "Analyze"**
|
||||
3. **Select videos from the playlist selector (optional, defaults to all)**
|
||||
4. **Choose desired format/quality**
|
||||
5. **Click "Download"**
|
||||
|
||||
> 💡 The application automatically handles the download queue, and you can export playlist entries as `.txt`, `.csv`, `.m3u`, or `.json`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Generic Mode for Non-YouTube Sites</summary>
|
||||
|
||||
Use Generic Mode when you want YTSage to accept URLs from sites supported by yt-dlp, such as Dailymotion, CBC Gem, TikTok, and others.
|
||||
|
||||
How to use it:
|
||||
|
||||
1. Open `Download Settings`.
|
||||
2. Toggle on `Generic Mode`.
|
||||
3. Paste a supported video or playlist URL that is not from YouTube.
|
||||
4. Click `Analyze`.
|
||||
5. Choose a format and download as usual.
|
||||
|
||||
Notes:
|
||||
|
||||
- Generic mode only changes the URL validation inside YTSage. The target site must still be supported by your installed version of yt-dlp.
|
||||
- Some sites require cookies, login sessions, proxy, or extra yt-dlp arguments depending on the extractor.
|
||||
- If a site fails, update yt-dlp from the built-in updater tab first before reporting an issue.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🧰 Media & Download Options</summary>
|
||||
|
||||
- **Subtitle Options:** Filter languages and embed subtitles into the video file.
|
||||
- **Subtitle Merging:** Merge subtitles into the video file for hardcoded/burned-in subtitles.
|
||||
- **Save Description:** Save the video description as a text file.
|
||||
- **Save Thumbnail:** Save the video thumbnail as an image file.
|
||||
- **Embed Chapters:** Embed chapter markers as metadata for compatible video players.
|
||||
- **Remove Sponsored Segments:** Remove sponsored segments from the video using SponsorBlock.
|
||||
- **Trim Video:** Download only specific parts of a video by specifying time ranges in `HH:MM:SS` format.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⚙️ Output & File Settings</summary>
|
||||
|
||||
- **Speed Limiter:** Limit download speed, e.g., `500K` for 500 KB/s.
|
||||
- **Save Download Path:** Saves the default download path for future downloads. Available in **Download Settings → Download Path**.
|
||||
- **Default Video Resolution:** Set your preferred default video resolution for auto-selection (e.g., 1080p, 720p). Available in **Download Settings → Default Video Resolution**.
|
||||
- **Default Subtitle Languages:** Set default subtitle languages for auto-selection (comma-separated, e.g., `en,es`). Available in **Download Settings → Default Subtitle Languages**.
|
||||
- **Output Filename Format:** Customize the output filename format using variables like `%(title)s`, `%(uploader)s`, `%(playlist_index)s`, and `%(resolution)s`. Available in **Download Settings → Filename Format**.
|
||||
- **Force Output Format:** Force video downloads into a specific container format like `mp4`, `webm`, or `mkv`. Available in **Download Settings → Output Format Settings**.
|
||||
- **Audio Format Conversion:** Convert audio-only downloads into preferred formats such as `AAC`, `MP3`, `FLAC`, `WAV`, `Opus`, `M4A`, `Vorbis`, or `Best`. Available in **Download Settings → Audio Format Settings**.
|
||||
- **Audio Normalization:** Standardize volume for audio-only downloads using EBU R128.
|
||||
- **Concurrent Connections:** Dramatically increase download speed by downloading files in multiple fragments simultaneously. Available in **Download Settings → General → Concurrent Connections** (Default is 1, maximum recommended is 8-10 to avoid IP throttling).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌐 Access & Network</summary>
|
||||
|
||||
- **Login with Cookies:** Log in to YouTube using cookies to access private content.
|
||||
How to use it:
|
||||
1. **Recommended:** Use the built-in `Extract cookies from browser` option in the app, then select your browser and optionally a profile.
|
||||
2. Alternatively, extract cookies manually:
|
||||
a. Export browser cookies using an extension like [cookie-editor](https://github.com/moustachauve/cookie-editor?tab=readme-ov-file)
|
||||
b. Copy cookies in Netscape format
|
||||
c. Create a file named `cookies.txt` and paste cookies
|
||||
d. Select the `cookies.txt` file in the app
|
||||
- **Proxy Support:** Use a proxy server for downloads, e.g., `http://<proxy-server>:<port>`
|
||||
- **Generic Mode:** Allows YTSage to analyze and download from non-YouTube sites supported by yt-dlp. Enable from **Download Settings → Generic Mode**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🛠️ Tools & Maintenance</summary>
|
||||
|
||||
- **Custom Commands:** Access advanced yt-dlp features via command-line arguments.
|
||||
- **Updater Tab:** Manage built-in update tools from one place in Custom Options:
|
||||
- **yt-dlp Updates:** Check for updates and toggle between Stable and Nightly release channels.
|
||||
- **FFmpeg Version Checker:** Check your FFmpeg version and open installation guides.
|
||||
- **Deno Updates:** Check and update the Deno runtime.
|
||||
- **FFmpeg/yt-dlp/Deno Detection:** Automatically detects paths and versions for FFmpeg, yt-dlp, and Deno from the About dialog.
|
||||
- **Download History:** View past downloads with thumbnails and statuses from the **History** button.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🌍 Localization</summary>
|
||||
|
||||
YTSage supports **14 languages** for global accessibility. Select your preferred language in **Custom Options → Language**.
|
||||
|
||||
### Supported Languages
|
||||
|
||||
| Language | Code | Language | Code |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 English | `en` | 🇪🇸 Spanish | `es` |
|
||||
| 🇸🇦 Arabic | `ar` | 🇫🇷 French | `fr` |
|
||||
| 🇩🇪 German | `de` | 🇮🇳 Hindi | `hi` |
|
||||
| 🇮🇩 Indonesian | `id` | 🇮🇹 Italian | `it` |
|
||||
| 🇯🇵 Japanese | `ja` | 🇵🇱 Polish | `pl` |
|
||||
| 🇧🇷 Portuguese | `pt` | 🇷🇺 Russian | `ru` |
|
||||
| 🇹🇷 Turkish | `tr` | 🇨🇳 Chinese | `zh` |
|
||||
|
||||
### README Translations
|
||||
|
||||
| Language | File | Language | File |
|
||||
|----------|------|----------|------|
|
||||
| 🇺🇸 English | [README.md](README.md) | 🇪🇸 Spanish | [readme-translations/README.es.md](readme-translations/README.es.md) |
|
||||
| 🇸🇦 Arabic | [readme-translations/README.ar.md](readme-translations/README.ar.md) | 🇫🇷 French | [readme-translations/README.fr.md](readme-translations/README.fr.md) |
|
||||
| 🇩🇪 German | [readme-translations/README.de.md](readme-translations/README.de.md) | 🇮🇳 Hindi | [readme-translations/README.hi.md](readme-translations/README.hi.md) |
|
||||
| 🇮🇩 Indonesian | [readme-translations/README.id.md](readme-translations/README.id.md) | 🇮🇹 Italian | [readme-translations/README.it.md](readme-translations/README.it.md) |
|
||||
| 🇯🇵 Japanese | [readme-translations/README.ja.md](readme-translations/README.ja.md) | 🇵🇱 Polish | [readme-translations/README.pl.md](readme-translations/README.pl.md) |
|
||||
| 🇧🇷 Portuguese | [readme-translations/README.pt.md](readme-translations/README.pt.md) | 🇷🇺 Russian | [readme-translations/README.ru.md](readme-translations/README.ru.md) |
|
||||
| 🇹🇷 Turkish | [readme-translations/README.tr.md](readme-translations/README.tr.md) | 🇨🇳 Chinese | [readme-translations/README.zh.md](readme-translations/README.zh.md) |
|
||||
|
||||
> 💡 **Want to contribute a translation?** Check out the [Contributing](#contributing) section to help us add more languages!
|
||||
|
||||
</details>
|
||||
|
||||
<a id="troubleshooting"></a>
|
||||
## 🛠️ Troubleshooting
|
||||
|
||||
<details>
|
||||
<summary>Click to view common issues and solutions</summary>
|
||||
|
||||
- **Format table not appearing:** Update yt-dlp to latest version and switch to nightly yt-dlp.
|
||||
- **Download failed:** Check your internet connection and ensure the video is available.
|
||||
- **Specific Download Errors:**
|
||||
- **Private Videos:** Use cookie authentication to access private content.
|
||||
- **Age-Restricted Content:** Log in to your YouTube account to view age-restricted videos.
|
||||
- **Geo-Blocked Videos:** Consider using a VPN to bypass regional restrictions.
|
||||
- **Deleted Videos:** Video is no longer available on YouTube.
|
||||
- **Live Streams:** Live streams cannot be downloaded; wait for the broadcast to end.
|
||||
- **Network Errors:** Check your internet connection and try again.
|
||||
- **Invalid URLs:** Ensure the URL is correct and from a supported platform.
|
||||
- **Premium Content:** Requires a YouTube Premium subscription.
|
||||
- **Copyright Blocks:** Content is blocked due to copyright restrictions.
|
||||
- **Video and Audio Files separate after download:** This happens when FFmpeg is missing or not detected. YTSage requires FFmpeg to merge high-quality video and audio streams.
|
||||
- **Solution:** Ensure FFmpeg is installed and accessible in your system's PATH. For Windows users, the easiest option is to download the `YTSage-v<version>-ffmpeg.exe` file, which comes bundled with FFmpeg.
|
||||
|
||||
---
|
||||
|
||||
#### 🛡️ Windows Defender / Antivirus Warning
|
||||
|
||||
Some antivirus software may flag `.exe` files as false positives. This is a **known limitation** of packaged applications.
|
||||
|
||||
**Why this happens:**
|
||||
- Antivirus heuristics can mistakenly identify packaged executables as suspicious.
|
||||
|
||||
**Safe Alternatives:**
|
||||
- ✅ **Use pip install:** `pip install ytsage` (Recommended)
|
||||
- ✅ **Build from Source**: by following this [guide](.github/CI_CD_README.md)
|
||||
- ✅ **Whitelist the app** in your antivirus software.
|
||||
|
||||
#### 🍎 macOS: "Application is damaged and cannot be opened"
|
||||
If you see this error on macOS Sonoma or newer, you need to remove the quarantine attribute.
|
||||
|
||||
1. **Open Terminal** (you can find this using Spotlight).
|
||||
2. **Type the following command** but **do not** press Enter yet. Make sure to include the space at the end:
|
||||
```bash
|
||||
xattr -d com.apple.quarantine
|
||||
```
|
||||
3. **Drag the `YTSage.app` file** from your Finder window and drop it directly into the Terminal window. This will automatically paste the correct file path.
|
||||
4. **Press Enter** to run the command.
|
||||
5. **Try opening YTSage.app again.** It should now launch correctly.
|
||||
|
||||
---
|
||||
|
||||
#### **Config Locations (Advanced)**
|
||||
- **Windows:** `%LOCALAPPDATA%\YTSage`
|
||||
- **macOS:** `~/Library/Application Support/YTSage`
|
||||
- **Linux:** `~/.local/share/YTSage`
|
||||
|
||||
</details>
|
||||
|
||||
<a id="sponsor"></a>
|
||||
## 💖 Sponsor
|
||||
|
||||
If YTSage saves you time, please consider sponsoring the project. Sponsoring helps cover development time, testing across all platforms, and future improvements.
|
||||
|
||||
- GitHub Sponsors: https://github.com/sponsors/oop7
|
||||
- Sponsorship link is also available directly in the app via the About dialog.
|
||||
|
||||
[](https://github.com/sponsors/oop7)
|
||||
|
||||
<a id="contributing"></a>
|
||||
## 👥 Contributing
|
||||
|
||||
We welcome contributions! Here’s how you can help:
|
||||
|
||||
1. 🍴 Fork the repository
|
||||
2. 🌿 Create your feature branch:
|
||||
```bash
|
||||
git checkout -b feature/AmazingFeature
|
||||
```
|
||||
3. 💾 Commit your changes:
|
||||
```bash
|
||||
git commit -m 'Add some AmazingFeature'
|
||||
```
|
||||
4. 📤 Push to the branch:
|
||||
```bash
|
||||
git push origin feature/AmazingFeature
|
||||
```
|
||||
5. 🔄 Open a Pull Request
|
||||
|
||||
### 🌍 Contributing Translations
|
||||
|
||||
- Update the relevant localized README file (e.g., `readme-translations/README.fr.md`)
|
||||
- Keep app strings synced by editing `ytsage/languages/<code>.json`
|
||||
- If your language is missing, start from `README.md` and create `readme-translations/README.<code>.md`
|
||||
|
||||
<details>
|
||||
<summary>📂 Project Structure</summary>
|
||||
|
||||
## YTSage - Project Structure
|
||||
|
||||
This document describes the organized folder structure of YTSage.
|
||||
|
||||
### 📁 Project Structure
|
||||
|
||||
```
|
||||
YTSage/
|
||||
├── 📁 .github/ # GitHub configuration
|
||||
│ ├── 📁 ISSUE_TEMPLATE/ # Issue templates
|
||||
│ │ └── 🐛-bug-report.md # Bug report template
|
||||
│ ├─── 📁 workflows/ # GitHub Actions workflows
|
||||
│ │ ├── build-linux.yml # Linux build workflow
|
||||
│ │ ├── build-macos.yml # macOS build workflow
|
||||
│ │ │── build-windows.yml # Windows build workflow
|
||||
| | └── release-all.yml # Release master workflow
|
||||
│ └── 📄 CI_CD_README.md # CI/CD documentation
|
||||
├── 📁 branding/ # Branding assets (Screenshots, SVGs)
|
||||
│ ├── 📁 icons/ # App icons
|
||||
│ ├── 📁 screenshots/ # Documentation screenshots
|
||||
│ └── 📁 svg/ # SVG assets
|
||||
├── 📄 LICENSE # License file
|
||||
├── 📄 pyproject.toml # Project metadata and dependencies
|
||||
├── 📄 README.md # Project documentation
|
||||
├── 📄 requirements.txt # Python dependencies (dev)
|
||||
└── 📁 ytsage/ # Source package
|
||||
├── 📁 assets/ # Runtime assets
|
||||
│ ├── 📁 Icon/ # App icons
|
||||
│ └── 📁 sound/ # Sound files
|
||||
├── 📁 languages/ # Localization files
|
||||
│ ├── 📄 ar.json # Arabic translation
|
||||
│ ├── 📄 de.json # German translation
|
||||
│ ├── 📄 en.json # English translation
|
||||
│ └── ... # Other languages
|
||||
├── 📁 core/ # Core business logic
|
||||
│ ├── 📄 __init__.py # Core package init
|
||||
│ ├── 📄 ytsage_deno.py # Deno integration
|
||||
│ ├── 📄 ytsage_downloader.py # Download functionality
|
||||
│ ├── 📄 ytsage_ffmpeg.py # FFmpeg integration
|
||||
│ ├── 📄 ytsage_utils.py # Utility functions
|
||||
│ └── 📄 ytsage_yt_dlp.py # yt-dlp integration
|
||||
├── 📁 gui/ # UI components
|
||||
│ ├── 📄 __init__.py # GUI package init
|
||||
│ ├── 📄 ytsage_gui_main.py # Main app window
|
||||
│ └── 📁 ytsage_gui_dialogs/ # Dialog classes
|
||||
├── 📁 utils/ # Utility modules
|
||||
│ ├── 📄 __init__.py # Utils package init
|
||||
│ ├── 📄 ytsage_config_manager.py # Config management
|
||||
│ └── 📄 ytsage_logger.py # Logging utilities
|
||||
├── 📄 __init__.py # Package entry point
|
||||
└── 📄 main.py # Main execution script
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ⭐️ Star History
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#oop7/YTSage&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
## 📜 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
<details>
|
||||
<summary>Show Acknowledgments</summary>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<p>A big thanks to everyone who contributed to this project by opening an issue to suggest an improvement or report a bug.</p>
|
||||
|
||||
<table>
|
||||
<tr class="section"><th colspan="2">Core Components</th></tr>
|
||||
<tr>
|
||||
<td width="35%"><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
||||
<td>Download Engine</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://ffmpeg.org/">FFmpeg</a></td>
|
||||
<td>Media Processing</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://deno.com/">Deno</a></td>
|
||||
<td>Runtime for yt-dlp plugins</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Libraries & Frameworks</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://wiki.qt.io/Qt_for_Python">PySide6</a></td>
|
||||
<td>GUI Framework</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-pillow.org/">Pillow</a></td>
|
||||
<td>Image Processing</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://requests.readthedocs.io/">requests</a></td>
|
||||
<td>HTTP Requests</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://packaging.python.org/">packaging</a></td>
|
||||
<td>Version/Package Management</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://python-markdown.github.io/">markdown</a></td>
|
||||
<td>Markdown Rendering</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/Delgan/loguru">loguru</a></td>
|
||||
<td>Logging</td>
|
||||
</tr>
|
||||
<tr class="section"><th colspan="2">Assets & Contributors</th></tr>
|
||||
<tr>
|
||||
<td><a href="https://pixabay.com/sound-effects/new-notification-09-352705/">New Notification 09 by Universfield</a></td>
|
||||
<td>Notification Sound</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/viru185">viru185</a></td>
|
||||
<td>Code Contributor</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
This tool is for personal use only. Please respect YouTube's Terms of Service and content creator rights.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
Made with ❤️ by [oop7](https://github.com/oop7)
|
||||
|
||||
</div>
|
||||
+10
-7
@@ -3,11 +3,12 @@ requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ytsage"
|
||||
version = "5.2.0"
|
||||
description = "Modern YouTube downloader with a clean PySide6 interface."
|
||||
name = "sagetube"
|
||||
version = "0.1.0"
|
||||
description = "Watch-first YouTube client with embedded mpv playback and yt-dlp downloading. Fork of YTSage."
|
||||
authors = [
|
||||
{ name = "oop7", email = "oop7_support@proton.me" },
|
||||
{ name = "Houmeres", email = "admin@ecoposta.sk" },
|
||||
{ name = "oop7 (YTSage upstream)", email = "oop7_support@proton.me" },
|
||||
]
|
||||
dependencies = [
|
||||
"PySide6>=6.10.1",
|
||||
@@ -17,6 +18,7 @@ dependencies = [
|
||||
"markdown>=3.10",
|
||||
"loguru>=0.7.3",
|
||||
"setuptools>=80.9.0",
|
||||
"python-mpv>=1.0.7",
|
||||
]
|
||||
requires-python = ">=3.10,<3.15"
|
||||
readme = "README.md"
|
||||
@@ -47,12 +49,13 @@ keywords = ["youtube", "downloader", "video", "audio", "PySide6", "yt-dlp", "GUI
|
||||
|
||||
|
||||
[project.scripts]
|
||||
sagetube = "ytsage.main:main"
|
||||
ytsage = "ytsage.main:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/oop7/YTSage"
|
||||
Bug-Tracker = "https://github.com/oop7/YTSage/issues"
|
||||
Reddit = "https://www.reddit.com/r/NO-N_A_M_E/"
|
||||
Homepage = "https://git.houmeres.sk/Houmeres/SageTube"
|
||||
Bug-Tracker = "https://git.houmeres.sk/Houmeres/SageTube/issues"
|
||||
Upstream = "https://github.com/oop7/YTSage"
|
||||
|
||||
[tool.setuptools]
|
||||
include-package-data = true
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@ YTSage - YouTube Video Downloader
|
||||
A modern, user-friendly YouTube video downloader built with PySide6.
|
||||
"""
|
||||
|
||||
__version__ = "5.2.0"
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "oop7"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -1,16 +1,13 @@
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from PySide6.QtCore import QMetaObject, Qt, Q_ARG, QThread, Signal, QTimer
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from ..core.ytsage_client import run_ytdlp_capture
|
||||
from ..core.ytsage_utils import validate_video_url, parse_yt_dlp_error
|
||||
from ..core.ytsage_yt_dlp import get_yt_dlp_path
|
||||
from ..utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
@@ -100,44 +97,8 @@ class AnalysisThread(QThread):
|
||||
|
||||
@staticmethod
|
||||
def _run_ytdlp(cmd: list, timeout: int) -> subprocess.CompletedProcess:
|
||||
"""Run yt-dlp capturing output, killing the whole process group on timeout.
|
||||
|
||||
subprocess.run() only kills the direct child on TimeoutExpired, leaking
|
||||
grandchildren (deno); it also loses any stderr produced before the
|
||||
timeout. Use Popen with a new session so the entire group can be
|
||||
reaped, and surface the partial stderr in the raised exception.
|
||||
"""
|
||||
popen_kwargs: Dict[str, Any] = {
|
||||
"stdout": subprocess.PIPE,
|
||||
"stderr": subprocess.PIPE,
|
||||
"text": True,
|
||||
"encoding": "utf-8",
|
||||
"errors": "replace",
|
||||
}
|
||||
if sys.platform == "win32":
|
||||
popen_kwargs["creationflags"] = SUBPROCESS_CREATIONFLAGS
|
||||
else:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
proc = subprocess.Popen(cmd, **popen_kwargs)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
if sys.platform == "win32":
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
stdout, stderr = proc.communicate()
|
||||
exc.stdout, exc.stderr = stdout, stderr
|
||||
raise
|
||||
return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
|
||||
"""Run yt-dlp via the shared client runner (process-group-safe)."""
|
||||
return run_ytdlp_capture(cmd, timeout=timeout)
|
||||
|
||||
def _analyze_url_with_subprocess(self, url: str) -> None:
|
||||
"""Analyze URL using yt-dlp executable."""
|
||||
|
||||
@@ -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)
|
||||
@@ -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.setSpacing(15)
|
||||
|
||||
author_link = '<a href="https://github.com/oop7/" style="color: #c90000; text-decoration: none;">oop7</a>'
|
||||
author_link = '<a href="https://git.houmeres.sk/Houmeres" style="color: #c90000; text-decoration: none;">Houmeres</a>'
|
||||
author_label = QLabel(
|
||||
f"{_('about.author', author=author_link)}"
|
||||
)
|
||||
author_label.setOpenExternalLinks(True)
|
||||
info_layout.addWidget(author_label)
|
||||
|
||||
repo_link = '<a href="https://github.com/oop7/YTSage/" style="color: #c90000; text-decoration: none;">YTSage</a>'
|
||||
repo_link = '<a href="https://git.houmeres.sk/Houmeres/SageTube" style="color: #c90000; text-decoration: none;">SageTube</a>'
|
||||
repo_label = QLabel(
|
||||
f"{_('about.github', repo=repo_link)}"
|
||||
)
|
||||
repo_label.setOpenExternalLinks(True)
|
||||
info_layout.addWidget(repo_label)
|
||||
|
||||
sponsor_link = '<a href="https://github.com/sponsors/oop7" style="color: #c90000; text-decoration: none;">❤️ Sponsor</a>'
|
||||
sponsor_label = QLabel(sponsor_link)
|
||||
sponsor_label.setOpenExternalLinks(True)
|
||||
info_layout.addWidget(sponsor_label)
|
||||
upstream_link = (
|
||||
'<a href="https://github.com/oop7/YTSage" style="color: #c90000; text-decoration: none;">'
|
||||
"Based on YTSage by oop7</a>"
|
||||
)
|
||||
upstream_label = QLabel(upstream_link)
|
||||
upstream_label.setOpenExternalLinks(True)
|
||||
info_layout.addWidget(upstream_label)
|
||||
|
||||
# Center the info layout
|
||||
info_container = QHBoxLayout()
|
||||
|
||||
@@ -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()
|
||||
@@ -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_video_info import VideoInfoMixin
|
||||
from .ytsage_gui_analysis import AnalysisMixin
|
||||
from .ytsage_smooth_tab_widget import SmoothTabWidget
|
||||
from ..utils.ytsage_constants import (
|
||||
ICON_PATH,
|
||||
SOUND_PATH,
|
||||
@@ -381,9 +382,10 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
self.setWindowTitle(f"{_('app.title')} {_('app.version', version=self.version)}")
|
||||
self.setMinimumSize(900, 750)
|
||||
|
||||
# Main widget and layout
|
||||
# Downloads page keeps the original single-page layout; the central
|
||||
# widget becomes a tab shell built at the end of init_ui
|
||||
main_widget = QWidget()
|
||||
self.setCentralWidget(main_widget)
|
||||
self.download_page = main_widget
|
||||
layout = QVBoxLayout(main_widget)
|
||||
layout.setSpacing(8)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
@@ -626,6 +628,62 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
# Disable analysis-dependent controls until video is analyzed
|
||||
self.toggle_analysis_dependent_controls(enabled=False)
|
||||
|
||||
self._setup_main_tabs()
|
||||
|
||||
def _setup_main_tabs(self) -> None:
|
||||
"""Wrap the pages in the watch-first tab shell (SageTube)."""
|
||||
from .ytsage_gui_browse import BrowsePage
|
||||
from .ytsage_gui_feed import FeedPage
|
||||
from .ytsage_gui_player import PlayerPanel, create_player_panel
|
||||
from .ytsage_gui_router import AppRouter
|
||||
from .ytsage_gui_search import SearchPage
|
||||
from .ytsage_gui_watch import WatchPage
|
||||
|
||||
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:
|
||||
"""Enable or disable the Analyze button based on URL input content."""
|
||||
self.analyze_button.setEnabled(bool(text.strip()))
|
||||
@@ -1192,6 +1250,13 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
def closeEvent(self, event) -> None:
|
||||
"""Handle application close event to ensure proper cleanup of background threads."""
|
||||
try:
|
||||
# Persist watch position/queue and release libmpv
|
||||
if hasattr(self, "watch_page"):
|
||||
try:
|
||||
self.watch_page.shutdown()
|
||||
except Exception as e:
|
||||
logger.debug(f"Watch page shutdown error: {e}")
|
||||
|
||||
# Stop the analysis thread if it's running
|
||||
if hasattr(self, "_analysis_thread") and self._analysis_thread is not None and self._analysis_thread.isRunning():
|
||||
logger.info("Stopping analysis thread...")
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -22,7 +22,7 @@
|
||||
"restart_notice": "Language change will take effect after restarting the application."
|
||||
},
|
||||
"app": {
|
||||
"title": "YTSage",
|
||||
"title": "SageTube",
|
||||
"version": "v{version}",
|
||||
"ready": "Ready"
|
||||
},
|
||||
@@ -240,9 +240,9 @@
|
||||
"update_in_progress_message": "yt-dlp is currently updating. Please wait a moment."
|
||||
},
|
||||
"about": {
|
||||
"title": "About YTSage",
|
||||
"title": "About SageTube",
|
||||
"version": "Version {version}",
|
||||
"description": "Modern YouTube downloader with clean PySide6 interface.",
|
||||
"description": "Watch-first YouTube client with embedded mpv playback and yt-dlp downloading. Fork of YTSage by oop7 (MIT).",
|
||||
"author": "By: {author}",
|
||||
"github": "GitHub: {repo}",
|
||||
"system_info": "System Information",
|
||||
@@ -639,5 +639,69 @@
|
||||
"update_success": "✅ Deno has been successfully updated!",
|
||||
"update_failed": "❌ Update failed: {error}",
|
||||
"check_failed": "Failed to check for updates. Please check your internet connection."
|
||||
},
|
||||
"player": {
|
||||
"quality_auto": "Auto",
|
||||
"subtitles": "CC",
|
||||
"unavailable": "Video player unavailable",
|
||||
"now_playing": "Now Playing",
|
||||
"queue": "Queue",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"stream_stalled": "Stream failed to start after several attempts - YouTube may be throttling. Try again or pick a lower quality."
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ def main():
|
||||
try:
|
||||
logger.info("Starting YTSage application")
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("SageTube")
|
||||
app.setDesktopFileName("sagetube")
|
||||
|
||||
window = YTSageApp() # Instantiate the main application class
|
||||
window.show()
|
||||
|
||||
@@ -86,7 +86,7 @@ class ConfigManager:
|
||||
"geo_proxy_url": None,
|
||||
"auto_update_ytdlp": True,
|
||||
"auto_update_frequency": "daily",
|
||||
"check_app_updates": True,
|
||||
"check_app_updates": False, # fork updates come from Gitea, not the upstream PyPI package
|
||||
"check_beta_updates": False,
|
||||
"last_update_check": 0,
|
||||
"concurrent_fragments": 1,
|
||||
@@ -109,6 +109,17 @@ class ConfigManager:
|
||||
# the app-managed, SHA256-verified binary is absent
|
||||
"allow_system_ytdlp": False,
|
||||
},
|
||||
"player": {
|
||||
"default_quality": 1080, # max stream height; None = auto/best
|
||||
"volume": 100,
|
||||
"resume": "auto", # auto | off
|
||||
"source_mode": "ytdl", # ytdl (mpv ytdl_hook) | direct (raw stream URLs)
|
||||
},
|
||||
"feed": {
|
||||
"mode": "local", # local (per-channel aggregation) | account (cookies)
|
||||
"per_channel_items": 15,
|
||||
"auto_refresh_minutes": 0, # 0 = manual refresh only
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -91,11 +91,11 @@ if OS_NAME == "Windows":
|
||||
|
||||
# Always use user data directory for app data, logs, config, and binaries
|
||||
# Even when frozen, we don't want to create these folders next to the executable
|
||||
APP_DIR: Path = Path(os.environ.get("LOCALAPPDATA", USER_HOME_DIR / "AppData" / "Local")) / "YTSage"
|
||||
APP_DIR: Path = Path(os.environ.get("LOCALAPPDATA", USER_HOME_DIR / "AppData" / "Local")) / "SageTube"
|
||||
APP_BIN_DIR: Path = APP_DIR / "bin"
|
||||
APP_DATA_DIR: Path = APP_DIR / "data"
|
||||
APP_LOG_DIR: Path = APP_DIR / "logs"
|
||||
APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json"
|
||||
APP_CONFIG_FILE: Path = APP_DATA_DIR / "sagetube_config.json"
|
||||
APP_HISTORY_FILE: Path = APP_DATA_DIR / "ytsage_history.json"
|
||||
APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails"
|
||||
|
||||
@@ -110,11 +110,11 @@ elif OS_NAME == "Darwin": # macOS
|
||||
|
||||
# Always use user data directory for app data, logs, config, and binaries
|
||||
# Even when frozen, we don't want to create these folders next to the executable
|
||||
APP_DIR: Path = USER_HOME_DIR / "Library" / "Application Support" / "YTSage"
|
||||
APP_DIR: Path = USER_HOME_DIR / "Library" / "Application Support" / "SageTube"
|
||||
APP_BIN_DIR: Path = APP_DIR / "bin"
|
||||
APP_DATA_DIR: Path = APP_DIR / "data"
|
||||
APP_LOG_DIR: Path = APP_DIR / "logs"
|
||||
APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json"
|
||||
APP_CONFIG_FILE: Path = APP_DATA_DIR / "sagetube_config.json"
|
||||
APP_HISTORY_FILE: Path = APP_DATA_DIR / "ytsage_history.json"
|
||||
APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails"
|
||||
|
||||
@@ -129,11 +129,11 @@ else: # Linux and other UNIX-like
|
||||
|
||||
# Always use user data directory for app data, logs, config, and binaries
|
||||
# Even when frozen, we don't want to create these folders next to the executable
|
||||
APP_DIR: Path = USER_HOME_DIR / ".local" / "share" / "YTSage"
|
||||
APP_DIR: Path = USER_HOME_DIR / ".local" / "share" / "SageTube"
|
||||
APP_BIN_DIR: Path = APP_DIR / "bin"
|
||||
APP_DATA_DIR: Path = APP_DIR / "data"
|
||||
APP_LOG_DIR: Path = APP_DIR / "logs"
|
||||
APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json"
|
||||
APP_CONFIG_FILE: Path = APP_DATA_DIR / "sagetube_config.json"
|
||||
APP_HISTORY_FILE: Path = APP_DATA_DIR / "ytsage_history.json"
|
||||
APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails"
|
||||
|
||||
@@ -238,3 +238,13 @@ else:
|
||||
YTDLP_APP_BIN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
if "DENO_APP_BIN_PATH" in globals():
|
||||
DENO_APP_BIN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Put the managed-binaries dir on PATH for this process and all children:
|
||||
# yt-dlp locates the Deno JS runtime (nsig/PO-token challenges) via PATH,
|
||||
# and nothing else ever exports APP_BIN_DIR.
|
||||
_bin_dirs = {str(APP_BIN_DIR)}
|
||||
if "DENO_APP_BIN_PATH" in globals():
|
||||
_bin_dirs.add(str(DENO_APP_BIN_PATH.parent))
|
||||
for _bin_dir in _bin_dirs:
|
||||
if _bin_dir not in os.environ.get("PATH", "").split(os.pathsep):
|
||||
os.environ["PATH"] = _bin_dir + os.pathsep + os.environ.get("PATH", "")
|
||||
|
||||
@@ -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
|
||||
]
|
||||
Reference in New Issue
Block a user