328 lines
12 KiB
Python
328 lines
12 KiB
Python
"""
|
|||
|
|
dialog_history.py
|
||
|
|
|
||
|
|
Dialog for viewing and managing download history.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import os
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
from datetime import datetime
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from PySide6 import QtCore, QtWidgets
|
||
|
|
|
||
|
|
from tidal_dl_ng.history import HistoryService
|
||
|
|
from tidal_dl_ng.logger import logger_gui
|
||
|
|
|
||
|
|
|
||
|
|
class DialogHistory(QtWidgets.QDialog):
|
||
|
|
"""Dialog for managing download history.
|
||
|
|
|
||
|
|
Displays tracks grouped by source (album, playlist, mix) with ability to:
|
||
|
|
- View download dates and source information
|
||
|
|
- Export/Import history
|
||
|
|
- Clear history or remove selected items
|
||
|
|
- View statistics
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, history_service: HistoryService, parent=None):
|
||
|
|
"""Initialize the history dialog.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
history_service: The HistoryService instance.
|
||
|
|
parent: Parent widget.
|
||
|
|
"""
|
||
|
|
super().__init__(parent)
|
||
|
|
|
||
|
|
# Import the generated UI
|
||
|
|
from tidal_dl_ng.ui.dialog_history import Ui_DialogHistory
|
||
|
|
|
||
|
|
self.ui = Ui_DialogHistory()
|
||
|
|
self.ui.setupUi(self)
|
||
|
|
self.history_service = history_service
|
||
|
|
|
||
|
|
self._init_ui()
|
||
|
|
self._connect_signals()
|
||
|
|
self._load_history()
|
||
|
|
|
||
|
|
self.exec()
|
||
|
|
|
||
|
|
def _init_ui(self):
|
||
|
|
"""Initialize UI elements."""
|
||
|
|
# Set file path
|
||
|
|
file_path = self.history_service.get_history_file_path()
|
||
|
|
self.ui.le_file_path.setText(file_path)
|
||
|
|
|
||
|
|
# Configure tree widget
|
||
|
|
self.ui.tw_history.setColumnWidth(0, 400)
|
||
|
|
self.ui.tw_history.setColumnWidth(1, 100)
|
||
|
|
self.ui.tw_history.setColumnWidth(2, 180)
|
||
|
|
self.ui.tw_history.setColumnWidth(3, 100)
|
||
|
|
|
||
|
|
# Set window size
|
||
|
|
self.resize(900, 600)
|
||
|
|
|
||
|
|
def _connect_signals(self):
|
||
|
|
"""Connect UI signals to handlers."""
|
||
|
|
self.ui.pb_refresh.clicked.connect(self._load_history)
|
||
|
|
self.ui.pb_export.clicked.connect(self._on_export)
|
||
|
|
self.ui.pb_import.clicked.connect(self._on_import)
|
||
|
|
self.ui.pb_clear_history.clicked.connect(self._on_clear_history)
|
||
|
|
self.ui.pb_remove_selected.clicked.connect(self._on_remove_selected)
|
||
|
|
self.ui.pb_close.clicked.connect(self.close)
|
||
|
|
self.ui.pb_open_folder.clicked.connect(self._on_open_folder)
|
||
|
|
|
||
|
|
def _load_history(self):
|
||
|
|
"""Load and display the download history."""
|
||
|
|
# Clear existing items
|
||
|
|
self.ui.tw_history.clear()
|
||
|
|
|
||
|
|
# Get history grouped by source
|
||
|
|
grouped_history = self.history_service.get_history_by_source()
|
||
|
|
|
||
|
|
# Get statistics
|
||
|
|
stats = self.history_service.get_statistics()
|
||
|
|
self._update_statistics(stats)
|
||
|
|
|
||
|
|
# Sort sources by name
|
||
|
|
sorted_sources = sorted(grouped_history.items(), key=lambda x: x[0])
|
||
|
|
|
||
|
|
# Populate tree
|
||
|
|
for _, tracks in sorted_sources:
|
||
|
|
if not tracks:
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Create parent item for source
|
||
|
|
source_item = self._create_source_item(tracks)
|
||
|
|
self.ui.tw_history.addTopLevelItem(source_item)
|
||
|
|
|
||
|
|
# Add tracks as children
|
||
|
|
for track_data in sorted(tracks, key=lambda x: x.get("download_date", ""), reverse=True):
|
||
|
|
track_item = self._create_track_item(track_data)
|
||
|
|
source_item.addChild(track_item)
|
||
|
|
|
||
|
|
# Expand all top-level items
|
||
|
|
self.ui.tw_history.expandAll()
|
||
|
|
|
||
|
|
logger_gui.info(f"Loaded {stats['total_tracks']} tracks from history")
|
||
|
|
|
||
|
|
def _create_source_item(self, tracks: list) -> QtWidgets.QTreeWidgetItem:
|
||
|
|
"""Create a tree widget item for a source.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
tracks: List of track data dictionaries.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
QTreeWidgetItem for the source.
|
||
|
|
"""
|
||
|
|
if not tracks:
|
||
|
|
return QtWidgets.QTreeWidgetItem()
|
||
|
|
|
||
|
|
first_track = tracks[0]
|
||
|
|
source_type = first_track.get("source_type", "unknown")
|
||
|
|
source_name = first_track.get("source_name", "Unknown")
|
||
|
|
source_id = first_track.get("source_id", "")
|
||
|
|
|
||
|
|
# Format source name
|
||
|
|
if source_type == "manual" or not source_name:
|
||
|
|
display_name = f"📝 Manual Downloads ({len(tracks)} tracks)"
|
||
|
|
elif source_type == "album":
|
||
|
|
display_name = f"💿 {source_name} ({len(tracks)} tracks)"
|
||
|
|
elif source_type == "playlist":
|
||
|
|
display_name = f"📋 {source_name} ({len(tracks)} tracks)"
|
||
|
|
elif source_type == "mix":
|
||
|
|
display_name = f"🎵 {source_name} ({len(tracks)} tracks)"
|
||
|
|
elif source_type == "track":
|
||
|
|
display_name = f"🎼 Individual Tracks ({len(tracks)} tracks)"
|
||
|
|
else:
|
||
|
|
display_name = f"{source_name} ({len(tracks)} tracks)"
|
||
|
|
|
||
|
|
item = QtWidgets.QTreeWidgetItem()
|
||
|
|
item.setText(0, display_name)
|
||
|
|
item.setText(1, source_type.capitalize())
|
||
|
|
item.setText(2, "")
|
||
|
|
item.setText(3, source_id or "")
|
||
|
|
|
||
|
|
# Make it bold
|
||
|
|
font = item.font(0)
|
||
|
|
font.setBold(True)
|
||
|
|
item.setFont(0, font)
|
||
|
|
|
||
|
|
return item
|
||
|
|
|
||
|
|
def _create_track_item(self, track_data: dict) -> QtWidgets.QTreeWidgetItem:
|
||
|
|
"""Create a tree widget item for a track.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
track_data: Dictionary with track information.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
QTreeWidgetItem for the track.
|
||
|
|
"""
|
||
|
|
track_id = track_data.get("track_id", "")
|
||
|
|
download_date = track_data.get("download_date", "")
|
||
|
|
|
||
|
|
# Format date
|
||
|
|
try:
|
||
|
|
if download_date:
|
||
|
|
dt = datetime.fromisoformat(download_date.replace("Z", "+00:00"))
|
||
|
|
formatted_date = dt.strftime("%Y-%m-%d %H:%M:%S")
|
||
|
|
else:
|
||
|
|
formatted_date = "Unknown"
|
||
|
|
except:
|
||
|
|
formatted_date = download_date
|
||
|
|
|
||
|
|
item = QtWidgets.QTreeWidgetItem()
|
||
|
|
item.setText(0, f" Track {track_id}")
|
||
|
|
item.setText(1, "Track")
|
||
|
|
item.setText(2, formatted_date)
|
||
|
|
item.setText(3, track_id)
|
||
|
|
|
||
|
|
# Store track ID for later use
|
||
|
|
item.setData(0, QtCore.Qt.ItemDataRole.UserRole, track_id)
|
||
|
|
|
||
|
|
return item
|
||
|
|
|
||
|
|
def _update_statistics(self, stats: dict):
|
||
|
|
"""Update statistics labels.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
stats: Dictionary with statistics.
|
||
|
|
"""
|
||
|
|
total = stats.get("total_tracks", 0)
|
||
|
|
by_type = stats.get("by_source_type", {})
|
||
|
|
|
||
|
|
self.ui.l_total_tracks.setText(f"Total Tracks: {total}")
|
||
|
|
self.ui.l_by_albums.setText(f"Albums: {by_type.get('album', 0)}")
|
||
|
|
self.ui.l_by_playlists.setText(f"Playlists: {by_type.get('playlist', 0)}")
|
||
|
|
self.ui.l_by_mixes.setText(f"Mixes: {by_type.get('mix', 0)}")
|
||
|
|
self.ui.l_by_manual.setText(f"Manual: {by_type.get('manual', 0)}")
|
||
|
|
|
||
|
|
def _on_export(self):
|
||
|
|
"""Handle export button click."""
|
||
|
|
file_path, _ = QtWidgets.QFileDialog.getSaveFileName(
|
||
|
|
self, "Export Download History", "download_history_export.json", "JSON Files (*.json)"
|
||
|
|
)
|
||
|
|
|
||
|
|
if file_path:
|
||
|
|
success, message = self.history_service.export_history(file_path)
|
||
|
|
|
||
|
|
if success:
|
||
|
|
QtWidgets.QMessageBox.information(self, "Export Successful", message)
|
||
|
|
logger_gui.info(f"Exported history to: {file_path}")
|
||
|
|
else:
|
||
|
|
QtWidgets.QMessageBox.critical(self, "Export Failed", f"Failed to export history:\n{message}")
|
||
|
|
logger_gui.error(f"Export failed: {message}")
|
||
|
|
|
||
|
|
def _on_import(self):
|
||
|
|
"""Handle import button click."""
|
||
|
|
file_path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Import Download History", "", "JSON Files (*.json)")
|
||
|
|
|
||
|
|
if not file_path:
|
||
|
|
return
|
||
|
|
|
||
|
|
# Ask merge or replace
|
||
|
|
reply = QtWidgets.QMessageBox.question(
|
||
|
|
self,
|
||
|
|
"Import Mode",
|
||
|
|
"Do you want to MERGE with existing history?\n\n"
|
||
|
|
"Yes = Merge (add new tracks, keep existing)\n"
|
||
|
|
"No = Replace (delete all existing, import only new)\n"
|
||
|
|
"Cancel = Abort import",
|
||
|
|
QtWidgets.QMessageBox.StandardButton.Yes
|
||
|
|
| QtWidgets.QMessageBox.StandardButton.No
|
||
|
|
| QtWidgets.QMessageBox.StandardButton.Cancel,
|
||
|
|
)
|
||
|
|
|
||
|
|
if reply == QtWidgets.QMessageBox.StandardButton.Cancel:
|
||
|
|
return
|
||
|
|
|
||
|
|
merge = reply == QtWidgets.QMessageBox.StandardButton.Yes
|
||
|
|
|
||
|
|
# Perform import
|
||
|
|
success, message = self.history_service.import_history(file_path, merge=merge)
|
||
|
|
|
||
|
|
if success:
|
||
|
|
QtWidgets.QMessageBox.information(self, "Import Successful", message)
|
||
|
|
logger_gui.info(f"Imported history from: {file_path}")
|
||
|
|
self._load_history() # Refresh display
|
||
|
|
else:
|
||
|
|
QtWidgets.QMessageBox.critical(self, "Import Failed", f"Failed to import history:\n{message}")
|
||
|
|
logger_gui.error(f"Import failed: {message}")
|
||
|
|
|
||
|
|
def _on_clear_history(self):
|
||
|
|
"""Handle clear history button click."""
|
||
|
|
reply = QtWidgets.QMessageBox.warning(
|
||
|
|
self,
|
||
|
|
"Clear Download History",
|
||
|
|
"Are you sure you want to clear ALL download history?\n\n" "This action cannot be undone!",
|
||
|
|
QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
|
||
|
|
)
|
||
|
|
|
||
|
|
if reply == QtWidgets.QMessageBox.StandardButton.Yes:
|
||
|
|
self.history_service.clear_history()
|
||
|
|
QtWidgets.QMessageBox.information(self, "History Cleared", "Download history has been cleared.")
|
||
|
|
logger_gui.info("Download history cleared")
|
||
|
|
self._load_history() # Refresh display
|
||
|
|
|
||
|
|
def _on_remove_selected(self):
|
||
|
|
"""Handle remove selected button click."""
|
||
|
|
selected_items = self.ui.tw_history.selectedItems()
|
||
|
|
|
||
|
|
if not selected_items:
|
||
|
|
QtWidgets.QMessageBox.warning(self, "No Selection", "Please select one or more tracks to remove.")
|
||
|
|
return
|
||
|
|
|
||
|
|
# Collect track IDs from selected items (only child items, not parents)
|
||
|
|
track_ids = []
|
||
|
|
for item in selected_items:
|
||
|
|
# Check if it's a track (child item) by checking if it has a parent
|
||
|
|
if item.parent() is not None:
|
||
|
|
track_id = item.data(0, QtCore.Qt.ItemDataRole.UserRole)
|
||
|
|
if track_id:
|
||
|
|
track_ids.append(track_id)
|
||
|
|
|
||
|
|
if not track_ids:
|
||
|
|
QtWidgets.QMessageBox.warning(
|
||
|
|
self, "No Tracks Selected", "Please select individual tracks (not source groups) to remove."
|
||
|
|
)
|
||
|
|
return
|
||
|
|
|
||
|
|
reply = QtWidgets.QMessageBox.question(
|
||
|
|
self,
|
||
|
|
"Remove Tracks",
|
||
|
|
f"Are you sure you want to remove {len(track_ids)} track(s) from history?",
|
||
|
|
QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
|
||
|
|
)
|
||
|
|
|
||
|
|
if reply == QtWidgets.QMessageBox.StandardButton.Yes:
|
||
|
|
removed_count = 0
|
||
|
|
for track_id in track_ids:
|
||
|
|
if self.history_service.remove_track_from_history(track_id):
|
||
|
|
removed_count += 1
|
||
|
|
|
||
|
|
QtWidgets.QMessageBox.information(self, "Tracks Removed", f"Removed {removed_count} track(s) from history.")
|
||
|
|
logger_gui.info(f"Removed {removed_count} tracks from history")
|
||
|
|
self._load_history() # Refresh display
|
||
|
|
|
||
|
|
def _on_open_folder(self):
|
||
|
|
"""Open the folder containing the history file."""
|
||
|
|
file_path = Path(self.history_service.get_history_file_path())
|
||
|
|
folder_path = file_path.parent
|
||
|
|
|
||
|
|
try:
|
||
|
|
if sys.platform == "win32":
|
||
|
|
os.startfile(folder_path) # noqa: S606
|
||
|
|
elif sys.platform == "darwin":
|
||
|
|
# Security: folder_path is from config, not user input
|
||
|
|
subprocess.run(["open", str(folder_path)], check=False) # noqa: S603, S607
|
||
|
|
else:
|
||
|
|
# Security: folder_path is from config, not user input
|
||
|
|
subprocess.run(["xdg-open", str(folder_path)], check=False) # noqa: S603, S607
|
||
|
|
|
||
|
|
logger_gui.info(f"Opened folder: {folder_path}")
|
||
|
|
except Exception as e:
|
||
|
|
QtWidgets.QMessageBox.warning(self, "Cannot Open Folder", f"Failed to open folder:\n{e!s}")
|
||
|
|
logger_gui.error(f"Failed to open folder: {e}")
|