From cda1865dba789422736602e8992bf582fd6bd772 Mon Sep 17 00:00:00 2001 From: trots Date: Sun, 15 Dec 2024 02:04:32 +0300 Subject: [PATCH 01/13] Implement main window tabs --- engine.py | 5 +- search.py | 217 ++++++++++++++++++++++++++++++ settings.py | 19 ++- theme.py | 43 ++++++ widgets.py | 85 +++++++++++- youtube-analyzer.py | 314 +++++++++++--------------------------------- 6 files changed, 439 insertions(+), 244 deletions(-) create mode 100644 search.py create mode 100644 theme.py diff --git a/engine.py b/engine.py index e9213cc..fa91839 100644 --- a/engine.py +++ b/engine.py @@ -102,11 +102,12 @@ def _handle_finished(self, reply: QNetworkReply): return image = QImage() image.loadFromData(reply.readAll()) + url = reply.url() if image.isNull() and self._try_again: - self._manager.get(QNetworkRequest(reply.url())) + self._manager.get(QNetworkRequest(url)) self._try_again = False self._try_again = True - self._data_cache.cache_image(reply.url().toString(), image) + self._data_cache.cache_image(url.toString(), image) self.finished.emit(image) diff --git a/search.py b/search.py new file mode 100644 index 0000000..238716c --- /dev/null +++ b/search.py @@ -0,0 +1,217 @@ +from PySide6.QtCore import ( + Qt, + QSortFilterProxyModel, + QItemSelection +) +from PySide6.QtWidgets import ( + QApplication, + QHBoxLayout, + QVBoxLayout, + QSplitter, + QSizePolicy, + QWidget, + QLabel, + QPushButton, + QSpinBox, + QTableView, + QTabWidget, + QMessageBox +) +from PySide6.QtCharts import ( + QChart +) +from theme import ( + Theme +) +from settings import ( + Settings +) +from engine import ( + YoutubeApiEngine, + YoutubeGrepEngine +) +from model import ( + ResultFields, + ResultTableModel +) +from widgets import ( + SearchLineEdit, + TabWorkspaceFactory, + VideoDetailsWidget, + AnalyticsWidget +) + + +class SearchWorkspace(QWidget): + def __init__(self, settings: Settings, parent: QWidget = None): + super().__init__(parent) + + self.request_text = "" + self._settings = settings + + h_layout = QHBoxLayout() + self._search_line_edit = SearchLineEdit() + self._search_line_edit.returnPressed.connect(self._on_search_clicked) + h_layout.addWidget(self._search_line_edit) + self._search_limit_spin_box = QSpinBox() + self._search_limit_spin_box.setToolTip(self.tr("Set the search result limit")) + self._search_limit_spin_box.setMinimumWidth(50) + self._search_limit_spin_box.setRange(2, 30) + request_limit = int(self._settings.get(Settings.RequestLimit)) + self._search_limit_spin_box.setValue(request_limit) + h_layout.addWidget(self._search_limit_spin_box) + self._search_button = QPushButton(self.tr("Search")) + self._search_button.setToolTip(self.tr("Click to start searching")) + self._search_button.clicked.connect(self._on_search_clicked) + h_layout.addWidget(self._search_button) + + self.model = ResultTableModel(self) + self._sort_model = QSortFilterProxyModel(self) + self._sort_model.setSortRole(ResultTableModel.SortRole) + self._sort_model.setSourceModel(self.model) + self._table_view = QTableView(self) + self._table_view.setModel(self._sort_model) + self._table_view.setSelectionMode(QTableView.SelectionMode.SingleSelection) + self._table_view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) + self._table_view.setSortingEnabled(True) + self._table_view.horizontalHeader().setSectionsMovable(True) + self._table_view.selectionModel().selectionChanged.connect(self._on_table_row_changed) + + self._side_tab_widget = QTabWidget() + + self._details_widget = VideoDetailsWidget(self.model, self) + self._side_tab_widget.addTab(self._details_widget, self.tr("Details")) + + self._analytics_widget = AnalyticsWidget(self.model, self) + self._side_tab_widget.addTab(self._analytics_widget, self.tr("Analytics")) + self._analytics_widget.set_current_index_following(self._settings.get(Settings.AnalyticsFollowTableSelect)) + if int(self._settings.get(Settings.Theme)) == Theme.Dark: + self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeDark) + else: + self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeLight) + self._analytics_widget.set_current_chart_index(int(self._settings.get(Settings.LastActiveChartIndex))) + + self._main_splitter = QSplitter(Qt.Orientation.Horizontal) + self._main_splitter.addWidget(self._table_view) + self._main_splitter.addWidget(self._side_tab_widget) + self._main_splitter.setCollapsible(0, False) + + v_layout = QVBoxLayout() + v_layout.addLayout(h_layout) + v_layout.addWidget(self._main_splitter) + self.setLayout(v_layout) + + def load_state(self): + # Restore main splitter + splitter_state = self._settings.get(Settings.MainSplitterState) + if not splitter_state.isEmpty(): + self._main_splitter.restoreState(splitter_state) + # Restore main table + table_header_state = self._settings.get(Settings.MainTableHeaderState) + if not table_header_state.isEmpty(): + self._table_view.horizontalHeader().restoreState(table_header_state) + self._table_view.resizeColumnsToContents() + self._search_line_edit.setFocus() + + def save_state(self): + self._settings.set(Settings.RequestLimit, self._search_limit_spin_box.value()) + self._settings.set(Settings.LastActiveChartIndex, self._analytics_widget.get_current_chart_index()) + self._settings.set(Settings.MainTableHeaderState, self._table_view.horizontalHeader().saveState()) + self._settings.set(Settings.MainSplitterState, self._main_splitter.saveState()) + self._settings.set(Settings.LastActiveDetailsTab, self._side_tab_widget.currentIndex()) + + def handle_preferences_change(self): + if int(self._settings.get(Settings.Theme)) == Theme.Dark: + self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeDark) + else: + self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeLight) + + self._analytics_widget.set_current_index_following(self._settings.get(Settings.AnalyticsFollowTableSelect)) + if self._settings.get(Settings.AnalyticsFollowTableSelect): + self._analytics_widget.set_current_index(self._table_view.currentIndex()) + + def _on_search_clicked(self): + self.request_text = self._search_line_edit.text() + + if self.request_text == "": + return + + self._search_line_edit.setDisabled(True) + self._search_button.setDisabled(True) + self._table_view.setDisabled(True) + self._side_tab_widget.setDisabled(True) + QApplication.setOverrideCursor(Qt.CursorShape.BusyCursor) + self.model.clear() + self._sort_model.sort(-1) + self._details_widget.clear() + QApplication.instance().processEvents() + + engine = self._create_engine() + if engine.search(self.request_text): + for i in range(len(self.model.result)): + video_idx = self._sort_model.index(i, self.model.get_field_column(ResultFields.VideoTitle)) + video_item = self.model.result[i] + video_label = self._create_link_label(video_item[ResultFields.VideoLink], video_item[ResultFields.VideoTitle]) + self._table_view.setIndexWidget(video_idx, video_label) + + channel_idx = self._sort_model.index(i, self.model.get_field_column(ResultFields.ChannelTitle)) + channel_label = self._create_link_label(video_item[ResultFields.ChannelLink], + video_item[ResultFields.ChannelTitle]) + self._table_view.setIndexWidget(channel_idx, channel_label) + + self._table_view.resizeColumnsToContents() + else: + dialog = QMessageBox() + dialog.setWindowTitle(self.tr("Error")) + dialog.setText(self.tr("Error in the searching process")) + dialog.setIcon(QMessageBox.Critical) + dialog.setDetailedText(engine.error) + dialog.exec() + + QApplication.restoreOverrideCursor() + self._search_line_edit.setDisabled(False) + self._search_button.setDisabled(False) + self._table_view.setDisabled(False) + self._side_tab_widget.setDisabled(False) + + def _on_table_row_changed(self, current: QItemSelection, _previous: QItemSelection): + indexes = current.indexes() + if len(indexes) > 0: + index = self._sort_model.mapToSource(indexes[0]) + self._details_widget.set_current_index(index) + self._analytics_widget.set_current_index(index) + else: + self._details_widget.set_current_index(None) + self._analytics_widget.set_current_index(None) + + def _create_engine(self): + request_limit = self._search_limit_spin_box.value() + api_key = self._settings.get(Settings.YouTubeApiKey) + if not api_key: + return YoutubeGrepEngine(self.model, request_limit) + else: + return YoutubeApiEngine(self.model, request_limit, api_key) + + def _create_link_label(self, link: str, text: str): + label = QLabel("" + text + "") + label_size_policy = label.sizePolicy() + label_size_policy.setHorizontalPolicy(QSizePolicy.Policy.Expanding) + label.setSizePolicy(label_size_policy) + label.setTextFormat(Qt.TextFormat.RichText) + label.setOpenExternalLinks(True) + return label + + +class SearchWorkspaceFactory(TabWorkspaceFactory): + def __init__(self, parent: QWidget = None): + super().__init__(parent) + + def get_workspace_name(self) -> str: + return "Search" + + def create_workspace_button(self) -> QPushButton: + button = QPushButton("Search video...") + return button + + def create_workspace_widget(self, settings: Settings, parent: QWidget) -> QWidget: + return SearchWorkspace(settings, parent) diff --git a/settings.py b/settings.py index 429e5ec..a5181fd 100644 --- a/settings.py +++ b/settings.py @@ -34,15 +34,20 @@ class Settings: Language = SettingsKey("language", "") Theme = SettingsKey("theme", 0) MainSplitterState = SettingsKey("main_splitter_state", [0, 0]) - DetailsVisible = SettingsKey("details", True) + DetailsVisible = SettingsKey("details", True) # Not used LastActiveDetailsTab = SettingsKey("last_active_details_tab", 0) AnalyticsFollowTableSelect = SettingsKey("analytics_follow_table_select", True) LastActiveChartIndex = SettingsKey("last_active_chart_index", 0) RequestTimeoutSec = SettingsKey("request_timeout_sec", 10) MainTableHeaderState = SettingsKey("main_table_header_state", QByteArray()) + MainTabsArray = SettingsKey("main_tabs", 0) + TabWorkspaceIndex = SettingsKey("tab_workspace_index", -1) + ActiveTabIndex = SettingsKey("active_tab_index", 0) def __init__(self, app_name: str): self._impl = QSettings(QSettings.Format.IniFormat, QSettings.Scope.UserScope, app_name) + print(self._impl.fileName()) + # TODO: add settings converter for a new version on the first run def get(self, key: SettingsKey): if type(key.default_value) is bool: @@ -52,6 +57,18 @@ def get(self, key: SettingsKey): def set(self, key: SettingsKey, value: any): self._impl.setValue(key.key, value) + def begin_read_array(self, key: SettingsKey): + return self._impl.beginReadArray(key.key) + + def begin_write_array(self, key: SettingsKey): + self._impl.beginWriteArray(key.key) + + def set_array_index(self, index: int): + self._impl.setArrayIndex(index) + + def end_array(self): + self._impl.endArray() + class GeneralTab(QWidget): language_changed = Signal() diff --git a/theme.py b/theme.py new file mode 100644 index 0000000..03de1bc --- /dev/null +++ b/theme.py @@ -0,0 +1,43 @@ +from PySide6.QtCore import ( + Qt +) +from PySide6.QtGui import ( + QColor, + QPalette +) +from PySide6.QtWidgets import ( + QApplication +) + + +class Theme: + System: int = 0 + Dark: int = 1 + + @staticmethod + def apply(app: QApplication, theme_index: int): + if theme_index == Theme.Dark: + palette = QPalette() + palette.setColor(QPalette.Window, QColor(53, 53, 53)) + palette.setColor(QPalette.WindowText, Qt.white) + palette.setColor(QPalette.Base, QColor(25, 25, 25)) + palette.setColor(QPalette.AlternateBase, QColor(53, 53, 53)) + palette.setColor(QPalette.ToolTipBase, Qt.black) + palette.setColor(QPalette.ToolTipText, Qt.white) + palette.setColor(QPalette.Text, Qt.white) + palette.setColor(QPalette.Button, QColor(53, 53, 53)) + palette.setColor(QPalette.ButtonText, Qt.white) + palette.setColor(QPalette.BrightText, Qt.red) + palette.setColor(QPalette.Link, QColor(148, 192, 236)) + palette.setColor(QPalette.Highlight, QColor(19, 60, 110)) + palette.setColor(QPalette.HighlightedText, Qt.white) + palette.setColor(QPalette.Active, QPalette.Button, QColor(53, 53, 53)) + palette.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(53, 53, 53).lighter()) + palette.setColor(QPalette.Disabled, QPalette.WindowText, QColor(53, 53, 53).lighter()) + palette.setColor(QPalette.Disabled, QPalette.Text, QColor(53, 53, 53).lighter()) + palette.setColor(QPalette.Disabled, QPalette.Light, QColor(53, 53, 53)) + app.setPalette(palette) + else: + palette = QPalette() + palette.setColor(QPalette.Link, QColor(19, 60, 110)) + app.setPalette(palette) diff --git a/widgets.py b/widgets.py index 37f4958..f20460e 100644 --- a/widgets.py +++ b/widgets.py @@ -23,11 +23,16 @@ QStackedLayout, QComboBox, QLineEdit, - QCompleter + QCompleter, + QPushButton, + QTabWidget ) from PySide6.QtCharts import ( QChartView ) +from settings import ( + Settings +) from engine import ( ImageDownloader, SearchAutocompleteDownloader @@ -161,7 +166,7 @@ def __init__(self, model: ResultTableModel, parent: QWidget = None): self._stacked_layout = QStackedLayout() self._stacked_layout.setContentsMargins(0, 0, 0, 0) - no_video_selected_label = QLabel(self.tr("Select a video to see its details")) + no_video_selected_label = QLabel(self.tr("Select a video to see its details"), self) no_video_selected_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self._stacked_layout.addWidget(no_video_selected_label) self._stacked_layout.addWidget(scroll_area) @@ -320,3 +325,79 @@ def _on_editing_timeout(self): def _on_autocomplete_downloaded(self, autocomplete_list): self._autocomplete_model.setStringList(autocomplete_list) + + +class TabWorkspaceFactory(QWidget): + def __init__(self, parent: QWidget = None): + super().__init__(parent) + + def get_workspace_name(self) -> str: + raise "Not implemented" + + def create_workspace_button(self) -> QPushButton: + raise "Not implemented" + + def create_workspace_widget(self, settings, parent) -> QWidget: + raise "Not implemented" + + +class TabWidget(QWidget): + workspace_factories = [] + + def __init__(self, settings: Settings, parent_tab_widget: QTabWidget, parent: QWidget = None): + super().__init__(parent) + self._settings = settings + self._parent_tab_widget = parent_tab_widget + self._current_workspace_index = -1 + + self._main_stacked_layout = QStackedLayout() + + self._main_layout = QVBoxLayout() + self._main_layout.setContentsMargins(0, 0, 0, 0) + container = QWidget() + container.setLayout(self._main_layout) + self._main_stacked_layout.addWidget(container) + self._main_stacked_layout.setCurrentIndex(0) + self.setLayout(self._main_stacked_layout) + + for workspace_factory in TabWidget.workspace_factories: + workspace_button = workspace_factory.create_workspace_button() + workspace_button.clicked.connect(self._create_workspace) + self._main_layout.addWidget(workspace_button, alignment=Qt.AlignmentFlag.AlignCenter) + + def current_workspace(self): + return self._main_stacked_layout.currentWidget() if self._main_stacked_layout.currentIndex() == 1 else None + + def load_state(self): + workspace_index = int(self._settings.get(Settings.TabWorkspaceIndex)) + if workspace_index >= 0: + workspace_widget = self.create_workspace(workspace_index) + workspace_widget.load_state() + + def save_state(self): + self._settings.set(Settings.TabWorkspaceIndex, self._current_workspace_index) + if self._current_workspace_index >= 0: + workspace_widget = self._main_stacked_layout.currentWidget() + workspace_widget.save_state() + + def handle_preferences_change(self): + workspace = self.current_workspace() + if workspace: + workspace.handle_preferences_change() + + def create_workspace(self, workspace_index): + if workspace_index < 0 or workspace_index >= len(TabWidget.workspace_factories): + return + factory = TabWidget.workspace_factories[workspace_index] + workspace_widget = factory.create_workspace_widget(self._settings, self) + self._main_stacked_layout.addWidget(workspace_widget) + self._main_stacked_layout.setCurrentIndex(1) + tab_index = self._parent_tab_widget.indexOf(self) + self._parent_tab_widget.setTabText(tab_index, factory.get_workspace_name()) + self._current_workspace_index = workspace_index + return workspace_widget + + def _create_workspace(self): + workspace_button = self.sender() + workspace_index = self._main_layout.indexOf(workspace_button) + self.create_workspace(workspace_index) diff --git a/youtube-analyzer.py b/youtube-analyzer.py index f26fb14..b4c5e69 100644 --- a/youtube-analyzer.py +++ b/youtube-analyzer.py @@ -4,29 +4,20 @@ from PySide6.QtCore import ( Qt, QFileInfo, - QSortFilterProxyModel, QTranslator, - QLibraryInfo, - QItemSelection, - QTimer + QLibraryInfo ) from PySide6.QtGui import ( QKeySequence, QIcon, - QShowEvent, - QPalette, - QColor + QShowEvent ) from PySide6.QtWidgets import ( QApplication, QMainWindow, - QPushButton, - QHBoxLayout, QVBoxLayout, QWidget, - QTableView, QFileDialog, - QSpinBox, QDialog, QGridLayout, QLabel, @@ -34,33 +25,26 @@ QSpacerItem, QMessageBox, QCheckBox, - QSplitter, QTextEdit, - QTabWidget -) -from PySide6.QtCharts import ( - QChart + QTabWidget, + QToolButton ) from defines import ( app_name, version ) +from theme import ( + Theme +) from settings import ( Settings, SettingsDialog ) -from model import ( - ResultFields, - ResultTableModel -) -from engine import ( - YoutubeGrepEngine, - YoutubeApiEngine -) from widgets import ( - VideoDetailsWidget, - AnalyticsWidget, - SearchLineEdit + TabWidget +) +from search import ( + SearchWorkspaceFactory ) from export import ( export_to_xlsx, @@ -72,39 +56,6 @@ app_need_restart = False -class Theme: - System: int = 0 - Dark: int = 1 - - @staticmethod - def apply(app: QApplication, theme_index: int): - if theme_index == Theme.Dark: - palette = QPalette() - palette.setColor(QPalette.Window, QColor(53, 53, 53)) - palette.setColor(QPalette.WindowText, Qt.white) - palette.setColor(QPalette.Base, QColor(25, 25, 25)) - palette.setColor(QPalette.AlternateBase, QColor(53, 53, 53)) - palette.setColor(QPalette.ToolTipBase, Qt.black) - palette.setColor(QPalette.ToolTipText, Qt.white) - palette.setColor(QPalette.Text, Qt.white) - palette.setColor(QPalette.Button, QColor(53, 53, 53)) - palette.setColor(QPalette.ButtonText, Qt.white) - palette.setColor(QPalette.BrightText, Qt.red) - palette.setColor(QPalette.Link, QColor(148, 192, 236)) - palette.setColor(QPalette.Highlight, QColor(19, 60, 110)) - palette.setColor(QPalette.HighlightedText, Qt.white) - palette.setColor(QPalette.Active, QPalette.Button, QColor(53, 53, 53)) - palette.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(53, 53, 53).lighter()) - palette.setColor(QPalette.Disabled, QPalette.WindowText, QColor(53, 53, 53).lighter()) - palette.setColor(QPalette.Disabled, QPalette.Text, QColor(53, 53, 53).lighter()) - palette.setColor(QPalette.Disabled, QPalette.Light, QColor(53, 53, 53)) - app.setPalette(palette) - else: - palette = QPalette() - palette.setColor(QPalette.Link, QColor(19, 60, 110)) - app.setPalette(palette) - - class DontAskAgainQuestionDialog(QMessageBox): def __init__(self, title: str, text: str, parent=None): super().__init__(parent) @@ -192,6 +143,7 @@ def __init__(self, parent=None): class MainWindow(QMainWindow): def __init__(self, settings: Settings): super().__init__() + self._request_text = "" self._settings = settings self._restore_geometry_on_show = True @@ -215,10 +167,9 @@ def __init__(self, settings: Settings): preferences_action = edit_menu.addAction(self.tr("Preferences...")) preferences_action.triggered.connect(self._on_preferences) - view_menu = self.menuBar().addMenu(self.tr("View")) - self._show_details_action = view_menu.addAction(self.tr("Show details")) - self._show_details_action.setCheckable(True) - self._show_details_action.setChecked(False) + window_menu = self.menuBar().addMenu(self.tr("Window")) + add_new_tab_action = window_menu.addAction(self.tr("Create a new tab")) + add_new_tab_action.triggered.connect(self._create_new_tab) help_menu = self.menuBar().addMenu(self.tr("Help")) authors_action = help_menu.addAction(self.tr("Authors...")) @@ -226,85 +177,42 @@ def __init__(self, settings: Settings): about_action = help_menu.addAction(self.tr("About...")) about_action.triggered.connect(self._on_about) - h_layout = QHBoxLayout() - self._search_line_edit = SearchLineEdit() - self._search_line_edit.returnPressed.connect(self._on_search_clicked) - h_layout.addWidget(self._search_line_edit) - self._search_limit_spin_box = QSpinBox() - self._search_limit_spin_box.setToolTip(self.tr("Set the search result limit")) - self._search_limit_spin_box.setMinimumWidth(50) - self._search_limit_spin_box.setRange(2, 30) - request_limit = int(self._settings.get(Settings.RequestLimit)) - self._search_limit_spin_box.setValue(request_limit) - h_layout.addWidget(self._search_limit_spin_box) - self._search_button = QPushButton(self.tr("Search")) - self._search_button.setToolTip(self.tr("Click to start searching")) - self._search_button.clicked.connect(self._on_search_clicked) - h_layout.addWidget(self._search_button) - - self._model = ResultTableModel(self) - self._sort_model = QSortFilterProxyModel(self) - self._sort_model.setSortRole(ResultTableModel.SortRole) - self._sort_model.setSourceModel(self._model) - self._table_view = QTableView(self) - self._table_view.setModel(self._sort_model) - self._table_view.setSelectionMode(QTableView.SelectionMode.SingleSelection) - self._table_view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) - self._table_view.setSortingEnabled(True) - self._table_view.horizontalHeader().setSectionsMovable(True) - self._table_view.selectionModel().selectionChanged.connect(self._on_table_row_changed) - - self._side_tab_widget = QTabWidget() - self._side_tab_widget.setVisible(False) - self._show_details_action.toggled.connect(self._on_switch_side_panel) - - self._details_widget = VideoDetailsWidget(self._model, self) - self._details_widget.setVisible(False) - - self._analytics_widget = AnalyticsWidget(self._model, self) - self._analytics_widget.setVisible(False) - self._analytics_widget.set_current_index_following(self._settings.get(Settings.AnalyticsFollowTableSelect)) - if int(self._settings.get(Settings.Theme)) == Theme.Dark: - self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeDark) - else: - self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeLight) - self._analytics_widget.set_current_chart_index(int(self._settings.get(Settings.LastActiveChartIndex))) - - self._main_splitter = QSplitter(Qt.Orientation.Horizontal) - self._main_splitter.setChildrenCollapsible(False) - self._main_splitter.addWidget(self._table_view) - self._main_splitter.addWidget(self._side_tab_widget) - v_layout = QVBoxLayout() - v_layout.addLayout(h_layout) - v_layout.addWidget(self._main_splitter) + + self._main_tab_widget = QTabWidget() + self._main_tab_widget.setTabsClosable(True) + self._main_tab_widget.tabCloseRequested.connect(self._on_close_tab_requested) + v_layout.addWidget(self._main_tab_widget) + + add_new_tab_button = QToolButton() + add_new_tab_button.setFixedHeight(20) + add_new_tab_button.setText("+") + add_new_tab_button.setToolTip(self.tr("Create a new tab")) + add_new_tab_button.clicked.connect(self._create_new_tab) + self._main_tab_widget.setCornerWidget(add_new_tab_button, Qt.Corner.TopRightCorner) central_widget = QWidget() central_widget.setLayout(v_layout) self.setCentralWidget(central_widget) + self._create_new_tab() + def showEvent(self, _event: QShowEvent): if self._restore_geometry_on_show: # Restore window geometry geometry = self._settings.get(Settings.MainWindowGeometry) if not geometry.isEmpty(): self.restoreGeometry(geometry) - # Restore main splitter sizes - splitter_sizes = list(map(int, self._settings.get(Settings.MainSplitterState))) - if len(splitter_sizes) != 2 or splitter_sizes[0] == 0 or splitter_sizes[1] == 0: - window_width_part = self.width() / 5 - splitter_sizes = [3 * window_width_part, 2 * window_width_part] - self._main_splitter.setSizes(splitter_sizes) - # Restore details panel visibility - if self._settings.get(Settings.DetailsVisible): - self._show_details_action.setChecked(True) + + tabs_count = self._settings.begin_read_array(Settings.MainTabsArray) + for tab_index in range(tabs_count): + self._settings.set_array_index(tab_index) + tab_widget = self._main_tab_widget.widget(0) if tab_index == 0 else self._create_new_tab() + tab_widget.load_state() + self._settings.end_array() + current_tab_index = int(self._settings.get(Settings.ActiveTabIndex)) + self._main_tab_widget.setCurrentIndex(current_tab_index) self._restore_geometry_on_show = False - # Restore main table - table_header_state = self._settings.get(Settings.MainTableHeaderState) - if not table_header_state.isEmpty(): - self._table_view.horizontalHeader().restoreState(table_header_state) - self._table_view.resizeColumnsToContents() - self._search_line_edit.setFocus() def closeEvent(self, event): global app_need_restart @@ -320,94 +228,34 @@ def closeEvent(self, event): def save_state(self): self._settings.set(Settings.MainWindowGeometry, self.saveGeometry()) - self._settings.set(Settings.DetailsVisible, self._show_details_action.isChecked()) - self._settings.set(Settings.LastActiveChartIndex, self._analytics_widget.get_current_chart_index()) - self._settings.set(Settings.MainTableHeaderState, self._table_view.horizontalHeader().saveState()) - if self._show_details_action.isChecked(): - self._settings.set(Settings.MainSplitterState, self._main_splitter.sizes()) - self._settings.set(Settings.LastActiveDetailsTab, self._side_tab_widget.currentIndex()) - - def _on_switch_side_panel(self, visible): - # Workaround for the hide/show bug of the QTabWidget. - # Bug: If QTabWidget has the current tab index > 0, then QTabWidget hides first tab forever after hide/show iteration. - # So we need remove/add all tabs after hide/show to fix this bug. - if visible: - self._side_tab_widget.addTab(self._details_widget, self.tr("Details")) - self._side_tab_widget.addTab(self._analytics_widget, self.tr("Analytics")) - self._side_tab_widget.show() - QTimer.singleShot(0, lambda: self._side_tab_widget.setCurrentIndex( - int(self._settings.get(Settings.LastActiveDetailsTab)))) - else: - self._settings.set(Settings.MainSplitterState, self._main_splitter.sizes()) - self._settings.set(Settings.LastActiveDetailsTab, self._side_tab_widget.currentIndex()) - self._side_tab_widget.hide() - self._side_tab_widget.removeTab(1) - self._side_tab_widget.removeTab(0) - - def _on_search_clicked(self): - self._request_text = self._search_line_edit.text() - request_limit = self._search_limit_spin_box.value() - - if self._request_text == "": - return - - self._settings.set(Settings.RequestLimit, request_limit) - - self._search_line_edit.setDisabled(True) - self._search_button.setDisabled(True) - self._table_view.setDisabled(True) - self._side_tab_widget.setDisabled(True) - QApplication.setOverrideCursor(Qt.CursorShape.BusyCursor) - self._model.clear() - self._sort_model.sort(-1) - self._details_widget.clear() - QApplication.instance().processEvents() - - engine = self._create_engine() - if engine.search(self._request_text): - for i in range(len(self._model.result)): - video_idx = self._sort_model.index(i, self._model.get_field_column(ResultFields.VideoTitle)) - video_item = self._model.result[i] - video_label = self._create_link_label(video_item[ResultFields.VideoLink], video_item[ResultFields.VideoTitle]) - self._table_view.setIndexWidget(video_idx, video_label) - - channel_idx = self._sort_model.index(i, self._model.get_field_column(ResultFields.ChannelTitle)) - channel_label = self._create_link_label(video_item[ResultFields.ChannelLink], - video_item[ResultFields.ChannelTitle]) - self._table_view.setIndexWidget(channel_idx, channel_label) - - self._table_view.resizeColumnsToContents() - else: - dialog = QMessageBox() - dialog.setWindowTitle(app_name) - dialog.setText(self.tr("Error in the searching process")) - dialog.setIcon(QMessageBox.Critical) - dialog.setDetailedText(engine.error) - dialog.exec() - - QApplication.restoreOverrideCursor() - self._search_line_edit.setDisabled(False) - self._search_button.setDisabled(False) - self._table_view.setDisabled(False) - self._side_tab_widget.setDisabled(False) - - def _on_table_row_changed(self, current: QItemSelection, _previous: QItemSelection): - indexes = current.indexes() - if len(indexes) > 0: - index = self._sort_model.mapToSource(indexes[0]) - self._details_widget.set_current_index(index) - self._analytics_widget.set_current_index(index) - else: - self._details_widget.set_current_index(None) - self._analytics_widget.set_current_index(None) + self._settings.begin_write_array(Settings.MainTabsArray) + for tab_index in range(self._main_tab_widget.count()): + self._settings.set_array_index(tab_index) + tab_widget = self._main_tab_widget.widget(tab_index) + tab_widget.save_state() + self._settings.end_array() + self._settings.set(Settings.ActiveTabIndex, self._main_tab_widget.currentIndex()) + + def _create_new_tab(self): + tab_widget = TabWidget(self._settings, self._main_tab_widget) + tab_index = self._main_tab_widget.addTab(tab_widget, self.tr("New tab")) + self._main_tab_widget.setCurrentIndex(tab_index) + return tab_widget + + def _on_close_tab_requested(self, index): + if self._main_tab_widget.count() > 1: + self._main_tab_widget.removeTab(index) def _get_file_path_to_export(self, caption: str, filter: str, file_suffix: str): - if self._request_text == "" or len(self._model.result) == 0: + current_workspace = self._main_tab_widget.currentWidget().current_workspace() + request_text = current_workspace.request_text if current_workspace.request_text else "export" + if not current_workspace or len(current_workspace.model.result) == 0: + QMessageBox.warning(self, app_name, self.tr("There is no data to export")) return "" last_save_dir = self._settings.get(Settings.LastSaveDir) file_name = QFileDialog.getSaveFileName(self, caption=caption, filter=filter, - dir=(last_save_dir + "/" + self._request_text + file_suffix)) + dir=(last_save_dir + "/" + request_text + file_suffix)) if not file_name[0]: return "" self._settings.set(Settings.LastSaveDir, QFileInfo(file_name[0]).dir().absolutePath()) @@ -416,17 +264,23 @@ def _get_file_path_to_export(self, caption: str, filter: str, file_suffix: str): def _on_export_xlsx(self): file_path = self._get_file_path_to_export(self.tr("Save XLSX"), self.tr("Xlsx File (*.xlsx)"), ".xlsx") if file_path: - export_to_xlsx(file_path, self._model) + current_workspace = self._main_tab_widget.currentWidget().current_workspace() + if current_workspace: + export_to_xlsx(file_path, current_workspace.model) def _on_export_csv(self): file_path = self._get_file_path_to_export(self.tr("Save CSV"), self.tr("Csv File (*.csv)"), ".csv") if file_path: - export_to_csv(file_path, self._model) + current_workspace = self._main_tab_widget.currentWidget().current_workspace() + if current_workspace: + export_to_csv(file_path, current_workspace.model) def _on_export_html(self): file_path = self._get_file_path_to_export(self.tr("Save HTML"), self.tr("Html File (*.html)"), ".html") if file_path: - export_to_html(file_path, self._model) + current_workspace = self._main_tab_widget.currentWidget().current_workspace() + if current_workspace: + export_to_html(file_path, current_workspace.model) def _on_preferences(self): global app_need_restart @@ -435,14 +289,10 @@ def _on_preferences(self): return Theme.apply(QApplication.instance(), int(self._settings.get(Settings.Theme))) - if int(self._settings.get(Settings.Theme)) == Theme.Dark: - self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeDark) - else: - self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeLight) - self._analytics_widget.set_current_index_following(self._settings.get(Settings.AnalyticsFollowTableSelect)) - if self._settings.get(Settings.AnalyticsFollowTableSelect): - self._analytics_widget.set_current_index(self._table_view.currentIndex()) + for tab_index in range(self._main_tab_widget.count()): + tab_widget = self._main_tab_widget.widget(tab_index) + tab_widget.handle_preferences_change() if dialog.is_need_restart(): app_need_restart = True @@ -456,23 +306,6 @@ def _on_authors(self): dialog = AuthorsDialog(self) dialog.exec() - def _create_engine(self): - request_limit = self._search_limit_spin_box.value() - api_key = self._settings.get(Settings.YouTubeApiKey) - if not api_key: - return YoutubeGrepEngine(self._model, request_limit) - else: - return YoutubeApiEngine(self._model, request_limit, api_key) - - def _create_link_label(self, link: str, text: str): - label = QLabel("" + text + "") - label_size_policy = label.sizePolicy() - label_size_policy.setHorizontalPolicy(QSizePolicy.Policy.Expanding) - label.setSizePolicy(label_size_policy) - label.setTextFormat(Qt.TextFormat.RichText) - label.setOpenExternalLinks(True) - return label - def top_exception_handler(_exctype, value, tb): dialog = QMessageBox(QMessageBox.Critical, app_name, str(value)) @@ -495,6 +328,9 @@ def top_exception_handler(_exctype, value, tb): settings = Settings(app_name) Theme.apply(app, int(settings.get(Settings.Theme))) +TabWidget.workspace_factories.append(SearchWorkspaceFactory()) +# ^^^ Don't change the factories order. Append new factories to the end of list + while True: my_translator = QTranslator() qt_translator = QTranslator() From 4288886fdf183e44554de3abede46827abc0aff1 Mon Sep 17 00:00:00 2001 From: trots Date: Sat, 21 Dec 2024 00:57:46 +0300 Subject: [PATCH 02/13] Move tests and sources into separate modules --- .github/workflows/python-app.yml | 4 ++-- tests/__init__.py | 0 test_engines.py => tests/test_engines.py | 4 ++-- test_export.py => tests/test_export.py | 4 ++-- youtubeanalyzer/__init__.py | 0 youtube-analyzer.py => youtubeanalyzer/__main__.py | 12 ++++++------ chart.py => youtubeanalyzer/chart.py | 2 +- defines.py => youtubeanalyzer/defines.py | 0 engine.py => youtubeanalyzer/engine.py | 2 +- export.py => youtubeanalyzer/export.py | 2 +- model.py => youtubeanalyzer/model.py | 0 search.py => youtubeanalyzer/search.py | 10 +++++----- settings.py => youtubeanalyzer/settings.py | 0 theme.py => youtubeanalyzer/theme.py | 0 widgets.py => youtubeanalyzer/widgets.py | 8 ++++---- 15 files changed, 24 insertions(+), 24 deletions(-) create mode 100644 tests/__init__.py rename test_engines.py => tests/test_engines.py (99%) rename test_export.py => tests/test_export.py (96%) create mode 100644 youtubeanalyzer/__init__.py rename youtube-analyzer.py => youtubeanalyzer/__main__.py (98%) rename chart.py => youtubeanalyzer/chart.py (99%) rename defines.py => youtubeanalyzer/defines.py (100%) rename engine.py => youtubeanalyzer/engine.py (99%) rename export.py => youtubeanalyzer/export.py (98%) rename model.py => youtubeanalyzer/model.py (100%) rename search.py => youtubeanalyzer/search.py (97%) rename settings.py => youtubeanalyzer/settings.py (100%) rename theme.py => youtubeanalyzer/theme.py (100%) rename widgets.py => youtubeanalyzer/widgets.py (99%) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index e8fdb3d..65a7e3e 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -37,5 +37,5 @@ jobs: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with unittest run: | - python test_engines.py - python test_export.py + python -m unittest tests.test_engines + python -m unittest tests.test_export diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test_engines.py b/tests/test_engines.py similarity index 99% rename from test_engines.py rename to tests/test_engines.py index 1635a74..b5f4810 100644 --- a/test_engines.py +++ b/tests/test_engines.py @@ -1,7 +1,7 @@ import unittest from datetime import timedelta -from model import (ResultFields, ResultTableModel) -from engine import ( +from youtubeanalyzer.model import (ResultFields, ResultTableModel) +from youtubeanalyzer.engine import ( timedelta_to_str, view_count_to_int, subcriber_count_to_int, YoutubeGrepEngine, YoutubeApiEngine) diff --git a/test_export.py b/tests/test_export.py similarity index 96% rename from test_export.py rename to tests/test_export.py index ff9cbee..4682aac 100644 --- a/test_export.py +++ b/tests/test_export.py @@ -1,11 +1,11 @@ import os import unittest from datetime import timedelta -from model import ( +from youtubeanalyzer.model import ( ResultTableModel, make_result_row ) -from export import ( +from youtubeanalyzer.export import ( export_to_xlsx, export_to_csv, export_to_html diff --git a/youtubeanalyzer/__init__.py b/youtubeanalyzer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/youtube-analyzer.py b/youtubeanalyzer/__main__.py similarity index 98% rename from youtube-analyzer.py rename to youtubeanalyzer/__main__.py index b4c5e69..688286c 100644 --- a/youtube-analyzer.py +++ b/youtubeanalyzer/__main__.py @@ -29,24 +29,24 @@ QTabWidget, QToolButton ) -from defines import ( +from youtubeanalyzer.defines import ( app_name, version ) -from theme import ( +from youtubeanalyzer.theme import ( Theme ) -from settings import ( +from youtubeanalyzer.settings import ( Settings, SettingsDialog ) -from widgets import ( +from youtubeanalyzer.widgets import ( TabWidget ) -from search import ( +from youtubeanalyzer.search import ( SearchWorkspaceFactory ) -from export import ( +from youtubeanalyzer.export import ( export_to_xlsx, export_to_csv, export_to_html diff --git a/chart.py b/youtubeanalyzer/chart.py similarity index 99% rename from chart.py rename to youtubeanalyzer/chart.py index 5ac0a96..53b6c61 100644 --- a/chart.py +++ b/youtubeanalyzer/chart.py @@ -14,7 +14,7 @@ QBarCategoryAxis, QChart ) -from model import ( +from youtubeanalyzer.model import ( ResultFields, ResultTableModel ) diff --git a/defines.py b/youtubeanalyzer/defines.py similarity index 100% rename from defines.py rename to youtubeanalyzer/defines.py diff --git a/engine.py b/youtubeanalyzer/engine.py similarity index 99% rename from engine.py rename to youtubeanalyzer/engine.py index fa91839..0d5a9f1 100644 --- a/engine.py +++ b/youtubeanalyzer/engine.py @@ -21,7 +21,7 @@ Video ) import googleapiclient.discovery -from model import ( +from youtubeanalyzer.model import ( make_result_row, ResultFields, ResultTableModel, diff --git a/export.py b/youtubeanalyzer/export.py similarity index 98% rename from export.py rename to youtubeanalyzer/export.py index 3d45e0c..257bb52 100644 --- a/export.py +++ b/youtubeanalyzer/export.py @@ -1,6 +1,6 @@ import xlsxwriter import csv -from model import ( +from youtubeanalyzer.model import ( ResultFields, ResultTableModel ) diff --git a/model.py b/youtubeanalyzer/model.py similarity index 100% rename from model.py rename to youtubeanalyzer/model.py diff --git a/search.py b/youtubeanalyzer/search.py similarity index 97% rename from search.py rename to youtubeanalyzer/search.py index 238716c..d146297 100644 --- a/search.py +++ b/youtubeanalyzer/search.py @@ -20,21 +20,21 @@ from PySide6.QtCharts import ( QChart ) -from theme import ( +from youtubeanalyzer.theme import ( Theme ) -from settings import ( +from youtubeanalyzer.settings import ( Settings ) -from engine import ( +from youtubeanalyzer.engine import ( YoutubeApiEngine, YoutubeGrepEngine ) -from model import ( +from youtubeanalyzer.model import ( ResultFields, ResultTableModel ) -from widgets import ( +from youtubeanalyzer.widgets import ( SearchLineEdit, TabWorkspaceFactory, VideoDetailsWidget, diff --git a/settings.py b/youtubeanalyzer/settings.py similarity index 100% rename from settings.py rename to youtubeanalyzer/settings.py diff --git a/theme.py b/youtubeanalyzer/theme.py similarity index 100% rename from theme.py rename to youtubeanalyzer/theme.py diff --git a/widgets.py b/youtubeanalyzer/widgets.py similarity index 99% rename from widgets.py rename to youtubeanalyzer/widgets.py index f20460e..b3bc9a7 100644 --- a/widgets.py +++ b/youtubeanalyzer/widgets.py @@ -30,18 +30,18 @@ from PySide6.QtCharts import ( QChartView ) -from settings import ( +from youtubeanalyzer.settings import ( Settings ) -from engine import ( +from youtubeanalyzer.engine import ( ImageDownloader, SearchAutocompleteDownloader ) -from model import ( +from youtubeanalyzer.model import ( ResultFields, ResultTableModel ) -from chart import ( +from youtubeanalyzer.chart import ( ChannelsPieChart, VideoDurationChart, WordsPieChart From 9715dcabfe57c4e358eee78861a4ee033a6eea50 Mon Sep 17 00:00:00 2001 From: trots Date: Sat, 21 Dec 2024 16:24:46 +0300 Subject: [PATCH 03/13] Add settings upgrade from previous version --- tests/data/settings_version_1.ini | 14 ++++++++ tests/test_settings.py | 59 +++++++++++++++++++++++++++++++ youtubeanalyzer/search.py | 4 ++- youtubeanalyzer/settings.py | 40 ++++++++++++++++++--- youtubeanalyzer/widgets.py | 2 +- 5 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 tests/data/settings_version_1.ini create mode 100644 tests/test_settings.py diff --git a/tests/data/settings_version_1.ini b/tests/data/settings_version_1.ini new file mode 100644 index 0000000..b2d8276 --- /dev/null +++ b/tests/data/settings_version_1.ini @@ -0,0 +1,14 @@ +[General] +request_limit=30 +dont_ask_again_exit=0 +main_window_geometry=@ByteArray(\x1\xd9\xd0\xcb\0\x3\0\0\0\0\0\0\xff\xff\xff\xf8\0\0\a\x7f\0\0\x4\a\0\0\x1 \0\0\0\x8a\0\0\x6_\0\0\x3}\0\0\0\0\x2\0\0\0\a\x80\0\0\0\0\0\0\0\x17\0\0\a\x7f\0\0\x4\a) +main_splitter_state=1279, 619 +details=true +youtube_api_key=Abcd1234 +language=Ru +theme=0 +analytics_follow_table_select=true +request_timeout_sec=10 +last_active_chart_index=2 +main_table_header_state=@ByteArray(\0\0\0\xff\0\0\0\0\0\0\0\x1\0\0\0\0\0\0\0\x2\x1\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x5\xca\0\0\0\a\x1\x1\x1\0\0\0\0\0\0\0\0\0\0\0\0\0\x64\xff\xff\xff\xff\0\0\0\x84\0\0\0\0\0\0\0\a\0\0\x1\xfb\0\0\0\x1\0\0\0\0\0\0\0\x8b\0\0\0\x1\0\0\0\0\0\0\0\x92\0\0\0\x1\0\0\0\0\0\0\0\xa7\0\0\0\x1\0\0\0\0\0\0\0\xaf\0\0\0\x1\0\0\0\0\0\0\0\xb0\0\0\0\x1\0\0\0\0\0\0\0\xac\0\0\0\x1\0\0\0\0\0\0\x3\xe8\0\0\0\0\0\0\0\0\0) +last_active_details_tab=1 diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..b3b0e98 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,59 @@ +import os +import shutil +import unittest +from youtubeanalyzer.settings import ( + Settings, + CurrentSettingsVersion +) + + +class TestSettingsModule(unittest.TestCase): + + def test_settings_keys_persistance(self): + self.assertEqual(Settings.MainWindowGeometry.key, "main_window_geometry") + self.assertEqual(Settings.RequestLimit.key, "request_limit") + self.assertEqual(Settings.LastSaveDir.key, "last_save_dir") + self.assertEqual(Settings.DontAskAgainExit.key, "dont_ask_again_exit") + self.assertEqual(Settings.YouTubeApiKey.key, "youtube_api_key") + self.assertEqual(Settings.Language.key, "language") + self.assertEqual(Settings.Theme.key, "theme") + self.assertEqual(Settings.MainSplitterState.key, "main_splitter_state") + self.assertEqual(Settings.DetailsVisible.key, "details") + self.assertEqual(Settings.LastActiveDetailsTab.key, "last_active_details_tab") + self.assertEqual(Settings.AnalyticsFollowTableSelect.key, "analytics_follow_table_select") + self.assertEqual(Settings.LastActiveChartIndex.key, "last_active_chart_index") + self.assertEqual(Settings.RequestTimeoutSec.key, "request_timeout_sec") + self.assertEqual(Settings.MainTableHeaderState.key, "main_table_header_state") + self.assertEqual(Settings.MainTabsArray.key, "main_tabs") + self.assertEqual(Settings.TabWorkspaceIndex.key, "tab_workspace_index") + self.assertEqual(Settings.ActiveTabIndex.key, "active_tab_index") + + def test_upgrade_from_version_1(self): + etalon_test_file = "tests/data/settings_version_1.ini" + target_test_file = "settings.ini" + shutil.copyfile(etalon_test_file, target_test_file) + settings = Settings("test", target_test_file) + # Check removed keys + self.assertEqual(int(settings.get(Settings.RequestLimit)), 10) + self.assertEqual(int(settings.get(Settings.LastActiveChartIndex)), 0) + self.assertEqual(int(settings.get(Settings.LastActiveDetailsTab)), 0) + self.assertTrue(settings.get(Settings.MainTableHeaderState).isEmpty()) + # Check main tab converting + settings.begin_read_array(Settings.MainTabsArray) + settings.set_array_index(0) + self.assertEqual(int(settings.get(Settings.RequestLimit)), 30) + self.assertEqual(int(settings.get(Settings.LastActiveChartIndex)), 2) + self.assertEqual(int(settings.get(Settings.LastActiveDetailsTab)), 1) + self.assertFalse(settings.get(Settings.MainTableHeaderState).isEmpty()) + settings.end_array() + self.assertEqual(int(settings.get(Settings.ActiveTabIndex)), 0) + # Check version + self.assertEqual(int(settings.get(Settings.Version)), CurrentSettingsVersion) + + @classmethod + def tearDownClass(cls): + os.remove("settings.ini") + + +if __name__ == "__main__": + unittest.main() diff --git a/youtubeanalyzer/search.py b/youtubeanalyzer/search.py index d146297..d96fded 100644 --- a/youtubeanalyzer/search.py +++ b/youtubeanalyzer/search.py @@ -95,6 +95,8 @@ def __init__(self, settings: Settings, parent: QWidget = None): self._main_splitter.addWidget(self._table_view) self._main_splitter.addWidget(self._side_tab_widget) self._main_splitter.setCollapsible(0, False) + self._main_splitter.setStretchFactor(0, 3) + self._main_splitter.setStretchFactor(1, 1) v_layout = QVBoxLayout() v_layout.addLayout(h_layout) @@ -104,7 +106,7 @@ def __init__(self, settings: Settings, parent: QWidget = None): def load_state(self): # Restore main splitter splitter_state = self._settings.get(Settings.MainSplitterState) - if not splitter_state.isEmpty(): + if splitter_state and not splitter_state.isEmpty(): self._main_splitter.restoreState(splitter_state) # Restore main table table_header_state = self._settings.get(Settings.MainTableHeaderState) diff --git a/youtubeanalyzer/settings.py b/youtubeanalyzer/settings.py index a5181fd..1c3d4fc 100644 --- a/youtubeanalyzer/settings.py +++ b/youtubeanalyzer/settings.py @@ -19,6 +19,9 @@ ) +CurrentSettingsVersion = 1 + + @dataclass class SettingsKey: key: str @@ -33,7 +36,7 @@ class Settings: YouTubeApiKey = SettingsKey("youtube_api_key", "") Language = SettingsKey("language", "") Theme = SettingsKey("theme", 0) - MainSplitterState = SettingsKey("main_splitter_state", [0, 0]) + MainSplitterState = SettingsKey("main_splitter_state", QByteArray()) DetailsVisible = SettingsKey("details", True) # Not used LastActiveDetailsTab = SettingsKey("last_active_details_tab", 0) AnalyticsFollowTableSelect = SettingsKey("analytics_follow_table_select", True) @@ -43,11 +46,15 @@ class Settings: MainTabsArray = SettingsKey("main_tabs", 0) TabWorkspaceIndex = SettingsKey("tab_workspace_index", -1) ActiveTabIndex = SettingsKey("active_tab_index", 0) + Version = SettingsKey("version", CurrentSettingsVersion) - def __init__(self, app_name: str): - self._impl = QSettings(QSettings.Format.IniFormat, QSettings.Scope.UserScope, app_name) + def __init__(self, app_name: str, filename: str = None): + if filename: + self._impl = QSettings(filename, QSettings.Format.IniFormat) + else: + self._impl = QSettings(QSettings.Format.IniFormat, QSettings.Scope.UserScope, app_name) print(self._impl.fileName()) - # TODO: add settings converter for a new version on the first run + self._upgrade_settings() def get(self, key: SettingsKey): if type(key.default_value) is bool: @@ -69,6 +76,31 @@ def set_array_index(self, index: int): def end_array(self): self._impl.endArray() + def _upgrade_settings(self): + if self._impl.contains(Settings.RequestLimit.key) and not self._impl.contains(Settings.Version.key): + # Need to upgrade to version 1 + request_limit = self.get(Settings.RequestLimit) + self._impl.remove(Settings.RequestLimit.key) + last_active_chart_index = self.get(Settings.LastActiveChartIndex) + self._impl.remove(Settings.LastActiveChartIndex.key) + last_active_details_tab = self.get(Settings.LastActiveDetailsTab) + self._impl.remove(Settings.LastActiveDetailsTab.key) + main_table_header_state = self.get(Settings.MainTableHeaderState) + self._impl.remove(Settings.MainTableHeaderState.key) + self._impl.remove(Settings.MainSplitterState.key) + + self.begin_write_array(Settings.MainTabsArray) + self.set_array_index(0) + self.set(Settings.TabWorkspaceIndex, 0) + self.set(Settings.RequestLimit, request_limit) + self.set(Settings.LastActiveChartIndex, last_active_chart_index) + self.set(Settings.MainTableHeaderState, main_table_header_state) + self.set(Settings.LastActiveDetailsTab, last_active_details_tab) + self.end_array() + self.set(Settings.ActiveTabIndex, 0) + + self.set(Settings.Version, CurrentSettingsVersion) + class GeneralTab(QWidget): language_changed = Signal() diff --git a/youtubeanalyzer/widgets.py b/youtubeanalyzer/widgets.py index b3bc9a7..59da603 100644 --- a/youtubeanalyzer/widgets.py +++ b/youtubeanalyzer/widgets.py @@ -379,7 +379,7 @@ def save_state(self): if self._current_workspace_index >= 0: workspace_widget = self._main_stacked_layout.currentWidget() workspace_widget.save_state() - + def handle_preferences_change(self): workspace = self.current_workspace() if workspace: From 3150d677119fc8b389e76159ad160c4cd6d222e1 Mon Sep 17 00:00:00 2001 From: trots Date: Wed, 1 Jan 2025 17:55:05 +0300 Subject: [PATCH 04/13] Add trends tab First implementation --- requirements.txt | Bin 1718 -> 1068 bytes youtubeanalyzer/__main__.py | 4 + youtubeanalyzer/engine.py | 125 ++++++++++++++++- youtubeanalyzer/model.py | 6 + youtubeanalyzer/search.py | 39 +++--- youtubeanalyzer/settings.py | 2 + youtubeanalyzer/trends.py | 262 ++++++++++++++++++++++++++++++++++++ youtubeanalyzer/widgets.py | 27 +++- 8 files changed, 434 insertions(+), 31 deletions(-) create mode 100644 youtubeanalyzer/trends.py diff --git a/requirements.txt b/requirements.txt index 2522b20a4a3af7a954a0b60e5c72af270b4ff90c..393865f16b49e572ecdcc6b44c593a38667454df 100644 GIT binary patch literal 1068 zcmY*Yxo+Gr5bgOdB38%BcA+9gng9s`xJX*AL`jpSNQTGS^4I4LwKg1J8;)k)%sW<@ zv;^hXVK~Y9L3Tpfo!l^VvXLh-s&UoHITMHBEL+)#QHMk`4MTHkT3Oc^662~u)Ty#A zY|7A^4#UtM-Ojk6HpS?j?8WGAI;cet!-E8#m?$Pg7yjJJM=_Jp#*J4VukjO$bBocH z@>Gp3V62h#8FziE`#r7BR-++pQ)#HXPaQddS|ghtx4%!JR;?*{Y;JfpGd;dxQN*Tg z1ELQuIq@j` zg)1j6L^r7fq9@sd|2MLK6mEnTP-}x5uE+)1dlbH_aLxW%&4)5@??+&^_nffgUxN^gYaaGdjZ6uk~b>NaCxEtW$qXz zFH70N&J@)yc`gY$GvuzKEz+Gq=y+9|o3hb>VBayck1pF3fYKl)rH^b(${5ZPjxS9g zrD}EV%0^*^(}Q!54v!!fHY^x#^YlWtg@R4fil6&4O}b}5-f@3cU#7`f-12|*Ym5jK zDUSZ}1xeEp(mmql4%m0(_SC;Wsxj^UpoPeZp#Nq}004Hg+#$l6PCf}(kp2CC)jt)l z;z}2H)%N4ONJvmI`Xr*IdFI?82EfUIlHS-1c*QQ%C>bQ9Tow7s!9vSDj} poA~;RCm|{8^CWhcQ@&^2qW6th76QzqPmaMv)(OvV+wgz181m`nvJhTG6%gwyIL= zQ(5Pz_d?7aNrkoItq=!$&=);s%AYx+RIlh4$h|7IszSv=oooGe)ZZ#){_;BHQ#;q+ zDAoxNX65ynf%pe!N!Lobm8z}n(Gf(;kpZ1|&VT2)Lj4-$6hhN~>lsX48k5EB*E1Sv z)k~3D+{uU2RzLTX8l{wdt;*opp%F?igq-;l|Mm-Hp=B!nx9yiEV7OIwb?lpUPu05R~^`B}*HaiIB2@(65GMv_rDSn=N#O$HBJ6vq?H-iB^Zf8#Fl&^T+;QnknB9$4a1Y*j@%(a} zEGdKXFnACGui{1=Og;7-THvv)@yZ6_6JqABN{mjb)`@La8_%VQaZBz+wN5ACz zPz^6KH@CqBfor`h8lmI=a5#&bepG&30xoVWkwI6IkcD9-Fz?YOhV|BEnR72Sx* z;>;v(>@r^1XCXJ@zznDb@^$pRP`n)QJov=dvf%mR4CnGEll9Jgsv+BteQ^Cx(wJyJ zvLHI2?3Ibbo2c>8T@x?ZAVn@;#)aZ>8}kvj7nham4D)NMpl94gyp<>ro5Lr(s(3iw zXApU_gYS#$IQk2bFC{rMd<71Fz+~=9Gh=q{vm(6UgR2V9=MfcGo?pf9s?v$=w{z!k jT=GGva}rzLuR;B`+5u|e9R8OSVw&GjwhoWjJXiD=GQRys diff --git a/youtubeanalyzer/__main__.py b/youtubeanalyzer/__main__.py index 688286c..e3ac4e8 100644 --- a/youtubeanalyzer/__main__.py +++ b/youtubeanalyzer/__main__.py @@ -46,6 +46,9 @@ from youtubeanalyzer.search import ( SearchWorkspaceFactory ) +from youtubeanalyzer.trends import ( + TrendsWorkspaceFactory +) from youtubeanalyzer.export import ( export_to_xlsx, export_to_csv, @@ -329,6 +332,7 @@ def top_exception_handler(_exctype, value, tb): Theme.apply(app, int(settings.get(Settings.Theme))) TabWidget.workspace_factories.append(SearchWorkspaceFactory()) +TabWidget.workspace_factories.append(TrendsWorkspaceFactory()) # ^^^ Don't change the factories order. Append new factories to the end of list while True: diff --git a/youtubeanalyzer/engine.py b/youtubeanalyzer/engine.py index 0d5a9f1..a1aa07a 100644 --- a/youtubeanalyzer/engine.py +++ b/youtubeanalyzer/engine.py @@ -25,7 +25,8 @@ make_result_row, ResultFields, ResultTableModel, - DataCache + DataCache, + VideoCategory ) @@ -149,7 +150,8 @@ def _handle_finished(self, reply: QNetworkReply): class AbstractYoutubeEngine: def __init__(self, model: ResultTableModel, request_limit: int, request_timeout_sec: int = 10): - self.error = "" + self.errorDetails = None + self.errorReason = None self._model = model self._request_limit = request_limit self._request_timeout_sec = request_timeout_sec @@ -160,14 +162,18 @@ def set_request_timeout_sec(self, value_sec: int): def search(self, request_text: str): pass + def get_video_categories(self): + pass + class YoutubeGrepEngine(AbstractYoutubeEngine): def __init__(self, model: ResultTableModel, request_limit: int): super().__init__(model, request_limit) def search(self, request_text: str): + self.errorDetails = None + self.errorReason = None try: - self.error = "" videos_search = self._create_video_searcher(request_text) result = [] has_next_page = True @@ -201,7 +207,7 @@ def search(self, request_text: str): return True except Exception as exc: print(exc) - self.error = traceback.format_exc() + self.errorDetails = traceback.format_exc() return False @staticmethod @@ -264,6 +270,8 @@ def __init__(self, model: ResultTableModel, request_limit: int, api_key: str): self._api_key = api_key def search(self, request_text: str): + self.errorDetails = None + self.errorReason = None try: youtube = self._create_youtube_client() search_response = self._search_videos(youtube, request_text) @@ -320,7 +328,114 @@ def search(self, request_text: str): self._model.setData(result) return True except Exception as e: - self.error = str(e) + self.errorDetails = str(e) + return False + + def get_video_categories(self, region_code: str = "US", output_language="en_US"): + self.errorDetails = None + self.errorReason = None + try: + youtube = self._create_youtube_client() + request = youtube.videoCategories().list( + part="snippet", + regionCode=region_code, + hl=output_language + ) + response = request.execute() + categories = [] + for item in response["items"]: + snippet = item["snippet"] + categories.append(VideoCategory(item["id"], snippet["title"])) + return categories + except Exception as e: + self.errorDetails = str(e) + return [] + + def search_trends(self, category_id: int, request_limit: int, region_code: str = "US", results_per_page: int = 10): + self.errorDetails = None + self.errorReason = None + try: + results_per_page = min(request_limit, results_per_page) + youtube = self._create_youtube_client() + page_token = "" + total_count = 0 + result = [] + + while True: + request = youtube.videos().list( + part="snippet,contentDetails,statistics", + chart="mostPopular", + regionCode=region_code, + videoCategoryId=category_id, + maxResults=results_per_page, + pageToken=page_token + ) + response = request.execute() + + video_ids = "" + channel_ids = "" + for search_item in response["items"]: + video_ids = video_ids + "," + search_item["id"] + channel_id = search_item["snippet"]["channelId"] + if channel_id in channel_ids: + continue + channel_ids = channel_ids + "," + channel_id + video_ids = video_ids[1:] # Remove first comma + channel_ids = channel_ids[1:] # Remove first comma + + video_response = self._get_video_details(youtube, video_ids) + channel_response = self._get_channel_details(youtube, channel_ids) + channels = {} + for channel_item in channel_response["items"]: + channels[channel_item["id"]] = channel_item + + count = 0 + for search_item in response["items"]: + video_item = video_response["items"][count] + search_snippet = search_item["snippet"] + content_details = search_item["contentDetails"] + statistics = search_item["statistics"] + video_title = search_snippet["title"] + video_published_time = str(datetime.strptime(search_snippet["publishedAt"], "%Y-%m-%dT%H:%M:%SZ")) + video_duration_td = timedelta(seconds=isodate.parse_duration(content_details["duration"]).total_seconds()) + video_duration = timedelta_to_str(video_duration_td) + views = int(statistics["viewCount"]) + video_link = "https://www.youtube.com/watch?v=" + search_item["id"] + channel_title = search_snippet["channelTitle"] + channel_url = "https://www.youtube.com/channel/" + search_snippet["channelId"] + channel_item = channels[search_snippet["channelId"]] + channel_subscribers = int(channel_item["statistics"]["subscriberCount"]) + channel_views = int(channel_item["statistics"]["viewCount"]) + channel_joined_date = "" + video_preview_link = search_snippet["thumbnails"]["high"]["url"] + channel_snippet = channel_item["snippet"] + channel_logo_link = channel_snippet["thumbnails"]["default"]["url"] + channel_logo_link = channel_logo_link.replace("https", "http") # https is not working. I don't know why + video_snippet = video_item["snippet"] + tags = video_snippet["tags"] if "tags" in video_snippet else None + result.append( + make_result_row(video_title, video_published_time, video_duration, views, + video_link, channel_title, channel_url, channel_subscribers, + channel_views, channel_joined_date, video_preview_link, channel_logo_link, tags, + video_duration_td, total_count)) + count = count + 1 + total_count = total_count + 1 + + if total_count >= request_limit: + break + + if total_count >= request_limit: + break + + if response["nextPageToken"]: + page_token = response["nextPageToken"] + + self._model.setData(result) + return True + except Exception as e: + if hasattr(e, "reason"): + self.errorReason = e.reason + self.errorDetails = str(e) return False def _create_youtube_client(self): diff --git a/youtubeanalyzer/model.py b/youtubeanalyzer/model.py index c1e6be3..06a3c36 100644 --- a/youtubeanalyzer/model.py +++ b/youtubeanalyzer/model.py @@ -171,3 +171,9 @@ def get_image(self, url): def clear(self): self._images.clear() + + +class VideoCategory: + def __init__(self, id: int, text: str): + self.id = id + self.text = text diff --git a/youtubeanalyzer/search.py b/youtubeanalyzer/search.py index d96fded..e1a0b1f 100644 --- a/youtubeanalyzer/search.py +++ b/youtubeanalyzer/search.py @@ -8,18 +8,18 @@ QHBoxLayout, QVBoxLayout, QSplitter, - QSizePolicy, QWidget, - QLabel, QPushButton, QSpinBox, QTableView, - QTabWidget, - QMessageBox + QTabWidget ) from PySide6.QtCharts import ( QChart ) +from youtubeanalyzer.defines import ( + app_name +) from youtubeanalyzer.theme import ( Theme ) @@ -35,6 +35,8 @@ ResultTableModel ) from youtubeanalyzer.widgets import ( + critial_detailed_message, + create_link_label, SearchLineEdit, TabWorkspaceFactory, VideoDetailsWidget, @@ -57,8 +59,7 @@ def __init__(self, settings: Settings, parent: QWidget = None): self._search_limit_spin_box.setToolTip(self.tr("Set the search result limit")) self._search_limit_spin_box.setMinimumWidth(50) self._search_limit_spin_box.setRange(2, 30) - request_limit = int(self._settings.get(Settings.RequestLimit)) - self._search_limit_spin_box.setValue(request_limit) + self._search_limit_spin_box.setValue(10) h_layout.addWidget(self._search_limit_spin_box) self._search_button = QPushButton(self.tr("Search")) self._search_button.setToolTip(self.tr("Click to start searching")) @@ -104,6 +105,8 @@ def __init__(self, settings: Settings, parent: QWidget = None): self.setLayout(v_layout) def load_state(self): + request_limit = int(self._settings.get(Settings.RequestLimit)) + self._search_limit_spin_box.setValue(request_limit) # Restore main splitter splitter_state = self._settings.get(Settings.MainSplitterState) if splitter_state and not splitter_state.isEmpty(): @@ -153,22 +156,19 @@ def _on_search_clicked(self): for i in range(len(self.model.result)): video_idx = self._sort_model.index(i, self.model.get_field_column(ResultFields.VideoTitle)) video_item = self.model.result[i] - video_label = self._create_link_label(video_item[ResultFields.VideoLink], video_item[ResultFields.VideoTitle]) + video_label = create_link_label(video_item[ResultFields.VideoLink], video_item[ResultFields.VideoTitle]) self._table_view.setIndexWidget(video_idx, video_label) channel_idx = self._sort_model.index(i, self.model.get_field_column(ResultFields.ChannelTitle)) - channel_label = self._create_link_label(video_item[ResultFields.ChannelLink], - video_item[ResultFields.ChannelTitle]) + channel_label = create_link_label(video_item[ResultFields.ChannelLink], video_item[ResultFields.ChannelTitle]) self._table_view.setIndexWidget(channel_idx, channel_label) self._table_view.resizeColumnsToContents() else: - dialog = QMessageBox() - dialog.setWindowTitle(self.tr("Error")) - dialog.setText(self.tr("Error in the searching process")) - dialog.setIcon(QMessageBox.Critical) - dialog.setDetailedText(engine.error) - dialog.exec() + text = self.tr("Error in the searching process") + if engine.errorReason is not None: + text += ": " + engine.errorReason + critial_detailed_message(self, app_name, text, engine.errorDetails) QApplication.restoreOverrideCursor() self._search_line_edit.setDisabled(False) @@ -194,15 +194,6 @@ def _create_engine(self): else: return YoutubeApiEngine(self.model, request_limit, api_key) - def _create_link_label(self, link: str, text: str): - label = QLabel("" + text + "") - label_size_policy = label.sizePolicy() - label_size_policy.setHorizontalPolicy(QSizePolicy.Policy.Expanding) - label.setSizePolicy(label_size_policy) - label.setTextFormat(Qt.TextFormat.RichText) - label.setOpenExternalLinks(True) - return label - class SearchWorkspaceFactory(TabWorkspaceFactory): def __init__(self, parent: QWidget = None): diff --git a/youtubeanalyzer/settings.py b/youtubeanalyzer/settings.py index 1c3d4fc..2bcb4dd 100644 --- a/youtubeanalyzer/settings.py +++ b/youtubeanalyzer/settings.py @@ -47,6 +47,8 @@ class Settings: TabWorkspaceIndex = SettingsKey("tab_workspace_index", -1) ActiveTabIndex = SettingsKey("active_tab_index", 0) Version = SettingsKey("version", CurrentSettingsVersion) + TrendsRegion = SettingsKey("trends_region", "US") + TrendsVideoCategoryId = SettingsKey("trends_video_category_id", 0) def __init__(self, app_name: str, filename: str = None): if filename: diff --git a/youtubeanalyzer/trends.py b/youtubeanalyzer/trends.py new file mode 100644 index 0000000..52b8658 --- /dev/null +++ b/youtubeanalyzer/trends.py @@ -0,0 +1,262 @@ +import pycountry +from PySide6.QtCore import ( + Qt, + QSortFilterProxyModel, + QItemSelection, + QTimer +) +from PySide6.QtWidgets import ( + QApplication, + QHBoxLayout, + QVBoxLayout, + QSplitter, + QWidget, + QLabel, + QPushButton, + QComboBox, + QTableView, + QTabWidget, + QMessageBox, + QSpinBox +) +from PySide6.QtCharts import ( + QChart +) +from youtubeanalyzer.defines import ( + app_name +) +from youtubeanalyzer.theme import ( + Theme +) +from youtubeanalyzer.settings import ( + Settings +) +from youtubeanalyzer.engine import ( + YoutubeApiEngine +) +from youtubeanalyzer.model import ( + ResultFields, + ResultTableModel +) +from youtubeanalyzer.widgets import ( + create_link_label, + critial_detailed_message, + TabWorkspaceFactory, + VideoDetailsWidget, + AnalyticsWidget +) + + +class TrendsWorkspace(QWidget): + def __init__(self, settings: Settings, parent: QWidget = None): + super().__init__(parent) + + self._settings = settings + self._selected_category = None + self._loaded_category_id = None + + h_layout = QHBoxLayout() + h_layout.addWidget(QLabel(self.tr("Category:"))) + self._category_combo_box = QComboBox() + h_layout.addWidget(self._category_combo_box, 1) + h_layout.addSpacing(10) + h_layout.addWidget(QLabel(self.tr("Region:"))) + self._region_combo_box = QComboBox() + for country in pycountry.countries: + self._region_combo_box.addItem(country.name, country.alpha_2) + h_layout.addWidget(self._region_combo_box) + self._region_combo_box.currentIndexChanged.connect(self._update_categories) + + h_layout.addStretch(2) + self._request_limit_spin_box = QSpinBox() + self._request_limit_spin_box.setToolTip(self.tr("Set the result limit")) + self._request_limit_spin_box.setMinimumWidth(50) + self._request_limit_spin_box.setRange(2, 200) + self._request_limit_spin_box.setValue(10) + h_layout.addWidget(self._request_limit_spin_box) + + h_layout.addStretch() + self._show_button = QPushButton(self.tr("Show")) + self._show_button.setToolTip(self.tr("Click to show trends")) + self._show_button.clicked.connect(self._on_show_trends_clicked) + h_layout.addWidget(self._show_button) + + self.model = ResultTableModel(self) + self._sort_model = QSortFilterProxyModel(self) + self._sort_model.setSortRole(ResultTableModel.SortRole) + self._sort_model.setSourceModel(self.model) + self._table_view = QTableView(self) + self._table_view.setModel(self._sort_model) + self._table_view.setSelectionMode(QTableView.SelectionMode.SingleSelection) + self._table_view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) + self._table_view.setSortingEnabled(True) + self._table_view.horizontalHeader().setSectionsMovable(True) + self._table_view.selectionModel().selectionChanged.connect(self._on_table_row_changed) + + self._side_tab_widget = QTabWidget() + + self._details_widget = VideoDetailsWidget(self.model, self) + self._side_tab_widget.addTab(self._details_widget, self.tr("Details")) + + self._analytics_widget = AnalyticsWidget(self.model, self) + self._side_tab_widget.addTab(self._analytics_widget, self.tr("Analytics")) + self._analytics_widget.set_current_index_following(self._settings.get(Settings.AnalyticsFollowTableSelect)) + if int(self._settings.get(Settings.Theme)) == Theme.Dark: + self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeDark) + else: + self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeLight) + + self._main_splitter = QSplitter(Qt.Orientation.Horizontal) + self._main_splitter.addWidget(self._table_view) + self._main_splitter.addWidget(self._side_tab_widget) + self._main_splitter.setCollapsible(0, False) + self._main_splitter.setStretchFactor(0, 3) + self._main_splitter.setStretchFactor(1, 1) + + v_layout = QVBoxLayout() + v_layout.addLayout(h_layout) + v_layout.addWidget(self._main_splitter) + self.setLayout(v_layout) + + QTimer.singleShot(0, self, self._update_categories) + + def _update_categories(self): + self._category_combo_box.clear() + api_key = self._settings.get(Settings.YouTubeApiKey) + if not api_key: + return + + engine = YoutubeApiEngine(None, 0, api_key) + region_code = self._region_combo_box.currentData() + if not region_code: + region_code = "US" + categories_lang = "ru_RU" if self._settings.get(Settings.Language) == "Ru" else "en_US" + categories = engine.get_video_categories(region_code, categories_lang) + if len(categories) == 0: + critial_detailed_message(self, app_name, self.tr("Unable to get video categories"), engine.errorDetails) + for category in categories: + self._category_combo_box.addItem(category.text, category.id) + if self._loaded_category_id is not None: + index = self._category_combo_box.findData(self._loaded_category_id) + if index >= 0: + self._category_combo_box.setCurrentIndex(index) + self._loaded_category_id = None + + def load_state(self): + request_limit = int(self._settings.get(Settings.RequestLimit)) + self._request_limit_spin_box.setValue(request_limit) + # Restore main splitter + splitter_state = self._settings.get(Settings.MainSplitterState) + if splitter_state and not splitter_state.isEmpty(): + self._main_splitter.restoreState(splitter_state) + # Restore main table + table_header_state = self._settings.get(Settings.MainTableHeaderState) + if not table_header_state.isEmpty(): + self._table_view.horizontalHeader().restoreState(table_header_state) + self._table_view.resizeColumnsToContents() + self._analytics_widget.set_current_chart_index(int(self._settings.get(Settings.LastActiveChartIndex))) + index = self._region_combo_box.findData(self._settings.get(Settings.TrendsRegion)) + if index >= 0: + self._region_combo_box.setCurrentIndex(index) + self._loaded_category_id = int(self._settings.get(Settings.TrendsVideoCategoryId)) + + def save_state(self): + self._settings.set(Settings.RequestLimit, self._request_limit_spin_box.value()) + self._settings.set(Settings.LastActiveChartIndex, self._analytics_widget.get_current_chart_index()) + self._settings.set(Settings.MainTableHeaderState, self._table_view.horizontalHeader().saveState()) + self._settings.set(Settings.MainSplitterState, self._main_splitter.saveState()) + self._settings.set(Settings.LastActiveDetailsTab, self._side_tab_widget.currentIndex()) + self._settings.set(Settings.TrendsRegion, self._region_combo_box.currentData()) + if self._category_combo_box.currentData(): + self._settings.set(Settings.TrendsVideoCategoryId, int(self._category_combo_box.currentData())) + + def handle_preferences_change(self): + if int(self._settings.get(Settings.Theme)) == Theme.Dark: + self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeDark) + else: + self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeLight) + + self._analytics_widget.set_current_index_following(self._settings.get(Settings.AnalyticsFollowTableSelect)) + if self._settings.get(Settings.AnalyticsFollowTableSelect): + self._analytics_widget.set_current_index(self._table_view.currentIndex()) + + def _on_show_trends_clicked(self): + self.setDisabled(True) + QApplication.setOverrideCursor(Qt.CursorShape.BusyCursor) + + self.model.clear() + self._sort_model.sort(-1) + self._details_widget.clear() + QApplication.instance().processEvents() + + category_id = int(self._category_combo_box.currentData()) + if not category_id: + QApplication.restoreOverrideCursor() + self.setDisabled(False) + QMessageBox.critical(self, app_name, self.tr("Unable to show trends. Video category is not selected.")) + return + + region_code = self._region_combo_box.currentData() + if not region_code: + region_code = "US" + print("Region code is not set. Using 'US' by default") + + api_key = self._settings.get(Settings.YouTubeApiKey) + if not api_key: + QApplication.restoreOverrideCursor() + self.setDisabled(False) + QMessageBox.critical(self, app_name, self.tr("Unable to show trends. YouTube API key is not set. \ + Please set it in the preferences")) + return + + request_limit = self._request_limit_spin_box.value() + if not request_limit: + request_limit = 10 + print("Request limit is not set. Using '10' by default") + + engine = YoutubeApiEngine(self.model, 0, api_key) + if engine.search_trends(category_id, request_limit, region_code): + for i in range(len(self.model.result)): + video_idx = self._sort_model.index(i, self.model.get_field_column(ResultFields.VideoTitle)) + video_item = self.model.result[i] + video_label = create_link_label(video_item[ResultFields.VideoLink], video_item[ResultFields.VideoTitle]) + self._table_view.setIndexWidget(video_idx, video_label) + + channel_idx = self._sort_model.index(i, self.model.get_field_column(ResultFields.ChannelTitle)) + channel_label = create_link_label(video_item[ResultFields.ChannelLink], video_item[ResultFields.ChannelTitle]) + self._table_view.setIndexWidget(channel_idx, channel_label) + + self._table_view.resizeColumnsToContents() + else: + text = self.tr("Trends searching failed") + if engine.errorReason is not None: + text += ": " + engine.errorReason + critial_detailed_message(self, app_name, text, engine.errorDetails) + + QApplication.restoreOverrideCursor() + self.setDisabled(False) + + def _on_table_row_changed(self, current: QItemSelection, _previous: QItemSelection): + indexes = current.indexes() + if len(indexes) > 0: + index = self._sort_model.mapToSource(indexes[0]) + self._details_widget.set_current_index(index) + self._analytics_widget.set_current_index(index) + else: + self._details_widget.set_current_index(None) + self._analytics_widget.set_current_index(None) + + +class TrendsWorkspaceFactory(TabWorkspaceFactory): + def __init__(self, parent: QWidget = None): + super().__init__(parent) + + def get_workspace_name(self) -> str: + return "Trends" + + def create_workspace_button(self) -> QPushButton: + button = QPushButton("Show trends...") + return button + + def create_workspace_widget(self, settings: Settings, parent: QWidget) -> QWidget: + return TrendsWorkspace(settings, parent) diff --git a/youtubeanalyzer/widgets.py b/youtubeanalyzer/widgets.py index 59da603..56593e6 100644 --- a/youtubeanalyzer/widgets.py +++ b/youtubeanalyzer/widgets.py @@ -25,7 +25,9 @@ QLineEdit, QCompleter, QPushButton, - QTabWidget + QTabWidget, + QSizePolicy, + QMessageBox ) from PySide6.QtCharts import ( QChartView @@ -48,6 +50,25 @@ ) +def create_link_label(link: str, text: str): + label = QLabel("" + text + "") + label_size_policy = label.sizePolicy() + label_size_policy.setHorizontalPolicy(QSizePolicy.Policy.Expanding) + label.setSizePolicy(label_size_policy) + label.setTextFormat(Qt.TextFormat.RichText) + label.setOpenExternalLinks(True) + return label + + +def critial_detailed_message(parent, title, text, details_text): + dialog = QMessageBox(parent) + dialog.setIcon(QMessageBox.Critical) + dialog.setWindowTitle(title) + dialog.setText(text) + dialog.setDetailedText(details_text) + return dialog.exec() + + class PixmapLabel(QLabel): def __init__(self, parent=None): super().__init__(parent) @@ -360,10 +381,12 @@ def __init__(self, settings: Settings, parent_tab_widget: QTabWidget, parent: QW self._main_stacked_layout.setCurrentIndex(0) self.setLayout(self._main_stacked_layout) + self._main_layout.addStretch() for workspace_factory in TabWidget.workspace_factories: workspace_button = workspace_factory.create_workspace_button() workspace_button.clicked.connect(self._create_workspace) self._main_layout.addWidget(workspace_button, alignment=Qt.AlignmentFlag.AlignCenter) + self._main_layout.addStretch() def current_workspace(self): return self._main_stacked_layout.currentWidget() if self._main_stacked_layout.currentIndex() == 1 else None @@ -399,5 +422,5 @@ def create_workspace(self, workspace_index): def _create_workspace(self): workspace_button = self.sender() - workspace_index = self._main_layout.indexOf(workspace_button) + workspace_index = self._main_layout.indexOf(workspace_button) - 1 self.create_workspace(workspace_index) From 7aeb27796c5e9150a79477f618f6bdb5a5778fdd Mon Sep 17 00:00:00 2001 From: trots Date: Wed, 1 Jan 2025 18:12:00 +0300 Subject: [PATCH 05/13] Add page limits customization for YouTube requests --- youtubeanalyzer/settings.py | 13 +++++++++++-- youtubeanalyzer/trends.py | 7 ++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/youtubeanalyzer/settings.py b/youtubeanalyzer/settings.py index 2bcb4dd..629e348 100644 --- a/youtubeanalyzer/settings.py +++ b/youtubeanalyzer/settings.py @@ -49,6 +49,7 @@ class Settings: Version = SettingsKey("version", CurrentSettingsVersion) TrendsRegion = SettingsKey("trends_region", "US") TrendsVideoCategoryId = SettingsKey("trends_video_category_id", 0) + RequestPageLimit = SettingsKey("request_page_limit", 25) def __init__(self, app_name: str, filename: str = None): if filename: @@ -181,8 +182,15 @@ def __init__(self, settings: Settings, parent=None): self._settings = settings layout = QVBoxLayout() - timeout_label = QLabel(self.tr("Request timeout in seconds:")) - layout.addWidget(timeout_label) + layout.addWidget(QLabel(self.tr("Request page limit:"))) + self._request_page_limit_edit = QSpinBox() + self._request_page_limit_edit.setMinimum(5) + self._request_page_limit_edit.setMaximum(200) + self._request_page_limit_edit.setToolTip(self.tr("Set the maximum page size for YouTube requests")) + self._request_page_limit_edit.setValue(int(self._settings.get(Settings.RequestPageLimit))) + layout.addWidget(self._request_page_limit_edit) + + layout.addWidget(QLabel(self.tr("Request timeout in seconds:"))) self._request_timeout_sec_edit = QSpinBox() self._request_timeout_sec_edit.setMinimum(2) self._request_timeout_sec_edit.setMaximum(1000) @@ -194,6 +202,7 @@ def __init__(self, settings: Settings, parent=None): self.setLayout(layout) def save_settings(self): + self._settings.set(Settings.RequestPageLimit, self._request_page_limit_edit.value()) self._settings.set(Settings.RequestTimeoutSec, self._request_timeout_sec_edit.value()) diff --git a/youtubeanalyzer/trends.py b/youtubeanalyzer/trends.py index 52b8658..a83bdd5 100644 --- a/youtubeanalyzer/trends.py +++ b/youtubeanalyzer/trends.py @@ -214,8 +214,13 @@ def _on_show_trends_clicked(self): request_limit = 10 print("Request limit is not set. Using '10' by default") + request_page_limit = int(self._settings.get(Settings.RequestPageLimit)) + if not request_page_limit: + request_page_limit = 25 + print("Request page limit is not set. Using '25' by default") + engine = YoutubeApiEngine(self.model, 0, api_key) - if engine.search_trends(category_id, request_limit, region_code): + if engine.search_trends(category_id, request_limit, region_code, request_page_limit): for i in range(len(self.model.result)): video_idx = self._sort_model.index(i, self.model.get_field_column(ResultFields.VideoTitle)) video_item = self.model.result[i] From 9491ae55722605eddc7f55a0358a2ad8457da471 Mon Sep 17 00:00:00 2001 From: trots Date: Wed, 1 Jan 2025 18:13:07 +0300 Subject: [PATCH 06/13] Fix crash if request limit is higher than request results --- youtubeanalyzer/engine.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/youtubeanalyzer/engine.py b/youtubeanalyzer/engine.py index a1aa07a..5fe78fd 100644 --- a/youtubeanalyzer/engine.py +++ b/youtubeanalyzer/engine.py @@ -426,7 +426,8 @@ def search_trends(self, category_id: int, request_limit: int, region_code: str = if total_count >= request_limit: break - + if "nextPageToken" not in response: + break if response["nextPageToken"]: page_token = response["nextPageToken"] From 0c30c501badd12fe943450c5aeb10432e34cdb06 Mon Sep 17 00:00:00 2001 From: trots Date: Wed, 1 Jan 2025 21:54:26 +0300 Subject: [PATCH 07/13] Engine refactoring --- tests/test_engines.py | 2 +- youtubeanalyzer/engine.py | 183 +++++++++++++++++--------------------- youtubeanalyzer/search.py | 2 +- youtubeanalyzer/trends.py | 6 +- 4 files changed, 88 insertions(+), 105 deletions(-) diff --git a/tests/test_engines.py b/tests/test_engines.py index b5f4810..75a6a15 100644 --- a/tests/test_engines.py +++ b/tests/test_engines.py @@ -122,7 +122,7 @@ def _get_channel_info(self, _channel_id: str): class MockApiEngine(YoutubeApiEngine): def __init__(self, empty=False, exception=False): - super().__init__(ResultTableModel(None), 1, "") + super().__init__("", ResultTableModel(None), 1) self._exception = exception self._search_responce = { "items": [] diff --git a/youtubeanalyzer/engine.py b/youtubeanalyzer/engine.py index 5fe78fd..fac03c4 100644 --- a/youtubeanalyzer/engine.py +++ b/youtubeanalyzer/engine.py @@ -149,21 +149,26 @@ def _handle_finished(self, reply: QNetworkReply): class AbstractYoutubeEngine: - def __init__(self, model: ResultTableModel, request_limit: int, request_timeout_sec: int = 10): + def __init__(self, model: ResultTableModel = None, request_limit: int = 10, results_per_page: int = 10, + request_timeout_sec: int = 10): self.errorDetails = None self.errorReason = None self._model = model self._request_limit = request_limit + self._results_per_page = min(request_limit, results_per_page) self._request_timeout_sec = request_timeout_sec def set_request_timeout_sec(self, value_sec: int): self._request_timeout_sec = value_sec def search(self, request_text: str): - pass + raise "AbstractYoutubeEngine.search is not implemented" def get_video_categories(self): - pass + raise "AbstractYoutubeEngine.get_video_categories is not implemented" + + def trends(self, category_id: int, region_code: str = "US"): + raise "AbstractYoutubeEngine.trends is not implemented" class YoutubeGrepEngine(AbstractYoutubeEngine): @@ -210,6 +215,16 @@ def search(self, request_text: str): self.errorDetails = traceback.format_exc() return False + def get_video_categories(self, region_code: str = "US", output_language="en_US"): + self.errorDetails = "YoutubeGrepEngine doesn't support video categories" + self.errorReason = "Not supported" + return [] + + def trends(self, category_id: int, region_code: str = "US"): + self.errorDetails = "YoutubeGrepEngine doesn't support trends" + self.errorReason = "Not supported" + return False + @staticmethod def duration_to_timedelta(duration: str): if not duration: @@ -265,8 +280,8 @@ def _get_channel_info(self, channel_id: str): class YoutubeApiEngine(AbstractYoutubeEngine): - def __init__(self, model: ResultTableModel, request_limit: int, api_key: str): - super().__init__(model, request_limit) + def __init__(self, api_key: str, model: ResultTableModel = None, request_limit: int = 10, results_per_page: int = 10): + super().__init__(model, request_limit, results_per_page) self._api_key = api_key def search(self, request_text: str): @@ -274,55 +289,16 @@ def search(self, request_text: str): self.errorReason = None try: youtube = self._create_youtube_client() - search_response = self._search_videos(youtube, request_text) - - video_ids = "" - channel_ids = "" - for search_item in search_response["items"]: - video_ids = video_ids + "," + search_item["id"]["videoId"] - channel_id = search_item["snippet"]["channelId"] - if channel_id in channel_ids: - continue - channel_ids = channel_ids + "," + channel_id - video_ids = video_ids[1:] # Remove first comma - channel_ids = channel_ids[1:] # Remove first comma - - video_response = self._get_video_details(youtube, video_ids) - channel_response = self._get_channel_details(youtube, channel_ids) - channels = {} - for channel_item in channel_response["items"]: - channels[channel_item["id"]] = channel_item + response = self._search_videos(youtube, request_text) + + def video_id_getter(item): return item["id"]["videoId"] + video_response, channels = self._get_response_details(youtube, response, video_id_getter) result = [] count = 0 - for search_item in search_response["items"]: - video_item = video_response["items"][count] - search_snippet = search_item["snippet"] - content_details = video_item["contentDetails"] - statistics = video_item["statistics"] - video_title = search_snippet["title"] - video_published_time = str(datetime.strptime(search_snippet["publishTime"], "%Y-%m-%dT%H:%M:%SZ")) - video_duration_td = timedelta(seconds=isodate.parse_duration(content_details["duration"]).total_seconds()) - video_duration = timedelta_to_str(video_duration_td) - views = int(statistics["viewCount"]) - video_link = "https://www.youtube.com/watch?v=" + search_item["id"]["videoId"] - channel_title = search_snippet["channelTitle"] - channel_url = "https://www.youtube.com/channel/" + search_snippet["channelId"] - channel_item = channels[search_snippet["channelId"]] - channel_subscribers = int(channel_item["statistics"]["subscriberCount"]) - channel_views = int(channel_item["statistics"]["viewCount"]) - channel_joined_date = "" - video_preview_link = search_snippet["thumbnails"]["high"]["url"] - channel_snippet = channel_item["snippet"] - channel_logo_link = channel_snippet["thumbnails"]["default"]["url"] - channel_logo_link = channel_logo_link.replace("https", "http") # https is not working. I don't know why - video_snippet = video_item["snippet"] - tags = video_snippet["tags"] if "tags" in video_snippet else None - result.append( - make_result_row(video_title, video_published_time, video_duration, views, - video_link, channel_title, channel_url, channel_subscribers, - channel_views, channel_joined_date, video_preview_link, channel_logo_link, tags, - video_duration_td, count)) + for responce_item in response["items"]: + result.append(self._responce_item_to_result(responce_item, video_response["items"][count], count, channels, + "publishTime", video_id_getter)) count = count + 1 self._model.setData(result) @@ -351,11 +327,10 @@ def get_video_categories(self, region_code: str = "US", output_language="en_US") self.errorDetails = str(e) return [] - def search_trends(self, category_id: int, request_limit: int, region_code: str = "US", results_per_page: int = 10): + def trends(self, category_id: int, region_code: str = "US"): self.errorDetails = None self.errorReason = None try: - results_per_page = min(request_limit, results_per_page) youtube = self._create_youtube_client() page_token = "" total_count = 0 @@ -367,64 +342,25 @@ def search_trends(self, category_id: int, request_limit: int, region_code: str = chart="mostPopular", regionCode=region_code, videoCategoryId=category_id, - maxResults=results_per_page, + maxResults=self._results_per_page, pageToken=page_token ) response = request.execute() - video_ids = "" - channel_ids = "" - for search_item in response["items"]: - video_ids = video_ids + "," + search_item["id"] - channel_id = search_item["snippet"]["channelId"] - if channel_id in channel_ids: - continue - channel_ids = channel_ids + "," + channel_id - video_ids = video_ids[1:] # Remove first comma - channel_ids = channel_ids[1:] # Remove first comma - - video_response = self._get_video_details(youtube, video_ids) - channel_response = self._get_channel_details(youtube, channel_ids) - channels = {} - for channel_item in channel_response["items"]: - channels[channel_item["id"]] = channel_item + def video_id_getter(item): return item["id"] + video_response, channels = self._get_response_details(youtube, response, video_id_getter) count = 0 - for search_item in response["items"]: - video_item = video_response["items"][count] - search_snippet = search_item["snippet"] - content_details = search_item["contentDetails"] - statistics = search_item["statistics"] - video_title = search_snippet["title"] - video_published_time = str(datetime.strptime(search_snippet["publishedAt"], "%Y-%m-%dT%H:%M:%SZ")) - video_duration_td = timedelta(seconds=isodate.parse_duration(content_details["duration"]).total_seconds()) - video_duration = timedelta_to_str(video_duration_td) - views = int(statistics["viewCount"]) - video_link = "https://www.youtube.com/watch?v=" + search_item["id"] - channel_title = search_snippet["channelTitle"] - channel_url = "https://www.youtube.com/channel/" + search_snippet["channelId"] - channel_item = channels[search_snippet["channelId"]] - channel_subscribers = int(channel_item["statistics"]["subscriberCount"]) - channel_views = int(channel_item["statistics"]["viewCount"]) - channel_joined_date = "" - video_preview_link = search_snippet["thumbnails"]["high"]["url"] - channel_snippet = channel_item["snippet"] - channel_logo_link = channel_snippet["thumbnails"]["default"]["url"] - channel_logo_link = channel_logo_link.replace("https", "http") # https is not working. I don't know why - video_snippet = video_item["snippet"] - tags = video_snippet["tags"] if "tags" in video_snippet else None - result.append( - make_result_row(video_title, video_published_time, video_duration, views, - video_link, channel_title, channel_url, channel_subscribers, - channel_views, channel_joined_date, video_preview_link, channel_logo_link, tags, - video_duration_td, total_count)) + for responce_item in response["items"]: + result.append(self._responce_item_to_result(responce_item, video_response["items"][count], total_count, + channels, "publishedAt", video_id_getter)) count = count + 1 total_count = total_count + 1 - if total_count >= request_limit: + if total_count >= self._request_limit: break - if total_count >= request_limit: + if total_count >= self._request_limit: break if "nextPageToken" not in response: break @@ -466,3 +402,50 @@ def _get_channel_details(self, youtube, channel_ids): id=channel_ids ) return channel_request.execute() + + def _get_response_details(self, youtube, response, video_id_getter): + video_ids = "" + channel_ids = "" + for item in response["items"]: + video_ids = video_ids + "," + video_id_getter(item) + channel_id = item["snippet"]["channelId"] + if channel_id in channel_ids: + continue + channel_ids = channel_ids + "," + channel_id + video_ids = video_ids[1:] # Removing of the first comma + channel_ids = channel_ids[1:] # Removing of the first comma + + video_response = self._get_video_details(youtube, video_ids) + channel_response = self._get_channel_details(youtube, channel_ids) + channels = {} + for channel_item in channel_response["items"]: + channels[channel_item["id"]] = channel_item + + return video_response, channels + + def _responce_item_to_result(self, responce_item, video_item, result_index, channels, publish_time_key, video_id_getter): + search_snippet = responce_item["snippet"] + content_details = video_item["contentDetails"] + statistics = video_item["statistics"] + video_title = search_snippet["title"] + video_published_time = str(datetime.strptime(search_snippet[publish_time_key], "%Y-%m-%dT%H:%M:%SZ")) + video_duration_td = timedelta(seconds=isodate.parse_duration(content_details["duration"]).total_seconds()) + video_duration = timedelta_to_str(video_duration_td) + views = int(statistics["viewCount"]) + video_link = "https://www.youtube.com/watch?v=" + video_id_getter(responce_item) + channel_title = search_snippet["channelTitle"] + channel_url = "https://www.youtube.com/channel/" + search_snippet["channelId"] + channel_item = channels[search_snippet["channelId"]] + channel_subscribers = int(channel_item["statistics"]["subscriberCount"]) + channel_views = int(channel_item["statistics"]["viewCount"]) + channel_joined_date = "" + video_preview_link = search_snippet["thumbnails"]["high"]["url"] + channel_snippet = channel_item["snippet"] + channel_logo_link = channel_snippet["thumbnails"]["default"]["url"] + channel_logo_link = channel_logo_link.replace("https", "http") # https is not working. I don't know why + video_snippet = video_item["snippet"] + tags = video_snippet["tags"] if "tags" in video_snippet else None + return make_result_row(video_title, video_published_time, video_duration, views, + video_link, channel_title, channel_url, channel_subscribers, + channel_views, channel_joined_date, video_preview_link, channel_logo_link, tags, + video_duration_td, result_index) diff --git a/youtubeanalyzer/search.py b/youtubeanalyzer/search.py index e1a0b1f..d5a5cf3 100644 --- a/youtubeanalyzer/search.py +++ b/youtubeanalyzer/search.py @@ -192,7 +192,7 @@ def _create_engine(self): if not api_key: return YoutubeGrepEngine(self.model, request_limit) else: - return YoutubeApiEngine(self.model, request_limit, api_key) + return YoutubeApiEngine(api_key, self.model, request_limit) class SearchWorkspaceFactory(TabWorkspaceFactory): diff --git a/youtubeanalyzer/trends.py b/youtubeanalyzer/trends.py index a83bdd5..6ec6286 100644 --- a/youtubeanalyzer/trends.py +++ b/youtubeanalyzer/trends.py @@ -126,7 +126,7 @@ def _update_categories(self): if not api_key: return - engine = YoutubeApiEngine(None, 0, api_key) + engine = YoutubeApiEngine(api_key) region_code = self._region_combo_box.currentData() if not region_code: region_code = "US" @@ -219,8 +219,8 @@ def _on_show_trends_clicked(self): request_page_limit = 25 print("Request page limit is not set. Using '25' by default") - engine = YoutubeApiEngine(self.model, 0, api_key) - if engine.search_trends(category_id, request_limit, region_code, request_page_limit): + engine = YoutubeApiEngine(api_key, self.model, request_limit, request_page_limit) + if engine.trends(category_id, region_code): for i in range(len(self.model.result)): video_idx = self._sort_model.index(i, self.model.get_field_column(ResultFields.VideoTitle)) video_item = self.model.result[i] From 14fac209e111d0394f39b579057c0d930a897ebd Mon Sep 17 00:00:00 2001 From: trots Date: Wed, 1 Jan 2025 23:27:49 +0300 Subject: [PATCH 08/13] Search and trends tabs refactoring --- youtubeanalyzer/search.py | 135 ++++------------------------------ youtubeanalyzer/trends.py | 144 ++++++------------------------------- youtubeanalyzer/widgets.py | 120 ++++++++++++++++++++++++++++++- 3 files changed, 153 insertions(+), 246 deletions(-) diff --git a/youtubeanalyzer/search.py b/youtubeanalyzer/search.py index d5a5cf3..45729e1 100644 --- a/youtubeanalyzer/search.py +++ b/youtubeanalyzer/search.py @@ -1,28 +1,15 @@ from PySide6.QtCore import ( - Qt, - QSortFilterProxyModel, - QItemSelection + Qt ) from PySide6.QtWidgets import ( QApplication, QHBoxLayout, - QVBoxLayout, - QSplitter, QWidget, - QPushButton, - QSpinBox, - QTableView, - QTabWidget -) -from PySide6.QtCharts import ( - QChart + QPushButton ) from youtubeanalyzer.defines import ( app_name ) -from youtubeanalyzer.theme import ( - Theme -) from youtubeanalyzer.settings import ( Settings ) @@ -31,109 +18,30 @@ YoutubeGrepEngine ) from youtubeanalyzer.model import ( - ResultFields, - ResultTableModel + ResultFields ) from youtubeanalyzer.widgets import ( critial_detailed_message, create_link_label, SearchLineEdit, TabWorkspaceFactory, - VideoDetailsWidget, - AnalyticsWidget + AbstractVideoTableWorkspace ) -class SearchWorkspace(QWidget): +class SearchWorkspace(AbstractVideoTableWorkspace): def __init__(self, settings: Settings, parent: QWidget = None): - super().__init__(parent) - + super().__init__(settings, parent) self.request_text = "" - self._settings = settings - - h_layout = QHBoxLayout() - self._search_line_edit = SearchLineEdit() - self._search_line_edit.returnPressed.connect(self._on_search_clicked) - h_layout.addWidget(self._search_line_edit) - self._search_limit_spin_box = QSpinBox() - self._search_limit_spin_box.setToolTip(self.tr("Set the search result limit")) - self._search_limit_spin_box.setMinimumWidth(50) - self._search_limit_spin_box.setRange(2, 30) - self._search_limit_spin_box.setValue(10) - h_layout.addWidget(self._search_limit_spin_box) - self._search_button = QPushButton(self.tr("Search")) - self._search_button.setToolTip(self.tr("Click to start searching")) - self._search_button.clicked.connect(self._on_search_clicked) - h_layout.addWidget(self._search_button) - - self.model = ResultTableModel(self) - self._sort_model = QSortFilterProxyModel(self) - self._sort_model.setSortRole(ResultTableModel.SortRole) - self._sort_model.setSourceModel(self.model) - self._table_view = QTableView(self) - self._table_view.setModel(self._sort_model) - self._table_view.setSelectionMode(QTableView.SelectionMode.SingleSelection) - self._table_view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) - self._table_view.setSortingEnabled(True) - self._table_view.horizontalHeader().setSectionsMovable(True) - self._table_view.selectionModel().selectionChanged.connect(self._on_table_row_changed) - - self._side_tab_widget = QTabWidget() - - self._details_widget = VideoDetailsWidget(self.model, self) - self._side_tab_widget.addTab(self._details_widget, self.tr("Details")) - - self._analytics_widget = AnalyticsWidget(self.model, self) - self._side_tab_widget.addTab(self._analytics_widget, self.tr("Analytics")) - self._analytics_widget.set_current_index_following(self._settings.get(Settings.AnalyticsFollowTableSelect)) - if int(self._settings.get(Settings.Theme)) == Theme.Dark: - self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeDark) - else: - self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeLight) - self._analytics_widget.set_current_chart_index(int(self._settings.get(Settings.LastActiveChartIndex))) - - self._main_splitter = QSplitter(Qt.Orientation.Horizontal) - self._main_splitter.addWidget(self._table_view) - self._main_splitter.addWidget(self._side_tab_widget) - self._main_splitter.setCollapsible(0, False) - self._main_splitter.setStretchFactor(0, 3) - self._main_splitter.setStretchFactor(1, 1) - - v_layout = QVBoxLayout() - v_layout.addLayout(h_layout) - v_layout.addWidget(self._main_splitter) - self.setLayout(v_layout) def load_state(self): - request_limit = int(self._settings.get(Settings.RequestLimit)) - self._search_limit_spin_box.setValue(request_limit) - # Restore main splitter - splitter_state = self._settings.get(Settings.MainSplitterState) - if splitter_state and not splitter_state.isEmpty(): - self._main_splitter.restoreState(splitter_state) - # Restore main table - table_header_state = self._settings.get(Settings.MainTableHeaderState) - if not table_header_state.isEmpty(): - self._table_view.horizontalHeader().restoreState(table_header_state) - self._table_view.resizeColumnsToContents() + super().load_state() self._search_line_edit.setFocus() - def save_state(self): - self._settings.set(Settings.RequestLimit, self._search_limit_spin_box.value()) - self._settings.set(Settings.LastActiveChartIndex, self._analytics_widget.get_current_chart_index()) - self._settings.set(Settings.MainTableHeaderState, self._table_view.horizontalHeader().saveState()) - self._settings.set(Settings.MainSplitterState, self._main_splitter.saveState()) - self._settings.set(Settings.LastActiveDetailsTab, self._side_tab_widget.currentIndex()) - - def handle_preferences_change(self): - if int(self._settings.get(Settings.Theme)) == Theme.Dark: - self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeDark) - else: - self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeLight) - - self._analytics_widget.set_current_index_following(self._settings.get(Settings.AnalyticsFollowTableSelect)) - if self._settings.get(Settings.AnalyticsFollowTableSelect): - self._analytics_widget.set_current_index(self._table_view.currentIndex()) + def _create_toolbar(self, h_layout: QHBoxLayout): + self._search_line_edit = SearchLineEdit() + self._search_line_edit.returnPressed.connect(self._on_search_clicked) + h_layout.addWidget(self._search_line_edit) def _on_search_clicked(self): self.request_text = self._search_line_edit.text() @@ -141,11 +49,9 @@ def _on_search_clicked(self): if self.request_text == "": return - self._search_line_edit.setDisabled(True) - self._search_button.setDisabled(True) - self._table_view.setDisabled(True) - self._side_tab_widget.setDisabled(True) + self.setDisabled(True) QApplication.setOverrideCursor(Qt.CursorShape.BusyCursor) + self.model.clear() self._sort_model.sort(-1) self._details_widget.clear() @@ -171,20 +77,7 @@ def _on_search_clicked(self): critial_detailed_message(self, app_name, text, engine.errorDetails) QApplication.restoreOverrideCursor() - self._search_line_edit.setDisabled(False) - self._search_button.setDisabled(False) - self._table_view.setDisabled(False) - self._side_tab_widget.setDisabled(False) - - def _on_table_row_changed(self, current: QItemSelection, _previous: QItemSelection): - indexes = current.indexes() - if len(indexes) > 0: - index = self._sort_model.mapToSource(indexes[0]) - self._details_widget.set_current_index(index) - self._analytics_widget.set_current_index(index) - else: - self._details_widget.set_current_index(None) - self._analytics_widget.set_current_index(None) + self.setDisabled(False) def _create_engine(self): request_limit = self._search_limit_spin_box.value() diff --git a/youtubeanalyzer/trends.py b/youtubeanalyzer/trends.py index 6ec6286..8b1ff86 100644 --- a/youtubeanalyzer/trends.py +++ b/youtubeanalyzer/trends.py @@ -1,33 +1,20 @@ import pycountry from PySide6.QtCore import ( Qt, - QSortFilterProxyModel, - QItemSelection, QTimer ) from PySide6.QtWidgets import ( QApplication, QHBoxLayout, - QVBoxLayout, - QSplitter, QWidget, QLabel, QPushButton, QComboBox, - QTableView, - QTabWidget, - QMessageBox, - QSpinBox -) -from PySide6.QtCharts import ( - QChart + QMessageBox ) from youtubeanalyzer.defines import ( app_name ) -from youtubeanalyzer.theme import ( - Theme -) from youtubeanalyzer.settings import ( Settings ) @@ -35,89 +22,24 @@ YoutubeApiEngine ) from youtubeanalyzer.model import ( - ResultFields, - ResultTableModel + ResultFields ) from youtubeanalyzer.widgets import ( create_link_label, critial_detailed_message, TabWorkspaceFactory, - VideoDetailsWidget, - AnalyticsWidget + AbstractVideoTableWorkspace ) -class TrendsWorkspace(QWidget): +class TrendsWorkspace(AbstractVideoTableWorkspace): def __init__(self, settings: Settings, parent: QWidget = None): - super().__init__(parent) + super().__init__(settings, parent) + self._search_limit_spin_box.setMaximum(200) - self._settings = settings self._selected_category = None self._loaded_category_id = None - h_layout = QHBoxLayout() - h_layout.addWidget(QLabel(self.tr("Category:"))) - self._category_combo_box = QComboBox() - h_layout.addWidget(self._category_combo_box, 1) - h_layout.addSpacing(10) - h_layout.addWidget(QLabel(self.tr("Region:"))) - self._region_combo_box = QComboBox() - for country in pycountry.countries: - self._region_combo_box.addItem(country.name, country.alpha_2) - h_layout.addWidget(self._region_combo_box) - self._region_combo_box.currentIndexChanged.connect(self._update_categories) - - h_layout.addStretch(2) - self._request_limit_spin_box = QSpinBox() - self._request_limit_spin_box.setToolTip(self.tr("Set the result limit")) - self._request_limit_spin_box.setMinimumWidth(50) - self._request_limit_spin_box.setRange(2, 200) - self._request_limit_spin_box.setValue(10) - h_layout.addWidget(self._request_limit_spin_box) - - h_layout.addStretch() - self._show_button = QPushButton(self.tr("Show")) - self._show_button.setToolTip(self.tr("Click to show trends")) - self._show_button.clicked.connect(self._on_show_trends_clicked) - h_layout.addWidget(self._show_button) - - self.model = ResultTableModel(self) - self._sort_model = QSortFilterProxyModel(self) - self._sort_model.setSortRole(ResultTableModel.SortRole) - self._sort_model.setSourceModel(self.model) - self._table_view = QTableView(self) - self._table_view.setModel(self._sort_model) - self._table_view.setSelectionMode(QTableView.SelectionMode.SingleSelection) - self._table_view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) - self._table_view.setSortingEnabled(True) - self._table_view.horizontalHeader().setSectionsMovable(True) - self._table_view.selectionModel().selectionChanged.connect(self._on_table_row_changed) - - self._side_tab_widget = QTabWidget() - - self._details_widget = VideoDetailsWidget(self.model, self) - self._side_tab_widget.addTab(self._details_widget, self.tr("Details")) - - self._analytics_widget = AnalyticsWidget(self.model, self) - self._side_tab_widget.addTab(self._analytics_widget, self.tr("Analytics")) - self._analytics_widget.set_current_index_following(self._settings.get(Settings.AnalyticsFollowTableSelect)) - if int(self._settings.get(Settings.Theme)) == Theme.Dark: - self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeDark) - else: - self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeLight) - - self._main_splitter = QSplitter(Qt.Orientation.Horizontal) - self._main_splitter.addWidget(self._table_view) - self._main_splitter.addWidget(self._side_tab_widget) - self._main_splitter.setCollapsible(0, False) - self._main_splitter.setStretchFactor(0, 3) - self._main_splitter.setStretchFactor(1, 1) - - v_layout = QVBoxLayout() - v_layout.addLayout(h_layout) - v_layout.addWidget(self._main_splitter) - self.setLayout(v_layout) - QTimer.singleShot(0, self, self._update_categories) def _update_categories(self): @@ -143,44 +65,32 @@ def _update_categories(self): self._loaded_category_id = None def load_state(self): - request_limit = int(self._settings.get(Settings.RequestLimit)) - self._request_limit_spin_box.setValue(request_limit) - # Restore main splitter - splitter_state = self._settings.get(Settings.MainSplitterState) - if splitter_state and not splitter_state.isEmpty(): - self._main_splitter.restoreState(splitter_state) - # Restore main table - table_header_state = self._settings.get(Settings.MainTableHeaderState) - if not table_header_state.isEmpty(): - self._table_view.horizontalHeader().restoreState(table_header_state) - self._table_view.resizeColumnsToContents() - self._analytics_widget.set_current_chart_index(int(self._settings.get(Settings.LastActiveChartIndex))) + super().load_state() index = self._region_combo_box.findData(self._settings.get(Settings.TrendsRegion)) if index >= 0: self._region_combo_box.setCurrentIndex(index) self._loaded_category_id = int(self._settings.get(Settings.TrendsVideoCategoryId)) def save_state(self): - self._settings.set(Settings.RequestLimit, self._request_limit_spin_box.value()) - self._settings.set(Settings.LastActiveChartIndex, self._analytics_widget.get_current_chart_index()) - self._settings.set(Settings.MainTableHeaderState, self._table_view.horizontalHeader().saveState()) - self._settings.set(Settings.MainSplitterState, self._main_splitter.saveState()) - self._settings.set(Settings.LastActiveDetailsTab, self._side_tab_widget.currentIndex()) + super().save_state() self._settings.set(Settings.TrendsRegion, self._region_combo_box.currentData()) if self._category_combo_box.currentData(): self._settings.set(Settings.TrendsVideoCategoryId, int(self._category_combo_box.currentData())) - def handle_preferences_change(self): - if int(self._settings.get(Settings.Theme)) == Theme.Dark: - self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeDark) - else: - self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeLight) - - self._analytics_widget.set_current_index_following(self._settings.get(Settings.AnalyticsFollowTableSelect)) - if self._settings.get(Settings.AnalyticsFollowTableSelect): - self._analytics_widget.set_current_index(self._table_view.currentIndex()) + def _create_toolbar(self, h_layout: QHBoxLayout): + h_layout.addWidget(QLabel(self.tr("Category:"))) + self._category_combo_box = QComboBox() + h_layout.addWidget(self._category_combo_box, 1) + h_layout.addSpacing(10) + h_layout.addWidget(QLabel(self.tr("Region:"))) + self._region_combo_box = QComboBox() + for country in pycountry.countries: + self._region_combo_box.addItem(country.name, country.alpha_2) + h_layout.addWidget(self._region_combo_box) + self._region_combo_box.currentIndexChanged.connect(self._update_categories) + h_layout.addStretch(2) - def _on_show_trends_clicked(self): + def _on_search_clicked(self): self.setDisabled(True) QApplication.setOverrideCursor(Qt.CursorShape.BusyCursor) @@ -209,7 +119,7 @@ def _on_show_trends_clicked(self): Please set it in the preferences")) return - request_limit = self._request_limit_spin_box.value() + request_limit = self._search_limit_spin_box.value() if not request_limit: request_limit = 10 print("Request limit is not set. Using '10' by default") @@ -241,16 +151,6 @@ def _on_show_trends_clicked(self): QApplication.restoreOverrideCursor() self.setDisabled(False) - def _on_table_row_changed(self, current: QItemSelection, _previous: QItemSelection): - indexes = current.indexes() - if len(indexes) > 0: - index = self._sort_model.mapToSource(indexes[0]) - self._details_widget.set_current_index(index) - self._analytics_widget.set_current_index(index) - else: - self._details_widget.set_current_index(None) - self._analytics_widget.set_current_index(None) - class TrendsWorkspaceFactory(TabWorkspaceFactory): def __init__(self, parent: QWidget = None): diff --git a/youtubeanalyzer/widgets.py b/youtubeanalyzer/widgets.py index 56593e6..a916e53 100644 --- a/youtubeanalyzer/widgets.py +++ b/youtubeanalyzer/widgets.py @@ -4,7 +4,9 @@ QModelIndex, QUrl, QTimer, - QStringListModel + QStringListModel, + QSortFilterProxyModel, + QItemSelection ) from PySide6.QtGui import ( QImage, @@ -27,10 +29,17 @@ QPushButton, QTabWidget, QSizePolicy, - QMessageBox + QMessageBox, + QSpinBox, + QTableView, + QSplitter ) from PySide6.QtCharts import ( - QChartView + QChartView, + QChart +) +from youtubeanalyzer.theme import ( + Theme ) from youtubeanalyzer.settings import ( Settings @@ -424,3 +433,108 @@ def _create_workspace(self): workspace_button = self.sender() workspace_index = self._main_layout.indexOf(workspace_button) - 1 self.create_workspace(workspace_index) + + +class AbstractVideoTableWorkspace(QWidget): + def __init__(self, settings: Settings, parent: QWidget = None): + super().__init__(parent) + + self._settings = settings + + h_layout = QHBoxLayout() + self._create_toolbar(h_layout) + + self._search_limit_spin_box = QSpinBox() + self._search_limit_spin_box.setToolTip(self.tr("Set the search result limit")) + self._search_limit_spin_box.setMinimumWidth(50) + self._search_limit_spin_box.setRange(2, 30) + self._search_limit_spin_box.setValue(10) + h_layout.addWidget(self._search_limit_spin_box) + self._search_button = QPushButton(self.tr("Search")) + self._search_button.setToolTip(self.tr("Click to start searching")) + self._search_button.clicked.connect(self._on_search_clicked) + h_layout.addWidget(self._search_button) + + self.model = ResultTableModel(self) + self._sort_model = QSortFilterProxyModel(self) + self._sort_model.setSortRole(ResultTableModel.SortRole) + self._sort_model.setSourceModel(self.model) + self._table_view = QTableView(self) + self._table_view.setModel(self._sort_model) + self._table_view.setSelectionMode(QTableView.SelectionMode.SingleSelection) + self._table_view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) + self._table_view.setSortingEnabled(True) + self._table_view.horizontalHeader().setSectionsMovable(True) + self._table_view.selectionModel().selectionChanged.connect(self._on_table_row_changed) + + self._side_tab_widget = QTabWidget() + + self._details_widget = VideoDetailsWidget(self.model, self) + self._side_tab_widget.addTab(self._details_widget, self.tr("Details")) + + self._analytics_widget = AnalyticsWidget(self.model, self) + self._side_tab_widget.addTab(self._analytics_widget, self.tr("Analytics")) + self._analytics_widget.set_current_index_following(self._settings.get(Settings.AnalyticsFollowTableSelect)) + if int(self._settings.get(Settings.Theme)) == Theme.Dark: + self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeDark) + else: + self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeLight) + self._analytics_widget.set_current_chart_index(int(self._settings.get(Settings.LastActiveChartIndex))) + + self._main_splitter = QSplitter(Qt.Orientation.Horizontal) + self._main_splitter.addWidget(self._table_view) + self._main_splitter.addWidget(self._side_tab_widget) + self._main_splitter.setCollapsible(0, False) + self._main_splitter.setStretchFactor(0, 3) + self._main_splitter.setStretchFactor(1, 1) + + v_layout = QVBoxLayout() + v_layout.addLayout(h_layout) + v_layout.addWidget(self._main_splitter) + self.setLayout(v_layout) + + def load_state(self): + request_limit = int(self._settings.get(Settings.RequestLimit)) + self._search_limit_spin_box.setValue(request_limit) + # Restore main splitter + splitter_state = self._settings.get(Settings.MainSplitterState) + if splitter_state and not splitter_state.isEmpty(): + self._main_splitter.restoreState(splitter_state) + # Restore main table + table_header_state = self._settings.get(Settings.MainTableHeaderState) + if not table_header_state.isEmpty(): + self._table_view.horizontalHeader().restoreState(table_header_state) + self._table_view.resizeColumnsToContents() + + def save_state(self): + self._settings.set(Settings.RequestLimit, self._search_limit_spin_box.value()) + self._settings.set(Settings.LastActiveChartIndex, self._analytics_widget.get_current_chart_index()) + self._settings.set(Settings.MainTableHeaderState, self._table_view.horizontalHeader().saveState()) + self._settings.set(Settings.MainSplitterState, self._main_splitter.saveState()) + self._settings.set(Settings.LastActiveDetailsTab, self._side_tab_widget.currentIndex()) + + def handle_preferences_change(self): + if int(self._settings.get(Settings.Theme)) == Theme.Dark: + self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeDark) + else: + self._analytics_widget.set_charts_theme(QChart.ChartTheme.ChartThemeLight) + + self._analytics_widget.set_current_index_following(self._settings.get(Settings.AnalyticsFollowTableSelect)) + if self._settings.get(Settings.AnalyticsFollowTableSelect): + self._analytics_widget.set_current_index(self._table_view.currentIndex()) + + def _create_toolbar(self, h_layout: QHBoxLayout): + raise "AbstractVideoTableWorkspace._create_toolbar is not implemented" + + def _on_search_clicked(self): + raise "AbstractVideoTableWorkspace._on_search_clicked is not implemented" + + def _on_table_row_changed(self, current: QItemSelection, _previous: QItemSelection): + indexes = current.indexes() + if len(indexes) > 0: + index = self._sort_model.mapToSource(indexes[0]) + self._details_widget.set_current_index(index) + self._analytics_widget.set_current_index(index) + else: + self._details_widget.set_current_index(None) + self._analytics_widget.set_current_index(None) From 1dccfaa41722e4c62567d4abf4ef93ca3f4cddd4 Mon Sep 17 00:00:00 2001 From: trots Date: Thu, 2 Jan 2025 00:30:52 +0300 Subject: [PATCH 09/13] Update translations En translation is removed because it is default --- compile_executable.bat | 1 - compile_translations.bat | 1 - translations/en.ts | 577 ------------------------------------ translations/ru.ts | 357 +++++++++++++++------- update_translations.bat | 3 +- youtubeanalyzer/__main__.py | 13 +- youtubeanalyzer/search.py | 4 +- youtubeanalyzer/trends.py | 53 ++-- youtubeanalyzer/widgets.py | 5 +- 9 files changed, 283 insertions(+), 731 deletions(-) delete mode 100644 translations/en.ts diff --git a/compile_executable.bat b/compile_executable.bat index e04ab60..19377df 100644 --- a/compile_executable.bat +++ b/compile_executable.bat @@ -3,5 +3,4 @@ copy logo.png dist\youtube-analyzer\logo.png call compile_translations.bat mkdir dist\youtube-analyzer\translations -copy translations\en.qm dist\youtube-analyzer\translations\en.qm copy translations\ru.qm dist\youtube-analyzer\translations\ru.qm diff --git a/compile_translations.bat b/compile_translations.bat index d29789b..497bdfe 100644 --- a/compile_translations.bat +++ b/compile_translations.bat @@ -1,2 +1 @@ -".venv/Scripts/pyside6-lrelease.exe" translations/en.ts -qm translations/en.qm ".venv/Scripts/pyside6-lrelease.exe" translations/ru.ts -qm translations/ru.qm diff --git a/translations/en.ts b/translations/en.ts deleted file mode 100644 index a80d4ea..0000000 --- a/translations/en.ts +++ /dev/null @@ -1,577 +0,0 @@ - - - - - AboutDialog - - - About - About - - - - Version: - Version: - - - - Software for analyzing of YouTube search output - Software for analyzing of YouTube search output - - - - Based on: PySide6, youtube-search-python, - google-api-python-client, XlsxWriter, isodate. - Based on: PySide6, youtube-search-python, - google-api-python-client, XlsxWriter, isodate. - - - - Web site: - Web site: - - - - License: - License: - - - - All rights reserved - All rights reserved - - - - AdvancedTab - - - Request timeout in seconds: - Request timeout in seconds: - - - - Set the maximum waiting time in seconds for YouTube request - Set the maximum waiting time in seconds for YouTube request - - - - AnalyticsTab - - - Follow table selections in analytics charts - Follow table selections in analytics charts - - - - Highlight the selected item on the analytics charts - Highlight the selected item on the analytics charts - - - - AnalyticsWidget - - - Channels distribution chart - Channels distribution chart - - - - Video duration chart - Video duration chart - - - - Popular title words chart - Popular title words chart - - - - AuthorsDialog - - - Authors - Authors - - - - The YouTube Analyzer team, in alphabetical order: - - The YouTube Analyzer team, in alphabetical order: - - - - - DontAskAgainQuestionDialog - - - Don't ask again - Don't ask again - - - - GeneralTab - - - YouTube API Key: - YouTube API Key: - - - - Set the key to use the YouTube API for YouTube search - Set the key to use the YouTube API for YouTube search - - - - Language: - Language: - - - - Set the interface language - Set the interface language - - - - Theme: - Theme: - - - - Set the interface color theme - Set the interface color theme - - - - System - System - - - - Dark - Dark - - - - MainWindow - - - File - File - - - - Export to XLSX... - Export to XLSX... - - - - Export to CSV... - Export to CSV... - - - - Export to HTML... - Export to HTML... - - - - Exit - Exit - - - - Edit - Edit - - - - Preferences... - Preferences... - - - - View - View - - - - Show details - Show details - - - - Help - Help - - - - Authors... - Authors... - - - - About... - About... - - - - Set the search result limit - Set the search result limit - - - - Search - Search - - - - Click to start searching - Click to start searching - - - - Details - Details - - - - Analytics - Analytics - - - - Exit? - Exit? - - - - Error in the searching process - Error in the searching process - - - - Save XLSX - Save XLSX - - - - Xlsx File (*.xlsx) - Xlsx File (*.xlsx) - - - - Save CSV - Save CSV - - - - Csv File (*.csv) - Csv File (*.csv) - - - - Save HTML - Save HTML - - - - Html File (*.html) - Html File (*.html) - - - - ResultTableModel - - - Title - Title - - - - Published Time - Published Time - - - - Duration - Duration - - - - View Count - View Count - - - - Link - Link - - - - Channel Name - Channel Name - - - - Channel Link - Channel Link - - - - Channel Subscribers - Channel Subscribers - - - - Channel Views - Channel Views - - - - Channel Joined Date - Channel Joined Date - - - - Views/Subscribers - Views/Subscribers - - - - Preview Link - Preview Link - - - - Channel Logo Link - Channel Logo Link - - - - Video Tags - Video Tags - - - - Video Duration Timedelta - Video Duration Timedelta - - - - # - # - - - - Video title - Video title - - - - Video published time - Video published time - - - - Video duration - Video duration - - - - Video view count - Video view count - - - - Video link - Video link - - - - Channel name - Channel name - - - - Channel link - Channel link - - - - Channel subscriber count - Channel subscriber count - - - - Channel view count - Channel view count - - - - Channel registration date - Channel registration date - - - - Video views to channel subscribers ratio in percents - Video views to channel subscribers ratio in percents - - - - Preview image link - Preview image link - - - - Channel logo image link - Channel logo image link - - - - Video tag list - Video tag list - - - - Video duration timedelta - Video duration timedelta - - - - Video relevance in search output (0 is the higest relevance) - Video relevance in search output (0 is the higest relevance) - - - - SearchLineEdit - - - Enter request and press 'Search'... - Enter request and press 'Search'... - - - - SettingsDialog - - - Settings - Settings - - - - General - General - - - - Analytics - Analytics - - - - Advanced - Advanced - - - - Restart the application now to apply the selected language? - Restart the application now to apply the selected language? - - - - VideoDetailsWidget - - - Tags: - Tags: - - - - The video tags - The video tags - - - - No tags - No tags - - - - Select a video to see its details - Select a video to see its details - - - - subscribers - subscribers - - - - views - views - - - - Download error: - Download error: - - - - VideoDurationChart - - - 5m - 5m - - - - 10m - 10m - - - - 15m - 15m - - - - 20m - 20m - - - - 30m - 30m - - - - 45m - 45m - - - - 1h - 1h - - - - 1,5h - 1,5h - - - - 2h - 2h - - - - 3h - 3h - - - - 3+h - 3+h - - - diff --git a/translations/ru.ts b/translations/ru.ts index 2e9c231..b00deac 100644 --- a/translations/ru.ts +++ b/translations/ru.ts @@ -4,52 +4,90 @@ AboutDialog - + About О программе - + Version: Версия: - + Software for analyzing of YouTube search output Программа для анализа поисковой выдачи YouTube - + Based on: PySide6, youtube-search-python, google-api-python-client, XlsxWriter, isodate. Основан на: PySide6, youtube-search-python, google-api-python-client, XlsxWriter, isodate. - + Web site: Веб-сайт: - + License: Лицензия: - + All rights reserved Все права защищены + + AbstractVideoTableWorkspace + + + Set the search result limit + Установка максимального количества результатов поиска + + + + Search + Поиск + + + + Click to start searching + Начать поиск + + + + Details + Подробности + + + + Analytics + Аналитика + + AdvancedTab - + + Request page limit: + Максимальный размер страницы запроса: + + + + Set the maximum page size for YouTube requests + Установить максимальный размер страницы для запроса YouTube + + + Request timeout in seconds: Время ожидания запроса в секундах: - + Set the maximum waiting time in seconds for YouTube request Установить максимальное время ожидания запроса на YouTube в секундах @@ -57,12 +95,12 @@ AnalyticsTab - + Follow table selections in analytics charts Следовать в графиках за выделением в таблице - + Highlight the selected item on the analytics charts Подсвечивать выбранный в таблице элемент на графиках аналитики @@ -70,17 +108,17 @@ AnalyticsWidget - + Channels distribution chart График распределения каналов - + Video duration chart График продолжительности видео - + Popular title words chart График популярных слов в заголовках @@ -88,12 +126,12 @@ AuthorsDialog - + Authors Авторы - + The YouTube Analyzer team, in alphabetical order: YouTube Analyzer team, в алфавитном порядке: @@ -103,7 +141,7 @@ DontAskAgainQuestionDialog - + Don't ask again Не спрашивать снова @@ -111,42 +149,42 @@ GeneralTab - + YouTube API Key: Ключ YouTube API: - + Set the key to use the YouTube API for YouTube search Установите ключ для включения поиска через YouTube API - + Language: Язык: - + Set the interface language Установите язык интерфейса - + Theme: Тема: - + Set the interface color theme Установите цветовую тему интерфейса - + System Системная - + Dark Тёмная @@ -154,127 +192,140 @@ MainWindow - + File Файл - + Export to XLSX... Экспорт в XLSX... - + Export to CSV... Экспорт в CSV... - + Export to HTML... Экспорт в HTML... - + Exit Выход - + Edit Правка - + Preferences... Настройки... - + + Window + Окно + + + + + Create a new tab + Создать новую вкладку + + + + New tab + Новая вкладка + + + + There is no data to export + Нет данных для экспорта + + View - Вид + Вид - Show details - Показать подробности + Показать подробности - + Help Справка - + Authors... Авторы... - + About... О программе... - Set the search result limit - Установка максимального количества результатов поиска + Установка максимального количества результатов поиска - Search - Поиск + Поиск - Click to start searching - Начать поиск + Начать поиск - Details - Подробности + Подробности - Analytics - Аналитика + Аналитика - + Exit? Выйти? - Error in the searching process - Поиск завершился с ошибкой + Поиск завершился с ошибкой - + Save XLSX Сохранить XLSX - + Xlsx File (*.xlsx) Xlsx-файл (*.xlsx) - + Save CSV Сохранить CSV - + Csv File (*.csv) Csv-файл (*.csv) - + Save HTML Сохранить HTML - + Html File (*.html) Html-файл (*.html) @@ -282,162 +333,162 @@ ResultTableModel - + Title Название - + Published Time Время публикации - + Duration Продолжительность - + View Count Количество просмотров - + Link Ссылка - + Channel Name Название канала - + Channel Link Ссылка на канал - + Channel Subscribers Количество подписчиков - + Channel Views Количество просмотров на канале - + Channel Joined Date Дата создания канала - + Views/Subscribers Просмотры/Подписчики - + Preview Link Ссылка на превью - + Channel Logo Link Ссылка на логотип канала - + Video Tags Теги видео - + Video Duration Timedelta Дельта продолжительности видео - + # # - + Video title Название видео - + Video published time Время публикации видео - + Video duration Продолжительность видео - + Video view count Количество просмотров видео - + Video link Ссылка на видео - + Channel name Название канала - + Channel link Ссылка на канал - + Channel subscriber count Количество подписчиков на канале - + Channel view count Количество просмотров на канале - + Channel registration date Дата регистрации канала - + Video views to channel subscribers ratio in percents Отношение количества просмотров видео к количеству подписчиков канала в процентах - + Preview image link Ссылка на изображение превью - + Channel logo image link Ссылка на изображение логотипа канала - + Video tag list Список тегов видео - + Video duration timedelta Дельта продолжительности видео - + Video relevance in search output (0 is the higest relevance) Релевантность видео в поисковой выдаче (0 обозначает наивысшую релевантность) @@ -445,73 +496,151 @@ SearchLineEdit - + Enter request and press 'Search'... Введите запрос и нажмите 'Поиск'... + + SearchWorkspace + + + Error in the searching process + Поиск завершился с ошибкой + + + + SearchWorkspaceFactory + + + Search + Поиск + + + + Search video... + Поиск видео... + + SettingsDialog - + Settings Настройки - + General Общие - + Analytics Аналитика - + Advanced Продвинутые - + Restart the application now to apply the selected language? Перезапустить приложение для применения выбранного языка? + + TrendsWorkspace + + + Unable to get video categories + Не удалось загрузить категории видео + + + + Category: + Категория: + + + + Select the video category to search trends + Выберите категорию видео для поиска трендов + + + + Region: + Регион: + + + + Select the region to search trends + Выберите регион для поиска трендов + + + + Unable to show trends. Video category is not selected. + Не удалось показать тренды. Не задана категория видео. + + + + Unable to show trends. YouTube API key is not set. + Please set it in the preferences + Не удалось показать тренды. Не установлен ключ YouTube API. Пожалуйста, установите его в настройках + + + + Trends searching failed + Поиск по трендам завершился с ошибкой + + + + TrendsWorkspaceFactory + + + Trends + Тренды + + + + Search trends... + Поиск трендов... + + VideoDetailsWidget - + Tags: Теги: - + The video tags Теги видео - + No tags Нет тегов - + Select a video to see its details Выберите видео для просмотра подробностей - + subscribers подписчиков - + views просмотров - + Download error: Ошибка загрузки: @@ -519,57 +648,57 @@ VideoDurationChart - + 5m - + 10m 10м - + 15m 15м - + 20m 20м - + 30m 30м - + 45m 45м - + 1h - + 1,5h 1,5ч - + 2h - + 3h - + 3+h 3+ч diff --git a/update_translations.bat b/update_translations.bat index 0ebc441..2fcdc9e 100644 --- a/update_translations.bat +++ b/update_translations.bat @@ -1,2 +1 @@ -".venv/Scripts/pyside6-lupdate.exe" youtube-analyzer.py settings.py model.py widgets.py chart.py -ts translations/en.ts -".venv/Scripts/pyside6-lupdate.exe" youtube-analyzer.py settings.py model.py widgets.py chart.py -ts translations/ru.ts +".venv/Scripts/pyside6-lupdate.exe" -extensions py youtubeanalyzer/ -ts translations/ru.ts diff --git a/youtubeanalyzer/__main__.py b/youtubeanalyzer/__main__.py index e3ac4e8..3911278 100644 --- a/youtubeanalyzer/__main__.py +++ b/youtubeanalyzer/__main__.py @@ -341,13 +341,10 @@ def top_exception_handler(_exctype, value, tb): if settings.get(Settings.Language) == "Ru": my_lang = "translations/ru.qm" qt_lang = "qtbase_ru.qm" - else: - my_lang = "translations/en.qm" - qt_lang = "qtbase_en.qm" - if my_translator.load(my_lang): - app.installTranslator(my_translator) - if qt_translator.load(qt_lang, QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)): - app.installTranslator(qt_translator) + if my_translator.load(my_lang): + app.installTranslator(my_translator) + if qt_translator.load(qt_lang, QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)): + app.installTranslator(qt_translator) window = MainWindow(settings) window.resize(app.screens()[0].size() * 0.7) @@ -356,5 +353,7 @@ def top_exception_handler(_exctype, value, tb): if app_need_restart: app_need_restart = False + app.removeTranslator(my_translator) + app.removeTranslator(qt_translator) else: break diff --git a/youtubeanalyzer/search.py b/youtubeanalyzer/search.py index 45729e1..24283bb 100644 --- a/youtubeanalyzer/search.py +++ b/youtubeanalyzer/search.py @@ -93,10 +93,10 @@ def __init__(self, parent: QWidget = None): super().__init__(parent) def get_workspace_name(self) -> str: - return "Search" + return self.tr("Search") def create_workspace_button(self) -> QPushButton: - button = QPushButton("Search video...") + button = QPushButton(self.tr("Search video...")) return button def create_workspace_widget(self, settings: Settings, parent: QWidget) -> QWidget: diff --git a/youtubeanalyzer/trends.py b/youtubeanalyzer/trends.py index 8b1ff86..4a74c55 100644 --- a/youtubeanalyzer/trends.py +++ b/youtubeanalyzer/trends.py @@ -1,6 +1,7 @@ import pycountry from PySide6.QtCore import ( Qt, + QObject, QTimer ) from PySide6.QtWidgets import ( @@ -42,28 +43,6 @@ def __init__(self, settings: Settings, parent: QWidget = None): QTimer.singleShot(0, self, self._update_categories) - def _update_categories(self): - self._category_combo_box.clear() - api_key = self._settings.get(Settings.YouTubeApiKey) - if not api_key: - return - - engine = YoutubeApiEngine(api_key) - region_code = self._region_combo_box.currentData() - if not region_code: - region_code = "US" - categories_lang = "ru_RU" if self._settings.get(Settings.Language) == "Ru" else "en_US" - categories = engine.get_video_categories(region_code, categories_lang) - if len(categories) == 0: - critial_detailed_message(self, app_name, self.tr("Unable to get video categories"), engine.errorDetails) - for category in categories: - self._category_combo_box.addItem(category.text, category.id) - if self._loaded_category_id is not None: - index = self._category_combo_box.findData(self._loaded_category_id) - if index >= 0: - self._category_combo_box.setCurrentIndex(index) - self._loaded_category_id = None - def load_state(self): super().load_state() index = self._region_combo_box.findData(self._settings.get(Settings.TrendsRegion)) @@ -80,16 +59,40 @@ def save_state(self): def _create_toolbar(self, h_layout: QHBoxLayout): h_layout.addWidget(QLabel(self.tr("Category:"))) self._category_combo_box = QComboBox() + self._category_combo_box.setToolTip(self.tr("Select the video category to search trends")) h_layout.addWidget(self._category_combo_box, 1) h_layout.addSpacing(10) h_layout.addWidget(QLabel(self.tr("Region:"))) self._region_combo_box = QComboBox() + self._region_combo_box.setToolTip(self.tr("Select the region to search trends")) for country in pycountry.countries: self._region_combo_box.addItem(country.name, country.alpha_2) h_layout.addWidget(self._region_combo_box) self._region_combo_box.currentIndexChanged.connect(self._update_categories) h_layout.addStretch(2) + def _update_categories(self): + self._category_combo_box.clear() + api_key = self._settings.get(Settings.YouTubeApiKey) + if not api_key: + return + + engine = YoutubeApiEngine(api_key) + region_code = self._region_combo_box.currentData() + if not region_code: + region_code = "US" + categories_lang = "ru_RU" if self._settings.get(Settings.Language) == "Ru" else "en_US" + categories = engine.get_video_categories(region_code, categories_lang) + if len(categories) == 0: + critial_detailed_message(self, app_name, self.tr("Unable to get video categories"), engine.errorDetails) + for category in categories: + self._category_combo_box.addItem(category.text, category.id) + if self._loaded_category_id is not None: + index = self._category_combo_box.findData(self._loaded_category_id) + if index >= 0: + self._category_combo_box.setCurrentIndex(index) + self._loaded_category_id = None + def _on_search_clicked(self): self.setDisabled(True) QApplication.setOverrideCursor(Qt.CursorShape.BusyCursor) @@ -153,14 +156,14 @@ def _on_search_clicked(self): class TrendsWorkspaceFactory(TabWorkspaceFactory): - def __init__(self, parent: QWidget = None): + def __init__(self, parent: QObject = None): super().__init__(parent) def get_workspace_name(self) -> str: - return "Trends" + return self.tr("Trends") def create_workspace_button(self) -> QPushButton: - button = QPushButton("Show trends...") + button = QPushButton(self.tr("Search trends...")) return button def create_workspace_widget(self, settings: Settings, parent: QWidget) -> QWidget: diff --git a/youtubeanalyzer/widgets.py b/youtubeanalyzer/widgets.py index a916e53..ae6ee78 100644 --- a/youtubeanalyzer/widgets.py +++ b/youtubeanalyzer/widgets.py @@ -1,4 +1,5 @@ from PySide6.QtCore import ( + QObject, QSize, Qt, QModelIndex, @@ -357,8 +358,8 @@ def _on_autocomplete_downloaded(self, autocomplete_list): self._autocomplete_model.setStringList(autocomplete_list) -class TabWorkspaceFactory(QWidget): - def __init__(self, parent: QWidget = None): +class TabWorkspaceFactory(QObject): + def __init__(self, parent: QObject = None): super().__init__(parent) def get_workspace_name(self) -> str: From 186d27bf2496480650f010c4ca3b504cbed092dc Mon Sep 17 00:00:00 2001 From: trots Date: Thu, 2 Jan 2025 16:11:37 +0300 Subject: [PATCH 10/13] Actualize compile scripts and installer config --- .gitignore | 8 +++----- compile_executable.bat | 2 +- compile_executable_nuitka.bat | 9 ++++----- youtube-analyzer.iss | 2 +- youtubeanalyzer/__main__.py | 6 +++--- 5 files changed, 12 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index fa384d4..1473d18 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,10 @@ -build -dist +*build +*dist .vscode .venv -youtube-analyzer.spec +*.spec __pycache__ translations/*.qm -youtube-analyzer.build -youtube-analyzer.dist Output .coverage* unit_test_* diff --git a/compile_executable.bat b/compile_executable.bat index 19377df..488fee1 100644 --- a/compile_executable.bat +++ b/compile_executable.bat @@ -1,4 +1,4 @@ -.venv\Scripts\pyinstaller.exe --windowed --icon=logo.png youtube-analyzer.py +.venv\Scripts\pyinstaller.exe --windowed --icon=logo.png --name youtube-analyzer youtubeanalyzer/__main__.py copy logo.png dist\youtube-analyzer\logo.png call compile_translations.bat diff --git a/compile_executable_nuitka.bat b/compile_executable_nuitka.bat index 4892f50..6c12830 100644 --- a/compile_executable_nuitka.bat +++ b/compile_executable_nuitka.bat @@ -1,8 +1,7 @@ -.venv\Scripts\python.exe -m nuitka --standalone --disable-console --include-package-data=googleapiclient --enable-plugin=pyside6 --windows-icon-from-ico=logo.png youtube-analyzer.py +.venv\Scripts\python.exe -m nuitka --standalone --windows-console-mode=disable --include-package-data=googleapiclient --enable-plugin=pyside6 --include-module=PySide6.support.deprecated --windows-icon-from-ico=logo.png --output-filename=youtube-analyzer youtubeanalyzer/__main__.py -copy logo.png youtube-analyzer.dist\logo.png +copy logo.png __main__.dist\logo.png call compile_translations.bat -mkdir youtube-analyzer.dist\translations -copy translations\en.qm youtube-analyzer.dist\translations\en.qm -copy translations\ru.qm youtube-analyzer.dist\translations\ru.qm +mkdir __main__.dist\translations +copy translations\ru.qm __main__.dist\translations\ru.qm diff --git a/youtube-analyzer.iss b/youtube-analyzer.iss index b25969e..0b8939f 100644 --- a/youtube-analyzer.iss +++ b/youtube-analyzer.iss @@ -6,7 +6,7 @@ #define MyAppPublisher "Alexander Trotsenko" #define MyAppURL "https://github.com/trots/youtube-analyzer" #define MyAppExeName "youtube-analyzer.exe" -#define MyAppDistDir "youtube-analyzer.dist" +#define MyAppDistDir "__main__.dist" [Setup] ; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. diff --git a/youtubeanalyzer/__main__.py b/youtubeanalyzer/__main__.py index 3911278..7f39a88 100644 --- a/youtubeanalyzer/__main__.py +++ b/youtubeanalyzer/__main__.py @@ -5,10 +5,10 @@ Qt, QFileInfo, QTranslator, - QLibraryInfo + QLibraryInfo, + QKeyCombination ) from PySide6.QtGui import ( - QKeySequence, QIcon, QShowEvent ) @@ -163,7 +163,7 @@ def __init__(self, settings: Settings): file_menu.addSeparator() exit_action = file_menu.addAction(self.tr("Exit")) - exit_action.setShortcut(QKeySequence(Qt.Modifier.CTRL | Qt.Key.Key_Q)) + exit_action.setShortcut(QKeyCombination(Qt.Modifier.CTRL, Qt.Key.Key_Q)) exit_action.triggered.connect(self.close) edit_menu = self.menuBar().addMenu(self.tr("Edit")) From b086733e26c6eaf70ead0030c7453aeb0993f120 Mon Sep 17 00:00:00 2001 From: trots Date: Thu, 2 Jan 2025 16:27:20 +0300 Subject: [PATCH 11/13] Fix data export --- youtubeanalyzer/__main__.py | 7 ++++--- youtubeanalyzer/search.py | 3 +++ youtubeanalyzer/trends.py | 5 +++++ youtubeanalyzer/widgets.py | 3 +++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/youtubeanalyzer/__main__.py b/youtubeanalyzer/__main__.py index 7f39a88..fb68d37 100644 --- a/youtubeanalyzer/__main__.py +++ b/youtubeanalyzer/__main__.py @@ -147,7 +147,6 @@ class MainWindow(QMainWindow): def __init__(self, settings: Settings): super().__init__() - self._request_text = "" self._settings = settings self._restore_geometry_on_show = True @@ -251,14 +250,16 @@ def _on_close_tab_requested(self, index): def _get_file_path_to_export(self, caption: str, filter: str, file_suffix: str): current_workspace = self._main_tab_widget.currentWidget().current_workspace() - request_text = current_workspace.request_text if current_workspace.request_text else "export" + data_name = current_workspace.get_data_name() + if not data_name: + data_name = "export" if not current_workspace or len(current_workspace.model.result) == 0: QMessageBox.warning(self, app_name, self.tr("There is no data to export")) return "" last_save_dir = self._settings.get(Settings.LastSaveDir) file_name = QFileDialog.getSaveFileName(self, caption=caption, filter=filter, - dir=(last_save_dir + "/" + request_text + file_suffix)) + dir=(last_save_dir + "/" + data_name + file_suffix)) if not file_name[0]: return "" self._settings.set(Settings.LastSaveDir, QFileInfo(file_name[0]).dir().absolutePath()) diff --git a/youtubeanalyzer/search.py b/youtubeanalyzer/search.py index 24283bb..f143381 100644 --- a/youtubeanalyzer/search.py +++ b/youtubeanalyzer/search.py @@ -38,6 +38,9 @@ def load_state(self): super().load_state() self._search_line_edit.setFocus() + def get_data_name(self): + return self.request_text + def _create_toolbar(self, h_layout: QHBoxLayout): self._search_line_edit = SearchLineEdit() self._search_line_edit.returnPressed.connect(self._on_search_clicked) diff --git a/youtubeanalyzer/trends.py b/youtubeanalyzer/trends.py index 4a74c55..a9cafb4 100644 --- a/youtubeanalyzer/trends.py +++ b/youtubeanalyzer/trends.py @@ -56,6 +56,11 @@ def save_state(self): if self._category_combo_box.currentData(): self._settings.set(Settings.TrendsVideoCategoryId, int(self._category_combo_box.currentData())) + def get_data_name(self): + category = self._category_combo_box.currentText() + region = self._region_combo_box.currentData() + return (category + " " + region + " trends") if category and region else "" + def _create_toolbar(self, h_layout: QHBoxLayout): h_layout.addWidget(QLabel(self.tr("Category:"))) self._category_combo_box = QComboBox() diff --git a/youtubeanalyzer/widgets.py b/youtubeanalyzer/widgets.py index ae6ee78..f65d2f1 100644 --- a/youtubeanalyzer/widgets.py +++ b/youtubeanalyzer/widgets.py @@ -494,6 +494,9 @@ def __init__(self, settings: Settings, parent: QWidget = None): v_layout.addWidget(self._main_splitter) self.setLayout(v_layout) + def get_data_name(self): + raise "AbstractVideoTableWorkspace.get_data_name is not implemented" + def load_state(self): request_limit = int(self._settings.get(Settings.RequestLimit)) self._search_limit_spin_box.setValue(request_limit) From 16e6edb919602dc53f497805951f6ab76df9276c Mon Sep 17 00:00:00 2001 From: trots Date: Thu, 2 Jan 2025 16:39:22 +0300 Subject: [PATCH 12/13] Fix trends workflow if API key is not set --- youtubeanalyzer/trends.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/youtubeanalyzer/trends.py b/youtubeanalyzer/trends.py index a9cafb4..fea939c 100644 --- a/youtubeanalyzer/trends.py +++ b/youtubeanalyzer/trends.py @@ -61,6 +61,12 @@ def get_data_name(self): region = self._region_combo_box.currentData() return (category + " " + region + " trends") if category and region else "" + def handle_preferences_change(self): + super().handle_preferences_change() + api_key = self._settings.get(Settings.YouTubeApiKey) + if api_key and self._category_combo_box.count() == 0: + self._update_categories() + def _create_toolbar(self, h_layout: QHBoxLayout): h_layout.addWidget(QLabel(self.tr("Category:"))) self._category_combo_box = QComboBox() @@ -72,6 +78,7 @@ def _create_toolbar(self, h_layout: QHBoxLayout): self._region_combo_box.setToolTip(self.tr("Select the region to search trends")) for country in pycountry.countries: self._region_combo_box.addItem(country.name, country.alpha_2) + self._region_combo_box.setCurrentText("United States") h_layout.addWidget(self._region_combo_box) self._region_combo_box.currentIndexChanged.connect(self._update_categories) h_layout.addStretch(2) @@ -107,7 +114,7 @@ def _on_search_clicked(self): self._details_widget.clear() QApplication.instance().processEvents() - category_id = int(self._category_combo_box.currentData()) + category_id = self._category_combo_box.currentData() if not category_id: QApplication.restoreOverrideCursor() self.setDisabled(False) @@ -138,7 +145,7 @@ def _on_search_clicked(self): print("Request page limit is not set. Using '25' by default") engine = YoutubeApiEngine(api_key, self.model, request_limit, request_page_limit) - if engine.trends(category_id, region_code): + if engine.trends(int(category_id), region_code): for i in range(len(self.model.result)): video_idx = self._sort_model.index(i, self.model.get_field_column(ResultFields.VideoTitle)) video_item = self.model.result[i] From f5e7e10d52e80109b1a2a7ee2c156474cd5479f1 Mon Sep 17 00:00:00 2001 From: trots Date: Thu, 2 Jan 2025 16:47:57 +0300 Subject: [PATCH 13/13] Update CI script --- .github/workflows/python-app.yml | 7 ++++--- requirements.txt | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 65a7e3e..90e8ab7 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -19,13 +19,13 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.10 + - name: Set up Python 3.12 uses: actions/setup-python@v3 with: - python-version: "3.10" + python-version: "3.12" - name: Install dependencies run: | - sudo apt install -y freeglut3 freeglut3-dev + sudo apt install -y libegl1 python -m pip install --upgrade pip pip install flake8 if [ -f requirements.txt ]; then pip install -r requirements.txt; fi @@ -39,3 +39,4 @@ jobs: run: | python -m unittest tests.test_engines python -m unittest tests.test_export + python -m unittest tests.test_settings diff --git a/requirements.txt b/requirements.txt index 393865f..12a2086 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,7 +23,7 @@ isodate==0.6.1 Jinja2==3.1.3 macholib==1.16.3 MarkupSafe==2.1.4 -Nuitka==2.0.3 +Nuitka==2.5.9 numpy==1.26.4 ordered-set==4.1.0 packaging==23.2 @@ -38,13 +38,13 @@ pycountry==24.6.1 pyinstaller==6.4.0 pyinstaller-hooks-contrib==2024.0 pyparsing==3.1.1 -pypiwin32==223 +pypiwin32==223; platform_system=="Windows" PySide6==6.6.1 PySide6-Addons==6.6.1 PySide6-Essentials==6.6.1 pytest==8.3.4 -pywin32==306 -pywin32-ctypes==0.2.2 +pywin32==306; platform_system=="Windows" +pywin32-ctypes==0.2.2; platform_system=="Windows" requests==2.31.0 rsa==4.9 setuptools==69.0.3