Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f33d8bd5d |
No files matched your search
@@ -1,214 +1,28 @@
|
||||
# Journey FM Playlist Creator
|
||||
|
||||
A cross-platform desktop application that automatically creates Plex playlists from recently played songs on My Journey FM radio station.
|
||||
This script automates the process of creating a Plex playlist from recently played songs on My Journey FM radio station.
|
||||
|
||||

|
||||
## Features
|
||||
|
||||
## ✨ Features
|
||||
- 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
|
||||
|
||||
- **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
|
||||
## Setup
|
||||
|
||||
## 🚀 Quick Start
|
||||
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`
|
||||
|
||||
### Windows
|
||||
## Requirements
|
||||
|
||||
1. **Install Dependencies**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
- Python 3.6+
|
||||
- Plex server
|
||||
- Local music library with ID3 tags
|
||||
|
||||
2. **Create Application Icon**
|
||||
```bash
|
||||
python create_icon.py
|
||||
```
|
||||
## Note
|
||||
|
||||
3. **Run the Application**
|
||||
```bash
|
||||
python journey_fm_app.py
|
||||
```
|
||||
Or use the batch file: `run_app.bat`
|
||||
|
||||
### Linux
|
||||
|
||||
1. **Run the Installer**
|
||||
```bash
|
||||
./install.sh
|
||||
```
|
||||
This will:
|
||||
- Install Python dependencies
|
||||
- Set up virtual environment
|
||||
- Install Chromium browser
|
||||
- Create desktop icon
|
||||
- Add to applications menu
|
||||
|
||||
2. **Launch from Menu**
|
||||
- Search for "Journey FM Playlist" in your applications menu
|
||||
- Or run: `./journey_fm_app.sh`
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### First-Time Setup
|
||||
|
||||
When you first run the application, a setup wizard will guide you through:
|
||||
|
||||
1. **Plex Token**: See below for how to get your Plex token
|
||||
2. **Server IP**: Your Plex server's local IP address (e.g., 192.168.1.100)
|
||||
3. **Playlist Name**: Name for your Journey FM playlist (default: "Journey FM Recently Played")
|
||||
4. **Auto-Update Settings**: Enable automatic updates and set intervals
|
||||
|
||||
#### How to Get Your Plex Token
|
||||
|
||||
1. Go to [https://plex.tv/claim](https://plex.tv/claim) in your web browser
|
||||
2. Sign in with your Plex account
|
||||
3. Copy the claim token from the page (it looks like: `pU-mHWYUZU6iXJFhJyA`)
|
||||
4. Paste it into the application setup
|
||||
|
||||
**Note**: This token is used to authenticate with your Plex server. Keep it secure and don't share it.
|
||||
|
||||
### 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.
|
||||
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.
|
||||
@@ -1,8 +0,0 @@
|
||||
Songs not in your library - Amazon search links:
|
||||
|
||||
Cochren & Co. - Who Can
|
||||
https://www.amazon.com/s?k=Cochren%20%26%20%20Co.%20Who%20%20Can&i=digital-music
|
||||
|
||||
Matthew West - All In
|
||||
https://www.amazon.com/s?k=Matthew%20%20West%20All%20%20In&i=digital-music
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/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")
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Journey FM Playlist Creator - Linux Installer
|
||||
|
||||
echo "Journey FM Playlist Creator - Linux Installer"
|
||||
echo "=============================================="
|
||||
|
||||
# Get the directory where this script is located
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
|
||||
echo "Installing in: $DIR"
|
||||
|
||||
# Check if Python 3 is installed
|
||||
if ! command -v python3 > /dev/null 2>&1; then
|
||||
echo "Python 3 not found. Installing Python 3..."
|
||||
sudo apt update && sudo apt install -y python3 python3-pip python3-venv
|
||||
fi
|
||||
|
||||
# Create virtual environment
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv "$DIR/.venv"
|
||||
|
||||
# Activate virtual environment
|
||||
source "$DIR/.venv/bin/activate"
|
||||
|
||||
# Install Python dependencies
|
||||
echo "Installing Python dependencies..."
|
||||
pip install -r "$DIR/requirements.txt"
|
||||
|
||||
# Install Chromium browser
|
||||
if ! command -v chromium-browser > /dev/null 2>&1; then
|
||||
echo "Installing Chromium browser..."
|
||||
sudo apt update && sudo apt install -y chromium-browser
|
||||
fi
|
||||
|
||||
# Create application icon
|
||||
echo "Creating application icon..."
|
||||
python3 "$DIR/create_icon.py"
|
||||
|
||||
# Create desktop entry
|
||||
echo "Creating desktop entry..."
|
||||
mkdir -p ~/.local/share/applications
|
||||
|
||||
cat > ~/.local/share/applications/journey-fm.desktop << EOF
|
||||
[Desktop Entry]
|
||||
Name=Journey FM Playlist
|
||||
Exec=$DIR/journey_fm_app.sh
|
||||
Icon=$DIR/icon.png
|
||||
Type=Application
|
||||
Categories=Utility;
|
||||
EOF
|
||||
|
||||
chmod +x ~/.local/share/applications/journey-fm.desktop
|
||||
|
||||
# Update desktop database
|
||||
echo "Updating desktop database..."
|
||||
update-desktop-database ~/.local/share/applications
|
||||
|
||||
# Make scripts executable
|
||||
chmod +x "$DIR/journey_fm_app.sh"
|
||||
|
||||
echo "Installation complete!"
|
||||
echo "You can now find 'Journey FM Playlist' in your applications menu."
|
||||
echo "Or run: $DIR/journey_fm_app.sh"
|
||||
@@ -1,837 +0,0 @@
|
||||
#!/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, QTextBrowser, QTableWidget, QTableWidgetItem, QHeaderView, QListWidget, QListWidgetItem
|
||||
)
|
||||
from PySide6.QtCore import (
|
||||
Qt, QTimer, QThread, Signal, QSettings, QSize
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QIcon, QAction, QFont, QPixmap, QPainter, QColor, QDesktopServices
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
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
|
||||
|
||||
# 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(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.exec()
|
||||
|
||||
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(0) # 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() == 2: # 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() == 2: # 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_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", "Added Songs", "Missing Songs", "Details"])
|
||||
table.horizontalHeader().setStretchLastSection(True)
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect('playlist_history.db')
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT date, added_count, added_songs, missing_count, missing_songs FROM history ORDER BY date DESC')
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
table.setRowCount(len(rows))
|
||||
for row_idx, (date, added_count, added_songs_json, missing_count, missing_songs_json) in enumerate(rows):
|
||||
# Parse date
|
||||
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)
|
||||
added_text = f"{added_count} songs\n" + "\n".join(added_songs[:3]) # Show first 3
|
||||
if len(added_songs) > 3:
|
||||
added_text += f"\n... and {len(added_songs)-3} more"
|
||||
except:
|
||||
added_text = f"{added_count} songs"
|
||||
|
||||
# Missing songs
|
||||
try:
|
||||
missing_songs = json.loads(missing_songs_json)
|
||||
missing_text = f"{missing_count} songs\n" + "\n".join([f"{s['artist']} - {s['title']}" for s in missing_songs[:3]])
|
||||
if len(missing_songs) > 3:
|
||||
missing_text += f"\n... and {len(missing_songs)-3} more"
|
||||
except:
|
||||
missing_text = f"{missing_count} songs"
|
||||
|
||||
table.setItem(row_idx, 0, QTableWidgetItem(date_str))
|
||||
table.setItem(row_idx, 1, QTableWidgetItem(added_text))
|
||||
table.setItem(row_idx, 2, QTableWidgetItem(missing_text))
|
||||
table.setItem(row_idx, 3, QTableWidgetItem(f"Added: {added_count}, Missing: {missing_count}"))
|
||||
|
||||
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()
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/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 )"
|
||||
|
||||
# Create virtual environment if it doesn't exist
|
||||
if [ ! -d "$DIR/.venv" ]; then
|
||||
python3 -m venv "$DIR/.venv"
|
||||
fi
|
||||
|
||||
# Activate virtual environment
|
||||
source "$DIR/.venv/bin/activate"
|
||||
|
||||
# Install dependencies
|
||||
pip install -r "$DIR/requirements.txt"
|
||||
|
||||
# Check if Chromium is installed, install if not
|
||||
if ! command -v chromium-browser > /dev/null 2>&1; then
|
||||
echo "Chromium not found. Installing Chromium..."
|
||||
sudo apt update && sudo apt install -y chromium-browser
|
||||
fi
|
||||
|
||||
# Run the application
|
||||
cd "$DIR"
|
||||
python3 journey_fm_app.py
|
||||
@@ -11,21 +11,16 @@ import time
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from webdriver_manager.chrome import ChromeDriverManager
|
||||
from selenium.webdriver.common.by import By
|
||||
from webdriver_manager.chrome import ChromeDriverManager
|
||||
from bs4 import BeautifulSoup
|
||||
import os
|
||||
from plexapi.myplex import MyPlexAccount
|
||||
from mutagen.easyid3 import EasyID3
|
||||
import re
|
||||
from datetime import datetime
|
||||
import json
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import sqlite3
|
||||
import urllib.parse
|
||||
|
||||
# Configuration (defaults, will be overridden by config.json)
|
||||
# Configuration
|
||||
PLEX_TOKEN = 'pU-m3HWYUZU6iXJFhJyA' # Your Plex.tv token
|
||||
SERVER_IP = '172.16.16.106' # Your local server IP
|
||||
PLAYLIST_NAME = 'Journey FM Recently Played'
|
||||
@@ -37,12 +32,7 @@ def scrape_recently_played():
|
||||
options.add_argument("--no-sandbox")
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
options.add_argument("--disable-gpu")
|
||||
options.add_argument("--remote-debugging-port=9222")
|
||||
options.add_argument("--user-data-dir=/tmp/chromium")
|
||||
options.add_argument("--no-first-run")
|
||||
options.add_argument("--disable-extensions")
|
||||
options.binary_location = "/usr/bin/chromium-browser"
|
||||
service = Service()
|
||||
service = Service(ChromeDriverManager().install())
|
||||
driver = webdriver.Chrome(service=service, options=options)
|
||||
|
||||
url = 'https://www.myjourneyfm.com/recently-played/'
|
||||
@@ -105,7 +95,7 @@ def scrape_recently_played():
|
||||
|
||||
def normalize_string(s):
|
||||
"""Normalize string for comparison"""
|
||||
return re.sub(r'[^\\w\\s]', '', s).lower().strip()
|
||||
return re.sub(r'[^\w\s]', '', s).lower().strip()
|
||||
|
||||
def find_song_in_library(artist, title, library_path):
|
||||
"""Check if song exists in local library"""
|
||||
@@ -123,13 +113,11 @@ def find_song_in_library(artist, title, library_path):
|
||||
return None
|
||||
|
||||
def create_playlist_in_plex(plex, songs, playlist_name):
|
||||
"""Create playlist in Plex and return added count, added songs, and missing songs"""
|
||||
"""Create playlist in Plex"""
|
||||
# Assuming Plex has a music library named 'Music'
|
||||
music_library = plex.library.section('Music')
|
||||
tracks = []
|
||||
seen_keys = set() # Track which ratingKeys we've already added to prevent duplicates within this run
|
||||
missing = []
|
||||
added_songs = []
|
||||
|
||||
for song in songs:
|
||||
# Clean title and artist by removing text in parentheses and normalizing spaces
|
||||
@@ -143,7 +131,7 @@ def create_playlist_in_plex(plex, songs, playlist_name):
|
||||
continue
|
||||
|
||||
# Skip entries that are ONLY punctuation (but allow titles with punctuation in them)
|
||||
if re.match(r'^[!?\\\\s]+$', clean_title):
|
||||
if re.match(r'^[!?\s]+$', clean_title):
|
||||
continue
|
||||
|
||||
# Remove only ! and ? for searching, but keep apostrophes
|
||||
@@ -164,7 +152,7 @@ def create_playlist_in_plex(plex, songs, playlist_name):
|
||||
search_artist_lower = search_artist.lower()
|
||||
|
||||
# Clean featured artists from search (e.g., "W/ Emerson Day", "feat. Someone")
|
||||
search_artist_main = re.sub(r'\s+(w/|feat\.||featuring|ft\.).*', '', search_artist_lower).strip()
|
||||
search_artist_main = re.sub(r'\s+(w/|feat\.|featuring|ft\.).*', '', search_artist_lower).strip()
|
||||
|
||||
# Normalize & and + in track artist as well
|
||||
track_artist = re.sub(r'\s*[&+]\s*', ' and ', track_artist)
|
||||
@@ -181,9 +169,8 @@ def create_playlist_in_plex(plex, songs, playlist_name):
|
||||
break
|
||||
# If no artist match, skip this track (don't add wrong songs)
|
||||
if not matched:
|
||||
missing.append(song)
|
||||
pass # Don't add if artist doesn't match # Take the first match
|
||||
|
||||
added = 0
|
||||
if tracks:
|
||||
try:
|
||||
# Try to find existing playlist
|
||||
@@ -199,103 +186,26 @@ def create_playlist_in_plex(plex, songs, playlist_name):
|
||||
print(f"Added {len(new_tracks)} new songs to existing playlist '{playlist_name}':")
|
||||
for track in new_tracks:
|
||||
print(f" + '{track.title}' by {track.artist().title}")
|
||||
added = len(new_tracks)
|
||||
added_songs.extend([f"{track.title} by {track.artist().title}" for track in new_tracks])
|
||||
else:
|
||||
print(f"No new songs to add - all {len(tracks)} matching songs already in playlist.")
|
||||
added = 0
|
||||
except:
|
||||
# Playlist doesn't exist, create it
|
||||
plex.createPlaylist(playlist_name, tracks)
|
||||
print(f"Created playlist '{playlist_name}' with {len(tracks)} songs:")
|
||||
for track in tracks:
|
||||
print(f" + '{track.title}' by {track.artist().title}")
|
||||
added = len(tracks)
|
||||
added_songs.extend([f"{track.title} by {track.artist().title}" for track in tracks])
|
||||
except Exception as e:
|
||||
print(f"Error with playlist: {e}")
|
||||
added = 0
|
||||
added_songs = []
|
||||
else:
|
||||
print("No matching tracks found in Plex.")
|
||||
added = 0
|
||||
added_songs = []
|
||||
|
||||
return added, added_songs, missing
|
||||
|
||||
def prompt_for_config():
|
||||
"""Prompt user for configuration variables"""
|
||||
print("Configuration not found. Please provide the following:")
|
||||
plex_token = input("Enter your Plex token: ").strip()
|
||||
server_ip = input("Enter your Plex server IP: ").strip()
|
||||
playlist_name = input("Enter the playlist name: ").strip()
|
||||
config = {
|
||||
'PLEX_TOKEN': plex_token,
|
||||
'SERVER_IP': server_ip,
|
||||
'PLAYLIST_NAME': playlist_name
|
||||
}
|
||||
with open('config.json', 'w') as f:
|
||||
json.dump(config, f)
|
||||
return config
|
||||
|
||||
def init_history_db():
|
||||
"""Initialize the history database"""
|
||||
conn = sqlite3.connect('playlist_history.db')
|
||||
c = conn.cursor()
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS history (
|
||||
id INTEGER PRIMARY KEY,
|
||||
date TEXT,
|
||||
added_count INTEGER,
|
||||
added_songs TEXT,
|
||||
missing_count INTEGER,
|
||||
missing_songs TEXT
|
||||
)''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def save_history_entry(added_count, added_songs, missing_count, missing_songs):
|
||||
"""Save a history entry to the database"""
|
||||
conn = sqlite3.connect('playlist_history.db')
|
||||
c = conn.cursor()
|
||||
date = datetime.now().isoformat()
|
||||
c.execute('INSERT INTO history (date, added_count, added_songs, missing_count, missing_songs) VALUES (?, ?, ?, ?, ?)',
|
||||
(date, added_count, json.dumps(added_songs), missing_count, json.dumps(missing_songs)))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def setup_scheduler(params):
|
||||
"""Run the setup_scheduler.ps1 with parameters"""
|
||||
cmd = ['powershell.exe', '-File', 'setup_scheduler.ps1']
|
||||
for k, v in params.items():
|
||||
cmd.extend(['-' + k, str(v)])
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
print("Scheduler setup completed.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error setting up scheduler: {e}")
|
||||
|
||||
def main():
|
||||
global PLEX_TOKEN, SERVER_IP, PLAYLIST_NAME
|
||||
# Load or prompt for config
|
||||
if os.path.exists('config.json'):
|
||||
with open('config.json', 'r') as f:
|
||||
config = json.load(f)
|
||||
PLEX_TOKEN = config.get('PLEX_TOKEN', PLEX_TOKEN)
|
||||
SERVER_IP = config.get('SERVER_IP', SERVER_IP)
|
||||
PLAYLIST_NAME = config.get('PLAYLIST_NAME', PLAYLIST_NAME)
|
||||
else:
|
||||
# If no config file, use defaults (GUI should handle this)
|
||||
pass
|
||||
|
||||
# Log start time
|
||||
start_time = datetime.now()
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Journey FM Playlist Update - {start_time.strftime('%Y-%m-%d %I:%M:%S %p')}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
# Initialize history database
|
||||
init_history_db()
|
||||
|
||||
# Scrape songs
|
||||
songs = scrape_recently_played()
|
||||
print(f"Found {len(songs)} recently played songs.")
|
||||
@@ -336,28 +246,9 @@ def main():
|
||||
|
||||
# Create playlist
|
||||
if songs:
|
||||
added, added_songs, missing = create_playlist_in_plex(plex, songs, PLAYLIST_NAME)
|
||||
create_playlist_in_plex(plex, songs, PLAYLIST_NAME)
|
||||
else:
|
||||
print("No songs found.")
|
||||
added, added_songs, missing = 0, [], []
|
||||
|
||||
# Save to history
|
||||
save_history_entry(added, added_songs, len(missing), missing)
|
||||
|
||||
# Create Amazon buy list for missing songs
|
||||
if missing:
|
||||
print(f"\n{len(missing)} songs not found in your library. Creating Amazon buy list...")
|
||||
with open('amazon_buy_list.txt', 'w') as f:
|
||||
f.write("Songs not in your library - Amazon search links:\n\n")
|
||||
for song in missing:
|
||||
artist = song['artist']
|
||||
title = song['title']
|
||||
# Create Amazon search URL
|
||||
query = urllib.parse.quote(f"{artist} {title}")
|
||||
url = f"https://www.amazon.com/s?k={query}&i=digital-music"
|
||||
f.write(f"{artist} - {title}\n{url}\n\n")
|
||||
print(f" - {artist} - {title}: {url}")
|
||||
print("Buy list saved to amazon_buy_list.txt")
|
||||
|
||||
# Log completion
|
||||
end_time = datetime.now()
|
||||
@@ -366,4 +257,4 @@ def main():
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
main()
|
||||
@@ -1,28 +0,0 @@
|
||||
Requirement already satisfied: requests in ./.venv/lib/python3.13/site-packages (from -r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 1)) (2.32.5)
|
||||
Requirement already satisfied: beautifulsoup4 in ./.venv/lib/python3.13/site-packages (from -r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 2)) (4.14.2)
|
||||
Requirement already satisfied: plexapi in ./.venv/lib/python3.13/site-packages (from -r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 3)) (4.17.1)
|
||||
Requirement already satisfied: mutagen in ./.venv/lib/python3.13/site-packages (from -r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 4)) (1.47.0)
|
||||
Requirement already satisfied: selenium in ./.venv/lib/python3.13/site-packages (from -r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (4.38.0)
|
||||
Requirement already satisfied: webdriver-manager in ./.venv/lib/python3.13/site-packages (from -r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 6)) (4.0.2)
|
||||
Requirement already satisfied: PySide6 in ./.venv/lib/python3.13/site-packages (from -r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 7)) (6.10.0)
|
||||
Requirement already satisfied: charset_normalizer<4,>=2 in ./.venv/lib/python3.13/site-packages (from requests->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 1)) (3.4.4)
|
||||
Requirement already satisfied: idna<4,>=2.5 in ./.venv/lib/python3.13/site-packages (from requests->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 1)) (3.11)
|
||||
Requirement already satisfied: urllib3<3,>=1.21.1 in ./.venv/lib/python3.13/site-packages (from requests->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 1)) (2.5.0)
|
||||
Requirement already satisfied: certifi>=2017.4.17 in ./.venv/lib/python3.13/site-packages (from requests->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 1)) (2025.11.12)
|
||||
Requirement already satisfied: soupsieve>1.2 in ./.venv/lib/python3.13/site-packages (from beautifulsoup4->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 2)) (2.8)
|
||||
Requirement already satisfied: typing-extensions>=4.0.0 in ./.venv/lib/python3.13/site-packages (from beautifulsoup4->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 2)) (4.15.0)
|
||||
Requirement already satisfied: trio<1.0,>=0.31.0 in ./.venv/lib/python3.13/site-packages (from selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (0.32.0)
|
||||
Requirement already satisfied: trio-websocket<1.0,>=0.12.2 in ./.venv/lib/python3.13/site-packages (from selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (0.12.2)
|
||||
Requirement already satisfied: websocket-client<2.0,>=1.8.0 in ./.venv/lib/python3.13/site-packages (from selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (1.9.0)
|
||||
Requirement already satisfied: attrs>=23.2.0 in ./.venv/lib/python3.13/site-packages (from trio<1.0,>=0.31.0->selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (25.4.0)
|
||||
Requirement already satisfied: sortedcontainers in ./.venv/lib/python3.13/site-packages (from trio<1.0,>=0.31.0->selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (2.4.0)
|
||||
Requirement already satisfied: outcome in ./.venv/lib/python3.13/site-packages (from trio<1.0,>=0.31.0->selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (1.3.0.post0)
|
||||
Requirement already satisfied: sniffio>=1.3.0 in ./.venv/lib/python3.13/site-packages (from trio<1.0,>=0.31.0->selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (1.3.1)
|
||||
Requirement already satisfied: wsproto>=0.14 in ./.venv/lib/python3.13/site-packages (from trio-websocket<1.0,>=0.12.2->selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (1.3.1)
|
||||
Requirement already satisfied: pysocks!=1.5.7,<2.0,>=1.5.6 in ./.venv/lib/python3.13/site-packages (from urllib3[socks]<3.0,>=2.5.0->selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (1.7.1)
|
||||
Requirement already satisfied: python-dotenv in ./.venv/lib/python3.13/site-packages (from webdriver-manager->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 6)) (1.2.1)
|
||||
Requirement already satisfied: packaging in ./.venv/lib/python3.13/site-packages (from webdriver-manager->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 6)) (25.0)
|
||||
Requirement already satisfied: shiboken6==6.10.0 in ./.venv/lib/python3.13/site-packages (from PySide6->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 7)) (6.10.0)
|
||||
Requirement already satisfied: PySide6_Essentials==6.10.0 in ./.venv/lib/python3.13/site-packages (from PySide6->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 7)) (6.10.0)
|
||||
Requirement already satisfied: PySide6_Addons==6.10.0 in ./.venv/lib/python3.13/site-packages (from PySide6->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 7)) (6.10.0)
|
||||
Requirement already satisfied: h11<1,>=0.16.0 in ./.venv/lib/python3.13/site-packages (from wsproto>=0.14->trio-websocket<1.0,>=0.12.2->selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (0.16.0)
|
||||
+1
-2
@@ -3,5 +3,4 @@ beautifulsoup4
|
||||
plexapi
|
||||
mutagen
|
||||
selenium
|
||||
webdriver-manager
|
||||
PySide6
|
||||
webdriver-manager
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
@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 without console window
|
||||
pythonw journey_fm_app.py
|
||||
+21
-17
@@ -1,26 +1,30 @@
|
||||
# Setup Task Scheduler for Journey FM Playlist Updater
|
||||
# Run this script as Administrator to create scheduled tasks
|
||||
|
||||
param(
|
||||
[string]$Type = "Both", # Interval, Startup, Both
|
||||
[int]$Interval = 15,
|
||||
[string]$Unit = "Minutes" # Minutes, Hours, Days
|
||||
)
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$psScript = "$scriptDir\run_journey_playlist.ps1"
|
||||
|
||||
# Task 1: Run every 15 minutes
|
||||
$action1 = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$psScript`""
|
||||
$trigger1 = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 15)
|
||||
$settings1 = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||
if ($Type -eq "Interval" -or $Type -eq "Both") {
|
||||
$action = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$psScript`""
|
||||
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -$Unit $Interval)
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||
Register-ScheduledTask -TaskName "Journey FM Playlist Updater - Every $Interval $Unit" -Action $action -Trigger $trigger -Settings $settings -Description "Updates Plex playlist with recently played songs every $Interval $Unit"
|
||||
Write-Host "Created task: Every $Interval $Unit" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Register-ScheduledTask -TaskName "Journey FM Playlist Updater - Every 15 Minutes" -Action $action1 -Trigger $trigger1 -Settings $settings1 -Description "Updates Plex playlist with recently played songs from Journey FM every 15 minutes" -Force
|
||||
|
||||
# Task 2: Run at startup
|
||||
$action2 = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$psScript`""
|
||||
$trigger2 = New-ScheduledTaskTrigger -AtStartup
|
||||
$trigger2.Delay = "PT1M" # 1 minute delay after startup
|
||||
$settings2 = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||
|
||||
Register-ScheduledTask -TaskName "Journey FM Playlist Updater - At Startup" -Action $action2 -Trigger $trigger2 -Settings $settings2 -Description "Updates Plex playlist with recently played songs from Journey FM at computer startup" -Force
|
||||
if ($Type -eq "Startup" -or $Type -eq "Both") {
|
||||
$action = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$psScript`""
|
||||
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
$trigger.Delay = "PT1M" # 1 minute delay
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||
Register-ScheduledTask -TaskName "Journey FM Playlist Updater - At Startup" -Action $action -Trigger $trigger -Settings $settings -Description "Updates Plex playlist with recently played songs at startup"
|
||||
Write-Host "Created task: At Startup" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host "Scheduled tasks created successfully!" -ForegroundColor Green
|
||||
Write-Host "Task 1: Runs every 15 minutes" -ForegroundColor Cyan
|
||||
Write-Host "Task 2: Runs at computer startup (with 60 second delay)" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "You can view and manage these tasks in Task Scheduler." -ForegroundColor Yellow
|
||||
Reference in new issue
Block a user