#!/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()