Files
SageTube/src/gui/ytsage_gui_dialogs/ytsage_dialogs_history.py
T
oop7 e2c4dfea4e Increase menu button area in history delegate
Expanded the clickable area for the menu button in HistoryDelegate to 60x50px and adjusted text width accordingly. This improves usability and addresses issues with the left side of the button not responding to clicks.
2026-01-21 18:10:34 +02:00

577 lines
20 KiB
Python

"""
History Dialog for YTSage application.
Displays download history with thumbnails using a virtualized list for performance.
"""
import os
import subprocess
import json
from datetime import datetime
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Optional, List, Any, Dict
import requests
from PIL import Image
from PySide6.QtCore import (
Qt, QSize, Signal, QThread, QTimer, QAbstractListModel,
QModelIndex, QRect, QPoint, QEvent
)
from PySide6.QtGui import (
QPixmap, QIcon, QPainter, QColor, QFont, QBrush, QPen,
QMouseEvent, QDesktopServices, QAction, QCursor, QPainterPath
)
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QListView,
QWidget,
QMenu,
QMessageBox,
QStyledItemDelegate,
QStyle,
QApplication
)
from src.utils.ytsage_history_manager import HistoryManager
from src.utils.ytsage_constants import APP_THUMBNAILS_DIR, SUBPROCESS_CREATIONFLAGS
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 HistoryLoaderThread(QThread):
"""Thread to load history and pre-fetch thumbnails."""
entries_loaded = Signal(list)
thumbnail_loaded = Signal(str, bytes) # entry_id, image_bytes
def run(self):
try:
# Load entries from DB
entries = HistoryManager.get_all_entries()
self.entries_loaded.emit(entries)
# Background thumbnail loader
for entry in entries:
if self.isInterruptionRequested():
break
thumbnail_url = entry.get("thumbnail_url")
entry_id = entry.get("id", "")
if not thumbnail_url or not entry_id:
continue
thumbnail_filename = f"{entry_id}.jpg"
thumbnail_path = APP_THUMBNAILS_DIR / thumbnail_filename
if not thumbnail_path.exists():
try:
response = requests.get(thumbnail_url, timeout=5)
if response.status_code == 200:
APP_THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
# Optimize image before saving
img_io = BytesIO(response.content)
image = Image.open(img_io)
# Save to disk
image.save(thumbnail_path, "JPEG", quality=90, optimize=True)
# Emit bytes for memory cache
self.thumbnail_loaded.emit(entry_id, response.content)
except Exception as e:
logger.debug(f"Error caching thumbnail: {e}")
except Exception as e:
logger.error(f"Error loading history: {e}")
self.entries_loaded.emit([])
class HistoryModel(QAbstractListModel):
"""List Model for History Entries."""
EntryRole = Qt.ItemDataRole.UserRole + 1
IdRole = Qt.ItemDataRole.UserRole + 2
ThumbnailRole = Qt.ItemDataRole.UserRole + 3
def __init__(self, entries=None, parent=None):
super().__init__(parent)
self._entries = entries or []
self.thumbnail_cache = {} # Map entry_id -> QPixmap
def rowCount(self, parent=QModelIndex()):
return len(self._entries)
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid() or not (0 <= index.row() < len(self._entries)):
return None
entry = self._entries[index.row()]
entry_id = entry.get("id")
if role == self.EntryRole:
return entry
elif role == self.IdRole:
return entry_id
elif role == self.ThumbnailRole:
return self.thumbnail_cache.get(entry_id)
elif role == Qt.ItemDataRole.DisplayRole:
return entry.get("title", "")
return None
def update_entries(self, entries):
self.beginResetModel()
self._entries = entries
self.endResetModel()
def remove_item(self, row):
if 0 <= row < len(self._entries):
self.beginRemoveRows(QModelIndex(), row, row)
del self._entries[row]
self.endRemoveRows()
def update_thumbnail(self, entry_id, pixmap):
"""Update cache and notify view."""
self.thumbnail_cache[entry_id] = pixmap
# Find index for this ID
for i, entry in enumerate(self._entries):
if entry.get("id") == entry_id:
idx = self.index(i)
self.dataChanged.emit(idx, idx, [self.ThumbnailRole])
break
class HistoryDelegate(QStyledItemDelegate):
"""Delegate to render history cards similar to the widgets."""
menu_clicked = Signal(QModelIndex, QPoint) # Signal for menu click
def __init__(self, parent=None):
super().__init__(parent)
self.padding = 10
self.thumb_width = 240
self.thumb_height = 135
# Increased card height to accommodate spacing
self.card_height = 175
# Define margins for spacing between cards
self.h_margin = 10
self.v_margin = 8
def sizeHint(self, option, index):
return QSize(option.rect.width(), self.card_height)
def paint(self, painter, option, index):
entry = index.data(HistoryModel.EntryRole)
if not entry:
return
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = option.rect
# Apply margins for spacing
card_rect = rect.adjusted(self.h_margin, self.v_margin, -self.h_margin, -self.v_margin)
is_hover = option.state & QStyle.StateFlag.State_MouseOver
bg_color = QColor("#252830") if is_hover else QColor("#1d1e22")
border_color = QColor("#3a3d46") if is_hover else QColor("#2a2d36")
# Draw Card
path = QPainterPath()
path.addRoundedRect(card_rect, 8, 8)
painter.fillPath(path, QBrush(bg_color))
painter.setPen(QPen(border_color, 1))
painter.drawPath(path)
# Draw Thumbnail
thumb_rect = QRect(
card_rect.left() + 10,
card_rect.top() + 10,
self.thumb_width,
self.thumb_height
)
pixmap = index.data(HistoryModel.ThumbnailRole)
if pixmap and not pixmap.isNull():
scaled = pixmap.scaled(
thumb_rect.size(),
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation
)
# Clip to rect
painter.setClipRect(thumb_rect)
painter.drawPixmap(thumb_rect.topLeft(), scaled)
painter.setClipping(False)
else:
painter.fillRect(thumb_rect, QColor("#15181b"))
painter.setPen(QPen(QColor("#666666")))
icon_char = "🎵" if entry.get("is_audio_only") else "📹"
painter.setFont(QFont("Segoe UI Emoji", 24))
painter.drawText(thumb_rect, Qt.AlignmentFlag.AlignCenter, icon_char)
# Draw Border around thumb
painter.setPen(QPen(QColor("#3d3d3d"), 2))
painter.drawRect(thumb_rect)
# Text Area
text_x = thumb_rect.right() + 12
text_width = card_rect.right() - text_x - 85 # Leave even more space for the larger 60px button
# Title
title_rect = QRect(text_x, thumb_rect.top(), text_width, 50)
painter.setPen(QColor("#ffffff"))
font_title = QFont()
font_title.setBold(True)
font_title.setPixelSize(14)
painter.setFont(font_title)
# Use simple alignment flags
painter.drawText(title_rect, Qt.AlignmentFlag.AlignLeft | Qt.TextFlag.TextWordWrap, entry.get("title", ""))
current_y = title_rect.bottom() + 5
# Channel
channel = entry.get("channel")
if channel:
painter.setPen(QColor("#cccccc"))
font_meta = QFont()
font_meta.setPixelSize(12)
painter.setFont(font_meta)
painter.drawText(text_x, current_y, f"{_('video_info.channel')}: {channel}")
current_y += 18
# Date
date_str = entry.get("download_date", "")[:16].replace('T', ' ')
if date_str:
painter.setPen(QColor("#aaaaaa"))
painter.setFont(QFont("Arial", 11))
painter.drawText(text_x, current_y, f"{_('history.downloaded_on', date=date_str)}")
current_y += 25
# Badge
is_audio = entry.get("is_audio_only", False)
badge_text = _("history.audio_download") if is_audio else _("history.video_download")
badge_color = QColor("#0066cc") if is_audio else QColor("#c90000")
badge_rect = QRect(text_x, current_y, 80, 20)
painter.setBrush(QBrush(badge_color))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(badge_rect, 3, 3)
painter.setPen(QColor("white"))
font_badge = QFont()
font_badge.setBold(True)
font_badge.setPixelSize(10)
painter.setFont(font_badge)
painter.drawText(badge_rect, Qt.AlignmentFlag.AlignCenter, badge_text)
# File Size
file_size = entry.get("file_size", 0)
if file_size > 0:
size_str = self.format_file_size(file_size)
painter.setPen(QColor("#aaaaaa"))
painter.setFont(QFont("Arial", 11))
painter.drawText(badge_rect.right() + 10, current_y + 14, size_str)
# Menu Button
menu_rect = self.get_menu_rect(card_rect)
# Check hover on menu button specifically
mouse_pos = QCursor.pos()
if option.widget:
mouse_pos = option.widget.mapFromGlobal(mouse_pos)
if menu_rect.contains(mouse_pos):
painter.setPen(QColor("#c90000"))
else:
painter.setPen(QColor("#ffffff"))
painter.setFont(QFont("Arial", 18, QFont.Weight.Bold))
painter.drawText(menu_rect, Qt.AlignmentFlag.AlignCenter, "⋮")
painter.restore()
def get_menu_rect(self, card_rect):
# Widen the clickable area significantly (60x50) and shift slightly left
# to fix "left side not working" issues.
w = 60
h = 50
margin_right = 5
margin_top = 5
return QRect(
card_rect.right() - w - margin_right,
card_rect.top() + margin_top,
w,
h
)
def editorEvent(self, event, model, option, index):
"""Handle mouse clicks."""
if event.type() == QEvent.Type.MouseButtonRelease:
if event.button() == Qt.MouseButton.LeftButton:
# Use same margins as paint to ensure hit consistency
card_rect = option.rect.adjusted(self.h_margin, self.v_margin, -self.h_margin, -self.v_margin)
menu_rect = self.get_menu_rect(card_rect)
if menu_rect.contains(event.pos()):
self.menu_clicked.emit(index, event.globalPos())
return True
return super().editorEvent(event, model, option, index)
def format_file_size(self, size_bytes: int) -> str:
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} TB"
class HistoryDialog(QDialog):
"""Dialog to display and manage download history."""
redownload_requested = Signal(dict)
def __init__(self, parent: Optional["YTSageApp"] = None):
super().__init__(parent)
self.parent_app = parent
self.setup_ui()
self.show_loading_state()
QTimer.singleShot(100, self.start_loading_history)
def setup_ui(self):
self.setWindowTitle(_("history.title"))
self.resize(850, 600)
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint)
self.setStyleSheet("""
QDialog { background-color: #15181b; }
QLabel { color: #ffffff; }
""")
layout = QVBoxLayout(self)
# --- Header ---
header = QHBoxLayout()
title = QLabel(_("history.title"))
title.setStyleSheet("font-size: 18px; font-weight: bold;")
header.addWidget(title)
header.addStretch()
self.clear_btn = QPushButton(_("history.clear_all"))
self.clear_btn.setStyleSheet("""
QPushButton {
background-color: #c90000; color: white; padding: 6px 12px;
border: none; border-radius: 4px; font-weight: bold;
}
QPushButton:hover { background-color: #a50000; }
QPushButton:disabled { background-color: #555555; color: #aaaaaa; }
""")
self.clear_btn.clicked.connect(self.clear_all_history)
header.addWidget(self.clear_btn)
layout.addLayout(header)
# --- Search ---
self.search_input = QLineEdit()
self.search_input.setPlaceholderText(_("history.search_placeholder"))
self.search_input.setStyleSheet("""
QLineEdit {
padding: 8px; border: 2px solid #2a2d36; border-radius: 4px;
background-color: #1b2021; color: white;
}
""")
self.search_input.textChanged.connect(self.filter_history)
layout.addWidget(self.search_input)
# --- List View ---
self.list_view = QListView()
self.list_view.setStyleSheet("""
QListView {
background-color: transparent;
border: none;
outline: none;
}
QListView::item {
border: none;
background: transparent;
}
""")
self.list_view.setVerticalScrollMode(QListView.ScrollMode.ScrollPerPixel)
self.list_view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.list_view.setUniformItemSizes(True)
self.list_view.setSelectionMode(QListView.SelectionMode.NoSelection)
self.list_view.setMouseTracking(True)
self.list_view.setResizeMode(QListView.ResizeMode.Adjust)
self.model = HistoryModel([], self)
self.list_view.setModel(self.model)
self.delegate = HistoryDelegate(self.list_view)
self.delegate.menu_clicked.connect(self.show_context_menu)
self.list_view.setItemDelegate(self.delegate)
self.list_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.list_view.customContextMenuRequested.connect(self.on_context_menu_requested)
layout.addWidget(self.list_view)
# --- Status ---
self.status_label = QLabel()
self.status_label.setStyleSheet("color: #aaaaaa; font-size: 12px;")
layout.addWidget(self.status_label)
def show_loading_state(self):
self.status_label.setText("Loading history...")
self.clear_btn.setEnabled(False)
def start_loading_history(self):
self.loader_thread = HistoryLoaderThread()
self.loader_thread.entries_loaded.connect(self.on_entries_loaded)
self.loader_thread.thumbnail_loaded.connect(self.on_thumbnail_loaded)
self.loader_thread.start()
def on_entries_loaded(self, entries):
self.model.update_entries(entries)
count = len(entries)
if count == 0:
self.status_label.setText(_("history.no_history"))
self.clear_btn.setEnabled(False)
else:
self.status_label.setText(_("history.entries_count", count=count))
self.clear_btn.setEnabled(True)
self.load_cached_thumbnails(entries)
def load_cached_thumbnails(self, entries):
for entry in entries:
eid = entry.get("id")
if not eid: continue
p = APP_THUMBNAILS_DIR / f"{eid}.jpg"
if p.exists():
pix = QPixmap(str(p))
if not pix.isNull():
self.model.thumbnail_cache[eid] = pix
def on_thumbnail_loaded(self, entry_id, data_bytes):
pixmap = QPixmap()
pixmap.loadFromData(data_bytes)
if not pixmap.isNull():
self.model.update_thumbnail(entry_id, pixmap)
def on_context_menu_requested(self, pos):
index = self.list_view.indexAt(pos)
if index.isValid():
global_pos = self.list_view.mapToGlobal(pos)
self.show_context_menu(index, global_pos)
def show_context_menu(self, index, global_pos):
entry = index.data(HistoryModel.EntryRole)
if not entry: return
menu = QMenu(self)
menu.setStyleSheet("""
QMenu {
background-color: #2a2d36; border: 1px solid #3a3d46; color: white;
}
QMenu::item {
padding: 8px 20px;
}
QMenu::item:selected {
background-color: #c90000;
}
""")
act_open = menu.addAction("📁 " + _("history.open_location"))
act_redownload = menu.addAction("⬇️ " + _("history.redownload"))
menu.addSeparator()
act_remove = menu.addAction("🗑️ " + _("history.remove"))
action = menu.exec(global_pos)
if action == act_open:
self.open_file_location(entry)
elif action == act_redownload:
self.redownload_entry(entry)
elif action == act_remove:
self.remove_entry(index)
def open_file_location(self, entry):
path_str = entry.get("file_path", "")
if not path_str: return
path = Path(path_str)
if not path.exists():
QMessageBox.warning(self, "Error", f"File not found: {path}")
return
try:
if os.name == "nt":
subprocess.run(['explorer', '/select,', str(path)], creationflags=SUBPROCESS_CREATIONFLAGS)
elif subprocess.sys.platform == "darwin":
subprocess.run(['open', '-R', str(path)])
else:
folder_path = path.parent
subprocess.run(['xdg-open', str(folder_path)])
except Exception as e:
logger.error(f"Failed to open file: {e}")
def redownload_entry(self, entry):
reply = QMessageBox.question(
self,
_("history.redownload_confirm_title"),
_("history.redownload_confirm_message", title=entry.get("title")),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
self.redownload_requested.emit(entry)
self.accept()
def remove_entry(self, index):
entry = index.data(HistoryModel.EntryRole)
reply = QMessageBox.question(
self,
_("history.remove_confirm_title"),
_("history.remove_confirm_message", title=entry.get("title")),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
if HistoryManager.remove_entry(entry.get("id")):
self.model.remove_item(index.row())
self.status_label.setText(
_("history.entries_count", count=self.model.rowCount())
)
def clear_all_history(self):
reply = QMessageBox.question(
self,
_("history.clear_confirm_title"),
_("history.clear_confirm_message"),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
HistoryManager.clear_history()
self.model.update_entries([])
self.clear_btn.setEnabled(False)
self.status_label.setText(_("history.no_history"))
def filter_history(self, query):
results = HistoryManager.search_entries(query)
self.model.update_entries(results)
self.load_cached_thumbnails(results)