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:
Nate Emmert committed 2025-11-17 08:08:20 -05:00
1 parent 345c40f40d
commit ce5175d8db
8 files changed
+760 -29

No files matched your search

+195 -18
View File
@@ -1,28 +1,205 @@
# Journey FM Playlist Creator # Journey FM Playlist Creator
This script automates the process of creating a Plex playlist from recently played songs on My Journey FM radio station. A cross-platform desktop application that automatically creates Plex playlists from recently played songs on My Journey FM radio station.
## Features ![Application Screenshot](screenshot.png)
- Scrapes recently played songs from https://www.myjourneyfm.com/recently-played/ ## ✨ Features
- Checks your local music library for matching songs
- Creates a playlist in Plex with the found songs
## Setup - **System Tray Integration**: Runs minimized in the taskbar/system tray
- **Automatic Updates**: Configurable automatic playlist updates
- **Cross-Platform**: Works on Windows and Linux
- **Log Viewer**: Built-in log viewer for monitoring updates
- **Easy Setup**: Guided setup wizard for initial configuration
- **Duplicate Prevention**: Smart duplicate detection and prevention
- **Artist Matching**: Handles featured artists, punctuation, and name variations
1. Install dependencies: `pip install -r requirements.txt` ## 🚀 Quick Start
2. Configure the script:
- Set `PLEX_URL` to your Plex server URL
- Set `PLEX_TOKEN` to your Plex authentication token
- Set `MUSIC_LIBRARY_PATH` to the path of your music library
3. Run the script: `python main.py`
## Requirements ### Windows
- Python 3.6+ 1. **Install Dependencies**
- Plex server ```bash
- Local music library with ID3 tags pip install -r requirements.txt
```
## Note 2. **Create Application Icon**
```bash
python create_icon.py
```
The HTML scraping part may need adjustment based on the actual structure of the website. Inspect the page to find the correct selectors for artist and title. 3. **Run the Application**
```bash
python journey_fm_app.py
```
Or use the batch file: `run_app.bat`
### Linux
1. **Install Dependencies**
```bash
pip install -r requirements.txt
```
2. **Make Scripts Executable**
```bash
chmod +x journey_fm_app.sh
```
3. **Run the Application**
```bash
./journey_fm_app.sh
```
## ⚙️ Configuration
### First-Time Setup
When you first run the application, a setup wizard will guide you through:
1. **Plex Token**: Get this from https://plex.tv/claim
2. **Server IP**: Your Plex server's local IP address
3. **Playlist Name**: Name for your Journey FM playlist
4. **Auto-Update Settings**: Enable automatic updates and set intervals
### Manual Configuration
You can also edit settings anytime through the Settings menu in the application.
## 📱 System Tray Features
- **Minimize to Tray**: The app minimizes to system tray instead of closing
- **Right-click Menu**:
- Show: Restore the main window
- Update Now: Manually trigger playlist update
- Quit: Exit the application
- **Double-click**: Restore the main window
- **Notifications**: Get notified when automatic updates complete
## 📋 Log Viewer
The built-in log viewer shows:
- Update timestamps
- Songs added to playlist
- Error messages
- Update duration
- Connection status
Logs are automatically saved to `playlist_log.txt` for external viewing.
## 🔄 Automatic Updates
Configure automatic updates to run every:
- 5-120 minutes
- Hours (up to several hours)
The app will:
- Run in the background
- Show tray notifications when updates complete
- Prevent duplicate songs
- Handle connection errors gracefully
## 🛠️ Troubleshooting
### Common Issues
**"Server not found"**
- Ensure your Plex server is running
- Check that you've claimed the server at http://[SERVER_IP]:32400/web
- Verify the server IP address in settings
**"No matching tracks found"**
- Ensure your music library is properly scanned in Plex
- Check that song metadata matches (artist names, titles)
- The app handles variations like "&" vs "and", featured artists, etc.
**System tray not working**
- On Linux, ensure you have a system tray (like KDE/GNOME)
- Some desktop environments may need additional packages
### Manual Testing
Test the core functionality:
```bash
python main.py
```
This runs the update once without the GUI.
## 📁 Project Structure
```
journey-fm-playlist/
├── journey_fm_app.py # Main GUI application
├── main.py # Core update logic
├── create_icon.py # Icon generator
├── run_app.bat # Windows launcher
├── journey_fm_app.sh # Linux launcher
├── requirements.txt # Python dependencies
├── config.json # Configuration (auto-generated)
├── playlist_log.txt # Update logs
├── icon.png # Application icon
└── README.md # This file
```
## 🔧 Development
### Adding Features
The application is built with PyQt6 for cross-platform compatibility. Key components:
- `MainWindow`: Main application window
- `SystemTrayApp`: Tray icon and menu
- `SetupWizard`: Configuration wizard
- `LogViewer`: Log display widget
- `UpdateWorker`: Background update thread
### Building Standalone Executables
**Windows (PyInstaller)**
```bash
pip install pyinstaller
pyinstaller --onefile --windowed --icon=icon.ico journey_fm_app.py
```
**Linux (PyInstaller)**
```bash
pip install pyinstaller
pyinstaller --onefile --icon=icon.png journey_fm_app.py
```
## 📋 Requirements
- **Python**: 3.8+
- **PyQt6**: GUI framework
- **Selenium**: Web scraping
- **PlexAPI**: Plex integration
- **BeautifulSoup4**: HTML parsing
### System Requirements
- **Windows**: 10+ with system tray support
- **Linux**: Modern desktop environment with system tray
- **Plex Server**: Local or remote Plex server access
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test on both Windows and Linux
5. Submit a pull request
## 📄 License
This project is open source. Feel free to use and modify as needed.
## 🆘 Support
If you encounter issues:
1. Check the application logs
2. Verify your Plex server connection
3. Ensure all dependencies are installed
4. Test with `python main.py` first
For bugs or feature requests, please create an issue on GitHub.
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""
Create application icon
"""
from PySide6.QtGui import QIcon, QPixmap, QPainter, QColor, QFont
from PySide6.QtCore import QSize
def create_icon():
"""Create a simple application icon"""
pixmap = QPixmap(64, 64)
pixmap.fill(QColor(30, 144, 255)) # Dodger blue background
painter = QPainter(pixmap)
painter.setPen(QColor(255, 255, 255))
painter.setFont(QFont("Arial", 32, QFont.Weight.Bold))
# Draw music note symbol
painter.drawText(16, 48, "♪")
painter.end()
icon = QIcon(pixmap)
return icon
if __name__ == "__main__":
from PySide6.QtWidgets import QApplication
import sys
app = QApplication(sys.argv)
icon = create_icon()
pixmap = icon.pixmap(QSize(64, 64))
pixmap.save("icon.png")
print("Icon created: icon.png")
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 566 B

+500
View File
@@ -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()
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# Journey FM Playlist Creator - Linux Startup Script
# Get the directory where this script is located
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
# Activate virtual environment if it exists
if [ -d "$DIR/.venv" ]; then
source "$DIR/.venv/bin/activate"
fi
# Run the application
cd "$DIR"
python journey_fm_app.py
+2 -10
View File
@@ -53,7 +53,6 @@ def scrape_recently_played():
html = driver.page_source html = driver.page_source
driver.quit() driver.quit()
tweet = scrape(tweet_code)
# Optional: save HTML to file for debugging (comment out if not needed) # Optional: save HTML to file for debugging (comment out if not needed)
# with open('page.html', 'w', encoding='utf-8') as f: # with open('page.html', 'w', encoding='utf-8') as f:
# f.write(html) # f.write(html)
@@ -259,15 +258,8 @@ def main():
SERVER_IP = config.get('SERVER_IP', SERVER_IP) SERVER_IP = config.get('SERVER_IP', SERVER_IP)
PLAYLIST_NAME = config.get('PLAYLIST_NAME', PLAYLIST_NAME) PLAYLIST_NAME = config.get('PLAYLIST_NAME', PLAYLIST_NAME)
else: else:
config = prompt_for_config() # If no config file, use defaults (GUI should handle this)
PLEX_TOKEN = config['PLEX_TOKEN'] pass
SERVER_IP = config['SERVER_IP']
PLAYLIST_NAME = config['PLAYLIST_NAME']
# Prompt for scheduler setup
sched_params = prompt_for_scheduler()
if sched_params:
setup_scheduler(sched_params)
# Log start time # Log start time
start_time = datetime.now() start_time = datetime.now()
+2 -1
View File
@@ -3,4 +3,5 @@ beautifulsoup4
plexapi plexapi
mutagen mutagen
selenium selenium
webdriver-manager webdriver-manager
PySide6
+14
View File
@@ -0,0 +1,14 @@
@echo off
REM Journey FM Playlist Creator - Windows Launcher
cd /d "%~dp0"
REM Activate virtual environment if it exists
if exist .venv\Scripts\activate.bat (
call .venv\Scripts\activate.bat
)
REM Run the application
python journey_fm_app.py
pause