f2a6f3c3e6
Introduces logic to enable or disable UI controls based on whether video analysis is complete, with appropriate tooltips for disabled states. Updates all language files to include new tooltip strings. Ensures controls like subtitle selection, SponsorBlock, and merge subtitles are only enabled after analysis, improving user guidance and preventing invalid actions.
465 lines
19 KiB
Python
465 lines
19 KiB
Python
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]
|