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
|
@@ -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
|