Add desktop GUI application with system tray support
- Created PySide6-based desktop application - Added system tray integration for Windows and Linux - Built-in log viewer and configuration wizard - Easy setup and automatic playlist updates - Cross-platform compatibility - Updated README with comprehensive instructions
This commit is contained in:
1 parent
345c40f40d
commit
ce5175d8db
8 files changed
+760
-29
No files matched your search
@@ -0,0 +1,500 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Journey FM Playlist Creator - Desktop Application
|
||||
|
||||
A cross-platform desktop application for automating Journey FM playlist updates.
|
||||
Features system tray integration, log viewing, and easy setup.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# GUI imports
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLabel, QPushButton, QTextEdit, QLineEdit, QFormLayout,
|
||||
QDialog, QDialogButtonBox, QSystemTrayIcon, QMenu,
|
||||
QGroupBox, QCheckBox, QSpinBox, QComboBox, QMessageBox,
|
||||
QProgressBar, QSplitter, QFrame, QScrollArea
|
||||
)
|
||||
from PySide6.QtCore import (
|
||||
Qt, QTimer, QThread, Signal, QSettings, QSize
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QIcon, QAction, QFont, QPixmap, QPainter, QColor
|
||||
)
|
||||
|
||||
# Import our existing functionality
|
||||
from main import scrape_recently_played, create_playlist_in_plex, PLEX_TOKEN, SERVER_IP, PLAYLIST_NAME
|
||||
|
||||
class Config:
|
||||
"""Configuration management"""
|
||||
def __init__(self):
|
||||
self.settings = QSettings('JourneyFM', 'PlaylistCreator')
|
||||
self.config_file = Path('config.json')
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self.settings.value(key, default)
|
||||
|
||||
def set(self, key, value):
|
||||
self.settings.setValue(key, value)
|
||||
self.settings.sync()
|
||||
|
||||
def load_config(self):
|
||||
"""Load config from JSON file for backward compatibility"""
|
||||
if self.config_file.exists():
|
||||
try:
|
||||
with open(self.config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
# Migrate to QSettings
|
||||
for key, value in config.items():
|
||||
self.set(key, value)
|
||||
# Remove old file
|
||||
self.config_file.unlink()
|
||||
return config
|
||||
except:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def save_config(self, config):
|
||||
"""Save config to QSettings"""
|
||||
for key, value in config.items():
|
||||
self.set(key, value)
|
||||
|
||||
class SetupWizard(QDialog):
|
||||
"""Setup wizard for initial configuration"""
|
||||
def __init__(self, config, parent=None):
|
||||
super().__init__(parent)
|
||||
self.config = config
|
||||
self.setWindowTitle("Journey FM Setup")
|
||||
self.setModal(True)
|
||||
self.resize(500, 400)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Welcome message
|
||||
welcome = QLabel("Welcome to Journey FM Playlist Creator!")
|
||||
welcome.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
||||
layout.addWidget(welcome)
|
||||
|
||||
desc = QLabel("This wizard will help you set up automatic playlist updates from Journey FM.")
|
||||
desc.setWordWrap(True)
|
||||
layout.addWidget(desc)
|
||||
|
||||
# Configuration form
|
||||
form_group = QGroupBox("Plex Configuration")
|
||||
form_layout = QFormLayout()
|
||||
|
||||
self.token_input = QLineEdit()
|
||||
self.token_input.setPlaceholderText("Get this from https://plex.tv/claim")
|
||||
form_layout.addRow("Plex Token:", self.token_input)
|
||||
|
||||
self.server_input = QLineEdit()
|
||||
self.server_input.setPlaceholderText("e.g., 192.168.1.100")
|
||||
form_layout.addRow("Server IP:", self.server_input)
|
||||
|
||||
self.playlist_input = QLineEdit("Journey FM Recently Played")
|
||||
form_layout.addRow("Playlist Name:", self.playlist_input)
|
||||
|
||||
form_group.setLayout(form_layout)
|
||||
layout.addWidget(form_group)
|
||||
|
||||
# Scheduling options
|
||||
sched_group = QGroupBox("Scheduling")
|
||||
sched_layout = QVBoxLayout()
|
||||
|
||||
self.auto_update = QCheckBox("Enable automatic updates")
|
||||
self.auto_update.setChecked(True)
|
||||
sched_layout.addWidget(self.auto_update)
|
||||
|
||||
interval_layout = QHBoxLayout()
|
||||
interval_layout.addWidget(QLabel("Update every"))
|
||||
self.interval_spin = QSpinBox()
|
||||
self.interval_spin.setValue(15)
|
||||
self.interval_spin.setRange(5, 120)
|
||||
interval_layout.addWidget(self.interval_spin)
|
||||
self.interval_unit = QComboBox()
|
||||
self.interval_unit.addItems(["Minutes", "Hours"])
|
||||
interval_layout.addWidget(self.interval_unit)
|
||||
interval_layout.addStretch()
|
||||
sched_layout.addLayout(interval_layout)
|
||||
|
||||
sched_group.setLayout(sched_layout)
|
||||
layout.addWidget(sched_group)
|
||||
|
||||
# Buttons
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
# Load existing config
|
||||
self.token_input.setText(config.get('PLEX_TOKEN', ''))
|
||||
self.server_input.setText(config.get('SERVER_IP', ''))
|
||||
self.playlist_input.setText(config.get('PLAYLIST_NAME', 'Journey FM Recently Played'))
|
||||
|
||||
def get_config(self):
|
||||
return {
|
||||
'PLEX_TOKEN': self.token_input.text().strip(),
|
||||
'SERVER_IP': self.server_input.text().strip(),
|
||||
'PLAYLIST_NAME': self.playlist_input.text().strip(),
|
||||
'AUTO_UPDATE': self.auto_update.isChecked(),
|
||||
'UPDATE_INTERVAL': self.interval_spin.value(),
|
||||
'UPDATE_UNIT': self.interval_unit.currentText()
|
||||
}
|
||||
|
||||
class LogViewer(QWidget):
|
||||
"""Widget for viewing application logs"""
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Header
|
||||
header = QHBoxLayout()
|
||||
header.addWidget(QLabel("Application Logs"))
|
||||
clear_btn = QPushButton("Clear")
|
||||
clear_btn.clicked.connect(self.clear_logs)
|
||||
header.addWidget(clear_btn)
|
||||
header.addStretch()
|
||||
layout.addLayout(header)
|
||||
|
||||
# Log text area
|
||||
self.log_text = QTextEdit()
|
||||
self.log_text.setReadOnly(True)
|
||||
self.log_text.setFont(QFont("Consolas", 9))
|
||||
layout.addWidget(self.log_text)
|
||||
|
||||
# Load existing logs
|
||||
self.load_logs()
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def load_logs(self):
|
||||
"""Load logs from file"""
|
||||
log_file = Path('playlist_log.txt')
|
||||
if log_file.exists():
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
self.log_text.setPlainText(content)
|
||||
# Scroll to bottom
|
||||
cursor = self.log_text.textCursor()
|
||||
cursor.movePosition(cursor.MoveOperation.End)
|
||||
self.log_text.setTextCursor(cursor)
|
||||
except Exception as e:
|
||||
self.log_text.setPlainText(f"Error loading logs: {e}")
|
||||
|
||||
def clear_logs(self):
|
||||
"""Clear the log file"""
|
||||
try:
|
||||
with open('playlist_log.txt', 'w', encoding='utf-8') as f:
|
||||
f.write("")
|
||||
self.log_text.clear()
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Error", f"Failed to clear logs: {e}")
|
||||
|
||||
def refresh_logs(self):
|
||||
"""Refresh the log display"""
|
||||
self.load_logs()
|
||||
|
||||
class UpdateWorker(QThread):
|
||||
"""Worker thread for running playlist updates"""
|
||||
finished = Signal(str)
|
||||
progress = Signal(str)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.progress.emit("Starting playlist update...")
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from main import main as update_main
|
||||
|
||||
# Capture output by redirecting stdout
|
||||
import io
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
output_buffer = io.StringIO()
|
||||
with redirect_stdout(output_buffer):
|
||||
update_main()
|
||||
|
||||
result = output_buffer.getvalue()
|
||||
self.finished.emit(result)
|
||||
|
||||
except Exception as e:
|
||||
self.finished.emit(f"Error: {str(e)}")
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Main application window"""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.tray_icon = None # Will be set later
|
||||
self.config = Config()
|
||||
self.config.load_config()
|
||||
|
||||
self.setWindowTitle("Journey FM Playlist Creator")
|
||||
self.setWindowIcon(QIcon("icon.png")) # You'll need to add an icon
|
||||
self.resize(800, 600)
|
||||
|
||||
# Create central widget
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
layout = QVBoxLayout(central)
|
||||
|
||||
# Toolbar
|
||||
toolbar = self.addToolBar("Main")
|
||||
toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
|
||||
|
||||
update_action = QAction("Update Now", self)
|
||||
update_action.triggered.connect(self.manual_update)
|
||||
toolbar.addAction(update_action)
|
||||
|
||||
settings_action = QAction("Settings", self)
|
||||
settings_action.triggered.connect(self.show_settings)
|
||||
toolbar.addAction(settings_action)
|
||||
|
||||
# Progress bar
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setVisible(False)
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
# Splitter for main content
|
||||
splitter = QSplitter(Qt.Orientation.Vertical)
|
||||
|
||||
# Status section
|
||||
status_group = QGroupBox("Status")
|
||||
status_layout = QVBoxLayout()
|
||||
|
||||
self.status_label = QLabel("Ready")
|
||||
status_layout.addWidget(self.status_label)
|
||||
|
||||
self.last_update_label = QLabel("Last update: Never")
|
||||
status_layout.addWidget(self.last_update_label)
|
||||
|
||||
status_group.setLayout(status_layout)
|
||||
splitter.addWidget(status_group)
|
||||
|
||||
# Log viewer
|
||||
self.log_viewer = LogViewer()
|
||||
splitter.addWidget(self.log_viewer)
|
||||
|
||||
splitter.setSizes([100, 400])
|
||||
layout.addWidget(splitter)
|
||||
|
||||
# Setup timer for automatic updates
|
||||
self.update_timer = QTimer()
|
||||
self.update_timer.timeout.connect(self.auto_update)
|
||||
|
||||
# Load settings and start auto-update if enabled
|
||||
self.load_settings()
|
||||
|
||||
# Check if first run
|
||||
if not self.config.get('PLEX_TOKEN'):
|
||||
self.show_setup_wizard()
|
||||
|
||||
def set_tray_icon(self, tray_icon):
|
||||
"""Set the tray icon reference"""
|
||||
self.tray_icon = tray_icon
|
||||
|
||||
def show_setup_wizard(self):
|
||||
"""Show the setup wizard"""
|
||||
wizard = SetupWizard(self.config, self)
|
||||
if wizard.exec() == QDialog.DialogCode.Accepted:
|
||||
config = wizard.get_config()
|
||||
self.config.save_config(config)
|
||||
self.load_settings()
|
||||
QMessageBox.information(self, "Setup Complete",
|
||||
"Configuration saved! The app will now update your playlist automatically.")
|
||||
|
||||
def load_settings(self):
|
||||
"""Load settings and configure auto-update"""
|
||||
auto_update = self.config.get('AUTO_UPDATE', False)
|
||||
if auto_update:
|
||||
interval = self.config.get('UPDATE_INTERVAL', 15)
|
||||
unit = self.config.get('UPDATE_UNIT', 'Minutes')
|
||||
|
||||
# Convert to milliseconds
|
||||
if unit == 'Hours':
|
||||
interval_ms = interval * 60 * 60 * 1000
|
||||
else:
|
||||
interval_ms = interval * 60 * 1000
|
||||
|
||||
self.update_timer.start(interval_ms)
|
||||
self.status_label.setText(f"Auto-update enabled (every {interval} {unit.lower()})")
|
||||
else:
|
||||
self.update_timer.stop()
|
||||
self.status_label.setText("Auto-update disabled")
|
||||
|
||||
def manual_update(self):
|
||||
"""Manually trigger playlist update"""
|
||||
if hasattr(self, 'worker') and self.worker.isRunning():
|
||||
QMessageBox.information(self, "Update in Progress",
|
||||
"An update is already running. Please wait.")
|
||||
return
|
||||
|
||||
self.worker = UpdateWorker()
|
||||
self.worker.progress.connect(self.update_progress)
|
||||
self.worker.finished.connect(self.update_finished)
|
||||
self.worker.start()
|
||||
|
||||
self.progress_bar.setVisible(True)
|
||||
self.progress_bar.setRange(0, 0) # Indeterminate progress
|
||||
self.status_label.setText("Updating playlist...")
|
||||
|
||||
def update_progress(self, message):
|
||||
"""Update progress display"""
|
||||
self.status_label.setText(message)
|
||||
|
||||
def update_finished(self, result):
|
||||
"""Handle update completion"""
|
||||
self.progress_bar.setVisible(False)
|
||||
self.last_update_label.setText(f"Last update: {datetime.now().strftime('%Y-%m-%d %I:%M:%S %p')}")
|
||||
self.status_label.setText("Ready")
|
||||
|
||||
# Refresh logs
|
||||
self.log_viewer.refresh_logs()
|
||||
|
||||
# Show result in status
|
||||
if "Error" in result:
|
||||
QMessageBox.warning(self, "Update Error", result)
|
||||
else:
|
||||
# Extract summary from result
|
||||
lines = result.strip().split('\n')
|
||||
for line in reversed(lines):
|
||||
if line.startswith("Added") or line.startswith("No new songs") or line.startswith("No matching"):
|
||||
self.status_label.setText(line)
|
||||
break
|
||||
|
||||
def auto_update(self):
|
||||
"""Automatic update (runs in background)"""
|
||||
if hasattr(self, 'worker') and self.worker.isRunning():
|
||||
return # Skip if already running
|
||||
|
||||
self.worker = UpdateWorker()
|
||||
self.worker.finished.connect(self.auto_update_finished)
|
||||
self.worker.start()
|
||||
|
||||
def auto_update_finished(self, result):
|
||||
"""Handle automatic update completion"""
|
||||
self.last_update_label.setText(f"Last update: {datetime.now().strftime('%Y-%m-%d %I:%M:%S %p')}")
|
||||
|
||||
# Refresh logs
|
||||
self.log_viewer.refresh_logs()
|
||||
|
||||
# Show tray notification if minimized
|
||||
if self.isMinimized() or not self.isVisible():
|
||||
if self.tray_icon:
|
||||
self.tray_icon.showMessage(
|
||||
"Journey FM Update",
|
||||
"Playlist updated successfully",
|
||||
QSystemTrayIcon.MessageIcon.Information,
|
||||
3000
|
||||
)
|
||||
|
||||
def show_settings(self):
|
||||
"""Show settings dialog"""
|
||||
wizard = SetupWizard(self.config, self)
|
||||
if wizard.exec() == QDialog.DialogCode.Accepted:
|
||||
config = wizard.get_config()
|
||||
self.config.save_config(config)
|
||||
self.load_settings()
|
||||
QMessageBox.information(self, "Settings Saved", "Configuration updated successfully!")
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle window close - minimize to tray instead of closing"""
|
||||
if self.tray_icon and self.tray_icon.isVisible():
|
||||
self.hide()
|
||||
self.tray_icon.showMessage(
|
||||
"Journey FM",
|
||||
"Application minimized to system tray",
|
||||
QSystemTrayIcon.MessageIcon.Information,
|
||||
2000
|
||||
)
|
||||
event.ignore()
|
||||
else:
|
||||
event.accept()
|
||||
|
||||
class SystemTrayApp:
|
||||
"""System tray application wrapper"""
|
||||
def __init__(self):
|
||||
self.app = QApplication(sys.argv)
|
||||
self.app.setApplicationName("Journey FM Playlist Creator")
|
||||
self.app.setApplicationVersion("1.0")
|
||||
self.app.setOrganizationName("JourneyFM")
|
||||
|
||||
# Create main window first
|
||||
self.main_window = MainWindow()
|
||||
|
||||
# Create system tray
|
||||
self.create_tray_icon()
|
||||
|
||||
# Set tray icon reference in main window
|
||||
self.main_window.set_tray_icon(self.tray_icon)
|
||||
|
||||
# Show window initially
|
||||
self.main_window.show()
|
||||
|
||||
def create_tray_icon(self):
|
||||
"""Create system tray icon"""
|
||||
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||
QMessageBox.critical(None, "System Tray",
|
||||
"System tray not available on this system")
|
||||
return
|
||||
|
||||
self.tray_icon = QSystemTrayIcon(self.main_window)
|
||||
self.tray_icon.setIcon(self.main_window.windowIcon())
|
||||
|
||||
# Create tray menu
|
||||
tray_menu = QMenu()
|
||||
|
||||
show_action = QAction("Show", self.main_window)
|
||||
show_action.triggered.connect(self.main_window.show)
|
||||
tray_menu.addAction(show_action)
|
||||
|
||||
update_action = QAction("Update Now", self.main_window)
|
||||
update_action.triggered.connect(self.main_window.manual_update)
|
||||
tray_menu.addAction(update_action)
|
||||
|
||||
tray_menu.addSeparator()
|
||||
|
||||
quit_action = QAction("Quit", self.main_window)
|
||||
quit_action.triggered.connect(self.app.quit)
|
||||
tray_menu.addAction(quit_action)
|
||||
|
||||
self.tray_icon.setContextMenu(tray_menu)
|
||||
self.tray_icon.activated.connect(self.tray_activated)
|
||||
|
||||
self.tray_icon.show()
|
||||
|
||||
def tray_activated(self, reason):
|
||||
"""Handle tray icon activation"""
|
||||
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
|
||||
self.main_window.show()
|
||||
self.main_window.raise_()
|
||||
self.main_window.activateWindow()
|
||||
|
||||
def run(self):
|
||||
"""Run the application"""
|
||||
return self.app.exec()
|
||||
|
||||
def main():
|
||||
"""Main application entry point"""
|
||||
# Set up high DPI scaling
|
||||
QApplication.setHighDpiScaleFactorRoundingPolicy(
|
||||
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
||||
)
|
||||
|
||||
# Create and run application
|
||||
tray_app = SystemTrayApp()
|
||||
sys.exit(tray_app.run())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user