import re from datetime import datetime from io import BytesIO from pathlib import Path from typing import TYPE_CHECKING, cast import requests from PIL import Image from PySide6.QtCore import Qt from PySide6.QtGui import QPixmap from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.py SponsorBlockCategoryDialog, SubtitleSelectionDialog, ) from src.utils.ytsage_localization import _ from src.utils.ytsage_logger import logger if TYPE_CHECKING: from src.gui.ytsage_gui_main import YTSageApp class VideoInfoMixin: def setup_video_info_section(self) -> QHBoxLayout: self = cast("YTSageApp", self) # for autocompletion and type inference. # Create a horizontal layout for thumbnail and video info media_info_layout = QHBoxLayout() media_info_layout.setSpacing(15) # Left side container for thumbnail thumbnail_container = QWidget() thumbnail_container.setFixedWidth(320) thumbnail_layout = QVBoxLayout(thumbnail_container) thumbnail_layout.setContentsMargins(0, 0, 0, 0) # Thumbnail on the left self.thumbnail_label = QLabel() self.thumbnail_label.setFixedSize(320, 180) self.thumbnail_label.setStyleSheet("border: 2px solid #3d3d3d; border-radius: 4px;") self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter) thumbnail_layout.addWidget(self.thumbnail_label) thumbnail_layout.addStretch() media_info_layout.addWidget(thumbnail_container) # Video information on the right video_info_layout = QVBoxLayout() video_info_layout.setSpacing(2) # Reduce spacing between elements video_info_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Title and info labels self.title_label = QLabel() self.title_label.setWordWrap(True) self.title_label.setStyleSheet("font-size: 14px; font-weight: bold;") # Add basic info labels self.channel_label = QLabel() self.views_label = QLabel() self.date_label = QLabel() self.duration_label = QLabel() self.like_count_label = QLabel() # Style the info labels for label in [ self.channel_label, self.views_label, self.date_label, self.duration_label, self.like_count_label, ]: label.setStyleSheet( """ QLabel { color: #cccccc; font-size: 12px; padding: 1px 0; } """ ) # Add labels to video info layout video_info_layout.addWidget(self.title_label) video_info_layout.addWidget(self.channel_label) video_info_layout.addWidget(self.views_label) video_info_layout.addWidget(self.like_count_label) video_info_layout.addWidget(self.date_label) video_info_layout.addWidget(self.duration_label) # Add spacing before subtitle section video_info_layout.addSpacing(10) # --- Subtitle Section --- subtitle_layout = QHBoxLayout() subtitle_layout.setSpacing(10) # Subtitle selection button self.subtitle_select_btn = QPushButton(_("main_ui.select_subtitles")) # Renamed & changed text self.subtitle_select_btn.setFixedHeight(30) # self.subtitle_select_btn.setFixedWidth(150) # Let it size naturally or adjust as needed self.subtitle_select_btn.clicked.connect(self.open_subtitle_dialog) self.subtitle_select_btn.setStyleSheet( """ QPushButton { background-color: #1d1e22; border: 2px solid #1d1e22; border-radius: 4px; padding: 5px 10px; /* Adjusted padding */ } QPushButton:hover { background-color: #2a2d36; } /* Optional: Style differently if subtitles ARE selected */ QPushButton[subtitlesSelected="true"] { border-color: #c90000; /* Indicate selection */ } /* Style for disabled state */ QPushButton:disabled { background-color: #3d3d3d; color: #888888; border-color: #3d3d3d; } """ ) self.subtitle_select_btn.setProperty("subtitlesSelected", False) # Custom property for styling subtitle_layout.addWidget(self.subtitle_select_btn) # Label to show number of selected subtitles self.selected_subs_label = QLabel(_("selection.none_selected")) self.selected_subs_label.setStyleSheet("color: #cccccc; padding-left: 5px;") subtitle_layout.addWidget(self.selected_subs_label) # Add the subtitle layout to the main video info layout video_info_layout.addLayout(subtitle_layout) # --- End Subtitle Section --- # Add small spacing between subtitle and sponsorblock sections video_info_layout.addSpacing(4) # --- SponsorBlock Section --- sponsorblock_layout = QHBoxLayout() self.sponsorblock_select_btn = QPushButton(_("main_ui.sponsorblock_categories")) self.sponsorblock_select_btn.setFixedHeight(30) self.sponsorblock_select_btn.clicked.connect(self.open_sponsorblock_dialog) self.sponsorblock_select_btn.setStyleSheet( """ QPushButton { background-color: #1d1e22; border: 2px solid #1d1e22; border-radius: 4px; padding: 5px 10px; } QPushButton:hover { background-color: #2a2d36; } QPushButton[sponsorBlockSelected="true"] { border-color: #c90000; } QPushButton:disabled { background-color: #3d3d3d; color: #888888; border-color: #3d3d3d; } """ ) self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", False) self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", False) sponsorblock_layout.addWidget(self.sponsorblock_select_btn) # Label to show selection count self.selected_sponsorblock_label = QLabel(_("selection.none_selected")) self.selected_sponsorblock_label.setStyleSheet("color: #cccccc; padding-left: 5px;") sponsorblock_layout.addWidget(self.selected_sponsorblock_label) sponsorblock_layout.addStretch() # Add the sponsorblock layout to the main video info layout video_info_layout.addLayout(sponsorblock_layout) # --- End SponsorBlock Section --- # Initialize SponsorBlock categories as empty initially (will be set to defaults when user opens dialog) self.selected_sponsorblock_categories = [] self._update_sponsorblock_display() # Add stretch at the bottom video_info_layout.addStretch() # Add video info layout to main layout media_info_layout.addLayout(video_info_layout, stretch=1) return media_info_layout def setup_playlist_info_section(self) -> QLabel: self = cast("YTSageApp", self) # for autocompletion and type inference. self.playlist_info_label = QLabel() self.playlist_info_label.setVisible(False) self.playlist_info_label.setStyleSheet( """ QLabel { font-size: 12px; color: #ffffff; padding: 5px 8px; margin: 0; background-color: #1d1e22; border: 1px solid #c90000; border-radius: 4px; min-height: 30px; max-height: 30px; } """ ) self.playlist_info_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) return self.playlist_info_label def update_video_info(self, info) -> None: self = cast("YTSageApp", self) # for autocompletion and type inference. if hasattr(self, "is_playlist") and self.is_playlist: # Playlist Mode: Show playlist title and video count self.title_label.setText(self.playlist_info.get("title", _("playlist.unknown"))) num_videos = len(getattr(self, "playlist_entries", [])) self.duration_label.setText(_("playlist.total_videos", count=num_videos)) # Hide video-specific info self.channel_label.setText("") self.views_label.setText("") self.date_label.setText("") self.like_count_label.setText("") self.channel_label.setVisible(False) self.views_label.setVisible(False) self.date_label.setVisible(False) self.like_count_label.setVisible(False) else: # Single Video Mode: Show standard video info # Ensure labels are visible first self.channel_label.setVisible(True) self.views_label.setVisible(True) self.date_label.setVisible(True) self.like_count_label.setVisible(True) # Format view count with commas views = info.get("view_count") formatted_views = f"{views:,}" if views is not None else "N/A" # Format like count with commas likes = info.get("like_count") formatted_likes = f"{likes:,}" if likes is not None else "N/A" # Format upload date upload_date = info.get("upload_date", "") if upload_date: date_obj = datetime.strptime(upload_date, "%Y%m%d") formatted_date = date_obj.strftime("%B %d, %Y") else: formatted_date = _("video_info.unknown_date") # Format duration duration = info.get("duration", 0) minutes = duration // 60 seconds = duration % 60 duration_str = f"{minutes}:{seconds:02d}" # Update labels with localized text self.title_label.setText(info.get("title", _("video_info.unknown_title"))) self.channel_label.setText(f"{_("video_info.channel")}: {info.get('uploader', _("video_info.unknown_channel"))}") self.views_label.setText(f"{_("video_info.views")}: {formatted_views}") self.like_count_label.setText(f"{_("video_info.likes")}: {formatted_likes}") self.date_label.setText(f"{_("video_info.upload_date")}: {formatted_date}") self.duration_label.setText(f"{_("video_info.duration")}: {duration_str}") def open_subtitle_dialog(self) -> None: self = cast("YTSageApp", self) # for autocompletion and type inference. if not hasattr(self, "available_subtitles") or not hasattr(self, "available_automatic_subtitles"): logger.warning("Subtitle info not loaded yet.") return if not hasattr(self, "selected_subtitles"): self.selected_subtitles = [] dialog = SubtitleSelectionDialog( self.available_subtitles, # type: ignore[reportAttributeAccessIssue] self.available_automatic_subtitles, # type: ignore[reportAttributeAccessIssue] self.selected_subtitles, self, # Parent for the dialog ) # removed extra logic for mapping to main_windows merge_checkbox = getattr(self, "merge_subs_checkbox", None) if dialog.exec(): # If user clicks OK self.selected_subtitles = dialog.get_selected_subtitles() logger.info(f"Selected subtitles: {self.selected_subtitles}") # Update UI to reflect selection count = len(self.selected_subtitles) self.selected_subs_label.setText(_("subtitle_selection.count_selected", count=count)) self.subtitle_select_btn.setProperty("subtitlesSelected", count > 0) # Enable/disable the merge checkbox in the parent window if merge_checkbox: # Only enable merge checkbox if we're not in Audio Only mode and analysis is complete is_audio_only = hasattr(self, "audio_button") and self.audio_button.isChecked() has_analysis = getattr(self, "analysis_completed", False) # In audio-only mode, we still allow subtitle selection but not merging should_enable = count > 0 and not is_audio_only and has_analysis merge_checkbox.setEnabled(should_enable) # Update tooltip if not has_analysis: merge_checkbox.setToolTip(_("main_ui.analyze_first_tooltip")) elif is_audio_only: merge_checkbox.setToolTip(_("main_ui.audio_mode_disabled")) elif count == 0: merge_checkbox.setToolTip(_("main_ui.select_subtitles_first")) else: merge_checkbox.setToolTip("") else: logger.warning("merge_subs_checkbox not found on parent window.") # Re-apply stylesheet to update button border if property changed self.subtitle_select_btn.style().unpolish(self.subtitle_select_btn) self.subtitle_select_btn.style().polish(self.subtitle_select_btn) # No else needed for cancel, state remains unchanged def open_sponsorblock_dialog(self) -> None: """Open the SponsorBlock category selection dialog.""" self = cast("YTSageApp", self) # for autocompletion and type inference. # Initialize selected categories if not exists or empty (first time opening) if not hasattr(self, "selected_sponsorblock_categories") or not self.selected_sponsorblock_categories: # Use None to let the dialog set its own defaults dialog_categories = None else: dialog_categories = self.selected_sponsorblock_categories dialog = SponsorBlockCategoryDialog(dialog_categories, self) if dialog.exec(): self.selected_sponsorblock_categories = dialog.get_selected_categories() logger.info(f"SponsorBlock categories selected: {self.selected_sponsorblock_categories}") self._update_sponsorblock_display() def _update_sponsorblock_display(self) -> None: """Update the SponsorBlock button and label to reflect current selection.""" self = cast("YTSageApp", self) # for autocompletion and type inference. if not hasattr(self, "selected_sponsorblock_categories"): self.selected_sponsorblock_categories = [] count = len(self.selected_sponsorblock_categories) # Update label text if count == 0: self.selected_sponsorblock_label.setText(_("selection.none_selected")) elif count == 1: self.selected_sponsorblock_label.setText(_("selection.one_selected")) else: self.selected_sponsorblock_label.setText(_("selection.count_selected", count=count)) # Update button property for styling self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", count > 0) # Force style refresh self.sponsorblock_select_btn.style().unpolish(self.sponsorblock_select_btn) self.sponsorblock_select_btn.style().polish(self.sponsorblock_select_btn) def download_thumbnail(self, url) -> None: self = cast("YTSageApp", self) # for autocompletion and type inference. try: # Store both thumbnail URL and video URL self.thumbnail_url = url self.video_url = self.url_input.text() # Get actual video URL # Download thumbnail but don't save yet response = requests.get(url) self.thumbnail_image = Image.open(BytesIO(response.content)) # Display thumbnail image = self.thumbnail_image.resize((320, 180), Image.Resampling.LANCZOS) img_byte_arr = BytesIO() image.save(img_byte_arr, format="PNG") pixmap = QPixmap() pixmap.loadFromData(img_byte_arr.getvalue()) self.thumbnail_label.setPixmap(pixmap) except Exception as e: logger.exception(f"Error loading thumbnail: {e}") def download_thumbnail_file(self, video_url, path) -> bool: self = cast("YTSageApp", self) # for autocompletion and type inference. def download_thumbnail_file(self, video_url, path) -> bool: if not self.save_thumbnail: return False try: # Note: yt_dlp Python package removed - this feature now uses subprocess # Extract thumbnail info using yt-dlp CLI instead from src.core.ytsage_yt_dlp import get_yt_dlp_path from src.utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS import subprocess import json logger.debug(f"Attempting to save thumbnail for URL: {video_url}") ytdlp_path = get_yt_dlp_path() # Use yt-dlp CLI to extract thumbnail info result = subprocess.run( [ytdlp_path, "--dump-json", "--skip-download", video_url], capture_output=True, text=True, timeout=30, creationflags=SUBPROCESS_CREATIONFLAGS, ) if result.returncode != 0: logger.error(f"Failed to extract video info: {result.stderr}") return False info = json.loads(result.stdout) thumbnails = info.get("thumbnails", []) if not thumbnails: logger.info("No thumbnails available") return False thumbnail_url = max( thumbnails, key=lambda t: (t.get("height", 0) or 0) * (t.get("width", 0) or 0), ).get("url") if not thumbnail_url: logger.info("Failed to extract thumbnail URL") return False # Download using requests response = requests.get(thumbnail_url) response.raise_for_status() # Save the thumbnail thumb_dir = Path(path).joinpath("Thumbnails") thumb_dir.mkdir(exist_ok=True) filename = f"{self.sanitize_filename(info['title'])}.jpg" thumbnail_path = thumb_dir.joinpath(filename) with open(thumbnail_path, "wb") as f: f.write(response.content) logger.info(f"Thumbnail saved to: {thumbnail_path}") self.signals.update_status.emit(f"✅ Thumbnail saved: {filename}") return True except Exception as e: error_msg = f"❌ Thumbnail error: {e}" logger.exception(f"Thumbnail Save Error: {e}") self.signals.update_status.emit(error_msg) return False def sanitize_filename(self, name) -> str: """Clean filename for filesystem safety""" return re.sub(r'[\\/*?:"<>|]', "", name).strip()[:75]