Files
journey-fm-playlist/journey_fm_app.py
T

967 lines
35 KiB
Python

#!/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, QVBoxLayout, QWidget, QPushButton, QTextEdit, QLineEdit, QLabel, QHBoxLayout, QDialog, QListWidget, QListWidgetItem, QTableWidget, QTableWidgetItem, QHeaderView, QCheckBox, QInputDialog, QMessageBox, QProgressBar, QSystemTrayIcon, QMenu, QComboBox, QGroupBox, QFormLayout, QSpinBox, QTextBrowser, QTabWidget, QDialogButtonBox, QSplitter
from PySide6.QtCore import QTimer, Qt, QThread, Signal, QSettings, QUrl
from PySide6.QtGui import QIcon, QDesktopServices, QFont, QAction
import json
import csv
from urllib.parse import quote
import sqlite3
from datetime import datetime
import matplotlib.pyplot as plt
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
# 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")
# Token input with help button
token_layout = QHBoxLayout()
token_layout.addWidget(self.token_input)
help_button = QPushButton("?")
help_button.setMaximumWidth(30)
help_button.setToolTip("Click to open Plex token page")
help_button.clicked.connect(lambda: QDesktopServices.openUrl("https://plex.tv/claim"))
token_layout.addWidget(help_button)
form_layout.addRow("Plex Token:", token_layout)
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)
# Buy list button
self.buy_list_button = QPushButton("Show Buy List")
self.buy_list_button.clicked.connect(self.show_buy_list)
status_layout.addWidget(self.buy_list_button)
# History button
self.history_button = QPushButton("View History")
self.history_button.clicked.connect(self.show_history)
status_layout.addWidget(self.history_button)
# Export button
self.export_button = QPushButton("Export Playlist")
self.export_button.clicked.connect(self.export_playlist)
status_layout.addWidget(self.export_button)
# Statistics button
self.stats_button = QPushButton("Statistics")
self.stats_button.clicked.connect(self.show_statistics)
status_layout.addWidget(self.stats_button)
# Analytics button
self.analytics_button = QPushButton("Analytics")
self.analytics_button.clicked.connect(self.show_analytics)
status_layout.addWidget(self.analytics_button)
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 = int(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")
# Append result to log file
try:
with open('playlist_log.txt', 'a', encoding='utf-8') as f:
f.write(result + '\n')
except Exception as e:
print(f"Error writing to log: {e}")
# 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')}")
# Append result to log file
try:
with open('playlist_log.txt', 'a', encoding='utf-8') as f:
f.write(result + '\n')
except Exception as e:
print(f"Error writing to log: {e}")
# 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 show_buy_list(self):
"""Show the Amazon buy list dialog with interactive features"""
try:
with open('amazon_buy_list.txt', 'r') as f:
content = f.read()
except FileNotFoundError:
QMessageBox.information(self, "No Buy List", "No buy list available. Run an update to generate the list.")
return
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to read buy list file: {e}")
return
try:
# Parse content to get songs
lines = content.split('\n')
songs = []
i = 0
while i < len(lines):
if lines[i].startswith('Songs not in your library'):
i += 2 # Skip header
continue
if lines[i].strip() and not lines[i].startswith('http'):
artist_title = lines[i].strip()
if i + 1 < len(lines) and lines[i + 1].startswith('http'):
url = lines[i + 1].strip()
songs.append((artist_title, url))
i += 3 # Skip blank line
else:
i += 1
else:
i += 1
dialog = QDialog(self)
dialog.setWindowTitle("Amazon Buy List")
dialog.setModal(True)
dialog.resize(600, 400)
layout = QVBoxLayout()
# Search box
search_layout = QHBoxLayout()
search_layout.addWidget(QLabel("Search:"))
self.search_input = QLineEdit()
self.search_input.textChanged.connect(lambda: self.filter_buy_list(songs))
search_layout.addWidget(self.search_input)
layout.addLayout(search_layout)
# List widget
self.buy_list_widget = QListWidget()
self.populate_buy_list(songs)
layout.addWidget(QLabel(f"Found {len(songs)} songs to buy:"))
layout.addWidget(self.buy_list_widget)
# Buttons
button_layout = QHBoxLayout()
open_button = QPushButton("Open Selected")
open_button.clicked.connect(self.open_selected_buy_items)
button_layout.addWidget(open_button)
remove_button = QPushButton("Remove Selected")
remove_button.clicked.connect(lambda: self.remove_selected_buy_items(songs, dialog))
button_layout.addWidget(remove_button)
close_button = QPushButton("Close")
close_button.clicked.connect(dialog.close)
button_layout.addWidget(close_button)
layout.addLayout(button_layout)
dialog.setLayout(layout)
dialog.show()
dialog.raise_()
dialog.activateWindow()
dialog.exec()
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to show buy list dialog: {e}")
def populate_buy_list(self, songs):
"""Populate the buy list widget"""
self.buy_list_widget.clear()
for artist_title, url in songs:
item = QListWidgetItem(artist_title)
item.setData(1, url) # Store URL in item data
item.setCheckState(Qt.CheckState.Unchecked)
self.buy_list_widget.addItem(item)
def filter_buy_list(self, all_songs):
"""Filter the buy list based on search text"""
search_text = self.search_input.text().lower()
filtered = [(at, url) for at, url in all_songs if search_text in at.lower()]
self.populate_buy_list(filtered)
def open_selected_buy_items(self):
"""Open selected buy list items in browser"""
for i in range(self.buy_list_widget.count()):
item = self.buy_list_widget.item(i)
if item.checkState() == Qt.CheckState.Checked:
url = item.data(1)
QDesktopServices.openUrl(url)
def remove_selected_buy_items(self, all_songs, dialog):
"""Remove selected items from buy list"""
to_remove = []
for i in range(self.buy_list_widget.count()):
item = self.buy_list_widget.item(i)
if item.checkState() == Qt.CheckState.Checked:
artist_title = item.text()
to_remove.append(artist_title)
if not to_remove:
QMessageBox.information(dialog, "No Selection", "Please select items to remove.")
return
# Confirm
reply = QMessageBox.question(dialog, "Confirm Removal",
f"Remove {len(to_remove)} selected items from buy list?",
QMessageBox.Yes | QMessageBox.No)
if reply != QMessageBox.Yes:
return
# Remove from all_songs
updated_songs = [(at, url) for at, url in all_songs if at not in to_remove]
# Rewrite file
with open('amazon_buy_list.txt', 'w') as f:
f.write("Songs not in your library - Amazon search links:\n\n")
for artist_title, url in updated_songs:
f.write(f"{artist_title}\n{url}\n\n")
# Update display
self.populate_buy_list(updated_songs)
QMessageBox.information(dialog, "Removed", f"Removed {len(to_remove)} items from buy list.")
def show_statistics(self):
"""Show statistics dashboard"""
import sqlite3
import json
dialog = QDialog(self)
dialog.setWindowTitle("Statistics Dashboard")
dialog.setModal(True)
dialog.resize(400, 300)
layout = QVBoxLayout()
try:
conn = sqlite3.connect('playlist_history.db')
c = conn.cursor()
c.execute('SELECT COUNT(*), SUM(added_count), SUM(missing_count) FROM history')
result = c.fetchone()
conn.close()
total_updates = result[0] or 0
total_added = result[1] or 0
total_missing = result[2] or 0
layout.addWidget(QLabel(f"Total Updates: {total_updates}"))
layout.addWidget(QLabel(f"Total Songs Added to Plex: {total_added}"))
layout.addWidget(QLabel(f"Total Songs in Buy List: {total_missing}"))
if total_updates > 0:
avg_added = total_added / total_updates
avg_missing = total_missing / total_updates
layout.addWidget(QLabel(f"Average Songs Added per Update: {avg_added:.1f}"))
layout.addWidget(QLabel(f"Average Missing Songs per Update: {avg_missing:.1f}"))
except Exception as e:
layout.addWidget(QLabel(f"Error loading statistics: {str(e)}"))
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(dialog.close)
layout.addWidget(buttons)
dialog.setLayout(layout)
dialog.exec()
def show_analytics(self):
"""Show analytics dashboard with charts"""
import sqlite3
import json
from datetime import datetime
from collections import Counter
dialog = QDialog(self)
dialog.setWindowTitle("Playlist Analytics")
dialog.setModal(True)
dialog.resize(1000, 700)
layout = QVBoxLayout()
tab_widget = QTabWidget()
try:
conn = sqlite3.connect('playlist_history.db')
c = conn.cursor()
c.execute('SELECT date, added_songs, missing_songs FROM history ORDER BY date')
rows = c.fetchall()
conn.close()
# Prepare data
dates = []
cumulative_added = 0
cumulative_list = []
artist_counter = Counter()
update_freq = []
for date, added_json, missing_json in rows:
dt = datetime.fromisoformat(date)
dates.append(dt.strftime('%Y-%m-%d'))
added_songs = json.loads(added_json) if added_json else []
cumulative_added += len(added_songs)
cumulative_list.append(cumulative_added)
update_freq.append(len(added_songs))
for song in added_songs:
# Parse artist from "Title by Artist"
if " by " in song:
artist = song.split(" by ", 1)[1]
artist_counter[artist] += 1
# Tab 1: Playlist Growth
growth_tab = QWidget()
growth_layout = QVBoxLayout()
fig1 = Figure(figsize=(8, 6))
ax1 = fig1.add_subplot(111)
ax1.plot(dates, cumulative_list, marker='o')
ax1.set_title('Playlist Growth Over Time')
ax1.set_xlabel('Date')
ax1.set_ylabel('Total Songs')
ax1.tick_params(axis='x', rotation=45)
canvas1 = FigureCanvas(fig1)
growth_layout.addWidget(canvas1)
growth_tab.setLayout(growth_layout)
tab_widget.addTab(growth_tab, "Growth")
# Tab 2: Top Artists
artists_tab = QWidget()
artists_layout = QVBoxLayout()
fig2 = Figure(figsize=(8, 6))
ax2 = fig2.add_subplot(111)
top_artists = artist_counter.most_common(10)
if top_artists:
artists, counts = zip(*top_artists)
ax2.bar(artists, counts)
ax2.set_title('Top 10 Artists')
ax2.set_xlabel('Artist')
ax2.set_ylabel('Songs Added')
ax2.tick_params(axis='x', rotation=45)
canvas2 = FigureCanvas(fig2)
artists_layout.addWidget(canvas2)
artists_tab.setLayout(artists_layout)
tab_widget.addTab(artists_tab, "Top Artists")
# Tab 3: Update Frequency
freq_tab = QWidget()
freq_layout = QVBoxLayout()
fig3 = Figure(figsize=(8, 6))
ax3 = fig3.add_subplot(111)
ax3.bar(dates, update_freq)
ax3.set_title('Songs Added per Update')
ax3.set_xlabel('Date')
ax3.set_ylabel('Songs Added')
ax3.tick_params(axis='x', rotation=45)
canvas3 = FigureCanvas(fig3)
freq_layout.addWidget(canvas3)
freq_tab.setLayout(freq_layout)
tab_widget.addTab(freq_tab, "Update Frequency")
except Exception as e:
error_tab = QWidget()
error_layout = QVBoxLayout()
error_layout.addWidget(QLabel(f"Error loading analytics: {str(e)}"))
error_tab.setLayout(error_layout)
tab_widget.addTab(error_tab, "Error")
layout.addWidget(tab_widget)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(dialog.close)
layout.addWidget(buttons)
dialog.setLayout(layout)
dialog.exec()
def show_history(self):
"""Show the playlist history dialog"""
import sqlite3
import json
from datetime import datetime
dialog = QDialog(self)
dialog.setWindowTitle("Playlist History")
dialog.setModal(True)
dialog.resize(800, 600)
layout = QVBoxLayout()
table = QTableWidget()
table.setColumnCount(4)
table.setHorizontalHeaderLabels(["Date", "Type", "Artist", "Song"])
table.horizontalHeader().setStretchLastSection(True)
try:
conn = sqlite3.connect('playlist_history.db')
c = conn.cursor()
c.execute('SELECT date, added_songs, missing_songs FROM history ORDER BY date DESC')
rows = c.fetchall()
conn.close()
# Collect all entries
entries = []
for date, added_songs_json, missing_songs_json in rows:
try:
dt = datetime.fromisoformat(date)
date_str = dt.strftime('%Y-%m-%d %H:%M')
except:
date_str = date
# Added songs
try:
added_songs = json.loads(added_songs_json)
for song in added_songs:
# Format is "Title by Artist"
if " by " in song:
title, artist = song.split(" by ", 1)
else:
title = song
artist = "Unknown"
entries.append((date_str, "Added", artist, title))
except:
pass
# Missing songs
try:
missing_songs = json.loads(missing_songs_json)
for song in missing_songs:
artist = song.get('artist', 'Unknown')
title = song.get('title', 'Unknown')
entries.append((date_str, "Missing", artist, title))
except:
pass
table.setRowCount(len(entries))
for row_idx, (date_str, type_str, artist, song) in enumerate(entries):
table.setItem(row_idx, 0, QTableWidgetItem(date_str))
table.setItem(row_idx, 1, QTableWidgetItem(type_str))
table.setItem(row_idx, 2, QTableWidgetItem(artist))
table.setItem(row_idx, 3, QTableWidgetItem(song))
except Exception as e:
table.setRowCount(1)
table.setItem(0, 0, QTableWidgetItem("Error loading history"))
table.setItem(0, 1, QTableWidgetItem(str(e)))
layout.addWidget(table)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(dialog.close)
layout.addWidget(buttons)
dialog.setLayout(layout)
dialog.exec()
def export_playlist(self):
"""Export the current Plex playlist to CSV"""
try:
# Get config
plex_token = self.config.get('PLEX_TOKEN')
server_ip = self.config.get('SERVER_IP')
playlist_name = self.config.get('PLAYLIST_NAME', 'Journey FM Recently Played')
if not plex_token or not server_ip:
QMessageBox.warning(self, "Configuration Error", "Plex token and server IP are required. Please check settings.")
return
# Connect to Plex
from plexapi.myplex import MyPlexAccount
account = MyPlexAccount(token=plex_token)
resources = account.resources()
server_resource = None
for r in resources:
for conn in r.connections:
if server_ip in conn.address:
server_resource = r
break
if server_resource:
break
if not server_resource:
QMessageBox.warning(self, "Connection Error", f"Server at {server_ip} not found.")
return
plex = server_resource.connect()
# Get playlist
try:
playlist = plex.playlist(playlist_name)
tracks = playlist.items()
except:
QMessageBox.warning(self, "Playlist Error", f"Playlist '{playlist_name}' not found.")
return
# Export to CSV
import csv
with open('playlist_export.csv', 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow(['Title', 'Artist', 'Album'])
for track in tracks:
writer.writerow([
track.title,
track.artist().title if track.artist() else '',
track.album().title if track.album() else ''
])
QMessageBox.information(self, "Export Complete", f"Playlist exported to playlist_export.csv ({len(tracks)} songs)")
except Exception as e:
QMessageBox.critical(self, "Export Error", f"Failed to export playlist: {str(e)}")
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()