Merge branch 'feature/json-downloaded' into fusion-refac-&-info

# Conflicts:
#	.gitignore
#	README.md
#	tidal_dl_ng/gui.py
This commit is contained in:
Warry
2025-12-03 11:41:18 +01:00
22 changed files with 5339 additions and 226 deletions
+327
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+22 -7
View File
@@ -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
View File
@@ -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
+437
View File
@@ -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
+5 -2
View File
@@ -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)
+171
View File
@@ -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
+206
View File
@@ -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>
+165 -2
View File
@@ -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))
+170 -3
View File
@@ -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>