Improve Windows installer, shortcuts, GUI reliability, and README

This commit is contained in:
nmemmert committed 2026-02-25 17:55:44 +00:00
1 parent dab676787d
commit 3e4ce93824
7 files changed
+501 -283

No files matched your search

+125 -172
View File
@@ -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.
![Application Screenshot](screenshot.png) ## 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
View File
@@ -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")
+16
View File
@@ -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
+129
View File
@@ -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
View File
@@ -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:
+140 -59
View File
@@ -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
View File
@@ -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