-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathHomepage.py
More file actions
516 lines (425 loc) · 19.4 KB
/
Homepage.py
File metadata and controls
516 lines (425 loc) · 19.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
from PySide6 import QtCore
from PySide6.QtWidgets import (QApplication, QMainWindow, QStackedWidget, QWidget,
QLineEdit, QListWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QListWidgetItem, QCompleter, QGridLayout)
from PySide6.QtCore import Qt, QStringListModel, QSize, Signal
from PySide6.QtGui import QIcon
import threading
from typing import Optional, Dict, List, Any, Callable
import os
from Data.DatabaseManager import DatabaseManager
from Backend.ScrapeChannel import Search
from utils.AppState import app_state
from utils.Logger import logger
from utils.Formatters import parse_sub_count, format_sub_count
from UI.SplashScreen import SplashScreen
def get_channel_sub_count_safe(db, channel_id: str) -> int:
rows = db.fetch(
table="CHANNEL",
where="channel_id=?",
params=(channel_id,)
)
if not rows:
return 0
return parse_sub_count(rows[0].get("sub_count", 0))
class Home(QWidget):
"""
Main home widget for channel search and selection functionality.
This widget provides a search interface for YouTube channels, allowing users to:
- Search for channels by keyword with auto-completion
- View search results with channel information
- Select channels for detailed scraping
Attributes:
results_ready (Signal): Emitted when search results are ready with list of channel names
search_complete (Signal): Emitted when final search operation completes
progress_update (Signal): Emitted to update progress bar with (progress: int, status: str)
show_splash_signal (Signal): Signal to display splash screen
close_splash_signal (Signal): Signal to close splash screen
home_page_scrape_video_signal (Signal): Signal to initiate video scraping from home page
videos (dict): Storage for video information
video_url (list): List of video URLs
live (dict): Storage for live stream information
shorts (dict): Storage for YouTube shorts information
content (dict): General content storage
"""
results_ready = Signal(list)
search_complete = Signal() # Changed to simple signal
progress_update = Signal(int, str) # New signal for progress updates
show_splash_signal = Signal() # Signal to show splash
close_splash_signal = Signal() # Signal to close splash
home_page_scrape_video_signal = Signal()
videos = {}
video_url = []
live = {}
shorts = {}
content = {}
def __init__(self, parent: Optional[QMainWindow] = None) -> None:
"""
Initialize the Home widget.
Sets up the UI components, database connection, search functionality,
and signal-slot connections for asynchronous operations.
Args:
parent: Parent QMainWindow widget, defaults to None
"""
super(Home, self).__init__(parent)
self.mainwindow = parent
self.db: DatabaseManager = app_state.db
self.search = Search()
self.splash = None
self.top_panel = QWidget()
self.central_layout = QVBoxLayout()
self.central_widget = QStackedWidget()
# Replace ComboBox with LineEdit and ListWidget
self.searchbar = QLineEdit()
self.select_scrape_button = QPushButton("Select and scrape info")
self.channel_list = QListWidget()
self.model = QStringListModel()
self.completer = QCompleter(self.model, self.searchbar)
self.select_scrape_button.clicked.connect(self.select_channel)
self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion)
self.searchbar.setCompleter(self.completer)
self.searchbar.mousePressEvent = lambda event, e=self.searchbar.mousePressEvent: (e(event), self.completer.complete())
self.searchbar.focusInEvent = lambda event, e=self.searchbar.focusInEvent: (e(event), self.completer.complete())
self.completer_active = False
self.search_timer = QtCore.QTimer()
self.stop_event = threading.Event()
self.search_thread_instance = None
self.channels = None
self.search_channel_button = QPushButton("Search")
self.search_timer.setSingleShot(True)
self.search_timer.timeout.connect(lambda: self.search_keyword(self.searchbar.text()))
# Setup search components
self.searchbar.setPlaceholderText("Search")
self.searchbar.textChanged.connect(self.reset_search_timer)
self.completer.activated.connect(self.on_completer_activated)
self.search_channel_button.clicked.connect(self.search_channel)
# Connect signals to slots
self.results_ready.connect(self.update_results)
self.search_complete.connect(self.on_search_complete)
self.progress_update.connect(self.on_progress_update)
self.show_splash_signal.connect(self.show_search_splash)
self.close_splash_signal.connect(self.close_splash)
self.setupUi()
self.setLayout(self.central_layout)
self.central_layout.addWidget(self.top_panel)
def on_completer_activated(self, text: str) -> None:
"""
Handle completer selection event.
When user selects an item from the auto-complete dropdown, this method
stops any pending search operations and temporarily sets the completer_active flag.
Args:
text: The text that was selected from the completer
"""
self.completer_active = True
self.search_timer.stop() # Stop any pending search
# Reset flag after a short delay
QtCore.QTimer.singleShot(50, lambda: setattr(self, 'completer_active', False))
def setupUi(self) -> None:
"""
Set up the user interface of the main window.
Initializes and arranges all UI components in their proper layout.
"""
self.setuptop()
def setuptop(self) -> None:
"""
Set up the top panel of the home widget.
Creates a grid layout containing:
- Search bar (row 0, col 0)
- Search button (row 0, col 1)
- Select and scrape button (row 2, cols 0-1)
"""
self.top_layout = QGridLayout()
self.top_panel.setLayout(self.top_layout)
self.top_layout.addWidget(self.searchbar, 0, 0, alignment=Qt.AlignTop)
self.top_layout.addWidget(self.search_channel_button, 0, 1)
self.top_layout.addWidget(self.select_scrape_button, 2, 0, 1, 2, alignment=Qt.AlignBottom)
self.top_panel.show()
def select_channel(self) -> None:
"""
Handle channel selection from the channel list.
Extracts channel information from the currently selected list item,
stores it in the application state, and emits a signal to initiate
video scraping for the selected channel.
The channel information includes:
- channel_name: Display name of the channel
- channel_id: Unique identifier for the channel
- channel_url: URL to the channel page
- profile_pic: Path to the channel's profile picture
"""
item = self.channel_list.currentItem()
channel_info = {}
if item:
data = item.data(Qt.UserRole)
channel_info['channel_name'] = data['channel_name']
channel_info['channel_id'] = data['channel_id']
channel_info['channel_url'] = data['channel_url']
channel_info['profile_pic'] = data['profile_pic']
app_state.channel_info = channel_info
self.home_page_scrape_video_signal.emit()
def reset_search_timer(self) -> None:
"""
Reset the search timer for debounced search functionality.
Starts a 5ms timer before triggering a search. This provides a debounce
effect to avoid excessive search operations while the user is typing.
Only resets the timer if the completer is not currently active.
"""
if not self.completer_active:
self.search_timer.start(5)
def on_item_selected(self, item: QListWidgetItem) -> None:
"""
Handle item selection from dropdown.
Updates the search bar text with the selected item and returns focus
to the search bar for continued interaction.
Args:
item: The QListWidgetItem that was selected
"""
if item:
self.searchbar.blockSignals(True)
selected_text = item.text()
self.searchbar.setText(selected_text)
# Return focus to input after selection
QtCore.QTimer.singleShot(10, lambda: self.searchbar.setFocus())
self.searchbar.blockSignals(False)
@QtCore.Slot()
def show_search_splash(self) -> None:
cwd = os.getcwd()
gif_path = os.path.join(cwd, "assets", "gif", "loading.gif")
if self.splash:
self.splash.close()
self.splash = SplashScreen(parent=self.mainwindow, gif_path=gif_path)
self.splash.set_title("Searching Channels...")
self.splash.update_status("Fetching channel information...")
self.splash.set_progress(0)
# Runtime overlay + cancel support
self.splash.enable_runtime_mode(
parent_window=self.mainwindow,
cancel_callback=self.cancel_search
)
self.splash.show_with_animation()
self.splash.raise_()
self.splash.activateWindow()
@QtCore.Slot(int, str)
def on_progress_update(self, progress: int, status: str) -> None:
"""
Update splash screen progress.
Updates the progress bar and status message on the splash screen
during long-running search operations.
Args:
progress: Progress percentage (0-100), or -1 to skip progress bar update
status: Status message to display on the splash screen
"""
if self.splash:
if progress >= 0: # Only update progress bar for numeric values
self.splash.set_progress(progress)
if status:
self.splash.update_status(status)
@QtCore.Slot()
def close_splash(self) -> None:
if self.splash:
self.splash.fade_and_close(400)
self.splash = None
def cancel_search(self):
logger.warning("User cancelled search operation.")
self.stop_event.set()
if self.search_thread_instance and self.search_thread_instance.is_alive():
self.search_thread_instance.join(timeout=0.5)
self.close_splash_signal.emit()
def update_results(self, channels: List[str]) -> None:
"""
Update dropdown list with search results.
Populates the auto-complete dropdown with channel names from
the search results and triggers the completer to display them.
Args:
channels: List of channel names to display in the dropdown
"""
if channels:
self.model.setStringList(channels)
self.completer.complete()
def _set_item_icon_lazy(self, item: QListWidgetItem, path: str):
if path and os.path.exists(path):
item.setIcon(QIcon(path))
@QtCore.Slot()
def on_search_complete(self) -> None:
"""
Handle completion of final search with downloads.
Called when the final comprehensive search operation completes.
Updates the UI with the full channel list and closes the splash screen.
"""
logger.info("Final search complete, updating UI...")
self.update_channel_list()
self.close_splash()
@QtCore.Slot()
def update_channel_list(self) -> None:
"""
Update the channel list widget with search results.
Populates the channel list widget with detailed information about each
channel including profile picture, name, and subscriber count. Retrieves
additional information from the database if available.
Each list item stores channel data including:
- channel_id: Unique channel identifier
- channel_name: Display name
- channel_url: Channel URL
- profile_pic: Path to profile picture
- sub_count: Subscriber count
"""
self.channel_list.clear()
if not self.channels:
return
# Create a copy of channels to avoid iteration issues
channels_copy = list(self.channels.items())
channels_copy.sort(
key=lambda item: get_channel_sub_count_safe(self.db, item[0]),
reverse=True
)
for channel_id, info in channels_copy:
inf = self.db.fetch(table="CHANNEL", where="channel_id=?", params=(channel_id,))
if not inf:
logger.debug(f"Channel not yet in DB: {channel_id}")
channel_name = info.get("title", "Unknown")
sub_int = 0
profile_pic = None
else:
row = inf[0]
channel_name = row.get("name") or info.get("title", "Unknown")
sub_int = parse_sub_count(row.get("sub_count"))
profile_pic = row.get("profile_pic")
sub_text = format_sub_count(sub_int)
text_label = f"{channel_name}\n{sub_text} subscribers"
cwd = os.getcwd()
default_avatar = os.path.join(cwd, "assets", "icon", "default_avatar.png")
placeholder = QIcon(default_avatar)
item = QListWidgetItem(placeholder, text_label)
if profile_pic:
QtCore.QTimer.singleShot(
0, lambda p=profile_pic, i=item: self._set_item_icon_lazy(i, p)
)
item.setData(Qt.UserRole, {
"channel_id": channel_id,
"channel_name": channel_name,
"channel_url": info.get('url'),
"profile_pic": profile_pic,
"sub_count": sub_int
})
self.channel_list.addItem(item)
self.channel_list.setIconSize(QSize(32, 32))
# add widget to layout only if not already present
if self.top_layout.itemAtPosition(1, 0) is None:
self.top_layout.addWidget(self.channel_list, 1, 0, 1, 2)
def search_keyword(self, query: str, final: bool = False) -> None:
"""
Initiate a channel search operation.
Starts a background thread to search for channels matching the given query.
Handles cancellation of existing search threads before starting a new one.
Args:
query: Search keyword or channel name
final: If True, performs a comprehensive search with progress tracking
and downloads. If False, performs a quick limited search for
auto-complete suggestions. Defaults to False.
"""
try:
# Signal any existing thread to stop
if self.search_thread_instance and self.search_thread_instance.is_alive():
self.stop_event.set()
logger.debug("Signaling previous search thread to stop")
self.stop_event.clear()
if query:
self.search_thread_instance = threading.Thread(
target=self._run_search,
daemon=True,
args=(query, final)
)
self.search_thread_instance.start()
except Exception as e:
logger.exception("Search keyword error:")
def _run_search(self, query: str, final: bool) -> None:
"""
Run search in background thread.
Performs the actual channel search operation in a separate thread to avoid
blocking the UI. Handles progress updates, splash screen display, and
thread cancellation.
For quick searches (final=False), limits results to 6 channels without
downloading additional data. For final searches (final=True), retrieves
up to 20 channels with full details including profile pictures.
Args:
query: Search keyword or channel name
final: If True, performs comprehensive search with progress tracking.
If False, performs quick limited search.
"""
logger.debug("Search channel thread triggered")
# Check if thread should stop before starting work
if self.stop_event.is_set():
self.close_splash_signal.emit()
logger.debug("Search thread cancelled before execution")
return
try:
if final:
# Define progress callback for the search
def progress_callback(progress: Any, status: Optional[str] = None):
if self.stop_event.is_set():
return
if isinstance(progress, (int, float)):
self.progress_update.emit(int(progress), status or "")
if self.splash:
self.splash.update_eta(int(progress))
elif isinstance(progress, str):
self.progress_update.emit(-1, progress)
# Perform search with progress tracking
self.channels = self.search.search_channel(
query,
limit=20,
stop_event=self.stop_event,
final=final,
progress_callback=progress_callback
)
else:
# Quick search without splash
self.channels = self.search.search_channel(
query,
limit=6,
stop_event=self.stop_event,
final=final
)
except Exception as e:
if self.stop_event.is_set():
logger.debug("Search thread stopped during execution")
return
# Close splash on error
self.close_splash_signal.emit()
logger.exception("Search error occurred:")
return
# Check again before processing results
if self.stop_event.is_set():
logger.debug("Search thread cancelled after search")
self.close_splash_signal.emit()
return
self.channel_name = [item.get('title') for key, item in self.channels.items()]
if not final:
self.results_ready.emit(self.channel_name)
else:
# Signal that search is complete
self.search_complete.emit()
if self.stop_event.is_set():
self.close_splash_signal.emit()
return
def search_channel(self) -> None:
"""
Handle search button click event.
"""
query = self.searchbar.text().strip()
if not query:
return
# CANCEL any running search
if self.search_thread_instance and self.search_thread_instance.is_alive():
self.stop_event.set()
self.search_thread_instance.join(timeout=0.5)
self.channels = None
self.stop_event.clear()
# SHOW SPLASH IMMEDIATELY (MAIN THREAD)
self.show_search_splash()
# START WORKER THREAD AFTER SPLASH IS VISIBLE
self.search_thread_instance = threading.Thread(
target=self._run_search,
daemon=True,
args=(query, True)
)
self.search_thread_instance.start()