Improve Windows installer, shortcuts, GUI reliability, and README
This commit is contained in:
1 parent
dab676787d
commit
3e4ce93824
7 files changed
+501
-283
No files matched your search
@@ -1,214 +1,167 @@
|
|||||||
# Journey FM Playlist Creator
|
# Journey FM Playlist Creator
|
||||||
|
|
||||||
A cross-platform desktop application that automatically creates Plex playlists from recently played songs on My Journey FM radio station.
|
Desktop app + script that pulls recently played songs from Journey FM / Spirit FM and updates a Plex playlist.
|
||||||
|
|
||||||

|
## What It Does
|
||||||
|
|
||||||
## ✨ Features
|
- Scrapes recently played songs from:
|
||||||
|
- `https://www.myjourneyfm.com/recently-played/`
|
||||||
|
- `https://spiritfm.com/ajax/now_playing_history.txt`
|
||||||
|
- Matches tracks in your Plex Music library with title/artist normalization.
|
||||||
|
- Creates or updates your configured Plex playlist.
|
||||||
|
- Avoids adding duplicates already in the target playlist.
|
||||||
|
- Tracks run history in `playlist_history.db`.
|
||||||
|
- Writes missing songs to `amazon_buy_list.txt` with Amazon search links.
|
||||||
|
- Provides a GUI with system tray support, settings, log viewer, history, stats, analytics, and CSV export.
|
||||||
|
|
||||||
- **System Tray Integration**: Runs minimized in the taskbar/system tray
|
## Requirements
|
||||||
- **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
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
- Python 3.10+
|
||||||
|
- Plex account token + reachable Plex server
|
||||||
|
- A Chrome/Chromium browser for Selenium scraping
|
||||||
|
|
||||||
|
Dependencies are listed in `requirements.txt` (GUI uses `PySide6`).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
1. **Install Dependencies**
|
Simple install from GitHub (recommended):
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create Application Icon**
|
1. Open **PowerShell**.
|
||||||
```bash
|
2. Run:
|
||||||
python create_icon.py
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Run the Application**
|
```powershell
|
||||||
```bash
|
git clone https://github.com/nmemmert/journey-fm-playlist.git
|
||||||
python journey_fm_app.py
|
cd journey-fm-playlist
|
||||||
```
|
.\install_windows.bat
|
||||||
Or use the batch file: `run_app.bat`
|
```
|
||||||
|
|
||||||
|
3. Launch from:
|
||||||
|
- Start Menu: **Journey FM Playlist Creator**
|
||||||
|
- Desktop shortcut: **Journey FM Playlist Creator**
|
||||||
|
|
||||||
|
No Git? Download ZIP from GitHub (**Code -> Download ZIP**), extract, open PowerShell in that folder, and run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\install_windows.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
If script execution is blocked:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File .\install_windows.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional installer switches:
|
||||||
|
|
||||||
|
- `-NoDesktopShortcut` skips Desktop shortcut creation
|
||||||
|
- `-NoStartMenuShortcut` skips Start Menu shortcut creation
|
||||||
|
|
||||||
|
Manual install (advanced):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -m venv .venv
|
||||||
|
.venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python journey_fm_app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Installer files:
|
||||||
|
|
||||||
|
- `install_windows.bat` - one-click installer launcher
|
||||||
|
- `install_windows.ps1` - installer logic
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
1. **Run the Installer**
|
Quick setup:
|
||||||
```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**
|
```bash
|
||||||
- Search for "Journey FM Playlist" in your applications menu
|
./install.sh
|
||||||
- Or run: `./journey_fm_app.sh`
|
```
|
||||||
|
|
||||||
## ⚙️ Configuration
|
Manual setup:
|
||||||
|
|
||||||
### First-Time Setup
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python journey_fm_app.py
|
||||||
|
```
|
||||||
|
|
||||||
When you first run the application, a setup wizard will guide you through:
|
## Configuration
|
||||||
|
|
||||||
1. **Plex Token**: See below for how to get your Plex token
|
Use the GUI setup wizard (first launch) or edit `config.json` directly.
|
||||||
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
|
Example `config.json`:
|
||||||
|
|
||||||
1. Go to [https://plex.tv/claim](https://plex.tv/claim) in your web browser
|
```json
|
||||||
2. Sign in with your Plex account
|
{
|
||||||
3. Copy the claim token from the page (it looks like: `pU-mHWYUZU6iXJFhJyA`)
|
"PLEX_TOKEN": "your-plex-token",
|
||||||
4. Paste it into the application setup
|
"SERVER_IP": "192.168.1.100",
|
||||||
|
"PLAYLIST_NAME": "Journey FM Recently Played",
|
||||||
|
"AUTO_UPDATE": true,
|
||||||
|
"UPDATE_INTERVAL": 15,
|
||||||
|
"UPDATE_UNIT": "Minutes",
|
||||||
|
"SELECTED_STATIONS": "journey_fm,spirit_fm"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**Note**: This token is used to authenticate with your Plex server. Keep it secure and don't share it.
|
You can also provide runtime env vars:
|
||||||
|
|
||||||
### Manual Configuration
|
- `PLEX_TOKEN`
|
||||||
|
- `SERVER_IP`
|
||||||
|
- `PLAYLIST_NAME`
|
||||||
|
|
||||||
You can also edit settings anytime through the Settings menu in the application.
|
Optional browser override:
|
||||||
|
|
||||||
## 📱 System Tray Features
|
- `CHROME_BINARY` (absolute path to Chrome/Chromium executable)
|
||||||
|
|
||||||
- **Minimize to Tray**: The app minimizes to system tray instead of closing
|
If `CHROME_BINARY` is not set, the app auto-detects browser locations for Windows/Linux/macOS and then falls back to `PATH`.
|
||||||
- **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
|
## Running Without GUI
|
||||||
|
|
||||||
The built-in log viewer shows:
|
Run one update cycle from CLI:
|
||||||
- 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
|
```bash
|
||||||
python main.py
|
python main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
This runs the update once without the GUI.
|
## GUI Notes
|
||||||
|
|
||||||
## 📁 Project Structure
|
- Minimize-to-tray on close when tray is available.
|
||||||
|
- Tray menu supports Show / Update Now / Quit.
|
||||||
|
- Auto-update runs on configurable interval.
|
||||||
|
- Works on X11 and Wayland Linux sessions.
|
||||||
|
|
||||||
```
|
## Output Files
|
||||||
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
|
- `config.json` - runtime settings shared by GUI and CLI
|
||||||
|
- `playlist_history.db` - update history and statistics data
|
||||||
|
- `playlist_log.txt` - GUI update logs
|
||||||
|
- `amazon_buy_list.txt` - missing songs with Amazon links
|
||||||
|
- `playlist_export.csv` - exported playlist from GUI action
|
||||||
|
|
||||||
### Adding Features
|
## Troubleshooting
|
||||||
|
|
||||||
The application is built with PyQt6 for cross-platform compatibility. Key components:
|
- **Server not found**: verify `SERVER_IP`, Plex availability, and that you are signed in with the correct account token.
|
||||||
|
- **No songs added**: confirm scraped songs exist in your Plex library and metadata is correct.
|
||||||
|
- **Browser startup errors**: install Chrome/Chromium or set `CHROME_BINARY` explicitly.
|
||||||
|
- **No tray icon**: some desktop environments disable or hide legacy trays.
|
||||||
|
- **GUI fails at startup (`libGL.so.1` / PySide6 import errors)**: install missing system graphics libs (Linux example: `sudo apt install libgl1`) and reinstall Python deps with `pip install -r requirements.txt`.
|
||||||
|
|
||||||
- `MainWindow`: Main application window
|
## Core Files
|
||||||
- `SystemTrayApp`: Tray icon and menu
|
|
||||||
- `SetupWizard`: Configuration wizard
|
|
||||||
- `LogViewer`: Log display widget
|
|
||||||
- `UpdateWorker`: Background update thread
|
|
||||||
|
|
||||||
### Building Standalone Executables
|
- `journey_fm_app.py` - desktop GUI and tray app
|
||||||
|
- `main.py` - scraping + Plex update pipeline
|
||||||
|
- `requirements.txt` - Python dependencies
|
||||||
|
- `install.sh` - Linux setup helper
|
||||||
|
|
||||||
**Windows (PyInstaller)**
|
## Pre-Release Checklist
|
||||||
```bash
|
|
||||||
pip install pyinstaller
|
|
||||||
pyinstaller --onefile --windowed --icon=icon.ico journey_fm_app.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**Linux (PyInstaller)**
|
- Verify `config.json` has valid `PLEX_TOKEN`, `SERVER_IP`, and `PLAYLIST_NAME`.
|
||||||
```bash
|
- Run one CLI update: `python main.py` and confirm songs/history are written.
|
||||||
pip install pyinstaller
|
- Run GUI update: `python journey_fm_app.py` and verify status/log output updates.
|
||||||
pyinstaller --onefile --icon=icon.png journey_fm_app.py
|
- Confirm tray behavior (minimize to tray, Show/Update/Quit actions).
|
||||||
```
|
- On Windows/Linux, confirm Chrome/Chromium detection or set `CHROME_BINARY`.
|
||||||
|
- Export playlist once and verify `playlist_export.csv` is created.
|
||||||
## 📋 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.
|
|
||||||
+8
-2
@@ -29,5 +29,11 @@ if __name__ == "__main__":
|
|||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
icon = create_icon()
|
icon = create_icon()
|
||||||
pixmap = icon.pixmap(QSize(64, 64))
|
pixmap = icon.pixmap(QSize(64, 64))
|
||||||
pixmap.save("icon.png")
|
png_ok = pixmap.save("icon.png")
|
||||||
print("Icon created: icon.png")
|
ico_ok = pixmap.save("icon.ico")
|
||||||
|
if png_ok:
|
||||||
|
print("Icon created: icon.png")
|
||||||
|
if ico_ok:
|
||||||
|
print("Icon created: icon.ico")
|
||||||
|
if not png_ok and not ico_ok:
|
||||||
|
print("Failed to create icon files")
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@echo off
|
||||||
|
REM Journey FM Playlist Creator - Windows Installer Launcher
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install_windows.ps1"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo Installation failed. See output above for details.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Installation finished successfully.
|
||||||
|
pause
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
param(
|
||||||
|
[switch]$NoDesktopShortcut,
|
||||||
|
[switch]$NoStartMenuShortcut
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
Write-Host "Journey FM Playlist Creator - Windows Installer" -ForegroundColor Cyan
|
||||||
|
Write-Host "================================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
Set-Location $scriptDir
|
||||||
|
|
||||||
|
function Get-PythonCommand {
|
||||||
|
if (Get-Command py -ErrorAction SilentlyContinue) {
|
||||||
|
return @("py", "-3")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Get-Command python -ErrorAction SilentlyContinue) {
|
||||||
|
return @("python")
|
||||||
|
}
|
||||||
|
|
||||||
|
throw "Python 3 is not installed or not available on PATH. Install Python 3.10+ and try again."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Python {
|
||||||
|
param(
|
||||||
|
[string[]]$BaseCommand,
|
||||||
|
[string[]]$Arguments
|
||||||
|
)
|
||||||
|
|
||||||
|
$command = $BaseCommand[0]
|
||||||
|
$prefixArgs = @()
|
||||||
|
if ($BaseCommand.Count -gt 1) {
|
||||||
|
$prefixArgs = $BaseCommand[1..($BaseCommand.Count - 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
& $command @prefixArgs @Arguments
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Command failed: $command $($prefixArgs -join ' ') $($Arguments -join ' ')"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pythonCmd = Get-PythonCommand
|
||||||
|
Write-Host "Using Python command: $($pythonCmd -join ' ')" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$venvDir = Join-Path $scriptDir ".venv"
|
||||||
|
$venvPython = Join-Path $venvDir "Scripts\python.exe"
|
||||||
|
|
||||||
|
if (-not (Test-Path $venvPython)) {
|
||||||
|
Write-Host "Creating virtual environment..." -ForegroundColor Yellow
|
||||||
|
Invoke-Python -BaseCommand $pythonCmd -Arguments @("-m", "venv", ".venv")
|
||||||
|
} else {
|
||||||
|
Write-Host "Virtual environment already exists." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Upgrading pip..." -ForegroundColor Yellow
|
||||||
|
& $venvPython -m pip install --upgrade pip
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Failed to upgrade pip."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Installing dependencies from requirements.txt..." -ForegroundColor Yellow
|
||||||
|
& $venvPython -m pip install -r requirements.txt
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Failed to install dependencies from requirements.txt."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create icon assets for shortcuts
|
||||||
|
try {
|
||||||
|
Write-Host "Ensuring icon assets are available..." -ForegroundColor Yellow
|
||||||
|
& $venvPython create_icon.py
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Icon generation failed: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$shell = New-Object -ComObject WScript.Shell
|
||||||
|
$targetPath = Join-Path $scriptDir "run_app.bat"
|
||||||
|
$iconIco = Join-Path $scriptDir "icon.ico"
|
||||||
|
$iconPng = Join-Path $scriptDir "icon.png"
|
||||||
|
$iconLocation = "$env:SystemRoot\System32\shell32.dll,220"
|
||||||
|
if (Test-Path $iconIco) {
|
||||||
|
$iconLocation = $iconIco
|
||||||
|
} elseif (Test-Path $iconPng) {
|
||||||
|
$iconLocation = $iconPng
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-AppShortcut {
|
||||||
|
param(
|
||||||
|
[string]$ShortcutPath
|
||||||
|
)
|
||||||
|
|
||||||
|
$shortcut = $shell.CreateShortcut($ShortcutPath)
|
||||||
|
$shortcut.TargetPath = $targetPath
|
||||||
|
$shortcut.WorkingDirectory = $scriptDir
|
||||||
|
$shortcut.IconLocation = $iconLocation
|
||||||
|
$shortcut.Description = "Launch Journey FM Playlist Creator"
|
||||||
|
$shortcut.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $NoDesktopShortcut) {
|
||||||
|
try {
|
||||||
|
Write-Host "Creating desktop shortcut..." -ForegroundColor Yellow
|
||||||
|
$desktop = [Environment]::GetFolderPath("Desktop")
|
||||||
|
$shortcutPath = Join-Path $desktop "Journey FM Playlist Creator.lnk"
|
||||||
|
New-AppShortcut -ShortcutPath $shortcutPath
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Desktop shortcut creation failed: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $NoStartMenuShortcut) {
|
||||||
|
try {
|
||||||
|
Write-Host "Creating Start Menu shortcut..." -ForegroundColor Yellow
|
||||||
|
$programs = [Environment]::GetFolderPath("Programs")
|
||||||
|
$shortcutPath = Join-Path $programs "Journey FM Playlist Creator.lnk"
|
||||||
|
New-AppShortcut -ShortcutPath $shortcutPath
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Start Menu shortcut creation failed: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Installation complete." -ForegroundColor Green
|
||||||
|
Write-Host "Launch the app with: run_app.bat" -ForegroundColor Green
|
||||||
|
Write-Host "Or run the GUI directly with: .venv\Scripts\pythonw.exe journey_fm_app.py" -ForegroundColor Green
|
||||||
+78
-46
@@ -9,23 +9,24 @@ Features system tray integration, log viewing, and easy setup.
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import threading
|
import logging
|
||||||
import time
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# GUI imports
|
# 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, QGridLayout
|
try:
|
||||||
from PySide6.QtCore import QTimer, Qt, QThread, Signal, QSettings, QUrl
|
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, QGridLayout
|
||||||
from PySide6.QtGui import QIcon, QDesktopServices, QFont, QAction
|
from PySide6.QtCore import QTimer, Qt, QThread, Signal, QSettings, QUrl
|
||||||
import json
|
from PySide6.QtGui import QIcon, QDesktopServices, QFont, QAction
|
||||||
import csv
|
except Exception as gui_import_error:
|
||||||
from urllib.parse import quote
|
print("Failed to start GUI: required Qt/PySide6 dependencies are missing or not loadable.")
|
||||||
import sqlite3
|
print(f"Details: {gui_import_error}")
|
||||||
from datetime import datetime
|
print("On Linux, install graphics/runtime libs (for example: libgl1) and ensure PySide6 is installed.")
|
||||||
|
print("Then run: pip install -r requirements.txt")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# Import our existing functionality
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
from main import scrape_recently_played, create_playlist_in_plex, update_playlist, PLEX_TOKEN, SERVER_IP, PLAYLIST_NAME
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
"""Configuration management"""
|
"""Configuration management"""
|
||||||
@@ -49,11 +50,9 @@ class Config:
|
|||||||
# Migrate to QSettings
|
# Migrate to QSettings
|
||||||
for key, value in config.items():
|
for key, value in config.items():
|
||||||
self.set(key, value)
|
self.set(key, value)
|
||||||
# Remove old file
|
|
||||||
self.config_file.unlink()
|
|
||||||
return config
|
return config
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.warning("Failed loading config file %s: %s", self.config_file, e)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def save_config(self, config):
|
def save_config(self, config):
|
||||||
@@ -77,7 +76,17 @@ class Config:
|
|||||||
with open(self.config_file, 'w') as f:
|
with open(self.config_file, 'w') as f:
|
||||||
json.dump(json_config, f, indent=2)
|
json.dump(json_config, f, indent=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving config.json: {e}")
|
logger.error("Error saving config.json: %s", e)
|
||||||
|
|
||||||
|
def parse_bool(value):
|
||||||
|
"""Safely parse truthy values from QSettings/JSON."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return value != 0
|
||||||
|
return False
|
||||||
|
|
||||||
class SetupWizard(QDialog):
|
class SetupWizard(QDialog):
|
||||||
"""Setup wizard for initial configuration"""
|
"""Setup wizard for initial configuration"""
|
||||||
@@ -112,7 +121,7 @@ class SetupWizard(QDialog):
|
|||||||
help_button = QPushButton("?")
|
help_button = QPushButton("?")
|
||||||
help_button.setMaximumWidth(30)
|
help_button.setMaximumWidth(30)
|
||||||
help_button.setToolTip("Click to open Plex token page")
|
help_button.setToolTip("Click to open Plex token page")
|
||||||
help_button.clicked.connect(lambda: QDesktopServices.openUrl("https://plex.tv/claim"))
|
help_button.clicked.connect(lambda: QDesktopServices.openUrl(QUrl("https://plex.tv/claim")))
|
||||||
token_layout.addWidget(help_button)
|
token_layout.addWidget(help_button)
|
||||||
|
|
||||||
form_layout.addRow("Plex Token:", token_layout)
|
form_layout.addRow("Plex Token:", token_layout)
|
||||||
@@ -185,6 +194,8 @@ class SetupWizard(QDialog):
|
|||||||
selected_stations = config.get('SELECTED_STATIONS', 'journey_fm,spirit_fm')
|
selected_stations = config.get('SELECTED_STATIONS', 'journey_fm,spirit_fm')
|
||||||
if isinstance(selected_stations, str):
|
if isinstance(selected_stations, str):
|
||||||
selected_stations = selected_stations.split(',')
|
selected_stations = selected_stations.split(',')
|
||||||
|
elif isinstance(selected_stations, list):
|
||||||
|
selected_stations = [str(station).strip() for station in selected_stations]
|
||||||
else:
|
else:
|
||||||
selected_stations = ['journey_fm', 'spirit_fm']
|
selected_stations = ['journey_fm', 'spirit_fm']
|
||||||
self.journey_fm_checkbox.setChecked('journey_fm' in selected_stations)
|
self.journey_fm_checkbox.setChecked('journey_fm' in selected_stations)
|
||||||
@@ -283,13 +294,15 @@ class UpdateWorker(QThread):
|
|||||||
|
|
||||||
# Capture output by redirecting stdout
|
# Capture output by redirecting stdout
|
||||||
import io
|
import io
|
||||||
from contextlib import redirect_stdout
|
from contextlib import redirect_stdout, redirect_stderr
|
||||||
|
|
||||||
output_buffer = io.StringIO()
|
output_buffer = io.StringIO()
|
||||||
with redirect_stdout(output_buffer):
|
with redirect_stdout(output_buffer), redirect_stderr(output_buffer):
|
||||||
update_main()
|
update_main()
|
||||||
|
|
||||||
result = output_buffer.getvalue()
|
result = output_buffer.getvalue()
|
||||||
|
if not result.strip():
|
||||||
|
result = "Update completed."
|
||||||
self.finished.emit(result)
|
self.finished.emit(result)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -308,6 +321,7 @@ class MainWindow(QMainWindow):
|
|||||||
if os.path.exists(icon_path):
|
if os.path.exists(icon_path):
|
||||||
self.setWindowIcon(QIcon(icon_path))
|
self.setWindowIcon(QIcon(icon_path))
|
||||||
self.resize(800, 600)
|
self.resize(800, 600)
|
||||||
|
self.setMinimumSize(760, 520)
|
||||||
|
|
||||||
# Create central widget
|
# Create central widget
|
||||||
central = QWidget()
|
central = QWidget()
|
||||||
@@ -399,10 +413,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def run_update_playlist(self):
|
def run_update_playlist(self):
|
||||||
"""Run playlist update in background thread"""
|
"""Run playlist update in background thread"""
|
||||||
import threading
|
self.manual_update()
|
||||||
self.status_label.setText("Updating playlist...")
|
|
||||||
self.update_button.setEnabled(False)
|
|
||||||
threading.Thread(target=update_playlist, daemon=True).start()
|
|
||||||
|
|
||||||
def set_tray_icon(self, tray_icon):
|
def set_tray_icon(self, tray_icon):
|
||||||
"""Set the tray icon reference"""
|
"""Set the tray icon reference"""
|
||||||
@@ -420,7 +431,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def load_settings(self):
|
def load_settings(self):
|
||||||
"""Load settings and configure auto-update"""
|
"""Load settings and configure auto-update"""
|
||||||
auto_update = self.config.get('AUTO_UPDATE', False)
|
auto_update = parse_bool(self.config.get('AUTO_UPDATE', False))
|
||||||
if auto_update:
|
if auto_update:
|
||||||
interval = int(self.config.get('UPDATE_INTERVAL', 15))
|
interval = int(self.config.get('UPDATE_INTERVAL', 15))
|
||||||
unit = self.config.get('UPDATE_UNIT', 'Minutes')
|
unit = self.config.get('UPDATE_UNIT', 'Minutes')
|
||||||
@@ -444,6 +455,7 @@ class MainWindow(QMainWindow):
|
|||||||
"An update is already running. Please wait.")
|
"An update is already running. Please wait.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self.update_button.setEnabled(False)
|
||||||
self.worker = UpdateWorker()
|
self.worker = UpdateWorker()
|
||||||
self.worker.progress.connect(self.update_progress)
|
self.worker.progress.connect(self.update_progress)
|
||||||
self.worker.finished.connect(self.update_finished)
|
self.worker.finished.connect(self.update_finished)
|
||||||
@@ -460,6 +472,7 @@ class MainWindow(QMainWindow):
|
|||||||
def update_finished(self, result):
|
def update_finished(self, result):
|
||||||
"""Handle update completion"""
|
"""Handle update completion"""
|
||||||
self.progress_bar.setVisible(False)
|
self.progress_bar.setVisible(False)
|
||||||
|
self.update_button.setEnabled(True)
|
||||||
self.last_update_label.setText(f"Last update: {datetime.now().strftime('%Y-%m-%d %I:%M:%S %p')}")
|
self.last_update_label.setText(f"Last update: {datetime.now().strftime('%Y-%m-%d %I:%M:%S %p')}")
|
||||||
self.status_label.setText("Ready")
|
self.status_label.setText("Ready")
|
||||||
|
|
||||||
@@ -468,7 +481,7 @@ class MainWindow(QMainWindow):
|
|||||||
with open('playlist_log.txt', 'a', encoding='utf-8') as f:
|
with open('playlist_log.txt', 'a', encoding='utf-8') as f:
|
||||||
f.write(result + '\n')
|
f.write(result + '\n')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error writing to log: {e}")
|
logger.error("Error writing to log: %s", e)
|
||||||
|
|
||||||
# Refresh logs
|
# Refresh logs
|
||||||
self.log_viewer.refresh_logs()
|
self.log_viewer.refresh_logs()
|
||||||
@@ -479,10 +492,14 @@ class MainWindow(QMainWindow):
|
|||||||
else:
|
else:
|
||||||
# Extract summary from result
|
# Extract summary from result
|
||||||
lines = result.strip().split('\n')
|
lines = result.strip().split('\n')
|
||||||
|
summary_found = False
|
||||||
for line in reversed(lines):
|
for line in reversed(lines):
|
||||||
if line.startswith("Added") or line.startswith("No new songs") or line.startswith("No matching"):
|
if line.startswith("Added") or line.startswith("No new songs") or line.startswith("No matching"):
|
||||||
self.status_label.setText(line)
|
self.status_label.setText(line)
|
||||||
|
summary_found = True
|
||||||
break
|
break
|
||||||
|
if not summary_found:
|
||||||
|
self.status_label.setText("Update completed")
|
||||||
|
|
||||||
def auto_update(self):
|
def auto_update(self):
|
||||||
"""Automatic update (runs in background)"""
|
"""Automatic update (runs in background)"""
|
||||||
@@ -502,7 +519,7 @@ class MainWindow(QMainWindow):
|
|||||||
with open('playlist_log.txt', 'a', encoding='utf-8') as f:
|
with open('playlist_log.txt', 'a', encoding='utf-8') as f:
|
||||||
f.write(result + '\n')
|
f.write(result + '\n')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error writing to log: {e}")
|
logger.error("Error writing to log: %s", e)
|
||||||
|
|
||||||
# Refresh logs
|
# Refresh logs
|
||||||
self.log_viewer.refresh_logs()
|
self.log_viewer.refresh_logs()
|
||||||
@@ -598,9 +615,6 @@ class MainWindow(QMainWindow):
|
|||||||
layout.addLayout(button_layout)
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
dialog.setLayout(layout)
|
dialog.setLayout(layout)
|
||||||
dialog.show()
|
|
||||||
dialog.raise_()
|
|
||||||
dialog.activateWindow()
|
|
||||||
dialog.exec()
|
dialog.exec()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error", f"Failed to show buy list dialog: {e}")
|
QMessageBox.critical(self, "Error", f"Failed to show buy list dialog: {e}")
|
||||||
@@ -627,7 +641,7 @@ class MainWindow(QMainWindow):
|
|||||||
item = self.buy_list_widget.item(i)
|
item = self.buy_list_widget.item(i)
|
||||||
if item.checkState() == Qt.CheckState.Checked:
|
if item.checkState() == Qt.CheckState.Checked:
|
||||||
url = item.data(1)
|
url = item.data(1)
|
||||||
QDesktopServices.openUrl(url)
|
QDesktopServices.openUrl(QUrl(url))
|
||||||
|
|
||||||
def remove_selected_buy_items(self, all_songs, dialog):
|
def remove_selected_buy_items(self, all_songs, dialog):
|
||||||
"""Remove selected items from buy list"""
|
"""Remove selected items from buy list"""
|
||||||
@@ -653,10 +667,14 @@ class MainWindow(QMainWindow):
|
|||||||
updated_songs = [(at, url) for at, url in all_songs if at not in to_remove]
|
updated_songs = [(at, url) for at, url in all_songs if at not in to_remove]
|
||||||
|
|
||||||
# Rewrite file
|
# Rewrite file
|
||||||
with open('amazon_buy_list.txt', 'w') as f:
|
try:
|
||||||
f.write("Songs not in your library - Amazon search links:\n\n")
|
with open('amazon_buy_list.txt', 'w') as f:
|
||||||
for artist_title, url in updated_songs:
|
f.write("Songs not in your library - Amazon search links:\n\n")
|
||||||
f.write(f"{artist_title}\n{url}\n\n")
|
for artist_title, url in updated_songs:
|
||||||
|
f.write(f"{artist_title}\n{url}\n\n")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(dialog, "Error", f"Failed to update buy list file: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
# Update display
|
# Update display
|
||||||
self.populate_buy_list(updated_songs)
|
self.populate_buy_list(updated_songs)
|
||||||
@@ -833,7 +851,11 @@ class MainWindow(QMainWindow):
|
|||||||
table = QTableWidget()
|
table = QTableWidget()
|
||||||
table.setColumnCount(4)
|
table.setColumnCount(4)
|
||||||
table.setHorizontalHeaderLabels(["Date", "Type", "Artist", "Song"])
|
table.setHorizontalHeaderLabels(["Date", "Type", "Artist", "Song"])
|
||||||
table.horizontalHeader().setStretchLastSection(True)
|
table.setWordWrap(True)
|
||||||
|
table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect('playlist_history.db')
|
conn = sqlite3.connect('playlist_history.db')
|
||||||
@@ -848,7 +870,7 @@ class MainWindow(QMainWindow):
|
|||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(date)
|
dt = datetime.fromisoformat(date)
|
||||||
date_str = dt.strftime('%Y-%m-%d %H:%M')
|
date_str = dt.strftime('%Y-%m-%d %H:%M')
|
||||||
except:
|
except Exception:
|
||||||
date_str = date
|
date_str = date
|
||||||
|
|
||||||
# Added songs
|
# Added songs
|
||||||
@@ -862,7 +884,7 @@ class MainWindow(QMainWindow):
|
|||||||
title = song
|
title = song
|
||||||
artist = "Unknown"
|
artist = "Unknown"
|
||||||
entries.append((date_str, "Added", artist, title))
|
entries.append((date_str, "Added", artist, title))
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Missing songs
|
# Missing songs
|
||||||
@@ -872,7 +894,7 @@ class MainWindow(QMainWindow):
|
|||||||
artist = song.get('artist', 'Unknown')
|
artist = song.get('artist', 'Unknown')
|
||||||
title = song.get('title', 'Unknown')
|
title = song.get('title', 'Unknown')
|
||||||
entries.append((date_str, "Missing", artist, title))
|
entries.append((date_str, "Missing", artist, title))
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
table.setRowCount(len(entries))
|
table.setRowCount(len(entries))
|
||||||
@@ -931,7 +953,7 @@ class MainWindow(QMainWindow):
|
|||||||
try:
|
try:
|
||||||
playlist = plex.playlist(playlist_name)
|
playlist = plex.playlist(playlist_name)
|
||||||
tracks = playlist.items()
|
tracks = playlist.items()
|
||||||
except:
|
except Exception:
|
||||||
QMessageBox.warning(self, "Playlist Error", f"Playlist '{playlist_name}' not found.")
|
QMessageBox.warning(self, "Playlist Error", f"Playlist '{playlist_name}' not found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -970,6 +992,7 @@ class SystemTrayApp:
|
|||||||
"""System tray application wrapper"""
|
"""System tray application wrapper"""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.app = QApplication(sys.argv)
|
self.app = QApplication(sys.argv)
|
||||||
|
self.tray_icon = None
|
||||||
self.app.setApplicationName("Journey FM Playlist Creator")
|
self.app.setApplicationName("Journey FM Playlist Creator")
|
||||||
self.app.setApplicationVersion("1.0")
|
self.app.setApplicationVersion("1.0")
|
||||||
self.app.setOrganizationName("JourneyFM")
|
self.app.setOrganizationName("JourneyFM")
|
||||||
@@ -1000,7 +1023,7 @@ class SystemTrayApp:
|
|||||||
tray_menu = QMenu()
|
tray_menu = QMenu()
|
||||||
|
|
||||||
show_action = QAction("Show", self.main_window)
|
show_action = QAction("Show", self.main_window)
|
||||||
show_action.triggered.connect(self.main_window.show)
|
show_action.triggered.connect(self.show_main_window)
|
||||||
tray_menu.addAction(show_action)
|
tray_menu.addAction(show_action)
|
||||||
|
|
||||||
update_action = QAction("Update Now", self.main_window)
|
update_action = QAction("Update Now", self.main_window)
|
||||||
@@ -1018,12 +1041,19 @@ class SystemTrayApp:
|
|||||||
|
|
||||||
self.tray_icon.show()
|
self.tray_icon.show()
|
||||||
|
|
||||||
|
def show_main_window(self):
|
||||||
|
"""Restore and focus the main window."""
|
||||||
|
self.main_window.show()
|
||||||
|
self.main_window.setWindowState(
|
||||||
|
self.main_window.windowState() & ~Qt.WindowState.WindowMinimized
|
||||||
|
)
|
||||||
|
self.main_window.raise_()
|
||||||
|
self.main_window.activateWindow()
|
||||||
|
|
||||||
def tray_activated(self, reason):
|
def tray_activated(self, reason):
|
||||||
"""Handle tray icon activation"""
|
"""Handle tray icon activation"""
|
||||||
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
|
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
|
||||||
self.main_window.show()
|
self.show_main_window()
|
||||||
self.main_window.raise_()
|
|
||||||
self.main_window.activateWindow()
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Run the application"""
|
"""Run the application"""
|
||||||
@@ -1035,8 +1065,10 @@ def main():
|
|||||||
log.write(f"{datetime.now()}: Starting Journey FM Playlist app...\n")
|
log.write(f"{datetime.now()}: Starting Journey FM Playlist app...\n")
|
||||||
print("Starting Journey FM Playlist app...")
|
print("Starting Journey FM Playlist app...")
|
||||||
try:
|
try:
|
||||||
if not os.environ.get('DISPLAY'):
|
has_x11 = bool(os.environ.get('DISPLAY'))
|
||||||
raise Exception("DISPLAY environment variable not set - no graphical display available")
|
has_wayland = bool(os.environ.get('WAYLAND_DISPLAY'))
|
||||||
|
if os.name != 'nt' and not (has_x11 or has_wayland):
|
||||||
|
raise Exception("No graphical display detected (DISPLAY/WAYLAND_DISPLAY not set)")
|
||||||
|
|
||||||
print("Initializing GUI...")
|
print("Initializing GUI...")
|
||||||
with open('app_log.txt', 'a') as log:
|
with open('app_log.txt', 'a') as log:
|
||||||
|
|||||||
@@ -25,12 +25,57 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import urllib.parse
|
import logging
|
||||||
|
import shutil
|
||||||
|
|
||||||
# Configuration (defaults, will be overridden by config.json)
|
# Configuration (defaults, can be overridden by env vars or config.json)
|
||||||
PLEX_TOKEN = 'pU-m3HWYUZU6iXJFhJyA' # Your Plex.tv token
|
PLEX_TOKEN = os.getenv('PLEX_TOKEN', '').strip()
|
||||||
SERVER_IP = '172.16.16.106' # Your local server IP
|
SERVER_IP = os.getenv('SERVER_IP', '').strip()
|
||||||
PLAYLIST_NAME = 'Journey FM Holiday'
|
PLAYLIST_NAME = os.getenv('PLAYLIST_NAME', 'Journey FM Holiday').strip()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def detect_chrome_binary():
|
||||||
|
"""Detect a Chrome/Chromium executable across common OS locations."""
|
||||||
|
env_binary = os.getenv('CHROME_BINARY', '').strip()
|
||||||
|
if env_binary and os.path.exists(env_binary):
|
||||||
|
return env_binary
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
if os.name == 'nt':
|
||||||
|
local_app_data = os.getenv('LOCALAPPDATA', '')
|
||||||
|
program_files = os.getenv('PROGRAMFILES', '')
|
||||||
|
program_files_x86 = os.getenv('PROGRAMFILES(X86)', '')
|
||||||
|
candidates.extend([
|
||||||
|
os.path.join(local_app_data, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||||
|
os.path.join(program_files, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||||
|
os.path.join(program_files_x86, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||||
|
os.path.join(program_files, 'Chromium', 'Application', 'chrome.exe'),
|
||||||
|
os.path.join(program_files_x86, 'Chromium', 'Application', 'chrome.exe')
|
||||||
|
])
|
||||||
|
elif os.name == 'posix':
|
||||||
|
candidates.extend([
|
||||||
|
'/usr/bin/google-chrome',
|
||||||
|
'/usr/bin/google-chrome-stable',
|
||||||
|
'/usr/bin/chromium',
|
||||||
|
'/usr/bin/chromium-browser',
|
||||||
|
'/opt/google/chrome/chrome',
|
||||||
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||||
|
'/Applications/Chromium.app/Contents/MacOS/Chromium'
|
||||||
|
])
|
||||||
|
|
||||||
|
for binary in candidates:
|
||||||
|
if binary and os.path.exists(binary):
|
||||||
|
return binary
|
||||||
|
|
||||||
|
for name in ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome']:
|
||||||
|
found = shutil.which(name)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def scrape_recently_played(selected_stations=None):
|
def scrape_recently_played(selected_stations=None):
|
||||||
"""Scrape recently played songs from Journey FM and Spirit FM"""
|
"""Scrape recently played songs from Journey FM and Spirit FM"""
|
||||||
@@ -47,14 +92,19 @@ def scrape_recently_played(selected_stations=None):
|
|||||||
options.add_argument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
options.add_argument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||||
options.add_experimental_option('useAutomationExtension', False)
|
options.add_experimental_option('useAutomationExtension', False)
|
||||||
options.binary_location = "/usr/bin/google-chrome-stable"
|
chrome_binary = detect_chrome_binary()
|
||||||
|
if chrome_binary:
|
||||||
|
options.binary_location = chrome_binary
|
||||||
|
else:
|
||||||
|
logger.warning("No Chrome/Chromium binary detected; Selenium will use its default browser resolution")
|
||||||
|
|
||||||
service = Service(ChromeDriverManager().install())
|
service = Service(ChromeDriverManager().install())
|
||||||
|
driver = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
driver = webdriver.Chrome(service=service, options=options)
|
driver = webdriver.Chrome(service=service, options=options)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to start Chrome: {e}")
|
logger.error("Failed to start Chrome: %s", e)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
all_songs = []
|
all_songs = []
|
||||||
@@ -63,16 +113,20 @@ def scrape_recently_played(selected_stations=None):
|
|||||||
# Journey FM - uses <strong> for title, normal text for artist
|
# Journey FM - uses <strong> for title, normal text for artist
|
||||||
if 'journey_fm' in selected_stations:
|
if 'journey_fm' in selected_stations:
|
||||||
try:
|
try:
|
||||||
print("Scraping Journey FM...")
|
logger.info("Scraping Journey FM...")
|
||||||
driver.get('https://www.myjourneyfm.com/recently-played/')
|
driver.get('https://www.myjourneyfm.com/recently-played/')
|
||||||
time.sleep(5)
|
WebDriverWait(driver, 15).until(
|
||||||
|
EC.presence_of_element_located((By.CSS_SELECTOR, 'div.rp-item'))
|
||||||
|
)
|
||||||
|
|
||||||
# Click "View More" button if exists
|
# Click "View More" button if exists
|
||||||
try:
|
try:
|
||||||
more_button = driver.find_element(By.ID, "moreSongs")
|
more_button = WebDriverWait(driver, 5).until(
|
||||||
|
EC.element_to_be_clickable((By.ID, "moreSongs"))
|
||||||
|
)
|
||||||
more_button.click()
|
more_button.click()
|
||||||
time.sleep(5)
|
time.sleep(2)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
soup = BeautifulSoup(driver.page_source, 'html.parser')
|
soup = BeautifulSoup(driver.page_source, 'html.parser')
|
||||||
@@ -94,19 +148,19 @@ def scrape_recently_played(selected_stations=None):
|
|||||||
if key not in seen:
|
if key not in seen:
|
||||||
all_songs.append({'title': title, 'artist': artist, 'source': 'Journey FM'})
|
all_songs.append({'title': title, 'artist': artist, 'source': 'Journey FM'})
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
print(f" Found: {title} by {artist}")
|
logger.info(" Found: %s by %s", title, artist)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error scraping Journey FM: {e}")
|
logger.error("Error scraping Journey FM: %s", e)
|
||||||
|
|
||||||
# Spirit FM - loads songs from iframe text file
|
# Spirit FM - loads songs from iframe text file
|
||||||
if 'spirit_fm' in selected_stations:
|
if 'spirit_fm' in selected_stations:
|
||||||
try:
|
try:
|
||||||
print("Scraping Spirit FM...")
|
logger.info("Scraping Spirit FM...")
|
||||||
driver.get('https://spiritfm.com/ajax/now_playing_history.txt')
|
driver.get('https://spiritfm.com/ajax/now_playing_history.txt')
|
||||||
time.sleep(3)
|
time.sleep(2)
|
||||||
|
|
||||||
# Parse the plain text format: "Mon 01:45PM Artist - Title"
|
# Parse the plain text format: "Mon 01:45PM Artist - Title"
|
||||||
soup = BeautifulSoup(driver.page_source, 'html.parser')
|
soup = BeautifulSoup(driver.page_source, 'html.parser')
|
||||||
@@ -133,19 +187,20 @@ def scrape_recently_played(selected_stations=None):
|
|||||||
if key not in seen:
|
if key not in seen:
|
||||||
all_songs.append({'title': title, 'artist': artist, 'source': 'Spirit FM'})
|
all_songs.append({'title': title, 'artist': artist, 'source': 'Spirit FM'})
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
print(f" Found: {title} by {artist}")
|
logger.info(" Found: %s by %s", title, artist)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error scraping Spirit FM: {e}")
|
logger.error("Error scraping Spirit FM: %s", e)
|
||||||
|
|
||||||
driver.quit()
|
if driver:
|
||||||
|
driver.quit()
|
||||||
|
|
||||||
print(f"\nTotal unique songs found: {len(all_songs)}")
|
logger.info("Total unique songs found: %s", len(all_songs))
|
||||||
return all_songs
|
return all_songs
|
||||||
|
|
||||||
def normalize_string(s):
|
def normalize_string(s):
|
||||||
"""Normalize string for comparison"""
|
"""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):
|
def find_song_in_library(artist, title, library_path):
|
||||||
"""Check if song exists in local library"""
|
"""Check if song exists in local library"""
|
||||||
@@ -158,7 +213,7 @@ def find_song_in_library(artist, title, library_path):
|
|||||||
file_title = normalize_string(audio.get('title', [''])[0])
|
file_title = normalize_string(audio.get('title', [''])[0])
|
||||||
if normalize_string(artist) == file_artist and normalize_string(title) == file_title:
|
if normalize_string(artist) == file_artist and normalize_string(title) == file_title:
|
||||||
return os.path.join(root, file)
|
return os.path.join(root, file)
|
||||||
except:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -204,7 +259,7 @@ def create_playlist_in_plex(plex, songs, playlist_name):
|
|||||||
search_artist_lower = search_artist.lower()
|
search_artist_lower = search_artist.lower()
|
||||||
|
|
||||||
# Clean featured artists from search (e.g., "W/ Emerson Day", "feat. Someone")
|
# 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
|
# Normalize & and + in track artist as well
|
||||||
track_artist = re.sub(r'\s*[&+]\s*', ' and ', track_artist)
|
track_artist = re.sub(r'\s*[&+]\s*', ' and ', track_artist)
|
||||||
@@ -244,7 +299,7 @@ def create_playlist_in_plex(plex, songs, playlist_name):
|
|||||||
added_songs.extend([f"{track.title} by {track.artist().title} ({song['source']})" for track, song in new_tracks])
|
added_songs.extend([f"{track.title} by {track.artist().title} ({song['source']})" for track, song in new_tracks])
|
||||||
else:
|
else:
|
||||||
added = 0
|
added = 0
|
||||||
except:
|
except Exception:
|
||||||
# Playlist doesn't exist, create it
|
# Playlist doesn't exist, create it
|
||||||
plex.createPlaylist(playlist_name, [ts[0] for ts in tracks])
|
plex.createPlaylist(playlist_name, [ts[0] for ts in tracks])
|
||||||
for track, song in tracks:
|
for track, song in tracks:
|
||||||
@@ -253,11 +308,11 @@ def create_playlist_in_plex(plex, songs, playlist_name):
|
|||||||
track.rate(5)
|
track.rate(5)
|
||||||
added = len(tracks)
|
added = len(tracks)
|
||||||
added_songs.extend([f"{track.title} by {track.artist().title} ({song['source']})" for track, song in tracks])
|
added_songs.extend([f"{track.title} by {track.artist().title} ({song['source']})" for track, song in tracks])
|
||||||
except Exception as e:
|
except Exception:
|
||||||
added = 0
|
added = 0
|
||||||
added_songs = []
|
added_songs = []
|
||||||
else:
|
else:
|
||||||
print("No matching tracks found in Plex.")
|
logger.info("No matching tracks found in Plex.")
|
||||||
added = 0
|
added = 0
|
||||||
added_songs = []
|
added_songs = []
|
||||||
|
|
||||||
@@ -278,6 +333,37 @@ def prompt_for_config():
|
|||||||
json.dump(config, f)
|
json.dump(config, f)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
"""Load runtime configuration from config.json and environment variables."""
|
||||||
|
config = {
|
||||||
|
'PLEX_TOKEN': PLEX_TOKEN,
|
||||||
|
'SERVER_IP': SERVER_IP,
|
||||||
|
'PLAYLIST_NAME': PLAYLIST_NAME,
|
||||||
|
'SELECTED_STATIONS': ['journey_fm', 'spirit_fm']
|
||||||
|
}
|
||||||
|
|
||||||
|
if not os.path.exists('config.json'):
|
||||||
|
return config
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open('config.json', 'r') as f:
|
||||||
|
file_config = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Unable to parse config.json: %s", e)
|
||||||
|
return config
|
||||||
|
|
||||||
|
config['PLEX_TOKEN'] = file_config.get('PLEX_TOKEN', config['PLEX_TOKEN'])
|
||||||
|
config['SERVER_IP'] = file_config.get('SERVER_IP', config['SERVER_IP'])
|
||||||
|
config['PLAYLIST_NAME'] = file_config.get('PLAYLIST_NAME', config['PLAYLIST_NAME'])
|
||||||
|
|
||||||
|
selected_stations = file_config.get('SELECTED_STATIONS')
|
||||||
|
if isinstance(selected_stations, str) and selected_stations.strip():
|
||||||
|
config['SELECTED_STATIONS'] = [s.strip() for s in selected_stations.split(',') if s.strip()]
|
||||||
|
elif isinstance(selected_stations, list) and selected_stations:
|
||||||
|
config['SELECTED_STATIONS'] = [str(s).strip() for s in selected_stations if str(s).strip()]
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
def init_history_db():
|
def init_history_db():
|
||||||
"""Initialize the history database"""
|
"""Initialize the history database"""
|
||||||
conn = sqlite3.connect('playlist_history.db')
|
conn = sqlite3.connect('playlist_history.db')
|
||||||
@@ -310,28 +396,21 @@ def setup_scheduler(params):
|
|||||||
cmd.extend(['-' + k, str(v)])
|
cmd.extend(['-' + k, str(v)])
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True)
|
subprocess.run(cmd, check=True)
|
||||||
print("Scheduler setup completed.")
|
logger.info("Scheduler setup completed.")
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
print(f"Scheduler setup failed: {e}")
|
logger.error("Scheduler setup failed: %s", e)
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
global PLEX_TOKEN, SERVER_IP, PLAYLIST_NAME
|
global PLEX_TOKEN, SERVER_IP, PLAYLIST_NAME
|
||||||
# Load or prompt for config
|
config = load_config()
|
||||||
selected_stations = ['journey_fm', 'spirit_fm'] # default
|
PLEX_TOKEN = config['PLEX_TOKEN']
|
||||||
if os.path.exists('config.json'):
|
SERVER_IP = config['SERVER_IP']
|
||||||
with open('config.json', 'r') as f:
|
PLAYLIST_NAME = config['PLAYLIST_NAME']
|
||||||
config = json.load(f)
|
selected_stations = config['SELECTED_STATIONS']
|
||||||
PLEX_TOKEN = config.get('PLEX_TOKEN', PLEX_TOKEN)
|
|
||||||
SERVER_IP = config.get('SERVER_IP', SERVER_IP)
|
if not PLEX_TOKEN or not SERVER_IP:
|
||||||
PLAYLIST_NAME = config.get('PLAYLIST_NAME', PLAYLIST_NAME)
|
logger.error("Missing configuration: set PLEX_TOKEN and SERVER_IP in config.json or environment variables")
|
||||||
# Load selected stations
|
return
|
||||||
selected_stations_str = config.get('SELECTED_STATIONS', 'journey_fm,spirit_fm')
|
|
||||||
if isinstance(selected_stations_str, str) and selected_stations_str:
|
|
||||||
selected_stations = [s for s in selected_stations_str.split(',') if s]
|
|
||||||
# If empty or invalid, keep default
|
|
||||||
else:
|
|
||||||
# If no config file, use defaults (GUI should handle this)
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Log start time
|
# Log start time
|
||||||
start_time = datetime.now()
|
start_time = datetime.now()
|
||||||
@@ -343,8 +422,12 @@ def main():
|
|||||||
songs = scrape_recently_played(selected_stations)
|
songs = scrape_recently_played(selected_stations)
|
||||||
|
|
||||||
# Connect to Plex via MyPlexAccount
|
# Connect to Plex via MyPlexAccount
|
||||||
account = MyPlexAccount(token=PLEX_TOKEN)
|
try:
|
||||||
resources = account.resources()
|
account = MyPlexAccount(token=PLEX_TOKEN)
|
||||||
|
resources = account.resources()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to authenticate with Plex: %s", e)
|
||||||
|
return
|
||||||
|
|
||||||
# Find the server by IP
|
# Find the server by IP
|
||||||
server_resource = None
|
server_resource = None
|
||||||
@@ -357,16 +440,14 @@ def main():
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not server_resource:
|
if not server_resource:
|
||||||
print("Please claim the server: Open http://172.16.16.106:32400/web, sign in with your Plex account, and follow the claim prompts.")
|
logger.error("Server not found at %s. Verify SERVER_IP and that Plex is reachable.", SERVER_IP)
|
||||||
return
|
return
|
||||||
|
|
||||||
plex = server_resource.connect()
|
try:
|
||||||
|
plex = server_resource.connect()
|
||||||
# Debug: list library sections
|
except Exception as e:
|
||||||
|
logger.error("Failed to connect to Plex server: %s", e)
|
||||||
# Debug: list existing playlists (handle unicode safely)
|
return
|
||||||
playlists = plex.playlists()
|
|
||||||
playlist_names = [p.title.encode('ascii', 'ignore').decode('ascii') if not p.title.isascii() else p.title for p in playlists]
|
|
||||||
|
|
||||||
# Find matching songs in local library (optional, since Plex search might suffice)
|
# Find matching songs in local library (optional, since Plex search might suffice)
|
||||||
# But to filter only those in local library, perhaps skip or adjust
|
# But to filter only those in local library, perhaps skip or adjust
|
||||||
@@ -376,7 +457,7 @@ def main():
|
|||||||
if songs:
|
if songs:
|
||||||
added, added_songs, missing = create_playlist_in_plex(plex, songs, PLAYLIST_NAME)
|
added, added_songs, missing = create_playlist_in_plex(plex, songs, PLAYLIST_NAME)
|
||||||
else:
|
else:
|
||||||
print("No songs found.")
|
logger.info("No songs found.")
|
||||||
added, added_songs, missing = 0, [], []
|
added, added_songs, missing = 0, [], []
|
||||||
|
|
||||||
# Save to history
|
# Save to history
|
||||||
@@ -403,7 +484,7 @@ def main():
|
|||||||
existing_songs.add((artist.strip(), title.strip()))
|
existing_songs.add((artist.strip(), title.strip()))
|
||||||
i += 1
|
i += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pass
|
logger.warning("Failed reading amazon_buy_list.txt: %s", e)
|
||||||
|
|
||||||
# Filter new missing songs
|
# Filter new missing songs
|
||||||
new_missing = []
|
new_missing = []
|
||||||
@@ -425,9 +506,9 @@ def main():
|
|||||||
url = f"https://www.amazon.com/s?k={query}&i=digital-music"
|
url = f"https://www.amazon.com/s?k={query}&i=digital-music"
|
||||||
f.write(f"{artist} - {title}\n{url}\n\n")
|
f.write(f"{artist} - {title}\n{url}\n\n")
|
||||||
else:
|
else:
|
||||||
print("No new songs to add to buy list.")
|
logger.info("No new songs to add to buy list.")
|
||||||
|
|
||||||
print("Buy list updated.")
|
logger.info("Buy list updated.")
|
||||||
|
|
||||||
# Log completion
|
# Log completion
|
||||||
end_time = datetime.now()
|
end_time = datetime.now()
|
||||||
|
|||||||
+5
-4
@@ -3,10 +3,11 @@ REM Journey FM Playlist Creator - Windows Launcher
|
|||||||
|
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
|
|
||||||
REM Activate virtual environment if it exists
|
REM Prefer virtual environment pythonw if it exists
|
||||||
if exist .venv\Scripts\activate.bat (
|
if exist .venv\Scripts\pythonw.exe (
|
||||||
call .venv\Scripts\activate.bat
|
.venv\Scripts\pythonw.exe journey_fm_app.py
|
||||||
|
exit /b %errorlevel%
|
||||||
)
|
)
|
||||||
|
|
||||||
REM Run the application without console window
|
REM Fallback to PATH pythonw
|
||||||
pythonw journey_fm_app.py
|
pythonw journey_fm_app.py
|
||||||
Reference in new issue
Block a user