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
-
-
-
-
- Version:
-
-
-
-
- Software for analyzing of YouTube search output
-
-
-
-
- Based on: PySide6, youtube-search-python,
- google-api-python-client, XlsxWriter, isodate.
-
-
-
-
- Web site:
-
-
-
-
- License:
-
-
-
-
- All rights reserved
-
-
-
- AdvancedTab
-
-
-
- Request timeout in seconds:
-
-
-
-
- Set the maximum waiting time in seconds for YouTube request
-
-
-
- AnalyticsTab
-
-
-
- Follow table selections in analytics charts
-
-
-
-
- Highlight the selected item on the analytics charts
-
-
-
- AnalyticsWidget
-
-
-
- Channels distribution chart
-
-
-
-
- Video duration chart
-
-
-
-
- Popular title words chart
-
-
-
- AuthorsDialog
-
-
-
- Authors
-
-
-
-
- The YouTube Analyzer team, in alphabetical order:
-
-
-
-
- DontAskAgainQuestionDialog
-
-
-
- Don't ask again
-
-
-
- GeneralTab
-
-
-
- YouTube API Key:
-
-
-
-
- Set the key to use the YouTube API for YouTube search
-
-
-
-
- Language:
-
-
-
-
- Set the interface language
-
-
-
-
- Theme:
-
-
-
-
- Set the interface color theme
-
-
-
-
- System
-
-
-
-
- Dark
-
-
-
- MainWindow
-
-
-
- File
-
-
-
-
- Export to XLSX...
-
-
-
-
- Export to CSV...
-
-
-
-
- Export to HTML...
-
-
-
-
- Exit
-
-
-
-
- Edit
-
-
-
-
- Preferences...
-
-
-
-
- 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 File (*.xlsx)
-
-
-
-
- Save CSV
-
-
-
-
- Csv File (*.csv)
-
-
-
-
- Save HTML
-
-
-
-
- Html File (*.html)
-
-
-
- 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)
-
-
-
- SearchLineEdit
-
-
-
- Enter request and press 'Search'...
-
-
-
- SettingsDialog
-
-
-
- Settings
-
-
-
-
- General
-
-
-
-
- Analytics
-
-
-
-
- Advanced
-
-
-
-
- Restart the application now to apply the selected language?
-
-
-
- VideoDetailsWidget
-
-
-
- Tags:
-
-
-
-
- The video tags
-
-
-
-
- No tags
-
-
-
-
- Select a video to see its details
-
-
-
-
- subscribers
-
-
-
-
- views
-
-
-
-
- Download error:
-
-
-
- VideoDurationChart
-
-
-
- 5m
-
-
-
-
- 10m
-
-
-
-
- 15m
-
-
-
-
- 20m
-
-
-
-
- 30m
-
-
-
-
- 45m
-
-
-
-
- 1h
-
-
-
-
- 1,5h
-
-
-
-
- 2h
-
-
-
-
- 3h
-
-
-
-
- 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
-
+
О программе
-
+
Версия:
-
+
Программа для анализа поисковой выдачи YouTube
-
+
Основан на: PySide6, youtube-search-python,
google-api-python-client, XlsxWriter, isodate.
-
+
Веб-сайт:
-
+
Лицензия:
-
+
Все права защищены
+
+ AbstractVideoTableWorkspace
+
+
+
+ Установка максимального количества результатов поиска
+
+
+
+
+ Поиск
+
+
+
+
+ Начать поиск
+
+
+
+
+ Подробности
+
+
+
+
+ Аналитика
+
+
AdvancedTab
-
+
+
+ Максимальный размер страницы запроса:
+
+
+
+
+ Установить максимальный размер страницы для запроса YouTube
+
+
+
Время ожидания запроса в секундах:
-
+
Установить максимальное время ожидания запроса на YouTube в секундах
@@ -57,12 +95,12 @@
AnalyticsTab
-
+
Следовать в графиках за выделением в таблице
-
+
Подсвечивать выбранный в таблице элемент на графиках аналитики
@@ -70,17 +108,17 @@
AnalyticsWidget
-
+
График распределения каналов
-
+
График продолжительности видео
-
+
График популярных слов в заголовках
@@ -88,12 +126,12 @@
AuthorsDialog
-
+
Авторы
-
+
YouTube Analyzer team, в алфавитном порядке:
@@ -103,7 +141,7 @@
DontAskAgainQuestionDialog
-
+
Не спрашивать снова
@@ -111,42 +149,42 @@
GeneralTab
-
+
Ключ YouTube API:
-
+
Установите ключ для включения поиска через YouTube API
-
+
Язык:
-
+
Установите язык интерфейса
-
+
Тема:
-
+
Установите цветовую тему интерфейса
-
+
Системная
-
+
Тёмная
@@ -154,127 +192,140 @@
MainWindow
-
+
Файл
-
+
Экспорт в XLSX...
-
+
Экспорт в CSV...
-
+
Экспорт в HTML...
-
+
Выход
-
+
Правка
-
+
Настройки...
-
+
+
+ Окно
+
+
+
+
+
+ Создать новую вкладку
+
+
+
+
+ Новая вкладка
+
+
+
+
+ Нет данных для экспорта
+
+
- Вид
+ Вид
-
- Показать подробности
+ Показать подробности
-
+
Справка
-
+
Авторы...
-
+
О программе...
-
- Установка максимального количества результатов поиска
+ Установка максимального количества результатов поиска
-
- Поиск
+ Поиск
-
- Начать поиск
+ Начать поиск
-
- Подробности
+ Подробности
-
- Аналитика
+ Аналитика
-
+
Выйти?
-
- Поиск завершился с ошибкой
+ Поиск завершился с ошибкой
-
+
Сохранить XLSX
-
+
Xlsx-файл (*.xlsx)
-
+
Сохранить CSV
-
+
Csv-файл (*.csv)
-
+
Сохранить HTML
-
+
Html-файл (*.html)
@@ -282,162 +333,162 @@
ResultTableModel
-
+
Название
-
+
Время публикации
-
+
Продолжительность
-
+
Количество просмотров
-
+
Ссылка
-
+
Название канала
-
+
Ссылка на канал
-
+
Количество подписчиков
-
+
Количество просмотров на канале
-
+
Дата создания канала
-
+
Просмотры/Подписчики
-
+
Ссылка на превью
-
+
Ссылка на логотип канала
-
+
Теги видео
-
+
Дельта продолжительности видео
-
+
#
-
+
Название видео
-
+
Время публикации видео
-
+
Продолжительность видео
-
+
Количество просмотров видео
-
+
Ссылка на видео
-
+
Название канала
-
+
Ссылка на канал
-
+
Количество подписчиков на канале
-
+
Количество просмотров на канале
-
+
Дата регистрации канала
-
+
Отношение количества просмотров видео к количеству подписчиков канала в процентах
-
+
Ссылка на изображение превью
-
+
Ссылка на изображение логотипа канала
-
+
Список тегов видео
-
+
Дельта продолжительности видео
-
+
Релевантность видео в поисковой выдаче (0 обозначает наивысшую релевантность)
@@ -445,73 +496,151 @@
SearchLineEdit
-
+
Введите запрос и нажмите 'Поиск'...
+
+ SearchWorkspace
+
+
+
+ Поиск завершился с ошибкой
+
+
+
+ SearchWorkspaceFactory
+
+
+
+ Поиск
+
+
+
+
+ Поиск видео...
+
+
SettingsDialog
-
+
Настройки
-
+
Общие
-
+
Аналитика
-
+
Продвинутые
-
+
Перезапустить приложение для применения выбранного языка?
+
+ TrendsWorkspace
+
+
+
+ Не удалось загрузить категории видео
+
+
+
+
+ Категория:
+
+
+
+
+ Выберите категорию видео для поиска трендов
+
+
+
+
+ Регион:
+
+
+
+
+ Выберите регион для поиска трендов
+
+
+
+
+ Не удалось показать тренды. Не задана категория видео.
+
+
+
+
+ Не удалось показать тренды. Не установлен ключ YouTube API. Пожалуйста, установите его в настройках
+
+
+
+
+ Поиск по трендам завершился с ошибкой
+
+
+
+ TrendsWorkspaceFactory
+
+
+
+ Тренды
+
+
+
+
+ Поиск трендов...
+
+
VideoDetailsWidget
-
+
Теги:
-
+
Теги видео
-
+
Нет тегов
-
+
Выберите видео для просмотра подробностей
-
+
подписчиков
-
+
просмотров
-
+
Ошибка загрузки:
@@ -519,57 +648,57 @@
VideoDurationChart
-
+
5м
-
+
10м
-
+
15м
-
+
20м
-
+
30м
-
+
45м
-
+
1ч
-
+
1,5ч
-
+
2ч
-
+
3ч
-
+
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)