Files
PlaylistSaver/dialog.py
T

616 lines
21 KiB
Python
Raw Normal View History

import logging
import subprocess
import threading
from pathlib import Path
from PySide6.QtCore import Signal, QObject, QTimer
from PySide6.QtWidgets import (
QDialog,
QLabel, QPushButton, QProgressDialog,
QVBoxLayout, QFrame, QHBoxLayout, QMessageBox, QInputDialog, QScrollArea
)
from constant import PROGRAM_DIR
from pl_lib.messagebox import ask_yes_no_with_plain_text
from utils import find_mpv_path, download_file
from widgets import VideoItem
from yt_lib import resolve_video_page_url
logger = logging.getLogger("PlayerDialog")
logger.setLevel(logging.INFO)
class DownloadProgressDialog(QProgressDialog):
tasks_added = Signal(int)
task_finished = Signal(str, bool)
producer_finished = Signal()
def __init__(self, title: str, total: int, parent=None, producers: int = 0):
super().__init__("Preparing downloads...", "Hide", 0, total, parent)
self.completed_count = 0
self.failed_count = 0
self.failed_videos = []
self.remaining_producers = producers
self.summary_shown = False
self.setWindowTitle(title)
self.setFixedWidth(540)
self.setMinimumDuration(0)
self.setAutoClose(False)
self.setAutoReset(False)
if total == 0:
self.setRange(0, 0)
else:
self.setValue(0)
self.tasks_added.connect(self.add_tasks)
self.task_finished.connect(self.update_task)
self.producer_finished.connect(self.finish_producer)
status_label = self.findChild(QLabel)
if status_label is not None:
status_label.setWordWrap(True)
status_label.setMaximumWidth(500)
self.show()
def add_tasks(self, count: int):
if self.maximum() == 0:
self.setRange(0, count)
else:
self.setMaximum(self.maximum() + count)
self.setValue(self.completed_count)
self.setLabelText(f"Downloaded {self.completed_count}/{self.maximum()} videos")
def finish_future(self, result, video=None, playlist_name=None):
video_title = (video or {}).get("title") or "Unknown video"
video_id = (video or {}).get("id") or "unknown"
display_name = f"{video_title} [{video_id}]"
if playlist_name:
display_name = f"{playlist_name} / {display_name}"
if isinstance(result, Exception):
self.task_finished.emit(f"{display_name}\n{result}", False)
return
try:
path = result.result()
except Exception as error:
self.task_finished.emit(f"{display_name}\n{error}", False)
return
self.task_finished.emit(path.name, True)
def update_task(self, message: str, succeeded: bool):
self.completed_count += 1
if not succeeded:
self.failed_count += 1
self.failed_videos.append(message)
self.setValue(self.completed_count)
status = f"Downloaded {self.completed_count}/{self.maximum()} videos"
if self.failed_count:
status += f" ({self.failed_count} failed)"
self.setLabelText(f"{status}\n{message}")
self.show_failure_summary_if_finished()
def finish_producer(self):
self.remaining_producers = max(0, self.remaining_producers - 1)
self.show_failure_summary_if_finished()
def show_failure_summary_if_finished(self):
if self.summary_shown or self.remaining_producers > 0:
return
if self.maximum() == 0 or self.completed_count < self.maximum():
return
self.summary_shown = True
if not self.failed_videos:
return
def show_summary():
should_close = ask_yes_no_with_plain_text(
self.parentWidget(),
"Download failures",
f"{len(self.failed_videos)} video(s) could not be downloaded. "
"Close the download progress window?",
"\n\n".join(self.failed_videos),
)
if should_close:
self.hide()
QTimer.singleShot(0, show_summary)
class PlayerDialog(QDialog):
status_changed = Signal(str)
def __init__(self, app, video: dict, /, parent):
QDialog.__init__(self, parent)
self.app = app
self.parent = parent
self.video = video
self.video_url = resolve_video_page_url(video)
self.process = None
self.setWindowTitle(video.get("title", "Video"))
self.resize(440, 190)
self.status_changed.connect(self.set_status)
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(16, 16, 16, 16)
self.layout.setSpacing(10)
self.title_label = QLabel(video.get("title", "Video"), self)
self.title_label.setWordWrap(True)
self.status_label = QLabel("Launching mpv...", self)
self.url_label = QLabel(self.video_url or "", self)
self.url_label.setWordWrap(True)
self.close_btn = QPushButton("Close Player", self)
self.close_btn.clicked.connect(self.close)
self.layout.addWidget(self.title_label)
self.layout.addWidget(self.status_label)
self.layout.addWidget(self.url_label)
self.layout.addWidget(self.close_btn)
self.setStyleSheet("""
QDialog {
background-color: #202020;
}
QLabel {
background-color: transparent;
color: #f0f0f0;
}
QPushButton {
background-color: #3a3a3a;
color: #f0f0f0;
border: 1px solid #505050;
border-radius: 6px;
padding: 7px 10px;
}
QPushButton:hover {
background-color: #464646;
}
""")
threading.Thread(target=self.run_mpv, daemon=True).start()
def set_status(self, message: str):
self.status_label.setText(message)
def run_mpv(self):
mpv_path = find_mpv_path(PROGRAM_DIR)
if not mpv_path:
self.status_changed.emit("mpv not found in bin/ or PATH.")
return
try:
self.process = subprocess.Popen(
[
mpv_path,
# "--force-window=immediate",
"--focus-on=open",
self.video_url,
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception as e:
logger.error("Unable to start mpv: %s", e)
self.status_changed.emit(f"Unable to start mpv: {e}")
return
self.app.mpv_processes[self] = self.process
self.status_changed.emit("Playing in mpv...")
self.process.wait()
self.app.mpv_processes.pop(self, None)
self.status_changed.emit("Playback finished.")
def closeEvent(self, event):
process = self.app.mpv_processes.pop(self, None)
if process and process.poll() is None:
process.terminate()
event.accept()
class SwitchProfileDialog(QDialog):
profile_changed = Signal(str)
def __init__(self, app, /, parent):
QDialog.__init__(self, parent)
self.app = app
self.setWindowTitle("Switch Profile")
self.resize(420, 420)
# Layout
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(16, 16, 16, 16)
self.layout.setSpacing(12)
# Items
self.current_profile_label = QLabel(f"Current: {self.app.get_active_profile_name()}")
self.current_profile_label.setWordWrap(True)
self.current_profile_label.setObjectName("currentProfile")
# Scroll are and frame (for profile item)
self.scroll_area = QScrollArea(self)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.list_frame = QFrame(self.scroll_area)
self.list_frame.setObjectName("profileList")
self.list_layout = QVBoxLayout(self.list_frame)
self.list_layout.setContentsMargins(8, 8, 8, 8)
self.list_layout.setSpacing(8)
self.scroll_area.setWidget(self.list_frame)
self.create_profile_btn = QPushButton("Create Profile")
self.create_profile_btn.setMinimumHeight(34)
self.create_profile_btn.clicked.connect(self.create_profile)
self.layout.addWidget(self.current_profile_label)
self.layout.addWidget(self.scroll_area, 1)
self.layout.addWidget(self.create_profile_btn)
self.render_profiles()
self.setStyleSheet("""
QDialog {
background-color: #202020;
}
QScrollArea, #profileList {
background-color: #242424;
}
#currentProfile {
background-color: transparent;
color: #f0f0f0;
font-weight: 600;
padding-bottom: 4px;
}
QFrame#profileRow {
background-color: #2b2b2b;
border: 1px solid #3c3c3c;
border-radius: 8px;
}
QLabel {
background-color: transparent;
color: #f0f0f0;
}
QPushButton {
background-color: #3a3a3a;
color: #f0f0f0;
border: 1px solid #505050;
border-radius: 6px;
padding: 7px 10px;
}
QPushButton:hover {
background-color: #464646;
}
QPushButton:disabled {
color: #999999;
background-color: #303030;
}
""")
def clear_profiles(self):
while self.list_layout.count():
item = self.list_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
class ProfileRow(QFrame):
def __init__(self, parent, profile_name: str, active_profile: str,
switch_profile, delete_profile):
QFrame.__init__(self, parent)
self.setObjectName("profileRow")
self.row_layout = QHBoxLayout(self)
self.row_layout.setContentsMargins(10, 8, 10, 8)
self.row_layout.setSpacing(8)
self.name_label = QLabel(profile_name)
self.name_label.setWordWrap(True)
self.switch_btn = QPushButton("Current" if profile_name == active_profile else "Switch")
self.switch_btn.setMinimumWidth(88)
self.switch_btn.setEnabled(profile_name != active_profile)
self.switch_btn.clicked.connect(lambda _checked=False, name=profile_name: switch_profile(name))
self.delete_btn = QPushButton("Delete")
self.delete_btn.setMinimumWidth(88)
self.delete_btn.setDisabled(profile_name == "Default")
self.delete_btn.clicked.connect(lambda _checked=False, name=profile_name: delete_profile(name))
self.row_layout.addWidget(self.name_label, 1)
self.row_layout.addWidget(self.switch_btn)
self.row_layout.addWidget(self.delete_btn)
def render_profiles(self):
self.clear_profiles()
meta = self.app.load_profiles_meta()
active_profile = meta["active_profile"]
for profile_name in meta["profiles"]:
row = self.ProfileRow(self, profile_name, active_profile, self.switch_profile, self.delete_profile)
self.list_layout.addWidget(row)
self.list_layout.addStretch(1)
def switch_profile(self, profile_name: str):
self.profile_changed.emit(profile_name)
self.accept()
def delete_profile(self, profile_name: str):
meta = self.app.load_profiles_meta()
if profile_name == meta["active_profile"]:
QMessageBox.critical(self, "Error", "Cannot delete the active profile.")
return
if profile_name == self.app.DEFAULT_PROFILE_NAME:
QMessageBox.critical(self, "Error", "Cannot delete the default profile.")
return
reply = QMessageBox.question(
self,
"Delete Profile",
f"Delete profile '{profile_name}'?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
meta["profiles"] = [
name for name in meta["profiles"]
if name != profile_name
]
self.app.save_profiles_meta(meta)
self.render_profiles()
def create_profile(self):
profile_name, ok = QInputDialog.getText(self, "Create Profile", "Profile name:")
if not ok:
return
profile_name = profile_name.strip()
if not profile_name:
QMessageBox.critical(self, "Error", "Profile name cannot be empty.")
return
self.app.ensure_profile_exists(profile_name)
self.render_profiles()
class PlaylistWindow(QDialog):
class LoadSignals(QObject):
playlist_ready = Signal(dict)
error = Signal(str)
download_status = Signal(str)
def __init__(self, app, playlist: dict, /, parent=None,
open_player_callback=None):
QDialog.__init__(self, parent)
self.app = app
self.playlist = playlist
self.setWindowTitle(playlist.get("title", "Playlist"))
self.resize(760, 640)
self.signals = self.LoadSignals()
self.signals.playlist_ready.connect(self.render_videos)
self.signals.error.connect(self.show_error)
self.open_player_callback = open_player_callback
# Layout
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(16, 16, 16, 16)
self.layout.setSpacing(12)
self.header_label = QLabel(playlist.get("title", "Playlist"), self)
self.header_label.setObjectName("playlistTitle")
self.header_label.setWordWrap(True)
self.info_label = QLabel("Loading playlist videos...", self)
self.info_label.setObjectName("playlistInfo")
self.signals.download_status.connect(self.info_label.setText)
self.content_layout = QHBoxLayout()
self.content_layout.setSpacing(12)
# Scroll area and frame for video items
self.scroll_area = QScrollArea(self)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.list_frame = QFrame(self.scroll_area)
self.list_frame.setObjectName("playlistFrame")
self.list_layout = QVBoxLayout(self.list_frame)
self.list_layout.setContentsMargins(8, 8, 8, 8)
self.list_layout.setSpacing(8)
self.scroll_area.setWidget(self.list_frame)
self.control_panel = QFrame(self)
self.control_panel.setObjectName("playlistControls")
self.control_panel.setFixedWidth(150)
self.control_layout = QVBoxLayout(self.control_panel)
self.control_layout.setContentsMargins(12, 12, 12, 12)
self.control_layout.setSpacing(10)
self.reload_playlist_btn = QPushButton("Reload")
self.save_playlist_btn = QPushButton("Save Playlist")
self.reload_playlist_btn.setMinimumHeight(34)
self.reload_playlist_btn.clicked.connect(self.render_playlist)
self.save_playlist_btn.clicked.connect(self.save_playlist)
self.control_layout.addWidget(self.reload_playlist_btn)
self.control_layout.addWidget(self.save_playlist_btn)
self.control_layout.addStretch(1)
self.content_layout.addWidget(self.scroll_area, 1)
self.content_layout.addWidget(self.control_panel)
self.layout.addWidget(self.header_label)
self.layout.addWidget(self.info_label)
self.layout.addLayout(self.content_layout, 1)
self.render_playlist()
self.setStyleSheet("""
QDialog {
background-color: #202020;
}
QScrollArea, #playlistFrame {
background-color: #242424;
}
#playlistTitle {
color: #f0f0f0;
font-size: 20px;
font-weight: 700;
}
#playlistInfo {
color: #a8a8a8;
}
#playlistControls {
background-color: #2b2b2b;
border: 1px solid #3c3c3c;
border-radius: 8px;
}
QLabel {
background-color: transparent;
color: #f0f0f0;
}
QPushButton {
background-color: #3a3a3a;
color: #f0f0f0;
border: 1px solid #505050;
border-radius: 6px;
padding: 7px 10px;
}
QPushButton:hover {
background-color: #464646;
}
QPushButton:disabled {
color: #999999;
background-color: #303030;
}
""")
def render_playlist(self):
self.reload_playlist_btn.setEnabled(False)
self.info_label.setText("Loading playlist videos...")
self.app.main_window.clear_layout(self.list_layout)
threading.Thread(target=self.load_playlist_worker, daemon=True).start()
def load_playlist_worker(self):
try:
playlist_data = self.app.fetch_playlist_videos(self.playlist)
except Exception as e:
logger.error("Unable to load playlist videos: %s", e)
self.signals.error.emit(str(e))
return
if playlist_data is None:
playlist_data = {"entries": []}
self.signals.playlist_ready.emit(playlist_data)
def render_videos(self, playlist_data: dict):
self.playlist_data = playlist_data
self.app.main_window.clear_layout(self.list_layout)
self.reload_playlist_btn.setEnabled(True)
entries = [entry for entry in playlist_data.get("entries", []) if entry is not None]
self.info_label.setText(f"{len(entries)} videos")
if not entries:
self.list_layout.addWidget(QLabel("No videos found.", self.list_frame))
self.list_layout.addStretch(1)
return
for index, video in enumerate(entries, start=1):
thumbnail_path = self.get_video_thumbnail_path(video)
item = VideoItem(
self.list_frame, video, index, thumbnail_path,
thumbnail_size=self.app.THUMBNAIL_SIZE,
download_video_callback=lambda _checked=False, current=video: self.download_video(current),
)
item.clicked.connect(self.open_player_callback)
self.list_layout.addWidget(item)
self.list_layout.addStretch(1)
def save_playlist(self):
data = getattr(self, "playlist_data", None)
total = len([entry for entry in data.get("entries", []) if entry]) if data else 0
self.progress_dialog = DownloadProgressDialog(
"Save Playlist", total, self.app.main_window, producers=1
)
queued_callback = None if data else self.progress_dialog.tasks_added.emit
self.app.download_playlist(
self.playlist, data, self.progress_dialog.finish_future, queued_callback,
self.progress_dialog.producer_finished.emit,
)
def download_video(self, video: dict):
self.progress_dialog = DownloadProgressDialog("Download Video", 1, self.app.main_window)
self.app.download_video(video, callback=self.progress_dialog.finish_future)
def on_download_done(self, result, video=None, playlist_name=None):
if isinstance(result, Exception):
self.signals.error.emit(f"Download failed: {result}")
return
try:
path = result.result()
except Exception as error:
self.signals.error.emit(f"Download failed: {error}")
return
self.signals.download_status.emit(f"Downloaded: {path.name}")
def show_error(self, message: str):
self.reload_playlist_btn.setEnabled(True)
self.info_label.setText("Unable to load playlist.")
QMessageBox.critical(self, "Error", f"Unable to load playlist: {message}")
def get_video_thumbnail_path(self, video: dict):
thumbnails = video.get("thumbnails") or []
if not thumbnails:
return None
thumbnail = thumbnails[0]
thumbnail_url = thumbnail.get("url")
if not thumbnail_url:
return None
thumbnail_resolution = thumbnail.get("resolution", "unknown")
video_id = video.get("id") or "unknown"
thumbnail_path = Path(self.app.temp_path, "thumbnails",
f"thumbnail_{video_id}_{thumbnail_resolution}.jpg")
if thumbnail_path.exists():
return thumbnail_path
if download_file(thumbnail_url, thumbnail_path):
return thumbnail_path
return None