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 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
+ [![Build Status](https://github.com/Dawaman43/fifu/actions/workflows/publish.yml/badge.svg)](https://github.com/Dawaman43/fifu/actions)
6
+ [![PyPI version](https://badge.fury.io/py/fifu.svg)](https://badge.fury.io/py/fifu)
7
+ [![AUR version](https://img.shields.io/aur/version/fifu)](https://aur.archlinux.org/packages/fifu)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](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
+ });
@@ -0,0 +1,3 @@
1
+ """Fifu - YouTube Channel Video Downloader TUI"""
2
+
3
+ __version__ = "0.1.0"
@@ -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"]