Compare commits
19 Commits
c54574224a
...
v5.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
b3772ff484
|
|||
| b4ff618d41 | |||
| 8a05d5ff8f | |||
| a8ebe89cc1 | |||
| 3afe96a8cd | |||
| 8e935c6116 | |||
| 16be4b4afa | |||
| f886125857 | |||
| 8dd8a3c1ad | |||
| 1c885e3fc5 | |||
| 4cc48ae98f | |||
| 288d30ad8b | |||
| ebb9422591 | |||
| 852804ee5c | |||
| 5064b38315 | |||
| dff14ec3e8 | |||
| b40deb0d5f | |||
| 202dd9eaee | |||
| a1d1f87b65 |
@@ -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()
|
||||
@@ -112,18 +112,25 @@ class DownloadThread(QThread):
|
||||
self.last_file_path: Optional[str] = None # Initialize full file path storage
|
||||
self.subtitle_files: List[str] = [] # Track subtitle files that are created
|
||||
self.initial_subtitle_files: Set[Path] = set() # Track initial subtitle files before download
|
||||
self.download_files: Set[Path] = set() # Every destination path this download wrote to
|
||||
self.expected_phases: int = 1 # 2 when video and audio download separately before merge
|
||||
self._media_phase: int = 0 # Index of the media stream currently downloading
|
||||
|
||||
def cleanup_partial_files(self) -> None:
|
||||
"""Delete any partial files including .part and unmerged format-specific files"""
|
||||
"""Delete partial files (.part/.ytdl and unmerged .fNNN. streams), but only
|
||||
those belonging to destinations this download actually wrote — the download
|
||||
directory may contain unrelated files from other applications."""
|
||||
try:
|
||||
pattern = re.compile(r"\.f\d+\.") # Pattern to match format codes like .f243.
|
||||
for file_path in self.path.iterdir():
|
||||
if file_path.suffix == ".part" or pattern.search(file_path.name):
|
||||
self._safe_delete_with_retry(file_path)
|
||||
for dest in self.download_files:
|
||||
candidates = [dest.with_name(dest.name + ".part"), dest.with_name(dest.name + ".ytdl")]
|
||||
if dest.suffix == ".part" or pattern.search(dest.name):
|
||||
candidates.append(dest)
|
||||
for file_path in candidates:
|
||||
if file_path.exists():
|
||||
self._safe_delete_with_retry(file_path)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error cleaning partial files: {e}")
|
||||
# Don't emit error signal for cleanup issues to avoid crashing the thread
|
||||
logger.error(f"Error cleaning partial files: {e}")
|
||||
|
||||
def _safe_delete_with_retry(self, file_path: Path, max_retries: int = 5, delay: float = 2.0) -> None:
|
||||
"""Safely delete a file with retry mechanism for file locking issues across platforms"""
|
||||
@@ -219,8 +226,15 @@ class DownloadThread(QThread):
|
||||
logger.debug(f"Deleted {deleted_count[0]} of {len(self.subtitle_files)} tracked subtitle files")
|
||||
|
||||
# --- Method 2: Delete new subtitle files not in initial set ---
|
||||
# Only touch subtitles belonging to files this download wrote; the
|
||||
# directory may contain subtitle files from other processes.
|
||||
download_stems = {p.stem for p in self.download_files} | {Path(f).stem for f in self.subtitle_files or []}
|
||||
new_subtitle_files: Set[Path] = {
|
||||
f for f in Path(self.path).rglob("*") if f.suffix in [".vtt", ".srt"] and f not in self.initial_subtitle_files
|
||||
f
|
||||
for f in Path(self.path).rglob("*")
|
||||
if f.suffix in [".vtt", ".srt"]
|
||||
and f not in self.initial_subtitle_files
|
||||
and any(f.name.startswith(stem) for stem in download_stems if stem)
|
||||
}
|
||||
for subtitle_file in new_subtitle_files:
|
||||
deleted_count[1] += safe_delete(path=subtitle_file)
|
||||
@@ -232,8 +246,12 @@ class DownloadThread(QThread):
|
||||
def _build_yt_dlp_command(self) -> List[str]:
|
||||
"""Build the yt-dlp command line with all options for direct execution."""
|
||||
yt_dlp_path: str = get_yt_dlp_path()
|
||||
if str(yt_dlp_path) == "yt-dlp":
|
||||
# Sentinel: no managed binary and no opted-in system binary.
|
||||
# Never exec a bare command name from PATH.
|
||||
raise FileNotFoundError("yt-dlp is not installed - run the yt-dlp setup first")
|
||||
# Build the command line array
|
||||
cmd: List[str] = [yt_dlp_path]
|
||||
cmd: List[str] = [str(yt_dlp_path)]
|
||||
logger.debug(f"Using yt-dlp from: {yt_dlp_path}")
|
||||
|
||||
# Add concurrent fragments setting
|
||||
@@ -277,6 +295,7 @@ class DownloadThread(QThread):
|
||||
logger.debug(f"Using progressive format with bundled audio: {clean_format_id}")
|
||||
else:
|
||||
cmd.extend(["-f", f"{clean_format_id}+bestaudio/best"])
|
||||
self.expected_phases = 2 # separate video and audio downloads
|
||||
logger.debug(f"Using video-only format merged with best audio: {clean_format_id}+bestaudio/best")
|
||||
else:
|
||||
# If no specific format ID, use resolution-based sorting (-S)
|
||||
@@ -619,6 +638,9 @@ class DownloadThread(QThread):
|
||||
filepath = dest_match.group(1).strip()
|
||||
self.current_filename = Path(filepath).name
|
||||
self.last_file_path = filepath # Store the full path for later cleanup
|
||||
if Path(filepath) not in self.download_files and Path(filepath).suffix.lower() not in SUBTITLE_EXTENSIONS:
|
||||
self._media_phase += 1
|
||||
self.download_files.add(Path(filepath))
|
||||
logger.debug(f"Extracted filename: {self.current_filename}") # DEBUG
|
||||
|
||||
# Check if this is an audio-only download by looking in the previous lines
|
||||
@@ -711,6 +733,7 @@ class DownloadThread(QThread):
|
||||
dest_path = match.group(1).strip()
|
||||
self.current_filename = Path(dest_path).name
|
||||
self.last_file_path = dest_path
|
||||
self.download_files.add(Path(dest_path))
|
||||
logger.debug(f"Captured destination filename: {self.current_filename}")
|
||||
elif "Downloading API JSON" in line:
|
||||
self.status_signal.emit(_("download.processing_playlist"))
|
||||
@@ -737,6 +760,11 @@ class DownloadThread(QThread):
|
||||
if percent_match:
|
||||
try:
|
||||
percent = float(percent_match.group(1))
|
||||
# When video and audio download as separate streams, scale each
|
||||
# phase into its share of the bar instead of jumping 0-100 twice
|
||||
if self.expected_phases > 1 and not self.is_playlist:
|
||||
completed = max(0, min(self._media_phase - 1, self.expected_phases - 1))
|
||||
percent = (completed * 100.0 + percent) / self.expected_phases
|
||||
self.progress_signal.emit(percent)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
@@ -771,6 +799,7 @@ class DownloadThread(QThread):
|
||||
merged_filepath = merger_match.group(1).strip()
|
||||
self.current_filename = Path(merged_filepath).name
|
||||
self.last_file_path = merged_filepath
|
||||
self.download_files.add(Path(merged_filepath))
|
||||
logger.debug(f"Updated to merged filename: {self.current_filename}")
|
||||
elif "SponsorBlock" in line:
|
||||
self.status_signal.emit(_("download.removing_sponsor_segments"))
|
||||
@@ -824,9 +853,24 @@ class DownloadThread(QThread):
|
||||
|
||||
def pause(self) -> None:
|
||||
self.paused = True
|
||||
self._signal_process_group(signal.SIGSTOP if sys.platform != "win32" else None)
|
||||
|
||||
def resume(self) -> None:
|
||||
self.paused = False
|
||||
self._signal_process_group(signal.SIGCONT if sys.platform != "win32" else None)
|
||||
|
||||
def _signal_process_group(self, sig: Optional[int]) -> None:
|
||||
"""Send a signal to yt-dlp's whole process group (yt-dlp + ffmpeg children).
|
||||
|
||||
On Windows there is no SIGSTOP/SIGCONT; pausing there only stops output
|
||||
consumption, which is a known limitation.
|
||||
"""
|
||||
if sig is None or not self.process:
|
||||
return
|
||||
try:
|
||||
os.killpg(os.getpgid(self.process.pid), sig)
|
||||
except (ProcessLookupError, PermissionError, OSError) as e:
|
||||
logger.debug(f"Could not signal process group: {e}")
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancelled = True
|
||||
|
||||
@@ -91,7 +91,7 @@ def get_ffmpeg_install_path() -> Path:
|
||||
For Windows, tries to find the latest essentials build dynamically.
|
||||
"""
|
||||
if OS_NAME == "Windows":
|
||||
ffmpeg_base = Path(os.getenv("LOCALAPPDATA")) / "ffmpeg" # type: ignore
|
||||
ffmpeg_base = Path(os.getenv("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))) / "ffmpeg"
|
||||
|
||||
# If the directory exists, look for any ffmpeg-*-essentials_build folder
|
||||
if ffmpeg_base.exists():
|
||||
@@ -205,7 +205,7 @@ def install_ffmpeg_windows(progress_callback=None) -> bool:
|
||||
|
||||
try:
|
||||
# Define variables for essentials build
|
||||
extract_dir = Path(os.getenv("LOCALAPPDATA")) / "ffmpeg" # type: ignore
|
||||
extract_dir = Path(os.getenv("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))) / "ffmpeg"
|
||||
|
||||
# Create extraction directory if it doesn't exist
|
||||
extract_dir.mkdir(exist_ok=True)
|
||||
@@ -282,9 +282,14 @@ def install_ffmpeg_windows(progress_callback=None) -> bool:
|
||||
if progress_callback:
|
||||
progress_callback("🔐 Verifying download integrity...")
|
||||
if not verify_sha256(temp_file, FFMPEG_ZIP_SHA256_URL):
|
||||
logger.warning("SHA-256 verification failed for zip file, proceeding anyway...")
|
||||
logger.error("SHA-256 verification failed for zip file, aborting installation")
|
||||
if progress_callback:
|
||||
progress_callback("⚠️ SHA-256 verification failed, proceeding anyway...")
|
||||
progress_callback("❌ SHA-256 verification failed, aborting installation")
|
||||
try:
|
||||
Path(temp_file).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
logger.info("Extracting FFmpeg components from zip archive...")
|
||||
if progress_callback:
|
||||
@@ -337,29 +342,35 @@ def install_ffmpeg_windows(progress_callback=None) -> bool:
|
||||
if progress_callback:
|
||||
progress_callback("🔧 Configuring system paths...")
|
||||
|
||||
# Add to System Path
|
||||
user_path = os.environ.get("PATH", "")
|
||||
path_parts = user_path.split(os.pathsep)
|
||||
|
||||
# Remove old FFmpeg paths and add new one
|
||||
cleaned_paths = [p for p in path_parts if "ffmpeg" not in p.lower() or str(bin_dir) in p]
|
||||
if str(bin_dir) not in cleaned_paths:
|
||||
cleaned_paths.insert(0, str(bin_dir))
|
||||
|
||||
new_path = os.pathsep.join(cleaned_paths)
|
||||
|
||||
# Persist to the user PATH via the registry. setx must not be used here:
|
||||
# it truncates values at 1024 characters, and os.environ["PATH"] is the
|
||||
# merged system+user PATH, so writing it back would permanently duplicate
|
||||
# every system entry into the user hive.
|
||||
try:
|
||||
subprocess.run(
|
||||
["setx", "PATH", new_path],
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||
timeout=30,
|
||||
check=True,
|
||||
)
|
||||
os.environ["PATH"] = new_path
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_READ | winreg.KEY_WRITE) as key:
|
||||
try:
|
||||
stored_path, value_type = winreg.QueryValueEx(key, "Path")
|
||||
except FileNotFoundError:
|
||||
stored_path, value_type = "", winreg.REG_EXPAND_SZ
|
||||
user_parts = [p for p in stored_path.split(os.pathsep) if p]
|
||||
# Drop stale ffmpeg entries we previously added, then prepend the new one
|
||||
user_parts = [p for p in user_parts if "ffmpeg" not in p.lower() or p == str(bin_dir)]
|
||||
if str(bin_dir) not in user_parts:
|
||||
user_parts.insert(0, str(bin_dir))
|
||||
winreg.SetValueEx(key, "Path", 0, value_type, os.pathsep.join(user_parts))
|
||||
|
||||
# Broadcast the change so new shells pick it up without relogin
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.user32.SendMessageTimeoutW(0xFFFF, 0x001A, 0, "Environment", 0x0002, 5000, None)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update PATH permanently: {e}")
|
||||
# Still update for current session
|
||||
os.environ["PATH"] = new_path
|
||||
|
||||
# Update for the current session regardless
|
||||
if str(bin_dir) not in os.environ.get("PATH", "").split(os.pathsep):
|
||||
os.environ["PATH"] = str(bin_dir) + os.pathsep + os.environ.get("PATH", "")
|
||||
|
||||
# Verify installation
|
||||
if progress_callback:
|
||||
@@ -395,9 +406,14 @@ def install_ffmpeg_macos() -> bool:
|
||||
timeout=5,
|
||||
)
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
logger.info("Installing Homebrew...")
|
||||
brew_install_cmd = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
||||
subprocess.run(brew_install_cmd, shell=True, check=True, timeout=300)
|
||||
# Never curl|bash a remote script without the user's say-so.
|
||||
# Homebrew installation is the user's decision to make in a
|
||||
# terminal, where the script can also prompt for sudo properly.
|
||||
logger.error(
|
||||
"Homebrew is not installed. Install it from https://brew.sh and retry, "
|
||||
"or install ffmpeg another way."
|
||||
)
|
||||
return False
|
||||
|
||||
# Install FFmpeg
|
||||
logger.info("Installing FFmpeg...")
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
libmpv availability probe
|
||||
=========================
|
||||
|
||||
python-mpv is a ctypes binding: importing it raises OSError when the libmpv
|
||||
shared library is missing from the system. All player code must import mpv
|
||||
through probe_player() so the rest of the app (search, browse, downloads)
|
||||
keeps working without libmpv installed.
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from ..utils.ytsage_constants import OS_NAME
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
_probe_result: Optional[Tuple[bool, str]] = None
|
||||
|
||||
|
||||
def probe_player() -> Tuple[bool, str]:
|
||||
"""Return (available, hint). hint explains how to install libmpv when absent."""
|
||||
global _probe_result
|
||||
if _probe_result is not None:
|
||||
return _probe_result
|
||||
|
||||
try:
|
||||
import mpv # noqa: F401
|
||||
|
||||
_probe_result = (True, "")
|
||||
except (ImportError, OSError, AttributeError) as e:
|
||||
logger.warning(f"libmpv unavailable, watch features disabled: {e}")
|
||||
if OS_NAME == "Windows":
|
||||
hint = "Place libmpv-2.dll next to the application, or install mpv and add it to PATH."
|
||||
elif OS_NAME == "Darwin":
|
||||
hint = "Install mpv with Homebrew: brew install mpv"
|
||||
else:
|
||||
hint = "Install mpv with your package manager, e.g. sudo pacman -S mpv or sudo apt install libmpv2"
|
||||
_probe_result = (False, hint)
|
||||
return _probe_result
|
||||
+22
-11
@@ -72,8 +72,11 @@ def should_refresh_cache(tool_name: str, current_path: Optional[str]) -> bool:
|
||||
if not cache.get("version"):
|
||||
return True
|
||||
|
||||
# Refresh if path changed
|
||||
if cache.get("path") != current_path:
|
||||
# Refresh if path changed (cache stores str; callers may pass Path -
|
||||
# compare normalized strings or the cache would never hit)
|
||||
cached_path = cache.get("path")
|
||||
normalized_current = str(current_path) if current_path is not None else None
|
||||
if cached_path != normalized_current:
|
||||
return True
|
||||
|
||||
# Refresh if file was modified
|
||||
@@ -432,8 +435,9 @@ def save_path(main_window_instance: Any, path: Union[str, Path]) -> bool:
|
||||
def update_yt_dlp() -> bool:
|
||||
"""Check for yt-dlp updates and update if a newer version is available."""
|
||||
try:
|
||||
# Get the yt-dlp path
|
||||
yt_dlp_path: Path = get_yt_dlp_path()
|
||||
# Get the yt-dlp path (may be the bare "yt-dlp" sentinel string when
|
||||
# not installed - normalize to Path so .exists()/.samefile() work)
|
||||
yt_dlp_path: Path = Path(get_yt_dlp_path())
|
||||
|
||||
# Extra logic moved to src\utils\ytsage_constants.py
|
||||
|
||||
@@ -463,7 +467,7 @@ def update_yt_dlp() -> bool:
|
||||
|
||||
# Download the latest version
|
||||
try:
|
||||
response = requests.get(YTDLP_DOWNLOAD_URL, stream=True)
|
||||
response = requests.get(YTDLP_DOWNLOAD_URL, stream=True, timeout=60)
|
||||
if response.status_code == 200:
|
||||
# Create a temporary file
|
||||
temp_file = f"{yt_dlp_path}.new"
|
||||
@@ -472,21 +476,28 @@ def update_yt_dlp() -> bool:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
# Verify against the official checksums before touching the
|
||||
# trusted binary (same check as the first-install path)
|
||||
from ytsage.core.ytsage_yt_dlp import verify_ytdlp_sha256
|
||||
|
||||
if not verify_ytdlp_sha256(Path(temp_file), YTDLP_DOWNLOAD_URL):
|
||||
logger.error("SHA256 verification failed for yt-dlp update, keeping current binary")
|
||||
Path(temp_file).unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
# Make executable on Unix systems
|
||||
if OS_NAME != "Windows":
|
||||
os.chmod(temp_file, 0o755)
|
||||
|
||||
# Replace the old file with the new one
|
||||
# Replace the old file with the new one (os.replace is atomic
|
||||
# and overwrites on Windows too)
|
||||
try:
|
||||
# On Windows, we need to remove the old file first
|
||||
if OS_NAME == "Windows" and yt_dlp_path.exists():
|
||||
yt_dlp_path.unlink(missing_ok=True)
|
||||
|
||||
Path(temp_file).rename(yt_dlp_path)
|
||||
os.replace(temp_file, yt_dlp_path)
|
||||
logger.info("yt-dlp binary successfully updated")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception(f"Error replacing yt-dlp binary: {e}")
|
||||
Path(temp_file).unlink(missing_ok=True)
|
||||
return False
|
||||
else:
|
||||
logger.info(f"Failed to download latest yt-dlp: HTTP {response.status_code}")
|
||||
|
||||
@@ -112,6 +112,10 @@ class DownloadYtdlpThread(QThread):
|
||||
try:
|
||||
# Extra logic moved to src\utils\ytsage_constants.py
|
||||
exe_path = YTDLP_APP_BIN_PATH
|
||||
# Download to a temp name and only move to the trusted path after
|
||||
# the hash checks out, so a crash mid-download can never leave an
|
||||
# unverified binary where the app will execute it.
|
||||
part_path = exe_path.with_name(exe_path.name + ".part")
|
||||
|
||||
# Download with progress reporting
|
||||
logger.info(f"Downloading yt-dlp from: {YTDLP_DOWNLOAD_URL}")
|
||||
@@ -123,7 +127,7 @@ class DownloadYtdlpThread(QThread):
|
||||
if total_size == 0:
|
||||
self.progress_signal.emit(100)
|
||||
|
||||
with open(exe_path, "wb") as f:
|
||||
with open(part_path, "wb") as f:
|
||||
downloaded = 0
|
||||
for data in response.iter_content(block_size):
|
||||
f.write(data)
|
||||
@@ -133,22 +137,23 @@ class DownloadYtdlpThread(QThread):
|
||||
self.progress_signal.emit(progress)
|
||||
|
||||
logger.info("Download complete, verifying SHA256 hash...")
|
||||
|
||||
|
||||
# Verify SHA256 hash
|
||||
if not verify_ytdlp_sha256(exe_path, YTDLP_DOWNLOAD_URL):
|
||||
if not verify_ytdlp_sha256(part_path, YTDLP_DOWNLOAD_URL):
|
||||
# Hash verification failed - delete the downloaded file
|
||||
logger.error("SHA256 verification failed! Removing downloaded file.")
|
||||
if Path(exe_path).exists():
|
||||
Path(exe_path).unlink()
|
||||
part_path.unlink(missing_ok=True)
|
||||
self.finished_signal.emit(
|
||||
False,
|
||||
False,
|
||||
"SHA256 verification failed. The downloaded file may be corrupted or tampered with."
|
||||
)
|
||||
return
|
||||
|
||||
# Make executable on macOS and Linux
|
||||
if OS_NAME != "Windows":
|
||||
os.chmod(exe_path, 0o755)
|
||||
os.chmod(part_path, 0o755)
|
||||
|
||||
os.replace(part_path, exe_path)
|
||||
|
||||
logger.info("yt-dlp downloaded and verified successfully!")
|
||||
self.finished_signal.emit(True, str(exe_path))
|
||||
@@ -429,7 +434,7 @@ class YtdlpSetupDialog(QDialog):
|
||||
"""
|
||||
)
|
||||
|
||||
file_path, _ = file_dialog.getOpenFileName(
|
||||
file_path, _selected_filter = file_dialog.getOpenFileName(
|
||||
self, _("ytdlp_setup.select_executable_title"), "", file_filter
|
||||
)
|
||||
|
||||
@@ -607,19 +612,30 @@ def check_ytdlp_installed() -> bool:
|
||||
|
||||
def get_yt_dlp_path() -> Path:
|
||||
"""
|
||||
Get the yt-dlp path, either from the app's bin directory or system PATH.
|
||||
This replaces the function in ytsage_utils.py.
|
||||
Get the yt-dlp path. Prefers the app-managed, SHA256-verified binary;
|
||||
a system-installed yt-dlp is only used when the user explicitly opts in
|
||||
via the advanced.allow_system_ytdlp config key (resolved to an absolute
|
||||
path so Windows' implicit CWD lookup can never pick up a planted binary).
|
||||
Returns:
|
||||
str: Path to yt-dlp binary
|
||||
Path to yt-dlp binary, or the bare string "yt-dlp" sentinel meaning
|
||||
"not installed" (triggers the setup dialog).
|
||||
"""
|
||||
# First check if we have yt-dlp in our app's bin directory or system PATH
|
||||
ytdlp_path = check_ytdlp_binary()
|
||||
if ytdlp_path:
|
||||
logger.info(f"Using yt-dlp from: {ytdlp_path}")
|
||||
return ytdlp_path
|
||||
|
||||
# If not found anywhere, fall back to the command name as a last resort
|
||||
logger.info("yt-dlp not found in app directory or PATH, falling back to command name")
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
|
||||
if ConfigManager.get("advanced.allow_system_ytdlp"):
|
||||
system_ytdlp = shutil.which("yt-dlp")
|
||||
if system_ytdlp:
|
||||
resolved = Path(system_ytdlp).resolve()
|
||||
logger.info(f"Using system yt-dlp (advanced.allow_system_ytdlp): {resolved}")
|
||||
return resolved
|
||||
|
||||
# Not installed - return the sentinel that triggers the setup dialog
|
||||
logger.info("yt-dlp not found in app directory, returning setup sentinel")
|
||||
return "yt-dlp" # type: ignore[return-value]
|
||||
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import subprocess
|
||||
from PySide6.QtCore import QMetaObject, Qt, Q_ARG, QThread, Signal, QTimer
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from ..core.ytsage_client import run_ytdlp_capture
|
||||
from ..core.ytsage_utils import validate_video_url, parse_yt_dlp_error
|
||||
from ..core.ytsage_yt_dlp import get_yt_dlp_path
|
||||
from ..utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
@@ -95,13 +95,20 @@ class AnalysisThread(QThread):
|
||||
if self.geo_proxy_url:
|
||||
cmd.extend(["--geo-verification-proxy", self.geo_proxy_url])
|
||||
|
||||
@staticmethod
|
||||
def _run_ytdlp(cmd: list, timeout: int) -> subprocess.CompletedProcess:
|
||||
"""Run yt-dlp via the shared client runner (process-group-safe)."""
|
||||
return run_ytdlp_capture(cmd, timeout=timeout)
|
||||
|
||||
def _analyze_url_with_subprocess(self, url: str) -> None:
|
||||
"""Analyze URL using yt-dlp executable."""
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
yt_dlp_path = get_yt_dlp_path()
|
||||
if not yt_dlp_path:
|
||||
if not yt_dlp_path or str(yt_dlp_path) == "yt-dlp":
|
||||
# Sentinel: no managed binary and no opted-in system binary.
|
||||
# Never exec a bare command name from PATH.
|
||||
logger.error("yt-dlp executable not found. Please install yt-dlp first.")
|
||||
self.analysis_error.emit(_("errors.ytdlp_not_found"))
|
||||
self.playlist_info_visible.emit(False)
|
||||
@@ -118,12 +125,10 @@ class AnalysisThread(QThread):
|
||||
logger.debug(f"Executing yt-dlp command: {cmd}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=300,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Analysis timed out")
|
||||
result = self._run_ytdlp(cmd, timeout=300)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
stderr_tail = (e.stderr or "")[-500:] if isinstance(e.stderr, str) else ""
|
||||
logger.error(f"Analysis timed out. Partial stderr: {stderr_tail}")
|
||||
self.analysis_error.emit(_("errors.timeout"))
|
||||
return
|
||||
|
||||
@@ -181,7 +186,8 @@ class AnalysisThread(QThread):
|
||||
if first_info.get("_type") == "playlist":
|
||||
result_data["is_playlist"] = True
|
||||
result_data["playlist_info"] = first_info
|
||||
playlist_entries = first_info.get("entries", [])
|
||||
# Private/deleted videos can appear as None entries in flat playlists
|
||||
playlist_entries = [e for e in first_info.get("entries", []) if e]
|
||||
result_data["playlist_entries"] = playlist_entries
|
||||
|
||||
if not playlist_entries:
|
||||
@@ -201,15 +207,12 @@ class AnalysisThread(QThread):
|
||||
self._add_auth_options(cmd_single)
|
||||
|
||||
try:
|
||||
result_single = subprocess.run(
|
||||
cmd_single, capture_output=True, text=True, timeout=60,
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS
|
||||
)
|
||||
result_single = self._run_ytdlp(cmd_single, timeout=60)
|
||||
if result_single.returncode == 0:
|
||||
result_data["video_info"] = json.loads(result_single.stdout)
|
||||
else:
|
||||
result_data["video_info"] = first_video_entry
|
||||
except subprocess.TimeoutExpired:
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError):
|
||||
result_data["video_info"] = first_video_entry
|
||||
|
||||
if self._cancelled:
|
||||
|
||||
@@ -0,0 +1,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()
|
||||
|
||||
@@ -3,6 +3,8 @@ Custom functionality dialogs for YTSage application.
|
||||
Contains dialogs for custom commands, cookies, time ranges, and other special features.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
@@ -32,7 +34,7 @@ from PySide6.QtWidgets import (
|
||||
from ..ytsage_smooth_tab_widget import SmoothTabWidget
|
||||
from ...core.ytsage_yt_dlp import get_yt_dlp_path
|
||||
from ...core.ytsage_utils import update_auto_update_settings
|
||||
from ...utils.ytsage_constants import YTDLP_DOCS_URL
|
||||
from ...utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS, YTDLP_DOCS_URL
|
||||
from ...utils.ytsage_config_manager import ConfigManager
|
||||
from ...utils.ytsage_localization import LocalizationManager, _
|
||||
from ...utils.ytsage_logger import logger
|
||||
@@ -55,16 +57,28 @@ class CommandWorker(QObject):
|
||||
self.command = command
|
||||
self.url = url
|
||||
self.path = path
|
||||
self._proc = None
|
||||
self._cancelled = False
|
||||
|
||||
def cancel(self):
|
||||
"""Terminate a running command."""
|
||||
self._cancelled = True
|
||||
if self._proc and self._proc.poll() is None:
|
||||
try:
|
||||
self._proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def run_command(self):
|
||||
"""Run the yt-dlp command and emit signals for output"""
|
||||
try:
|
||||
# Split command into arguments
|
||||
args = self.command.split()
|
||||
# Split command into arguments; posix=False on Windows so quoted
|
||||
# backslash paths like "C:\Users\..." survive intact
|
||||
args = shlex.split(self.command, posix=(os.name != "nt"))
|
||||
|
||||
# Build the full command
|
||||
yt_dlp_path = get_yt_dlp_path()
|
||||
base_cmd = [yt_dlp_path] + args
|
||||
base_cmd = [str(yt_dlp_path)] + args
|
||||
|
||||
# Add download path if specified
|
||||
if self.path:
|
||||
@@ -78,19 +92,24 @@ class CommandWorker(QObject):
|
||||
self.output_received.emit("=" * 50)
|
||||
|
||||
# Run the command
|
||||
proc = subprocess.Popen(
|
||||
self._proc = subprocess.Popen(
|
||||
base_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
creationflags=SUBPROCESS_CREATIONFLAGS,
|
||||
)
|
||||
proc = self._proc
|
||||
|
||||
# Stream output
|
||||
for line in proc.stdout: # type: ignore[reportOptionalIterable]
|
||||
if line.strip(): # Only show non-empty lines
|
||||
self.output_received.emit(line.rstrip())
|
||||
with proc.stdout: # type: ignore[union-attr]
|
||||
for line in proc.stdout: # type: ignore[reportOptionalIterable]
|
||||
if self._cancelled:
|
||||
break
|
||||
if line.strip(): # Only show non-empty lines
|
||||
self.output_received.emit(line.rstrip())
|
||||
|
||||
ret = proc.wait()
|
||||
self.output_received.emit("=" * 50)
|
||||
|
||||
@@ -985,5 +985,5 @@ class AutoUpdateSettingsDialog(QDialog):
|
||||
msg_box.exec()
|
||||
except Exception as e:
|
||||
logger.exception(f"Error saving auto-update settings: {e}")
|
||||
msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, _("settings.error_title"), _("settings", "error_saving", error=str(e)))
|
||||
msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, _("settings.error_title"), _("settings.error_saving", error=str(e)))
|
||||
msg_box.exec()
|
||||
|
||||
@@ -200,18 +200,23 @@ class DenoUpdateThread(QThread):
|
||||
|
||||
class UpdaterTabWidget(QWidget):
|
||||
"""Widget for the Updater tab in Custom Options dialog."""
|
||||
|
||||
|
||||
# Emitted from the channel-switch worker thread; queued back to the GUI
|
||||
# thread so widget updates never happen off-thread.
|
||||
_channel_switch_finished = Signal(bool, str, str, str) # success, new_channel, current_channel, error
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._parent: "CustomOptionsDialog" = cast("CustomOptionsDialog", self.parent())
|
||||
|
||||
|
||||
# State variables
|
||||
self.current_version = "Unknown"
|
||||
self.latest_version = "Unknown"
|
||||
self.update_available = False
|
||||
|
||||
|
||||
self._init_ui()
|
||||
self._load_auto_update_settings()
|
||||
self._channel_switch_finished.connect(self._on_channel_switch_finished)
|
||||
|
||||
def _init_ui(self) -> None:
|
||||
"""Initialize the UI components."""
|
||||
@@ -815,69 +820,55 @@ class UpdaterTabWidget(QWidget):
|
||||
# Success - save the preference
|
||||
ConfigManager.set("ytdlp_channel", new_channel)
|
||||
logger.info(f"Successfully switched to {new_channel} channel")
|
||||
|
||||
# Update UI
|
||||
self.channel_status_label.setText(_("settings.ytdlp_channel_switched", channel=new_channel))
|
||||
self.channel_status_label.setStyleSheet(
|
||||
"color: #00cc00; font-size: 11px; padding: 5px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
|
||||
|
||||
# Make executable on Unix systems
|
||||
if OS_NAME != "Windows":
|
||||
import os
|
||||
os.chmod(yt_dlp_path, 0o755)
|
||||
|
||||
self._channel_switch_finished.emit(True, new_channel, current_channel, "")
|
||||
else:
|
||||
# Failed - revert radio button
|
||||
error_msg = result.stderr.strip() if result.stderr else result.stdout.strip() if result.stdout else "Unknown error"
|
||||
logger.error(f"Failed to switch channel: {error_msg}")
|
||||
|
||||
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error=error_msg))
|
||||
self.channel_status_label.setStyleSheet(
|
||||
"color: #ff6666; font-size: 11px; padding: 5px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
|
||||
# Revert radio selection
|
||||
if current_channel == "nightly":
|
||||
self.channel_nightly_radio.setChecked(True)
|
||||
else:
|
||||
self.channel_stable_radio.setChecked(True)
|
||||
|
||||
self._channel_switch_finished.emit(False, new_channel, current_channel, error_msg)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Channel switch timed out")
|
||||
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error="Timeout"))
|
||||
self.channel_status_label.setStyleSheet(
|
||||
"color: #ff6666; font-size: 11px; padding: 5px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
# Revert radio selection
|
||||
if current_channel == "nightly":
|
||||
self.channel_nightly_radio.setChecked(True)
|
||||
else:
|
||||
self.channel_stable_radio.setChecked(True)
|
||||
|
||||
self._channel_switch_finished.emit(False, new_channel, current_channel, "Timeout")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error switching channel: {e}")
|
||||
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error=str(e)))
|
||||
self.channel_status_label.setStyleSheet(
|
||||
"color: #ff6666; font-size: 11px; padding: 5px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
# Revert radio selection
|
||||
if current_channel == "nightly":
|
||||
self.channel_nightly_radio.setChecked(True)
|
||||
else:
|
||||
self.channel_stable_radio.setChecked(True)
|
||||
finally:
|
||||
# Re-enable radio buttons
|
||||
self.channel_stable_radio.setEnabled(True)
|
||||
self.channel_nightly_radio.setEnabled(True)
|
||||
|
||||
self._channel_switch_finished.emit(False, new_channel, current_channel, str(e))
|
||||
|
||||
# Start the thread
|
||||
thread = threading.Thread(target=switch_channel, daemon=True)
|
||||
thread.start()
|
||||
|
||||
|
||||
@Slot(bool, str, str, str)
|
||||
def _on_channel_switch_finished(self, success: bool, new_channel: str, current_channel: str, error_msg: str) -> None:
|
||||
"""Apply the channel-switch outcome to the UI (always on the GUI thread)."""
|
||||
if success:
|
||||
self.channel_status_label.setText(_("settings.ytdlp_channel_switched", channel=new_channel))
|
||||
self.channel_status_label.setStyleSheet(
|
||||
"color: #00cc00; font-size: 11px; padding: 5px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
else:
|
||||
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error=error_msg))
|
||||
self.channel_status_label.setStyleSheet(
|
||||
"color: #ff6666; font-size: 11px; padding: 5px; "
|
||||
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
|
||||
)
|
||||
# Revert radio selection
|
||||
if current_channel == "nightly":
|
||||
self.channel_nightly_radio.setChecked(True)
|
||||
else:
|
||||
self.channel_stable_radio.setChecked(True)
|
||||
|
||||
# Re-enable radio buttons
|
||||
self.channel_stable_radio.setEnabled(True)
|
||||
self.channel_nightly_radio.setEnabled(True)
|
||||
|
||||
|
||||
def _update_channel_status(self, channel: str) -> None:
|
||||
"""Update the channel status label."""
|
||||
self.channel_status_label.setText(_("settings.ytdlp_current_channel", channel=channel))
|
||||
|
||||
@@ -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()
|
||||
@@ -346,7 +346,10 @@ class FormatTableMixin:
|
||||
checkbox.setStyleSheet("QCheckBox { margin-left: 8px; }")
|
||||
checkbox.format_id = f["format_id"]
|
||||
checkbox.is_audio_only = f.get("vcodec") == "none"
|
||||
checkbox.has_audio = f.get("acodec") != "none"
|
||||
# A missing acodec means unknown, not progressive - treating it as
|
||||
# has-audio skips the +bestaudio merge and yields silent videos
|
||||
acodec = f.get("acodec")
|
||||
checkbox.has_audio = acodec is not None and acodec != "none"
|
||||
checkbox.clicked.connect(lambda checked, cb=checkbox: self.handle_checkbox_click(cb))
|
||||
self.format_checkboxes.append(checkbox)
|
||||
|
||||
|
||||
@@ -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()))
|
||||
@@ -1176,18 +1234,29 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
|
||||
# Clean up the thread reference and ensure it's properly finished
|
||||
if hasattr(self, "auto_update_thread"):
|
||||
thread = self.auto_update_thread
|
||||
# Disconnect all signals to prevent further callbacks
|
||||
self.auto_update_thread.update_finished.disconnect()
|
||||
# Make sure thread is finished
|
||||
if self.auto_update_thread.isRunning():
|
||||
self.auto_update_thread.quit()
|
||||
self.auto_update_thread.wait(1000) # Wait up to 1 second
|
||||
thread.update_finished.disconnect()
|
||||
# Dropping the Python reference while run() is still unwinding
|
||||
# can destroy a live QThread; let Qt delete it once finished.
|
||||
if thread.isRunning():
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
thread.quit()
|
||||
else:
|
||||
thread.deleteLater()
|
||||
# Remove the reference
|
||||
delattr(self, "auto_update_thread")
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
"""Handle application close event to ensure proper cleanup of background threads."""
|
||||
try:
|
||||
# Persist watch position/queue and release libmpv
|
||||
if hasattr(self, "watch_page"):
|
||||
try:
|
||||
self.watch_page.shutdown()
|
||||
except Exception as e:
|
||||
logger.debug(f"Watch page shutdown error: {e}")
|
||||
|
||||
# Stop 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"
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,12 @@ from .gui.ytsage_gui_main import YTSageApp # Import the main application class
|
||||
|
||||
|
||||
def show_error_dialog(message):
|
||||
# A QMessageBox needs a live QApplication; if startup failed before (or
|
||||
# while) creating one, constructing the dialog would abort the process
|
||||
# and swallow the real error.
|
||||
if QApplication.instance() is None:
|
||||
print(f"Application Error: {message}", file=sys.stderr)
|
||||
return
|
||||
error_dialog = QMessageBox()
|
||||
error_dialog.setIcon(QMessageBox.Icon.Critical)
|
||||
error_dialog.setText("Application Error")
|
||||
@@ -19,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()
|
||||
|
||||
@@ -49,7 +49,9 @@ Exceptions
|
||||
when possible.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
@@ -84,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,
|
||||
@@ -102,6 +104,22 @@ class ConfigManager:
|
||||
"ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
||||
"ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
||||
},
|
||||
"advanced": {
|
||||
# Allow falling back to a system-installed yt-dlp from PATH when
|
||||
# the app-managed, SHA256-verified binary is absent
|
||||
"allow_system_ytdlp": False,
|
||||
},
|
||||
"player": {
|
||||
"default_quality": 1080, # max stream height; None = auto/best
|
||||
"volume": 100,
|
||||
"resume": "auto", # auto | off
|
||||
"source_mode": "ytdl", # ytdl (mpv ytdl_hook) | direct (raw stream URLs)
|
||||
},
|
||||
"feed": {
|
||||
"mode": "local", # local (per-channel aggregation) | account (cookies)
|
||||
"per_channel_items": 15,
|
||||
"auto_refresh_minutes": 0, # 0 = manual refresh only
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -115,13 +133,17 @@ class ConfigManager:
|
||||
if cls._config_file.exists():
|
||||
try:
|
||||
with open(cls._config_file, "r", encoding="utf-8") as f:
|
||||
cls._settings = json.load(f)
|
||||
stored = json.load(f)
|
||||
# Merge on top of defaults so keys added in newer versions
|
||||
# exist without call sites needing `or <default>` fallbacks
|
||||
cls._settings = copy.deepcopy(cls._default_config)
|
||||
cls._settings.update(stored)
|
||||
logger.info("Config loaded from file.")
|
||||
except json.JSONDecodeError:
|
||||
cls._settings = cls._default_config.copy()
|
||||
cls._settings = copy.deepcopy(cls._default_config)
|
||||
logger.warning("Config file corrupt, loaded defaults.")
|
||||
else:
|
||||
cls._settings = cls._default_config.copy()
|
||||
cls._settings = copy.deepcopy(cls._default_config)
|
||||
cls._save()
|
||||
logger.info("Config file not found, created default config.")
|
||||
|
||||
@@ -136,8 +158,13 @@ class ConfigManager:
|
||||
"""
|
||||
with cls._lock:
|
||||
try:
|
||||
with open(cls._config_file, "w", encoding="utf-8") as f:
|
||||
# Atomic write: a crash mid-save must not truncate the config
|
||||
tmp_file = cls._config_file.with_suffix(".json.tmp")
|
||||
with open(tmp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(cls._settings, f, indent=4)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_file, cls._config_file)
|
||||
logger.debug("Config saved to file.")
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.exception(f"Failed to save config: {e}")
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -72,6 +72,10 @@ class HistoryManager:
|
||||
if cls._connection is None:
|
||||
cls._connection = sqlite3.connect(cls._db_file, check_same_thread=False)
|
||||
cls._connection.row_factory = sqlite3.Row
|
||||
# WAL lets the download thread write while the history
|
||||
# dialog reads; busy_timeout avoids "database is locked"
|
||||
cls._connection.execute("PRAGMA journal_mode=WAL")
|
||||
cls._connection.execute("PRAGMA busy_timeout=5000")
|
||||
|
||||
cursor = cls._connection.cursor()
|
||||
|
||||
|
||||
@@ -0,0 +1,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