Add download history feature with UI and localization
Introduces a download history dialog with thumbnail previews, redownload, and remove options. Updates all language files to support history-related UI strings. Adds HistoryManager utility and integrates history dialog into the main GUI. Enhances downloader to better track final downloaded files and merged outputs.
This commit is contained in:
@@ -376,6 +376,36 @@ class DownloadThread(QThread):
|
||||
if return_code == 0:
|
||||
self.progress_signal.emit(100)
|
||||
self.status_signal.emit(_("download.completed"))
|
||||
|
||||
# Try to find the actual final file if last_file_path contains format codes or doesn't exist
|
||||
if self.last_file_path:
|
||||
try:
|
||||
last_path = Path(self.last_file_path)
|
||||
|
||||
# Check if the file exists as-is
|
||||
if not last_path.exists():
|
||||
# The file path might have format codes or incorrect characters
|
||||
# Try to find the most recently modified video/audio file in the download directory
|
||||
video_audio_extensions = {'.mp4', '.webm', '.mkv', '.avi', '.mov', '.flv',
|
||||
'.m4a', '.mp3', '.opus', '.flac', '.aac', '.wav', '.ogg'}
|
||||
|
||||
# Get all video/audio files in the download directory
|
||||
potential_files = []
|
||||
for ext in video_audio_extensions:
|
||||
potential_files.extend(self.path.glob(f'*{ext}'))
|
||||
|
||||
# Sort by modification time and get the most recent one
|
||||
if potential_files:
|
||||
most_recent = max(potential_files, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
# Verify it was modified within the last 10 seconds (just downloaded)
|
||||
import time
|
||||
if time.time() - most_recent.stat().st_mtime < 10:
|
||||
self.last_file_path = str(most_recent)
|
||||
self.current_filename = most_recent.name
|
||||
logger.info(f"Found downloaded file: {self.last_file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error finding final file: {e}", exc_info=True)
|
||||
|
||||
# Clean up subtitle files if they were merged, with a small delay
|
||||
# to ensure the embedding process has completed
|
||||
@@ -562,6 +592,13 @@ class DownloadThread(QThread):
|
||||
if "[Merger]" in line or "Merging formats" in line:
|
||||
self.status_signal.emit(_("download.merging_formats"))
|
||||
self.progress_signal.emit(95)
|
||||
# Extract the merged output filename
|
||||
merger_match = re.search(r"Merging formats into \"(.+?)\"", line)
|
||||
if merger_match:
|
||||
merged_filepath = merger_match.group(1).strip()
|
||||
self.current_filename = Path(merged_filepath).name
|
||||
self.last_file_path = merged_filepath
|
||||
logger.debug(f"Updated to merged filename: {self.current_filename}")
|
||||
elif "SponsorBlock" in line:
|
||||
self.status_signal.emit(_("download.removing_sponsor_segments"))
|
||||
self.progress_signal.emit(97)
|
||||
|
||||
@@ -15,6 +15,7 @@ This package contains all dialog classes organized by functionality:
|
||||
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_base import AboutDialog, LogWindow
|
||||
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_custom import CustomOptionsDialog, TimeRangeDialog
|
||||
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_ffmpeg import FFmpegCheckDialog, FFmpegInstallThread
|
||||
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_history import HistoryDialog
|
||||
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_selection import (
|
||||
PlaylistSelectionDialog,
|
||||
SponsorBlockCategoryDialog,
|
||||
@@ -50,4 +51,7 @@ __all__ = [
|
||||
# Custom functionality dialogs
|
||||
"CustomOptionsDialog",
|
||||
"TimeRangeDialog",
|
||||
|
||||
# History dialog
|
||||
"HistoryDialog",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
"""
|
||||
History Dialog for YTSage application.
|
||||
Displays download history with thumbnails and provides options to redownload or remove entries.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
from PySide6.QtCore import Qt, QSize, Signal
|
||||
from PySide6.QtGui import QPixmap, QIcon
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QWidget,
|
||||
QFrame,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QSizePolicy,
|
||||
)
|
||||
|
||||
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 HistoryEntryWidget(QFrame):
|
||||
"""Widget representing a single history entry."""
|
||||
|
||||
remove_requested = Signal(str) # Emit entry ID when remove is requested
|
||||
redownload_requested = Signal(dict) # Emit entry data when redownload is requested
|
||||
|
||||
def __init__(self, entry: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self.entry = entry
|
||||
self.entry_id = entry.get("id", "")
|
||||
|
||||
self.setup_ui()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Setup the UI for this history entry."""
|
||||
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised)
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #1d1e22;
|
||||
border: 1px solid #2a2d36;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin: 5px;
|
||||
}
|
||||
QFrame:hover {
|
||||
background-color: #252830;
|
||||
border-color: #3a3d46;
|
||||
}
|
||||
""")
|
||||
|
||||
main_layout = QHBoxLayout(self)
|
||||
main_layout.setSpacing(15)
|
||||
main_layout.setContentsMargins(10, 10, 10, 10)
|
||||
|
||||
# Thumbnail - Balanced size for better visibility without taking too much space
|
||||
self.thumbnail_label = QLabel()
|
||||
self.thumbnail_label.setFixedSize(240, 135) # 16:9 ratio, balanced size
|
||||
self.thumbnail_label.setStyleSheet("""
|
||||
QLabel {
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 6px;
|
||||
background-color: #15181b;
|
||||
}
|
||||
""")
|
||||
self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.thumbnail_label.setScaledContents(True)
|
||||
|
||||
# Load thumbnail
|
||||
self.load_thumbnail()
|
||||
|
||||
main_layout.addWidget(self.thumbnail_label, alignment=Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
# Info section
|
||||
info_layout = QVBoxLayout()
|
||||
info_layout.setSpacing(5)
|
||||
|
||||
# Title
|
||||
title = self.entry.get("title", _("video_info.unknown_title"))
|
||||
self.title_label = QLabel(title)
|
||||
self.title_label.setWordWrap(True)
|
||||
self.title_label.setStyleSheet("""
|
||||
QLabel {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
}
|
||||
""")
|
||||
info_layout.addWidget(self.title_label)
|
||||
|
||||
# Channel (if available)
|
||||
channel = self.entry.get("channel")
|
||||
if channel:
|
||||
channel_label = QLabel(f"{_('video_info.channel')}: {channel}")
|
||||
channel_label.setStyleSheet("color: #cccccc; font-size: 12px;")
|
||||
info_layout.addWidget(channel_label)
|
||||
|
||||
# Download date
|
||||
download_date = self.entry.get("download_date", "")
|
||||
if download_date:
|
||||
try:
|
||||
dt = datetime.fromisoformat(download_date)
|
||||
date_str = dt.strftime("%Y-%m-%d %H:%M")
|
||||
date_label = QLabel(_("history.downloaded_on", date=date_str))
|
||||
date_label.setStyleSheet("color: #aaaaaa; font-size: 11px;")
|
||||
info_layout.addWidget(date_label)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing date: {e}")
|
||||
|
||||
# File size and type
|
||||
file_size = self.entry.get("file_size", 0)
|
||||
is_audio = self.entry.get("is_audio_only", False)
|
||||
|
||||
size_type_layout = QHBoxLayout()
|
||||
|
||||
# File type badge
|
||||
type_badge = QLabel(_("history.audio_download") if is_audio else _("history.video_download"))
|
||||
type_badge.setStyleSheet(f"""
|
||||
QLabel {{
|
||||
background-color: {'#c90000' if not is_audio else '#0066cc'};
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
""")
|
||||
size_type_layout.addWidget(type_badge)
|
||||
|
||||
# File size
|
||||
if file_size > 0:
|
||||
size_str = self.format_file_size(file_size)
|
||||
size_label = QLabel(_("history.file_size", size=size_str))
|
||||
size_label.setStyleSheet("color: #aaaaaa; font-size: 11px;")
|
||||
size_type_layout.addWidget(size_label)
|
||||
|
||||
size_type_layout.addStretch()
|
||||
info_layout.addLayout(size_type_layout)
|
||||
|
||||
info_layout.addStretch()
|
||||
|
||||
main_layout.addLayout(info_layout, 1)
|
||||
|
||||
# Three-dot menu button
|
||||
self.menu_button = QPushButton("⋮")
|
||||
self.menu_button.setFixedSize(40, 40)
|
||||
self.menu_button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #2a2d36;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #3a3d46;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #c90000;
|
||||
}
|
||||
""")
|
||||
self.menu_button.clicked.connect(self.show_menu)
|
||||
|
||||
main_layout.addWidget(self.menu_button, alignment=Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
def load_thumbnail(self):
|
||||
"""Load and display the thumbnail."""
|
||||
thumbnail_url = self.entry.get("thumbnail_url")
|
||||
|
||||
if not thumbnail_url:
|
||||
self.set_placeholder_thumbnail()
|
||||
return
|
||||
|
||||
# Check if thumbnail is cached
|
||||
thumbnail_filename = f"{self.entry_id}.jpg"
|
||||
thumbnail_path = APP_THUMBNAILS_DIR / thumbnail_filename
|
||||
|
||||
if thumbnail_path.exists():
|
||||
try:
|
||||
pixmap = QPixmap(str(thumbnail_path))
|
||||
if not pixmap.isNull():
|
||||
# Don't scale here, let setScaledContents handle it
|
||||
self.thumbnail_label.setPixmap(pixmap)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"Error loading cached thumbnail: {e}")
|
||||
|
||||
# Download thumbnail
|
||||
try:
|
||||
response = requests.get(thumbnail_url, timeout=5)
|
||||
response.raise_for_status()
|
||||
|
||||
image = Image.open(BytesIO(response.content))
|
||||
|
||||
# Don't resize, keep original quality and just save at higher quality
|
||||
# The QPixmap scaling will handle the display size with high quality
|
||||
|
||||
# Save to cache with higher quality
|
||||
try:
|
||||
APP_THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
image.save(thumbnail_path, "JPEG", quality=95, optimize=True)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error caching thumbnail: {e}")
|
||||
|
||||
# Convert to QPixmap
|
||||
image_bytes = BytesIO()
|
||||
image.save(image_bytes, format="JPEG", quality=95)
|
||||
image_bytes.seek(0)
|
||||
|
||||
pixmap = QPixmap()
|
||||
pixmap.loadFromData(image_bytes.read())
|
||||
|
||||
if not pixmap.isNull():
|
||||
# Don't scale here, let setScaledContents handle it
|
||||
self.thumbnail_label.setPixmap(pixmap)
|
||||
else:
|
||||
self.set_placeholder_thumbnail()
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error downloading thumbnail: {e}")
|
||||
self.set_placeholder_thumbnail()
|
||||
|
||||
def set_placeholder_thumbnail(self):
|
||||
"""Set a placeholder when thumbnail is not available."""
|
||||
self.thumbnail_label.setText("📹" if not self.entry.get("is_audio_only") else "🎵")
|
||||
self.thumbnail_label.setStyleSheet("""
|
||||
QLabel {
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
background-color: #15181b;
|
||||
color: #666666;
|
||||
font-size: 48px;
|
||||
}
|
||||
""")
|
||||
|
||||
def format_file_size(self, size_bytes: int) -> str:
|
||||
"""Format file size in human-readable format."""
|
||||
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"
|
||||
|
||||
def show_menu(self):
|
||||
"""Show the context menu with options."""
|
||||
menu = QMenu(self)
|
||||
menu.setStyleSheet("""
|
||||
QMenu {
|
||||
background-color: #2a2d36;
|
||||
border: 1px solid #3a3d46;
|
||||
color: white;
|
||||
padding: 5px;
|
||||
}
|
||||
QMenu::item {
|
||||
padding: 8px 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background-color: #c90000;
|
||||
}
|
||||
""")
|
||||
|
||||
# Open file location
|
||||
open_action = menu.addAction("📁 " + _("history.open_location"))
|
||||
open_action.triggered.connect(self.open_file_location)
|
||||
|
||||
# Redownload
|
||||
redownload_action = menu.addAction("⬇️ " + _("history.redownload"))
|
||||
redownload_action.triggered.connect(self.redownload)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
# Remove from history
|
||||
remove_action = menu.addAction("🗑️ " + _("history.remove"))
|
||||
remove_action.triggered.connect(self.remove_from_history)
|
||||
|
||||
# Show menu at button position
|
||||
menu.exec(self.menu_button.mapToGlobal(self.menu_button.rect().bottomLeft()))
|
||||
|
||||
def open_file_location(self):
|
||||
"""Open the file location in the system file explorer."""
|
||||
file_path = Path(self.entry.get("file_path", ""))
|
||||
|
||||
if not file_path.exists():
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
_("history.file_not_found"),
|
||||
_("history.file_not_found_message", path=str(file_path))
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# On Windows, use explorer with /select to highlight the file
|
||||
if os.name == "nt":
|
||||
subprocess.run(['explorer', '/select,', str(file_path)], creationflags=SUBPROCESS_CREATIONFLAGS)
|
||||
# On macOS, use open with -R to reveal in Finder
|
||||
elif subprocess.sys.platform == "darwin":
|
||||
subprocess.run(['open', '-R', str(file_path)])
|
||||
# On Linux, try to open the folder
|
||||
else:
|
||||
folder_path = file_path.parent
|
||||
subprocess.run(['xdg-open', str(folder_path)])
|
||||
|
||||
logger.info(f"Opened file location: {file_path}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error opening file location: {e}")
|
||||
QMessageBox.warning(self, "Error", f"Could not open file location: {str(e)}")
|
||||
|
||||
def redownload(self):
|
||||
"""Request redownload of this entry."""
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
_("history.redownload_confirm_title"),
|
||||
_("history.redownload_confirm_message", title=self.entry.get("title", "")),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.redownload_requested.emit(self.entry)
|
||||
|
||||
def remove_from_history(self):
|
||||
"""Request removal of this entry from history."""
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
_("history.remove_confirm_title"),
|
||||
_("history.remove_confirm_message", title=self.entry.get("title", "")),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.remove_requested.emit(self.entry_id)
|
||||
|
||||
|
||||
class HistoryDialog(QDialog):
|
||||
"""Dialog to display and manage download history."""
|
||||
|
||||
redownload_requested = Signal(dict) # Signal to request redownload in main window
|
||||
|
||||
def __init__(self, parent: Optional["YTSageApp"] = None):
|
||||
super().__init__(parent)
|
||||
self.parent_app = parent
|
||||
self.entry_widgets = []
|
||||
|
||||
self.setup_ui()
|
||||
self.load_history()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Setup the dialog UI."""
|
||||
self.setWindowTitle(_("history.title"))
|
||||
self.setMinimumSize(700, 500)
|
||||
self.resize(850, 600)
|
||||
|
||||
# Set window flags
|
||||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
# Header with title and buttons
|
||||
header_layout = QHBoxLayout()
|
||||
|
||||
title_label = QLabel(_("history.title"))
|
||||
title_label.setStyleSheet("""
|
||||
QLabel {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
""")
|
||||
header_layout.addWidget(title_label)
|
||||
|
||||
header_layout.addStretch()
|
||||
|
||||
# Clear all button
|
||||
self.clear_all_btn = QPushButton(_("history.clear_all"))
|
||||
self.clear_all_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #c90000;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
""")
|
||||
self.clear_all_btn.clicked.connect(self.clear_all_history)
|
||||
header_layout.addWidget(self.clear_all_btn)
|
||||
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# Search bar
|
||||
self.search_input = QLineEdit()
|
||||
self.search_input.setPlaceholderText(_("history.search_placeholder"))
|
||||
self.search_input.setStyleSheet("""
|
||||
QLineEdit {
|
||||
padding: 10px;
|
||||
border: 2px solid #1b2021;
|
||||
border-radius: 4px;
|
||||
background-color: #1b2021;
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
}
|
||||
""")
|
||||
self.search_input.textChanged.connect(self.filter_history)
|
||||
layout.addWidget(self.search_input)
|
||||
|
||||
# Scroll area for history entries
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setStyleSheet("""
|
||||
QScrollArea {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
""")
|
||||
|
||||
# Container for history entries
|
||||
self.history_container = QWidget()
|
||||
self.history_layout = QVBoxLayout(self.history_container)
|
||||
self.history_layout.setSpacing(10)
|
||||
self.history_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.history_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
scroll_area.setWidget(self.history_container)
|
||||
layout.addWidget(scroll_area)
|
||||
|
||||
# Status label
|
||||
self.status_label = QLabel()
|
||||
self.status_label.setStyleSheet("color: #aaaaaa; font-size: 12px;")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# Apply dark theme
|
||||
self.setStyleSheet("""
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
""")
|
||||
|
||||
def load_history(self):
|
||||
"""Load and display history entries."""
|
||||
# Clear existing widgets
|
||||
for widget in self.entry_widgets:
|
||||
widget.deleteLater()
|
||||
self.entry_widgets.clear()
|
||||
|
||||
# Get history entries
|
||||
entries = HistoryManager.get_all_entries()
|
||||
|
||||
if not entries:
|
||||
self.show_empty_state()
|
||||
return
|
||||
|
||||
# Create widgets for each entry
|
||||
for entry in entries:
|
||||
widget = HistoryEntryWidget(entry, self.history_container)
|
||||
widget.remove_requested.connect(self.remove_entry)
|
||||
widget.redownload_requested.connect(self.handle_redownload)
|
||||
|
||||
self.history_layout.addWidget(widget)
|
||||
self.entry_widgets.append(widget)
|
||||
|
||||
# Update status
|
||||
count = len(entries)
|
||||
if count == 1:
|
||||
status_text = _("history.one_entry")
|
||||
else:
|
||||
status_text = _("history.entries_count", count=count)
|
||||
self.status_label.setText(status_text)
|
||||
|
||||
def show_empty_state(self):
|
||||
"""Show empty state when there's no history."""
|
||||
empty_widget = QWidget()
|
||||
empty_layout = QVBoxLayout(empty_widget)
|
||||
empty_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
icon_label = QLabel("📂")
|
||||
icon_label.setStyleSheet("font-size: 64px; color: #555555;")
|
||||
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
empty_layout.addWidget(icon_label)
|
||||
|
||||
title_label = QLabel(_("history.no_history"))
|
||||
title_label.setStyleSheet("font-size: 16px; color: #888888; font-weight: bold;")
|
||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
empty_layout.addWidget(title_label)
|
||||
|
||||
desc_label = QLabel(_("history.no_history_description"))
|
||||
desc_label.setStyleSheet("font-size: 13px; color: #666666;")
|
||||
desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
empty_layout.addWidget(desc_label)
|
||||
|
||||
self.history_layout.addWidget(empty_widget)
|
||||
self.entry_widgets.append(empty_widget)
|
||||
|
||||
self.status_label.setText("")
|
||||
self.clear_all_btn.setEnabled(False)
|
||||
|
||||
def filter_history(self, query: str):
|
||||
"""Filter history entries based on search query."""
|
||||
if not query:
|
||||
# Show all entries
|
||||
for widget in self.entry_widgets:
|
||||
widget.show()
|
||||
return
|
||||
|
||||
# Hide/show based on query
|
||||
query_lower = query.lower()
|
||||
visible_count = 0
|
||||
|
||||
for widget in self.entry_widgets:
|
||||
if isinstance(widget, HistoryEntryWidget):
|
||||
title = (widget.entry.get("title") or "").lower()
|
||||
channel = (widget.entry.get("channel") or "").lower()
|
||||
|
||||
if query_lower in title or query_lower in channel:
|
||||
widget.show()
|
||||
visible_count += 1
|
||||
else:
|
||||
widget.hide()
|
||||
|
||||
def remove_entry(self, entry_id: str):
|
||||
"""Remove an entry from history."""
|
||||
success = HistoryManager.remove_entry(entry_id)
|
||||
|
||||
if success:
|
||||
# Reload history
|
||||
self.load_history()
|
||||
logger.info(f"Removed entry from history: {entry_id}")
|
||||
else:
|
||||
QMessageBox.warning(self, "Error", "Failed to remove entry from history")
|
||||
|
||||
def clear_all_history(self):
|
||||
"""Clear all history entries."""
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
_("history.clear_confirm_title"),
|
||||
_("history.clear_confirm_message"),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
count = HistoryManager.clear_history()
|
||||
self.load_history()
|
||||
logger.info(f"Cleared all history: {count} entries")
|
||||
|
||||
def handle_redownload(self, entry: dict):
|
||||
"""Handle redownload request."""
|
||||
# Emit signal to parent window
|
||||
self.redownload_requested.emit(entry)
|
||||
|
||||
# Close dialog
|
||||
self.accept()
|
||||
@@ -38,6 +38,7 @@ from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__
|
||||
CustomOptionsDialog,
|
||||
DownloadSettingsDialog,
|
||||
FFmpegCheckDialog,
|
||||
HistoryDialog,
|
||||
PlaylistSelectionDialog,
|
||||
TimeRangeDialog,
|
||||
YTDLPUpdateDialog,
|
||||
@@ -48,6 +49,7 @@ from src.utils.ytsage_constants import ICON_PATH, SOUND_PATH, SUBPROCESS_CREATIO
|
||||
from src.utils.ytsage_logger import logger
|
||||
from src.utils.ytsage_config_manager import ConfigManager
|
||||
from src.utils.ytsage_localization import LocalizationManager, _
|
||||
from src.utils.ytsage_history_manager import HistoryManager
|
||||
|
||||
# Note: yt-dlp Python package removed - using binary-only approach
|
||||
# DownloadError and ExtractorError definitions kept for compatibility
|
||||
@@ -571,6 +573,9 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
|
||||
|
||||
self.about_btn = QPushButton(_("buttons.about"))
|
||||
self.about_btn.clicked.connect(self.show_about_dialog)
|
||||
|
||||
self.history_btn = QPushButton(_("buttons.history"))
|
||||
self.history_btn.clicked.connect(self.show_history_dialog)
|
||||
|
||||
# Add new Time Range button
|
||||
self.time_range_btn = QPushButton(_("buttons.trim_video"))
|
||||
@@ -600,6 +605,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
|
||||
# Add all buttons to layout in the correct order
|
||||
download_layout.addWidget(self.custom_options_btn)
|
||||
download_layout.addWidget(self.about_btn)
|
||||
download_layout.addWidget(self.history_btn)
|
||||
download_layout.addWidget(self.time_range_btn) # New button position
|
||||
download_layout.addWidget(self.update_ytdlp_btn)
|
||||
download_layout.addWidget(self.settings_button)
|
||||
@@ -931,6 +937,44 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
|
||||
|
||||
# Show the open folder button
|
||||
self.open_folder_btn.setVisible(True)
|
||||
|
||||
# Save to history
|
||||
try:
|
||||
if self.download_thread.last_file_path and self.video_info:
|
||||
# Get video information
|
||||
title = self.video_info.get("title", _("video_info.unknown_title"))
|
||||
channel = self.video_info.get("channel", None) or self.video_info.get("uploader", None)
|
||||
duration = self.video_info.get("duration_string", None)
|
||||
|
||||
# Prepare download options
|
||||
download_options = {
|
||||
"format_id": self.download_thread.format_id,
|
||||
"subtitle_langs": self.download_thread.subtitle_langs,
|
||||
"merge_subs": self.download_thread.merge_subs,
|
||||
"enable_sponsorblock": self.download_thread.enable_sponsorblock,
|
||||
"sponsorblock_categories": self.download_thread.sponsorblock_categories,
|
||||
"save_description": self.download_thread.save_description,
|
||||
"embed_chapters": self.download_thread.embed_chapters,
|
||||
"download_section": self.download_thread.download_section,
|
||||
"force_keyframes": self.download_thread.force_keyframes,
|
||||
}
|
||||
|
||||
# Add to history
|
||||
HistoryManager.add_entry(
|
||||
title=title,
|
||||
url=self.video_url,
|
||||
thumbnail_url=self.thumbnail_url,
|
||||
file_path=str(self.download_thread.last_file_path),
|
||||
format_id=self.download_thread.format_id,
|
||||
is_audio_only=self.download_thread.is_audio_only,
|
||||
resolution=self.download_thread.resolution,
|
||||
channel=channel,
|
||||
duration=duration,
|
||||
download_options=download_options,
|
||||
)
|
||||
logger.info(f"Added download to history: {title}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving to history: {e}", exc_info=True)
|
||||
|
||||
# Play notification sound when download completes
|
||||
self.play_notification_sound()
|
||||
@@ -1325,6 +1369,37 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
|
||||
def show_about_dialog(self) -> None: # ADDED METHOD HERE
|
||||
dialog = AboutDialog(self)
|
||||
dialog.exec()
|
||||
|
||||
def show_history_dialog(self) -> None:
|
||||
"""Show the download history dialog."""
|
||||
dialog = HistoryDialog(self)
|
||||
dialog.redownload_requested.connect(self.handle_redownload_from_history)
|
||||
dialog.exec()
|
||||
|
||||
def handle_redownload_from_history(self, entry: dict) -> None:
|
||||
"""Handle redownload request from history dialog."""
|
||||
try:
|
||||
# Set URL
|
||||
url = entry.get("url", "")
|
||||
if url:
|
||||
self.url_input.setText(url)
|
||||
self.video_url = url
|
||||
|
||||
# Analyze the URL to get format info
|
||||
logger.info(f"Redownloading from history: {entry.get('title', 'Unknown')}")
|
||||
QMessageBox.information(
|
||||
self,
|
||||
_("history.redownload_started"),
|
||||
_("history.redownload_started") + f"\n\n{entry.get('title', '')}"
|
||||
)
|
||||
|
||||
# Trigger analysis
|
||||
self.analyze_url()
|
||||
else:
|
||||
QMessageBox.warning(self, "Error", "No URL found in history entry")
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling redownload from history: {e}", exc_info=True)
|
||||
QMessageBox.warning(self, "Error", f"Failed to start redownload: {str(e)}")
|
||||
|
||||
def file_already_exists(self, filename) -> None:
|
||||
"""Handle case when file already exists - simplified version"""
|
||||
|
||||
@@ -96,6 +96,8 @@ if OS_NAME == "Windows":
|
||||
APP_DATA_DIR: Path = APP_DIR / "data"
|
||||
APP_LOG_DIR: Path = APP_DIR / "logs"
|
||||
APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json"
|
||||
APP_HISTORY_FILE: Path = APP_DATA_DIR / "ytsage_history.json"
|
||||
APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails"
|
||||
|
||||
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
|
||||
YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp.exe"
|
||||
@@ -113,6 +115,8 @@ elif OS_NAME == "Darwin": # macOS
|
||||
APP_DATA_DIR: Path = APP_DIR / "data"
|
||||
APP_LOG_DIR: Path = APP_DIR / "logs"
|
||||
APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json"
|
||||
APP_HISTORY_FILE: Path = APP_DATA_DIR / "ytsage_history.json"
|
||||
APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails"
|
||||
|
||||
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
|
||||
YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp"
|
||||
@@ -130,6 +134,8 @@ else: # Linux and other UNIX-like
|
||||
APP_DATA_DIR: Path = APP_DIR / "data"
|
||||
APP_LOG_DIR: Path = APP_DIR / "logs"
|
||||
APP_CONFIG_FILE: Path = APP_DATA_DIR / "ytsage_config.json"
|
||||
APP_HISTORY_FILE: Path = APP_DATA_DIR / "ytsage_history.json"
|
||||
APP_THUMBNAILS_DIR: Path = APP_DATA_DIR / "thumbnails"
|
||||
|
||||
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
|
||||
YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp"
|
||||
@@ -167,3 +173,4 @@ else:
|
||||
APP_BIN_DIR.mkdir(parents=True, exist_ok=True)
|
||||
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
APP_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
APP_THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
History Manager Module
|
||||
======================
|
||||
|
||||
This module provides **thread-safe** centralized management for download
|
||||
history in YTSage. It handles reading, writing, and managing download history
|
||||
stored in a JSON file.
|
||||
|
||||
Thread safety is ensured using a reentrant lock (`RLock`), so multiple threads
|
||||
can safely access or modify history concurrently.
|
||||
|
||||
Features
|
||||
--------
|
||||
- Thread-safe operations for getting, adding, and removing history entries.
|
||||
- Loads history from a JSON file (`APP_HISTORY_FILE`).
|
||||
- Creates the history file if missing or corrupt.
|
||||
- Manages download history with metadata including thumbnails, file paths, and download options.
|
||||
- Provides safe error handling with logging instead of raising exceptions.
|
||||
- Persists updates back to disk automatically.
|
||||
|
||||
Usage
|
||||
-----
|
||||
from src.utils.ytsage_history_manager import HistoryManager
|
||||
|
||||
# Add a download to history
|
||||
HistoryManager.add_entry(
|
||||
title="Video Title",
|
||||
url="https://youtube.com/watch?v=...",
|
||||
thumbnail_url="https://...",
|
||||
file_path="/path/to/file.mp4",
|
||||
format_id="137+140",
|
||||
is_audio_only=False,
|
||||
resolution="1080p",
|
||||
download_options={...}
|
||||
)
|
||||
|
||||
# Get all history entries
|
||||
history = HistoryManager.get_all_entries()
|
||||
|
||||
# Remove an entry
|
||||
HistoryManager.remove_entry(entry_id)
|
||||
|
||||
# Clear all history
|
||||
HistoryManager.clear_history()
|
||||
|
||||
Design Notes
|
||||
------------
|
||||
- History entries are stored in `HistoryManager._history` (a list of dicts).
|
||||
- Each entry has a unique ID based on timestamp.
|
||||
- All modifications trigger a save (`_save`) to keep JSON in sync.
|
||||
- Logs actions and errors using the app's central logger.
|
||||
- Uses `RLock` to allow safe concurrent access from multiple threads.
|
||||
|
||||
Exceptions
|
||||
----------
|
||||
- Any issues during file I/O (permissions, disk errors, JSON corruption)
|
||||
are caught and logged. The application continues running with an empty
|
||||
history when possible.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from src.utils.ytsage_constants import APP_HISTORY_FILE
|
||||
from src.utils.ytsage_logger import logger
|
||||
|
||||
|
||||
class HistoryManager:
|
||||
"""
|
||||
Thread-safe history manager for YTSage.
|
||||
|
||||
Provides methods to load, save, get, add, and remove download history entries.
|
||||
Automatically persists changes to disk.
|
||||
"""
|
||||
|
||||
_lock = threading.RLock()
|
||||
_history_file = APP_HISTORY_FILE
|
||||
_history: List[Dict[str, Any]] = []
|
||||
_loaded = False
|
||||
|
||||
@classmethod
|
||||
def _load(cls) -> None:
|
||||
"""
|
||||
Loads download history from a JSON file if it exists and is valid.
|
||||
If the file is missing or corrupt, initializes with an empty history.
|
||||
Logs actions and errors during the process.
|
||||
"""
|
||||
with cls._lock:
|
||||
if cls._history_file.exists():
|
||||
try:
|
||||
with open(cls._history_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# Ensure it's a list
|
||||
if isinstance(data, list):
|
||||
cls._history = data
|
||||
else:
|
||||
cls._history = []
|
||||
logger.warning("History file format invalid, initialized empty history.")
|
||||
logger.info(f"History loaded from file: {len(cls._history)} entries.")
|
||||
except json.JSONDecodeError:
|
||||
cls._history = []
|
||||
logger.warning("History file corrupt, initialized empty history.")
|
||||
except Exception as e:
|
||||
cls._history = []
|
||||
logger.error(f"Error loading history file: {e}")
|
||||
else:
|
||||
cls._history = []
|
||||
cls._save()
|
||||
logger.info("History file not found, created empty history.")
|
||||
cls._loaded = True
|
||||
|
||||
@classmethod
|
||||
def _save(cls) -> None:
|
||||
"""
|
||||
Save current history to JSON file.
|
||||
|
||||
Note:
|
||||
May raise exceptions if the file cannot be written due to permission issues,
|
||||
disk errors, or other I/O problems.
|
||||
"""
|
||||
with cls._lock:
|
||||
try:
|
||||
# Ensure parent directory exists
|
||||
cls._history_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(cls._history_file, "w", encoding="utf-8") as f:
|
||||
json.dump(cls._history, f, indent=2, ensure_ascii=False)
|
||||
logger.debug(f"History saved to file: {len(cls._history)} entries.")
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.exception(f"Failed to save history: {e}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error while saving history: {e}")
|
||||
|
||||
@classmethod
|
||||
def _ensure_loaded(cls) -> None:
|
||||
"""Ensure history is loaded before any operation."""
|
||||
with cls._lock:
|
||||
if not cls._loaded:
|
||||
cls._load()
|
||||
|
||||
@classmethod
|
||||
def add_entry(
|
||||
cls,
|
||||
title: str,
|
||||
url: str,
|
||||
thumbnail_url: Optional[str],
|
||||
file_path: str,
|
||||
format_id: str,
|
||||
is_audio_only: bool,
|
||||
resolution: str,
|
||||
file_size: Optional[int] = None,
|
||||
channel: Optional[str] = None,
|
||||
duration: Optional[str] = None,
|
||||
download_options: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Add a new download entry to history.
|
||||
|
||||
Args:
|
||||
title: Video/audio title
|
||||
url: Original URL
|
||||
thumbnail_url: Thumbnail URL (can be None)
|
||||
file_path: Path to downloaded file
|
||||
format_id: Format ID used for download
|
||||
is_audio_only: Whether it's audio-only download
|
||||
resolution: Resolution string (e.g., "1080p", "best audio")
|
||||
file_size: File size in bytes (optional)
|
||||
channel: Channel name (optional)
|
||||
duration: Duration string (optional)
|
||||
download_options: Dictionary of all download options used (optional)
|
||||
|
||||
Returns:
|
||||
str: The unique ID of the created entry
|
||||
"""
|
||||
cls._ensure_loaded()
|
||||
|
||||
with cls._lock:
|
||||
# Generate unique ID based on timestamp
|
||||
entry_id = f"{int(time.time() * 1000)}"
|
||||
|
||||
# Get file size if not provided
|
||||
if file_size is None:
|
||||
try:
|
||||
file_path_obj = Path(file_path)
|
||||
if file_path_obj.exists():
|
||||
file_size = file_path_obj.stat().st_size
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get file size: {e}")
|
||||
file_size = 0
|
||||
|
||||
entry = {
|
||||
"id": entry_id,
|
||||
"title": title,
|
||||
"url": url,
|
||||
"thumbnail_url": thumbnail_url,
|
||||
"file_path": file_path,
|
||||
"download_date": datetime.now().isoformat(),
|
||||
"format_id": format_id,
|
||||
"is_audio_only": is_audio_only,
|
||||
"resolution": resolution,
|
||||
"file_size": file_size or 0,
|
||||
"channel": channel,
|
||||
"duration": duration,
|
||||
"download_options": download_options or {},
|
||||
}
|
||||
|
||||
# Add to beginning of list (most recent first)
|
||||
cls._history.insert(0, entry)
|
||||
cls._save()
|
||||
|
||||
logger.info(f"Added entry to history: {title}")
|
||||
return entry_id
|
||||
|
||||
@classmethod
|
||||
def get_all_entries(cls, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Retrieve all history entries.
|
||||
|
||||
Args:
|
||||
limit: Optional limit on number of entries to return (most recent first)
|
||||
|
||||
Returns:
|
||||
List of history entry dictionaries
|
||||
"""
|
||||
cls._ensure_loaded()
|
||||
|
||||
with cls._lock:
|
||||
if limit is not None:
|
||||
return cls._history[:limit]
|
||||
return cls._history.copy()
|
||||
|
||||
@classmethod
|
||||
def get_entry(cls, entry_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get a specific history entry by ID.
|
||||
|
||||
Args:
|
||||
entry_id: The unique entry ID
|
||||
|
||||
Returns:
|
||||
Entry dictionary or None if not found
|
||||
"""
|
||||
cls._ensure_loaded()
|
||||
|
||||
with cls._lock:
|
||||
for entry in cls._history:
|
||||
if entry.get("id") == entry_id:
|
||||
return entry.copy()
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def remove_entry(cls, entry_id: str) -> bool:
|
||||
"""
|
||||
Remove a specific entry from history.
|
||||
|
||||
Args:
|
||||
entry_id: The unique entry ID to remove
|
||||
|
||||
Returns:
|
||||
bool: True if entry was found and removed, False otherwise
|
||||
"""
|
||||
cls._ensure_loaded()
|
||||
|
||||
with cls._lock:
|
||||
for i, entry in enumerate(cls._history):
|
||||
if entry.get("id") == entry_id:
|
||||
removed = cls._history.pop(i)
|
||||
cls._save()
|
||||
logger.info(f"Removed entry from history: {removed.get('title', 'Unknown')}")
|
||||
return True
|
||||
|
||||
logger.debug(f"Entry ID '{entry_id}' not found in history.")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def clear_history(cls) -> int:
|
||||
"""
|
||||
Clear all history entries.
|
||||
|
||||
Returns:
|
||||
int: Number of entries that were cleared
|
||||
"""
|
||||
cls._ensure_loaded()
|
||||
|
||||
with cls._lock:
|
||||
count = len(cls._history)
|
||||
cls._history = []
|
||||
cls._save()
|
||||
logger.info(f"Cleared all history: {count} entries removed.")
|
||||
return count
|
||||
|
||||
@classmethod
|
||||
def search_entries(cls, query: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search history entries by title, channel, or URL.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
|
||||
Returns:
|
||||
List of matching history entries
|
||||
"""
|
||||
cls._ensure_loaded()
|
||||
|
||||
if not query:
|
||||
return cls.get_all_entries()
|
||||
|
||||
query_lower = query.lower()
|
||||
|
||||
with cls._lock:
|
||||
results = []
|
||||
for entry in cls._history:
|
||||
# Search in title, channel, and URL
|
||||
title = (entry.get("title") or "").lower()
|
||||
channel = (entry.get("channel") or "").lower()
|
||||
url = (entry.get("url") or "").lower()
|
||||
|
||||
if query_lower in title or query_lower in channel or query_lower in url:
|
||||
results.append(entry.copy())
|
||||
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def get_statistics(cls) -> Dict[str, Any]:
|
||||
"""
|
||||
Get statistics about download history.
|
||||
|
||||
Returns:
|
||||
Dictionary with statistics (total_downloads, total_size, etc.)
|
||||
"""
|
||||
cls._ensure_loaded()
|
||||
|
||||
with cls._lock:
|
||||
total_downloads = len(cls._history)
|
||||
total_size = sum(entry.get("file_size", 0) for entry in cls._history)
|
||||
video_count = sum(1 for entry in cls._history if not entry.get("is_audio_only", False))
|
||||
audio_count = sum(1 for entry in cls._history if entry.get("is_audio_only", False))
|
||||
|
||||
return {
|
||||
"total_downloads": total_downloads,
|
||||
"total_size": total_size,
|
||||
"video_count": video_count,
|
||||
"audio_count": audio_count,
|
||||
}
|
||||
Reference in New Issue
Block a user