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:
oop7
2025-11-03 18:27:22 +02:00
parent e862c811c2
commit df4a7ab49d
20 changed files with 1457 additions and 13 deletions
+37
View File
@@ -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)
+4
View File
@@ -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()
+75
View File
@@ -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"""
+7
View File
@@ -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)
+348
View File
@@ -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,
}