583 lines
No EOL
20 KiB
Python
583 lines
No EOL
20 KiB
Python
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
|
QFrame, QPushButton, QListWidget, QListWidgetItem,
|
|
QProgressBar, QTextEdit, QCheckBox, QComboBox,
|
|
QScrollArea, QSizePolicy, QMessageBox)
|
|
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer
|
|
from PyQt6.QtGui import QFont
|
|
|
|
class PlaylistLoaderThread(QThread):
|
|
playlist_loaded = pyqtSignal(object) # Single playlist
|
|
loading_finished = pyqtSignal(int) # Total count
|
|
loading_failed = pyqtSignal(str) # Error message
|
|
progress_updated = pyqtSignal(str) # Progress text
|
|
|
|
def __init__(self, spotify_client):
|
|
super().__init__()
|
|
self.spotify_client = spotify_client
|
|
|
|
def run(self):
|
|
try:
|
|
self.progress_updated.emit("Connecting to Spotify...")
|
|
if not self.spotify_client or not self.spotify_client.is_authenticated():
|
|
self.loading_failed.emit("Spotify not authenticated")
|
|
return
|
|
|
|
self.progress_updated.emit("Fetching playlists...")
|
|
playlists = self.spotify_client.get_user_playlists()
|
|
|
|
for i, playlist in enumerate(playlists):
|
|
self.progress_updated.emit(f"Loading playlist {i+1}/{len(playlists)}: {playlist.name}")
|
|
self.playlist_loaded.emit(playlist)
|
|
self.msleep(50) # Small delay to show progressive loading
|
|
|
|
self.loading_finished.emit(len(playlists))
|
|
|
|
except Exception as e:
|
|
self.loading_failed.emit(str(e))
|
|
|
|
class PlaylistItem(QFrame):
|
|
def __init__(self, name: str, track_count: int, sync_status: str, parent=None):
|
|
super().__init__(parent)
|
|
self.name = name
|
|
self.track_count = track_count
|
|
self.sync_status = sync_status
|
|
self.is_selected = False
|
|
self.setup_ui()
|
|
|
|
def setup_ui(self):
|
|
self.setFixedHeight(80)
|
|
self.setStyleSheet("""
|
|
PlaylistItem {
|
|
background: #282828;
|
|
border-radius: 8px;
|
|
border: 1px solid #404040;
|
|
}
|
|
PlaylistItem:hover {
|
|
background: #333333;
|
|
border: 1px solid #1db954;
|
|
}
|
|
""")
|
|
|
|
layout = QHBoxLayout(self)
|
|
layout.setContentsMargins(20, 15, 20, 15)
|
|
layout.setSpacing(15)
|
|
|
|
# Checkbox
|
|
self.checkbox = QCheckBox()
|
|
self.checkbox.setStyleSheet("""
|
|
QCheckBox::indicator {
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 9px;
|
|
border: 2px solid #b3b3b3;
|
|
background: transparent;
|
|
}
|
|
QCheckBox::indicator:checked {
|
|
background: #1db954;
|
|
border: 2px solid #1db954;
|
|
}
|
|
QCheckBox::indicator:checked:hover {
|
|
background: #1ed760;
|
|
}
|
|
""")
|
|
|
|
# Content layout
|
|
content_layout = QVBoxLayout()
|
|
content_layout.setSpacing(5)
|
|
|
|
# Playlist name
|
|
name_label = QLabel(self.name)
|
|
name_label.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
|
name_label.setStyleSheet("color: #ffffff;")
|
|
|
|
# Track count and status
|
|
info_layout = QHBoxLayout()
|
|
info_layout.setSpacing(20)
|
|
|
|
track_label = QLabel(f"{self.track_count} tracks")
|
|
track_label.setFont(QFont("Arial", 10))
|
|
track_label.setStyleSheet("color: #b3b3b3;")
|
|
|
|
status_label = QLabel(self.sync_status)
|
|
status_label.setFont(QFont("Arial", 10))
|
|
if self.sync_status == "Synced":
|
|
status_label.setStyleSheet("color: #1db954;")
|
|
elif self.sync_status == "Needs Sync":
|
|
status_label.setStyleSheet("color: #ffa500;")
|
|
else:
|
|
status_label.setStyleSheet("color: #e22134;")
|
|
|
|
info_layout.addWidget(track_label)
|
|
info_layout.addWidget(status_label)
|
|
info_layout.addStretch()
|
|
|
|
content_layout.addWidget(name_label)
|
|
content_layout.addLayout(info_layout)
|
|
|
|
# Action button
|
|
action_btn = QPushButton("View Details")
|
|
action_btn.setFixedSize(100, 30)
|
|
action_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: transparent;
|
|
border: 1px solid #1db954;
|
|
border-radius: 15px;
|
|
color: #1db954;
|
|
font-size: 10px;
|
|
font-weight: bold;
|
|
}
|
|
QPushButton:hover {
|
|
background: #1db954;
|
|
color: #000000;
|
|
}
|
|
""")
|
|
|
|
layout.addWidget(self.checkbox)
|
|
layout.addLayout(content_layout)
|
|
layout.addStretch()
|
|
layout.addWidget(action_btn)
|
|
|
|
def mousePressEvent(self, event):
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
self.checkbox.setChecked(not self.checkbox.isChecked())
|
|
super().mousePressEvent(event)
|
|
|
|
class SyncOptionsPanel(QFrame):
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setup_ui()
|
|
|
|
def setup_ui(self):
|
|
self.setStyleSheet("""
|
|
SyncOptionsPanel {
|
|
background: #282828;
|
|
border-radius: 8px;
|
|
border: 1px solid #404040;
|
|
}
|
|
""")
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(20, 20, 20, 20)
|
|
layout.setSpacing(15)
|
|
|
|
# Title
|
|
title_label = QLabel("Sync Options")
|
|
title_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
|
title_label.setStyleSheet("color: #ffffff;")
|
|
|
|
# Download missing tracks option
|
|
self.download_missing = QCheckBox("Download missing tracks from Soulseek")
|
|
self.download_missing.setChecked(True)
|
|
self.download_missing.setStyleSheet("""
|
|
QCheckBox {
|
|
color: #ffffff;
|
|
font-size: 11px;
|
|
}
|
|
QCheckBox::indicator {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 8px;
|
|
border: 2px solid #b3b3b3;
|
|
background: transparent;
|
|
}
|
|
QCheckBox::indicator:checked {
|
|
background: #1db954;
|
|
border: 2px solid #1db954;
|
|
}
|
|
""")
|
|
|
|
# Quality selection
|
|
quality_layout = QHBoxLayout()
|
|
quality_label = QLabel("Preferred Quality:")
|
|
quality_label.setStyleSheet("color: #b3b3b3; font-size: 11px;")
|
|
|
|
self.quality_combo = QComboBox()
|
|
self.quality_combo.addItems(["FLAC", "320 kbps MP3", "256 kbps MP3", "Any"])
|
|
self.quality_combo.setCurrentText("FLAC")
|
|
self.quality_combo.setStyleSheet("""
|
|
QComboBox {
|
|
background: #404040;
|
|
border: 1px solid #606060;
|
|
border-radius: 4px;
|
|
padding: 5px;
|
|
color: #ffffff;
|
|
font-size: 11px;
|
|
}
|
|
QComboBox::drop-down {
|
|
border: none;
|
|
}
|
|
QComboBox::down-arrow {
|
|
image: none;
|
|
border: none;
|
|
}
|
|
""")
|
|
|
|
quality_layout.addWidget(quality_label)
|
|
quality_layout.addWidget(self.quality_combo)
|
|
quality_layout.addStretch()
|
|
|
|
layout.addWidget(title_label)
|
|
layout.addWidget(self.download_missing)
|
|
layout.addLayout(quality_layout)
|
|
|
|
class SyncPage(QWidget):
|
|
def __init__(self, spotify_client=None, plex_client=None, parent=None):
|
|
super().__init__(parent)
|
|
self.spotify_client = spotify_client
|
|
self.plex_client = plex_client
|
|
self.current_playlists = []
|
|
self.playlist_loader = None
|
|
self.setup_ui()
|
|
|
|
# Start loading playlists asynchronously after UI is ready
|
|
QTimer.singleShot(100, self.load_playlists_async)
|
|
|
|
def setup_ui(self):
|
|
self.setStyleSheet("""
|
|
SyncPage {
|
|
background: #191414;
|
|
}
|
|
""")
|
|
|
|
main_layout = QVBoxLayout(self)
|
|
main_layout.setContentsMargins(30, 30, 30, 30)
|
|
main_layout.setSpacing(25)
|
|
|
|
# Header
|
|
header = self.create_header()
|
|
main_layout.addWidget(header)
|
|
|
|
# Content area
|
|
content_layout = QHBoxLayout()
|
|
content_layout.setSpacing(25)
|
|
|
|
# Left side - Playlist list
|
|
playlist_section = self.create_playlist_section()
|
|
content_layout.addWidget(playlist_section, 2)
|
|
|
|
# Right side - Options and actions
|
|
options_section = self.create_options_section()
|
|
content_layout.addWidget(options_section, 1)
|
|
|
|
main_layout.addLayout(content_layout)
|
|
|
|
# Progress section
|
|
progress_section = self.create_progress_section()
|
|
main_layout.addWidget(progress_section)
|
|
|
|
def create_header(self):
|
|
header = QWidget()
|
|
layout = QVBoxLayout(header)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(5)
|
|
|
|
# Title
|
|
title_label = QLabel("Playlist Sync")
|
|
title_label.setFont(QFont("Arial", 28, QFont.Weight.Bold))
|
|
title_label.setStyleSheet("color: #ffffff;")
|
|
|
|
# Subtitle
|
|
subtitle_label = QLabel("Synchronize your Spotify playlists with Plex")
|
|
subtitle_label.setFont(QFont("Arial", 14))
|
|
subtitle_label.setStyleSheet("color: #b3b3b3;")
|
|
|
|
layout.addWidget(title_label)
|
|
layout.addWidget(subtitle_label)
|
|
|
|
return header
|
|
|
|
def create_playlist_section(self):
|
|
section = QWidget()
|
|
layout = QVBoxLayout(section)
|
|
layout.setSpacing(15)
|
|
|
|
# Section header
|
|
header_layout = QHBoxLayout()
|
|
|
|
section_title = QLabel("Spotify Playlists")
|
|
section_title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
|
section_title.setStyleSheet("color: #ffffff;")
|
|
|
|
self.refresh_btn = QPushButton("🔄 Refresh")
|
|
self.refresh_btn.setFixedSize(100, 35)
|
|
self.refresh_btn.clicked.connect(self.refresh_playlists)
|
|
self.refresh_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: #1db954;
|
|
border: none;
|
|
border-radius: 17px;
|
|
color: #000000;
|
|
font-size: 11px;
|
|
font-weight: bold;
|
|
}
|
|
QPushButton:hover {
|
|
background: #1ed760;
|
|
}
|
|
QPushButton:pressed {
|
|
background: #1aa34a;
|
|
}
|
|
""")
|
|
|
|
header_layout.addWidget(section_title)
|
|
header_layout.addStretch()
|
|
header_layout.addWidget(self.refresh_btn)
|
|
|
|
# Playlist container
|
|
playlist_container = QScrollArea()
|
|
playlist_container.setWidgetResizable(True)
|
|
playlist_container.setStyleSheet("""
|
|
QScrollArea {
|
|
border: none;
|
|
background: transparent;
|
|
}
|
|
QScrollBar:vertical {
|
|
background: #282828;
|
|
width: 8px;
|
|
border-radius: 4px;
|
|
}
|
|
QScrollBar::handle:vertical {
|
|
background: #1db954;
|
|
border-radius: 4px;
|
|
}
|
|
""")
|
|
|
|
self.playlist_widget = QWidget()
|
|
self.playlist_layout = QVBoxLayout(self.playlist_widget)
|
|
self.playlist_layout.setSpacing(10)
|
|
|
|
# Playlists will be loaded asynchronously after UI setup
|
|
|
|
self.playlist_layout.addStretch()
|
|
playlist_container.setWidget(self.playlist_widget)
|
|
|
|
layout.addLayout(header_layout)
|
|
layout.addWidget(playlist_container)
|
|
|
|
return section
|
|
|
|
def create_options_section(self):
|
|
section = QWidget()
|
|
layout = QVBoxLayout(section)
|
|
layout.setSpacing(20)
|
|
|
|
# Sync options
|
|
options_panel = SyncOptionsPanel()
|
|
layout.addWidget(options_panel)
|
|
|
|
# Action buttons
|
|
actions_frame = QFrame()
|
|
actions_frame.setStyleSheet("""
|
|
QFrame {
|
|
background: #282828;
|
|
border-radius: 8px;
|
|
border: 1px solid #404040;
|
|
}
|
|
""")
|
|
|
|
actions_layout = QVBoxLayout(actions_frame)
|
|
actions_layout.setContentsMargins(20, 20, 20, 20)
|
|
actions_layout.setSpacing(15)
|
|
|
|
# Sync button
|
|
sync_btn = QPushButton("Start Sync")
|
|
sync_btn.setFixedHeight(45)
|
|
sync_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: #1db954;
|
|
border: none;
|
|
border-radius: 22px;
|
|
color: #000000;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
}
|
|
QPushButton:hover {
|
|
background: #1ed760;
|
|
}
|
|
QPushButton:pressed {
|
|
background: #1aa34a;
|
|
}
|
|
""")
|
|
|
|
# Preview button
|
|
preview_btn = QPushButton("Preview Changes")
|
|
preview_btn.setFixedHeight(35)
|
|
preview_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background: transparent;
|
|
border: 1px solid #1db954;
|
|
border-radius: 17px;
|
|
color: #1db954;
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
}
|
|
QPushButton:hover {
|
|
background: rgba(29, 185, 84, 0.1);
|
|
}
|
|
""")
|
|
|
|
actions_layout.addWidget(sync_btn)
|
|
actions_layout.addWidget(preview_btn)
|
|
|
|
layout.addWidget(actions_frame)
|
|
layout.addStretch()
|
|
|
|
return section
|
|
|
|
def create_progress_section(self):
|
|
section = QFrame()
|
|
section.setFixedHeight(150)
|
|
section.setStyleSheet("""
|
|
QFrame {
|
|
background: #282828;
|
|
border-radius: 8px;
|
|
border: 1px solid #404040;
|
|
}
|
|
""")
|
|
|
|
layout = QVBoxLayout(section)
|
|
layout.setContentsMargins(20, 15, 20, 15)
|
|
layout.setSpacing(10)
|
|
|
|
# Progress header
|
|
progress_header = QLabel("Sync Progress")
|
|
progress_header.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
|
progress_header.setStyleSheet("color: #ffffff;")
|
|
|
|
# Progress bar
|
|
self.progress_bar = QProgressBar()
|
|
self.progress_bar.setFixedHeight(8)
|
|
self.progress_bar.setStyleSheet("""
|
|
QProgressBar {
|
|
border: none;
|
|
border-radius: 4px;
|
|
background: #404040;
|
|
}
|
|
QProgressBar::chunk {
|
|
background: #1db954;
|
|
border-radius: 4px;
|
|
}
|
|
""")
|
|
|
|
# Progress text
|
|
self.progress_text = QLabel("Ready to sync...")
|
|
self.progress_text.setFont(QFont("Arial", 11))
|
|
self.progress_text.setStyleSheet("color: #b3b3b3;")
|
|
|
|
# Log area
|
|
self.log_area = QTextEdit()
|
|
self.log_area.setMaximumHeight(60)
|
|
self.log_area.setStyleSheet("""
|
|
QTextEdit {
|
|
background: #181818;
|
|
border: 1px solid #404040;
|
|
border-radius: 4px;
|
|
color: #ffffff;
|
|
font-size: 10px;
|
|
font-family: monospace;
|
|
}
|
|
""")
|
|
self.log_area.setPlainText("Waiting for sync to start...")
|
|
|
|
layout.addWidget(progress_header)
|
|
layout.addWidget(self.progress_bar)
|
|
layout.addWidget(self.progress_text)
|
|
layout.addWidget(self.log_area)
|
|
|
|
return section
|
|
|
|
def load_playlists_async(self):
|
|
"""Start asynchronous playlist loading"""
|
|
if self.playlist_loader and self.playlist_loader.isRunning():
|
|
return
|
|
|
|
# Clear existing playlists
|
|
self.clear_playlists()
|
|
|
|
# Show loading state
|
|
self.refresh_btn.setText("🔄 Loading...")
|
|
self.refresh_btn.setEnabled(False)
|
|
self.log_area.append("Starting playlist loading...")
|
|
|
|
# Create and start loader thread
|
|
self.playlist_loader = PlaylistLoaderThread(self.spotify_client)
|
|
self.playlist_loader.playlist_loaded.connect(self.add_playlist_to_ui)
|
|
self.playlist_loader.loading_finished.connect(self.on_loading_finished)
|
|
self.playlist_loader.loading_failed.connect(self.on_loading_failed)
|
|
self.playlist_loader.progress_updated.connect(self.update_progress)
|
|
self.playlist_loader.start()
|
|
|
|
def add_playlist_to_ui(self, playlist):
|
|
"""Add a single playlist to the UI as it's loaded"""
|
|
# Simple sync status (placeholder for now)
|
|
sync_status = "Never Synced" # TODO: Check actual sync status
|
|
item = PlaylistItem(playlist.name, playlist.total_tracks, sync_status)
|
|
# Insert before the stretch item
|
|
self.playlist_layout.insertWidget(self.playlist_layout.count() - 1, item)
|
|
self.current_playlists.append(playlist)
|
|
|
|
# Update log
|
|
self.log_area.append(f"Added playlist: {playlist.name} ({playlist.total_tracks} tracks)")
|
|
|
|
def on_loading_finished(self, count):
|
|
"""Handle completion of playlist loading"""
|
|
self.refresh_btn.setText("🔄 Refresh")
|
|
self.refresh_btn.setEnabled(True)
|
|
self.log_area.append(f"✓ Loaded {count} Spotify playlists successfully")
|
|
|
|
def on_loading_failed(self, error_msg):
|
|
"""Handle playlist loading failure"""
|
|
self.refresh_btn.setText("🔄 Refresh")
|
|
self.refresh_btn.setEnabled(True)
|
|
self.log_area.append(f"✗ Failed to load playlists: {error_msg}")
|
|
QMessageBox.critical(self, "Error", f"Failed to load playlists: {error_msg}")
|
|
|
|
def update_progress(self, message):
|
|
"""Update progress text"""
|
|
self.log_area.append(message)
|
|
|
|
def load_initial_playlists(self):
|
|
"""Load initial playlist data (placeholder or real)"""
|
|
if self.spotify_client and self.spotify_client.is_authenticated():
|
|
self.refresh_playlists()
|
|
else:
|
|
# Show placeholder playlists
|
|
playlists = [
|
|
("Liked Songs", 247, "Synced"),
|
|
("Discover Weekly", 30, "Needs Sync"),
|
|
("Chill Vibes", 89, "Synced"),
|
|
("Workout Mix", 156, "Needs Sync"),
|
|
("Road Trip", 67, "Never Synced"),
|
|
("Focus Music", 45, "Synced")
|
|
]
|
|
|
|
for name, count, status in playlists:
|
|
item = PlaylistItem(name, count, status)
|
|
self.playlist_layout.addWidget(item)
|
|
|
|
def refresh_playlists(self):
|
|
"""Refresh playlists from Spotify API using async loader"""
|
|
if not self.spotify_client:
|
|
QMessageBox.warning(self, "Error", "Spotify client not available")
|
|
return
|
|
|
|
if not self.spotify_client.is_authenticated():
|
|
QMessageBox.warning(self, "Error", "Spotify not authenticated. Please check your settings.")
|
|
return
|
|
|
|
# Use the async loader
|
|
self.load_playlists_async()
|
|
|
|
def clear_playlists(self):
|
|
"""Clear all playlist items from the layout"""
|
|
# Clear the current playlists list
|
|
self.current_playlists = []
|
|
|
|
# Remove all items except the stretch
|
|
for i in reversed(range(self.playlist_layout.count())):
|
|
item = self.playlist_layout.itemAt(i)
|
|
if item.widget():
|
|
item.widget().deleteLater()
|
|
elif item.spacerItem():
|
|
continue # Keep the stretch spacer
|
|
else:
|
|
self.playlist_layout.removeItem(item) |