c9217fccb7
Replace entry.get('file_size', 0) with entry.get('file_size') or 0 in HistoryDelegate (ytsage_dialogs_history.py) to treat None as 0. This prevents TypeError when comparing None > 0 and ensures file size formatting/display only occurs for positive sizes.
577 lines
20 KiB
Python
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 ...utils.ytsage_history_manager import HistoryManager
|
|
from ...utils.ytsage_constants import APP_THUMBNAILS_DIR, SUBPROCESS_CREATIONFLAGS
|
|
from ...utils.ytsage_localization import _
|
|
from ...utils.ytsage_logger import logger
|
|
|
|
if TYPE_CHECKING:
|
|
from ..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") or 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(_("history.loading"))
|
|
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, _("main_ui.error_title"), _("history.file_not_found_message", path=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)
|