diff --git a/README.md b/README.md index dc8f2d7..81d3fc5 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,205 @@ # 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/ -- Checks your local music library for matching songs -- Creates a playlist in Plex with the found songs +## ✨ Features -## 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` -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` +## 🚀 Quick Start -## Requirements +### Windows -- Python 3.6+ -- Plex server -- Local music library with ID3 tags +1. **Install Dependencies** + ```bash + 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. \ No newline at end of file +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. \ No newline at end of file diff --git a/create_icon.py b/create_icon.py new file mode 100644 index 0000000..592a601 --- /dev/null +++ b/create_icon.py @@ -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") \ No newline at end of file diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..092888f Binary files /dev/null and b/icon.png differ diff --git a/journey_fm_app.py b/journey_fm_app.py new file mode 100644 index 0000000..c7717e9 --- /dev/null +++ b/journey_fm_app.py @@ -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() \ No newline at end of file diff --git a/journey_fm_app.sh b/journey_fm_app.sh new file mode 100644 index 0000000..8628070 --- /dev/null +++ b/journey_fm_app.sh @@ -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 \ No newline at end of file diff --git a/main.py b/main.py index c3c0c52..39e61fa 100644 --- a/main.py +++ b/main.py @@ -53,7 +53,6 @@ def scrape_recently_played(): html = driver.page_source driver.quit() - tweet = scrape(tweet_code) # Optional: save HTML to file for debugging (comment out if not needed) # with open('page.html', 'w', encoding='utf-8') as f: # f.write(html) @@ -259,15 +258,8 @@ def main(): SERVER_IP = config.get('SERVER_IP', SERVER_IP) PLAYLIST_NAME = config.get('PLAYLIST_NAME', PLAYLIST_NAME) else: - config = prompt_for_config() - PLEX_TOKEN = config['PLEX_TOKEN'] - 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) + # If no config file, use defaults (GUI should handle this) + pass # Log start time start_time = datetime.now() diff --git a/requirements.txt b/requirements.txt index 755059f..c2349f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ beautifulsoup4 plexapi mutagen selenium -webdriver-manager \ No newline at end of file +webdriver-manager +PySide6 \ No newline at end of file diff --git a/run_app.bat b/run_app.bat new file mode 100644 index 0000000..b209686 --- /dev/null +++ b/run_app.bat @@ -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 \ No newline at end of file