fifu-tui 1.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.SRCINFO +18 -0
- package/.github/workflows/publish.yml +45 -0
- package/.github/workflows/release.yml +134 -0
- package/PKGBUILD +22 -0
- package/README.md +104 -0
- package/bin/fifu.js +29 -0
- package/fifu/__init__.py +3 -0
- package/fifu/__main__.py +16 -0
- package/fifu/app.py +260 -0
- package/fifu/screens/__init__.py +8 -0
- package/fifu/screens/channels.py +190 -0
- package/fifu/screens/download.py +217 -0
- package/fifu/screens/options.py +222 -0
- package/fifu/screens/search.py +231 -0
- package/fifu/services/__init__.py +7 -0
- package/fifu/services/config.py +87 -0
- package/fifu/services/downloader.py +190 -0
- package/fifu/services/youtube.py +260 -0
- package/fifu/styles/__init__.py +1 -0
- package/fifu/styles/app.tcss +173 -0
- package/package.json +29 -0
- package/pyproject.toml +36 -0
package/.SRCINFO
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
pkgbase = fifu
|
|
2
|
+
pkgdesc = A cross-platform TUI for downloading YouTube videos from channels
|
|
3
|
+
pkgver = 1.4.5
|
|
4
|
+
pkgrel = 1
|
|
5
|
+
url = https://github.com/Dawaman43/fifu
|
|
6
|
+
arch = any
|
|
7
|
+
license = MIT
|
|
8
|
+
makedepends = python-build
|
|
9
|
+
makedepends = python-installer
|
|
10
|
+
makedepends = python-hatchling
|
|
11
|
+
depends = python
|
|
12
|
+
depends = python-textual
|
|
13
|
+
depends = yt-dlp
|
|
14
|
+
depends = python-click
|
|
15
|
+
source = https://github.com/Dawaman43/fifu/archive/refs/tags/v1.4.5.tar.gz
|
|
16
|
+
sha256sums = SKIP
|
|
17
|
+
|
|
18
|
+
pkgname = fifu
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
name: Publish to PyPI and npm
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
pypi-publish:
|
|
10
|
+
name: Publish to PyPI
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- name: Set up Python
|
|
15
|
+
uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.11"
|
|
18
|
+
- name: Install dependencies
|
|
19
|
+
run: |
|
|
20
|
+
python -m pip install --upgrade pip
|
|
21
|
+
pip install build twine
|
|
22
|
+
- name: Build and publish
|
|
23
|
+
env:
|
|
24
|
+
TWINE_USERNAME: __token__
|
|
25
|
+
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
|
26
|
+
run: |
|
|
27
|
+
python -m build
|
|
28
|
+
twine upload dist/*
|
|
29
|
+
|
|
30
|
+
npm-publish:
|
|
31
|
+
name: Publish to npm
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@v4
|
|
35
|
+
- name: Set up Node.js
|
|
36
|
+
uses: actions/setup-node@v4
|
|
37
|
+
with:
|
|
38
|
+
node-version: "20"
|
|
39
|
+
registry-url: "https://registry.npmjs.org"
|
|
40
|
+
- name: Install dependencies
|
|
41
|
+
run: npm install
|
|
42
|
+
- name: Publish to npm
|
|
43
|
+
run: npm publish --access public
|
|
44
|
+
env:
|
|
45
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
name: Build and Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
name: Build (${{ matrix.os }})
|
|
14
|
+
runs-on: ${{ matrix.os }}
|
|
15
|
+
strategy:
|
|
16
|
+
matrix:
|
|
17
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
18
|
+
python-version: ["3.11"]
|
|
19
|
+
include:
|
|
20
|
+
- os: ubuntu-latest
|
|
21
|
+
artifact_name: fifu-linux
|
|
22
|
+
- os: windows-latest
|
|
23
|
+
artifact_name: fifu-win
|
|
24
|
+
- os: macos-latest
|
|
25
|
+
artifact_name: fifu-macos
|
|
26
|
+
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
|
|
30
|
+
- name: Set up Python
|
|
31
|
+
uses: actions/setup-python@v5
|
|
32
|
+
with:
|
|
33
|
+
python-version: ${{ matrix.python-version }}
|
|
34
|
+
|
|
35
|
+
- name: Install dependencies
|
|
36
|
+
run: |
|
|
37
|
+
python -m pip install --upgrade pip
|
|
38
|
+
pip install PyInstaller
|
|
39
|
+
pip install .
|
|
40
|
+
|
|
41
|
+
- name: Build with PyInstaller (Linux/macOS)
|
|
42
|
+
if: runner.os != 'Windows'
|
|
43
|
+
run: |
|
|
44
|
+
pyinstaller --onefile --add-data "fifu/styles/app.tcss:fifu/styles" --name fifu fifu/__main__.py
|
|
45
|
+
|
|
46
|
+
- name: Build with PyInstaller (Windows)
|
|
47
|
+
if: runner.os == 'Windows'
|
|
48
|
+
run: |
|
|
49
|
+
pyinstaller --onefile --add-data "fifu/styles/app.tcss;fifu/styles" --name fifu-win fifu/__main__.py
|
|
50
|
+
|
|
51
|
+
- name: Rename and Prepare (Linux)
|
|
52
|
+
if: runner.os == 'Linux'
|
|
53
|
+
run: |
|
|
54
|
+
mv dist/fifu dist/fifu-linux
|
|
55
|
+
# Prepare for FPM
|
|
56
|
+
mkdir -p package/usr/local/bin
|
|
57
|
+
cp dist/fifu-linux package/usr/local/bin/fifu
|
|
58
|
+
|
|
59
|
+
- name: Rename binaries (macOS)
|
|
60
|
+
if: runner.os == 'macOS'
|
|
61
|
+
run: mv dist/fifu dist/fifu-macos
|
|
62
|
+
|
|
63
|
+
- name: Install FPM (Linux)
|
|
64
|
+
if: runner.os == 'Linux'
|
|
65
|
+
run: |
|
|
66
|
+
sudo apt-get update
|
|
67
|
+
sudo apt-get install -y ruby ruby-dev rubygems build-essential rpm
|
|
68
|
+
sudo gem install --no-document fpm
|
|
69
|
+
|
|
70
|
+
- name: Create .deb (Linux)
|
|
71
|
+
if: runner.os == 'Linux'
|
|
72
|
+
run: |
|
|
73
|
+
fpm -s dir -t deb -n fifu -v ${{ github.ref_name }} \
|
|
74
|
+
--prefix / \
|
|
75
|
+
-C package \
|
|
76
|
+
--description "Fifu - YouTube Channel Video Downloader TUI" \
|
|
77
|
+
--maintainer "Dawaman43 <github.com/Dawaman43>" \
|
|
78
|
+
--url "https://github.com/Dawaman43/fifu" \
|
|
79
|
+
dist/fifu.deb
|
|
80
|
+
|
|
81
|
+
- name: Create .rpm (Linux)
|
|
82
|
+
if: runner.os == 'Linux'
|
|
83
|
+
run: |
|
|
84
|
+
fpm -s dir -t rpm -n fifu -v ${{ github.ref_name }} \
|
|
85
|
+
--prefix / \
|
|
86
|
+
-C package \
|
|
87
|
+
--description "Fifu - YouTube Channel Video Downloader TUI" \
|
|
88
|
+
--maintainer "Dawaman43 <github.com/Dawaman43>" \
|
|
89
|
+
--url "https://github.com/Dawaman43/fifu" \
|
|
90
|
+
dist/fifu.rpm
|
|
91
|
+
|
|
92
|
+
- name: Create .tar.gz (Linux/Arch)
|
|
93
|
+
if: runner.os == 'Linux'
|
|
94
|
+
run: |
|
|
95
|
+
cd dist && tar -czvf fifu-linux-x64.tar.gz fifu-linux && cd ..
|
|
96
|
+
|
|
97
|
+
- name: Upload Artifact
|
|
98
|
+
uses: actions/upload-artifact@v4
|
|
99
|
+
with:
|
|
100
|
+
name: ${{ matrix.artifact_name }}
|
|
101
|
+
path: |
|
|
102
|
+
dist/fifu-linux
|
|
103
|
+
dist/fifu-win.exe
|
|
104
|
+
dist/fifu-macos
|
|
105
|
+
dist/fifu.deb
|
|
106
|
+
dist/fifu.rpm
|
|
107
|
+
dist/fifu-linux-x64.tar.gz
|
|
108
|
+
|
|
109
|
+
release:
|
|
110
|
+
name: Create Release
|
|
111
|
+
needs: build
|
|
112
|
+
runs-on: ubuntu-latest
|
|
113
|
+
steps:
|
|
114
|
+
- name: Download all artifacts
|
|
115
|
+
uses: actions/download-artifact@v4
|
|
116
|
+
with:
|
|
117
|
+
merge-multiple: true
|
|
118
|
+
path: dist
|
|
119
|
+
|
|
120
|
+
- name: List files (Debug)
|
|
121
|
+
run: ls -R dist
|
|
122
|
+
|
|
123
|
+
- name: Create Release
|
|
124
|
+
uses: softprops/action-gh-release@v2
|
|
125
|
+
with:
|
|
126
|
+
files: |
|
|
127
|
+
dist/fifu-linux
|
|
128
|
+
dist/fifu-linux-x64.tar.gz
|
|
129
|
+
dist/fifu-win.exe
|
|
130
|
+
dist/fifu-macos
|
|
131
|
+
dist/fifu.deb
|
|
132
|
+
dist/fifu.rpm
|
|
133
|
+
env:
|
|
134
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
package/PKGBUILD
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Maintainer: Dawaman43 <github.com/Dawaman43>
|
|
2
|
+
pkgname=fifu
|
|
3
|
+
pkgver=1.4.5
|
|
4
|
+
pkgrel=1
|
|
5
|
+
pkgdesc="A cross-platform TUI for downloading YouTube videos from channels"
|
|
6
|
+
arch=('any')
|
|
7
|
+
url="https://github.com/Dawaman43/fifu"
|
|
8
|
+
license=('MIT')
|
|
9
|
+
depends=('python' 'python-textual' 'yt-dlp' 'python-click')
|
|
10
|
+
makedepends=('python-build' 'python-installer' 'python-hatchling')
|
|
11
|
+
source=("https://github.com/Dawaman43/fifu/archive/refs/tags/v${pkgver}.tar.gz")
|
|
12
|
+
sha256sums=('SKIP')
|
|
13
|
+
|
|
14
|
+
build() {
|
|
15
|
+
cd "${pkgname}-${pkgver}"
|
|
16
|
+
python -m build --wheel --no-isolation
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
package() {
|
|
20
|
+
cd "${pkgname}-${pkgver}"
|
|
21
|
+
python -m installer --destdir="$pkgdir" dist/*.whl
|
|
22
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# 🎬 Fifu
|
|
2
|
+
|
|
3
|
+
**The Ultra-Fast, Cross-Platform TUI for Downloading YouTube Channel Content.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/Dawaman43/fifu/actions)
|
|
6
|
+
[](https://badge.fury.io/py/fifu)
|
|
7
|
+
[](https://aur.archlinux.org/packages/fifu)
|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+
[](https://www.python.org/downloads/)
|
|
10
|
+
|
|
11
|
+
Fifu (Fetch It For Us) is a high-performance Terminal User Interface (TUI) designed for power users who want to download entire YouTube channels or playlists with zero friction. Built with **Textual** and powered by **yt-dlp**.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## ✨ Key Features
|
|
16
|
+
|
|
17
|
+
- 🚀 **Asynchronous Performance** - Multi-threaded metadata fetching and concurrent downloads (3 at a time).
|
|
18
|
+
- 🔍 **Smart Search** - Find any channel instantly, sorted by popularity (subscriber count).
|
|
19
|
+
- 📋 **Playlist Intelligence** - Direct support for downloading entire playlists or specific channel sections.
|
|
20
|
+
- ⚙️ **Custom Quality Profiles** - 1080p, 720p, 480p, or high-fidelity Audio (MP3/M4A).
|
|
21
|
+
- 💬 **Subtitle Support** - Automatically download and embed subtitles into your videos.
|
|
22
|
+
- 💾 **Safe History & Favorites** - One-click access to your most-visited channels.
|
|
23
|
+
- 🏁 **Graceful UX** - Beautiful progress bars, keyboard-first navigation, and instant shutdown.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 🚀 Quick Start
|
|
28
|
+
|
|
29
|
+
The fastest way to install Fifu on any system is using **pipx**:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install git+https://github.com/Dawaman43/fifu.git
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 📦 Installation Options
|
|
38
|
+
|
|
39
|
+
### 🐧 Linux
|
|
40
|
+
|
|
41
|
+
#### Arch Linux (AUR)
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
yay -S fifu
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
#### Debian / Ubuntu / Mint
|
|
48
|
+
|
|
49
|
+
Download the latest `.deb` from [Releases](https://github.com/Dawaman43/fifu/releases) and run:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
sudo dpkg -i fifu.deb
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
#### Fedora / RHEL
|
|
56
|
+
|
|
57
|
+
Download the latest `.rpm` from [Releases](https://github.com/Dawaman43/fifu/releases) and run:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
sudo rpm -i fifu.rpm
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 🪟 Windows
|
|
64
|
+
|
|
65
|
+
1. Download `fifu-win.exe` from [Releases](https://github.com/Dawaman43/fifu/releases).
|
|
66
|
+
2. Run it from your terminal or double-click to start.
|
|
67
|
+
|
|
68
|
+
### 🍎 macOS
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
curl -L -o fifu https://github.com/Dawaman43/fifu/releases/latest/download/fifu-macos
|
|
72
|
+
chmod +x fifu
|
|
73
|
+
./fifu
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## ⌨️ Controls
|
|
79
|
+
|
|
80
|
+
| Key | Action |
|
|
81
|
+
| :-------------- | :------------------------- |
|
|
82
|
+
| `Enter` | Select / Confirm / Start |
|
|
83
|
+
| `f` | Toggle Channel as Favorite |
|
|
84
|
+
| `PageUp / Down` | Navigate search results |
|
|
85
|
+
| `Escape` | Go back |
|
|
86
|
+
| `q` | Quit Safely |
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## 🛠️ Tech Stack
|
|
91
|
+
|
|
92
|
+
- **[Textual](https://textual.textualize.io/)** - Stunning TUI framework.
|
|
93
|
+
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** - The industry standard for video extraction.
|
|
94
|
+
- **[Asyncio](https://docs.python.org/3/library/asyncio.html)** - Powering concurrent downloads.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 📜 License
|
|
99
|
+
|
|
100
|
+
Fifu is released under the **MIT License**. See [LICENSE](LICENSE) for details.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
_Made with ❤️ by [Dawaman43](https://github.com/Dawaman43)_
|
package/bin/fifu.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// This script aims to run the 'fifu' command.
|
|
7
|
+
// If installed via pip, 'fifu' should be in the PATH.
|
|
8
|
+
// If using the bundled binary, it would be different,
|
|
9
|
+
// but for an npm package that isn't pulling down binaries,
|
|
10
|
+
// we assume the user has the python environment or we point them to it.
|
|
11
|
+
|
|
12
|
+
const child = spawn('fifu', process.argv.slice(2), {
|
|
13
|
+
stdio: 'inherit',
|
|
14
|
+
shell: true
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
child.on('error', (err) => {
|
|
18
|
+
if (err.code === 'ENOENT') {
|
|
19
|
+
console.error('Error: "fifu" command not found.');
|
|
20
|
+
console.error('Please install the python package first: pip install fifu');
|
|
21
|
+
} else {
|
|
22
|
+
console.error(`Error: ${err.message}`);
|
|
23
|
+
}
|
|
24
|
+
process.exit(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
child.on('exit', (code) => {
|
|
28
|
+
process.exit(code);
|
|
29
|
+
});
|
package/fifu/__init__.py
ADDED
package/fifu/__main__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""CLI entry point for Fifu."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from fifu.app import FifuApp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command()
|
|
9
|
+
def main():
|
|
10
|
+
"""Fifu - YouTube Channel Video Downloader TUI"""
|
|
11
|
+
app = FifuApp()
|
|
12
|
+
app.run()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
main()
|
package/fifu/app.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Main Fifu Textual application."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from textual.app import App
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
|
|
11
|
+
from fifu.screens.search import SearchScreen
|
|
12
|
+
from fifu.screens.channels import ChannelsScreen
|
|
13
|
+
from fifu.screens.download import DownloadScreen
|
|
14
|
+
from fifu.screens.options import OptionsScreen
|
|
15
|
+
from fifu.services.youtube import YouTubeService, ChannelInfo, VideoInfo, PlaylistInfo, ChannelInfo
|
|
16
|
+
from fifu.services.downloader import DownloadService, DownloadProgress
|
|
17
|
+
from fifu.services.config import ConfigService
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FifuApp(App):
|
|
21
|
+
"""Fifu - YouTube Channel Video Downloader TUI."""
|
|
22
|
+
|
|
23
|
+
TITLE = "Fifu"
|
|
24
|
+
SUB_TITLE = "YouTube Channel Video Downloader"
|
|
25
|
+
|
|
26
|
+
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
|
27
|
+
CSS_PATH = Path(sys._MEIPASS) / "fifu" / "styles" / "app.tcss"
|
|
28
|
+
else:
|
|
29
|
+
CSS_PATH = Path(__file__).parent / "styles" / "app.tcss"
|
|
30
|
+
|
|
31
|
+
BINDINGS = [
|
|
32
|
+
Binding("q", "quit", "Quit", show=True),
|
|
33
|
+
Binding("escape", "go_back", "Back", show=True),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
super().__init__()
|
|
38
|
+
self.youtube_service = YouTubeService()
|
|
39
|
+
self.download_service = DownloadService()
|
|
40
|
+
self.config_service = ConfigService()
|
|
41
|
+
self._download_task: Optional[asyncio.Task] = None
|
|
42
|
+
self._stop_downloads = False
|
|
43
|
+
self._current_channel: Optional[ChannelInfo] = None
|
|
44
|
+
self._videos: list[VideoInfo] = []
|
|
45
|
+
self._download_quality = "best"
|
|
46
|
+
self._max_videos = 9999
|
|
47
|
+
self._download_subtitles = False
|
|
48
|
+
|
|
49
|
+
def on_mount(self) -> None:
|
|
50
|
+
"""Initialize the application."""
|
|
51
|
+
self.push_screen(SearchScreen())
|
|
52
|
+
|
|
53
|
+
async def action_quit(self) -> None:
|
|
54
|
+
"""Handle quit action with cleanup."""
|
|
55
|
+
self.stop_downloads()
|
|
56
|
+
self.youtube_service.shutdown()
|
|
57
|
+
|
|
58
|
+
# Cancel any pending asyncio tasks
|
|
59
|
+
if self._download_task:
|
|
60
|
+
self._download_task.cancel()
|
|
61
|
+
try:
|
|
62
|
+
await self._download_task
|
|
63
|
+
except asyncio.CancelledError:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
await super().action_quit()
|
|
67
|
+
|
|
68
|
+
def action_go_back(self) -> None:
|
|
69
|
+
"""Go back to the previous screen."""
|
|
70
|
+
if len(self.screen_stack) > 1:
|
|
71
|
+
self.pop_screen()
|
|
72
|
+
|
|
73
|
+
def search_channels(self, query: str) -> None:
|
|
74
|
+
"""Search for channels and display results."""
|
|
75
|
+
self.run_worker(self._search_channels_async(query), exclusive=True)
|
|
76
|
+
|
|
77
|
+
async def _search_channels_async(self, query: str) -> None:
|
|
78
|
+
"""Async worker to search channels."""
|
|
79
|
+
current_screen = self.screen
|
|
80
|
+
if not isinstance(current_screen, SearchScreen):
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
current_screen.show_searching()
|
|
84
|
+
|
|
85
|
+
# Check for direct URL
|
|
86
|
+
if query.startswith(("http://", "https://", "www.youtube.com", "youtube.com")):
|
|
87
|
+
await self._handle_direct_url(query)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
channels = await asyncio.get_event_loop().run_in_executor(
|
|
91
|
+
None, self.youtube_service.search_channels, query
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if not channels:
|
|
95
|
+
current_screen.show_error("No channels found. Try a different search.")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
self.config_service.add_history(query)
|
|
99
|
+
self.push_screen(ChannelsScreen(channels, query))
|
|
100
|
+
|
|
101
|
+
async def _handle_direct_url(self, url: str) -> None:
|
|
102
|
+
"""Handle direct playlist/video URL."""
|
|
103
|
+
current_screen = self.screen
|
|
104
|
+
if not isinstance(current_screen, SearchScreen):
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
metadata = await asyncio.get_event_loop().run_in_executor(
|
|
108
|
+
None, self.youtube_service.get_playlist_metadata, url
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if metadata:
|
|
112
|
+
title, uploader = metadata
|
|
113
|
+
channel = ChannelInfo(
|
|
114
|
+
id="direct_url",
|
|
115
|
+
name=title,
|
|
116
|
+
url=url, # Use original URL
|
|
117
|
+
description=f"Direct URL from: {uploader}"
|
|
118
|
+
)
|
|
119
|
+
self._current_channel = channel
|
|
120
|
+
self._playlist_url = url
|
|
121
|
+
self.config_service.add_history(url)
|
|
122
|
+
self.push_screen(OptionsScreen(channel, [])) # No other playlists to select
|
|
123
|
+
else:
|
|
124
|
+
current_screen.show_error("Invalid URL or couldn't fetch metadata.")
|
|
125
|
+
|
|
126
|
+
def select_channel(self, channel: ChannelInfo) -> None:
|
|
127
|
+
"""Handle channel selection - show options screen."""
|
|
128
|
+
self._current_channel = channel
|
|
129
|
+
self.run_worker(self._load_options_screen(channel), exclusive=True)
|
|
130
|
+
|
|
131
|
+
async def _load_options_screen(self, channel: ChannelInfo) -> None:
|
|
132
|
+
"""Load options screen with playlists."""
|
|
133
|
+
playlists = await asyncio.get_event_loop().run_in_executor(
|
|
134
|
+
None, self.youtube_service.get_channel_playlists, channel.id
|
|
135
|
+
)
|
|
136
|
+
self.push_screen(OptionsScreen(channel, playlists))
|
|
137
|
+
|
|
138
|
+
def start_download_with_options(
|
|
139
|
+
self,
|
|
140
|
+
channel: ChannelInfo,
|
|
141
|
+
max_videos: int,
|
|
142
|
+
quality: str,
|
|
143
|
+
playlist_url: Optional[str] = None,
|
|
144
|
+
subtitles: bool = False,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Start downloads with user-selected options."""
|
|
147
|
+
self._current_channel = channel
|
|
148
|
+
self._max_videos = max_videos
|
|
149
|
+
self._download_quality = quality
|
|
150
|
+
self._playlist_url = playlist_url
|
|
151
|
+
self._download_subtitles = subtitles
|
|
152
|
+
self.push_screen(DownloadScreen(channel))
|
|
153
|
+
|
|
154
|
+
def start_downloads(self, channel: ChannelInfo) -> None:
|
|
155
|
+
"""Start downloading videos from the channel."""
|
|
156
|
+
self._stop_downloads = False
|
|
157
|
+
self._download_task = asyncio.create_task(self._download_loop(channel))
|
|
158
|
+
|
|
159
|
+
async def _download_loop(self, channel: ChannelInfo) -> None:
|
|
160
|
+
"""Main download loop with concurrency."""
|
|
161
|
+
download_screen = self.screen
|
|
162
|
+
if not isinstance(download_screen, DownloadScreen):
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
download_screen.log_message(f"📡 Fetching videos from {channel.name}...")
|
|
166
|
+
|
|
167
|
+
playlist_url = getattr(self, '_playlist_url', None)
|
|
168
|
+
if playlist_url:
|
|
169
|
+
download_screen.log_message(f"📋 Loading playlist...")
|
|
170
|
+
videos = await asyncio.get_event_loop().run_in_executor(
|
|
171
|
+
None,
|
|
172
|
+
lambda: self.youtube_service.get_playlist_videos(playlist_url, self._max_videos)
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
videos = await asyncio.get_event_loop().run_in_executor(
|
|
176
|
+
None,
|
|
177
|
+
lambda: self.youtube_service.get_channel_videos(channel.url, self._max_videos)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if not videos:
|
|
181
|
+
download_screen.log_message("No videos found.", "error")
|
|
182
|
+
download_screen.on_queue_complete()
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
videos = videos[:self._max_videos]
|
|
186
|
+
download_screen.log_message(f"📋 Found {len(videos)} videos to download")
|
|
187
|
+
download_screen.log_message(f"🎬 Quality: {self._download_quality}")
|
|
188
|
+
if self._download_subtitles:
|
|
189
|
+
download_screen.log_message("💬 Subtitles: Enabled")
|
|
190
|
+
|
|
191
|
+
output_dir = self.download_service.get_download_path(channel.name)
|
|
192
|
+
download_screen.log_message(f"📁 Saving to: {output_dir}")
|
|
193
|
+
|
|
194
|
+
downloaded_titles = self.download_service.get_downloaded_videos(output_dir)
|
|
195
|
+
|
|
196
|
+
# Filter out already downloaded
|
|
197
|
+
to_download = [v for v in videos if v.title not in downloaded_titles]
|
|
198
|
+
skipped = len(videos) - len(to_download)
|
|
199
|
+
if skipped:
|
|
200
|
+
download_screen.log_message(f"⏭ Skipping {skipped} already downloaded videos")
|
|
201
|
+
|
|
202
|
+
download_screen.update_total_progress(0, len(to_download))
|
|
203
|
+
|
|
204
|
+
if not to_download:
|
|
205
|
+
download_screen.on_queue_complete()
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
# Use Semaphore to limit concurrency
|
|
209
|
+
semaphore = asyncio.Semaphore(3)
|
|
210
|
+
tasks = []
|
|
211
|
+
|
|
212
|
+
async def download_task(video: VideoInfo, index: int):
|
|
213
|
+
async with semaphore:
|
|
214
|
+
if self._stop_downloads:
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
video_url = f"https://www.youtube.com/watch?v={video.id}"
|
|
218
|
+
|
|
219
|
+
def progress_callback(progress: DownloadProgress):
|
|
220
|
+
self.call_from_thread(download_screen.update_progress, progress)
|
|
221
|
+
|
|
222
|
+
quality = self._download_quality
|
|
223
|
+
subtitles = self._download_subtitles
|
|
224
|
+
result = await asyncio.get_event_loop().run_in_executor(
|
|
225
|
+
None,
|
|
226
|
+
lambda: self.download_service.download_video(
|
|
227
|
+
video_url, output_dir, progress_callback, quality, subtitles=subtitles
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if result.success:
|
|
232
|
+
self.call_from_thread(download_screen.on_download_complete, result.video_title)
|
|
233
|
+
else:
|
|
234
|
+
self.call_from_thread(
|
|
235
|
+
download_screen.on_download_error,
|
|
236
|
+
result.video_title,
|
|
237
|
+
result.error or "Unknown error"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
for i, video in enumerate(to_download):
|
|
241
|
+
tasks.append(asyncio.create_task(download_task(video, i)))
|
|
242
|
+
|
|
243
|
+
await asyncio.gather(*tasks)
|
|
244
|
+
download_screen.on_queue_complete()
|
|
245
|
+
|
|
246
|
+
def stop_downloads(self) -> None:
|
|
247
|
+
"""Stop the download loop."""
|
|
248
|
+
self._stop_downloads = True
|
|
249
|
+
if self._download_task:
|
|
250
|
+
self._download_task.cancel()
|
|
251
|
+
|
|
252
|
+
def toggle_favorite(self, channel: ChannelInfo) -> bool:
|
|
253
|
+
"""Toggle favorite status for a channel."""
|
|
254
|
+
channel_dict = {
|
|
255
|
+
"id": channel.id,
|
|
256
|
+
"name": channel.name,
|
|
257
|
+
"url": channel.url,
|
|
258
|
+
"sub_count_str": getattr(channel, 'subscriber_count_str', 'N/A')
|
|
259
|
+
}
|
|
260
|
+
return self.config_service.toggle_favorite(channel_dict)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Fifu TUI screens."""
|
|
2
|
+
|
|
3
|
+
from fifu.screens.search import SearchScreen
|
|
4
|
+
from fifu.screens.channels import ChannelsScreen
|
|
5
|
+
from fifu.screens.download import DownloadScreen
|
|
6
|
+
from fifu.screens.options import OptionsScreen
|
|
7
|
+
|
|
8
|
+
__all__ = ["SearchScreen", "ChannelsScreen", "DownloadScreen", "OptionsScreen"]
|