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
|
||||
|
||||
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
|
||||
- **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
|
||||
## Requirements
|
||||
|
||||
## 🚀 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
|
||||
|
||||
1. **Install Dependencies**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
Simple install from GitHub (recommended):
|
||||
|
||||
2. **Create Application Icon**
|
||||
```bash
|
||||
python create_icon.py
|
||||
```
|
||||
1. Open **PowerShell**.
|
||||
2. Run:
|
||||
|
||||
3. **Run the Application**
|
||||
```bash
|
||||
python journey_fm_app.py
|
||||
```
|
||||
Or use the batch file: `run_app.bat`
|
||||
```powershell
|
||||
git clone https://github.com/nmemmert/journey-fm-playlist.git
|
||||
cd journey-fm-playlist
|
||||
.\install_windows.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
|
||||
|
||||
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
|
||||
Quick setup:
|
||||
|
||||
2. **Launch from Menu**
|
||||
- Search for "Journey FM Playlist" in your applications menu
|
||||
- Or run: `./journey_fm_app.sh`
|
||||
```bash
|
||||
./install.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
|
||||
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
|
||||
Use the GUI setup wizard (first launch) or edit `config.json` directly.
|
||||
|
||||
#### How to Get Your Plex Token
|
||||
Example `config.json`:
|
||||
|
||||
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
|
||||
```json
|
||||
{
|
||||
"PLEX_TOKEN": "your-plex-token",
|
||||
"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
|
||||
- **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
|
||||
If `CHROME_BINARY` is not set, the app auto-detects browser locations for Windows/Linux/macOS and then falls back to `PATH`.
|
||||
|
||||
## 📋 Log Viewer
|
||||
## Running Without GUI
|
||||
|
||||
The built-in log viewer shows:
|
||||
- Update timestamps
|
||||
- Songs added to playlist
|
||||
- Error messages
|
||||
- Update duration
|
||||
- Connection status
|
||||
Run one update cycle from CLI:
|
||||
|
||||
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.
|
||||
## 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.
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
## Output Files
|
||||
|
||||
## 🔧 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
|
||||
- `SystemTrayApp`: Tray icon and menu
|
||||
- `SetupWizard`: Configuration wizard
|
||||
- `LogViewer`: Log display widget
|
||||
- `UpdateWorker`: Background update thread
|
||||
## Core Files
|
||||
|
||||
### 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)**
|
||||
```bash
|
||||
pip install pyinstaller
|
||||
pyinstaller --onefile --windowed --icon=icon.ico journey_fm_app.py
|
||||
```
|
||||
## Pre-Release Checklist
|
||||
|
||||
**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.
|
||||
- Verify `config.json` has valid `PLEX_TOKEN`, `SERVER_IP`, and `PLAYLIST_NAME`.
|
||||
- Run one CLI update: `python main.py` and confirm songs/history are written.
|
||||
- Run GUI update: `python journey_fm_app.py` and verify status/log output updates.
|
||||
- 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.
|
||||
+8
-2
@@ -29,5 +29,11 @@ if __name__ == "__main__":
|
||||
app = QApplication(sys.argv)
|
||||
icon = create_icon()
|
||||
pixmap = icon.pixmap(QSize(64, 64))
|
||||
pixmap.save("icon.png")
|
||||
print("Icon created: icon.png")
|
||||
png_ok = pixmap.save("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 os
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# GUI imports
|
||||
from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QPushButton, QTextEdit, QLineEdit, QLabel, QHBoxLayout, QDialog, QListWidget, QListWidgetItem, QTableWidget, QTableWidgetItem, QHeaderView, QCheckBox, QInputDialog, QMessageBox, QProgressBar, QSystemTrayIcon, QMenu, QComboBox, QGroupBox, QFormLayout, QSpinBox, QTextBrowser, QTabWidget, QDialogButtonBox, QSplitter, QGridLayout
|
||||
from PySide6.QtCore import QTimer, Qt, QThread, Signal, QSettings, QUrl
|
||||
from PySide6.QtGui import QIcon, QDesktopServices, QFont, QAction
|
||||
import json
|
||||
import csv
|
||||
from urllib.parse import quote
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
try:
|
||||
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.QtCore import QTimer, Qt, QThread, Signal, QSettings, QUrl
|
||||
from PySide6.QtGui import QIcon, QDesktopServices, QFont, QAction
|
||||
except Exception as gui_import_error:
|
||||
print("Failed to start GUI: required Qt/PySide6 dependencies are missing or not loadable.")
|
||||
print(f"Details: {gui_import_error}")
|
||||
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
|
||||
from main import scrape_recently_played, create_playlist_in_plex, update_playlist, PLEX_TOKEN, SERVER_IP, PLAYLIST_NAME
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Config:
|
||||
"""Configuration management"""
|
||||
@@ -49,11 +50,9 @@ class Config:
|
||||
# Migrate to QSettings
|
||||
for key, value in config.items():
|
||||
self.set(key, value)
|
||||
# Remove old file
|
||||
self.config_file.unlink()
|
||||
return config
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Failed loading config file %s: %s", self.config_file, e)
|
||||
return {}
|
||||
|
||||
def save_config(self, config):
|
||||
@@ -77,7 +76,17 @@ class Config:
|
||||
with open(self.config_file, 'w') as f:
|
||||
json.dump(json_config, f, indent=2)
|
||||
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):
|
||||
"""Setup wizard for initial configuration"""
|
||||
@@ -112,7 +121,7 @@ class SetupWizard(QDialog):
|
||||
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"))
|
||||
help_button.clicked.connect(lambda: QDesktopServices.openUrl(QUrl("https://plex.tv/claim")))
|
||||
token_layout.addWidget(help_button)
|
||||
|
||||
form_layout.addRow("Plex Token:", token_layout)
|
||||
@@ -185,6 +194,8 @@ class SetupWizard(QDialog):
|
||||
selected_stations = config.get('SELECTED_STATIONS', 'journey_fm,spirit_fm')
|
||||
if isinstance(selected_stations, str):
|
||||
selected_stations = selected_stations.split(',')
|
||||
elif isinstance(selected_stations, list):
|
||||
selected_stations = [str(station).strip() for station in selected_stations]
|
||||
else:
|
||||
selected_stations = ['journey_fm', 'spirit_fm']
|
||||
self.journey_fm_checkbox.setChecked('journey_fm' in selected_stations)
|
||||
@@ -283,13 +294,15 @@ class UpdateWorker(QThread):
|
||||
|
||||
# Capture output by redirecting stdout
|
||||
import io
|
||||
from contextlib import redirect_stdout
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
|
||||
output_buffer = io.StringIO()
|
||||
with redirect_stdout(output_buffer):
|
||||
with redirect_stdout(output_buffer), redirect_stderr(output_buffer):
|
||||
update_main()
|
||||
|
||||
result = output_buffer.getvalue()
|
||||
if not result.strip():
|
||||
result = "Update completed."
|
||||
self.finished.emit(result)
|
||||
|
||||
except Exception as e:
|
||||
@@ -308,6 +321,7 @@ class MainWindow(QMainWindow):
|
||||
if os.path.exists(icon_path):
|
||||
self.setWindowIcon(QIcon(icon_path))
|
||||
self.resize(800, 600)
|
||||
self.setMinimumSize(760, 520)
|
||||
|
||||
# Create central widget
|
||||
central = QWidget()
|
||||
@@ -399,10 +413,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def run_update_playlist(self):
|
||||
"""Run playlist update in background thread"""
|
||||
import threading
|
||||
self.status_label.setText("Updating playlist...")
|
||||
self.update_button.setEnabled(False)
|
||||
threading.Thread(target=update_playlist, daemon=True).start()
|
||||
self.manual_update()
|
||||
|
||||
def set_tray_icon(self, tray_icon):
|
||||
"""Set the tray icon reference"""
|
||||
@@ -420,7 +431,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def load_settings(self):
|
||||
"""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:
|
||||
interval = int(self.config.get('UPDATE_INTERVAL', 15))
|
||||
unit = self.config.get('UPDATE_UNIT', 'Minutes')
|
||||
@@ -444,6 +455,7 @@ class MainWindow(QMainWindow):
|
||||
"An update is already running. Please wait.")
|
||||
return
|
||||
|
||||
self.update_button.setEnabled(False)
|
||||
self.worker = UpdateWorker()
|
||||
self.worker.progress.connect(self.update_progress)
|
||||
self.worker.finished.connect(self.update_finished)
|
||||
@@ -460,6 +472,7 @@ class MainWindow(QMainWindow):
|
||||
def update_finished(self, result):
|
||||
"""Handle update completion"""
|
||||
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.status_label.setText("Ready")
|
||||
|
||||
@@ -468,7 +481,7 @@ class MainWindow(QMainWindow):
|
||||
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}")
|
||||
logger.error("Error writing to log: %s", e)
|
||||
|
||||
# Refresh logs
|
||||
self.log_viewer.refresh_logs()
|
||||
@@ -479,10 +492,14 @@ class MainWindow(QMainWindow):
|
||||
else:
|
||||
# Extract summary from result
|
||||
lines = result.strip().split('\n')
|
||||
summary_found = False
|
||||
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)
|
||||
summary_found = True
|
||||
break
|
||||
if not summary_found:
|
||||
self.status_label.setText("Update completed")
|
||||
|
||||
def auto_update(self):
|
||||
"""Automatic update (runs in background)"""
|
||||
@@ -502,7 +519,7 @@ class MainWindow(QMainWindow):
|
||||
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}")
|
||||
logger.error("Error writing to log: %s", e)
|
||||
|
||||
# Refresh logs
|
||||
self.log_viewer.refresh_logs()
|
||||
@@ -598,9 +615,6 @@ class MainWindow(QMainWindow):
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
dialog.setLayout(layout)
|
||||
dialog.show()
|
||||
dialog.raise_()
|
||||
dialog.activateWindow()
|
||||
dialog.exec()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Failed to show buy list dialog: {e}")
|
||||
@@ -627,7 +641,7 @@ class MainWindow(QMainWindow):
|
||||
item = self.buy_list_widget.item(i)
|
||||
if item.checkState() == Qt.CheckState.Checked:
|
||||
url = item.data(1)
|
||||
QDesktopServices.openUrl(url)
|
||||
QDesktopServices.openUrl(QUrl(url))
|
||||
|
||||
def remove_selected_buy_items(self, all_songs, dialog):
|
||||
"""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]
|
||||
|
||||
# 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")
|
||||
try:
|
||||
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")
|
||||
except Exception as e:
|
||||
QMessageBox.warning(dialog, "Error", f"Failed to update buy list file: {e}")
|
||||
return
|
||||
|
||||
# Update display
|
||||
self.populate_buy_list(updated_songs)
|
||||
@@ -833,7 +851,11 @@ class MainWindow(QMainWindow):
|
||||
table = QTableWidget()
|
||||
table.setColumnCount(4)
|
||||
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:
|
||||
conn = sqlite3.connect('playlist_history.db')
|
||||
@@ -848,7 +870,7 @@ class MainWindow(QMainWindow):
|
||||
try:
|
||||
dt = datetime.fromisoformat(date)
|
||||
date_str = dt.strftime('%Y-%m-%d %H:%M')
|
||||
except:
|
||||
except Exception:
|
||||
date_str = date
|
||||
|
||||
# Added songs
|
||||
@@ -862,7 +884,7 @@ class MainWindow(QMainWindow):
|
||||
title = song
|
||||
artist = "Unknown"
|
||||
entries.append((date_str, "Added", artist, title))
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Missing songs
|
||||
@@ -872,7 +894,7 @@ class MainWindow(QMainWindow):
|
||||
artist = song.get('artist', 'Unknown')
|
||||
title = song.get('title', 'Unknown')
|
||||
entries.append((date_str, "Missing", artist, title))
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
table.setRowCount(len(entries))
|
||||
@@ -931,7 +953,7 @@ class MainWindow(QMainWindow):
|
||||
try:
|
||||
playlist = plex.playlist(playlist_name)
|
||||
tracks = playlist.items()
|
||||
except:
|
||||
except Exception:
|
||||
QMessageBox.warning(self, "Playlist Error", f"Playlist '{playlist_name}' not found.")
|
||||
return
|
||||
|
||||
@@ -970,6 +992,7 @@ class SystemTrayApp:
|
||||
"""System tray application wrapper"""
|
||||
def __init__(self):
|
||||
self.app = QApplication(sys.argv)
|
||||
self.tray_icon = None
|
||||
self.app.setApplicationName("Journey FM Playlist Creator")
|
||||
self.app.setApplicationVersion("1.0")
|
||||
self.app.setOrganizationName("JourneyFM")
|
||||
@@ -1000,7 +1023,7 @@ class SystemTrayApp:
|
||||
tray_menu = QMenu()
|
||||
|
||||
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)
|
||||
|
||||
update_action = QAction("Update Now", self.main_window)
|
||||
@@ -1018,12 +1041,19 @@ class SystemTrayApp:
|
||||
|
||||
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):
|
||||
"""Handle tray icon activation"""
|
||||
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
|
||||
self.main_window.show()
|
||||
self.main_window.raise_()
|
||||
self.main_window.activateWindow()
|
||||
self.show_main_window()
|
||||
|
||||
def run(self):
|
||||
"""Run the application"""
|
||||
@@ -1035,8 +1065,10 @@ def main():
|
||||
log.write(f"{datetime.now()}: Starting Journey FM Playlist app...\n")
|
||||
print("Starting Journey FM Playlist app...")
|
||||
try:
|
||||
if not os.environ.get('DISPLAY'):
|
||||
raise Exception("DISPLAY environment variable not set - no graphical display available")
|
||||
has_x11 = bool(os.environ.get('DISPLAY'))
|
||||
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...")
|
||||
with open('app_log.txt', 'a') as log:
|
||||
|
||||
@@ -25,12 +25,57 @@ import json
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import sqlite3
|
||||
import urllib.parse
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
# Configuration (defaults, will be overridden by config.json)
|
||||
PLEX_TOKEN = 'pU-m3HWYUZU6iXJFhJyA' # Your Plex.tv token
|
||||
SERVER_IP = '172.16.16.106' # Your local server IP
|
||||
PLAYLIST_NAME = 'Journey FM Holiday'
|
||||
# Configuration (defaults, can be overridden by env vars or config.json)
|
||||
PLEX_TOKEN = os.getenv('PLEX_TOKEN', '').strip()
|
||||
SERVER_IP = os.getenv('SERVER_IP', '').strip()
|
||||
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):
|
||||
"""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_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
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())
|
||||
driver = None
|
||||
|
||||
try:
|
||||
driver = webdriver.Chrome(service=service, options=options)
|
||||
except Exception as e:
|
||||
print(f"Failed to start Chrome: {e}")
|
||||
logger.error("Failed to start Chrome: %s", e)
|
||||
return []
|
||||
|
||||
all_songs = []
|
||||
@@ -63,16 +113,20 @@ def scrape_recently_played(selected_stations=None):
|
||||
# Journey FM - uses <strong> for title, normal text for artist
|
||||
if 'journey_fm' in selected_stations:
|
||||
try:
|
||||
print("Scraping Journey FM...")
|
||||
logger.info("Scraping Journey FM...")
|
||||
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
|
||||
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()
|
||||
time.sleep(5)
|
||||
except:
|
||||
time.sleep(2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
soup = BeautifulSoup(driver.page_source, 'html.parser')
|
||||
@@ -94,19 +148,19 @@ def scrape_recently_played(selected_stations=None):
|
||||
if key not in seen:
|
||||
all_songs.append({'title': title, 'artist': artist, 'source': 'Journey FM'})
|
||||
seen.add(key)
|
||||
print(f" Found: {title} by {artist}")
|
||||
except Exception as e:
|
||||
logger.info(" Found: %s by %s", title, artist)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
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
|
||||
if 'spirit_fm' in selected_stations:
|
||||
try:
|
||||
print("Scraping Spirit FM...")
|
||||
logger.info("Scraping Spirit FM...")
|
||||
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"
|
||||
soup = BeautifulSoup(driver.page_source, 'html.parser')
|
||||
@@ -133,19 +187,20 @@ def scrape_recently_played(selected_stations=None):
|
||||
if key not in seen:
|
||||
all_songs.append({'title': title, 'artist': artist, 'source': 'Spirit FM'})
|
||||
seen.add(key)
|
||||
print(f" Found: {title} by {artist}")
|
||||
logger.info(" Found: %s by %s", title, artist)
|
||||
|
||||
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
|
||||
|
||||
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"""
|
||||
@@ -158,7 +213,7 @@ def find_song_in_library(artist, title, library_path):
|
||||
file_title = normalize_string(audio.get('title', [''])[0])
|
||||
if normalize_string(artist) == file_artist and normalize_string(title) == file_title:
|
||||
return os.path.join(root, file)
|
||||
except:
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
@@ -204,7 +259,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)
|
||||
@@ -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])
|
||||
else:
|
||||
added = 0
|
||||
except:
|
||||
except Exception:
|
||||
# Playlist doesn't exist, create it
|
||||
plex.createPlaylist(playlist_name, [ts[0] for ts in tracks])
|
||||
for track, song in tracks:
|
||||
@@ -253,11 +308,11 @@ def create_playlist_in_plex(plex, songs, playlist_name):
|
||||
track.rate(5)
|
||||
added = len(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_songs = []
|
||||
else:
|
||||
print("No matching tracks found in Plex.")
|
||||
logger.info("No matching tracks found in Plex.")
|
||||
added = 0
|
||||
added_songs = []
|
||||
|
||||
@@ -278,6 +333,37 @@ def prompt_for_config():
|
||||
json.dump(config, f)
|
||||
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():
|
||||
"""Initialize the history database"""
|
||||
conn = sqlite3.connect('playlist_history.db')
|
||||
@@ -310,28 +396,21 @@ def setup_scheduler(params):
|
||||
cmd.extend(['-' + k, str(v)])
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
print("Scheduler setup completed.")
|
||||
logger.info("Scheduler setup completed.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Scheduler setup failed: {e}")
|
||||
logger.error("Scheduler setup failed: %s", e)
|
||||
|
||||
def main():
|
||||
global PLEX_TOKEN, SERVER_IP, PLAYLIST_NAME
|
||||
# Load or prompt for config
|
||||
selected_stations = ['journey_fm', 'spirit_fm'] # default
|
||||
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)
|
||||
# Load selected stations
|
||||
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
|
||||
config = load_config()
|
||||
PLEX_TOKEN = config['PLEX_TOKEN']
|
||||
SERVER_IP = config['SERVER_IP']
|
||||
PLAYLIST_NAME = config['PLAYLIST_NAME']
|
||||
selected_stations = config['SELECTED_STATIONS']
|
||||
|
||||
if not PLEX_TOKEN or not SERVER_IP:
|
||||
logger.error("Missing configuration: set PLEX_TOKEN and SERVER_IP in config.json or environment variables")
|
||||
return
|
||||
|
||||
# Log start time
|
||||
start_time = datetime.now()
|
||||
@@ -343,8 +422,12 @@ def main():
|
||||
songs = scrape_recently_played(selected_stations)
|
||||
|
||||
# Connect to Plex via MyPlexAccount
|
||||
account = MyPlexAccount(token=PLEX_TOKEN)
|
||||
resources = account.resources()
|
||||
try:
|
||||
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
|
||||
server_resource = None
|
||||
@@ -357,16 +440,14 @@ def main():
|
||||
break
|
||||
|
||||
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
|
||||
|
||||
plex = server_resource.connect()
|
||||
|
||||
# Debug: list library sections
|
||||
|
||||
# Debug: list existing playlists (handle unicode safely)
|
||||
playlists = plex.playlists()
|
||||
playlist_names = [p.title.encode('ascii', 'ignore').decode('ascii') if not p.title.isascii() else p.title for p in playlists]
|
||||
try:
|
||||
plex = server_resource.connect()
|
||||
except Exception as e:
|
||||
logger.error("Failed to connect to Plex server: %s", e)
|
||||
return
|
||||
|
||||
# Find matching songs in local library (optional, since Plex search might suffice)
|
||||
# But to filter only those in local library, perhaps skip or adjust
|
||||
@@ -376,7 +457,7 @@ def main():
|
||||
if songs:
|
||||
added, added_songs, missing = create_playlist_in_plex(plex, songs, PLAYLIST_NAME)
|
||||
else:
|
||||
print("No songs found.")
|
||||
logger.info("No songs found.")
|
||||
added, added_songs, missing = 0, [], []
|
||||
|
||||
# Save to history
|
||||
@@ -403,7 +484,7 @@ def main():
|
||||
existing_songs.add((artist.strip(), title.strip()))
|
||||
i += 1
|
||||
except Exception as e:
|
||||
pass
|
||||
logger.warning("Failed reading amazon_buy_list.txt: %s", e)
|
||||
|
||||
# Filter new missing songs
|
||||
new_missing = []
|
||||
@@ -425,9 +506,9 @@ def main():
|
||||
url = f"https://www.amazon.com/s?k={query}&i=digital-music"
|
||||
f.write(f"{artist} - {title}\n{url}\n\n")
|
||||
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
|
||||
end_time = datetime.now()
|
||||
|
||||
+5
-4
@@ -3,10 +3,11 @@ 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 Prefer virtual environment pythonw if it exists
|
||||
if exist .venv\Scripts\pythonw.exe (
|
||||
.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
|
||||
Reference in new issue
Block a user