Refactor GUI package structure and update imports

Moved all files from src/gui/ to ytsage/gui/ and updated import statements to use relative imports within the new package structure. This improves modularity and prepares the codebase for distribution as a proper Python package.
This commit is contained in:
oop7
2026-01-25 14:07:51 +02:00
parent 4fb9815ac6
commit 96dadd7e5b
15 changed files with 78 additions and 78 deletions
@@ -0,0 +1,576 @@
"""
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", 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)