diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index f89a3e6..9f53249 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -1,4 +1,4 @@ -name: Build and Release (Portable + Installer) +name: Build & Release (Portable EXE + MSI) on: workflow_dispatch: @@ -10,247 +10,80 @@ permissions: env: RELEASE_DIR: releases - INSTALLER_SCRIPT: build/installer/StaTube.iss jobs: - ############################################### - # PORTABLE BUILD (onefile) - ############################################### - build-portable: - name: Build Portable (onefile) + build: runs-on: windows-latest steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - uses: actions/checkout@v4 - - name: Determine version from event - id: version - shell: bash - run: | - git fetch --tags - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - TAG=$(git describe --tags `git rev-list --tags --max-count=1`) - elif [[ "${{ github.event_name }}" == "release" ]]; then - TAG="${{ github.event.release.tag_name }}" - else - TAG="${{ github.ref_name }}" - fi - echo "TAG=$TAG" >> $GITHUB_OUTPUT - - - name: Setup Python 3.11 - uses: actions/setup-python@v4 + - uses: actions/setup-python@v4 with: python-version: "3.11" cache: pip - - name: Install requirements + Nuitka plugins + - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install "nuitka[plugins]" - - - name: Build Portable EXE (Nuitka) - shell: cmd - run: | - python -m nuitka ^ - --onefile ^ - --windows-disable-console ^ - --enable-plugin=pyside6 ^ - --windows-icon-from-ico=assets/icon/StaTube.ico ^ - --include-package=yt_dlp ^ - --include-module=yt_dlp.utils ^ - --include-package=nltk ^ - --include-package=wordcloud ^ - --include-package=utils ^ - --include-package-data=wordcloud ^ - --include-package-data=utils ^ - --include-package=scrapetube ^ - --nofollow-import-to=yt_dlp.extractor.lazy_extractors ^ - --include-data-dir=assets=assets ^ - --include-data-dir=UI=UI ^ - --include-data-file=UI/Style.qss=UI/Style.qss ^ - --include-data-file=Data/schema.sql=Data/schema.sql ^ - --assume-yes-for-downloads ^ - main.py - - - name: Validate portable build output - shell: pwsh - run: | - if (!(Test-Path "./main.exe")) { - throw "Portable exe not found." - } - - - name: Move and rename artifact - shell: pwsh - run: | - $version = "${{ steps.version.outputs.TAG }}" - New-Item -ItemType Directory -Force -Path $env:RELEASE_DIR | Out-Null - Move-Item "./main.exe" "./$env:RELEASE_DIR/StaTube-$version-portable.exe" -Force - - # ---------- NEW: UPLOAD PORTABLE ARTIFACT ---------- - - name: Upload portable artifact - uses: actions/upload-artifact@v4 - with: - name: portable - path: releases/*.exe - # ---------------------------------------------------- - - ############################################### - # INSTALLER BUILD (standalone) - ############################################### - build-installer: - name: Build Installer (standalone) - runs-on: windows-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Determine version from event - id: version - shell: bash - run: | - git fetch --tags - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - TAG=$(git describe --tags `git.rev-list --tags --max-count=1`) - elif [[ "${{ github.event_name }}" == "release" ]]; then - TAG="${{ github.event.release.tag_name }}" - else - TAG="${{ github.ref_name }}" - fi - echo "TAG=$TAG" >> $GITHUB_OUTPUT - - - name: Setup Python 3.11 - uses: actions/setup-python@v4 - with: - python-version: "3.11" - cache: pip - - - name: Install requirements + Nuitka plugins - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install "nuitka[plugins]" + pip install pyinstaller cx_Freeze + # ---------- Extract metadata ---------- - name: Extract metadata - id: meta shell: bash run: | python build/installer/extract_metadata.py main.py > meta.out - while IFS= read -r line; do echo "$line" >> $GITHUB_OUTPUT; done < meta.out + while IFS= read -r line; do + echo "$line" >> $GITHUB_ENV + done < meta.out - - name: Build Standalone Folder (Nuitka) - shell: cmd + # ---------- Portable EXE ---------- + - name: Build portable EXE (PyInstaller) run: | - python -m nuitka ^ - --standalone ^ - --output-dir=standalone_build ^ - --windows-disable-console ^ - --enable-plugin=pyside6 ^ - --windows-icon-from-ico=assets/icon/StaTube.ico ^ - --include-package=yt_dlp ^ - --include-module=yt_dlp.utils ^ - --include-package=nltk ^ - --include-package=wordcloud ^ - --include-package=utils ^ - --include-package-data=wordcloud ^ - --include-package-data=utils ^ - --include-package=scrapetube ^ - --nofollow-import-to=yt_dlp.extractor.lazy_extractors ^ - --include-data-dir=assets=assets ^ - --include-data-dir=UI=UI ^ - --include-data-file=UI/Style.qss=UI/Style.qss ^ - --include-data-file=Data/schema.sql=Data/schema.sql ^ - --assume-yes-for-downloads ^ - main.py + pyinstaller build/pyinstaller/StaTube.spec - - name: Validate standalone output + - name: Collect portable EXE shell: pwsh run: | - if (!(Test-Path "./standalone_build/main.dist")) { - throw "Standalone build folder missing!" - } + New-Item -ItemType Directory -Force $env:RELEASE_DIR | Out-Null + Move-Item dist/StaTube.exe "$env:RELEASE_DIR/${{ env.APP_NAME }}-${{ env.APP_VERSION }}-portable.exe" - - name: Create Installer Input Folder - shell: pwsh + # ---------- MSI Installer ---------- + - name: Build MSI installer (cx_Freeze) run: | - $version = "${{ steps.version.outputs.TAG }}" - New-Item -ItemType Directory -Force -Path $env:RELEASE_DIR | Out-Null - $target = "./$env:RELEASE_DIR/StaTube-$version-standalone" - if (Test-Path $target) { Remove-Item -Recurse -Force $target } - New-Item -ItemType Directory -Force -Path $target | Out-Null - Copy-Item -Recurse -Force "./standalone_build/main.dist/*" $target - - - name: Install Inno Setup - shell: pwsh - run: choco install innosetup -y + python build/cxfreeze/setup.py bdist_msi - - name: Build Installer (ISCC) + - name: Collect MSI shell: pwsh run: | - $version = "${{ steps.version.outputs.TAG }}" - $name = "${{ steps.meta.outputs.APP_NAME }}" - $appver = "${{ steps.meta.outputs.APP_VERSION }}" - $pub = "${{ steps.meta.outputs.APP_PUBLISHER }}" - $desc = "${{ steps.meta.outputs.APP_DESCRIPTION }}" + Move-Item dist/*.msi "$env:RELEASE_DIR/${{ env.APP_NAME }}-${{ env.APP_VERSION }}-setup.msi" - & "C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe" ` - /DMyAppName="$name" ` - /DMyAppVersion="$appver" ` - /DMyAppPublisher="$pub" ` - /DMyAppDescription="$desc" ` - /DMyAppTag="$version" ` - /DSourceDir="${{ github.workspace }}\\releases\\StaTube-$version-standalone" ` - /DOutputDir="${{ github.workspace }}\\releases" ` - "${{ github.workspace }}\\build\\installer\\StaTube.iss" - - - name: Validate installer output - shell: pwsh - run: | - $version = "${{ steps.version.outputs.TAG }}" - if (!(Test-Path "./$env:RELEASE_DIR/StaTube-$version-setup.exe")) { - throw "Installer not produced!" - } - - # ---------- NEW: UPLOAD INSTALLER ARTIFACT ---------- - - name: Upload installer artifact - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v4 with: - name: installer - path: releases/*.exe - # ---------------------------------------------------- + name: artifacts + path: releases/* - ############################################### - # CREATE RELEASE - ############################################### - create-release: + release: runs-on: ubuntu-latest - needs: [build-portable, build-installer] + needs: build steps: - - name: Download artifacts - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v4 with: - path: artifacts + name: artifacts - - name: Create GitHub Release + - name: Publish release assets (append notes) uses: softprops/action-gh-release@v1 with: - tag_name: ${{ github.event.release.tag_name }} - name: ${{ github.event.release.tag_name }} + append_body: true body: | - Automatic build for tag ${{ github.event.release.tag_name }} - - **Included:** - - Portable EXE - - Installer EXE - - Source code + --- + ### Build Artifacts + - Portable single-file EXE + - MSI installer (supports upgrade & uninstall) files: | - artifacts/**/* + *.exe + *.msi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Backend/ScrapeChannel.py b/Backend/ScrapeChannel.py index 70bf4e7..9950351 100644 --- a/Backend/ScrapeChannel.py +++ b/Backend/ScrapeChannel.py @@ -3,37 +3,66 @@ import threading import os from typing import Callable, Optional +import hashlib +import time from utils.AppState import app_state from utils.Logger import logger -def download_img(url: str, save_path: str) -> bool: - """ - Downloads an image from a given URL and saves it to the given save path. - Args: - url (str): URL of the image to download - save_path (str): Path where the image should be saved +def file_md5(path: str) -> str: + h = hashlib.md5() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return h.hexdigest() + - Returns: - bool: True if the image was downloaded and saved successfully, False otherwise +def download_img(url: str, save_path: str, retries: int = 3) -> bool: + """ + Downloads an image with retry + checksum validation. + Safe drop-in replacement. """ - try: - # Fix malformed URLs - if url.startswith("https:https://"): - url = url.replace("https:https://", "https://", 1) - - response = requests.get(str(url), timeout=15.0, stream=True) - response.raise_for_status() - with open(str(save_path), "wb") as f: - for chunk in response.iter_content(chunk_size=8192): - f.write(chunk) - return True - except Exception as e: - logger.error(f"Failed to download image: {url}") - logger.exception("Download image error:") + if not url: return False + # Fix malformed URLs + if url.startswith("https:https://"): + url = url.replace("https:https://", "https://", 1) + + for attempt in range(1, retries + 1): + try: + response = requests.get(url, timeout=15, stream=True) + response.raise_for_status() + + tmp_path = save_path + ".tmp" + with open(tmp_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + # Validate image size + if os.path.getsize(tmp_path) < 1024: + raise ValueError("Downloaded image too small") + + # If file already exists and checksum matches → skip replace + if os.path.exists(save_path): + if file_md5(tmp_path) == file_md5(save_path): + os.remove(tmp_path) + return True + + os.replace(tmp_path, save_path) + return True + + except Exception as e: + logger.warning(f"Image download failed (attempt {attempt}/{retries}): {url}") + logger.exception("Image download error:") + if attempt < retries: + time.sleep(0.5 * attempt) + + logger.error(f"Failed to download image after retries: {url}") + return False + + class Search: """ Class to handle searching for YouTube channels. @@ -67,42 +96,70 @@ def update_db(self, channel_id: str, title: str, sub_count: str, desc: str, prof Returns: bool: True if the channel was updated successfully, False otherwise """ - try: - profile_save_path = os.path.join(self.db.profile_pic_dir, f"{channel_id}.png") + profile_save_path = os.path.join(self.db.profile_pic_dir, f"{channel_id}.png") + + # Download only if missing or corrupted + needs_download = not os.path.exists(profile_save_path) + + success = False + if needs_download: success = download_img(profile_url, profile_save_path) - + if progress_callback and success: progress_callback(f"Downloaded profile for: {title}") - except Exception as e: - logger.error(f"Failed to save profile picture for {channel_id}: {e}") - logger.exception("Error saving profile picture:") - success = False - if channel_id: - url = f"https://www.youtube.com/channel/{channel_id}" - - # Use lock to safely update channels dictionary - with self.download_lock: - self.channels[channel_id] = {"title": title, "url": url, "sub_count": sub_count} + url = f"https://www.youtube.com/channel/{channel_id}" - # Check if channel already exists - existing_channels = self.db.fetch(table="CHANNEL", where="channel_id = ?", params=(channel_id,)) - - if not existing_channels: - # Channel doesn't exist, insert new one - self.db.insert( - "CHANNEL", - { - "channel_id": channel_id, - "name": title, - "url": url, - "sub_count": str(sub_count), - "desc": desc, - "profile_pic": profile_save_path, - }, - ) - logger.info(f"Added new channel: {title}") + with self.download_lock: + self.channels[channel_id] = { + "title": title, + "url": url, + "sub_count": sub_count, + } + + existing = self.db.fetch( + table="CHANNEL", + where="channel_id=?", + params=(channel_id,) + ) + + if not existing: + self.db.insert( + "CHANNEL", + { + "channel_id": channel_id, + "name": title, + "url": url, + "sub_count": str(sub_count), + "desc": desc, + "profile_pic": profile_save_path if success else None, + }, + ) + else: + update_fields = { + "name": title, + "sub_count": str(sub_count), + "desc": desc, + } + if success: + update_fields["profile_pic"] = profile_save_path + + self.db.update( + "CHANNEL", + update_fields, + where="channel_id=?", + params=(channel_id,) + ) + with self.download_lock: + self.completed_downloads += 1 + + if progress_callback: + progress = (self.completed_downloads / self.total_downloads) * 100 + progress_callback(progress, f"Processed {self.completed_downloads}/{self.total_downloads}") + + if self.completed_downloads >= self.total_downloads: + self.all_threads_complete.set() # Update completion counter with self.download_lock: self.completed_downloads += 1 @@ -152,7 +209,9 @@ def search_channel(self, name: str = None, limit: int = 6, stop_event=None, sub_count = ch.get("videoCountText", {}).get("accessibility", {}).get("accessibilityData", {}).get("label") desc = ch.get("descriptionSnippet", {}).get("runs")[0].get("text") if ch.get("descriptionSnippet") else None channel_id = ch.get("channelId") - profile_url = "https:" + ch.get("thumbnail", {}).get("thumbnails")[0].get("url") + thumbs = ch.get("thumbnail", {}).get("thumbnails", []) + raw_url = thumbs[0].get("url") if thumbs else None + profile_url = raw_url if raw_url and raw_url.startswith("http") else f"https:{raw_url}" if raw_url else None if channel_id: url = f"https://www.youtube.com/channel/{channel_id}" diff --git a/Backend/ScrapeVideo.py b/Backend/ScrapeVideo.py index 3b15c33..b4433b8 100644 --- a/Backend/ScrapeVideo.py +++ b/Backend/ScrapeVideo.py @@ -134,6 +134,7 @@ def extract_info(): return ydl.extract_info(f"https://www.youtube.com/shorts/{video_id}", download=False) info = await loop.run_in_executor(None, extract_info) + title = str(info.get('title', '') or 'Untitled') return { 'video_id': str(video_id), @@ -141,7 +142,7 @@ def extract_info(): 'upload_date': info.get('upload_date'), # YYYYMMDD or None 'description': str(info.get('description', '') or ''), 'view_count': int(info.get('view_count', 0) or 0), - 'title': str(info.get('title', '') or 'Untitled'), + 'title': str(title), } except Exception: logger.error(f"Failed to fetch metadata for short video: {video_id}") @@ -168,27 +169,25 @@ async def fetch_shorts_batch_async( async def fetch_with_progress(video_id: str): nonlocal completed - result = await fetch_shorts_metadata_async(str(video_id), session, semaphore) + result = await fetch_shorts_metadata_async(video_id, session, semaphore) completed += 1 if progress_callback: try: QMetaObject.invokeMethod( progress_callback, - "update_from_async", + "update_short_progress", Qt.QueuedConnection, Q_ARG(int, completed), - Q_ARG(int, total) + Q_ARG(int, total), + Q_ARG(str, result.get("title", "Untitled")) ) except Exception: - # fallback: call directly (shouldn't happen in Qt main thread) - try: - progress_callback.update_from_async(completed, total) - except Exception: - pass + pass return result + tasks = [fetch_with_progress(vid) for vid in video_ids] all_results = await asyncio.gather(*tasks, return_exceptions=True) @@ -247,19 +246,12 @@ def __init__(self, channel_id: str, channel_url: str, scrape_shorts: bool): @Slot() def run(self): """ - Entry point callable by a QThread. Uses asyncio.run for the coroutine root. - Guarantees finished signal in finally block of _fetch_video_urls_async. + Entry point callable by a QThread. """ try: asyncio.run(self._fetch_video_urls_async()) except Exception: logger.exception("VideoWorker crashed in run():") - finally: - # ensure finished if not already emitted - try: - self.finished.emit() - except Exception: - pass @Slot(int, int) def update_from_async(self, completed: int, total: int): @@ -281,6 +273,23 @@ def _should_stop(self): return QThread.currentThread().isInterruptionRequested() except Exception: return False + + @Slot(int, int, str) + def update_short_progress(self, completed: int, total: int, title: str): + """ + Receives per-short progress updates from async yt-dlp fetch. + """ + self.progress_updated.emit( + f"[Shorts] {completed}/{total}\n{title}" + ) + + try: + base = (self.current_type_counter - 1) * 33 + pct = base + int((completed / total) * 20) + except Exception: + pct = base + + self.progress_percentage.emit(min(pct, 95)) async def _fetch_video_urls_async(self): """ @@ -518,10 +527,16 @@ async def _fetch_video_urls_async(self): self.progress_percentage.emit(min(i * 33, 95)) self.progress_updated.emit(f"Completed scraping! Total {total_processed} videos saved.") - self.progress_percentage.emit(100) + self.progress_percentage.emit(99) except Exception: logger.exception("Async scrape failure") self.progress_updated.emit("Scraping failed — check logs.") self.progress_percentage.emit(0) # Do not swallow the exception silently — finalizer will emit finished + + finally: + try: + self.finished.emit() + except Exception: + pass diff --git a/Data/schema.sql b/Data/schema.sql index 3e61370..4b10e6c 100644 --- a/Data/schema.sql +++ b/Data/schema.sql @@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS CHANNEL ( channel_id TEXT PRIMARY KEY, name TEXT, url TEXT, - sub_count TEXT, + sub_count INTEGER, desc TEXT, profile_pic TEXT ); diff --git a/README.md b/README.md index 4e08e8e..44c4c5d 100644 --- a/README.md +++ b/README.md @@ -613,9 +613,11 @@ StaTube is protected under the [MIT License](https://choosealicense.com/licenses ## ✨ Acknowledgments - Built using the [PySide6](https://pypi.org/project/PySide6/) framework. + - YouTube data scraping powered by: - [yt-dlp](https://github.com/yt-dlp/yt-dlp) - [scrapetube](https://github.com/dermasmid/scrapetube) + - [youtube-transcript-api](https://github.com/jdepoix/youtube-transcript-api) - Compiled into windows binary using: - [Nuitka](https://nuitka.org/) - [Inno Setup](https://www.jrsoftware.org/isinfo.php) diff --git a/UI/AppStartup.py b/UI/AppStartup.py index 49c8745..c6ddfaf 100644 --- a/UI/AppStartup.py +++ b/UI/AppStartup.py @@ -64,9 +64,12 @@ def __init__(self): base_dir = os.path.dirname(os.path.abspath(__file__)) self.base_dir = os.path.dirname(base_dir) gif_path = os.path.join(self.base_dir, "assets", "splash", "loading.gif") + img_path = os.path.join(self.base_dir, "assets", "StaTube.png") - # parent=None to avoid QDialog parent type error - self.splash = SplashScreen(parent=None, gif_path=gif_path) + self.splash = SplashScreen( + parent=None, + img_path=img_path + ) self.splash.set_title("StaTube - YouTube Data Analysis Tool") self.splash.update_status("Booting system...") self.splash.set_progress(5) diff --git a/UI/Homepage.py b/UI/Homepage.py index c9c2108..7c0a49d 100644 --- a/UI/Homepage.py +++ b/UI/Homepage.py @@ -6,13 +6,25 @@ from PySide6.QtGui import QIcon import threading from typing import Optional, Dict, List, Any, Callable +import os from Data.DatabaseManager import DatabaseManager from Backend.ScrapeChannel import Search from utils.AppState import app_state from utils.Logger import logger +from utils.Formatters import parse_sub_count, format_sub_count from UI.SplashScreen import SplashScreen -import os + + +def get_channel_sub_count_safe(db, channel_id: str) -> int: + rows = db.fetch( + table="CHANNEL", + where="channel_id=?", + params=(channel_id,) + ) + if not rows: + return 0 + return parse_sub_count(rows[0].get("sub_count", 0)) class Home(QWidget): """ @@ -275,6 +287,10 @@ def update_results(self, channels: List[str]) -> None: if channels: self.model.setStringList(channels) self.completer.complete() + + def _set_item_icon_lazy(self, item: QListWidgetItem, path: str): + if path and os.path.exists(path): + item.setIcon(QIcon(path)) @QtCore.Slot() def on_search_complete(self) -> None: @@ -309,31 +325,44 @@ def update_channel_list(self) -> None: return # Create a copy of channels to avoid iteration issues - channels_copy = self.channels.copy() + channels_copy = list(self.channels.items()) + + channels_copy.sort( + key=lambda item: get_channel_sub_count_safe(self.db, item[0]), + reverse=True + ) - for channel_id, info in channels_copy.items(): + for channel_id, info in channels_copy: inf = self.db.fetch(table="CHANNEL", where="channel_id=?", params=(channel_id,)) if not inf: - # No DB row found — use sensible defaults and warn + logger.debug(f"Channel not yet in DB: {channel_id}") channel_name = info.get("title", "Unknown") - logger.warning(f"No DB entry for channel_id={channel_id}") - sub_count = 0 + sub_int = 0 profile_pic = None else: row = inf[0] - sub_count = row.get("sub_count") or 0 channel_name = row.get("name") or info.get("title", "Unknown") + sub_int = parse_sub_count(row.get("sub_count")) profile_pic = row.get("profile_pic") - icon = QIcon(profile_pic) if profile_pic else QIcon() - text_label = f'{channel_name}\n{sub_count}' - item = QListWidgetItem(icon, text_label) + sub_text = format_sub_count(sub_int) + text_label = f"{channel_name}\n{sub_text} subscribers" + cwd = os.getcwd() + default_avatar = os.path.join(cwd, "assets", "icon", "default_avatar.png") + placeholder = QIcon(default_avatar) + item = QListWidgetItem(placeholder, text_label) + + if profile_pic: + QtCore.QTimer.singleShot( + 0, lambda p=profile_pic, i=item: self._set_item_icon_lazy(i, p) + ) + item.setData(Qt.UserRole, { "channel_id": channel_id, "channel_name": channel_name, "channel_url": info.get('url'), "profile_pic": profile_pic, - "sub_count": sub_count + "sub_count": sub_int }) self.channel_list.addItem(item) diff --git a/UI/MainWindow.py b/UI/MainWindow.py index 8c633a1..6191367 100644 --- a/UI/MainWindow.py +++ b/UI/MainWindow.py @@ -4,10 +4,12 @@ from PySide6.QtWidgets import ( QApplication, QMainWindow, QStackedWidget, QFrame, QWidget, - QVBoxLayout, QHBoxLayout, QToolButton + QVBoxLayout, QHBoxLayout, QToolButton, + QMenuBar, QMenu, QToolBar, + QMessageBox, QDialog, QLabel, QPushButton ) -from PySide6.QtGui import QIcon -from PySide6.QtCore import Qt, QSize, QTimer +from PySide6.QtGui import QIcon, QDesktopServices, QAction +from PySide6.QtCore import Qt, QSize, QTimer, QUrl # ---- Import Pages ---- from .Homepage import Home @@ -29,36 +31,34 @@ class MainWindow(QMainWindow): Main window of the application. """ def __init__(self): - """ - Initializes the main window. - """ logger.info("MainWindow initialization started.") super().__init__() # Base dir and icon setup base_dir = os.path.dirname(os.path.abspath(__file__)) self.base_dir = os.path.dirname(base_dir) - icon_path = os.path.join(self.base_dir, "icon", "StaTube.ico") - gif_path = os.path.join(self.base_dir, "assets", "splash", "loading.gif") - logger.debug(f"Resolved application base directory: {self.base_dir}") - logger.debug(f"Using icon path: {icon_path}") + icon_path = os.path.join(self.base_dir, "assets", "icon", "StaTube.ico") + startup_img_path = os.path.join(self.base_dir, "assets", "StaTube.png") self.setWindowTitle("StaTube - YouTube Data Analysis Tool") self.setWindowIcon(QIcon(icon_path)) - # Initial geometry (window can be resized later) self.setGeometry(500, 200, 1000, 700) - # Placeholder central widget until UI is fully ready + # Central widget placeholder self.central_widget = QWidget() self.setCentralWidget(self.central_widget) - # Create stacked widget (pages will be added later in setup_ui) + # Stack & splash self.stack = QStackedWidget() - self.splash = SplashScreen(parent=self, gif_path=gif_path) + self.splash = SplashScreen(parent=self, img_path=startup_img_path) - # Sidebar button list + # Sidebar buttons self.sidebar_buttons = [] + # Menu + toolbar + self.setup_menu_and_toolbar() + + # ---------- Final init ---------- def finish_initialization(self): logger.info("Starting final initialization sequence.") @@ -67,8 +67,7 @@ def finish_initialization(self): self.splash.set_progress(40) self.splash.update_status("Connecting database...") - db = DatabaseManager() - app_state.db = db + app_state.db = DatabaseManager() self.splash.set_progress(70) self.splash.update_status("Building UI layout...") @@ -77,49 +76,67 @@ def finish_initialization(self): self.splash.update_status("Startup complete") - # ---------- Stylesheet ---------- - def load_stylesheet(self): - """ - Load and apply QSS stylesheet. - """ try: - # For Nuitka onefile builds, use sys.argv[0] if getattr(sys, 'frozen', False): - # Running as compiled executable base_dir = os.path.dirname(sys.argv[0]) else: - # Running as script base_dir = os.path.dirname(os.path.abspath(__file__)) - base_dir = os.path.dirname(base_dir) # Go up to project root + base_dir = os.path.dirname(base_dir) qss_path = os.path.join(base_dir, "UI", "Style.qss") - logger.debug(f"Attempting to load QSS stylesheet from: {qss_path}") - with open(qss_path, "r", encoding="utf-8") as f: self.setStyleSheet(f.read()) - logger.info("Stylesheet loaded successfully.") - - except FileNotFoundError: - logger.warning(f"Stylesheet not found at {qss_path}") - except Exception as e: - logger.exception("Error loading stylesheet:") + except Exception: + logger.exception("Error loading stylesheet") + + # ---------- Menu & Toolbar ---------- + def setup_menu_and_toolbar(self): + menubar = self.menuBar() + + # File + file_menu = menubar.addMenu("File") + exit_action = QAction("Exit", self) + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + # Settings + settings_menu = menubar.addMenu("Settings") + settings_action = QAction("Open Settings", self) + settings_action.triggered.connect(self.open_settings_page) + settings_menu.addAction(settings_action) + + # Help + help_menu = menubar.addMenu("Help") + docs_action = QAction("Documentation", self) + docs_action.triggered.connect(self.open_docs) + help_menu.addAction(docs_action) + + # About + about_menu = menubar.addMenu("About") + about_action = QAction("About StaTube", self) + about_action.triggered.connect(self.show_about_dialog) + about_menu.addAction(about_action) + + # Toolbar (text-only) + toolbar = QToolBar("Main Toolbar", self) + toolbar.setMovable(False) + self.addToolBar(Qt.TopToolBarArea, toolbar) + + toolbar.addAction(settings_action) + toolbar.addSeparator() + toolbar.addAction(docs_action) + toolbar.addSeparator() + toolbar.addAction(about_action) # ---------- UI Setup ---------- - def setup_ui(self): - """ - Setup the main UI once all startup tasks are done. - """ - logger.info("Setting up main UI components...") - # Root layout for central widget main_layout = QHBoxLayout(self.central_widget) main_layout.setContentsMargins(0, 0, 0, 0) - # Sidebar frame - logger.debug("Sidebar navigation buttons initialized.") + # Sidebar self.sidebar = QFrame() self.sidebar.setFixedWidth(80) side_layout = QVBoxLayout(self.sidebar) @@ -127,40 +144,35 @@ def setup_ui(self): side_layout.setContentsMargins(20, 0, 0, 0) side_layout.setSpacing(25) - # Icons path icon_path = os.path.join(self.base_dir, "assets", "icon", "light") - # Create buttons self.home_btn = QToolButton() self.video_btn = QToolButton() self.transcript_btn = QToolButton() self.comment_btn = QToolButton() - self.settings_btn = QToolButton() buttons_config = [ (self.home_btn, "light_home.ico", "Home"), (self.video_btn, "light_video.ico", "Videos"), (self.transcript_btn, "light_transcript.ico", "Transcription Analysis"), (self.comment_btn, "light_comment.ico", "Comment Analysis"), - (self.settings_btn, "light_settings.ico", "Settings"), ] - self.sidebar_buttons = [] - for i, (btn, filename, tooltip) in enumerate(buttons_config): - btn.setIcon(QIcon(os.path.join(icon_path, filename))) + self.sidebar_buttons.clear() + for i, (btn, icon, tooltip) in enumerate(buttons_config): + btn.setIcon(QIcon(os.path.join(icon_path, icon))) btn.setIconSize(QSize(28, 28)) btn.setToolTip(tooltip) btn.setCheckable(True) btn.setAutoExclusive(True) - btn.clicked.connect(lambda checked, idx=i: self.switch_page(idx)) + btn.clicked.connect(lambda _, idx=i: self.switch_page(idx)) side_layout.addWidget(btn) self.sidebar_buttons.append(btn) - # Add sidebar + stack main_layout.addWidget(self.sidebar) main_layout.addWidget(self.stack, stretch=1) - # Add pages + # Pages self.homepage = Home(self) self.video_page = Video(self) self.transcript_page = Transcript(self) @@ -171,75 +183,79 @@ def setup_ui(self): self.stack.addWidget(self.video_page) self.stack.addWidget(self.transcript_page) self.stack.addWidget(self.comment_page) - self.stack.addWidget(self.settings_page) - logger.debug("All pages instantiated and added to QStackedWidget.") - # Default page self.switch_page(0) - if self.sidebar_buttons: - self.sidebar_buttons[0].setChecked(True) + self.sidebar_buttons[0].setChecked(True) - # Cross-page signals + # Signals self.homepage.home_page_scrape_video_signal.connect(self.switch_and_scrape_video) self.video_page.video_page_scrape_transcript_signal.connect(self.switch_and_scrape_transcripts) self.video_page.video_page_scrape_comments_signal.connect(self.switch_and_scrape_comments) - logger.debug("Cross-page signals connected.") - logger.info("Main UI fully constructed.") + # ---------- Navigation ---------- + def switch_page(self, index: int): + self.stack.setCurrentIndex(index) - # ---------- Sidebar navigation ---------- + def open_settings_page(self): + if self.stack.indexOf(self.settings_page) == -1: + self.stack.addWidget(self.settings_page) + self.stack.setCurrentWidget(self.settings_page) - def switch_page(self, index: int): - """ - Switches to the specified page index. - """ - logger.debug(f"Switching to page index: {index}") - self.stack.setCurrentIndex(max(0, index)) - - def switch_and_scrape_video(self, scrape_shorts: bool = False): - """ - Switches to the video page and scrapes videos. - """ - if len(self.sidebar_buttons) >= 2: - self.sidebar_buttons[0].setChecked(False) - self.sidebar_buttons[1].setChecked(True) + def open_docs(self): + QDesktopServices.openUrl(QUrl("https://github.com/your-username/StaTube")) + + def show_about_dialog(self): + dialog = QDialog(self) + dialog.setWindowTitle("About StaTube") + dialog.setFixedSize(420, 260) + + layout = QVBoxLayout(dialog) + layout.setAlignment(Qt.AlignCenter) + + logo = QLabel() + logo_path = os.path.join(self.base_dir, "assets", "StaTube.png") + logo.setPixmap(QIcon(logo_path).pixmap(96, 96)) + logo.setAlignment(Qt.AlignCenter) + + title = QLabel("StaTube") + title.setAlignment(Qt.AlignCenter) + + version = QLabel("Version 1.0.0") + version.setAlignment(Qt.AlignCenter) + + desc = QLabel( + "Desktop application for YouTube\n" + "analytics, transcripts, and comments." + ) + desc.setAlignment(Qt.AlignCenter) + + close_btn = QPushButton("Close") + close_btn.clicked.connect(dialog.accept) + + layout.addWidget(logo) + layout.addWidget(title) + layout.addWidget(version) + layout.addWidget(desc) + layout.addSpacing(10) + layout.addWidget(close_btn) + + dialog.exec() + + # ---------- Cross-page helpers ---------- + def switch_and_scrape_video(self, scrape_shorts=False): + self.sidebar_buttons[1].setChecked(True) self.switch_page(1) - # Add a small delay to ensure page switch completes before showing splash - QTimer.singleShot(50, lambda: self.video_page.video_page_scrape_video_signal.emit(scrape_shorts)) + QTimer.singleShot( + 50, + lambda: self.video_page.video_page_scrape_video_signal.emit(scrape_shorts) + ) def switch_and_scrape_transcripts(self): - """ - Switches to the transcript page and scrapes transcripts. - """ - if len(self.sidebar_buttons) >= 3: - self.sidebar_buttons[1].setChecked(False) - self.sidebar_buttons[2].setChecked(True) + self.sidebar_buttons[2].setChecked(True) self.switch_page(2) self.transcript_page.transcript_page_scrape_transcripts_signal.emit() def switch_and_scrape_comments(self): - """ - Switches to the comment page and scrapes comments. - """ - if len(self.sidebar_buttons) >= 4: - self.sidebar_buttons[2].setChecked(False) - self.sidebar_buttons[3].setChecked(True) + self.sidebar_buttons[3].setChecked(True) self.switch_page(3) self.comment_page.comment_page_scrape_comments_signal.emit() - - # ---------- Close Event ---------- - - def closeEvent(self, event): - """ - Handle window close event (cleanup if needed). - """ - # If you need to close DB connections or save state, do it here. - super().closeEvent(event) - - -# Entry Point -if __name__ == "__main__": - app = QApplication(sys.argv) - window = MainWindow() - window.show() - sys.exit(app.exec()) diff --git a/UI/SettingsPage.py b/UI/SettingsPage.py index c958f4b..b4f3b0e 100644 --- a/UI/SettingsPage.py +++ b/UI/SettingsPage.py @@ -6,10 +6,4 @@ def __init__(self, parent=None): super(Settings, self).__init__(parent) self.main_layout = QVBoxLayout(self) - self.set_coming_soon() - def set_coming_soon(self): - coming_soon = QLabel("Settings Coming Soon") - coming_soon.setAlignment(QtCore.Qt.AlignCenter) - coming_soon.setStyleSheet("font-size: 30px;") - self.main_layout.addWidget(coming_soon) diff --git a/UI/SplashScreen.py b/UI/SplashScreen.py index 007ea31..9346890 100644 --- a/UI/SplashScreen.py +++ b/UI/SplashScreen.py @@ -1,17 +1,19 @@ from PySide6.QtWidgets import ( QDialog, QProgressBar, QLabel, QVBoxLayout, - QWidget, QPushButton + QWidget, QPushButton, QSizePolicy ) from PySide6.QtCore import ( Qt, QPropertyAnimation, QEasingCurve, Property, QEvent ) from PySide6.QtGui import ( - QFont, QPainter, QMovie, QColor, + QFont, QPainter, QMovie, QColor, QPixmap, QPen, QLinearGradient, QGuiApplication, QPalette ) import time +from utils.Logger import logger + class BlurOverlay(QWidget): """ @@ -51,7 +53,7 @@ class SplashScreen(QDialog): - Optional overlay dim & cancel button """ - def __init__(self, parent: QWidget | None = None, gif_path: str | None = None): + def __init__(self, parent: QWidget | None = None, gif_path: str | None = None, img_path: str | None = None): super().__init__(parent) self.overlay: BlurOverlay | None = None @@ -102,19 +104,33 @@ def __init__(self, parent: QWidget | None = None, gif_path: str | None = None): # GIF self.movie_label = QLabel(self) self.movie_label.setAlignment(Qt.AlignCenter) - self.movie_label.setFixedSize(200, 200) - self.movie: QMovie | None = None + self.movie_label.setSizePolicy( + QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + ) + + self.movie = None if gif_path: self.movie = QMovie(gif_path) if self.movie.isValid(): self.movie_label.setMovie(self.movie) self.movie.start() + logger.debug(f"Loaded GIF: {gif_path}") + else: + self._set_fallback_loader() + + elif img_path: + pixmap = QPixmap(img_path) + if not pixmap.isNull(): + self._logo_pixmap = pixmap # store original + self._update_logo_pixmap() + logger.debug(f"Loaded image: {img_path}") else: self._set_fallback_loader() else: self._set_fallback_loader() + layout.addWidget(self.movie_label, alignment=Qt.AlignCenter) # Progress bar @@ -154,8 +170,26 @@ def __init__(self, parent: QWidget | None = None, gif_path: str | None = None): self._fade_animation: QPropertyAnimation | None = None self._fade_in_animation: QPropertyAnimation | None = None - # ---------- Event Filter (track parent window) ---------- + def _update_logo_pixmap(self): + if not hasattr(self, "_logo_pixmap"): + return + + # Logo takes ~65% of splash height + target_height = int(self.height() * 0.65) + + scaled = self._logo_pixmap.scaled( + target_height, + target_height, + Qt.KeepAspectRatio, + Qt.SmoothTransformation + ) + self.movie_label.setPixmap(scaled) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_logo_pixmap() +# ---------- Event Filter (track parent window) ---------- def eventFilter(self, obj, event): """ Track the parent window so the splash: @@ -308,7 +342,19 @@ def update_status(self, message: str): self.status_label.setText(message) def set_progress(self, value: int): - self.progress_bar.setValue(int(value)) + value = max(0, min(100, int(value))) + + if not hasattr(self, "_progress_anim"): + self._progress_anim = QPropertyAnimation( + self.progress_bar, b"value", self + ) + self._progress_anim.setEasingCurve(QEasingCurve.OutCubic) + + self._progress_anim.stop() + self._progress_anim.setDuration(300) + self._progress_anim.setStartValue(self.progress_bar.value()) + self._progress_anim.setEndValue(value) + self._progress_anim.start() def update_eta(self, progress: int): if self.start_time is None or progress <= 0: diff --git a/UI/VideoPage.py b/UI/VideoPage.py index a7ef232..162fb91 100644 --- a/UI/VideoPage.py +++ b/UI/VideoPage.py @@ -386,7 +386,7 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self.comment_thread: Optional[QThread] = None self.comment_worker: Optional[CommentWorker] = None - self.video_page_scrape_video_signal.connect(self.scrape_videos) + self.video_page_scrape_video_signal.connect(lambda: self.scrape_videos(self.scrape_shorts_checkbox.isChecked())) # === Main layout === self.main_layout: QGridLayout = QGridLayout(self) @@ -570,7 +570,8 @@ def scrape_videos(self, scrape_shorts: bool) -> None: self.worker_thread.started.connect(self.worker.run) self.worker.progress_updated.connect(self.update_splash_progress) self.worker.progress_percentage.connect(self.update_splash_percentage) - self.worker.finished.connect(self.on_worker_finished) + self.worker.finished.connect(self.worker_thread.quit) + self.worker_thread.finished.connect(self.on_worker_finished) self.worker.finished.connect(self.worker_thread.quit) self.worker.finished.connect(self.worker.deleteLater) self.worker_thread.finished.connect(self.worker_thread.deleteLater) @@ -680,6 +681,7 @@ def on_worker_finished(self) -> None: self._clear_overlays() if self.splash: + self.splash.set_progress(100) self.splash.fade_and_close(400) self.splash = None diff --git a/assets/StaTube .png b/assets/StaTube.png similarity index 100% rename from assets/StaTube .png rename to assets/StaTube.png diff --git a/assets/icon/StaTube .png b/assets/icon/StaTube .png new file mode 100644 index 0000000..82b2fe2 Binary files /dev/null and b/assets/icon/StaTube .png differ diff --git a/assets/icon/default_avatar.png b/assets/icon/default_avatar.png new file mode 100644 index 0000000..2987951 Binary files /dev/null and b/assets/icon/default_avatar.png differ diff --git a/build/cxfreeze/setup.py b/build/cxfreeze/setup.py new file mode 100644 index 0000000..6aceedc --- /dev/null +++ b/build/cxfreeze/setup.py @@ -0,0 +1,62 @@ +from cx_Freeze import setup, Executable +import os +import sys + +# ---- Metadata injected by GitHub Actions ---- +APP_NAME = os.environ["APP_NAME"] +APP_VERSION = os.environ["APP_VERSION"] +APP_PUBLISHER = os.environ["APP_PUBLISHER"] +APP_DESCRIPTION = os.environ["APP_DESCRIPTION"] + +base = "Win32GUI" if sys.platform == "win32" else None + +build_exe_options = { + "packages": [ + "PySide6", + "yt_dlp", + "nltk", + "wordcloud", + "scrapetube", + "utils", + ], + "include_files": [ + ("assets", "assets"), + ("UI", "UI"), + ("Data/schema.sql", "Data/schema.sql"), + ("LICENSE", "LICENSE"), + ], + "include_msvcr": True, +} + +bdist_msi_options = { + # 🔒 DO NOT CHANGE once released + "upgrade_code": "{A6F3E7B2-5E0F-4B58-9D9C-STATUBE000001}", + "initial_target_dir": r"[ProgramFilesFolder]\StaTube", + "summary_data": { + "author": APP_PUBLISHER, + "comments": APP_DESCRIPTION, + }, +} + +executables = [ + Executable( + script="main.py", + base=base, + target_name="StaTube.exe", + icon="assets/icon/StaTube.ico", + shortcut_name=APP_NAME, + shortcut_dir="ProgramMenuFolder", + ) +] + +setup( + name=APP_NAME, + version=APP_VERSION, + description=APP_DESCRIPTION, + author=APP_PUBLISHER, + options={ + "build_exe": build_exe_options, + "bdist_msi": bdist_msi_options, + }, + executables=executables, +) diff --git a/build/pyinstaller/StaTube.spec b/build/pyinstaller/StaTube.spec new file mode 100644 index 0000000..0a678d0 --- /dev/null +++ b/build/pyinstaller/StaTube.spec @@ -0,0 +1,58 @@ +# PyInstaller spec for StaTube (PySide6, single-file portable EXE) + +from PyInstaller.utils.hooks import collect_all + +block_cipher = None + +# ---- PySide6 collection ---- +pyside6_datas, pyside6_binaries, pyside6_hiddenimports = collect_all("PySide6") + +# ---- Other dynamic / data-heavy libs ---- +yt_dlp_datas, yt_dlp_binaries, yt_dlp_hiddenimports = collect_all("yt_dlp") +nltk_datas, _, nltk_hiddenimports = collect_all("nltk") +wordcloud_datas, _, wordcloud_hiddenimports = collect_all("wordcloud") + +a = Analysis( + ["main.py"], + pathex=["."], + binaries=pyside6_binaries + yt_dlp_binaries, + datas=[ + ("assets", "assets"), + ("UI", "UI"), + ("Data/schema.sql", "Data/schema.sql"), + ("LICENSE", "."), + *pyside6_datas, + *yt_dlp_datas, + *nltk_datas, + *wordcloud_datas, + ], + hiddenimports=[ + *pyside6_hiddenimports, + *yt_dlp_hiddenimports, + *nltk_hiddenimports, + *wordcloud_hiddenimports, + "scrapetube", + "utils", + ], + hookspath=[], + runtime_hooks=[], + excludes=[], + cipher=block_cipher, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name="StaTube", + debug=False, + strip=False, + upx=True, + console=False, + icon="assets/icon/StaTube.ico", +) diff --git a/main.py b/main.py index d1c2403..57ea2d0 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,7 @@ from UI.AppStartup import AppStartup APP_NAME = "StaTube" -APP_VERSION = "0.4.2" +APP_VERSION = "0.4.4" APP_PUBLISHER = "StaTube" APP_DESCRIPTION = "A Python PySide6 GUI app for analyzing YouTube video transcripts and comments." diff --git a/utils/Formatters.py b/utils/Formatters.py new file mode 100644 index 0000000..cf11ec2 --- /dev/null +++ b/utils/Formatters.py @@ -0,0 +1,81 @@ +import re + +from .Logger import logger + +def parse_sub_count(value) -> int: + """ + Accepts: + - int / None + - 'none' + - '1 subscriber' + - '144 subscribers' + - '66.1K subscribers' + - '1.2M subscribers' + - '66.1 thousand' + - '1.16 million' + Returns int + """ + if value is None: + return 0 + + if isinstance(value, int): + return value + + if not isinstance(value, str): + return 0 + + text = ( + value.lower() + .replace(",", "") + .replace("subscribers", "") + .replace("subscriber", "") + .strip() + ) + + # Explicit "none" + if text in ("none", ""): + return 0 + + # --- word-based units --- + word_units = { + "thousand": 1_000, + "million": 1_000_000, + "billion": 1_000_000_000, + } + + for word, multiplier in word_units.items(): + if word in text: + try: + number = float(text.replace(word, "").strip()) + return int(number * multiplier) + except ValueError: + return 0 + + # --- short suffix units (K / M / B) --- + match = re.fullmatch(r"([\d]+(?:\.\d+)?)\s*([kmb]?)", text) + if not match: + return 0 + + number_str, suffix = match.groups() + + try: + number = float(number_str) + except ValueError: + logger.debug(f"Invalid number: {number_str}") + return 0 + + if suffix == "k": + return int(number * 1_000) + if suffix == "m": + return int(number * 1_000_000) + if suffix == "b": + return int(number * 1_000_000_000) + + return int(number) + +def format_sub_count(value: int) -> str: + if value >= 1_000_000: + return f"{value / 1_000_000:.1f}M" + if value >= 1_000: + return f"{value / 1_000:.1f}K" + return str(value)