diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index e8fdb3d..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 @@ -37,5 +37,6 @@ 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 + python -m unittest tests.test_settings 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 e04ab60..488fee1 100644 --- a/compile_executable.bat +++ b/compile_executable.bat @@ -1,7 +1,6 @@ -.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 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_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/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/requirements.txt b/requirements.txt index 2522b20..12a2086 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 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/test_engines.py b/tests/test_engines.py similarity index 99% rename from test_engines.py rename to tests/test_engines.py index 1635a74..75a6a15 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) @@ -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/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/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/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/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/youtube-analyzer.py b/youtube-analyzer.py deleted file mode 100644 index f26fb14..0000000 --- a/youtube-analyzer.py +++ /dev/null @@ -1,520 +0,0 @@ -import sys -import traceback - -from PySide6.QtCore import ( - Qt, - QFileInfo, - QSortFilterProxyModel, - QTranslator, - QLibraryInfo, - QItemSelection, - QTimer -) -from PySide6.QtGui import ( - QKeySequence, - QIcon, - QShowEvent, - QPalette, - QColor -) -from PySide6.QtWidgets import ( - QApplication, - QMainWindow, - QPushButton, - QHBoxLayout, - QVBoxLayout, - QWidget, - QTableView, - QFileDialog, - QSpinBox, - QDialog, - QGridLayout, - QLabel, - QSizePolicy, - QSpacerItem, - QMessageBox, - QCheckBox, - QSplitter, - QTextEdit, - QTabWidget -) -from PySide6.QtCharts import ( - QChart -) -from defines import ( - app_name, - version -) -from settings import ( - Settings, - SettingsDialog -) -from model import ( - ResultFields, - ResultTableModel -) -from engine import ( - YoutubeGrepEngine, - YoutubeApiEngine -) -from widgets import ( - VideoDetailsWidget, - AnalyticsWidget, - SearchLineEdit -) -from export import ( - export_to_xlsx, - export_to_csv, - export_to_html -) - - -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) - self.setWindowTitle(title) - self.setText(text) - self.setIcon(QMessageBox.Icon.Question) - self.addButton(QMessageBox.StandardButton.Yes) - self.addButton(QMessageBox.StandardButton.No) - self.setDefaultButton(QMessageBox.StandardButton.No) - self._check_box = QCheckBox(self.tr("Don't ask again")) - self.setCheckBox(self._check_box) - - def is_dont_ask_again(self): - return 1 if self._check_box.isChecked() else 0 - - -class AboutDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle(self.tr("About")) - layout = QGridLayout() - layout.setSizeConstraint(QGridLayout.SizeConstraint.SetFixedSize) - - row = 0 - title = QLabel(app_name) - title.setStyleSheet("font-size: 14px") - layout.addWidget(title, row, 0, 1, 2, Qt.AlignmentFlag.AlignCenter) - row += 1 - layout.addWidget(QLabel(self.tr("Software for analyzing of YouTube search output")), row, 0, 1, 2, - Qt.AlignmentFlag.AlignCenter) - row += 1 - layout.addWidget(QLabel(self.tr("Version: ") + version), - row, 0, 1, 2, Qt.AlignmentFlag.AlignCenter) - row += 1 - layout.addWidget(QLabel( - self.tr("Based on: PySide6, youtube-search-python,\n google-api-python-client, XlsxWriter, isodate.")), - row, 0, 1, 2, Qt.AlignmentFlag.AlignCenter) - row += 1 - vertical_spacer = QSpacerItem(1, 20, QSizePolicy.Minimum, QSizePolicy.Expanding) - layout.addItem(vertical_spacer, row, 0, 1, 2, Qt.AlignmentFlag.AlignCenter) - - row += 1 - layout.addWidget(QLabel(self.tr("Web site:")), row, 0, Qt.AlignmentFlag.AlignRight) - site = QLabel("https://github.com/trots/youtube-analyzer") - site.setOpenExternalLinks(True) - layout.addWidget(site, row, 1) - row += 1 - layout.addWidget(QLabel(self.tr("License:")), row, 0, Qt.AlignmentFlag.AlignRight) - lic = QLabel("MIT License") - lic.setOpenExternalLinks(True) - layout.addWidget(lic, row, 1) - - row += 1 - vertical_spacer = QSpacerItem(1, 20, QSizePolicy.Minimum, QSizePolicy.Expanding) - layout.addItem(vertical_spacer, row, 0, 1, 2, Qt.AlignmentFlag.AlignCenter) - - row += 1 - layout.addWidget(QLabel("Copyright 2023-2024 Alexander Trotsenko"), row, 0, 1, 2, - Qt.AlignmentFlag.AlignCenter) - row += 1 - layout.addWidget(QLabel(self.tr("All rights reserved")), row, 0, 1, 2, Qt.AlignmentFlag.AlignCenter) - - self.setLayout(layout) - - -class AuthorsDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle(self.tr("Authors")) - layout = QGridLayout() - layout.setSizeConstraint(QGridLayout.SizeConstraint.SetFixedSize) - - self._edit_text = QTextEdit() - self._edit_text.setReadOnly(True) - self._edit_text.append(self.tr("The YouTube Analyzer team, in alphabetical order:\n")) - self._edit_text.append("Alexander Trotsenko") - self._edit_text.append("Igor Trofimov") - self._edit_text.append("Nataliia Trotsenko") - - layout.addWidget(self._edit_text, 0, 0, 1, 2, - Qt.AlignmentFlag.AlignLeft) - self.setLayout(layout) - - -class MainWindow(QMainWindow): - def __init__(self, settings: Settings): - super().__init__() - self._request_text = "" - self._settings = settings - self._restore_geometry_on_show = True - - self.setWindowTitle(app_name + " " + version) - - file_menu = self.menuBar().addMenu(self.tr("File")) - export_xlsx_action = file_menu.addAction(self.tr("Export to XLSX...")) - export_xlsx_action.triggered.connect(self._on_export_xlsx) - export_csv_action = file_menu.addAction(self.tr("Export to CSV...")) - export_csv_action.triggered.connect(self._on_export_csv) - export_html_action = file_menu.addAction(self.tr("Export to HTML...")) - export_html_action.triggered.connect(self._on_export_html) - - file_menu.addSeparator() - exit_action = file_menu.addAction(self.tr("Exit")) - exit_action.setShortcut(QKeySequence(Qt.Modifier.CTRL | Qt.Key.Key_Q)) - exit_action.triggered.connect(self.close) - - edit_menu = self.menuBar().addMenu(self.tr("Edit")) - 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) - - help_menu = self.menuBar().addMenu(self.tr("Help")) - authors_action = help_menu.addAction(self.tr("Authors...")) - authors_action.triggered.connect(self._on_authors) - 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) - - central_widget = QWidget() - central_widget.setLayout(v_layout) - self.setCentralWidget(central_widget) - - 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) - 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 - if not app_need_restart and not int(self._settings.get(Settings.DontAskAgainExit)): - question = DontAskAgainQuestionDialog(app_name, self.tr("Exit?")) - if question.exec() == QMessageBox.StandardButton.No: - event.ignore() - return - self._settings.set(Settings.DontAskAgainExit, question.is_dont_ask_again()) - - event.accept() - self.save_state() - - 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) - - def _get_file_path_to_export(self, caption: str, filter: str, file_suffix: str): - if self._request_text == "" or len(self._model.result) == 0: - 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)) - if not file_name[0]: - return "" - self._settings.set(Settings.LastSaveDir, QFileInfo(file_name[0]).dir().absolutePath()) - return file_name[0] - - 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) - - 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) - - 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) - - def _on_preferences(self): - global app_need_restart - dialog = SettingsDialog(self._settings) - if dialog.exec() != SettingsDialog.DialogCode.Accepted: - 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()) - - if dialog.is_need_restart(): - app_need_restart = True - self.close() - - def _on_about(self): - dialog = AboutDialog(self) - dialog.exec() - - 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)) - if tb: - format_exception = traceback.format_tb(tb) - for line in format_exception: - dialog.setDetailedText(str(line)) - dialog.exec() - if window: - window.save_state() - app.exit(1) - - -sys.excepthook = top_exception_handler - -app = QApplication(sys.argv) -window = None -app.setWindowIcon(QIcon("logo.png")) -app.setStyle("Fusion") -settings = Settings(app_name) -Theme.apply(app, int(settings.get(Settings.Theme))) - -while True: - my_translator = QTranslator() - qt_translator = QTranslator() - 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) - - window = MainWindow(settings) - window.resize(app.screens()[0].size() * 0.7) - window.show() - app.exec() - - if app_need_restart: - app_need_restart = False - else: - break diff --git a/youtubeanalyzer/__init__.py b/youtubeanalyzer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/youtubeanalyzer/__main__.py b/youtubeanalyzer/__main__.py new file mode 100644 index 0000000..fb68d37 --- /dev/null +++ b/youtubeanalyzer/__main__.py @@ -0,0 +1,360 @@ +import sys +import traceback + +from PySide6.QtCore import ( + Qt, + QFileInfo, + QTranslator, + QLibraryInfo, + QKeyCombination +) +from PySide6.QtGui import ( + QIcon, + QShowEvent +) +from PySide6.QtWidgets import ( + QApplication, + QMainWindow, + QVBoxLayout, + QWidget, + QFileDialog, + QDialog, + QGridLayout, + QLabel, + QSizePolicy, + QSpacerItem, + QMessageBox, + QCheckBox, + QTextEdit, + QTabWidget, + QToolButton +) +from youtubeanalyzer.defines import ( + app_name, + version +) +from youtubeanalyzer.theme import ( + Theme +) +from youtubeanalyzer.settings import ( + Settings, + SettingsDialog +) +from youtubeanalyzer.widgets import ( + TabWidget +) +from youtubeanalyzer.search import ( + SearchWorkspaceFactory +) +from youtubeanalyzer.trends import ( + TrendsWorkspaceFactory +) +from youtubeanalyzer.export import ( + export_to_xlsx, + export_to_csv, + export_to_html +) + + +app_need_restart = False + + +class DontAskAgainQuestionDialog(QMessageBox): + def __init__(self, title: str, text: str, parent=None): + super().__init__(parent) + self.setWindowTitle(title) + self.setText(text) + self.setIcon(QMessageBox.Icon.Question) + self.addButton(QMessageBox.StandardButton.Yes) + self.addButton(QMessageBox.StandardButton.No) + self.setDefaultButton(QMessageBox.StandardButton.No) + self._check_box = QCheckBox(self.tr("Don't ask again")) + self.setCheckBox(self._check_box) + + def is_dont_ask_again(self): + return 1 if self._check_box.isChecked() else 0 + + +class AboutDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle(self.tr("About")) + layout = QGridLayout() + layout.setSizeConstraint(QGridLayout.SizeConstraint.SetFixedSize) + + row = 0 + title = QLabel(app_name) + title.setStyleSheet("font-size: 14px") + layout.addWidget(title, row, 0, 1, 2, Qt.AlignmentFlag.AlignCenter) + row += 1 + layout.addWidget(QLabel(self.tr("Software for analyzing of YouTube search output")), row, 0, 1, 2, + Qt.AlignmentFlag.AlignCenter) + row += 1 + layout.addWidget(QLabel(self.tr("Version: ") + version), + row, 0, 1, 2, Qt.AlignmentFlag.AlignCenter) + row += 1 + layout.addWidget(QLabel( + self.tr("Based on: PySide6, youtube-search-python,\n google-api-python-client, XlsxWriter, isodate.")), + row, 0, 1, 2, Qt.AlignmentFlag.AlignCenter) + row += 1 + vertical_spacer = QSpacerItem(1, 20, QSizePolicy.Minimum, QSizePolicy.Expanding) + layout.addItem(vertical_spacer, row, 0, 1, 2, Qt.AlignmentFlag.AlignCenter) + + row += 1 + layout.addWidget(QLabel(self.tr("Web site:")), row, 0, Qt.AlignmentFlag.AlignRight) + site = QLabel("https://github.com/trots/youtube-analyzer") + site.setOpenExternalLinks(True) + layout.addWidget(site, row, 1) + row += 1 + layout.addWidget(QLabel(self.tr("License:")), row, 0, Qt.AlignmentFlag.AlignRight) + lic = QLabel("MIT License") + lic.setOpenExternalLinks(True) + layout.addWidget(lic, row, 1) + + row += 1 + vertical_spacer = QSpacerItem(1, 20, QSizePolicy.Minimum, QSizePolicy.Expanding) + layout.addItem(vertical_spacer, row, 0, 1, 2, Qt.AlignmentFlag.AlignCenter) + + row += 1 + layout.addWidget(QLabel("Copyright 2023-2024 Alexander Trotsenko"), row, 0, 1, 2, + Qt.AlignmentFlag.AlignCenter) + row += 1 + layout.addWidget(QLabel(self.tr("All rights reserved")), row, 0, 1, 2, Qt.AlignmentFlag.AlignCenter) + + self.setLayout(layout) + + +class AuthorsDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle(self.tr("Authors")) + layout = QGridLayout() + layout.setSizeConstraint(QGridLayout.SizeConstraint.SetFixedSize) + + self._edit_text = QTextEdit() + self._edit_text.setReadOnly(True) + self._edit_text.append(self.tr("The YouTube Analyzer team, in alphabetical order:\n")) + self._edit_text.append("Alexander Trotsenko") + self._edit_text.append("Igor Trofimov") + self._edit_text.append("Nataliia Trotsenko") + + layout.addWidget(self._edit_text, 0, 0, 1, 2, + Qt.AlignmentFlag.AlignLeft) + self.setLayout(layout) + + +class MainWindow(QMainWindow): + def __init__(self, settings: Settings): + super().__init__() + + self._settings = settings + self._restore_geometry_on_show = True + + self.setWindowTitle(app_name + " " + version) + + file_menu = self.menuBar().addMenu(self.tr("File")) + export_xlsx_action = file_menu.addAction(self.tr("Export to XLSX...")) + export_xlsx_action.triggered.connect(self._on_export_xlsx) + export_csv_action = file_menu.addAction(self.tr("Export to CSV...")) + export_csv_action.triggered.connect(self._on_export_csv) + export_html_action = file_menu.addAction(self.tr("Export to HTML...")) + export_html_action.triggered.connect(self._on_export_html) + + file_menu.addSeparator() + exit_action = file_menu.addAction(self.tr("Exit")) + 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")) + preferences_action = edit_menu.addAction(self.tr("Preferences...")) + preferences_action.triggered.connect(self._on_preferences) + + 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...")) + authors_action.triggered.connect(self._on_authors) + about_action = help_menu.addAction(self.tr("About...")) + about_action.triggered.connect(self._on_about) + + v_layout = QVBoxLayout() + + 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) + + 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 + + def closeEvent(self, event): + global app_need_restart + if not app_need_restart and not int(self._settings.get(Settings.DontAskAgainExit)): + question = DontAskAgainQuestionDialog(app_name, self.tr("Exit?")) + if question.exec() == QMessageBox.StandardButton.No: + event.ignore() + return + self._settings.set(Settings.DontAskAgainExit, question.is_dont_ask_again()) + + event.accept() + self.save_state() + + def save_state(self): + self._settings.set(Settings.MainWindowGeometry, self.saveGeometry()) + 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): + current_workspace = self._main_tab_widget.currentWidget().current_workspace() + 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 + "/" + data_name + file_suffix)) + if not file_name[0]: + return "" + self._settings.set(Settings.LastSaveDir, QFileInfo(file_name[0]).dir().absolutePath()) + return file_name[0] + + 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: + 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: + 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: + 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 + dialog = SettingsDialog(self._settings) + if dialog.exec() != SettingsDialog.DialogCode.Accepted: + return + + Theme.apply(QApplication.instance(), int(self._settings.get(Settings.Theme))) + + 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 + self.close() + + def _on_about(self): + dialog = AboutDialog(self) + dialog.exec() + + def _on_authors(self): + dialog = AuthorsDialog(self) + dialog.exec() + + +def top_exception_handler(_exctype, value, tb): + dialog = QMessageBox(QMessageBox.Critical, app_name, str(value)) + if tb: + format_exception = traceback.format_tb(tb) + for line in format_exception: + dialog.setDetailedText(str(line)) + dialog.exec() + if window: + window.save_state() + app.exit(1) + + +sys.excepthook = top_exception_handler + +app = QApplication(sys.argv) +window = None +app.setWindowIcon(QIcon("logo.png")) +app.setStyle("Fusion") +settings = Settings(app_name) +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: + my_translator = QTranslator() + qt_translator = QTranslator() + if settings.get(Settings.Language) == "Ru": + my_lang = "translations/ru.qm" + qt_lang = "qtbase_ru.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) + + window = MainWindow(settings) + window.resize(app.screens()[0].size() * 0.7) + window.show() + app.exec() + + if app_need_restart: + app_need_restart = False + app.removeTranslator(my_translator) + app.removeTranslator(qt_translator) + else: + break 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 56% rename from engine.py rename to youtubeanalyzer/engine.py index e9213cc..fac03c4 100644 --- a/engine.py +++ b/youtubeanalyzer/engine.py @@ -21,11 +21,12 @@ Video ) import googleapiclient.discovery -from model import ( +from youtubeanalyzer.model import ( make_result_row, ResultFields, ResultTableModel, - DataCache + DataCache, + VideoCategory ) @@ -102,11 +103,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) @@ -147,17 +149,26 @@ def _handle_finished(self, reply: QNetworkReply): class AbstractYoutubeEngine: - def __init__(self, model: ResultTableModel, request_limit: int, request_timeout_sec: int = 10): - self.error = "" + 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): + 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): @@ -165,8 +176,9 @@ 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 @@ -200,9 +212,19 @@ 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 + 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: @@ -258,68 +280,99 @@ 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): + self.errorDetails = None + 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) 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 trends(self, category_id: int, region_code: str = "US"): + self.errorDetails = None + self.errorReason = None + try: + 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=self._results_per_page, + pageToken=page_token + ) + response = request.execute() + + def video_id_getter(item): return item["id"] + video_response, channels = self._get_response_details(youtube, response, video_id_getter) + + count = 0 + 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 >= self._request_limit: + break + + if total_count >= self._request_limit: + break + if "nextPageToken" not in response: + 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): @@ -349,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/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 98% rename from model.py rename to youtubeanalyzer/model.py index c1e6be3..06a3c36 100644 --- a/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 new file mode 100644 index 0000000..f143381 --- /dev/null +++ b/youtubeanalyzer/search.py @@ -0,0 +1,106 @@ +from PySide6.QtCore import ( + Qt +) +from PySide6.QtWidgets import ( + QApplication, + QHBoxLayout, + QWidget, + QPushButton +) +from youtubeanalyzer.defines import ( + app_name +) +from youtubeanalyzer.settings import ( + Settings +) +from youtubeanalyzer.engine import ( + YoutubeApiEngine, + YoutubeGrepEngine +) +from youtubeanalyzer.model import ( + ResultFields +) +from youtubeanalyzer.widgets import ( + critial_detailed_message, + create_link_label, + SearchLineEdit, + TabWorkspaceFactory, + AbstractVideoTableWorkspace +) + + +class SearchWorkspace(AbstractVideoTableWorkspace): + def __init__(self, settings: Settings, parent: QWidget = None): + super().__init__(settings, parent) + self.request_text = "" + + 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) + h_layout.addWidget(self._search_line_edit) + + def _on_search_clicked(self): + self.request_text = self._search_line_edit.text() + + if self.request_text == "": + return + + self.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 = 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("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.setDisabled(False) + + 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(api_key, self.model, request_limit) + + +class SearchWorkspaceFactory(TabWorkspaceFactory): + def __init__(self, parent: QWidget = None): + super().__init__(parent) + + def get_workspace_name(self) -> str: + return self.tr("Search") + + def create_workspace_button(self) -> QPushButton: + button = QPushButton(self.tr("Search video...")) + return button + + def create_workspace_widget(self, settings: Settings, parent: QWidget) -> QWidget: + return SearchWorkspace(settings, parent) diff --git a/settings.py b/youtubeanalyzer/settings.py similarity index 68% rename from settings.py rename to youtubeanalyzer/settings.py index 429e5ec..629e348 100644 --- a/settings.py +++ b/youtubeanalyzer/settings.py @@ -19,6 +19,9 @@ ) +CurrentSettingsVersion = 1 + + @dataclass class SettingsKey: key: str @@ -33,16 +36,28 @@ class Settings: YouTubeApiKey = SettingsKey("youtube_api_key", "") Language = SettingsKey("language", "") Theme = SettingsKey("theme", 0) - MainSplitterState = SettingsKey("main_splitter_state", [0, 0]) - DetailsVisible = SettingsKey("details", True) + 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) LastActiveChartIndex = SettingsKey("last_active_chart_index", 0) RequestTimeoutSec = SettingsKey("request_timeout_sec", 10) MainTableHeaderState = SettingsKey("main_table_header_state", QByteArray()) - - def __init__(self, app_name: str): - self._impl = QSettings(QSettings.Format.IniFormat, QSettings.Scope.UserScope, app_name) + MainTabsArray = SettingsKey("main_tabs", 0) + 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) + RequestPageLimit = SettingsKey("request_page_limit", 25) + + 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()) + self._upgrade_settings() def get(self, key: SettingsKey): if type(key.default_value) is bool: @@ -52,6 +67,43 @@ 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() + + 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() @@ -130,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) @@ -143,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/theme.py b/youtubeanalyzer/theme.py new file mode 100644 index 0000000..03de1bc --- /dev/null +++ b/youtubeanalyzer/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/youtubeanalyzer/trends.py b/youtubeanalyzer/trends.py new file mode 100644 index 0000000..fea939c --- /dev/null +++ b/youtubeanalyzer/trends.py @@ -0,0 +1,182 @@ +import pycountry +from PySide6.QtCore import ( + Qt, + QObject, + QTimer +) +from PySide6.QtWidgets import ( + QApplication, + QHBoxLayout, + QWidget, + QLabel, + QPushButton, + QComboBox, + QMessageBox +) +from youtubeanalyzer.defines import ( + app_name +) +from youtubeanalyzer.settings import ( + Settings +) +from youtubeanalyzer.engine import ( + YoutubeApiEngine +) +from youtubeanalyzer.model import ( + ResultFields +) +from youtubeanalyzer.widgets import ( + create_link_label, + critial_detailed_message, + TabWorkspaceFactory, + AbstractVideoTableWorkspace +) + + +class TrendsWorkspace(AbstractVideoTableWorkspace): + def __init__(self, settings: Settings, parent: QWidget = None): + super().__init__(settings, parent) + self._search_limit_spin_box.setMaximum(200) + + self._selected_category = None + self._loaded_category_id = None + + QTimer.singleShot(0, self, self._update_categories) + + def load_state(self): + 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): + 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 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 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() + 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) + 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) + + 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) + + self.model.clear() + self._sort_model.sort(-1) + self._details_widget.clear() + QApplication.instance().processEvents() + + category_id = 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._search_limit_spin_box.value() + if not request_limit: + 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(api_key, self.model, request_limit, request_page_limit) + 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] + 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) + + +class TrendsWorkspaceFactory(TabWorkspaceFactory): + def __init__(self, parent: QObject = None): + super().__init__(parent) + + def get_workspace_name(self) -> str: + return self.tr("Trends") + + def create_workspace_button(self) -> QPushButton: + button = QPushButton(self.tr("Search trends...")) + return button + + def create_workspace_widget(self, settings: Settings, parent: QWidget) -> QWidget: + return TrendsWorkspace(settings, parent) diff --git a/widgets.py b/youtubeanalyzer/widgets.py similarity index 56% rename from widgets.py rename to youtubeanalyzer/widgets.py index 37f4958..f65d2f1 100644 --- a/widgets.py +++ b/youtubeanalyzer/widgets.py @@ -1,10 +1,13 @@ from PySide6.QtCore import ( + QObject, QSize, Qt, QModelIndex, QUrl, QTimer, - QStringListModel + QStringListModel, + QSortFilterProxyModel, + QItemSelection ) from PySide6.QtGui import ( QImage, @@ -23,26 +26,59 @@ QStackedLayout, QComboBox, QLineEdit, - QCompleter + QCompleter, + QPushButton, + QTabWidget, + QSizePolicy, + QMessageBox, + QSpinBox, + QTableView, + QSplitter ) from PySide6.QtCharts import ( - QChartView + QChartView, + QChart ) -from engine import ( +from youtubeanalyzer.theme import ( + Theme +) +from youtubeanalyzer.settings import ( + Settings +) +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 ) +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) @@ -161,7 +197,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 +356,189 @@ def _on_editing_timeout(self): def _on_autocomplete_downloaded(self, autocomplete_list): self._autocomplete_model.setStringList(autocomplete_list) + + +class TabWorkspaceFactory(QObject): + def __init__(self, parent: QObject = 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) + + 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 + + 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) - 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 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) + # 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)