Merge branch 'feature/json-downloaded' into fusion-refac-&-info
# Conflicts: # .gitignore # README.md # tidal_dl_ng/gui.py
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
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}")
|
||||
+46
-2
@@ -75,6 +75,7 @@ from tidal_dl_ng.helper.tidal import (
|
||||
name_builder_title,
|
||||
parse_track_and_album_extras,
|
||||
)
|
||||
from tidal_dl_ng.history import HistoryService
|
||||
from tidal_dl_ng.metadata import Metadata
|
||||
from tidal_dl_ng.model.downloader import DownloadSegmentResult, TrackStreamInfo
|
||||
from tidal_dl_ng.model.gui_data import ProgressBars
|
||||
@@ -121,6 +122,7 @@ class Download:
|
||||
progress_overall: Progress
|
||||
event_abort: Event
|
||||
event_run: Event
|
||||
history_service: HistoryService
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -161,6 +163,7 @@ class Download:
|
||||
self.path_base = path_base
|
||||
self.event_abort = event_abort
|
||||
self.event_run = event_run
|
||||
self.history_service = HistoryService()
|
||||
|
||||
if not self.settings.data.path_binary_ffmpeg and (
|
||||
self.settings.data.video_convert_mp4 or self.settings.data.extract_flac
|
||||
@@ -177,7 +180,7 @@ class Download:
|
||||
def _get_media_urls(
|
||||
self,
|
||||
media: Track | Video,
|
||||
stream_manifest: StreamManifest | None = None,
|
||||
stream_manifest: StreamManifest | None,
|
||||
) -> list[str]:
|
||||
"""Extract URLs for the given media item.
|
||||
|
||||
@@ -531,6 +534,9 @@ class Download:
|
||||
is_parent_album: bool = False,
|
||||
list_position: int = 0,
|
||||
list_total: int = 0,
|
||||
source_type: str = "manual",
|
||||
source_id: str | None = None,
|
||||
source_name: str | None = None,
|
||||
) -> tuple[bool, pathlib.Path | str]:
|
||||
"""Download a single media item, handling file naming, skipping, and post-processing.
|
||||
|
||||
@@ -546,6 +552,9 @@ class Download:
|
||||
is_parent_album (bool, optional): Whether this is a parent album. Defaults to False.
|
||||
list_position (int, optional): Position in list. Defaults to 0.
|
||||
list_total (int, optional): Total items in list. Defaults to 0.
|
||||
source_type (str, optional): Source type (playlist, album, manual, mix, track). Defaults to "manual".
|
||||
source_id (str | None, optional): Source ID (UUID for playlist/album). Defaults to None.
|
||||
source_name (str | None, optional): Source name for display. Defaults to None.
|
||||
|
||||
Returns:
|
||||
tuple[bool, pathlib.Path | str]: (Downloaded, path to file)
|
||||
@@ -562,6 +571,13 @@ class Download:
|
||||
media, file_template, quality_audio, list_position, list_total
|
||||
)
|
||||
|
||||
# Step 2b: Duplicate prevention based on persistent history
|
||||
if isinstance(media, Track):
|
||||
track_id = str(media.id)
|
||||
if self.history_service.should_skip_download(track_id):
|
||||
self.fn_logger.info(f"Skipped item '{name_builder_item(media)}' (already in history).")
|
||||
return False, path_media_dst
|
||||
|
||||
if skip_file:
|
||||
self.fn_logger.debug(f"Download skipped, since file exists: '{path_media_dst}'")
|
||||
|
||||
@@ -587,6 +603,16 @@ class Download:
|
||||
skip_file,
|
||||
)
|
||||
|
||||
# Step 6: Add to download history if successful (only for Tracks)
|
||||
if download_success and isinstance(media, Track):
|
||||
try:
|
||||
self.history_service.add_track_to_history(
|
||||
track_id=str(media.id), source_type=source_type, source_id=source_id, source_name=source_name
|
||||
)
|
||||
except Exception as e:
|
||||
# Don't fail the download if history tracking fails
|
||||
self.fn_logger.warning(f"Failed to add track to history: {e}")
|
||||
|
||||
return download_success, path_media_dst
|
||||
|
||||
def _validate_and_prepare_media(
|
||||
@@ -1435,6 +1461,9 @@ class Download:
|
||||
download_delay: bool = True,
|
||||
quality_audio: Quality | None = None,
|
||||
quality_video: QualityVideo | None = None,
|
||||
source_type: str = "manual",
|
||||
source_id: str | None = None,
|
||||
source_name: str | None = None,
|
||||
) -> None:
|
||||
"""Download all items in an album, playlist, or mix.
|
||||
|
||||
@@ -1447,6 +1476,9 @@ class Download:
|
||||
download_delay (bool, optional): Whether to delay between downloads. Defaults to True.
|
||||
quality_audio (Quality | None, optional): Audio quality. Defaults to None.
|
||||
quality_video (QualityVideo | None, optional): Video quality. Defaults to None.
|
||||
source_type (str, optional): Source type (playlist, album, manual, mix). Defaults to "manual".
|
||||
source_id (str | None, optional): Source ID (UUID for playlist/album). Defaults to None.
|
||||
source_name (str | None, optional): Source name for display. Defaults to None.
|
||||
"""
|
||||
# Validate and prepare media collection
|
||||
validated_media = self._validate_and_prepare_media(media, media_id, media_type, video_download)
|
||||
@@ -1482,6 +1514,9 @@ class Download:
|
||||
progress,
|
||||
progress_task,
|
||||
progress_stdout,
|
||||
source_type,
|
||||
source_id,
|
||||
source_name,
|
||||
)
|
||||
|
||||
# Create playlist file if requested
|
||||
@@ -1520,7 +1555,7 @@ class Download:
|
||||
list_media_name_short: str = list_media_name[:30]
|
||||
|
||||
# Get all items of the list.
|
||||
items = items_results_all(media, videos_include=video_download)
|
||||
items = items_results_all(self.tidal.session, media, videos_include=video_download)
|
||||
|
||||
# Determine where to redirect the progress information.
|
||||
if self.progress_gui is None:
|
||||
@@ -1544,6 +1579,9 @@ class Download:
|
||||
progress: Progress,
|
||||
progress_task: TaskID,
|
||||
progress_stdout: bool,
|
||||
source_type: str = "manual",
|
||||
source_id: str | None = None,
|
||||
source_name: str | None = None,
|
||||
) -> list[pathlib.Path]:
|
||||
"""Execute downloads for all items in the collection.
|
||||
|
||||
@@ -1558,6 +1596,9 @@ class Download:
|
||||
progress (Progress): Progress bar instance.
|
||||
progress_task (TaskID): Progress task ID.
|
||||
progress_stdout (bool): Whether to show progress in stdout.
|
||||
source_type (str, optional): Source type. Defaults to "manual".
|
||||
source_id (str | None, optional): Source ID. Defaults to None.
|
||||
source_name (str | None, optional): Source name. Defaults to None.
|
||||
|
||||
Returns:
|
||||
list[pathlib.Path]: List of result directories.
|
||||
@@ -1589,6 +1630,9 @@ class Download:
|
||||
is_parent_album=is_album,
|
||||
list_position=count + 1,
|
||||
list_total=list_total,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
source_name=source_name,
|
||||
)
|
||||
for count, item_media in enumerate(items)
|
||||
]
|
||||
|
||||
+627
-15
@@ -57,13 +57,20 @@ from tidalapi.session import LinkLogin
|
||||
|
||||
from tidal_dl_ng import __version__, update_available
|
||||
from tidal_dl_ng.dialog import DialogLogin, DialogPreferences, DialogVersion
|
||||
from tidal_dl_ng.dialog_history import DialogHistory
|
||||
from tidal_dl_ng.helper.gui import (
|
||||
FilterHeader,
|
||||
HumanProxyModel,
|
||||
get_queue_download_media,
|
||||
get_queue_download_quality_audio,
|
||||
get_queue_download_quality_video,
|
||||
get_results_media_item,
|
||||
get_user_list_media_item,
|
||||
set_queue_download_media,
|
||||
set_user_list_media,
|
||||
)
|
||||
from tidal_dl_ng.helper.hover_manager import HoverManager
|
||||
from tidal_dl_ng.helper.path import get_format_template, resource_path
|
||||
from tidal_dl_ng.helper.tidal import (
|
||||
extract_contributor_names,
|
||||
favorite_function_factory,
|
||||
@@ -71,6 +78,7 @@ from tidal_dl_ng.helper.tidal import (
|
||||
items_results_all,
|
||||
name_builder_artist,
|
||||
parse_track_and_album_extras,
|
||||
user_media_lists,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -81,22 +89,25 @@ except ImportError as e:
|
||||
print("Qt dependencies missing. Cannot start GUI. Please read the 'README.md' carefully.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
from ansi2html import Ansi2HTMLConverter
|
||||
from rich.progress import Progress
|
||||
from tidalapi import Album, Mix, Playlist, Quality, Track, Video
|
||||
from tidalapi import Album, Mix, Playlist, Quality, Track, UserPlaylist, Video
|
||||
from tidalapi.artist import Artist
|
||||
from tidalapi.playlist import Folder
|
||||
from tidalapi.session import SearchTypes
|
||||
|
||||
from tidal_dl_ng.cache import TrackExtrasCache
|
||||
from tidal_dl_ng.config import HandlingApp, Settings, Tidal
|
||||
from tidal_dl_ng.constants import QualityVideo
|
||||
from tidal_dl_ng.constants import FAVORITES, QualityVideo, QueueDownloadStatus, TidalLists
|
||||
from tidal_dl_ng.download import Download
|
||||
from tidal_dl_ng.gui_covers import CoverManager
|
||||
from tidal_dl_ng.gui_playlist import GuiPlaylistManager
|
||||
from tidal_dl_ng.gui_queue import GuiQueueManager
|
||||
from tidal_dl_ng.gui_search import GuiSearchManager
|
||||
from tidal_dl_ng.history import HistoryService
|
||||
from tidal_dl_ng.logger import XStream, logger_gui
|
||||
from tidal_dl_ng.model.gui_data import ProgressBars, ResultItem, StatusbarMessage
|
||||
from tidal_dl_ng.model.gui_data import ProgressBars, QueueDownloadItem, ResultItem, StatusbarMessage
|
||||
from tidal_dl_ng.model.meta import ReleaseLatest
|
||||
from tidal_dl_ng.ui.info_tab_widget import InfoTabWidget
|
||||
from tidal_dl_ng.ui.main import Ui_MainWindow
|
||||
@@ -114,6 +125,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
settings: Settings
|
||||
tidal: Tidal
|
||||
dl: Download
|
||||
history_service: HistoryService
|
||||
threadpool: QtCore.QThreadPool
|
||||
tray: QtWidgets.QSystemTrayIcon
|
||||
spinners: dict
|
||||
@@ -174,6 +186,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
XStream.stdout().messageWritten.connect(self._log_output)
|
||||
# XStream.stderr().messageWritten.connect(self._log_output)
|
||||
|
||||
self.settings = Settings()
|
||||
self.history_service = HistoryService()
|
||||
|
||||
# Core components
|
||||
self._init_threads()
|
||||
self._init_gui()
|
||||
@@ -190,11 +205,15 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
self._init_tree_results(self.tr_results, self.model_tr_results)
|
||||
self.playlist_manager.init_ui()
|
||||
self.queue_manager.init_ui()
|
||||
self._init_tree_lists(self.tr_lists_user)
|
||||
self._init_tree_queue(self.tr_queue_download)
|
||||
self._init_info()
|
||||
self._init_progressbar()
|
||||
self._populate_quality(self.cb_quality_audio, Quality)
|
||||
self._populate_quality(self.cb_quality_video, QualityVideo)
|
||||
self._populate_search_types(self.cb_search_type, SearchTypes)
|
||||
self.apply_settings(self.settings)
|
||||
self._init_menu_actions()
|
||||
self._init_signals()
|
||||
|
||||
# Connect signal for invoking track extras callbacks
|
||||
@@ -205,13 +224,24 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
logger_gui.debug("All setup.")
|
||||
|
||||
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
|
||||
"""Ensure background workers and hover hooks stop before exit."""
|
||||
HandlingApp().event_abort.set()
|
||||
if hasattr(self, "hover_manager") and self.hover_manager:
|
||||
with contextlib.suppress(Exception):
|
||||
self.hover_manager.stop()
|
||||
self.threadpool.waitForDone(2000)
|
||||
super().closeEvent(event)
|
||||
"""Handle the close event of the main window.
|
||||
|
||||
Args:
|
||||
event (QtGui.QCloseEvent): The close event.
|
||||
"""
|
||||
# Save the main window size and position
|
||||
self.settings.data.window_x = self.x()
|
||||
self.settings.data.window_y = self.y()
|
||||
self.settings.data.window_w = self.width()
|
||||
self.settings.data.window_h = self.height()
|
||||
self.settings.save()
|
||||
|
||||
self.shutdown = True
|
||||
|
||||
handling_app: HandlingApp = HandlingApp()
|
||||
handling_app.event_abort.set()
|
||||
|
||||
event.accept()
|
||||
|
||||
def _init_gui(self) -> None:
|
||||
"""Initialize GUI-specific variables and state."""
|
||||
@@ -308,6 +338,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
# self.pb_progress.setVisible()
|
||||
self.statusbar.addPermanentWidget(pb)
|
||||
|
||||
def _init_info(self):
|
||||
"""Set default album cover image in the GUI."""
|
||||
path_image: str = resource_path("tidal_dl_ng/ui/default_album_image.png")
|
||||
|
||||
self.l_pm_cover.setPixmap(QtGui.QPixmap(path_image))
|
||||
|
||||
def get_track_extras(self, track_id: str, callback: Callable[[str, dict | None], None]) -> dict | None:
|
||||
"""Return cached extras for a track or start async fetch.
|
||||
|
||||
@@ -472,6 +508,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
tree.setColumnWidth(5, skinny_width) # duration
|
||||
tree.setColumnWidth(6, narrow_width) # quality
|
||||
tree.setColumnWidth(7, narrow_width) # date
|
||||
tree.setColumnWidth(8, skinny_width) # downloaded?
|
||||
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents)
|
||||
# Connect the contextmenu
|
||||
tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
@@ -495,12 +532,166 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
Args:
|
||||
model (QStandardItemModel): The model to initialize.
|
||||
"""
|
||||
labels_column: list[str] = ["#", "obj", "Artist", "Title", "Album", "Duration", "Quality", "Date"]
|
||||
labels_column: list[str] = [
|
||||
"#",
|
||||
"obj",
|
||||
"Artist",
|
||||
"Title",
|
||||
"Album",
|
||||
"Duration",
|
||||
"Quality",
|
||||
"Date",
|
||||
"Downloaded?",
|
||||
]
|
||||
|
||||
model.setColumnCount(len(labels_column))
|
||||
model.setRowCount(0)
|
||||
model.setHorizontalHeaderLabels(labels_column)
|
||||
|
||||
def _init_tree_queue(self, tree: QtWidgets.QTableWidget) -> None:
|
||||
"""Initialize the download queue table widget.
|
||||
|
||||
Args:
|
||||
tree (QTableWidget): The table widget.
|
||||
"""
|
||||
tree.setColumnHidden(1, True)
|
||||
tree.setColumnWidth(2, 200)
|
||||
|
||||
header = tree.header()
|
||||
|
||||
if hasattr(header, "setSectionResizeMode"):
|
||||
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents)
|
||||
tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
tree.customContextMenuRequested.connect(self.menu_context_queue_download)
|
||||
|
||||
def tidal_user_lists(self) -> None:
|
||||
"""Fetch and emit user playlists, mixes, and favorites from Tidal."""
|
||||
# Start loading spinner
|
||||
self.s_spinner_start.emit(self.tr_lists_user)
|
||||
self.s_pb_reload_status.emit(False)
|
||||
|
||||
user_all: dict[str, list] = user_media_lists(self.tidal.session)
|
||||
|
||||
self.s_populate_tree_lists.emit(user_all)
|
||||
|
||||
def on_populate_tree_lists(self, user_lists: dict[str, list]) -> None:
|
||||
"""Populate the user lists tree with playlists, mixes, and favorites.
|
||||
|
||||
Args:
|
||||
user_lists (dict[str, list]): Dictionary with 'playlists' (Folder/Playlist) and 'mixes' lists.
|
||||
"""
|
||||
twi_playlists: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems(
|
||||
TidalLists.Playlists, QtCore.Qt.MatchExactly, 0
|
||||
)[0]
|
||||
twi_mixes: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems(
|
||||
TidalLists.Mixes, QtCore.Qt.MatchExactly, 0
|
||||
)[0]
|
||||
twi_favorites: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems(
|
||||
TidalLists.Favorites, QtCore.Qt.MatchExactly, 0
|
||||
)[0]
|
||||
|
||||
# Remove all children if present
|
||||
for twi in [twi_playlists, twi_mixes]:
|
||||
for i in reversed(range(twi.childCount())):
|
||||
twi.removeChild(twi.child(i))
|
||||
|
||||
# Populate playlists (including folders)
|
||||
for item in user_lists.get("playlists", []):
|
||||
if isinstance(item, Folder):
|
||||
twi_child = QtWidgets.QTreeWidgetItem(twi_playlists)
|
||||
name: str = f"📁 {item.name}"
|
||||
info: str = f"({item.total_number_of_items} items)" if item.total_number_of_items else ""
|
||||
twi_child.setText(0, name)
|
||||
set_user_list_media(twi_child, item)
|
||||
twi_child.setText(2, info)
|
||||
|
||||
# Add disabled dummy child to show expansion arrow
|
||||
dummy_child = QtWidgets.QTreeWidgetItem(twi_child)
|
||||
dummy_child.setDisabled(True)
|
||||
elif isinstance(item, UserPlaylist | Playlist):
|
||||
twi_child = QtWidgets.QTreeWidgetItem(twi_playlists)
|
||||
name: str = item.name if getattr(item, "name", None) is not None else ""
|
||||
description: str = f" {item.description}" if item.description else ""
|
||||
info: str = f"({item.num_tracks + item.num_videos} Tracks){description}"
|
||||
twi_child.setText(0, name)
|
||||
set_user_list_media(twi_child, item)
|
||||
twi_child.setText(2, info)
|
||||
|
||||
# Populate mixes
|
||||
for item in user_lists.get("mixes", []):
|
||||
if isinstance(item, Mix):
|
||||
twi_child = QtWidgets.QTreeWidgetItem(twi_mixes)
|
||||
name: str = item.title
|
||||
info: str = item.sub_title
|
||||
twi_child.setText(0, name)
|
||||
set_user_list_media(twi_child, item)
|
||||
twi_child.setText(2, info)
|
||||
|
||||
# Remove all children from favorites to avoid duplication
|
||||
for i in reversed(range(twi_favorites.childCount())):
|
||||
twi_favorites.removeChild(twi_favorites.child(i))
|
||||
|
||||
# Populate static favorites
|
||||
for key, favorite in FAVORITES.items():
|
||||
twi_child = QtWidgets.QTreeWidgetItem(twi_favorites)
|
||||
name: str = favorite["name"]
|
||||
info: str = ""
|
||||
|
||||
twi_child.setText(0, name)
|
||||
set_user_list_media(twi_child, key)
|
||||
twi_child.setText(2, info)
|
||||
|
||||
# Stop load spinner
|
||||
self.s_spinner_stop.emit()
|
||||
self.s_pb_reload_status.emit(True)
|
||||
|
||||
def _init_tree_lists(self, tree: QtWidgets.QTreeWidget) -> None:
|
||||
"""Initialize the user lists tree widget.
|
||||
|
||||
Args:
|
||||
tree (QTreeWidget): The tree widget.
|
||||
"""
|
||||
# Adjust Tree.
|
||||
tree.setColumnWidth(0, 200)
|
||||
tree.setColumnHidden(1, True)
|
||||
tree.setColumnWidth(2, 300)
|
||||
tree.expandAll()
|
||||
|
||||
# Connect the contextmenu
|
||||
tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
tree.customContextMenuRequested.connect(self.playlist_manager.menu_context_tree_lists)
|
||||
|
||||
def _init_menu_actions(self) -> None:
|
||||
"""Initialize custom menu actions."""
|
||||
# Create Tools menu if it doesn't exist
|
||||
menubar = self.menuBar()
|
||||
tools_menu = None
|
||||
|
||||
# Find or create Tools menu
|
||||
for action in menubar.actions():
|
||||
if action.text() == "Tools":
|
||||
tools_menu = action.menu()
|
||||
break
|
||||
|
||||
if not tools_menu:
|
||||
tools_menu = menubar.addMenu("Tools")
|
||||
|
||||
# Create View History action
|
||||
self.a_view_history = QtGui.QAction("View Download History...", self)
|
||||
self.a_view_history.triggered.connect(self.on_view_history)
|
||||
tools_menu.addAction(self.a_view_history)
|
||||
|
||||
# Add separator
|
||||
tools_menu.addSeparator()
|
||||
|
||||
# Add duplicate prevention toggle
|
||||
self.a_toggle_duplicate_prevention = QtGui.QAction("Prevent Duplicate Downloads", self)
|
||||
self.a_toggle_duplicate_prevention.setCheckable(True)
|
||||
is_preventing = self.history_service.get_settings().get("preventDuplicates", True)
|
||||
self.a_toggle_duplicate_prevention.setChecked(is_preventing)
|
||||
self.a_toggle_duplicate_prevention.triggered.connect(self.on_toggle_duplicate_prevention)
|
||||
tools_menu.addAction(self.a_toggle_duplicate_prevention)
|
||||
|
||||
def on_update_check(self, on_startup: bool = True) -> None:
|
||||
"""Check for application updates and emit update signals.
|
||||
|
||||
@@ -587,10 +778,185 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
if isinstance(media, Track | Video) and hasattr(media, "album") and media.album:
|
||||
menu.addAction("Download Full Album", lambda: self.thread_download_album_from_track(point))
|
||||
|
||||
# Add mark/unmark as downloaded for Tracks
|
||||
if isinstance(media, Track):
|
||||
track_id = str(media.id)
|
||||
is_downloaded = self.history_service.is_downloaded(track_id)
|
||||
|
||||
if is_downloaded:
|
||||
menu.addAction(
|
||||
"✖️ Mark as Not Downloaded", lambda: self.on_mark_track_as_not_downloaded(track_id, index)
|
||||
)
|
||||
else:
|
||||
menu.addAction("✅ Mark as Downloaded", lambda: self.on_mark_track_as_downloaded(media, index))
|
||||
|
||||
menu.addAction("Copy Share URL", lambda: self.on_copy_url_share(self.tr_results, point))
|
||||
|
||||
menu.exec(self.tr_results.mapToGlobal(point))
|
||||
|
||||
def menu_context_queue_download(self, point: QtCore.QPoint) -> None:
|
||||
"""Show context menu for download queue.
|
||||
|
||||
Args:
|
||||
point (QPoint): The point where the menu is requested.
|
||||
"""
|
||||
# Get the item at this point
|
||||
item = self.tr_queue_download.itemAt(point)
|
||||
|
||||
if not item:
|
||||
return
|
||||
|
||||
# Build the menu
|
||||
menu = QtWidgets.QMenu()
|
||||
|
||||
# Show remove option for waiting items
|
||||
status = item.text(0)
|
||||
if status == QueueDownloadStatus.Waiting:
|
||||
menu.addAction("🗑️ Remove from Queue", lambda: self.on_queue_download_remove_item(item))
|
||||
|
||||
if menu.isEmpty():
|
||||
return
|
||||
|
||||
menu.exec(self.tr_queue_download.mapToGlobal(point))
|
||||
|
||||
def on_queue_download_remove_item(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Remove a specific item from the download queue.
|
||||
|
||||
Args:
|
||||
item (QTreeWidgetItem): The item to remove.
|
||||
"""
|
||||
index = self.tr_queue_download.indexOfTopLevelItem(item)
|
||||
if index >= 0:
|
||||
self.tr_queue_download.takeTopLevelItem(index)
|
||||
logger_gui.info("Removed item from download queue")
|
||||
|
||||
def on_mark_track_as_downloaded(self, track: Track, index: QtCore.QModelIndex) -> None:
|
||||
"""Mark a track as downloaded in history.
|
||||
|
||||
Args:
|
||||
track (Track): The track to mark.
|
||||
index (QModelIndex): The model index of the track.
|
||||
"""
|
||||
track_id = str(track.id)
|
||||
|
||||
# Determine source information (manual for now)
|
||||
source_type = "manual"
|
||||
source_id = None
|
||||
source_name = None
|
||||
|
||||
# Try to get album information if available
|
||||
if hasattr(track, "album") and track.album:
|
||||
source_type = "album"
|
||||
source_id = str(track.album.id)
|
||||
source_name = track.album.name
|
||||
|
||||
self.history_service.add_track_to_history(
|
||||
track_id=track_id, source_type=source_type, source_id=source_id, source_name=source_name
|
||||
)
|
||||
|
||||
# Update the UI - refresh the downloaded column
|
||||
self._update_downloaded_column(index, True)
|
||||
logger_gui.info(f"Marked track as downloaded: {track.name}")
|
||||
|
||||
def on_mark_track_as_not_downloaded(self, track_id: str, index: QtCore.QModelIndex) -> None:
|
||||
"""Remove a track from download history.
|
||||
|
||||
Args:
|
||||
track_id (str): The track ID to remove.
|
||||
index (QModelIndex): The model index of the track.
|
||||
"""
|
||||
success = self.history_service.remove_track_from_history(track_id)
|
||||
|
||||
if success:
|
||||
# Update the UI - refresh the downloaded column
|
||||
self._update_downloaded_column(index, False)
|
||||
logger_gui.info(f"Unmarked track (ID: {track_id})")
|
||||
|
||||
def _update_downloaded_column(self, index: QtCore.QModelIndex, is_downloaded: bool) -> None:
|
||||
"""Update the Downloaded? column for a specific index.
|
||||
|
||||
Args:
|
||||
index (QModelIndex): The model index of the item.
|
||||
is_downloaded (bool): Whether the track is downloaded.
|
||||
"""
|
||||
# Get the source index (column 0)
|
||||
source_index = self.proxy_tr_results.mapToSource(index)
|
||||
|
||||
# Get the item from the model
|
||||
item = self.model_tr_results.itemFromIndex(source_index)
|
||||
if not item:
|
||||
return
|
||||
|
||||
# Get the parent row (the row contains all columns)
|
||||
row = item.row()
|
||||
parent = item.parent()
|
||||
|
||||
# Column 8 is "Downloaded?"
|
||||
downloaded_item = self.model_tr_results.item(row, 8) if parent is None else parent.child(row, 8)
|
||||
|
||||
if downloaded_item:
|
||||
if is_downloaded:
|
||||
downloaded_item.setText("✅")
|
||||
downloaded_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
else:
|
||||
downloaded_item.setText("")
|
||||
|
||||
def thread_download_list_media(self, point: QtCore.QPoint) -> None:
|
||||
"""Start download of a list media item in a thread.
|
||||
|
||||
Args:
|
||||
point (QPoint): The point in the tree.
|
||||
"""
|
||||
self.thread_it(self.playlist_manager.on_download_list_media, point)
|
||||
|
||||
def on_download_all_albums_from_playlist(self, point: QtCore.QPoint) -> None:
|
||||
"""Download all unique albums from tracks in a playlist.
|
||||
|
||||
Args:
|
||||
point (QPoint): The point in the tree where the playlist was right-clicked.
|
||||
"""
|
||||
try:
|
||||
# Get and validate the playlist
|
||||
item = self.tr_lists_user.itemAt(point)
|
||||
media_list = get_user_list_media_item(item)
|
||||
|
||||
if not isinstance(media_list, Playlist | UserPlaylist | Mix):
|
||||
logger_gui.error("Please select a playlist or mix.")
|
||||
return
|
||||
|
||||
# Get all items from the playlist
|
||||
logger_gui.info(f"Fetching all tracks from: {media_list.name}")
|
||||
media_items = items_results_all(self.tidal.session, media_list)
|
||||
|
||||
# Extract unique album IDs from tracks
|
||||
album_ids = self._extract_album_ids_from_tracks(media_items)
|
||||
|
||||
if not album_ids:
|
||||
logger_gui.warning("No albums found in this playlist.")
|
||||
return
|
||||
|
||||
logger_gui.info(f"Found {len(album_ids)} unique albums. Loading with rate limiting...")
|
||||
|
||||
# Load albums with rate limiting
|
||||
albums_dict = self._load_albums_with_rate_limiting(album_ids)
|
||||
|
||||
if not albums_dict:
|
||||
logger_gui.error("Failed to load any albums from playlist.")
|
||||
return
|
||||
|
||||
# Prepare and queue albums
|
||||
self._queue_loaded_albums(albums_dict)
|
||||
|
||||
# Show confirmation
|
||||
message = f"Added {len(albums_dict)} albums to download queue"
|
||||
self.s_statusbar_message.emit(StatusbarMessage(message=message, timeout=3000))
|
||||
logger_gui.info(message)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error downloading albums from playlist: {e!s}"
|
||||
logger_gui.error(error_msg)
|
||||
self.s_statusbar_message.emit(StatusbarMessage(message=error_msg, timeout=3000))
|
||||
|
||||
def _extract_album_ids_from_tracks(self, media_items: list) -> dict[int, Album]:
|
||||
"""Extract unique album IDs from a list of media items.
|
||||
|
||||
@@ -791,6 +1157,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
item.date_user_added if item.date_user_added != "" else item.date_release
|
||||
)
|
||||
|
||||
# Check download history
|
||||
child_downloaded: QtGui.QStandardItem = QtGui.QStandardItem()
|
||||
if isinstance(item.obj, Track):
|
||||
track_id = str(item.obj.id)
|
||||
if self.history_service.is_downloaded(track_id):
|
||||
child_downloaded.setText("✅")
|
||||
child_downloaded.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
if isinstance(item.obj, Mix | Playlist | Album | Artist):
|
||||
# Add a disabled dummy child, so expansion arrow will appear. This Child will be replaced on expansion.
|
||||
child_dummy: QtGui.QStandardItem = QtGui.QStandardItem()
|
||||
@@ -807,6 +1181,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
child_duration,
|
||||
child_quality,
|
||||
child_date,
|
||||
child_downloaded,
|
||||
)
|
||||
|
||||
def on_tr_results_add_top_level_item(self, item_child: Sequence[QtGui.QStandardItem]):
|
||||
@@ -1050,10 +1425,230 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
media: Track | Album | Playlist | Video | Artist = get_results_media_item(
|
||||
item, self.proxy_tr_results, self.model_tr_results
|
||||
)
|
||||
queue_dl_item = self.queue_manager.media_to_queue_download_model(media)
|
||||
queue_dl_item: QueueDownloadItem = self.search_manager.media_to_queue_download_model(media)
|
||||
|
||||
if queue_dl_item:
|
||||
self.queue_manager.queue_download_media(queue_dl_item)
|
||||
self.queue_download_media(queue_dl_item)
|
||||
|
||||
def queue_download_media(self, queue_dl_item: QueueDownloadItem) -> None:
|
||||
"""Add a media item to the download queue.
|
||||
|
||||
Args:
|
||||
queue_dl_item (QueueDownloadItem): The item to add to the queue.
|
||||
"""
|
||||
# Populate child
|
||||
child: QtWidgets.QTreeWidgetItem = QtWidgets.QTreeWidgetItem()
|
||||
|
||||
child.setText(0, queue_dl_item.status)
|
||||
set_queue_download_media(child, queue_dl_item.obj)
|
||||
child.setText(2, queue_dl_item.name)
|
||||
child.setText(3, queue_dl_item.type_media)
|
||||
child.setText(4, queue_dl_item.quality_audio)
|
||||
child.setText(5, queue_dl_item.quality_video)
|
||||
self.tr_queue_download.addTopLevelItem(child)
|
||||
|
||||
def watcher_queue_download(self) -> None:
|
||||
"""Monitor the download queue and process items as they become available."""
|
||||
handling_app: HandlingApp = HandlingApp()
|
||||
|
||||
while not handling_app.event_abort.is_set():
|
||||
items: list[QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems(
|
||||
QueueDownloadStatus.Waiting, QtCore.Qt.MatchFlag.MatchExactly, column=0
|
||||
)
|
||||
|
||||
if len(items) > 0:
|
||||
result: QueueDownloadStatus
|
||||
item: QtWidgets.QTreeWidgetItem = items[0]
|
||||
media: Track | Album | Playlist | Video | Mix | Artist = get_queue_download_media(item)
|
||||
quality_audio: Quality = get_queue_download_quality_audio(item)
|
||||
quality_video: QualityVideo = get_queue_download_quality_video(item)
|
||||
|
||||
try:
|
||||
self.s_queue_download_item_downloading.emit(item)
|
||||
result = self.on_queue_download(media, quality_audio=quality_audio, quality_video=quality_video)
|
||||
|
||||
if result == QueueDownloadStatus.Finished:
|
||||
self.s_queue_download_item_finished.emit(item)
|
||||
elif result == QueueDownloadStatus.Skipped:
|
||||
self.s_queue_download_item_skipped.emit(item)
|
||||
except Exception as e:
|
||||
logger_gui.error(e)
|
||||
self.s_queue_download_item_failed.emit(item)
|
||||
else:
|
||||
time.sleep(2)
|
||||
|
||||
def on_queue_download_item_downloading(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Update the status of a queue download item to 'Downloading'.
|
||||
|
||||
Args:
|
||||
item (QtWidgets.QTreeWidgetItem): The item to update.
|
||||
"""
|
||||
self.queue_download_item_status(item, QueueDownloadStatus.Downloading)
|
||||
|
||||
def on_queue_download_item_finished(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Update the status of a queue download item to 'Finished'.
|
||||
|
||||
Args:
|
||||
item (QtWidgets.QTreeWidgetItem): The item to update.
|
||||
"""
|
||||
self.queue_download_item_status(item, QueueDownloadStatus.Finished)
|
||||
|
||||
def on_queue_download_item_failed(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Update the status of a queue download item to 'Failed'.
|
||||
|
||||
Args:
|
||||
item (QtWidgets.QTreeWidgetItem): The item to update.
|
||||
"""
|
||||
self.queue_download_item_status(item, QueueDownloadStatus.Failed)
|
||||
|
||||
def on_queue_download_item_skipped(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Update the status of a queue download item to 'Skipped'.
|
||||
|
||||
Args:
|
||||
item (QtWidgets.QTreeWidgetItem): The item to update.
|
||||
"""
|
||||
self.queue_download_item_status(item, QueueDownloadStatus.Skipped)
|
||||
|
||||
def queue_download_item_status(self, item: QtWidgets.QTreeWidgetItem, status: str) -> None:
|
||||
"""Set the status text of a queue download item.
|
||||
|
||||
Args:
|
||||
item (QtWidgets.QTreeWidgetItem): The item to update.
|
||||
status (str): The status text.
|
||||
"""
|
||||
item.setText(0, status)
|
||||
|
||||
def on_queue_download(
|
||||
self,
|
||||
media: Track | Album | Playlist | Video | Mix | Artist,
|
||||
quality_audio: Quality | None = None,
|
||||
quality_video: QualityVideo | None = None,
|
||||
) -> QueueDownloadStatus:
|
||||
"""Download the specified media item(s) and return the result status.
|
||||
|
||||
Args:
|
||||
media (Track | Album | Playlist | Video | Mix | Artist): The media item(s) to download.
|
||||
quality_audio (Quality | None, optional): Desired audio quality. Defaults to None.
|
||||
quality_video (QualityVideo | None, optional): Desired video quality. Defaults to None.
|
||||
|
||||
Returns:
|
||||
QueueDownloadStatus: The status of the download operation.
|
||||
"""
|
||||
result: QueueDownloadStatus
|
||||
items_media: [Track | Album | Playlist | Video | Mix | Artist]
|
||||
|
||||
if isinstance(media, Artist):
|
||||
items_media: [Album] = items_results_all(self.tidal.session, media)
|
||||
else:
|
||||
items_media = [media]
|
||||
|
||||
download_delay: bool = bool(isinstance(media, Track | Video) and self.settings.data.download_delay)
|
||||
|
||||
for item_media in items_media:
|
||||
result = self.download(
|
||||
item_media,
|
||||
self.dl,
|
||||
delay_track=download_delay,
|
||||
quality_audio=quality_audio,
|
||||
quality_video=quality_video,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def download(
|
||||
self,
|
||||
media: Track | Album | Playlist | Video | Mix | Artist,
|
||||
dl: Download,
|
||||
delay_track: bool = False,
|
||||
quality_audio: Quality | None = None,
|
||||
quality_video: QualityVideo | None = None,
|
||||
) -> QueueDownloadStatus:
|
||||
"""Download a media item and return the result status.
|
||||
|
||||
Args:
|
||||
media (Track | Album | Playlist | Video | Mix | Artist): The media item to download.
|
||||
dl (Download): The Download object to use.
|
||||
delay_track (bool, optional): Whether to apply download delay. Defaults to False.
|
||||
quality_audio (Quality | None, optional): Desired audio quality. Defaults to None.
|
||||
quality_video (QualityVideo | None, optional): Desired video quality. Defaults to None.
|
||||
|
||||
Returns:
|
||||
QueueDownloadStatus: The status of the download operation.
|
||||
"""
|
||||
result_dl: bool
|
||||
path_file: str
|
||||
result: QueueDownloadStatus
|
||||
self.s_pb_reset.emit()
|
||||
self.s_statusbar_message.emit(StatusbarMessage(message="Download started..."))
|
||||
|
||||
file_template = get_format_template(media, self.settings)
|
||||
|
||||
# Determine source information
|
||||
source_type = "manual"
|
||||
source_id = None
|
||||
source_name = None
|
||||
|
||||
if isinstance(media, Album):
|
||||
source_type = "album"
|
||||
source_id = str(media.id)
|
||||
source_name = media.name
|
||||
elif isinstance(media, Playlist | UserPlaylist):
|
||||
source_type = "playlist"
|
||||
source_id = str(media.id) if hasattr(media, "id") else None
|
||||
source_name = media.name if hasattr(media, "name") else None
|
||||
elif isinstance(media, Mix):
|
||||
source_type = "mix"
|
||||
source_id = str(media.id)
|
||||
source_name = media.title
|
||||
elif isinstance(media, Track):
|
||||
# For individual tracks, try to get album info
|
||||
if hasattr(media, "album") and media.album:
|
||||
source_type = "album"
|
||||
source_id = str(media.album.id)
|
||||
source_name = media.album.name
|
||||
else:
|
||||
source_type = "track"
|
||||
source_id = str(media.id)
|
||||
source_name = media.name
|
||||
|
||||
if isinstance(media, Track | Video):
|
||||
result_dl, path_file = dl.item(
|
||||
media=media,
|
||||
file_template=file_template,
|
||||
download_delay=delay_track,
|
||||
quality_audio=quality_audio,
|
||||
quality_video=quality_video,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
source_name=source_name,
|
||||
)
|
||||
elif isinstance(media, Album | Playlist | Mix):
|
||||
dl.items(
|
||||
media=media,
|
||||
file_template=file_template,
|
||||
video_download=self.settings.data.video_download,
|
||||
download_delay=self.settings.data.download_delay,
|
||||
quality_audio=quality_audio,
|
||||
quality_video=quality_video,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
source_name=source_name,
|
||||
)
|
||||
|
||||
# Dummy values
|
||||
result_dl = True
|
||||
path_file = "dummy"
|
||||
|
||||
self.s_statusbar_message.emit(StatusbarMessage(message="Download finished.", timeout=2000))
|
||||
|
||||
if result_dl and path_file:
|
||||
result = QueueDownloadStatus.Finished
|
||||
elif not result_dl and path_file:
|
||||
result = QueueDownloadStatus.Skipped
|
||||
else:
|
||||
result = QueueDownloadStatus.Failed
|
||||
|
||||
return result
|
||||
|
||||
def on_version(
|
||||
self, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest | None = None
|
||||
@@ -1071,6 +1666,21 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
"""Open the preferences dialog."""
|
||||
DialogPreferences(settings=self.settings, settings_save=self.s_settings_save, parent=self)
|
||||
|
||||
def on_view_history(self) -> None:
|
||||
"""Open the download history dialog."""
|
||||
DialogHistory(history_service=self.history_service, parent=self)
|
||||
|
||||
def on_toggle_duplicate_prevention(self, enabled: bool) -> None:
|
||||
"""Toggle duplicate download prevention on or off.
|
||||
|
||||
Args:
|
||||
enabled (bool): Whether duplicate prevention is enabled.
|
||||
"""
|
||||
self.history_service.update_settings(preventDuplicates=enabled)
|
||||
status_msg = "enabled" if enabled else "disabled"
|
||||
logger_gui.info(f"Duplicate download prevention {status_msg}")
|
||||
self.s_statusbar_message.emit(StatusbarMessage(message=f"Duplicate prevention {status_msg}.", timeout=2500))
|
||||
|
||||
def on_tr_results_expanded(self, index: QtCore.QModelIndex) -> None:
|
||||
"""Handle the event when a result item group is expanded.
|
||||
|
||||
@@ -1165,11 +1775,13 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
full_album_object = self.tidal.session.album(media_track.album.id)
|
||||
|
||||
# Convert the full album object into a queue item
|
||||
queue_dl_item = self.queue_manager.media_to_queue_download_model(full_album_object)
|
||||
queue_dl_item: QueueDownloadItem | None = self.search_manager.media_to_queue_download_model(
|
||||
full_album_object
|
||||
)
|
||||
|
||||
if queue_dl_item:
|
||||
# Add the item to the download queue
|
||||
self.queue_manager.queue_download_media(queue_dl_item)
|
||||
self.queue_download_media(queue_dl_item)
|
||||
else:
|
||||
logger_gui.warning(f"Failed to create a queue item for album ID: {full_album_object.id}")
|
||||
except Exception as e:
|
||||
|
||||
+442
-129
@@ -1,9 +1,9 @@
|
||||
# tidal_dl_ng/gui_playlist.py
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from PySide6 import QtCore, QtWidgets
|
||||
from tidalapi import Album, Mix, Playlist, UserPlaylist
|
||||
from tidalapi import Album, Artist, Mix, Playlist, Track, UserPlaylist
|
||||
from tidalapi.playlist import Folder
|
||||
|
||||
from tidal_dl_ng.constants import FAVORITES, TidalLists
|
||||
@@ -14,7 +14,7 @@ from tidal_dl_ng.helper.tidal import (
|
||||
user_media_lists,
|
||||
)
|
||||
from tidal_dl_ng.logger import logger_gui
|
||||
from tidal_dl_ng.model.gui_data import StatusbarMessage
|
||||
from tidal_dl_ng.model.gui_data import QueueDownloadItem, ResultItem, StatusbarMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tidal_dl_ng.gui import MainWindow
|
||||
@@ -100,14 +100,27 @@ class GuiPlaylistManager:
|
||||
self.main_window.s_pb_reload_status.emit(True)
|
||||
|
||||
def menu_context_tree_lists(self, point: QtCore.QPoint) -> None:
|
||||
"""Show context menu for user lists tree."""
|
||||
"""Show context menu for user lists tree.
|
||||
|
||||
Args:
|
||||
point (QPoint): The point where the menu is requested.
|
||||
"""
|
||||
# Infos about the node selected.
|
||||
index = self.main_window.tr_lists_user.indexAt(point)
|
||||
|
||||
# Do not open menu if something went wrong or a parent node is clicked.
|
||||
if not index.isValid() or not index.parent().data():
|
||||
return
|
||||
|
||||
# Get the media item to determine type
|
||||
item = self.main_window.tr_lists_user.itemAt(point)
|
||||
media = get_user_list_media_item(item)
|
||||
|
||||
# We build the menu.
|
||||
menu = QtWidgets.QMenu()
|
||||
|
||||
if isinstance(media, Folder):
|
||||
# Folder-specific menu items
|
||||
menu.addAction(
|
||||
"Download All Playlists in Folder",
|
||||
lambda: self.main_window.thread_it(self.on_download_folder_playlists, point),
|
||||
@@ -117,13 +130,15 @@ class GuiPlaylistManager:
|
||||
lambda: self.main_window.thread_it(self.on_download_folder_albums, point),
|
||||
)
|
||||
elif isinstance(media, str):
|
||||
# Favorites items (stored as string keys like "fav_tracks", "fav_albums")
|
||||
menu.addAction("Download All Items", lambda: self.main_window.thread_it(self.on_download_favorites, point))
|
||||
menu.addAction(
|
||||
"Download All Albums from Items",
|
||||
lambda: self.main_window.thread_it(self.on_download_albums_from_favorites, point),
|
||||
)
|
||||
else:
|
||||
menu.addAction("Download Playlist", lambda: self.main_window.thread_it(self.on_download_list_media, point))
|
||||
# Playlist/Mix menu items (existing)
|
||||
menu.addAction("Download Playlist", lambda: self.main_window.thread_download_list_media(point))
|
||||
menu.addAction(
|
||||
"Download All Albums in Playlist",
|
||||
lambda: self.main_window.thread_it(self.on_download_all_albums_from_playlist, point),
|
||||
@@ -131,89 +146,189 @@ class GuiPlaylistManager:
|
||||
menu.addAction(
|
||||
"Copy Share URL", lambda: self.main_window.on_copy_url_share(self.main_window.tr_lists_user, point)
|
||||
)
|
||||
menu.exec_(self.main_window.tr_lists_user.mapToGlobal(point))
|
||||
|
||||
menu.exec(self.main_window.tr_lists_user.mapToGlobal(point))
|
||||
|
||||
def on_download_list_media(self, point: QtCore.QPoint | None = None) -> None:
|
||||
"""Download all media items in a selected list."""
|
||||
items = (
|
||||
[self.main_window.tr_lists_user.itemAt(point)] if point else self.main_window.tr_lists_user.selectedItems()
|
||||
)
|
||||
if not items:
|
||||
logger_gui.error("Please select a mix or playlist first.")
|
||||
return
|
||||
"""Download all media items in a selected list.
|
||||
|
||||
Args:
|
||||
point (QPoint | None, optional): The point in the tree. Defaults to None.
|
||||
"""
|
||||
items: list[QtWidgets.QTreeWidgetItem] = []
|
||||
|
||||
if point:
|
||||
items = [self.main_window.tr_lists_user.itemAt(point)]
|
||||
else:
|
||||
items = self.main_window.tr_lists_user.selectedItems()
|
||||
|
||||
if len(items) == 0:
|
||||
logger_gui.error("Please select a mix or playlist first.")
|
||||
|
||||
for item in items:
|
||||
media = get_user_list_media_item(item)
|
||||
queue_dl_item = self.main_window.queue_manager.media_to_queue_download_model(media)
|
||||
queue_dl_item: QueueDownloadItem | None = self.main_window.search_manager.media_to_queue_download_model(
|
||||
media
|
||||
)
|
||||
|
||||
if queue_dl_item:
|
||||
self.main_window.queue_manager.queue_download_media(queue_dl_item)
|
||||
self.main_window.queue_download_media(queue_dl_item)
|
||||
|
||||
def on_download_folder_playlists(self, point: QtCore.QPoint) -> None:
|
||||
"""Download all playlists in a folder."""
|
||||
item = self.main_window.tr_lists_user.itemAt(point)
|
||||
media = get_user_list_media_item(item)
|
||||
if not isinstance(media, Folder):
|
||||
logger_gui.error("Please select a folder.")
|
||||
return
|
||||
logger_gui.info(f"Fetching playlists from folder: {media.name}")
|
||||
playlists = self._get_folder_playlists(media)
|
||||
if not playlists:
|
||||
logger_gui.info(f"No playlists found in folder: {media.name}")
|
||||
return
|
||||
logger_gui.info(f"Queueing {len(playlists)} playlists from folder: {media.name}")
|
||||
for playlist in playlists:
|
||||
queue_dl_item = self.main_window.queue_manager.media_to_queue_download_model(playlist)
|
||||
if queue_dl_item:
|
||||
self.main_window.queue_manager.queue_download_media(queue_dl_item)
|
||||
logger_gui.info(f"✅ Successfully queued {len(playlists)} playlists from folder: {media.name}")
|
||||
"""Download all playlists in a folder.
|
||||
|
||||
Args:
|
||||
point (QPoint): The point in the tree where the folder was right-clicked.
|
||||
"""
|
||||
try:
|
||||
# Get and validate the folder
|
||||
item = self.main_window.tr_lists_user.itemAt(point)
|
||||
media = get_user_list_media_item(item)
|
||||
|
||||
if not isinstance(media, Folder):
|
||||
logger_gui.error("Please select a folder.")
|
||||
return
|
||||
|
||||
# Fetch all playlists in the folder
|
||||
logger_gui.info(f"Fetching playlists from folder: {media.name}")
|
||||
playlists = self._get_folder_playlists(media)
|
||||
|
||||
if not playlists:
|
||||
logger_gui.info(f"No playlists found in folder: {media.name}")
|
||||
return
|
||||
|
||||
# Queue each playlist for download
|
||||
logger_gui.info(f"Queueing {len(playlists)} playlists from folder: {media.name}")
|
||||
|
||||
for playlist in playlists:
|
||||
queue_dl_item: QueueDownloadItem | None = self.main_window.search_manager.media_to_queue_download_model(
|
||||
playlist
|
||||
)
|
||||
|
||||
if queue_dl_item:
|
||||
self.main_window.queue_download_media(queue_dl_item)
|
||||
|
||||
logger_gui.info(f"✅ Successfully queued {len(playlists)} playlists from folder: {media.name}")
|
||||
|
||||
except Exception as e:
|
||||
logger_gui.exception(f"Error downloading playlists from folder: {e}")
|
||||
logger_gui.error("Failed to download playlists from folder. See log for details.")
|
||||
|
||||
def on_download_folder_albums(self, point: QtCore.QPoint) -> None:
|
||||
"""Download all unique albums from all playlists in a folder."""
|
||||
item = self.main_window.tr_lists_user.itemAt(point)
|
||||
media = get_user_list_media_item(item)
|
||||
if not isinstance(media, Folder):
|
||||
logger_gui.error("Please select a folder.")
|
||||
return
|
||||
playlists = self._get_folder_playlists(media)
|
||||
if not playlists:
|
||||
return
|
||||
all_tracks = []
|
||||
for playlist in playlists:
|
||||
try:
|
||||
tracks = self._get_playlist_tracks(playlist)
|
||||
all_tracks.extend(tracks)
|
||||
except Exception as e:
|
||||
logger_gui.error(f"Error getting tracks from playlist '{playlist.name}': {e}")
|
||||
if not all_tracks:
|
||||
return
|
||||
album_ids = self.main_window._extract_album_ids_from_tracks(all_tracks)
|
||||
if not album_ids:
|
||||
return
|
||||
albums_dict = self.main_window._load_albums_with_rate_limiting(album_ids)
|
||||
if not albums_dict:
|
||||
return
|
||||
self.main_window._queue_loaded_albums(albums_dict)
|
||||
"""Download all unique albums from all playlists in a folder.
|
||||
|
||||
Args:
|
||||
point (QPoint): The point in the tree where the folder was right-clicked.
|
||||
"""
|
||||
try:
|
||||
# Get and validate the folder
|
||||
item = self.main_window.tr_lists_user.itemAt(point)
|
||||
media = get_user_list_media_item(item)
|
||||
|
||||
if not isinstance(media, Folder):
|
||||
logger_gui.error("Please select a folder.")
|
||||
return
|
||||
|
||||
# Fetch all playlists in the folder
|
||||
logger_gui.info(f"Fetching playlists from folder: {media.name}")
|
||||
playlists = self._get_folder_playlists(media)
|
||||
|
||||
if not playlists:
|
||||
logger_gui.info(f"No playlists found in folder: {media.name}")
|
||||
return
|
||||
|
||||
logger_gui.info(f"Found {len(playlists)} playlists in folder: {media.name}")
|
||||
|
||||
# Collect all tracks from all playlists
|
||||
all_tracks: list[Track] = []
|
||||
|
||||
for playlist in playlists:
|
||||
try:
|
||||
tracks = self._get_playlist_tracks(playlist)
|
||||
all_tracks.extend(tracks)
|
||||
logger_gui.debug(f"Collected {len(tracks)} tracks from playlist: {playlist.name}")
|
||||
except Exception as e:
|
||||
logger_gui.error(f"Error getting tracks from playlist '{playlist.name}': {e}")
|
||||
continue
|
||||
|
||||
if not all_tracks:
|
||||
logger_gui.info(f"No tracks found in folder playlists: {media.name}")
|
||||
return
|
||||
|
||||
logger_gui.info(f"Collected {len(all_tracks)} total tracks from all playlists")
|
||||
|
||||
# Extract unique album IDs
|
||||
album_ids = self.main_window._extract_album_ids_from_tracks(all_tracks)
|
||||
logger_gui.info(f"Found {len(album_ids)} unique albums across all playlists in folder: {media.name}")
|
||||
|
||||
if not album_ids:
|
||||
logger_gui.info("No albums found to download.")
|
||||
return
|
||||
|
||||
# Load full album objects with rate limiting
|
||||
albums_dict = self.main_window._load_albums_with_rate_limiting(album_ids)
|
||||
|
||||
if not albums_dict:
|
||||
logger_gui.error("Failed to load any albums.")
|
||||
return
|
||||
|
||||
# Queue the albums for download
|
||||
self.main_window._queue_loaded_albums(albums_dict)
|
||||
|
||||
logger_gui.info(f"✅ Successfully queued {len(albums_dict)} unique albums from folder: {media.name}")
|
||||
|
||||
except Exception as e:
|
||||
logger_gui.exception(f"Error downloading albums from folder: {e}")
|
||||
logger_gui.error("Failed to download albums from folder. See log for details.")
|
||||
|
||||
def on_download_favorites(self, point: QtCore.QPoint) -> None:
|
||||
"""Download all items from a Favorites category."""
|
||||
item = self.main_window.tr_lists_user.itemAt(point)
|
||||
media_key = get_user_list_media_item(item)
|
||||
if not isinstance(media_key, str):
|
||||
logger_gui.error("Please select a favorites category.")
|
||||
return
|
||||
favorite_name = FAVORITES.get(media_key, {}).get("name", media_key)
|
||||
logger_gui.info(f"Fetching all items from favorites: {favorite_name}")
|
||||
favorite_function = favorite_function_factory(self.main_window.tidal, media_key)
|
||||
media_items = favorite_function()
|
||||
if not media_items:
|
||||
logger_gui.info(f"No items found in favorites: {favorite_name}")
|
||||
return
|
||||
queued_count = 0
|
||||
for media_item in media_items:
|
||||
queue_dl_item = self.main_window.queue_manager.media_to_queue_download_model(media_item)
|
||||
if queue_dl_item:
|
||||
self.main_window.queue_manager.queue_download_media(queue_dl_item)
|
||||
queued_count += 1
|
||||
logger_gui.info(f"✅ Successfully queued {queued_count} items from favorites: {favorite_name}")
|
||||
"""Download all items from a Favorites category.
|
||||
|
||||
Args:
|
||||
point (QPoint): The point in the tree where the favorites item was right-clicked.
|
||||
"""
|
||||
try:
|
||||
# Get and validate the favorites item
|
||||
item = self.main_window.tr_lists_user.itemAt(point)
|
||||
media = get_user_list_media_item(item)
|
||||
|
||||
if not isinstance(media, str):
|
||||
logger_gui.error("Please select a favorites category.")
|
||||
return
|
||||
|
||||
# Get the favorites category name for logging
|
||||
favorite_name = FAVORITES.get(media, {}).get("name", media)
|
||||
logger_gui.info(f"Fetching all items from favorites: {favorite_name}")
|
||||
|
||||
# Use the factory to get the appropriate favorites function
|
||||
favorite_function = favorite_function_factory(self.main_window.tidal, media)
|
||||
|
||||
# Fetch all items from this favorites category
|
||||
media_items = favorite_function()
|
||||
|
||||
if not media_items:
|
||||
logger_gui.info(f"No items found in favorites: {favorite_name}")
|
||||
return
|
||||
|
||||
logger_gui.info(f"Found {len(media_items)} items in favorites: {favorite_name}")
|
||||
|
||||
# Queue each item for download
|
||||
queued_count = 0
|
||||
|
||||
for media_item in media_items:
|
||||
queue_dl_item: QueueDownloadItem | None = self.main_window.search_manager.media_to_queue_download_model(
|
||||
media_item
|
||||
)
|
||||
|
||||
if queue_dl_item:
|
||||
self.main_window.queue_download_media(queue_dl_item)
|
||||
queued_count += 1
|
||||
|
||||
logger_gui.info(f"✅ Successfully queued {queued_count} items from favorites: {favorite_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger_gui.exception(f"Error downloading favorites: {e}")
|
||||
logger_gui.error("Failed to download favorites. See log for details.")
|
||||
|
||||
def _download_albums_from_fav_artists(self, media_items: list) -> None:
|
||||
"""Download all albums from favorite artists."""
|
||||
@@ -238,23 +353,47 @@ class GuiPlaylistManager:
|
||||
self.main_window._queue_loaded_albums(albums_dict)
|
||||
|
||||
def on_download_albums_from_favorites(self, point: QtCore.QPoint) -> None:
|
||||
"""Download all unique albums from items in a Favorites category."""
|
||||
item = self.main_window.tr_lists_user.itemAt(point)
|
||||
media_key = get_user_list_media_item(item)
|
||||
if not isinstance(media_key, str):
|
||||
return
|
||||
favorite_function = favorite_function_factory(self.main_window.tidal, media_key)
|
||||
media_items = favorite_function()
|
||||
if not media_items:
|
||||
return
|
||||
"""Download all unique albums from items in a Favorites category.
|
||||
|
||||
if media_key == "fav_albums":
|
||||
albums_dict = {album.id: album for album in media_items if isinstance(album, Album) and album.id}
|
||||
self.main_window._queue_loaded_albums(albums_dict)
|
||||
elif media_key == "fav_artists":
|
||||
self._download_albums_from_fav_artists(media_items)
|
||||
else:
|
||||
self._download_albums_from_fav_tracks(media_items)
|
||||
Args:
|
||||
point (QPoint): The point in the tree where the favorites item was right-clicked.
|
||||
"""
|
||||
try:
|
||||
# Get and validate the favorites item
|
||||
item = self.main_window.tr_lists_user.itemAt(point)
|
||||
media = get_user_list_media_item(item)
|
||||
|
||||
if not isinstance(media, str):
|
||||
logger_gui.error("Please select a favorites category.")
|
||||
return
|
||||
|
||||
# Get the favorites category name for logging
|
||||
favorite_name = FAVORITES.get(media, {}).get("name", media)
|
||||
logger_gui.info(f"Fetching all items from favorites: {favorite_name}")
|
||||
|
||||
# Use the factory to get the appropriate favorites function
|
||||
favorite_function = favorite_function_factory(self.main_window.tidal, media)
|
||||
|
||||
# Fetch all items from this favorites category
|
||||
media_items = favorite_function()
|
||||
|
||||
if not media_items:
|
||||
logger_gui.info(f"No items found in favorites: {favorite_name}")
|
||||
return
|
||||
|
||||
logger_gui.info(f"Found {len(media_items)} items in favorites: {favorite_name}")
|
||||
|
||||
# Delegate to appropriate handler based on favorites type
|
||||
if media == "fav_albums":
|
||||
self._download_albums_from_favorites_albums(media_items, favorite_name)
|
||||
elif media == "fav_artists":
|
||||
self._download_albums_from_favorites_artists(media_items, favorite_name)
|
||||
else:
|
||||
self._download_albums_from_favorites_tracks(media_items, favorite_name)
|
||||
|
||||
except Exception as e:
|
||||
logger_gui.exception(f"Error downloading albums from favorites: {e}")
|
||||
logger_gui.error("Failed to download albums from favorites. See log for details.")
|
||||
|
||||
def on_download_all_albums_from_playlist(self, point: QtCore.QPoint) -> None:
|
||||
"""Download all unique albums from tracks in a playlist."""
|
||||
@@ -277,38 +416,70 @@ class GuiPlaylistManager:
|
||||
self.main_window.s_statusbar_message.emit(StatusbarMessage(message=message, timeout=3000))
|
||||
|
||||
def on_tr_lists_user_expanded(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Handle expansion of folders in the user lists tree."""
|
||||
"""Handle expansion of folders in the user lists tree.
|
||||
|
||||
Args:
|
||||
item (QTreeWidgetItem): The expanded tree item.
|
||||
"""
|
||||
# Check if it's a first-time expansion (has disabled dummy child)
|
||||
if item.childCount() > 0 and item.child(0).isDisabled():
|
||||
# Run in thread to avoid blocking UI
|
||||
self.main_window.thread_it(self.tr_lists_user_load_folder_children, item)
|
||||
|
||||
def tr_lists_user_load_folder_children(self, parent_item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Load and display children of a folder in the user lists tree."""
|
||||
folder = get_user_list_media_item(parent_item)
|
||||
"""Load and display children of a folder in the user lists tree.
|
||||
|
||||
Args:
|
||||
parent_item (QTreeWidgetItem): The parent folder item.
|
||||
"""
|
||||
folder: Folder | None = get_user_list_media_item(parent_item)
|
||||
|
||||
if not isinstance(folder, Folder):
|
||||
return
|
||||
|
||||
# Show spinner while loading
|
||||
self.main_window.s_spinner_start.emit(self.main_window.tr_lists_user)
|
||||
|
||||
try:
|
||||
# Fetch folder contents
|
||||
folders, playlists = self._fetch_folder_contents(folder)
|
||||
|
||||
# Emit signal to populate in main thread
|
||||
self.main_window.s_populate_folder_children.emit(parent_item, folders, playlists)
|
||||
|
||||
finally:
|
||||
self.main_window.s_spinner_stop.emit()
|
||||
|
||||
def on_populate_folder_children(
|
||||
self, parent_item: QtWidgets.QTreeWidgetItem, folders: list[Folder], playlists: list[Playlist]
|
||||
) -> None:
|
||||
"""Populate folder children in the main thread."""
|
||||
"""Populate folder children in the main thread (signal handler).
|
||||
|
||||
Args:
|
||||
parent_item (QTreeWidgetItem): The parent folder item.
|
||||
folders (list[Folder]): List of sub-folders.
|
||||
playlists (list[Playlist]): List of playlists.
|
||||
"""
|
||||
# Remove dummy child
|
||||
parent_item.takeChild(0)
|
||||
|
||||
# Add sub-folders as children
|
||||
for sub_folder in folders:
|
||||
twi_child = QtWidgets.QTreeWidgetItem(parent_item)
|
||||
twi_child.setText(0, f"📁 {sub_folder.name}")
|
||||
set_user_list_media(twi_child, sub_folder)
|
||||
info = f"({sub_folder.total_number_of_items} items)" if sub_folder.total_number_of_items else ""
|
||||
twi_child.setText(2, info)
|
||||
|
||||
# Add dummy child for potential sub-folders
|
||||
dummy = QtWidgets.QTreeWidgetItem(twi_child)
|
||||
dummy.setDisabled(True)
|
||||
|
||||
# Add playlists as children
|
||||
for playlist in playlists:
|
||||
twi_child = QtWidgets.QTreeWidgetItem(parent_item)
|
||||
twi_child.setText(0, playlist.name or "")
|
||||
name = playlist.name if playlist.name else ""
|
||||
twi_child.setText(0, name)
|
||||
set_user_list_media(twi_child, playlist)
|
||||
info = f"({playlist.num_tracks + playlist.num_videos} Tracks)"
|
||||
if playlist.description:
|
||||
@@ -316,10 +487,21 @@ class GuiPlaylistManager:
|
||||
twi_child.setText(2, info)
|
||||
|
||||
def _fetch_folder_contents(self, folder: Folder) -> tuple[list[Folder], list[Playlist]]:
|
||||
"""Fetch contents of a folder."""
|
||||
folder_id = folder.id or "root"
|
||||
folders, playlists = [], []
|
||||
offset, limit = 0, 50
|
||||
"""Fetch contents (sub-folders and playlists) of a folder.
|
||||
|
||||
Args:
|
||||
folder (Folder): The folder to fetch contents for.
|
||||
|
||||
Returns:
|
||||
tuple[list[Folder], list[Playlist]]: Sub-folders and playlists within the folder.
|
||||
"""
|
||||
folder_id = folder.id if folder.id else "root"
|
||||
|
||||
# Fetch sub-folders with manual pagination
|
||||
offset = 0
|
||||
limit = 50
|
||||
folders = []
|
||||
|
||||
while True:
|
||||
batch = self.main_window.tidal.session.user.favorites.playlist_folders(
|
||||
limit=limit, offset=offset, parent_folder_id=folder_id
|
||||
@@ -330,7 +512,11 @@ class GuiPlaylistManager:
|
||||
if len(batch) < limit:
|
||||
break
|
||||
offset += limit
|
||||
|
||||
# Fetch playlists in this folder using folder.items() method
|
||||
offset = 0
|
||||
playlists = []
|
||||
|
||||
while True:
|
||||
batch = folder.items(offset=offset, limit=limit)
|
||||
if not batch:
|
||||
@@ -339,46 +525,173 @@ class GuiPlaylistManager:
|
||||
if len(batch) < limit:
|
||||
break
|
||||
offset += limit
|
||||
|
||||
return folders, playlists
|
||||
|
||||
def _get_folder_playlists(self, folder: Folder) -> list[Playlist]:
|
||||
"""Fetch all playlists from a folder."""
|
||||
"""Fetch all playlists from a folder.
|
||||
|
||||
Args:
|
||||
folder (Folder): The folder to fetch playlists from.
|
||||
|
||||
Returns:
|
||||
list[Playlist]: List of playlists in the folder.
|
||||
"""
|
||||
# Use existing method to fetch folder contents
|
||||
# Since folders can't contain folders, we ignore the folders return value
|
||||
_, playlists = self._fetch_folder_contents(folder)
|
||||
|
||||
logger_gui.debug(f"Found {len(playlists)} playlists in folder: {folder.name}")
|
||||
|
||||
return playlists
|
||||
|
||||
def _get_playlist_tracks(self, playlist: Playlist | UserPlaylist | Mix) -> list:
|
||||
"""Fetch all tracks from a playlist."""
|
||||
return [
|
||||
item
|
||||
for item in items_results_all(self.main_window.tidal.session, playlist)
|
||||
if isinstance(item, self.main_window.tidal.session.track)
|
||||
]
|
||||
def _get_playlist_tracks(self, playlist: Playlist | UserPlaylist | Mix) -> list[Track]:
|
||||
"""Fetch all tracks from a playlist.
|
||||
|
||||
Args:
|
||||
playlist (Playlist | UserPlaylist | Mix): The playlist to fetch tracks from.
|
||||
|
||||
Returns:
|
||||
list[Track]: List of tracks in the playlist.
|
||||
"""
|
||||
playlist_name = getattr(playlist, "name", "unknown")
|
||||
logger_gui.debug(f"Fetching tracks from playlist: {playlist_name}")
|
||||
media_items = items_results_all(self.main_window.tidal.session, playlist)
|
||||
|
||||
# Filter for Track objects only (items_results_all may return Videos too)
|
||||
tracks = [item for item in media_items if isinstance(item, Track)]
|
||||
|
||||
logger_gui.debug(f"Found {len(tracks)} tracks in playlist: {playlist_name}")
|
||||
|
||||
return tracks
|
||||
|
||||
def on_list_items_show(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Show the items in the selected playlist or mix."""
|
||||
"""Show the items in the selected playlist or mix.
|
||||
|
||||
Args:
|
||||
item (QtWidgets.QTreeWidgetItem): The selected tree widget item.
|
||||
"""
|
||||
self.main_window.thread_it(self.list_items_show, item)
|
||||
|
||||
def list_items_show(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Fetch and display the items in a playlist, mix, or folder."""
|
||||
media_list = get_user_list_media_item(item)
|
||||
if not media_list:
|
||||
return
|
||||
self.main_window.s_spinner_start.emit(self.main_window.tr_results)
|
||||
try:
|
||||
if isinstance(media_list, Folder):
|
||||
self._show_folder_contents(media_list)
|
||||
elif isinstance(media_list, str) and media_list.startswith("fav_"):
|
||||
function_list = favorite_function_factory(self.main_window.tidal, media_list)
|
||||
self.main_window.list_items_show_result(favorite_function=function_list)
|
||||
else:
|
||||
self.main_window.list_items_show_result(media_list)
|
||||
self.main_window.thread_it(self.main_window.cover_manager.load_cover, media_list)
|
||||
finally:
|
||||
self.main_window.s_spinner_stop.emit()
|
||||
"""Fetch and display the items in a playlist, mix, or folder.
|
||||
|
||||
Args:
|
||||
item (QtWidgets.QTreeWidgetItem): The tree widget item representing a playlist, mix, or folder.
|
||||
"""
|
||||
media_list: Album | Playlist | Folder | str = get_user_list_media_item(item)
|
||||
|
||||
# Only if clicked item is not a top level item.
|
||||
if media_list:
|
||||
# Show spinner while loading list
|
||||
self.main_window.s_spinner_start.emit(self.main_window.tr_results)
|
||||
try:
|
||||
if isinstance(media_list, Folder):
|
||||
# Show folder contents
|
||||
self._show_folder_contents(media_list)
|
||||
elif isinstance(media_list, str) and media_list.startswith("fav_"):
|
||||
function_list = favorite_function_factory(self.main_window.tidal, media_list)
|
||||
self.main_window.list_items_show_result(favorite_function=function_list)
|
||||
else:
|
||||
self.main_window.list_items_show_result(media_list)
|
||||
# Load cover asynchronously to avoid blocking the GUI
|
||||
if hasattr(self.main_window, "cover_manager"):
|
||||
self.main_window.cover_manager.load_cover(media_list)
|
||||
finally:
|
||||
self.main_window.s_spinner_stop.emit()
|
||||
|
||||
def _show_folder_contents(self, folder: Folder) -> None:
|
||||
"""Display folder contents in results pane."""
|
||||
"""Display folder contents (nested playlists/folders) in results pane.
|
||||
|
||||
Args:
|
||||
folder (Folder): The folder to display contents for.
|
||||
"""
|
||||
# Fetch folder contents using the shared helper method
|
||||
folders, playlists = self._fetch_folder_contents(folder)
|
||||
|
||||
# Combine folders and playlists
|
||||
items = folders + playlists
|
||||
|
||||
# Convert to ResultItems and display
|
||||
result = self.main_window.search_manager.search_result_to_model(items)
|
||||
self.main_window.search_manager.populate_tree_results(result)
|
||||
self.main_window.populate_tree_results(result)
|
||||
|
||||
def _download_albums_from_favorites_albums(self, media_items: list, favorite_name: str) -> None:
|
||||
"""Download albums from favorite albums list.
|
||||
|
||||
Args:
|
||||
media_items (list): List of favorite albums.
|
||||
favorite_name (str): Name of the favorites category for logging.
|
||||
"""
|
||||
logger_gui.info(f"Queueing {len(media_items)} albums from favorites: {favorite_name}")
|
||||
albums_dict = {album.id: album for album in media_items if isinstance(album, Album) and album.id}
|
||||
self.main_window._queue_loaded_albums(albums_dict)
|
||||
logger_gui.info(f"✅ Successfully queued {len(albums_dict)} albums from favorites: {favorite_name}")
|
||||
|
||||
def _download_albums_from_favorites_artists(self, media_items: list, favorite_name: str) -> None:
|
||||
"""Download albums from favorite artists list.
|
||||
|
||||
Args:
|
||||
media_items (list): List of favorite artists.
|
||||
favorite_name (str): Name of the favorites category for logging.
|
||||
"""
|
||||
logger_gui.info(f"Fetching albums from {len(media_items)} artists...")
|
||||
all_albums = {}
|
||||
|
||||
for artist in media_items:
|
||||
if isinstance(artist, Artist):
|
||||
try:
|
||||
artist_albums = items_results_all(self.main_window.tidal.session, artist)
|
||||
for album in artist_albums:
|
||||
if isinstance(album, Album) and album.id:
|
||||
all_albums[album.id] = album
|
||||
logger_gui.debug(f"Found {len(artist_albums)} albums from artist: {artist.name}")
|
||||
except Exception as e:
|
||||
logger_gui.error(f"Error getting albums from artist '{artist.name}': {e}")
|
||||
continue
|
||||
|
||||
if not all_albums:
|
||||
logger_gui.info("No albums found from favorite artists.")
|
||||
return
|
||||
|
||||
logger_gui.info(f"Found {len(all_albums)} unique albums from favorite artists")
|
||||
self.main_window._queue_loaded_albums(all_albums)
|
||||
logger_gui.info(f"✅ Successfully queued {len(all_albums)} albums from favorites: {favorite_name}")
|
||||
|
||||
def _download_albums_from_favorites_tracks(self, media_items: list, favorite_name: str) -> None:
|
||||
"""Download albums from favorite tracks/videos/mixes list.
|
||||
|
||||
Args:
|
||||
media_items (list): List of favorite tracks/videos/mixes.
|
||||
favorite_name (str): Name of the favorites category for logging.
|
||||
"""
|
||||
logger_gui.info("Extracting albums from tracks...")
|
||||
album_ids = self.main_window._extract_album_ids_from_tracks(media_items)
|
||||
|
||||
if not album_ids:
|
||||
logger_gui.info(f"No albums found in favorites: {favorite_name}")
|
||||
return
|
||||
|
||||
logger_gui.info(f"Found {len(album_ids)} unique albums. Loading with rate limiting...")
|
||||
|
||||
# Load full album objects with rate limiting
|
||||
albums_dict = self.main_window._load_albums_with_rate_limiting(album_ids)
|
||||
|
||||
if not albums_dict:
|
||||
logger_gui.error("Failed to load any albums from favorites.")
|
||||
return
|
||||
|
||||
# Queue the albums for download
|
||||
self.main_window._queue_loaded_albums(albums_dict)
|
||||
logger_gui.info(f"✅ Successfully queued {len(albums_dict)} unique albums from favorites: {favorite_name}")
|
||||
|
||||
def search_populate_results(self, query: str, type_media: Any) -> None:
|
||||
"""Populate the results tree with search results.
|
||||
|
||||
Args:
|
||||
query (str): The search query.
|
||||
type_media (SearchTypes): The type of media to search for.
|
||||
"""
|
||||
results: list[ResultItem] = self.main_window.search_manager.search(query, [type_media])
|
||||
|
||||
self.main_window.populate_tree_results(results)
|
||||
|
||||
@@ -82,18 +82,23 @@ class GuiQueueManager:
|
||||
def on_queue_download_clear_all(self) -> None:
|
||||
"""Clear all items from the download queue."""
|
||||
self.on_clear_queue_download(
|
||||
f"({QueueDownloadStatus.Waiting}|{QueueDownloadStatus.Finished}|{QueueDownloadStatus.Failed}|{QueueDownloadStatus.Skipped})"
|
||||
f"({QueueDownloadStatus.Waiting}|{QueueDownloadStatus.Finished}|{QueueDownloadStatus.Failed})"
|
||||
)
|
||||
|
||||
def on_queue_download_clear_finished(self) -> None:
|
||||
"""Clear finished items from the download queue."""
|
||||
self.on_clear_queue_download(f"({QueueDownloadStatus.Finished}|{QueueDownloadStatus.Skipped})")
|
||||
self.on_clear_queue_download(f"[{QueueDownloadStatus.Finished}]")
|
||||
|
||||
def on_clear_queue_download(self, regex: str) -> None:
|
||||
"""Clear items from the download queue matching the given regex."""
|
||||
items = self.main_window.tr_queue_download.findItems(
|
||||
"""Clear items from the download queue matching the given regex.
|
||||
|
||||
Args:
|
||||
regex (str): Regular expression to match items.
|
||||
"""
|
||||
items: list[QtWidgets.QTreeWidgetItem | None] = self.main_window.tr_queue_download.findItems(
|
||||
regex, QtCore.Qt.MatchFlag.MatchRegularExpression, column=0
|
||||
)
|
||||
|
||||
for item in items:
|
||||
self.main_window.tr_queue_download.takeTopLevelItem(
|
||||
self.main_window.tr_queue_download.indexOfTopLevelItem(item)
|
||||
@@ -101,12 +106,14 @@ class GuiQueueManager:
|
||||
|
||||
def on_queue_download_remove(self) -> None:
|
||||
"""Remove selected items from the download queue."""
|
||||
items = self.main_window.tr_queue_download.selectedItems()
|
||||
if not items:
|
||||
items: list[QtWidgets.QTreeWidgetItem | None] = self.main_window.tr_queue_download.selectedItems()
|
||||
|
||||
if len(items) == 0:
|
||||
logger_gui.error("Please select an item from the queue first.")
|
||||
else:
|
||||
for item in items:
|
||||
status: str = item.text(0)
|
||||
|
||||
if status != QueueDownloadStatus.Downloading:
|
||||
self.main_window.tr_queue_download.takeTopLevelItem(
|
||||
self.main_window.tr_queue_download.indexOfTopLevelItem(item)
|
||||
@@ -115,8 +122,12 @@ class GuiQueueManager:
|
||||
logger_gui.info("Cannot remove a currently downloading item from queue.")
|
||||
|
||||
def on_pb_queue_download_toggle(self) -> None:
|
||||
"""Toggle download status (pause / resume) accordingly."""
|
||||
"""Toggle download status (pause / resume) accordingly.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
handling_app: HandlingApp = HandlingApp()
|
||||
|
||||
if handling_app.event_run.is_set():
|
||||
self.pb_queue_download_pause()
|
||||
else:
|
||||
@@ -125,7 +136,9 @@ class GuiQueueManager:
|
||||
def pb_queue_download_run(self) -> None:
|
||||
"""Start the download queue and update the button state."""
|
||||
handling_app: HandlingApp = HandlingApp()
|
||||
|
||||
handling_app.event_run.set()
|
||||
|
||||
icon = QtGui.QIcon(QtGui.QIcon.fromTheme(QtGui.QIcon.ThemeIcon.MediaPlaybackPause))
|
||||
self.main_window.pb_queue_download_toggle.setIcon(icon)
|
||||
self.main_window.pb_queue_download_toggle.setStyleSheet("background-color: #e0a800; color: #212529")
|
||||
@@ -133,7 +146,9 @@ class GuiQueueManager:
|
||||
def pb_queue_download_pause(self) -> None:
|
||||
"""Pause the download queue and update the button state."""
|
||||
handling_app: HandlingApp = HandlingApp()
|
||||
|
||||
handling_app.event_run.clear()
|
||||
|
||||
icon = QtGui.QIcon(QtGui.QIcon.fromTheme(QtGui.QIcon.ThemeIcon.MediaPlaybackStart))
|
||||
self.main_window.pb_queue_download_toggle.setIcon(icon)
|
||||
self.main_window.pb_queue_download_toggle.setStyleSheet("background-color: #218838; color: #fff")
|
||||
|
||||
+233
-52
@@ -3,21 +3,24 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from tidalapi import Album, Artist, Mix, Playlist, Track, Video
|
||||
from tidalapi.media import AudioMode
|
||||
from tidalapi import VideoQuality as QualityVideo
|
||||
from tidalapi.media import AudioMode, Quality
|
||||
from tidalapi.playlist import Folder
|
||||
from tidalapi.session import SearchTypes
|
||||
|
||||
from tidal_dl_ng.constants import QueueDownloadStatus
|
||||
from tidal_dl_ng.helper.tidal import (
|
||||
get_tidal_media_id,
|
||||
get_tidal_media_type,
|
||||
instantiate_media,
|
||||
name_builder_artist,
|
||||
name_builder_title,
|
||||
quality_audio_highest,
|
||||
search_results_all,
|
||||
url_ending_clean,
|
||||
)
|
||||
from tidal_dl_ng.logger import logger_gui
|
||||
from tidal_dl_ng.model.gui_data import ResultItem
|
||||
from tidal_dl_ng.model.gui_data import QueueDownloadItem, ResultItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tidal_dl_ng.gui import MainWindow
|
||||
@@ -36,77 +39,142 @@ class GuiSearchManager:
|
||||
self.main_window.populate_tree_results(results)
|
||||
|
||||
def search(self, query: str, types_media: list[Any]) -> list[ResultItem]:
|
||||
"""Perform a search and return a list of ResultItems."""
|
||||
query_clean = query.strip()
|
||||
"""Perform a search and return a list of ResultItems.
|
||||
|
||||
Args:
|
||||
query (str): The search query.
|
||||
types_media (list[Any]): The types of media to search for.
|
||||
|
||||
Returns:
|
||||
list[ResultItem]: The search results.
|
||||
"""
|
||||
query_clean: str = query.strip()
|
||||
|
||||
# If a direct link was searched for, skip search and create the object from the link directly.
|
||||
if "http" in query_clean:
|
||||
query_clean = url_ending_clean(query_clean)
|
||||
query_clean: str = url_ending_clean(query_clean)
|
||||
media_type = get_tidal_media_type(query_clean)
|
||||
item_id = get_tidal_media_id(query_clean)
|
||||
|
||||
try:
|
||||
media = instantiate_media(self.main_window.tidal.session, media_type, item_id)
|
||||
result_search = {"direct": [media]}
|
||||
except Exception:
|
||||
except:
|
||||
logger_gui.error(f"Media not found (ID: {item_id}). Maybe it is not available anymore.")
|
||||
result_search = {"direct": []}
|
||||
|
||||
media = None
|
||||
|
||||
result_search = {"direct": [media]}
|
||||
else:
|
||||
result_search = search_results_all(
|
||||
result_search: dict[str, list[SearchTypes]] = search_results_all(
|
||||
session=self.main_window.tidal.session, needle=query_clean, types_media=types_media
|
||||
)
|
||||
result = []
|
||||
|
||||
result: list[ResultItem] = []
|
||||
|
||||
for _media_type, l_media in result_search.items():
|
||||
if isinstance(l_media, list):
|
||||
result.extend(self.search_result_to_model(l_media))
|
||||
result = result + self.search_result_to_model(l_media)
|
||||
|
||||
return result
|
||||
|
||||
def search_result_to_model(self, items: list[SearchTypes]) -> list[ResultItem]:
|
||||
"""Convert search results to ResultItem models."""
|
||||
return [self._to_result_item(idx, item) for idx, item in enumerate(items) if self._to_result_item(idx, item)]
|
||||
"""Convert search results to ResultItem models.
|
||||
|
||||
def _to_result_item(self, idx: int, item: Any) -> ResultItem | None:
|
||||
"""Helper to convert a single item to ResultItem."""
|
||||
Args:
|
||||
items (list[SearchTypes]): List of search result items.
|
||||
|
||||
Returns:
|
||||
list[ResultItem]: List of ResultItem models.
|
||||
"""
|
||||
result: list[ResultItem] = []
|
||||
|
||||
for idx, item in enumerate(items):
|
||||
result_item = self._to_result_item(idx, item)
|
||||
|
||||
if result_item is not None:
|
||||
result.append(result_item)
|
||||
|
||||
return result
|
||||
|
||||
def _to_result_item(self, idx: int, item) -> ResultItem | None:
|
||||
"""Helper to convert a single item to ResultItem, or None if not valid.
|
||||
|
||||
Args:
|
||||
idx (int): Index of the item.
|
||||
item: The item to convert.
|
||||
|
||||
Returns:
|
||||
ResultItem | None: The converted ResultItem or None if not valid.
|
||||
"""
|
||||
if not item or (hasattr(item, "available") and not item.available):
|
||||
return None
|
||||
|
||||
# Prepare common data
|
||||
explicit = " 🅴" if isinstance(item, Track | Video | Album) and item.explicit else ""
|
||||
date_user_added = (
|
||||
item.user_date_added.strftime("%Y-%m-%d_%H:%M") if getattr(item, "user_date_added", None) else ""
|
||||
)
|
||||
date_release = self._get_date_release(item)
|
||||
|
||||
# Map item types to their conversion methods
|
||||
type_handlers = {
|
||||
Track: self._result_item_from_track,
|
||||
Video: self._result_item_from_video,
|
||||
Playlist: self._result_item_from_playlist,
|
||||
Album: self._result_item_from_album,
|
||||
Mix: self._result_item_from_mix,
|
||||
Artist: self._result_item_from_artist,
|
||||
Folder: self._result_item_from_folder,
|
||||
Track: lambda: self._result_item_from_track(idx, item, explicit, date_user_added, date_release),
|
||||
Video: lambda: self._result_item_from_video(idx, item, explicit, date_user_added, date_release),
|
||||
Playlist: lambda: self._result_item_from_playlist(idx, item, date_user_added, date_release),
|
||||
Album: lambda: self._result_item_from_album(idx, item, explicit, date_user_added, date_release),
|
||||
Mix: lambda: self._result_item_from_mix(idx, item, date_user_added, date_release),
|
||||
Artist: lambda: self._result_item_from_artist(idx, item, date_user_added, date_release),
|
||||
Folder: lambda: self._result_item_from_folder(idx, item, date_user_added),
|
||||
}
|
||||
|
||||
# Find and execute the appropriate handler
|
||||
for item_type, handler in type_handlers.items():
|
||||
if isinstance(item, item_type):
|
||||
return handler(idx, item, explicit, date_user_added, date_release)
|
||||
return handler()
|
||||
|
||||
return None
|
||||
|
||||
def _get_date_release(self, item: Any) -> str:
|
||||
"""Get the release date string for an item."""
|
||||
def _get_date_release(self, item) -> str:
|
||||
"""Get the release date string for an item.
|
||||
|
||||
Args:
|
||||
item: The item to extract the release date from.
|
||||
|
||||
Returns:
|
||||
str: The formatted release date or empty string.
|
||||
"""
|
||||
if hasattr(item, "album") and item.album and getattr(item.album, "release_date", None):
|
||||
return item.album.release_date.strftime("%Y-%m-%d_%H:%M")
|
||||
|
||||
if hasattr(item, "release_date") and item.release_date:
|
||||
return item.release_date.strftime("%Y-%m-%d_%H:%M")
|
||||
|
||||
return ""
|
||||
|
||||
def _result_item_from_track(
|
||||
self, idx: int, item: Track, explicit: str, date_user_added: str, date_release: str
|
||||
self, idx: int, item, explicit: str, date_user_added: str, date_release: str
|
||||
) -> ResultItem:
|
||||
"""Create a ResultItem from a Track."""
|
||||
"""Create a ResultItem from a Track.
|
||||
|
||||
Args:
|
||||
idx (int): Index of the item.
|
||||
item: The Track item.
|
||||
explicit (str): Explicit tag.
|
||||
date_user_added (str): Date user added.
|
||||
date_release (str): Release date.
|
||||
|
||||
Returns:
|
||||
ResultItem: The constructed ResultItem.
|
||||
"""
|
||||
|
||||
final_quality = quality_audio_highest(item)
|
||||
if hasattr(item, "audio_modes") and AudioMode.dolby_atmos.value in item.audio_modes:
|
||||
final_quality = f"{final_quality} / Dolby Atmos"
|
||||
|
||||
return ResultItem(
|
||||
position=idx,
|
||||
artist=name_builder_artist(item),
|
||||
title=f"{item.name}{explicit}",
|
||||
title=f"{name_builder_title(item)}{explicit}",
|
||||
album=item.album.name,
|
||||
duration_sec=item.duration,
|
||||
obj=item,
|
||||
@@ -117,13 +185,24 @@ class GuiSearchManager:
|
||||
)
|
||||
|
||||
def _result_item_from_video(
|
||||
self, idx: int, item: Video, explicit: str, date_user_added: str, date_release: str
|
||||
self, idx: int, item, explicit: str, date_user_added: str, date_release: str
|
||||
) -> ResultItem:
|
||||
"""Create a ResultItem from a Video."""
|
||||
"""Create a ResultItem from a Video.
|
||||
|
||||
Args:
|
||||
idx (int): Index of the item.
|
||||
item: The Video item.
|
||||
explicit (str): Explicit tag.
|
||||
date_user_added (str): Date user added.
|
||||
date_release (str): Release date.
|
||||
|
||||
Returns:
|
||||
ResultItem: The constructed ResultItem.
|
||||
"""
|
||||
return ResultItem(
|
||||
position=idx,
|
||||
artist=name_builder_artist(item),
|
||||
title=f"{item.name}{explicit}",
|
||||
title=f"{name_builder_title(item)}{explicit}",
|
||||
album=item.album.name if item.album else "",
|
||||
duration_sec=item.duration,
|
||||
obj=item,
|
||||
@@ -133,10 +212,18 @@ class GuiSearchManager:
|
||||
date_release=date_release,
|
||||
)
|
||||
|
||||
def _result_item_from_playlist(
|
||||
self, idx: int, item: Playlist, explicit: str, date_user_added: str, date_release: str
|
||||
) -> ResultItem:
|
||||
"""Create a ResultItem from a Playlist."""
|
||||
def _result_item_from_playlist(self, idx: int, item, date_user_added: str, date_release: str) -> ResultItem:
|
||||
"""Create a ResultItem from a Playlist.
|
||||
|
||||
Args:
|
||||
idx (int): Index of the item.
|
||||
item: The Playlist item.
|
||||
date_user_added (str): Date user added.
|
||||
date_release (str): Release date.
|
||||
|
||||
Returns:
|
||||
ResultItem: The constructed ResultItem.
|
||||
"""
|
||||
return ResultItem(
|
||||
position=idx,
|
||||
artist=", ".join(artist.name for artist in item.promoted_artists) if item.promoted_artists else "",
|
||||
@@ -151,9 +238,20 @@ class GuiSearchManager:
|
||||
)
|
||||
|
||||
def _result_item_from_album(
|
||||
self, idx: int, item: Album, explicit: str, date_user_added: str, date_release: str
|
||||
self, idx: int, item, explicit: str, date_user_added: str, date_release: str
|
||||
) -> ResultItem:
|
||||
"""Create a ResultItem from an Album."""
|
||||
"""Create a ResultItem from an Album.
|
||||
|
||||
Args:
|
||||
idx (int): Index of the item.
|
||||
item: The Album item.
|
||||
explicit (str): Explicit tag.
|
||||
date_user_added (str): Date user added.
|
||||
date_release (str): Release date.
|
||||
|
||||
Returns:
|
||||
ResultItem: The constructed ResultItem.
|
||||
"""
|
||||
return ResultItem(
|
||||
position=idx,
|
||||
artist=name_builder_artist(item),
|
||||
@@ -167,16 +265,24 @@ class GuiSearchManager:
|
||||
date_release=date_release,
|
||||
)
|
||||
|
||||
def _result_item_from_mix(
|
||||
self, idx: int, item: Mix, explicit: str, date_user_added: str, date_release: str
|
||||
) -> ResultItem:
|
||||
"""Create a ResultItem from a Mix."""
|
||||
def _result_item_from_mix(self, idx: int, item, date_user_added: str, date_release: str) -> ResultItem:
|
||||
"""Create a ResultItem from a Mix.
|
||||
|
||||
Args:
|
||||
idx (int): Index of the item.
|
||||
item: The Mix item.
|
||||
date_user_added (str): Date user added.
|
||||
date_release (str): Release date.
|
||||
|
||||
Returns:
|
||||
ResultItem: The constructed ResultItem.
|
||||
"""
|
||||
return ResultItem(
|
||||
position=idx,
|
||||
artist=item.sub_title,
|
||||
title=item.title,
|
||||
album="",
|
||||
duration_sec=-1,
|
||||
duration_sec=-1, # TODO: Calculate total duration.
|
||||
obj=item,
|
||||
quality="",
|
||||
explicit=False,
|
||||
@@ -184,10 +290,18 @@ class GuiSearchManager:
|
||||
date_release=date_release,
|
||||
)
|
||||
|
||||
def _result_item_from_artist(
|
||||
self, idx: int, item: Artist, explicit: str, date_user_added: str, date_release: str
|
||||
) -> ResultItem:
|
||||
"""Create a ResultItem from an Artist."""
|
||||
def _result_item_from_artist(self, idx: int, item, date_user_added: str, date_release: str) -> ResultItem:
|
||||
"""Create a ResultItem from an Artist.
|
||||
|
||||
Args:
|
||||
idx (int): Index of the item.
|
||||
item: The Artist item.
|
||||
date_user_added (str): Date user added.
|
||||
date_release (str): Release date.
|
||||
|
||||
Returns:
|
||||
ResultItem: The constructed ResultItem.
|
||||
"""
|
||||
return ResultItem(
|
||||
position=idx,
|
||||
artist=item.name,
|
||||
@@ -201,11 +315,18 @@ class GuiSearchManager:
|
||||
date_release=date_release,
|
||||
)
|
||||
|
||||
def _result_item_from_folder(
|
||||
self, idx: int, item: Folder, explicit: str, date_user_added: str, date_release: str
|
||||
) -> ResultItem:
|
||||
"""Create a ResultItem from a Folder."""
|
||||
total_items = item.total_number_of_items if hasattr(item, "total_number_of_items") else 0
|
||||
def _result_item_from_folder(self, idx: int, item: Folder, date_user_added: str) -> ResultItem:
|
||||
"""Create a ResultItem from a Folder.
|
||||
|
||||
Args:
|
||||
idx (int): Index of the item.
|
||||
item (Folder): The Folder item.
|
||||
date_user_added (str): Date user added.
|
||||
|
||||
Returns:
|
||||
ResultItem: The constructed ResultItem.
|
||||
"""
|
||||
total_items: int = item.total_number_of_items if hasattr(item, "total_number_of_items") else 0
|
||||
return ResultItem(
|
||||
position=idx,
|
||||
artist="",
|
||||
@@ -216,5 +337,65 @@ class GuiSearchManager:
|
||||
quality="",
|
||||
explicit=False,
|
||||
date_user_added=date_user_added,
|
||||
date_release=date_release,
|
||||
date_release="",
|
||||
)
|
||||
|
||||
def media_to_queue_download_model(
|
||||
self, media: Artist | Track | Video | Album | Playlist | Mix
|
||||
) -> QueueDownloadItem | bool:
|
||||
"""Convert a media object to a QueueDownloadItem for the download queue.
|
||||
|
||||
Args:
|
||||
media (Artist | Track | Video | Album | Playlist | Mix): The media object.
|
||||
|
||||
Returns:
|
||||
QueueDownloadItem | bool: The queue item or False if not available.
|
||||
"""
|
||||
result: QueueDownloadItem | False
|
||||
name: str = ""
|
||||
quality_audio: Quality = self.main_window.settings.data.quality_audio
|
||||
quality_video: QualityVideo = self.main_window.settings.data.quality_video
|
||||
explicit: str = ""
|
||||
|
||||
# Check if item is available on TIDAL.
|
||||
# Note: Some albums have available=None, which should be treated as available
|
||||
if hasattr(media, "available") and media.available is False:
|
||||
return False
|
||||
|
||||
# Set "Explicit" tag
|
||||
if isinstance(media, Track | Video | Album):
|
||||
explicit = " 🅴" if media.explicit else ""
|
||||
|
||||
# Build name and set quality
|
||||
if isinstance(media, Track | Video):
|
||||
name = f"{name_builder_artist(media)} - {name_builder_title(media)}{explicit}"
|
||||
elif isinstance(media, Playlist | Artist):
|
||||
name = media.name
|
||||
elif isinstance(media, Album):
|
||||
name = f"{name_builder_artist(media)} - {media.name}{explicit}"
|
||||
elif isinstance(media, Mix):
|
||||
name = media.title
|
||||
|
||||
# Determine actual quality.
|
||||
if isinstance(media, Track | Album):
|
||||
quality_highest: Quality = quality_audio_highest(media)
|
||||
|
||||
if (
|
||||
self.main_window.settings.data.quality_audio == quality_highest
|
||||
or self.main_window.settings.data.quality_audio == Quality.hi_res_lossless
|
||||
):
|
||||
quality_audio = quality_highest
|
||||
|
||||
if name:
|
||||
result = QueueDownloadItem(
|
||||
name=name,
|
||||
quality_audio=quality_audio,
|
||||
quality_video=quality_video,
|
||||
type_media=type(media).__name__,
|
||||
status=QueueDownloadStatus.Waiting,
|
||||
obj=media,
|
||||
)
|
||||
else:
|
||||
result = False
|
||||
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
"""
|
||||
history.py
|
||||
|
||||
Implements the download history tracking service with JSON persistence.
|
||||
|
||||
Classes:
|
||||
HistoryService: Main service for managing download history with atomic JSON operations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
from tidal_dl_ng.helper.decorator import SingletonMeta
|
||||
from tidal_dl_ng.helper.path import path_config_base
|
||||
|
||||
|
||||
class HistoryFormatError(TypeError):
|
||||
"""Exception raised when history file format is invalid."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize with predefined message."""
|
||||
super().__init__("Invalid history file format")
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadHistoryEntry:
|
||||
"""Represents a single entry in the download history.
|
||||
|
||||
Attributes:
|
||||
source_type: Type of source (playlist, album, manual, mix).
|
||||
source_id: ID of the source (UUID or None for manual).
|
||||
source_name: Name of the source (playlist name, album name, etc.).
|
||||
download_date: ISO 8601 timestamp of download.
|
||||
"""
|
||||
|
||||
source_type: str
|
||||
source_id: str | None
|
||||
source_name: str | None
|
||||
download_date: str
|
||||
|
||||
|
||||
class HistoryService(metaclass=SingletonMeta):
|
||||
"""Service for managing download history with JSON persistence.
|
||||
|
||||
This service provides thread-safe operations for tracking downloaded tracks.
|
||||
The history is stored in a track-centric JSON file (trackId -> metadata).
|
||||
All write operations are atomic to prevent corruption.
|
||||
|
||||
Attributes:
|
||||
history_data: In-memory dictionary of track IDs to history entries.
|
||||
file_path: Path to the JSON history file.
|
||||
_lock: Thread lock for concurrent access safety.
|
||||
"""
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the history service and load existing data."""
|
||||
self.file_path: Path = Path(path_config_base()) / "downloaded_history.json"
|
||||
self.history_data: dict[str, dict[str, Any]] = {}
|
||||
self.settings_data: dict[str, Any] = {"preventDuplicates": True}
|
||||
self._lock: Lock = Lock()
|
||||
self._load_history()
|
||||
|
||||
def _load_history(self) -> None:
|
||||
"""Load history from JSON file.
|
||||
|
||||
If the file doesn't exist, creates a new empty history.
|
||||
If the file is corrupted, backs it up and starts fresh.
|
||||
Thread-safe operation.
|
||||
"""
|
||||
with self._lock:
|
||||
try:
|
||||
if not self.file_path.exists():
|
||||
# Create directory if needed
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Initialize with empty schema
|
||||
self._save_history_internal()
|
||||
return
|
||||
|
||||
with open(self.file_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise HistoryFormatError
|
||||
|
||||
settings = data.get("settings", {})
|
||||
self.settings_data = {"preventDuplicates": bool(settings.get("preventDuplicates", True))}
|
||||
|
||||
tracks_section = data.get("tracks")
|
||||
if isinstance(tracks_section, dict):
|
||||
self.history_data = tracks_section
|
||||
else:
|
||||
self.history_data = {k: v for k, v in data.items() if not k.startswith("_") and k != "settings"}
|
||||
|
||||
except (json.JSONDecodeError, ValueError, FileNotFoundError):
|
||||
# Backup corrupted file
|
||||
if self.file_path.exists():
|
||||
backup_path = self.file_path.with_suffix(".json.bak")
|
||||
counter = 1
|
||||
while backup_path.exists():
|
||||
backup_path = self.file_path.with_suffix(f".json.bak.{counter}")
|
||||
counter += 1
|
||||
shutil.copy2(self.file_path, backup_path)
|
||||
print(f"Warning: Download history file was corrupted. Backup saved to: {backup_path}")
|
||||
|
||||
# Start with empty history
|
||||
self.history_data = {}
|
||||
self.settings_data = {"preventDuplicates": True}
|
||||
self._save_history_internal()
|
||||
|
||||
def _save_history_internal(self) -> None:
|
||||
"""Internal method to save history to JSON file atomically.
|
||||
|
||||
Uses atomic write (write to temp file, then rename) to prevent corruption.
|
||||
Assumes lock is already held by caller.
|
||||
"""
|
||||
try:
|
||||
# Ensure directory exists
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Prepare data with metadata
|
||||
data_to_save = {
|
||||
"_schema_version": self.SCHEMA_VERSION,
|
||||
"_last_updated": datetime.now(UTC).isoformat(),
|
||||
"settings": self.settings_data,
|
||||
"tracks": self.history_data,
|
||||
}
|
||||
|
||||
# Atomic write: write to temp file, then rename
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", encoding="utf-8", dir=self.file_path.parent, delete=False, suffix=".tmp"
|
||||
) as tmp_file:
|
||||
json.dump(data_to_save, tmp_file, indent=2, ensure_ascii=False)
|
||||
tmp_path = tmp_file.name
|
||||
|
||||
# Atomic rename (on Windows, need to remove target first if exists)
|
||||
if os.name == "nt" and self.file_path.exists():
|
||||
os.replace(tmp_path, self.file_path)
|
||||
else:
|
||||
os.rename(tmp_path, self.file_path)
|
||||
|
||||
except Exception:
|
||||
# Clean up temp file if it exists
|
||||
if "tmp_path" in locals() and os.path.exists(tmp_path):
|
||||
from contextlib import suppress
|
||||
|
||||
with suppress(OSError):
|
||||
os.remove(tmp_path)
|
||||
raise
|
||||
|
||||
def save_history(self) -> None:
|
||||
"""Public method to save history (with lock acquisition)."""
|
||||
with self._lock:
|
||||
self._save_history_internal()
|
||||
|
||||
def get_settings(self) -> dict[str, Any]:
|
||||
"""Return a copy of history-related settings."""
|
||||
with self._lock:
|
||||
return dict(self.settings_data)
|
||||
|
||||
def update_settings(self, **kwargs: Any) -> None:
|
||||
"""Update history settings and persist immediately."""
|
||||
with self._lock:
|
||||
for key, value in kwargs.items():
|
||||
if key in self.settings_data:
|
||||
self.settings_data[key] = bool(value)
|
||||
self._save_history_internal()
|
||||
|
||||
def should_skip_download(self, track_id: str) -> bool:
|
||||
"""Return True if track should be skipped based on history and settings."""
|
||||
with self._lock:
|
||||
prevent_duplicates = self.settings_data.get("preventDuplicates", True)
|
||||
return prevent_duplicates and track_id in self.history_data
|
||||
|
||||
def is_downloaded(self, track_id: str) -> bool:
|
||||
"""Check if a track has been downloaded.
|
||||
|
||||
Args:
|
||||
track_id: The TIDAL track ID to check.
|
||||
|
||||
Returns:
|
||||
True if track is in download history, False otherwise.
|
||||
"""
|
||||
with self._lock:
|
||||
return track_id in self.history_data
|
||||
|
||||
def add_track_to_history(
|
||||
self, track_id: str, source_type: str = "manual", source_id: str | None = None, source_name: str | None = None
|
||||
) -> None:
|
||||
"""Add a track to download history.
|
||||
|
||||
Args:
|
||||
track_id: The TIDAL track ID.
|
||||
source_type: Type of source (playlist, album, manual, mix, track).
|
||||
source_id: ID of the source (UUID for playlist/album, None for manual).
|
||||
source_name: Name of the source (for display purposes).
|
||||
"""
|
||||
with self._lock:
|
||||
self.history_data[track_id] = {
|
||||
"sourceType": source_type,
|
||||
"sourceId": source_id,
|
||||
"sourceName": source_name,
|
||||
"downloadDate": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
# Save immediately to persist
|
||||
self._save_history_internal()
|
||||
|
||||
def remove_track_from_history(self, track_id: str) -> bool:
|
||||
"""Remove a track from download history.
|
||||
|
||||
Args:
|
||||
track_id: The TIDAL track ID to remove.
|
||||
|
||||
Returns:
|
||||
True if track was removed, False if not found.
|
||||
"""
|
||||
with self._lock:
|
||||
if track_id in self.history_data:
|
||||
del self.history_data[track_id]
|
||||
self._save_history_internal()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_history_by_source(self) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Transform track-centric history to source-centric view.
|
||||
|
||||
Returns:
|
||||
Dictionary grouped by source with format:
|
||||
{
|
||||
"source_key": [
|
||||
{
|
||||
"track_id": "123",
|
||||
"download_date": "2025-11-15T...",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
where source_key is "{sourceType}_{sourceId}" or "manual" for manual downloads.
|
||||
"""
|
||||
with self._lock:
|
||||
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
for track_id, entry in self.history_data.items():
|
||||
source_type = entry.get("sourceType", "manual")
|
||||
source_id = entry.get("sourceId")
|
||||
source_name = entry.get("sourceName", "Unknown")
|
||||
|
||||
# Create source key
|
||||
source_key = f"{source_type}_{source_id}" if source_id else f"{source_type}_manual"
|
||||
|
||||
# Initialize list if needed
|
||||
if source_key not in grouped:
|
||||
grouped[source_key] = []
|
||||
|
||||
# Add track info
|
||||
grouped[source_key].append(
|
||||
{
|
||||
"track_id": track_id,
|
||||
"source_type": source_type,
|
||||
"source_id": source_id,
|
||||
"source_name": source_name,
|
||||
"download_date": entry.get("downloadDate", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return grouped
|
||||
|
||||
def get_track_info(self, track_id: str) -> dict[str, Any] | None:
|
||||
"""Get download history info for a specific track.
|
||||
|
||||
Args:
|
||||
track_id: The TIDAL track ID.
|
||||
|
||||
Returns:
|
||||
Dictionary with history entry or None if not found.
|
||||
"""
|
||||
with self._lock:
|
||||
return self.history_data.get(track_id)
|
||||
|
||||
def get_history_file_path(self) -> str:
|
||||
"""Get the absolute path to the history file.
|
||||
|
||||
Returns:
|
||||
Absolute path as string.
|
||||
"""
|
||||
return str(self.file_path.absolute())
|
||||
|
||||
def _extract_tracks_from_data(self, data: dict) -> dict:
|
||||
"""Extract tracks from import data.
|
||||
|
||||
Args:
|
||||
data: Import data dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary of tracks.
|
||||
"""
|
||||
tracks_node = data.get("tracks")
|
||||
if isinstance(tracks_node, dict):
|
||||
return tracks_node
|
||||
return {k: v for k, v in data.items() if not k.startswith("_") and k != "settings"}
|
||||
|
||||
def _validate_tracks(self, tracks: dict) -> tuple[bool, str]:
|
||||
"""Validate track entries.
|
||||
|
||||
Args:
|
||||
tracks: Dictionary of tracks to validate.
|
||||
|
||||
Returns:
|
||||
Tuple of (valid, error_message).
|
||||
"""
|
||||
required_keys = {"sourceType", "downloadDate"}
|
||||
for track_id, entry in tracks.items():
|
||||
if not isinstance(entry, dict):
|
||||
return False, f"Invalid entry format for track {track_id}"
|
||||
if not required_keys.issubset(entry.keys()):
|
||||
return False, f"Missing required fields for track {track_id}"
|
||||
return True, ""
|
||||
|
||||
def import_history(self, file_path: str, merge: bool = True) -> tuple[bool, str]:
|
||||
"""Import history from an external JSON file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the JSON file to import.
|
||||
merge: If True, merge with existing history. If False, replace.
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str).
|
||||
"""
|
||||
try:
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
return False, f"Invalid JSON file: {e!s}"
|
||||
except Exception as e:
|
||||
return False, f"Import failed: {e!s}"
|
||||
else:
|
||||
# Validate format
|
||||
if not isinstance(data, dict):
|
||||
return False, "Invalid file format: expected JSON object"
|
||||
|
||||
# Extract and validate
|
||||
imported_settings = data.get("settings", {})
|
||||
imported_tracks = self._extract_tracks_from_data(data)
|
||||
|
||||
valid, error_msg = self._validate_tracks(imported_tracks)
|
||||
if not valid:
|
||||
return False, error_msg
|
||||
|
||||
# Apply import
|
||||
with self._lock:
|
||||
if merge:
|
||||
self.history_data.update(imported_tracks)
|
||||
message = f"Successfully merged {len(imported_tracks)} tracks"
|
||||
else:
|
||||
self.history_data = imported_tracks
|
||||
message = f"Successfully imported {len(imported_tracks)} tracks (replaced existing)"
|
||||
|
||||
if isinstance(imported_settings, dict) and "preventDuplicates" in imported_settings:
|
||||
self.settings_data["preventDuplicates"] = bool(imported_settings.get("preventDuplicates", True))
|
||||
|
||||
self._save_history_internal()
|
||||
|
||||
return True, message
|
||||
|
||||
def export_history(self, file_path: str) -> tuple[bool, str]:
|
||||
"""Export history to an external JSON file.
|
||||
|
||||
Args:
|
||||
file_path: Destination path for the exported JSON file.
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str).
|
||||
"""
|
||||
try:
|
||||
with self._lock:
|
||||
# Prepare export data (same format as internal storage)
|
||||
data_to_export = {
|
||||
"_schema_version": self.SCHEMA_VERSION,
|
||||
"_exported_date": datetime.now(UTC).isoformat(),
|
||||
"_total_tracks": len(self.history_data),
|
||||
"settings": self.settings_data,
|
||||
"tracks": self.history_data,
|
||||
}
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data_to_export, f, indent=2, ensure_ascii=False)
|
||||
|
||||
return True, f"Successfully exported {len(self.history_data)} tracks"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Export failed: {e!s}"
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear all download history.
|
||||
|
||||
This is a destructive operation - use with caution.
|
||||
"""
|
||||
with self._lock:
|
||||
self.history_data = {}
|
||||
self._save_history_internal()
|
||||
|
||||
def get_statistics(self) -> dict[str, Any]:
|
||||
"""Get statistics about the download history.
|
||||
|
||||
Returns:
|
||||
Dictionary with statistics (total tracks, by source type, etc.).
|
||||
"""
|
||||
with self._lock:
|
||||
stats = {
|
||||
"total_tracks": len(self.history_data),
|
||||
"by_source_type": {},
|
||||
"oldest_download": None,
|
||||
"newest_download": None,
|
||||
}
|
||||
|
||||
dates = []
|
||||
for entry in self.history_data.values():
|
||||
source_type = entry.get("sourceType", "unknown")
|
||||
stats["by_source_type"][source_type] = stats["by_source_type"].get(source_type, 0) + 1
|
||||
|
||||
download_date = entry.get("downloadDate")
|
||||
if download_date:
|
||||
dates.append(download_date)
|
||||
|
||||
if dates:
|
||||
stats["oldest_download"] = min(dates)
|
||||
stats["newest_download"] = max(dates)
|
||||
|
||||
return stats
|
||||
@@ -52,14 +52,17 @@ handler_qt: QtHandler = QtHandler()
|
||||
# log_fmt: str = "[%(asctime)s] %(levelname)s: %(message)s"
|
||||
log_fmt: str = "> %(message)s"
|
||||
# formatter = logging.Formatter(log_fmt)
|
||||
formatter = coloredlogs.ColoredFormatter(fmt=log_fmt)
|
||||
# Configure custom level styles to make INFO messages green
|
||||
level_styles = coloredlogs.DEFAULT_LEVEL_STYLES.copy()
|
||||
level_styles["info"] = {"color": "green"}
|
||||
formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles)
|
||||
handler_qt.setFormatter(formatter)
|
||||
logger_gui.addHandler(handler_qt)
|
||||
logger_gui.setLevel(logging.DEBUG)
|
||||
|
||||
logger_cli = logging.getLogger(__name__)
|
||||
handler_stream: logging.StreamHandler = logging.StreamHandler()
|
||||
formatter = coloredlogs.ColoredFormatter(fmt=log_fmt)
|
||||
formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles)
|
||||
handler_stream.setFormatter(formatter)
|
||||
logger_cli.addHandler(handler_stream)
|
||||
logger_cli.setLevel(logging.DEBUG)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
################################################################################
|
||||
## Form generated from reading UI file 'dialog_history.ui'
|
||||
##
|
||||
## Created by: Qt User Interface Compiler version 6.10.0
|
||||
##
|
||||
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||
################################################################################
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QMetaObject
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpacerItem,
|
||||
QTreeWidget,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
|
||||
class Ui_DialogHistory:
|
||||
def setupUi(self, DialogHistory):
|
||||
if not DialogHistory.objectName():
|
||||
DialogHistory.setObjectName("DialogHistory")
|
||||
DialogHistory.resize(900, 600)
|
||||
self.verticalLayout = QVBoxLayout(DialogHistory)
|
||||
self.verticalLayout.setObjectName("verticalLayout")
|
||||
self.l_info = QLabel(DialogHistory)
|
||||
self.l_info.setObjectName("l_info")
|
||||
self.l_info.setWordWrap(True)
|
||||
|
||||
self.verticalLayout.addWidget(self.l_info)
|
||||
|
||||
self.gb_file_info = QGroupBox(DialogHistory)
|
||||
self.gb_file_info.setObjectName("gb_file_info")
|
||||
self.horizontalLayout = QHBoxLayout(self.gb_file_info)
|
||||
self.horizontalLayout.setObjectName("horizontalLayout")
|
||||
self.le_file_path = QLineEdit(self.gb_file_info)
|
||||
self.le_file_path.setObjectName("le_file_path")
|
||||
self.le_file_path.setReadOnly(True)
|
||||
|
||||
self.horizontalLayout.addWidget(self.le_file_path)
|
||||
|
||||
self.pb_open_folder = QPushButton(self.gb_file_info)
|
||||
self.pb_open_folder.setObjectName("pb_open_folder")
|
||||
|
||||
self.horizontalLayout.addWidget(self.pb_open_folder)
|
||||
|
||||
self.verticalLayout.addWidget(self.gb_file_info)
|
||||
|
||||
self.gb_statistics = QGroupBox(DialogHistory)
|
||||
self.gb_statistics.setObjectName("gb_statistics")
|
||||
self.horizontalLayout_2 = QHBoxLayout(self.gb_statistics)
|
||||
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
|
||||
self.l_total_tracks = QLabel(self.gb_statistics)
|
||||
self.l_total_tracks.setObjectName("l_total_tracks")
|
||||
|
||||
self.horizontalLayout_2.addWidget(self.l_total_tracks)
|
||||
|
||||
self.l_by_albums = QLabel(self.gb_statistics)
|
||||
self.l_by_albums.setObjectName("l_by_albums")
|
||||
|
||||
self.horizontalLayout_2.addWidget(self.l_by_albums)
|
||||
|
||||
self.l_by_playlists = QLabel(self.gb_statistics)
|
||||
self.l_by_playlists.setObjectName("l_by_playlists")
|
||||
|
||||
self.horizontalLayout_2.addWidget(self.l_by_playlists)
|
||||
|
||||
self.l_by_mixes = QLabel(self.gb_statistics)
|
||||
self.l_by_mixes.setObjectName("l_by_mixes")
|
||||
|
||||
self.horizontalLayout_2.addWidget(self.l_by_mixes)
|
||||
|
||||
self.l_by_manual = QLabel(self.gb_statistics)
|
||||
self.l_by_manual.setObjectName("l_by_manual")
|
||||
|
||||
self.horizontalLayout_2.addWidget(self.l_by_manual)
|
||||
|
||||
self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||||
|
||||
self.horizontalLayout_2.addItem(self.horizontalSpacer)
|
||||
|
||||
self.verticalLayout.addWidget(self.gb_statistics)
|
||||
|
||||
self.tw_history = QTreeWidget(DialogHistory)
|
||||
self.tw_history.setObjectName("tw_history")
|
||||
self.tw_history.setAlternatingRowColors(True)
|
||||
self.tw_history.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||
self.tw_history.setSortingEnabled(True)
|
||||
|
||||
self.verticalLayout.addWidget(self.tw_history)
|
||||
|
||||
self.horizontalLayout_3 = QHBoxLayout()
|
||||
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
|
||||
self.pb_export = QPushButton(DialogHistory)
|
||||
self.pb_export.setObjectName("pb_export")
|
||||
|
||||
self.horizontalLayout_3.addWidget(self.pb_export)
|
||||
|
||||
self.pb_import = QPushButton(DialogHistory)
|
||||
self.pb_import.setObjectName("pb_import")
|
||||
|
||||
self.horizontalLayout_3.addWidget(self.pb_import)
|
||||
|
||||
self.pb_clear_history = QPushButton(DialogHistory)
|
||||
self.pb_clear_history.setObjectName("pb_clear_history")
|
||||
self.pb_clear_history.setStyleSheet("background-color: #dc3545; color: white;")
|
||||
|
||||
self.horizontalLayout_3.addWidget(self.pb_clear_history)
|
||||
|
||||
self.pb_remove_selected = QPushButton(DialogHistory)
|
||||
self.pb_remove_selected.setObjectName("pb_remove_selected")
|
||||
|
||||
self.horizontalLayout_3.addWidget(self.pb_remove_selected)
|
||||
|
||||
self.horizontalSpacer_2 = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||||
|
||||
self.horizontalLayout_3.addItem(self.horizontalSpacer_2)
|
||||
|
||||
self.pb_refresh = QPushButton(DialogHistory)
|
||||
self.pb_refresh.setObjectName("pb_refresh")
|
||||
|
||||
self.horizontalLayout_3.addWidget(self.pb_refresh)
|
||||
|
||||
self.pb_close = QPushButton(DialogHistory)
|
||||
self.pb_close.setObjectName("pb_close")
|
||||
|
||||
self.horizontalLayout_3.addWidget(self.pb_close)
|
||||
|
||||
self.verticalLayout.addLayout(self.horizontalLayout_3)
|
||||
|
||||
self.retranslateUi(DialogHistory)
|
||||
|
||||
QMetaObject.connectSlotsByName(DialogHistory)
|
||||
|
||||
# setupUi
|
||||
|
||||
def retranslateUi(self, DialogHistory):
|
||||
DialogHistory.setWindowTitle(QCoreApplication.translate("DialogHistory", "Download History", None))
|
||||
self.l_info.setText(
|
||||
QCoreApplication.translate(
|
||||
"DialogHistory",
|
||||
"View and manage your download history. Tracks are grouped by source (album, playlist, mix).",
|
||||
None,
|
||||
)
|
||||
)
|
||||
self.gb_file_info.setTitle(QCoreApplication.translate("DialogHistory", "History File Location", None))
|
||||
self.pb_open_folder.setText(QCoreApplication.translate("DialogHistory", "Open Folder", None))
|
||||
self.gb_statistics.setTitle(QCoreApplication.translate("DialogHistory", "Statistics", None))
|
||||
self.l_total_tracks.setText(QCoreApplication.translate("DialogHistory", "Total Tracks: 0", None))
|
||||
self.l_by_albums.setText(QCoreApplication.translate("DialogHistory", "Albums: 0", None))
|
||||
self.l_by_playlists.setText(QCoreApplication.translate("DialogHistory", "Playlists: 0", None))
|
||||
self.l_by_mixes.setText(QCoreApplication.translate("DialogHistory", "Mixes: 0", None))
|
||||
self.l_by_manual.setText(QCoreApplication.translate("DialogHistory", "Manual: 0", None))
|
||||
___qtreewidgetitem = self.tw_history.headerItem()
|
||||
___qtreewidgetitem.setText(3, QCoreApplication.translate("DialogHistory", "Track ID", None))
|
||||
___qtreewidgetitem.setText(2, QCoreApplication.translate("DialogHistory", "Download Date", None))
|
||||
___qtreewidgetitem.setText(1, QCoreApplication.translate("DialogHistory", "Type", None))
|
||||
___qtreewidgetitem.setText(0, QCoreApplication.translate("DialogHistory", "Source / Track", None))
|
||||
self.pb_export.setText(QCoreApplication.translate("DialogHistory", "Export...", None))
|
||||
self.pb_import.setText(QCoreApplication.translate("DialogHistory", "Import...", None))
|
||||
self.pb_clear_history.setText(QCoreApplication.translate("DialogHistory", "Clear History", None))
|
||||
self.pb_remove_selected.setText(QCoreApplication.translate("DialogHistory", "Remove Selected", None))
|
||||
self.pb_refresh.setText(QCoreApplication.translate("DialogHistory", "Refresh", None))
|
||||
self.pb_close.setText(QCoreApplication.translate("DialogHistory", "Close", None))
|
||||
|
||||
# retranslateUi
|
||||
@@ -0,0 +1,206 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>DialogHistory</class>
|
||||
<widget class="QDialog" name="DialogHistory">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>900</width>
|
||||
<height>600</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Download History</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="l_info">
|
||||
<property name="text">
|
||||
<string>View and manage your download history. Tracks are grouped by source (album, playlist, mix).</string>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="gb_file_info">
|
||||
<property name="title">
|
||||
<string>History File Location</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QLineEdit" name="le_file_path">
|
||||
<property name="readOnly">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pb_open_folder">
|
||||
<property name="text">
|
||||
<string>Open Folder</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="gb_statistics">
|
||||
<property name="title">
|
||||
<string>Statistics</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QLabel" name="l_total_tracks">
|
||||
<property name="text">
|
||||
<string>Total Tracks: 0</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="l_by_albums">
|
||||
<property name="text">
|
||||
<string>Albums: 0</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="l_by_playlists">
|
||||
<property name="text">
|
||||
<string>Playlists: 0</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="l_by_mixes">
|
||||
<property name="text">
|
||||
<string>Mixes: 0</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="l_by_manual">
|
||||
<property name="text">
|
||||
<string>Manual: 0</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTreeWidget" name="tw_history">
|
||||
<property name="alternatingRowColors">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="selectionMode">
|
||||
<enum>QAbstractItemView::ExtendedSelection</enum>
|
||||
</property>
|
||||
<property name="sortingEnabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Source / Track</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Type</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Download Date</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Track ID</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<widget class="QPushButton" name="pb_export">
|
||||
<property name="text">
|
||||
<string>Export...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pb_import">
|
||||
<property name="text">
|
||||
<string>Import...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pb_clear_history">
|
||||
<property name="text">
|
||||
<string>Clear History</string>
|
||||
</property>
|
||||
<property name="styleSheet">
|
||||
<string notr="true">background-color: #dc3545; color: white;</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pb_remove_selected">
|
||||
<property name="text">
|
||||
<string>Remove Selected</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pb_refresh">
|
||||
<property name="text">
|
||||
<string>Refresh</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pb_close">
|
||||
<property name="text">
|
||||
<string>Close</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -106,7 +106,8 @@ class Ui_DialogSettings:
|
||||
"}\n"
|
||||
"\n"
|
||||
"QWidget {\n"
|
||||
" background-color: transparent;\n"
|
||||
" background-color: #2b2b2b;\n"
|
||||
" color: #e0e0e0;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QGroupBox {\n"
|
||||
@@ -124,6 +125,165 @@ class Ui_DialogSettings:
|
||||
" padding: 4px 8px;\n"
|
||||
" background-color: #3a3a3a;\n"
|
||||
" border-radius: 4px;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QComboBox {\n"
|
||||
" background-color: #2b2b2b;\n"
|
||||
" border: 1px solid #3d3d3d;\n"
|
||||
" border-radius: 4px;\n"
|
||||
" padding: 4px 8px;\n"
|
||||
" color: #e0e0e0;\n"
|
||||
" min-height: 20px;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QComboBox:hover {\n"
|
||||
" border: 1px solid #4a4a4a;\n"
|
||||
" background-color: #333333;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QComboBox:focus {\n"
|
||||
" border: 1px solid #3d5a80;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QComboBox::drop-down {\n"
|
||||
" border"
|
||||
": none;\n"
|
||||
" width: 20px;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QComboBox QAbstractItemView {\n"
|
||||
" background-color: #2b2b2b !important;\n"
|
||||
" border: 1px solid #3d3d3d !important;\n"
|
||||
" selection-background-color: #3d5a80;\n"
|
||||
" selection-color: white;\n"
|
||||
" color: #e0e0e0;\n"
|
||||
" outline: none;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QComboBox QAbstractItemView * {\n"
|
||||
" background-color: #2b2b2b !important;\n"
|
||||
" color: #e0e0e0 !important;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QComboBox QAbstractItemView::item {\n"
|
||||
" padding: 4px 8px;\n"
|
||||
" min-height: 20px;\n"
|
||||
" background-color: transparent;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QComboBox QAbstractItemView::item:hover {\n"
|
||||
" background-color: #3a3a3a !important;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QComboBox QAbstractItemView::item:selected {\n"
|
||||
" background-color: #3d5a80 !important;\n"
|
||||
" color: white !important;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QLineEdit {\n"
|
||||
" background-color: #2b2b2b;\n"
|
||||
" border: 1px solid #3d3d3d;\n"
|
||||
" border-radius: 4px;\n"
|
||||
" padding: 4px 8px;\n"
|
||||
" color: #e0e0e0;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QLineEdit:hover {\n"
|
||||
" border:"
|
||||
" 1px solid #4a4a4a;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QLineEdit:focus {\n"
|
||||
" border: 1px solid #3d5a80;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QSpinBox {\n"
|
||||
" background-color: #2b2b2b;\n"
|
||||
" border: 1px solid #3d3d3d;\n"
|
||||
" border-radius: 4px;\n"
|
||||
" padding: 4px 8px;\n"
|
||||
" color: #e0e0e0;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QSpinBox:hover {\n"
|
||||
" border: 1px solid #4a4a4a;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QSpinBox:focus {\n"
|
||||
" border: 1px solid #3d5a80;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QSpinBox::up-button, QSpinBox::down-button {\n"
|
||||
" background-color: #3a3a3a;\n"
|
||||
" border: none;\n"
|
||||
" width: 16px;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QSpinBox::up-button:hover, QSpinBox::down-button:hover {\n"
|
||||
" background-color: #4a4a4a;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QSpinBox::up-arrow {\n"
|
||||
" image: none;\n"
|
||||
" border-left: 4px solid transparent;\n"
|
||||
" border-right: 4px solid transparent;\n"
|
||||
" border-bottom: 5px solid #e0e0e0;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QSpinBox::down-arrow {\n"
|
||||
" image: none;\n"
|
||||
" border-left: 4px solid transparent;\n"
|
||||
" border-right: 4px solid transparent;\n"
|
||||
" border-top: 5px solid #e0e0e0;\n"
|
||||
"}\n"
|
||||
""
|
||||
"\n"
|
||||
"QCheckBox {\n"
|
||||
" color: #e0e0e0;\n"
|
||||
" spacing: 8px;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QCheckBox::indicator {\n"
|
||||
" width: 18px;\n"
|
||||
" height: 18px;\n"
|
||||
" border: 1px solid #3d3d3d;\n"
|
||||
" border-radius: 3px;\n"
|
||||
" background-color: #2b2b2b;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QCheckBox::indicator:hover {\n"
|
||||
" border: 1px solid #4a4a4a;\n"
|
||||
" background-color: #333333;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QCheckBox::indicator:checked {\n"
|
||||
" background-color: #3d5a80;\n"
|
||||
" border: 1px solid #3d5a80;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QCheckBox::indicator:checked:hover {\n"
|
||||
" background-color: #4a6a9a;\n"
|
||||
" border: 1px solid #4a6a9a;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QPushButton {\n"
|
||||
" background-color: #3a3a3a;\n"
|
||||
" border: 1px solid #4a4a4a;\n"
|
||||
" border-radius: 4px;\n"
|
||||
" padding: 4px 12px;\n"
|
||||
" color: #e0e0e0;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QPushButton:hover {\n"
|
||||
" background-color: #4a4a4a;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QPushButton:pressed {\n"
|
||||
" background-color: #2a2a2a;\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"QLabel {\n"
|
||||
" color: #e0e0e0;\n"
|
||||
"}"
|
||||
)
|
||||
self.sw_categories.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
@@ -876,7 +1036,7 @@ class Ui_DialogSettings:
|
||||
self.bb_dialog.accepted.connect(DialogSettings.accept)
|
||||
self.bb_dialog.rejected.connect(DialogSettings.reject)
|
||||
|
||||
self.sw_categories.setCurrentIndex(0)
|
||||
self.sw_categories.setCurrentIndex(1)
|
||||
|
||||
QMetaObject.connectSlotsByName(DialogSettings)
|
||||
|
||||
@@ -905,10 +1065,13 @@ class Ui_DialogSettings:
|
||||
self.gb_choices.setTitle(QCoreApplication.translate("DialogSettings", "Choices", None))
|
||||
self.l_icon_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
|
||||
self.l_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
|
||||
self.c_quality_audio.setPlaceholderText(QCoreApplication.translate("DialogSettings", "text", None))
|
||||
self.l_icon_quality_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
|
||||
self.l_quality_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
|
||||
self.c_quality_video.setPlaceholderText(QCoreApplication.translate("DialogSettings", "text", None))
|
||||
self.l_icon_metadata_cover_dimension.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
|
||||
self.l_metadata_cover_dimension.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
|
||||
self.c_metadata_cover_dimension.setPlaceholderText(QCoreApplication.translate("DialogSettings", "text", None))
|
||||
self.gb_numbers.setTitle(QCoreApplication.translate("DialogSettings", "Numbers", None))
|
||||
self.l_album_track_num_pad_min.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
|
||||
self.l_icon_album_track_num_pad_min.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
|
||||
|
||||
@@ -147,6 +147,162 @@ QGroupBox::title {
|
||||
padding: 4px 8px;
|
||||
background-color: #3a3a3a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
QComboBox {
|
||||
background-color: #2b2b2b;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
color: #e0e0e0;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
QComboBox:hover {
|
||||
border: 1px solid #4a4a4a;
|
||||
background-color: #333333;
|
||||
}
|
||||
|
||||
QComboBox:focus {
|
||||
border: 1px solid #3d5a80;
|
||||
}
|
||||
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #2b2b2b !important;
|
||||
border: 1px solid #3d3d3d !important;
|
||||
selection-background-color: #3d5a80;
|
||||
selection-color: white;
|
||||
color: #e0e0e0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView * {
|
||||
background-color: #2b2b2b !important;
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView::item {
|
||||
padding: 4px 8px;
|
||||
min-height: 20px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView::item:hover {
|
||||
background-color: #3a3a3a !important;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView::item:selected {
|
||||
background-color: #3d5a80 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
QLineEdit {
|
||||
background-color: #2b2b2b;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
QLineEdit:hover {
|
||||
border: 1px solid #4a4a4a;
|
||||
}
|
||||
|
||||
QLineEdit:focus {
|
||||
border: 1px solid #3d5a80;
|
||||
}
|
||||
|
||||
QSpinBox {
|
||||
background-color: #2b2b2b;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
QSpinBox:hover {
|
||||
border: 1px solid #4a4a4a;
|
||||
}
|
||||
|
||||
QSpinBox:focus {
|
||||
border: 1px solid #3d5a80;
|
||||
}
|
||||
|
||||
QSpinBox::up-button, QSpinBox::down-button {
|
||||
background-color: #3a3a3a;
|
||||
border: none;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
QSpinBox::up-button:hover, QSpinBox::down-button:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
QSpinBox::up-arrow {
|
||||
image: none;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-bottom: 5px solid #e0e0e0;
|
||||
}
|
||||
|
||||
QSpinBox::down-arrow {
|
||||
image: none;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid #e0e0e0;
|
||||
}
|
||||
|
||||
QCheckBox {
|
||||
color: #e0e0e0;
|
||||
spacing: 8px;
|
||||
}
|
||||
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 3px;
|
||||
background-color: #2b2b2b;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:hover {
|
||||
border: 1px solid #4a4a4a;
|
||||
background-color: #333333;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:checked {
|
||||
background-color: #3d5a80;
|
||||
border: 1px solid #3d5a80;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:checked:hover {
|
||||
background-color: #4a6a9a;
|
||||
border: 1px solid #4a6a9a;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
QLabel {
|
||||
color: #e0e0e0;
|
||||
}</string>
|
||||
</property>
|
||||
<property name="frameShape">
|
||||
@@ -159,7 +315,7 @@ QGroupBox::title {
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
<number>1</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="page_flags">
|
||||
<layout class="QVBoxLayout" name="lv_page_flags">
|
||||
@@ -463,7 +619,11 @@ QGroupBox::title {
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="c_quality_audio"/>
|
||||
<widget class="QComboBox" name="c_quality_audio">
|
||||
<property name="placeholderText">
|
||||
<string>text</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
@@ -496,7 +656,11 @@ QGroupBox::title {
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="c_quality_video"/>
|
||||
<widget class="QComboBox" name="c_quality_video">
|
||||
<property name="placeholderText">
|
||||
<string>text</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
@@ -539,6 +703,9 @@ QGroupBox::title {
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="placeholderText">
|
||||
<string>text</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
|
||||
Reference in New Issue
Block a user