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.
@@ -0,0 +1,231 @@
1
+ """Search screen for channel name input."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Container, Vertical, Horizontal
5
+ from textual.screen import Screen
6
+ from textual.widgets import Button, Input, Label, LoadingIndicator, ListItem, ListView
7
+ from fifu.services.youtube import ChannelInfo
8
+
9
+
10
+ class SearchScreen(Screen):
11
+ """Screen for searching YouTube channels."""
12
+
13
+ CSS = """
14
+ #search-container {
15
+ width: 100%;
16
+ height: 100%;
17
+ align: center middle;
18
+ }
19
+
20
+ #search-box {
21
+ width: 70;
22
+ height: auto;
23
+ padding: 1 2;
24
+ border: round $primary;
25
+ background: $surface-darken-1;
26
+ }
27
+
28
+ #search-title {
29
+ width: 100%;
30
+ text-align: center;
31
+ text-style: bold;
32
+ color: $primary;
33
+ margin-bottom: 1;
34
+ }
35
+
36
+ #search-subtitle {
37
+ width: 100%;
38
+ text-align: center;
39
+ color: $text-muted;
40
+ margin-bottom: 2;
41
+ }
42
+
43
+ #search-input {
44
+ width: 100%;
45
+ margin-bottom: 1;
46
+ }
47
+
48
+ #search-button {
49
+ width: 100%;
50
+ margin-top: 1;
51
+ }
52
+
53
+ #search-status {
54
+ width: 100%;
55
+ text-align: center;
56
+ color: $warning;
57
+ margin-top: 1;
58
+ height: 3;
59
+ }
60
+
61
+ #loading {
62
+ width: 100%;
63
+ height: 3;
64
+ margin-top: 1;
65
+ display: none;
66
+ }
67
+
68
+ #shoutouts {
69
+ width: 100%;
70
+ text-align: center;
71
+ color: $text-muted;
72
+ margin-bottom: 1;
73
+ }
74
+
75
+ .shoutout-link {
76
+ color: $accent;
77
+ text-style: italic;
78
+ }
79
+
80
+ #lists-container {
81
+ width: 100%;
82
+ height: auto;
83
+ margin-top: 1;
84
+ }
85
+
86
+ .list-section {
87
+ width: 1fr;
88
+ height: auto;
89
+ padding: 0 1;
90
+ }
91
+
92
+ .section-title {
93
+ text-style: bold;
94
+ color: $primary;
95
+ margin-bottom: 1;
96
+ }
97
+
98
+ #history-list, #favorites-list {
99
+ background: $surface;
100
+ border: none;
101
+ height: auto;
102
+ min-height: 3;
103
+ max-height: 8;
104
+ }
105
+
106
+ ListItem {
107
+ padding: 0 1;
108
+ }
109
+
110
+ ListItem:hover {
111
+ background: $accent 20%;
112
+ }
113
+ """
114
+
115
+ def compose(self) -> ComposeResult:
116
+ """Create the search screen layout."""
117
+ with Container(id="search-container"):
118
+ with Vertical(id="search-box"):
119
+ yield Label("🎬 FIFU", id="search-title")
120
+ yield Label("YouTube Channel or Playlist Downloader", id="search-subtitle")
121
+ yield Label(
122
+ "Shout out to [b]The PrimeTime[/b] & [b]Devtopia[/b] ⚡",
123
+ id="shoutouts"
124
+ )
125
+ yield Input(
126
+ placeholder="Enter channel name or paste playlist URL...",
127
+ id="search-input",
128
+ )
129
+ yield Button("Search / Process URL", id="search-button", variant="primary")
130
+ yield LoadingIndicator(id="loading")
131
+ yield Label("", id="search-status")
132
+
133
+ with Horizontal(id="lists-container"):
134
+ with Vertical(classes="list-section"):
135
+ yield Label(" Recent", classes="section-title")
136
+ yield ListView(id="history-list")
137
+
138
+ with Vertical(classes="list-section"):
139
+ yield Label("⭐ Favorites", classes="section-title")
140
+ yield ListView(id="favorites-list")
141
+
142
+ def on_mount(self) -> None:
143
+ """Initialize the screen."""
144
+ self.query_one("#search-input", Input).focus()
145
+ self._refresh_lists()
146
+
147
+ def on_screen_resume(self) -> None:
148
+ """Refresh lists when returning to this screen."""
149
+ self._refresh_lists()
150
+
151
+ def _refresh_lists(self) -> None:
152
+ """Reload history and favorites from config."""
153
+ history_list = self.query_one("#history-list", ListView)
154
+ fav_list = self.query_one("#favorites-list", ListView)
155
+
156
+ history_list.clear()
157
+ for item in self.app.config_service.get_history():
158
+ li = ListItem(Label(item))
159
+ li.history_query = item # Store original query safely
160
+ history_list.append(li)
161
+
162
+ fav_list.clear()
163
+ for fav in self.app.config_service.get_favorites():
164
+ fav_list.append(ListItem(
165
+ Label(f"📺 {fav['name']} ({fav.get('sub_count_str', 'N/A')})"),
166
+ id=f"fav-{fav['id']}"
167
+ ))
168
+
169
+ def on_button_pressed(self, event: Button.Pressed) -> None:
170
+ """Handle search button press."""
171
+ if event.button.id == "search-button":
172
+ self._do_search()
173
+
174
+ def on_input_submitted(self, event: Input.Submitted) -> None:
175
+ """Handle enter key in input."""
176
+ if event.input.id == "search-input":
177
+ self._do_search()
178
+
179
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
180
+ """Handle selection in history or favorites."""
181
+ item = event.item
182
+ if hasattr(item, "history_query"):
183
+ query = item.history_query
184
+ self.query_one("#search-input", Input).value = query
185
+ self._do_search()
186
+ elif item.id and item.id.startswith("fav-"):
187
+ channel_id = item.id[4:]
188
+ favorites = self.app.config_service.get_favorites()
189
+ fav = next((f for f in favorites if f["id"] == channel_id), None)
190
+ if fav:
191
+ channel = ChannelInfo(
192
+ id=fav["id"],
193
+ name=fav["name"],
194
+ url=fav["url"]
195
+ )
196
+ self.app.select_channel(channel)
197
+
198
+ def _do_search(self) -> None:
199
+ """Perform the channel search."""
200
+ input_widget = self.query_one("#search-input", Input)
201
+ query = input_widget.value.strip()
202
+
203
+ if not query:
204
+ status = self.query_one("#search-status", Label)
205
+ status.update("Please enter a channel name")
206
+ return
207
+
208
+ status = self.query_one("#search-status", Label)
209
+ status.update("Searching...")
210
+
211
+ self.app.search_channels(query)
212
+
213
+ def show_error(self, message: str) -> None:
214
+ """Display an error message."""
215
+ self.query_one("#loading").display = False
216
+ try:
217
+ self.query_one("#search-button").display = True
218
+ except Exception:
219
+ pass
220
+ status = self.query_one("#search-status", Label)
221
+ status.update(f"❌ {message}")
222
+
223
+ def show_searching(self) -> None:
224
+ """Show searching status."""
225
+ try:
226
+ self.query_one("#search-button").display = False
227
+ except Exception:
228
+ pass
229
+ self.query_one("#loading").display = True
230
+ status = self.query_one("#search-status", Label)
231
+ status.update("🔍 Searching...")
@@ -0,0 +1,7 @@
1
+ """Fifu services for YouTube and download management."""
2
+
3
+ from fifu.services.youtube import YouTubeService, ChannelInfo, VideoInfo, PlaylistInfo
4
+ from fifu.services.downloader import DownloadService
5
+ from fifu.services.config import ConfigService
6
+
7
+ __all__ = ["YouTubeService", "ChannelInfo", "VideoInfo", "PlaylistInfo", "DownloadService", "ConfigService"]
@@ -0,0 +1,87 @@
1
+ """Service for managing persistent configuration and history."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ class ConfigService:
9
+ """Manages application configuration, history, and favorites."""
10
+
11
+ def __init__(self):
12
+ self.config_dir = Path.home() / ".config" / "fifu"
13
+ self.config_file = self.config_dir / "data.json"
14
+ self._data = {
15
+ "history": [],
16
+ "favorites": []
17
+ }
18
+ self._load()
19
+
20
+ def _load(self) -> None:
21
+ """Load data from JSON file."""
22
+ if self.config_file.exists():
23
+ try:
24
+ with open(self.config_file, "r") as f:
25
+ loaded_data = json.load(f)
26
+ # Merge with defaults to handle schema changes
27
+ self._data.update(loaded_data)
28
+ except Exception:
29
+ pass
30
+
31
+ def _save(self) -> None:
32
+ """Save data to JSON file."""
33
+ self.config_dir.mkdir(parents=True, exist_ok=True)
34
+ try:
35
+ with open(self.config_file, "w") as f:
36
+ json.dump(self._data, f, indent=4)
37
+ except Exception:
38
+ pass
39
+
40
+ def get_history(self) -> list[str]:
41
+ """Get search history."""
42
+ return self._data.get("history", [])
43
+
44
+ def add_history(self, query: str) -> None:
45
+ """Add a query to history, ensuring unique and limited to 10 items."""
46
+ if not query:
47
+ return
48
+
49
+ history = self.get_history()
50
+ # Remove if already exists to move to top
51
+ if query in history:
52
+ history.remove(query)
53
+
54
+ history.insert(0, query)
55
+ self._data["history"] = history[:10]
56
+ self._save()
57
+
58
+ def clear_history(self) -> None:
59
+ """Clear search history."""
60
+ self._data["history"] = []
61
+ self._save()
62
+
63
+ def get_favorites(self) -> list[dict[str, Any]]:
64
+ """Get favorite channels."""
65
+ return self._data.get("favorites", [])
66
+
67
+ def is_favorite(self, channel_id: str) -> bool:
68
+ """Check if a channel is in favorites."""
69
+ return any(f.get("id") == channel_id for f in self.get_favorites())
70
+
71
+ def toggle_favorite(self, channel_info_dict: dict[str, Any]) -> bool:
72
+ """Add/remove channel from favorites. Returns True if now a favorite."""
73
+ favorites = self.get_favorites()
74
+ channel_id = channel_info_dict.get("id")
75
+
76
+ existing = next((f for f in favorites if f.get("id") == channel_id), None)
77
+
78
+ if existing:
79
+ favorites.remove(existing)
80
+ result = False
81
+ else:
82
+ favorites.insert(0, channel_info_dict)
83
+ result = True
84
+
85
+ self._data["favorites"] = favorites
86
+ self._save()
87
+ return result
@@ -0,0 +1,190 @@
1
+ """Download service for managing video downloads."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Callable, Optional
8
+ import yt_dlp
9
+
10
+
11
+ @dataclass
12
+ class DownloadProgress:
13
+ """Progress information for a download."""
14
+ video_title: str
15
+ status: str
16
+ downloaded_bytes: int = 0
17
+ total_bytes: int = 0
18
+ speed: Optional[str] = None
19
+ eta: Optional[str] = None
20
+ percent: float = 0.0
21
+
22
+
23
+ @dataclass
24
+ class DownloadResult:
25
+ """Result of a download operation."""
26
+ success: bool
27
+ video_title: str
28
+ file_path: Optional[Path] = None
29
+ error: Optional[str] = None
30
+
31
+
32
+ @dataclass
33
+ class DownloadQueue:
34
+ """Queue of videos to download."""
35
+ channel_name: str
36
+ videos: list = field(default_factory=list)
37
+ current_index: int = 0
38
+ downloaded_ids: set = field(default_factory=set)
39
+
40
+
41
+ class DownloadService:
42
+ """Service for downloading YouTube videos."""
43
+
44
+ def __init__(self):
45
+ self.log_file = Path.home() / ".config" / "fifu" / "downloader.log"
46
+ self.log_file.parent.mkdir(parents=True, exist_ok=True)
47
+ logging.basicConfig(
48
+ filename=str(self.log_file),
49
+ level=logging.DEBUG,
50
+ format="%(asctime)s - %(levelname)s - %(message)s",
51
+ )
52
+ logging.info("Downloader service initialized")
53
+
54
+ def get_download_path(self, channel_name: str) -> Path:
55
+ """Get the download path for a channel."""
56
+ safe_name = self._sanitize_filename(channel_name)
57
+ downloads_dir = Path.home() / "Downloads" / "videos" / safe_name
58
+ downloads_dir.mkdir(parents=True, exist_ok=True)
59
+ return downloads_dir
60
+
61
+ def _sanitize_filename(self, name: str) -> str:
62
+ """Sanitize a string for use as a filename."""
63
+ safe = re.sub(r'[<>:"/\\|?*]', '', name)
64
+ safe = safe.strip('. ')
65
+ return safe or "unknown_channel"
66
+
67
+
68
+ def download_video(
69
+ self,
70
+ video_url: str,
71
+ output_dir: Path,
72
+ progress_callback: Optional[Callable[[DownloadProgress], None]] = None,
73
+ quality: str = "best",
74
+ video_id: str = "unknown",
75
+ subtitles: bool = False
76
+ ) -> DownloadResult:
77
+ """Download a single video with specified quality."""
78
+ current_title = "Loading..."
79
+
80
+ def progress_hook(d: dict):
81
+ if progress_callback:
82
+ status = d.get("status", "unknown")
83
+ if status == "downloading":
84
+ downloaded = d.get("downloaded_bytes", 0)
85
+ total = d.get("total_bytes") or d.get("total_bytes_estimate", 0)
86
+ percent = (downloaded / total * 100) if total > 0 else 0
87
+
88
+ progress_callback(DownloadProgress(
89
+ video_title=current_title,
90
+ status="downloading",
91
+ downloaded_bytes=downloaded,
92
+ total_bytes=total,
93
+ speed=d.get("_speed_str", ""),
94
+ eta=d.get("_eta_str", ""),
95
+ percent=percent,
96
+ ))
97
+ elif status == "finished":
98
+ progress_callback(DownloadProgress(
99
+ video_title=current_title,
100
+ status="processing",
101
+ percent=100.0,
102
+ ))
103
+
104
+ output_template = str(output_dir / "%(title)s.%(ext)s")
105
+
106
+ if quality == "best":
107
+ format_str = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best"
108
+ elif quality == "bestaudio/best":
109
+ format_str = "bestaudio/best"
110
+ else:
111
+ format_str = quality
112
+
113
+ class YDLogger:
114
+ def debug(self, msg):
115
+ if msg.startswith('[debug] '): pass
116
+ else: self.info(msg)
117
+ def info(self, msg): logging.info(f"yt-dlp: {msg}")
118
+ def warning(self, msg): logging.warning(f"yt-dlp: {msg}")
119
+ def error(self, msg): logging.error(f"yt-dlp: {msg}")
120
+
121
+ ydl_opts = {
122
+ "format": format_str,
123
+ "outtmpl": output_template,
124
+ "progress_hooks": [progress_hook],
125
+ "quiet": True,
126
+ "no_warnings": True,
127
+ "merge_output_format": "mp4",
128
+ "logger": YDLogger(),
129
+ "noprogress": True,
130
+ }
131
+
132
+ if subtitles:
133
+ ydl_opts.update({
134
+ "writesubtitles": True,
135
+ "subtitleslangs": ["en.*", ".*"],
136
+ "embedsubs": True,
137
+ })
138
+
139
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
140
+ try:
141
+ info = ydl.extract_info(video_url, download=False)
142
+ if info:
143
+ current_title = info.get("title", "Unknown")
144
+
145
+ if progress_callback:
146
+ progress_callback(DownloadProgress(
147
+ video_title=current_title,
148
+ status="starting",
149
+ percent=0.0,
150
+ ))
151
+
152
+ logging.info(f"Starting download: {current_title} ({video_url})")
153
+ ydl.download([video_url])
154
+ logging.info(f"Download finished: {current_title}")
155
+
156
+ filename = ydl.prepare_filename(info)
157
+ file_path = Path(filename)
158
+
159
+ if not file_path.exists():
160
+ mp4_path = file_path.with_suffix('.mp4')
161
+ if mp4_path.exists():
162
+ file_path = mp4_path
163
+
164
+ return DownloadResult(
165
+ success=True,
166
+ video_title=current_title,
167
+ file_path=file_path if file_path.exists() else None,
168
+ )
169
+ except Exception as e:
170
+ logging.error(f"Download failed for {video_url}: {str(e)}")
171
+ return DownloadResult(
172
+ success=False,
173
+ video_title=current_title,
174
+ error=str(e),
175
+ )
176
+
177
+ return DownloadResult(
178
+ success=False,
179
+ video_title=current_title,
180
+ error="Unknown error",
181
+ )
182
+
183
+ def get_downloaded_videos(self, output_dir: Path) -> set[str]:
184
+ """Get set of already downloaded video titles (without extension)."""
185
+ downloaded = set()
186
+ if output_dir.exists():
187
+ for file in output_dir.iterdir():
188
+ if file.is_file() and file.suffix in ('.mp4', '.mkv', '.webm', '.m4a', '.mp3'):
189
+ downloaded.add(file.stem)
190
+ return downloaded