From 18f777e3e747f97b84fb916cbdf143773a7431d6 Mon Sep 17 00:00:00 2001 From: Nate Emmert Date: Fri, 13 Mar 2026 09:31:16 -0400 Subject: [PATCH] Add K-LOVE support and Podman-friendly container deployment --- .dockerignore | 11 + .gitignore | 5 + Dockerfile | 59 + README.md | 77 +- docker-compose.yml | 24 + journey_fm_app.py | 1249 ++++++++++++++++--- journeyfm/__init__.py | 1 + journeyfm/config_store.py | 170 +++ journeyfm/history_service.py | 73 ++ journeyfm/paths.py | 16 + journeyfm/plex_service.py | 273 ++++ journeyfm/scraper_service.py | 452 +++++++ journeyfm/update_service.py | 138 ++ main.py | 531 +------- requirements.txt | 3 +- run_container.py | 38 + test_scrape.py | 75 +- tests/fixtures/journeyfm_recent_markup.html | 14 + tests/fixtures/klove_recent_markup.html | 33 + 19 files changed, 2560 insertions(+), 682 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 journeyfm/__init__.py create mode 100644 journeyfm/config_store.py create mode 100644 journeyfm/history_service.py create mode 100644 journeyfm/paths.py create mode 100644 journeyfm/plex_service.py create mode 100644 journeyfm/scraper_service.py create mode 100644 journeyfm/update_service.py create mode 100644 run_container.py create mode 100644 tests/fixtures/journeyfm_recent_markup.html create mode 100644 tests/fixtures/klove_recent_markup.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e67261e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.venv +__pycache__/ +*.pyc +*.pyo +*.pyd +*.log +*.db +nohup.out +icon.png diff --git a/.gitignore b/.gitignore index 4c078f0..d27f031 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__/ # Logs playlist_log.txt +app_log.txt page.html # Selenium/ChromeDriver @@ -28,3 +29,7 @@ Thumbs.db # Environment variables (if you add a .env file later) .env + +# Debug / local runtime outputs +debug_scrapes/ +test_live_klove.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..777765e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,59 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN mkdir -p /data + +# System deps for headless Chromium + Selenium +RUN apt-get update && apt-get install -y --no-install-recommends \ + chromium \ + chromium-driver \ + ca-certificates \ + fonts-liberation \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libc6 \ + libcairo2 \ + libcups2 \ + libdbus-1-3 \ + libdrm2 \ + libexpat1 \ + libfontconfig1 \ + libgbm1 \ + libglib2.0-0 \ + libgtk-3-0 \ + libnspr4 \ + libnss3 \ + libpango-1.0-0 \ + libx11-6 \ + libx11-xcb1 \ + libxcb1 \ + libxcomposite1 \ + libxdamage1 \ + libxext6 \ + libxfixes3 \ + libxrandr2 \ + libxrender1 \ + libxss1 \ + libxtst6 \ + xdg-utils \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Override if your distro path differs +ENV CHROME_BINARY=/usr/bin/chromium \ + CHROMEDRIVER_PATH=/usr/bin/chromedriver \ + JOURNEYFM_DATA_DIR=/data \ + JOURNEYFM_CONTAINER=1 + +VOLUME ["/data"] + +CMD ["python", "run_container.py"] diff --git a/README.md b/README.md index 80cbd68..7b02b12 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Desktop app + script that pulls recently played songs from Journey FM / Spirit F - Scrapes recently played songs from: - `https://www.myjourneyfm.com/recently-played/` - `https://spiritfm.com/ajax/now_playing_history.txt` + - `https://www.klove.com/music/songs` - 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. @@ -93,17 +94,19 @@ python journey_fm_app.py Use the GUI setup wizard (first launch) or edit `config.json` directly. +The Plex token is now stored in your OS credential store via `keyring` when available. +On Windows this means the app no longer needs to persist `PLEX_TOKEN` in `config.json`. + Example `config.json`: ```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" + "SELECTED_STATIONS": "journey_fm,spirit_fm,klove" } ``` @@ -112,6 +115,10 @@ You can also provide runtime env vars: - `PLEX_TOKEN` - `SERVER_IP` - `PLAYLIST_NAME` +- `AUTO_UPDATE` +- `UPDATE_INTERVAL` +- `UPDATE_UNIT` +- `SELECTED_STATIONS` Optional browser override: @@ -127,20 +134,75 @@ Run one update cycle from CLI: python main.py ``` +## Container Deployment + +Container mode is intended for scheduled/headless playlist updates. The container now runs as a long-lived loop and sleeps between syncs instead of relying on restart churn. + +1. Create a `.env` file in the project root: + +```env +PLEX_TOKEN=your-plex-token +SERVER_IP=192.168.1.100 +PLAYLIST_NAME=Journey FM Recently Played +SELECTED_STATIONS=journey_fm,spirit_fm,klove +UPDATE_INTERVAL=15 +UPDATE_UNIT=Minutes +CONTAINER_RUN_MODE=loop +``` + +2. Build and run with Docker Compose: + +```bash +docker-compose up -d --build +``` + +Podman equivalent: + +```bash +podman compose up -d --build +``` + +3. Trigger a one-off run and review logs: + +```bash +docker-compose run --rm -e CONTAINER_RUN_MODE=once journey-fm-playlist +docker-compose logs -f journey-fm-playlist +``` + +Podman equivalent: + +```bash +podman compose run --rm -e CONTAINER_RUN_MODE=once journey-fm-playlist +podman compose logs -f journey-fm-playlist +``` + +Notes: + +- The container uses Chromium + chromedriver in headless mode. +- Runtime state is stored in the named volume mounted at `/data`, so `config.json`, `playlist_history.db`, `playlist_log.txt`, `amazon_buy_list.txt`, and `debug_scrapes/` persist without bind-mounting the repo. +- GUI (`journey_fm_app.py`) is still intended to run natively on your desktop. +- In container mode, environment variables are preferred over local config or keyring storage. +- The Compose file is Podman-friendly because it uses a named volume instead of a source bind mount. + ## GUI Notes - 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. +- Updated visual style includes a dashboard hero section, richer status chips, and a refreshed control layout. +- Setup now includes Plex connection testing, keyring-backed token storage, playlist browsing with item counts, music-only filtering, and typed creation of new playlist names. ## Output Files -- `config.json` - runtime settings shared by GUI and CLI +- `config.json` - non-secret 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 +- `debug_scrapes/` - captured raw station responses for scraper debugging + +In container mode these files live under `/data` inside the container volume. ## Troubleshooting @@ -153,15 +215,18 @@ python main.py ## Core Files - `journey_fm_app.py` - desktop GUI and tray app -- `main.py` - scraping + Plex update pipeline +- `main.py` - CLI entrypoint for a single update run +- `run_container.py` - loop/once runner used by container deployments +- `journeyfm/` - shared service modules (config, scraper, Plex, history, orchestration) - `requirements.txt` - Python dependencies - `install.sh` - Linux setup helper ## Pre-Release Checklist -- Verify `config.json` has valid `PLEX_TOKEN`, `SERVER_IP`, and `PLAYLIST_NAME`. +- Verify `config.json` has valid `SERVER_IP`, `PLAYLIST_NAME`, and station/schedule settings. - 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. \ No newline at end of file +- Export playlist once and verify `playlist_export.csv` is created. +- For container mode, run `docker-compose run --rm -e CONTAINER_RUN_MODE=once journey-fm-playlist` and verify logs/history output. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..11288ef --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + journey-fm-playlist: + build: + context: . + dockerfile: Dockerfile + container_name: journey-fm-playlist + restart: unless-stopped + environment: + PLEX_TOKEN: ${PLEX_TOKEN} + SERVER_IP: ${SERVER_IP} + PLAYLIST_NAME: ${PLAYLIST_NAME:-Journey FM Recently Played} + SELECTED_STATIONS: ${SELECTED_STATIONS:-journey_fm,spirit_fm} + AUTO_UPDATE: ${AUTO_UPDATE:-true} + UPDATE_INTERVAL: ${UPDATE_INTERVAL:-15} + UPDATE_UNIT: ${UPDATE_UNIT:-Minutes} + CONTAINER_RUN_MODE: ${CONTAINER_RUN_MODE:-loop} + CHROME_BINARY: /usr/bin/chromium + CHROMEDRIVER_PATH: /usr/bin/chromedriver + JOURNEYFM_DATA_DIR: /data + volumes: + - journey-fm-data:/data + +volumes: + journey-fm-data: diff --git a/journey_fm_app.py b/journey_fm_app.py index e7dfd2c..eed7778 100644 --- a/journey_fm_app.py +++ b/journey_fm_app.py @@ -8,16 +8,19 @@ Features system tray integration, log viewing, and easy setup. import sys import os -import json import logging from datetime import datetime from pathlib import Path +from journeyfm.config_store import load_runtime_config, save_runtime_config +from journeyfm.plex_service import PlexConnectionError, fetch_playlists, validate_playlist_target +from journeyfm.update_service import format_result_summary, run_update_job + # GUI imports 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 + from PySide6.QtGui import QIcon, QDesktopServices, QFont, QFontDatabase, 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}") @@ -40,7 +43,7 @@ def configure_logging(): handlers.append(logging.StreamHandler(stream)) logging.basicConfig(level=logging.INFO, format=log_format, handlers=handlers, force=True) - for noisy_logger in ('webdriver_manager', 'matplotlib', 'urllib3', 'plexapi'): + for noisy_logger in ('webdriver_manager', 'WDM', 'matplotlib', 'urllib3', 'plexapi'): logging.getLogger(noisy_logger).setLevel(logging.WARNING) @@ -50,52 +53,24 @@ logger = logging.getLogger(__name__) class Config: """Configuration management""" def __init__(self): - self.settings = QSettings('JourneyFM', 'PlaylistCreator') self.config_file = Path('config.json') + self._cache = {} def get(self, key, default=None): - return self.settings.value(key, default) + return self._cache.get(key, default) def set(self, key, value): - self.settings.setValue(key, value) - self.settings.sync() + self._cache[key] = value def load_config(self): - """Load config from JSON file for backward compatibility""" - if self.config_file.exists(): - try: - with open(self.config_file, 'r') as f: - config = json.load(f) - # Migrate to QSettings - for key, value in config.items(): - self.set(key, value) - return config - except Exception as e: - logger.warning("Failed loading config file %s: %s", self.config_file, e) - return {} + """Load config using shared runtime config rules.""" + self._cache = load_runtime_config(self.config_file) + return dict(self._cache) def save_config(self, config): - """Save config to QSettings and config.json""" - # Save to QSettings - for key, value in config.items(): - # Handle list values (convert to comma-separated string) - if isinstance(value, list): - value = ','.join(value) - self.set(key, value) - - # Also save to config.json for main.py to use - json_config = {} - for key, value in config.items(): - if isinstance(value, list): - json_config[key] = ','.join(value) - else: - json_config[key] = value - - try: - with open(self.config_file, 'w') as f: - json.dump(json_config, f, indent=2) - except Exception as e: - logger.error("Error saving config.json: %s", e) + """Save config with secrets stored outside config.json when possible.""" + save_runtime_config(config, self.config_file) + self.load_config() def parse_bool(value): """Safely parse truthy values from QSettings/JSON.""" @@ -112,15 +87,23 @@ class SetupWizard(QDialog): def __init__(self, config, parent=None): super().__init__(parent) self.config = config + self.playlist_records = [] + self.connection_validated = False self.setWindowTitle("Journey FM Setup") self.setModal(True) - self.resize(500, 400) + self.resize(620, 470) layout = QVBoxLayout() # Welcome message welcome = QLabel("Welcome to Journey FM Playlist Creator!") - welcome.setFont(QFont("Arial", 14, QFont.Weight.Bold)) + welcome_font = QFont(self.font()) + base_size = welcome_font.pointSize() + if base_size <= 0: + base_size = 10 + welcome_font.setPointSize(max(base_size + 3, 14)) + welcome_font.setBold(True) + welcome.setFont(welcome_font) layout.addWidget(welcome) desc = QLabel("This wizard will help you set up automatic playlist updates from Journey FM.") @@ -149,8 +132,46 @@ class SetupWizard(QDialog): self.server_input.setPlaceholderText("e.g., 192.168.1.100") form_layout.addRow("Server IP:", self.server_input) - self.playlist_input = QLineEdit("Journey FM Recently Played") - form_layout.addRow("Playlist Name:", self.playlist_input) + self.connection_status = QLabel("Connection not tested") + self.connection_status.setObjectName("SecondaryStatus") + form_layout.addRow("Connection:", self.connection_status) + + connection_button_row = QHBoxLayout() + self.test_connection_btn = QPushButton("Test Plex") + self.test_connection_btn.clicked.connect(self.test_connection) + connection_button_row.addWidget(self.test_connection_btn) + connection_button_row.addStretch() + form_layout.addRow("", connection_button_row) + + # Editable combo — user can pick an existing playlist or type a new name. + playlist_row = QHBoxLayout() + self.playlist_combo = QComboBox() + self.playlist_combo.setEditable(True) + self.playlist_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + self.playlist_combo.lineEdit().setPlaceholderText("Type a name or click Browse") + self.playlist_combo.addItem("Journey FM Recently Played") + playlist_row.addWidget(self.playlist_combo, stretch=1) + + self.new_playlist_btn = QPushButton("New…") + self.new_playlist_btn.setToolTip("Create a new playlist name") + self.new_playlist_btn.clicked.connect(self.create_new_playlist_name) + playlist_row.addWidget(self.new_playlist_btn) + + self.browse_btn = QPushButton("Browse…") + self.browse_btn.setToolTip("Fetch playlists from your Plex server") + self.browse_btn.clicked.connect(self.fetch_playlists) + playlist_row.addWidget(self.browse_btn) + + form_layout.addRow("Playlist:", playlist_row) + + self.music_only_checkbox = QCheckBox("Show music playlists only") + self.music_only_checkbox.setChecked(True) + self.music_only_checkbox.toggled.connect(self.refresh_playlist_combo) + form_layout.addRow("", self.music_only_checkbox) + + self.playlist_fetch_status = QLabel("") + self.playlist_fetch_status.setObjectName("SecondaryStatus") + form_layout.addRow("", self.playlist_fetch_status) form_group.setLayout(form_layout) layout.addWidget(form_group) @@ -168,6 +189,10 @@ class SetupWizard(QDialog): self.spirit_fm_checkbox.setChecked(True) station_layout.addWidget(self.spirit_fm_checkbox) + self.klove_checkbox = QCheckBox("K-LOVE (klove.com)") + self.klove_checkbox.setChecked(False) + station_layout.addWidget(self.klove_checkbox) + station_group.setLayout(station_layout) layout.addWidget(station_group) @@ -207,7 +232,8 @@ class SetupWizard(QDialog): # Load existing config self.token_input.setText(config.get('PLEX_TOKEN', '')) self.server_input.setText(config.get('SERVER_IP', '')) - self.playlist_input.setText(config.get('PLAYLIST_NAME', 'Journey FM Recently Played')) + saved_playlist = config.get('PLAYLIST_NAME', 'Journey FM Recently Played') + self.playlist_combo.setCurrentText(saved_playlist) # Load station selections selected_stations = config.get('SELECTED_STATIONS', 'journey_fm,spirit_fm') @@ -219,6 +245,7 @@ class SetupWizard(QDialog): selected_stations = ['journey_fm', 'spirit_fm'] self.journey_fm_checkbox.setChecked('journey_fm' in selected_stations) self.spirit_fm_checkbox.setChecked('spirit_fm' in selected_stations) + self.klove_checkbox.setChecked('klove' in selected_stations) def get_config(self): selected_stations = [] @@ -226,23 +253,136 @@ class SetupWizard(QDialog): selected_stations.append('journey_fm') if self.spirit_fm_checkbox.isChecked(): selected_stations.append('spirit_fm') + if self.klove_checkbox.isChecked(): + selected_stations.append('klove') + playlist_name = self._selected_playlist_title() return { 'PLEX_TOKEN': self.token_input.text().strip(), 'SERVER_IP': self.server_input.text().strip(), - 'PLAYLIST_NAME': self.playlist_input.text().strip(), + 'PLAYLIST_NAME': playlist_name, 'AUTO_UPDATE': self.auto_update.isChecked(), 'UPDATE_INTERVAL': self.interval_spin.value(), 'UPDATE_UNIT': self.interval_unit.currentText(), 'SELECTED_STATIONS': ','.join(selected_stations) } + def _selected_playlist_title(self): + current_index = self.playlist_combo.currentIndex() + if current_index >= 0: + record = self.playlist_combo.itemData(current_index) + if isinstance(record, dict) and record.get('title'): + return record['title'] + return self.playlist_combo.currentText().strip() or 'Journey FM Recently Played' + + def _format_playlist_label(self, record): + playlist_type = record.get('playlist_type', 'unknown') + item_count = record.get('item_count', 0) + return f"{record['title']} [{playlist_type}, {item_count} items]" + + def refresh_playlist_combo(self, *_): + current_title = self._selected_playlist_title() + self.playlist_combo.blockSignals(True) + self.playlist_combo.clear() + records = self.playlist_records + if self.music_only_checkbox.isChecked(): + records = [record for record in records if record.get('playlist_type') == 'audio'] + for record in records: + self.playlist_combo.addItem(self._format_playlist_label(record), record) + match_index = -1 + for index in range(self.playlist_combo.count()): + record = self.playlist_combo.itemData(index) + if isinstance(record, dict) and record.get('title', '').lower() == current_title.lower(): + match_index = index + break + if match_index >= 0: + self.playlist_combo.setCurrentIndex(match_index) + else: + self.playlist_combo.setCurrentText(current_title) + self.playlist_combo.blockSignals(False) + + def create_new_playlist_name(self): + name, accepted = QInputDialog.getText(self, 'Create Playlist', 'Enter a new playlist name:') + if accepted and name.strip(): + self.playlist_combo.setCurrentText(name.strip()) + self.connection_validated = False + + def fetch_playlists(self): + """Connect to Plex in background and populate the playlist combo.""" + token = self.token_input.text().strip() + server_ip = self.server_input.text().strip() + if not token or not server_ip: + QMessageBox.warning(self, "Missing Credentials", + "Please enter your Plex Token and Server IP first.") + return + + self.browse_btn.setEnabled(False) + self.playlist_fetch_status.setText("Connecting to Plex…") + + self._fetch_worker = PlexFetchPlaylistsWorker(token, server_ip, False, parent=self) + self._fetch_worker.finished.connect(self._on_playlists_fetched) + self._fetch_worker.error.connect(self._on_fetch_error) + self._fetch_worker.start() + + def _on_playlists_fetched(self, records): + self.playlist_records = records + self.refresh_playlist_combo() + count = len(records) + self.playlist_fetch_status.setText(f"{count} playlist{'s' if count != 1 else ''} found") + self.browse_btn.setEnabled(True) + self.connection_validated = False + + def _on_fetch_error(self, message): + self.playlist_fetch_status.setText(f"Error: {message}") + self.browse_btn.setEnabled(True) + self.connection_validated = False + + def test_connection(self): + token = self.token_input.text().strip() + server_ip = self.server_input.text().strip() + playlist_name = self._selected_playlist_title() + if not token or not server_ip: + QMessageBox.warning(self, 'Missing Credentials', 'Please enter your Plex Token and Server IP first.') + return + self.connection_status.setText('Testing Plex connection…') + self.test_connection_btn.setEnabled(False) + self._validation_worker = PlexConnectionTestWorker( + token, + server_ip, + playlist_name, + self.music_only_checkbox.isChecked(), + parent=self, + ) + self._validation_worker.finished.connect(self._on_connection_tested) + self._validation_worker.error.connect(self._on_connection_failed) + self._validation_worker.start() + + def _on_connection_tested(self, payload): + self.connection_status.setText(payload.get('message', 'Plex connection verified')) + self.test_connection_btn.setEnabled(True) + self.connection_validated = True + + def _on_connection_failed(self, message): + self.connection_status.setText(f'Connection failed: {message}') + self.test_connection_btn.setEnabled(True) + self.connection_validated = False + def validate_and_accept(self): """Validate configuration before accepting""" if not self.journey_fm_checkbox.isChecked() and not self.spirit_fm_checkbox.isChecked(): QMessageBox.warning(self, "Validation Error", "Please select at least one station to monitor.") return + try: + validate_playlist_target( + self.token_input.text().strip(), + self.server_input.text().strip(), + self._selected_playlist_title(), + self.music_only_checkbox.isChecked(), + ) + except Exception as exc: + QMessageBox.warning(self, 'Validation Error', str(exc)) + return self.accept() class LogViewer(QWidget): @@ -263,7 +403,11 @@ class LogViewer(QWidget): # Log text area self.log_text = QTextEdit() self.log_text.setReadOnly(True) - self.log_text.setFont(QFont("Consolas", 9)) + log_font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + log_size = log_font.pointSize() + if log_size <= 0: + log_font.setPointSize(10) + self.log_text.setFont(log_font) layout.addWidget(self.log_text) # Load existing logs @@ -299,34 +443,92 @@ class LogViewer(QWidget): """Refresh the log display""" self.load_logs() +class PlexFetchPlaylistsWorker(QThread): + """Fetch Plex playlists in the background.""" + finished = Signal(list) + error = Signal(str) + + def __init__(self, token, server_ip, music_only, parent=None): + super().__init__(parent) + self.token = token + self.server_ip = server_ip + self.music_only = music_only + + def run(self): + try: + records = fetch_playlists(self.token, self.server_ip, self.music_only) + self.finished.emit(records) + except Exception as e: + self.error.emit(str(e)) + + +class PlexConnectionTestWorker(QThread): + """Validate Plex connectivity and playlist write target in the background.""" + finished = Signal(dict) + error = Signal(str) + + def __init__(self, token, server_ip, playlist_name, music_only, parent=None): + super().__init__(parent) + self.token = token + self.server_ip = server_ip + self.playlist_name = playlist_name + self.music_only = music_only + + def run(self): + try: + payload = validate_playlist_target( + self.token, + self.server_ip, + self.playlist_name, + self.music_only, + ) + self.finished.emit(payload) + except Exception as e: + self.error.emit(str(e)) + + class UpdateWorker(QThread): """Worker thread for running playlist updates""" finished = Signal(str) progress = Signal(str) + def __init__(self, config_data=None, parent=None): + super().__init__(parent) + self.config_data = config_data + def run(self): try: self.progress.emit("Starting playlist update...") - - # Import here to avoid circular imports - from main import main as update_main - - # Capture output by redirecting stdout - import io - from contextlib import redirect_stdout, redirect_stderr - - output_buffer = io.StringIO() - 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) + result = run_update_job(config=self.config_data) + self.finished.emit(format_result_summary(result)) except Exception as e: self.finished.emit(f"Error: {str(e)}") + +class PreviewWorker(QThread): + """Worker thread for generating sync preview without writing changes.""" + finished = Signal(dict) + progress = Signal(str) + error = Signal(str) + + def __init__(self, config_data=None, parent=None): + super().__init__(parent) + self.config_data = config_data + + def run(self): + try: + self.progress.emit("Preparing sync preview...") + result = run_update_job( + config=self.config_data, + dry_run=True, + persist_history=False, + write_buy_list=False, + ) + self.finished.emit(result) + except Exception as exc: + self.error.emit(str(exc)) + class MainWindow(QMainWindow): """Main application window""" def __init__(self): @@ -334,18 +536,54 @@ class MainWindow(QMainWindow): self.tray_icon = None # Will be set later self.config = Config() self.config.load_config() + self.connection_verified = False self.setWindowTitle("Journey FM Playlist Creator") icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'icon.png') if os.path.exists(icon_path): self.setWindowIcon(QIcon(icon_path)) - self.resize(800, 600) - self.setMinimumSize(760, 520) + self.resize(980, 700) + self.setMinimumSize(860, 600) # Create central widget central = QWidget() + central.setObjectName("AppRoot") self.setCentralWidget(central) layout = QVBoxLayout(central) + layout.setContentsMargins(18, 16, 18, 16) + layout.setSpacing(12) + + # Hero panel + hero_panel = QWidget() + hero_panel.setObjectName("HeroPanel") + hero_layout = QHBoxLayout(hero_panel) + hero_layout.setContentsMargins(20, 16, 20, 16) + + hero_text_layout = QVBoxLayout() + hero_title = QLabel("Journey FM Sync Hub") + hero_title.setObjectName("HeroTitle") + hero_subtitle = QLabel("Automate radio-to-Plex updates, monitor run health, and manage your backlog.") + hero_subtitle.setObjectName("HeroSubtitle") + hero_subtitle.setWordWrap(True) + hero_text_layout.addWidget(hero_title) + hero_text_layout.addWidget(hero_subtitle) + + chip_layout = QVBoxLayout() + chip_layout.setSpacing(8) + self.connection_status_chip = QLabel("Plex: Not checked") + self.connection_status_chip.setObjectName("StatusChip") + chip_layout.addWidget(self.connection_status_chip) + self.auto_status_chip = QLabel("Auto: Disabled") + self.auto_status_chip.setObjectName("StatusChip") + self.last_sync_chip = QLabel("Last sync: Never") + self.last_sync_chip.setObjectName("StatusChip") + chip_layout.addWidget(self.auto_status_chip) + chip_layout.addWidget(self.last_sync_chip) + chip_layout.addStretch() + + hero_layout.addLayout(hero_text_layout, stretch=3) + hero_layout.addLayout(chip_layout, stretch=2) + layout.addWidget(hero_panel) # Toolbar toolbar = self.addToolBar("Main") @@ -359,6 +597,11 @@ class MainWindow(QMainWindow): settings_action.triggered.connect(self.show_settings) toolbar.addAction(settings_action) + test_action = QAction("Test Plex", self) + test_action.triggered.connect(self.refresh_connection_status) + toolbar.addAction(test_action) + toolbar.setObjectName("MainToolbar") + # Progress bar self.progress_bar = QProgressBar() self.progress_bar.setVisible(False) @@ -372,9 +615,11 @@ class MainWindow(QMainWindow): status_layout = QVBoxLayout() self.status_label = QLabel("Ready") + self.status_label.setObjectName("PrimaryStatus") status_layout.addWidget(self.status_label) self.last_update_label = QLabel("Last update: Never") + self.last_update_label.setObjectName("SecondaryStatus") status_layout.addWidget(self.last_update_label) # Buttons in grid layout @@ -384,10 +629,14 @@ class MainWindow(QMainWindow): self.update_button = QPushButton("Update Playlist") self.update_button.clicked.connect(self.run_update_playlist) button_layout.addWidget(self.update_button, 0, 0) + + self.preview_button = QPushButton("Preview Sync") + self.preview_button.clicked.connect(self.preview_update) + button_layout.addWidget(self.preview_button, 0, 1) self.buy_list_button = QPushButton("Show Buy List") self.buy_list_button.clicked.connect(self.show_buy_list) - button_layout.addWidget(self.buy_list_button, 0, 1) + button_layout.addWidget(self.buy_list_button, 0, 2) # Row 2 self.history_button = QPushButton("View History") @@ -397,6 +646,10 @@ class MainWindow(QMainWindow): self.export_button = QPushButton("Export Playlist") self.export_button.clicked.connect(self.export_playlist) button_layout.addWidget(self.export_button, 1, 1) + + self.station_health_button = QPushButton("Station Health") + self.station_health_button.clicked.connect(self.show_station_health) + button_layout.addWidget(self.station_health_button, 1, 2) # Row 3 self.stats_button = QPushButton("Statistics") @@ -423,17 +676,235 @@ class MainWindow(QMainWindow): self.update_timer = QTimer() self.update_timer.timeout.connect(self.auto_update) - # Load settings and start auto-update if enabled + self.apply_modern_theme() + self.load_settings() + self.set_action_controls_enabled(False) # Check if first run if not self.config.get('PLEX_TOKEN'): self.show_setup_wizard() + else: + self.refresh_connection_status() + + def apply_modern_theme(self): + """Apply an updated visual style to the desktop app.""" + self.setStyleSheet(""" + /* ── Global base: dark text everywhere by default ── */ + QWidget { + font-family: 'Trebuchet MS', 'Segoe UI', sans-serif; + font-size: 13px; + color: #22343d; + } + QWidget#AppRoot { + background: qlineargradient( + x1:0, y1:0, x2:1, y2:1, + stop:0 #f7f3ec, + stop:0.45 #f1efe8, + stop:1 #e7ece7 + ); + } + + /* ── Labels default to dark ── */ + QLabel { color: #22343d; } + + /* ── GroupBox ── */ + QGroupBox { + color: #22343d; + font-weight: 700; + border: 1px solid #d2d7ce; + border-radius: 10px; + margin-top: 8px; + padding-top: 10px; + background-color: #fbfaf6; + } + QGroupBox::title { color: #22343d; } + + /* ── Hero panel: light text on dark bg ── */ + QWidget#HeroPanel { + border-radius: 14px; + border: 1px solid #d0d8d1; + background: qlineargradient( + x1:0, y1:0, x2:1, y2:0, + stop:0 #204a57, + stop:1 #2f6f7b + ); + } + QLabel#HeroTitle { + color: #f8f4e8; + font-size: 22px; + font-weight: 700; + letter-spacing: 0.5px; + } + QLabel#HeroSubtitle { + color: #d4ebe6; + font-size: 12px; + } + QLabel#StatusChip { + background-color: rgba(255, 255, 255, 0.18); + color: #f0f4f2; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 14px; + padding: 6px 12px; + font-weight: 600; + } + + /* ── Toolbar ── */ + QToolBar#MainToolbar { + background: #f2ede2; + border: 1px solid #d3cbba; + border-radius: 10px; + spacing: 8px; + padding: 5px; + } + QToolBar#MainToolbar QToolButton { + color: #22343d; + background: transparent; + border: 1px solid transparent; + border-radius: 7px; + padding: 4px 10px; + font-weight: 600; + } + QToolBar#MainToolbar QToolButton:hover { + background: #e4ddd1; + border-color: #c8bfb0; + } + QToolBar#MainToolbar QToolButton:pressed { + background: #d8d0c4; + } + + /* ── Action buttons: white text on dark teal ── */ + QPushButton { + background-color: #1f7668; + color: #f0f4f2; + border: 1px solid #15584d; + border-radius: 9px; + min-height: 30px; + padding: 5px 10px; + font-weight: 600; + } + QPushButton:hover { background-color: #2a8d7c; } + QPushButton:pressed { background-color: #1a5e53; } + + /* ── Named status labels ── */ + QLabel#PrimaryStatus { + color: #19434f; + font-size: 15px; + font-weight: 700; + } + QLabel#SecondaryStatus { + color: #57656b; + font-size: 12px; + } + + /* ── Inputs ── */ + QLineEdit, QSpinBox, QComboBox { + color: #22343d; + background-color: #ffffff; + border: 1px solid #c8ccc7; + border-radius: 6px; + padding: 4px 8px; + } + QComboBox QAbstractItemView { + color: #22343d; + background-color: #ffffff; + selection-color: #ffffff; + selection-background-color: #1f7668; + border: 1px solid #c8ccc7; + outline: none; + } + QComboBox QAbstractItemView::item { + color: #22343d; + background-color: #ffffff; + padding: 4px 8px; + min-height: 24px; + } + QComboBox QAbstractItemView::item:hover { + color: #ffffff; + background-color: #2a8d7c; + } + QCheckBox { color: #22343d; } + + /* ── Text areas ── */ + QTextEdit { + color: #22343d; + background-color: #fffdf8; + border: 1px solid #d7d9d2; + border-radius: 8px; + } + + /* ── Tables ── */ + QTableWidget { + color: #22343d; + background-color: #ffffff; + alternate-background-color: #f6f8f4; + gridline-color: #d9ddd6; + selection-color: #ffffff; + selection-background-color: #1f7668; + } + QTableWidget::item { + color: #22343d; + background-color: #ffffff; + } + QTableWidget::item:selected { + color: #ffffff; + background-color: #1f7668; + } + QHeaderView::section { + color: #22343d; + background-color: #eceae3; + font-weight: 600; + border: none; + padding: 4px 8px; + } + + /* ── Tabs ── */ + QTabWidget::pane { + border: 1px solid #d4d8d1; + background: #fffdf8; + } + QTabBar::tab { + color: #22343d; + background: #ebe9e0; + border: 1px solid #d4d8d1; + padding: 6px 12px; + margin-right: 2px; + } + QTabBar::tab:selected { + color: #ffffff; + background: #1f7668; + border-color: #15584d; + } + + /* ── Dialogs ── */ + QDialog { background-color: #f7f3ec; color: #22343d; } + QDialogButtonBox QPushButton { min-width: 80px; } + + /* ── Progress bar ── */ + QProgressBar { + border: 1px solid #d2d5cb; + border-radius: 7px; + background: #f0f2ec; + text-align: center; + min-height: 12px; + color: #22343d; + } + QProgressBar::chunk { + border-radius: 7px; + background-color: #d17b2f; + } + """) def run_update_playlist(self): """Run playlist update in background thread""" self.manual_update() + def set_action_controls_enabled(self, enabled): + self.update_button.setEnabled(enabled) + self.preview_button.setEnabled(enabled) + self.export_button.setEnabled(enabled) + self.station_health_button.setEnabled(True) + def set_tray_icon(self, tray_icon): """Set the tray icon reference""" self.tray_icon = tray_icon @@ -445,37 +916,72 @@ class MainWindow(QMainWindow): config = wizard.get_config() self.config.save_config(config) self.load_settings() + self.refresh_connection_status() QMessageBox.information(self, "Setup Complete", "Configuration saved! The app will now update your playlist automatically.") def load_settings(self): """Load settings and configure auto-update""" auto_update = parse_bool(self.config.get('AUTO_UPDATE', False)) + interval = int(self.config.get('UPDATE_INTERVAL', 15)) + unit = self.config.get('UPDATE_UNIT', 'Minutes') + self.requested_interval_ms = interval * 60 * 1000 if unit != 'Hours' else interval * 60 * 60 * 1000 if auto_update: - interval = int(self.config.get('UPDATE_INTERVAL', 15)) - unit = self.config.get('UPDATE_UNIT', 'Minutes') - - # Convert to milliseconds - if unit == 'Hours': - interval_ms = interval * 60 * 60 * 1000 - else: - interval_ms = interval * 60 * 1000 - - self.update_timer.start(interval_ms) - self.status_label.setText(f"Auto-update enabled (every {interval} {unit.lower()})") + self.update_timer.stop() + self.status_label.setText(f"Auto-update armed (every {interval} {unit.lower()})") + self.auto_status_chip.setText(f"Auto: On ({interval} {unit.lower()})") else: self.update_timer.stop() self.status_label.setText("Auto-update disabled") + self.auto_status_chip.setText("Auto: Off") + + def refresh_connection_status(self): + token = self.config.get('PLEX_TOKEN', '') + server_ip = self.config.get('SERVER_IP', '') + playlist_name = self.config.get('PLAYLIST_NAME', 'Journey FM Recently Played') + if not token or not server_ip: + self.connection_verified = False + self.connection_status_chip.setText('Plex: Setup required') + self.status_label.setText('Plex configuration incomplete') + self.set_action_controls_enabled(False) + self.update_timer.stop() + return + + self.connection_status_chip.setText('Plex: Checking…') + self.status_label.setText('Validating Plex connection…') + self.set_action_controls_enabled(False) + self._connection_worker = PlexConnectionTestWorker(token, server_ip, playlist_name, True, parent=self) + self._connection_worker.finished.connect(self._on_connection_ready) + self._connection_worker.error.connect(self._on_connection_error) + self._connection_worker.start() + + def _on_connection_ready(self, payload): + self.connection_verified = True + self.connection_status_chip.setText('Plex: Connected') + self.status_label.setText(payload.get('message', 'Plex connection verified')) + self.set_action_controls_enabled(True) + if parse_bool(self.config.get('AUTO_UPDATE', False)): + self.update_timer.start(self.requested_interval_ms) + + def _on_connection_error(self, message): + self.connection_verified = False + self.connection_status_chip.setText('Plex: Not Connected') + self.status_label.setText(message) + self.set_action_controls_enabled(False) + self.update_timer.stop() def manual_update(self): """Manually trigger playlist update""" + if not self.connection_verified: + QMessageBox.warning(self, 'Plex Not Ready', 'Validate Plex connection before running updates.') + return if hasattr(self, 'worker') and self.worker.isRunning(): QMessageBox.information(self, "Update in Progress", "An update is already running. Please wait.") return - self.update_button.setEnabled(False) - self.worker = UpdateWorker() + self.set_action_controls_enabled(False) + self.worker = UpdateWorker(self.config.load_config()) self.worker.progress.connect(self.update_progress) self.worker.finished.connect(self.update_finished) self.worker.start() @@ -483,6 +989,7 @@ class MainWindow(QMainWindow): self.progress_bar.setVisible(True) self.progress_bar.setRange(0, 0) # Indeterminate progress self.status_label.setText("Updating playlist...") + self.last_sync_chip.setText("Last sync: In progress") def update_progress(self, message): """Update progress display""" @@ -491,8 +998,10 @@ 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.set_action_controls_enabled(self.connection_verified) + last_sync = datetime.now().strftime('%Y-%m-%d %I:%M:%S %p') + self.last_update_label.setText(f"Last update: {last_sync}") + self.last_sync_chip.setText(f"Last sync: {last_sync}") self.status_label.setText("Ready") # Append result to log file @@ -522,16 +1031,53 @@ class MainWindow(QMainWindow): def auto_update(self): """Automatic update (runs in background)""" + if not self.connection_verified: + self.update_timer.stop() + return if hasattr(self, 'worker') and self.worker.isRunning(): return # Skip if already running - self.worker = UpdateWorker() + self.worker = UpdateWorker(self.config.load_config()) self.worker.finished.connect(self.auto_update_finished) self.worker.start() + def preview_update(self): + """Generate a dry-run preview of sync changes before applying.""" + if not self.connection_verified: + QMessageBox.warning(self, 'Plex Not Ready', 'Validate Plex connection before previewing updates.') + return + if hasattr(self, 'preview_worker') and self.preview_worker.isRunning(): + QMessageBox.information(self, 'Preview in Progress', 'A preview is already running. Please wait.') + return + + self.set_action_controls_enabled(False) + self.progress_bar.setVisible(True) + self.progress_bar.setRange(0, 0) + self.status_label.setText('Building preview...') + + self.preview_worker = PreviewWorker(self.config.load_config(), self) + self.preview_worker.progress.connect(self.update_progress) + self.preview_worker.finished.connect(self.preview_finished) + self.preview_worker.error.connect(self.preview_failed) + self.preview_worker.start() + + def preview_finished(self, result): + self.progress_bar.setVisible(False) + self.set_action_controls_enabled(self.connection_verified) + self.status_label.setText('Preview ready') + self.show_preview_dialog(result) + + def preview_failed(self, message): + self.progress_bar.setVisible(False) + self.set_action_controls_enabled(self.connection_verified) + self.status_label.setText('Preview failed') + QMessageBox.warning(self, 'Preview Error', message) + def auto_update_finished(self, result): """Handle automatic update completion""" - self.last_update_label.setText(f"Last update: {datetime.now().strftime('%Y-%m-%d %I:%M:%S %p')}") + last_sync = datetime.now().strftime('%Y-%m-%d %I:%M:%S %p') + self.last_update_label.setText(f"Last update: {last_sync}") + self.last_sync_chip.setText(f"Last sync: {last_sync}") # Append result to log file try: @@ -546,10 +1092,15 @@ class MainWindow(QMainWindow): # Show tray notification if minimized if self.isMinimized() or not self.isVisible(): if self.tray_icon: + message_icon = QSystemTrayIcon.MessageIcon.Information + message_text = "Playlist updated successfully" + if "Error" in result: + message_icon = QSystemTrayIcon.MessageIcon.Warning + message_text = "Playlist update failed" self.tray_icon.showMessage( "Journey FM Update", - "Playlist updated successfully", - QSystemTrayIcon.MessageIcon.Information, + message_text, + message_icon, 3000 ) @@ -560,13 +1111,14 @@ class MainWindow(QMainWindow): config = wizard.get_config() self.config.save_config(config) self.load_settings() + self.refresh_connection_status() QMessageBox.information(self, "Settings Saved", "Configuration updated successfully!") def show_buy_list(self): """Show the Amazon buy list dialog with interactive features""" try: buy_list_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'amazon_buy_list.txt') - with open(buy_list_path, 'r') as f: + with open(buy_list_path, 'r', encoding='utf-8') as f: content = f.read() except FileNotFoundError: QMessageBox.information(self, "No Buy List", "No buy list available. Run an update to generate the list.") @@ -576,6 +1128,9 @@ class MainWindow(QMainWindow): return try: + self.buy_list_state_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'amazon_buy_list_state.json') + self.buy_list_state = self.load_buy_list_state() + # Parse content to get songs lines = content.split('\n') songs = [] @@ -588,17 +1143,58 @@ class MainWindow(QMainWindow): artist_title = lines[i].strip() if i + 1 < len(lines) and lines[i + 1].startswith('http'): url = lines[i + 1].strip() - songs.append((artist_title, url)) + songs.append({ + 'artist_title': artist_title, + 'amazon_url': url, + 'key': artist_title.lower(), + }) i += 3 # Skip blank line else: i += 1 else: i += 1 + self.buy_list_all_songs = songs + dialog = QDialog(self) dialog.setWindowTitle("Amazon Buy List") dialog.setModal(True) - dialog.resize(600, 400) + dialog.resize(760, 470) + dialog.setStyleSheet(""" + QDialog { + background-color: #f7f3ec; + color: #22343d; + } + QLabel { + color: #22343d; + font-weight: 600; + } + QLineEdit { + color: #22343d; + background-color: #ffffff; + border: 1px solid #c8ccc7; + border-radius: 6px; + padding: 4px 8px; + } + QListWidget { + color: #22343d; + background-color: #ffffff; + alternate-background-color: #f6f8f4; + border: 1px solid #c8ccc7; + border-radius: 8px; + padding: 4px; + } + QListWidget::item { + color: #22343d; + background-color: #ffffff; + padding: 6px 8px; + border-radius: 4px; + } + QListWidget::item:selected { + color: #ffffff; + background-color: #1f7668; + } + """) layout = QVBoxLayout() @@ -606,12 +1202,17 @@ class MainWindow(QMainWindow): search_layout = QHBoxLayout() search_layout.addWidget(QLabel("Search:")) self.search_input = QLineEdit() - self.search_input.textChanged.connect(lambda: self.filter_buy_list(songs)) + self.search_input.textChanged.connect(self.filter_buy_list) search_layout.addWidget(self.search_input) + + self.hide_completed_checkbox = QCheckBox('Hide purchased') + self.hide_completed_checkbox.toggled.connect(self.filter_buy_list) + search_layout.addWidget(self.hide_completed_checkbox) layout.addLayout(search_layout) # List widget self.buy_list_widget = QListWidget() + self.buy_list_widget.setAlternatingRowColors(True) self.buy_list_label = QLabel(f"Found {len(songs)} songs to buy:") layout.addWidget(self.buy_list_label) self.populate_buy_list(songs) @@ -619,12 +1220,29 @@ class MainWindow(QMainWindow): # Buttons button_layout = QHBoxLayout() - open_button = QPushButton("Open Selected") - open_button.clicked.connect(self.open_selected_buy_items) - button_layout.addWidget(open_button) + + open_amazon_button = QPushButton('Open Amazon') + open_amazon_button.clicked.connect(lambda: self.open_selected_buy_items('amazon')) + button_layout.addWidget(open_amazon_button) + + open_apple_button = QPushButton('Open Apple Music') + open_apple_button.clicked.connect(lambda: self.open_selected_buy_items('apple')) + button_layout.addWidget(open_apple_button) + + open_spotify_button = QPushButton('Open Spotify') + open_spotify_button.clicked.connect(lambda: self.open_selected_buy_items('spotify')) + button_layout.addWidget(open_spotify_button) + + purchased_button = QPushButton('Mark Purchased') + purchased_button.clicked.connect(lambda: self.set_selected_purchase_state(True)) + button_layout.addWidget(purchased_button) + + unpurchased_button = QPushButton('Unmark Purchased') + unpurchased_button.clicked.connect(lambda: self.set_selected_purchase_state(False)) + button_layout.addWidget(unpurchased_button) remove_button = QPushButton("Remove Selected") - remove_button.clicked.connect(lambda: self.remove_selected_buy_items(songs, dialog)) + remove_button.clicked.connect(lambda: self.remove_selected_buy_items(dialog)) button_layout.addWidget(remove_button) close_button = QPushButton("Close") @@ -638,38 +1256,109 @@ class MainWindow(QMainWindow): except Exception as e: QMessageBox.critical(self, "Error", f"Failed to show buy list dialog: {e}") + def load_buy_list_state(self): + import json + + if not os.path.exists(self.buy_list_state_path): + return {} + try: + with open(self.buy_list_state_path, 'r', encoding='utf-8') as file_handle: + payload = json.load(file_handle) + return payload if isinstance(payload, dict) else {} + except Exception: + return {} + + def save_buy_list_state(self): + import json + + try: + with open(self.buy_list_state_path, 'w', encoding='utf-8') as file_handle: + json.dump(self.buy_list_state, file_handle, indent=2) + except Exception as exc: + logger.error('Failed to save buy-list state: %s', exc) + + def build_store_url(self, artist_title, provider): + import urllib.parse + + query = urllib.parse.quote_plus(artist_title) + if provider == 'apple': + return f'https://music.apple.com/us/search?term={query}' + if provider == 'spotify': + return f'https://open.spotify.com/search/{query}' + return f'https://www.amazon.com/s?k={query}&i=digital-music' + def populate_buy_list(self, songs): """Populate the buy list widget""" self.buy_list_widget.clear() - self.buy_list_label.setText(f"Found {len(songs)} songs to buy:") - for artist_title, url in songs: - item = QListWidgetItem(artist_title) - item.setData(1, url) # Store URL in item data + active_count = 0 + for song in songs: + key = song.get('key', song.get('artist_title', '').lower()) + purchased = bool(self.buy_list_state.get(key, {}).get('purchased', False)) + if purchased: + display = f"[Purchased] {song['artist_title']}" + else: + display = song['artist_title'] + active_count += 1 + item = QListWidgetItem(display) + item.setData(1, song) item.setCheckState(Qt.CheckState.Unchecked) self.buy_list_widget.addItem(item) + self.buy_list_label.setText(f"Showing {len(songs)} songs ({active_count} not purchased)") + + def filter_buy_list(self, *_): + """Filter the buy list based on search text and purchased state.""" + search_text = self.search_input.text().lower().strip() + hide_purchased = self.hide_completed_checkbox.isChecked() + + filtered = [] + for song in self.buy_list_all_songs: + artist_title = song.get('artist_title', '') + if search_text and search_text not in artist_title.lower(): + continue + key = song.get('key', artist_title.lower()) + purchased = bool(self.buy_list_state.get(key, {}).get('purchased', False)) + if hide_purchased and purchased: + continue + filtered.append(song) - def filter_buy_list(self, all_songs): - """Filter the buy list based on search text""" - search_text = self.search_input.text().lower() - filtered = [(at, url) for at, url in all_songs if search_text in at.lower()] self.populate_buy_list(filtered) - def open_selected_buy_items(self): - """Open selected buy list items in browser""" + def open_selected_buy_items(self, provider='amazon'): + """Open selected buy list items in browser for requested provider.""" for i in range(self.buy_list_widget.count()): item = self.buy_list_widget.item(i) if item.checkState() == Qt.CheckState.Checked: - url = item.data(1) + song = item.data(1) + artist_title = song.get('artist_title', '') + url = song.get('amazon_url') if provider == 'amazon' else self.build_store_url(artist_title, provider) QDesktopServices.openUrl(QUrl(url)) - def remove_selected_buy_items(self, all_songs, dialog): + def set_selected_purchase_state(self, purchased): + selected = 0 + for i in range(self.buy_list_widget.count()): + item = self.buy_list_widget.item(i) + if item.checkState() != Qt.CheckState.Checked: + continue + song = item.data(1) + key = song.get('key', song.get('artist_title', '').lower()) + self.buy_list_state.setdefault(key, {})['purchased'] = purchased + selected += 1 + + if selected == 0: + QMessageBox.information(self, 'No Selection', 'Please check one or more songs first.') + return + + self.save_buy_list_state() + self.filter_buy_list() + + def remove_selected_buy_items(self, dialog): """Remove selected items from buy list""" - to_remove = [] + to_remove = set() for i in range(self.buy_list_widget.count()): item = self.buy_list_widget.item(i) if item.checkState() == Qt.CheckState.Checked: - artist_title = item.text() - to_remove.append(artist_title) + song = item.data(1) + to_remove.add(song.get('key', song.get('artist_title', '').lower())) if not to_remove: QMessageBox.information(dialog, "No Selection", "Please select items to remove.") @@ -682,55 +1371,92 @@ class MainWindow(QMainWindow): if reply != QMessageBox.Yes: return - # Remove from all_songs - updated_songs = [(at, url) for at, url in all_songs if at not in to_remove] + # Remove from song cache + updated_songs = [song for song in self.buy_list_all_songs if song.get('key', song.get('artist_title', '').lower()) not in to_remove] # Rewrite file try: - with open('amazon_buy_list.txt', 'w') as f: + with open('amazon_buy_list.txt', 'w', encoding='utf-8') as f: f.write("Songs not in your library - Amazon search links:\n\n") - for artist_title, url in updated_songs: + for song in updated_songs: + artist_title = song.get('artist_title', '') + url = song.get('amazon_url', '') 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 + self.buy_list_all_songs = updated_songs + for key in list(self.buy_list_state.keys()): + if key in to_remove: + del self.buy_list_state[key] + self.save_buy_list_state() + # Update display - self.populate_buy_list(updated_songs) + self.filter_buy_list() QMessageBox.information(dialog, "Removed", f"Removed {len(to_remove)} items from buy list.") def show_statistics(self): """Show statistics dashboard""" import sqlite3 import json + from collections import Counter dialog = QDialog(self) dialog.setWindowTitle("Statistics Dashboard") dialog.setModal(True) - dialog.resize(400, 300) + dialog.resize(460, 380) layout = QVBoxLayout() try: conn = sqlite3.connect('playlist_history.db') c = conn.cursor() - c.execute('SELECT COUNT(*), SUM(added_count), SUM(missing_count) FROM history') + c.execute('SELECT COUNT(*), SUM(scraped_count), SUM(matched_count), SUM(added_count), SUM(missing_count), SUM(duplicate_count), SUM(skipped_count) FROM history') result = c.fetchone() + c.execute("SELECT MAX(date), MAX(CASE WHEN status='success' THEN date END) FROM history") + run_markers = c.fetchone() + c.execute('SELECT station_breakdown FROM history') + station_rows = c.fetchall() conn.close() total_updates = result[0] or 0 - total_added = result[1] or 0 - total_missing = result[2] or 0 + total_scraped = result[1] or 0 + total_matched = result[2] or 0 + total_added = result[3] or 0 + total_missing = result[4] or 0 + total_duplicates = result[5] or 0 + total_skipped = result[6] or 0 + + station_counter = Counter() + for (station_json,) in station_rows: + try: + for station in json.loads(station_json or '[]'): + if station.get('success'): + station_counter[station.get('display_name', station.get('station', 'Unknown'))] += station.get('scraped_count', 0) + except Exception: + continue layout.addWidget(QLabel(f"Total Updates: {total_updates}")) + layout.addWidget(QLabel(f"Total Songs Scraped: {total_scraped}")) + layout.addWidget(QLabel(f"Total Songs Matched in Plex: {total_matched}")) layout.addWidget(QLabel(f"Total Songs Added to Plex: {total_added}")) layout.addWidget(QLabel(f"Total Songs in Buy List: {total_missing}")) + layout.addWidget(QLabel(f"Duplicates Suppressed: {total_duplicates}")) + layout.addWidget(QLabel(f"Invalid/Skipped Entries: {total_skipped}")) + layout.addWidget(QLabel(f"Last Attempted Run: {run_markers[0] or 'Never'}")) + layout.addWidget(QLabel(f"Last Successful Run: {run_markers[1] or 'Never'}")) if total_updates > 0: - avg_added = total_added / total_updates - avg_missing = total_missing / total_updates - layout.addWidget(QLabel(f"Average Songs Added per Update: {avg_added:.1f}")) - layout.addWidget(QLabel(f"Average Missing Songs per Update: {avg_missing:.1f}")) + avg_match_rate = (total_matched / total_scraped * 100) if total_scraped else 0 + layout.addWidget(QLabel(f"Overall Match Rate: {avg_match_rate:.1f}%")) + layout.addWidget(QLabel(f"Average Songs Added per Update: {total_added / total_updates:.1f}")) + layout.addWidget(QLabel(f"Average Missing Songs per Update: {total_missing / total_updates:.1f}")) + + if station_counter: + layout.addWidget(QLabel('Per-Station Scrape Totals:')) + for station_name, count in station_counter.most_common(): + layout.addWidget(QLabel(f"{station_name}: {count}")) except Exception as e: layout.addWidget(QLabel(f"Error loading statistics: {str(e)}")) @@ -761,10 +1487,22 @@ class MainWindow(QMainWindow): tab_widget = QTabWidget() + def style_axis(fig, ax): + fig.patch.set_facecolor('#fffdf8') + ax.set_facecolor('#fffdf8') + for spine in ax.spines.values(): + spine.set_color('#b9c2b6') + ax.tick_params(axis='x', colors='#22343d') + ax.tick_params(axis='y', colors='#22343d') + ax.title.set_color('#22343d') + ax.xaxis.label.set_color('#22343d') + ax.yaxis.label.set_color('#22343d') + ax.grid(color='#d8ddd5', linestyle='-', linewidth=0.6, alpha=0.6) + try: conn = sqlite3.connect('playlist_history.db') c = conn.cursor() - c.execute('SELECT date, added_songs, missing_songs FROM history ORDER BY date') + c.execute('SELECT date, added_songs, missing_songs, matched_count, duplicate_count, station_breakdown FROM history ORDER BY date') rows = c.fetchall() conn.close() @@ -774,8 +1512,11 @@ class MainWindow(QMainWindow): cumulative_list = [] artist_counter = Counter() update_freq = [] + matched_counts = [] + duplicate_counts = [] + station_counter = Counter() - for date, added_json, missing_json in rows: + for date, added_json, missing_json, matched_count, duplicate_count, station_json in rows: dt = datetime.fromisoformat(date) dates.append(dt.strftime('%Y-%m-%d')) @@ -783,6 +1524,15 @@ class MainWindow(QMainWindow): cumulative_added += len(added_songs) cumulative_list.append(cumulative_added) update_freq.append(len(added_songs)) + matched_counts.append(matched_count or 0) + duplicate_counts.append(duplicate_count or 0) + + try: + for station in json.loads(station_json or '[]'): + if station.get('success'): + station_counter[station.get('display_name', station.get('station', 'Unknown'))] += station.get('scraped_count', 0) + except Exception: + pass for song in added_songs: # Parse artist from "Title by Artist" @@ -800,6 +1550,7 @@ class MainWindow(QMainWindow): ax1.set_xlabel('Date') ax1.set_ylabel('Total Songs') ax1.tick_params(axis='x', rotation=45) + style_axis(fig1, ax1) canvas1 = FigureCanvas(fig1) growth_layout.addWidget(canvas1) growth_tab.setLayout(growth_layout) @@ -818,6 +1569,7 @@ class MainWindow(QMainWindow): ax2.set_xlabel('Artist') ax2.set_ylabel('Songs Added') ax2.tick_params(axis='x', rotation=45) + style_axis(fig2, ax2) canvas2 = FigureCanvas(fig2) artists_layout.addWidget(canvas2) artists_tab.setLayout(artists_layout) @@ -833,11 +1585,47 @@ class MainWindow(QMainWindow): ax3.set_xlabel('Date') ax3.set_ylabel('Songs Added') ax3.tick_params(axis='x', rotation=45) + style_axis(fig3, ax3) canvas3 = FigureCanvas(fig3) freq_layout.addWidget(canvas3) freq_tab.setLayout(freq_layout) tab_widget.addTab(freq_tab, "Update Frequency") + # Tab 4: Match efficiency + match_tab = QWidget() + match_layout = QVBoxLayout() + fig4 = Figure(figsize=(8, 6)) + ax4 = fig4.add_subplot(111) + ax4.plot(dates, matched_counts, marker='o', label='Matched') + ax4.plot(dates, duplicate_counts, marker='s', label='Duplicates Suppressed') + ax4.set_title('Plex Match Quality') + ax4.set_xlabel('Date') + ax4.set_ylabel('Songs') + ax4.tick_params(axis='x', rotation=45) + ax4.legend() + style_axis(fig4, ax4) + canvas4 = FigureCanvas(fig4) + match_layout.addWidget(canvas4) + match_tab.setLayout(match_layout) + tab_widget.addTab(match_tab, "Match Quality") + + # Tab 5: Station contribution + station_tab = QWidget() + station_layout = QVBoxLayout() + fig5 = Figure(figsize=(8, 6)) + ax5 = fig5.add_subplot(111) + if station_counter: + station_names, station_values = zip(*station_counter.items()) + ax5.bar(station_names, station_values) + ax5.set_title('Songs Scraped by Station') + ax5.set_xlabel('Station') + ax5.set_ylabel('Songs') + style_axis(fig5, ax5) + canvas5 = FigureCanvas(fig5) + station_layout.addWidget(canvas5) + station_tab.setLayout(station_layout) + tab_widget.addTab(station_tab, "Stations") + except Exception as e: error_tab = QWidget() error_layout = QVBoxLayout() @@ -854,6 +1642,170 @@ class MainWindow(QMainWindow): dialog.setLayout(layout) dialog.exec() + def show_preview_dialog(self, result): + """Display dry-run sync results and offer apply action.""" + dialog = QDialog(self) + dialog.setWindowTitle('Sync Preview') + dialog.setModal(True) + dialog.resize(980, 680) + + layout = QVBoxLayout() + summary = QLabel(format_result_summary(result)) + summary.setWordWrap(True) + layout.addWidget(summary) + + tab_widget = QTabWidget() + + def build_table(headers, rows): + table = QTableWidget() + table.setColumnCount(len(headers)) + table.setHorizontalHeaderLabels(headers) + table.setRowCount(len(rows)) + table.setAlternatingRowColors(True) + for row_idx, row in enumerate(rows): + for col_idx, value in enumerate(row): + table.setItem(row_idx, col_idx, QTableWidgetItem(str(value))) + table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + return table + + added_rows = [] + for entry in result.get('added_songs', []): + if ' by ' in entry: + title, rest = entry.split(' by ', 1) + added_rows.append((title.strip(), rest.strip())) + else: + added_rows.append((entry, '')) + tab_widget.addTab(build_table(['Title', 'Artist/Source'], added_rows), f"Will Add ({len(added_rows)})") + + duplicate_rows = [ + (song.get('title', ''), song.get('artist', ''), song.get('reason', '')) + for song in result.get('duplicate_songs', []) + ] + tab_widget.addTab(build_table(['Title', 'Artist', 'Reason'], duplicate_rows), f"Duplicates ({len(duplicate_rows)})") + + missing_rows = [ + (song.get('title', ''), song.get('artist', ''), song.get('reason', '')) + for song in result.get('missing_songs', []) + ] + tab_widget.addTab(build_table(['Title', 'Artist', 'Reason'], missing_rows), f"Missing ({len(missing_rows)})") + + skipped_rows = [ + (song.get('title', ''), song.get('artist', ''), song.get('reason', '')) + for song in result.get('skipped_songs', []) + ] + tab_widget.addTab(build_table(['Title', 'Artist', 'Reason'], skipped_rows), f"Skipped ({len(skipped_rows)})") + + layout.addWidget(tab_widget) + + button_row = QHBoxLayout() + apply_btn = QPushButton('Apply Sync Now') + apply_btn.setEnabled(result.get('status') != 'error') + + def apply_and_close(): + dialog.accept() + self.manual_update() + + apply_btn.clicked.connect(apply_and_close) + button_row.addWidget(apply_btn) + button_row.addStretch() + + close_btn = QPushButton('Close') + close_btn.clicked.connect(dialog.close) + button_row.addWidget(close_btn) + layout.addLayout(button_row) + + dialog.setLayout(layout) + dialog.exec() + + def show_station_health(self): + """Show station reliability/quality metrics over recorded history.""" + import sqlite3 + import json + from datetime import datetime + + dialog = QDialog(self) + dialog.setWindowTitle('Station Health') + dialog.setModal(True) + dialog.resize(980, 520) + layout = QVBoxLayout() + + table = QTableWidget() + table.setColumnCount(7) + table.setHorizontalHeaderLabels([ + 'Station', 'Attempts', 'Success %', 'Last Success', + 'Last Pattern', 'Avg Payload KiB', 'Last Error' + ]) + table.setAlternatingRowColors(True) + + station_stats = {} + try: + conn = sqlite3.connect('playlist_history.db') + c = conn.cursor() + c.execute('SELECT date, station_breakdown FROM history ORDER BY date') + rows = c.fetchall() + conn.close() + + for date_text, station_json in rows: + try: + entries = json.loads(station_json or '[]') + except Exception: + entries = [] + for entry in entries: + station_name = entry.get('display_name', entry.get('station', 'Unknown')) + data = station_stats.setdefault(station_name, { + 'attempts': 0, + 'success': 0, + 'last_success': '', + 'last_pattern': '', + 'payload_total': 0, + 'payload_samples': 0, + 'last_error': '', + }) + data['attempts'] += 1 + if entry.get('success'): + data['success'] += 1 + data['last_pattern'] = entry.get('parse_pattern', data['last_pattern']) + payload = int(entry.get('raw_payload_bytes', 0) or 0) + if payload > 0: + data['payload_total'] += payload + data['payload_samples'] += 1 + try: + data['last_success'] = datetime.fromisoformat(date_text).strftime('%Y-%m-%d %H:%M') + except Exception: + data['last_success'] = date_text + else: + data['last_error'] = entry.get('error', '') + + table.setRowCount(len(station_stats)) + for row_idx, (station_name, stats) in enumerate(sorted(station_stats.items())): + success_rate = (stats['success'] / stats['attempts'] * 100.0) if stats['attempts'] else 0.0 + avg_payload = (stats['payload_total'] / stats['payload_samples'] / 1024.0) if stats['payload_samples'] else 0.0 + values = [ + station_name, + str(stats['attempts']), + f"{success_rate:.1f}", + stats['last_success'] or 'Never', + stats['last_pattern'] or 'n/a', + f"{avg_payload:.1f}", + stats['last_error'] or '', + ] + for col_idx, value in enumerate(values): + table.setItem(row_idx, col_idx, QTableWidgetItem(value)) + except Exception as exc: + table.setRowCount(1) + table.setItem(0, 0, QTableWidgetItem('Error loading station health')) + table.setItem(0, 1, QTableWidgetItem(str(exc))) + + table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + layout.addWidget(table) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + buttons.rejected.connect(dialog.close) + layout.addWidget(buttons) + + dialog.setLayout(layout) + dialog.exec() + def show_history(self): """Show the playlist history dialog""" import sqlite3 @@ -868,24 +1820,26 @@ class MainWindow(QMainWindow): layout = QVBoxLayout() table = QTableWidget() - table.setColumnCount(4) - table.setHorizontalHeaderLabels(["Date", "Type", "Artist", "Song"]) + table.setColumnCount(5) + table.setHorizontalHeaderLabels(["Date", "Type", "Artist", "Song", "Reason"]) + table.setAlternatingRowColors(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) + table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) try: conn = sqlite3.connect('playlist_history.db') c = conn.cursor() - c.execute('SELECT date, added_songs, missing_songs FROM history ORDER BY date DESC') + c.execute('SELECT date, added_songs, missing_songs, skipped_songs, duplicate_count FROM history ORDER BY date DESC') rows = c.fetchall() conn.close() # Collect all entries entries = [] - for date, added_songs_json, missing_songs_json in rows: + for date, added_songs_json, missing_songs_json, skipped_songs_json, duplicate_count in rows: try: dt = datetime.fromisoformat(date) date_str = dt.strftime('%Y-%m-%d %H:%M') @@ -902,7 +1856,7 @@ class MainWindow(QMainWindow): else: title = song artist = "Unknown" - entries.append((date_str, "Added", artist, title)) + entries.append((date_str, "Added", artist, title, "")) except Exception: pass @@ -912,16 +1866,29 @@ class MainWindow(QMainWindow): for song in missing_songs: artist = song.get('artist', 'Unknown') title = song.get('title', 'Unknown') - entries.append((date_str, "Missing", artist, title)) + entries.append((date_str, "Missing", artist, title, song.get('reason', 'not-found'))) except Exception: pass + try: + skipped_songs = json.loads(skipped_songs_json) + for song in skipped_songs: + artist = song.get('artist', 'Unknown') + title = song.get('title', 'Unknown') + entries.append((date_str, "Skipped", artist, title, song.get('reason', 'skipped'))) + except Exception: + pass + + if duplicate_count: + entries.append((date_str, "Info", "", "Duplicate suppression", str(duplicate_count))) + table.setRowCount(len(entries)) - for row_idx, (date_str, type_str, artist, song) in enumerate(entries): + for row_idx, (date_str, type_str, artist, song, reason) in enumerate(entries): table.setItem(row_idx, 0, QTableWidgetItem(date_str)) table.setItem(row_idx, 1, QTableWidgetItem(type_str)) table.setItem(row_idx, 2, QTableWidgetItem(artist)) table.setItem(row_idx, 3, QTableWidgetItem(song)) + table.setItem(row_idx, 4, QTableWidgetItem(reason)) except Exception as e: table.setRowCount(1) @@ -949,24 +1916,8 @@ class MainWindow(QMainWindow): QMessageBox.warning(self, "Configuration Error", "Plex token and server IP are required. Please check settings.") return - # Connect to Plex - from plexapi.myplex import MyPlexAccount - account = MyPlexAccount(token=plex_token) - resources = account.resources() - server_resource = None - for r in resources: - for conn in r.connections: - if server_ip in conn.address: - server_resource = r - break - if server_resource: - break - - if not server_resource: - QMessageBox.warning(self, "Connection Error", f"Server at {server_ip} not found.") - return - - plex = server_resource.connect() + from main import connect_to_plex_server + plex = connect_to_plex_server(plex_token, server_ip) # Get playlist try: diff --git a/journeyfm/__init__.py b/journeyfm/__init__.py new file mode 100644 index 0000000..a530a19 --- /dev/null +++ b/journeyfm/__init__.py @@ -0,0 +1 @@ +"""Shared services for Journey FM Playlist Creator.""" diff --git a/journeyfm/config_store.py b/journeyfm/config_store.py new file mode 100644 index 0000000..f0c42a3 --- /dev/null +++ b/journeyfm/config_store.py @@ -0,0 +1,170 @@ +import json +import os +from pathlib import Path + +from journeyfm.paths import data_path + +try: + import keyring +except Exception: + keyring = None + +CONFIG_PATH = data_path("config.json") +KEYRING_SERVICE = "JourneyFMPlaylistCreator" +DEFAULT_CONFIG = { + "PLEX_TOKEN": "", + "SERVER_IP": "", + "PLAYLIST_NAME": "Journey FM Recently Played", + "AUTO_UPDATE": False, + "UPDATE_INTERVAL": 15, + "UPDATE_UNIT": "Minutes", + "SELECTED_STATIONS": ["journey_fm", "spirit_fm"], +} +NON_SECRET_KEYS = { + "SERVER_IP", + "PLAYLIST_NAME", + "AUTO_UPDATE", + "UPDATE_INTERVAL", + "UPDATE_UNIT", + "SELECTED_STATIONS", +} + + +def is_containerized(): + return os.path.exists("/.dockerenv") or os.getenv("JOURNEYFM_CONTAINER") == "1" + + +def _normalize_selected_stations(value): + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + return list(DEFAULT_CONFIG["SELECTED_STATIONS"]) + + +def _read_config_file(config_path=CONFIG_PATH): + if not Path(config_path).exists(): + return {} + try: + with open(config_path, "r", encoding="utf-8") as file_handle: + return json.load(file_handle) + except Exception: + return {} + + +def _write_config_file(config, config_path=CONFIG_PATH): + with open(config_path, "w", encoding="utf-8") as file_handle: + json.dump(config, file_handle, indent=2) + + +def get_secret(key): + env_value = os.getenv(key, "").strip() + if env_value: + return env_value + if not is_containerized() and keyring is not None: + try: + stored = keyring.get_password(KEYRING_SERVICE, key) + if stored: + return stored + except Exception: + pass + file_config = _read_config_file() + return str(file_config.get(key, "")).strip() + + +def set_secret(key, value): + if not value: + delete_secret(key) + return + if not is_containerized() and keyring is not None: + try: + keyring.set_password(KEYRING_SERVICE, key, value) + return + except Exception: + pass + file_config = _read_config_file() + file_config[key] = value + _write_config_file(file_config) + + +def delete_secret(key): + if not is_containerized() and keyring is not None: + try: + keyring.delete_password(KEYRING_SERVICE, key) + except Exception: + pass + file_config = _read_config_file() + if key in file_config: + del file_config[key] + _write_config_file(file_config) + + +def migrate_legacy_secrets(config_path=CONFIG_PATH): + file_config = _read_config_file(config_path) + token = str(file_config.get("PLEX_TOKEN", "")).strip() + if token and not is_containerized() and keyring is not None: + try: + keyring.set_password(KEYRING_SERVICE, "PLEX_TOKEN", token) + del file_config["PLEX_TOKEN"] + _write_config_file(file_config, config_path) + except Exception: + pass + + +def load_runtime_config(config_path=CONFIG_PATH): + migrate_legacy_secrets(config_path) + config = dict(DEFAULT_CONFIG) + file_config = _read_config_file(config_path) + config.update({key: value for key, value in file_config.items() if key in NON_SECRET_KEYS}) + config["PLEX_TOKEN"] = get_secret("PLEX_TOKEN") + + env_server = os.getenv("SERVER_IP", "").strip() + env_playlist = os.getenv("PLAYLIST_NAME", "").strip() + env_stations = os.getenv("SELECTED_STATIONS", "").strip() + env_auto_update = os.getenv("AUTO_UPDATE", "").strip() + env_interval = os.getenv("UPDATE_INTERVAL", "").strip() + env_unit = os.getenv("UPDATE_UNIT", "").strip() + + if env_server: + config["SERVER_IP"] = env_server + if env_playlist: + config["PLAYLIST_NAME"] = env_playlist + if env_stations: + config["SELECTED_STATIONS"] = _normalize_selected_stations(env_stations) + else: + config["SELECTED_STATIONS"] = _normalize_selected_stations(config.get("SELECTED_STATIONS")) + if env_auto_update: + config["AUTO_UPDATE"] = env_auto_update.lower() in {"1", "true", "yes", "on"} + if env_interval: + try: + config["UPDATE_INTERVAL"] = int(env_interval) + except ValueError: + pass + if env_unit: + config["UPDATE_UNIT"] = env_unit + + return config + + +def save_runtime_config(config, config_path=CONFIG_PATH): + config = dict(config) + token = str(config.pop("PLEX_TOKEN", "")).strip() + if token: + set_secret("PLEX_TOKEN", token) + + persisted = {} + for key in NON_SECRET_KEYS: + if key not in config: + continue + value = config[key] + if isinstance(value, list): + persisted[key] = ",".join(value) + else: + persisted[key] = value + _write_config_file(persisted, config_path) + + +def get_display_config(config_path=CONFIG_PATH): + config = load_runtime_config(config_path) + config["PLEX_TOKEN"] = get_secret("PLEX_TOKEN") + return config diff --git a/journeyfm/history_service.py b/journeyfm/history_service.py new file mode 100644 index 0000000..b47ebdb --- /dev/null +++ b/journeyfm/history_service.py @@ -0,0 +1,73 @@ +import json +import sqlite3 +from datetime import datetime + +from journeyfm.paths import data_path + +HISTORY_COLUMNS = { + "status": "TEXT DEFAULT 'success'", + "scraped_count": "INTEGER DEFAULT 0", + "matched_count": "INTEGER DEFAULT 0", + "duplicate_count": "INTEGER DEFAULT 0", + "skipped_count": "INTEGER DEFAULT 0", + "station_breakdown": "TEXT DEFAULT '[]'", + "skipped_songs": "TEXT DEFAULT '[]'", + "error_message": "TEXT DEFAULT ''", +} + + +def init_history_db(db_path=None): + db_path = db_path or data_path("playlist_history.db") + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY, + date TEXT, + added_count INTEGER, + added_songs TEXT, + missing_count INTEGER, + missing_songs TEXT + ) + """ + ) + cursor.execute("PRAGMA table_info(history)") + existing_columns = {row[1] for row in cursor.fetchall()} + for column_name, definition in HISTORY_COLUMNS.items(): + if column_name not in existing_columns: + cursor.execute(f"ALTER TABLE history ADD COLUMN {column_name} {definition}") + conn.commit() + conn.close() + + +def save_history_entry(result, db_path=None): + db_path = db_path or data_path("playlist_history.db") + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO history ( + date, added_count, added_songs, missing_count, missing_songs, + status, scraped_count, matched_count, duplicate_count, skipped_count, + station_breakdown, skipped_songs, error_message + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + datetime.now().isoformat(), + result.get("added_count", 0), + json.dumps(result.get("added_songs", [])), + result.get("missing_count", 0), + json.dumps(result.get("missing_songs", [])), + result.get("status", "success"), + result.get("scraped_count", 0), + result.get("matched_count", 0), + result.get("duplicate_count", 0), + result.get("skipped_count", 0), + json.dumps(result.get("station_breakdown", [])), + json.dumps(result.get("skipped_songs", [])), + result.get("error_message", ""), + ), + ) + conn.commit() + conn.close() diff --git a/journeyfm/paths.py b/journeyfm/paths.py new file mode 100644 index 0000000..0990a9e --- /dev/null +++ b/journeyfm/paths.py @@ -0,0 +1,16 @@ +import os +from pathlib import Path + + +def get_data_dir() -> Path: + data_dir = os.getenv("JOURNEYFM_DATA_DIR", "").strip() + if data_dir: + path = Path(data_dir) + else: + path = Path(".") + path.mkdir(parents=True, exist_ok=True) + return path + + +def data_path(*parts: str) -> Path: + return get_data_dir().joinpath(*parts) \ No newline at end of file diff --git a/journeyfm/plex_service.py b/journeyfm/plex_service.py new file mode 100644 index 0000000..0ab1de1 --- /dev/null +++ b/journeyfm/plex_service.py @@ -0,0 +1,273 @@ +import urllib.parse + +from plexapi.myplex import MyPlexAccount +from plexapi.server import PlexServer + + +class PlexConnectionError(RuntimeError): + pass + + +def normalize_server_target(server_ip): + target = (server_ip or "").strip() + if not target: + return "" + if "://" not in target: + target = f"http://{target}" + parsed = urllib.parse.urlparse(target) + return (parsed.hostname or "").strip().lower() + + +def resolve_plex_server_url(token, server_ip): + target_host = normalize_server_target(server_ip) + if not token: + raise PlexConnectionError("Missing Plex token") + if not target_host: + raise PlexConnectionError("Missing Plex server address") + + try: + account = MyPlexAccount(token=token) + except Exception as exc: + raise PlexConnectionError(f"Failed to authenticate with Plex: {exc}") from exc + + for resource in account.resources(): + for connection in resource.connections: + connection_host = normalize_server_target(connection.uri) + address = (connection.address or "").strip().lower() + if connection_host == target_host or target_host == address or target_host in address: + return connection.uri + + raise PlexConnectionError( + f"Server not found at {server_ip}. Verify the configured address and that Plex is reachable." + ) + + +def connect_to_plex_server(token, server_ip): + server_url = resolve_plex_server_url(token, server_ip) + try: + return PlexServer(server_url, token) + except Exception as exc: + raise PlexConnectionError(f"Failed to connect to Plex server at {server_url}: {exc}") from exc + + +def playlist_item_count(playlist): + leaf_count = getattr(playlist, "leafCount", None) + if isinstance(leaf_count, int): + return leaf_count + try: + return len(playlist.items()) + except Exception: + return 0 + + +def fetch_playlists(token, server_ip, music_only=False): + plex = connect_to_plex_server(token, server_ip) + return fetch_playlists_for_plex(plex, music_only) + + +def fetch_playlists_for_plex(plex, music_only=False): + playlists = [] + for playlist in plex.playlists(): + playlist_type = getattr(playlist, "playlistType", "unknown") + if music_only and playlist_type != "audio": + continue + playlists.append( + { + "title": playlist.title, + "playlist_type": playlist_type, + "item_count": playlist_item_count(playlist), + } + ) + playlists.sort(key=lambda item: (item["title"].lower(), item["playlist_type"])) + return playlists + + +def validate_playlist_target(token, server_ip, playlist_name, music_only=False): + plex = connect_to_plex_server(token, server_ip) + playlist_name = (playlist_name or "").strip() + if not playlist_name: + raise PlexConnectionError("Playlist name is required") + + playlists = fetch_playlists_for_plex(plex, music_only=False) + for playlist in playlists: + if playlist["title"].strip().lower() == playlist_name.lower(): + if music_only and playlist["playlist_type"] != "audio": + raise PlexConnectionError("Selected playlist is not a music playlist") + return { + "exists": True, + "playlist": playlist, + "message": f"Ready to write to existing playlist '{playlist_name}'", + } + + try: + plex.library.section("Music") + except Exception as exc: + raise PlexConnectionError(f"Music library is not accessible on this server: {exc}") from exc + + return { + "exists": False, + "playlist": { + "title": playlist_name, + "playlist_type": "audio", + "item_count": 0, + }, + "message": f"Playlist '{playlist_name}' will be created on first sync", + } + + +def _normalize_artist(name): + """Return a simplified artist token for fuzzy comparison.""" + import re + name = name.lower() + # Drop featured artists — everything after feat/ft/with/w/ + name = re.sub(r"\s+(feat\.?|ft\.?|featuring|w/|with)\s+.*", "", name) + # Drop parenthetical / bracketed suffixes + name = re.sub(r"[\(\[].*?[\)\]]", "", name) + # Expand & / + to "and" + name = re.sub(r"\s*[&+]\s*", " and ", name) + # Collapse articles at start: "the foo" → "foo" + name = re.sub(r"^(the|a|an)\s+", "", name) + # Remove common punctuation that Plex sometimes strips + name = re.sub(r"['\.\-,]", "", name) + # Collapse whitespace + name = re.sub(r"\s+", " ", name).strip() + return name + + +def _artists_match(search_artist, track_artist_raw): + """ + Return True if the station artist string plausibly refers to the same + artist stored in Plex, using several normalisation / containment passes. + """ + sa = _normalize_artist(search_artist) + ta = _normalize_artist(track_artist_raw) + + if not sa or not ta: + return False + + # Exact normalised match + if sa == ta: + return True + + # One fully contains the other (e.g. "Brandon Lake" in "Brandon Lake & Maverick") + if sa in ta or ta in sa: + return True + + # Token-set overlap: every token in the shorter name appears in the longer + sa_tokens = set(sa.split()) + ta_tokens = set(ta.split()) + if sa_tokens and ta_tokens: + shorter = sa_tokens if len(sa_tokens) <= len(ta_tokens) else ta_tokens + longer = ta_tokens if len(sa_tokens) <= len(ta_tokens) else sa_tokens + # All tokens of the shorter name found in the longer (handles "for king country" ↔ "for king and country") + if shorter <= longer: + return True + # At least >60 % of tokens match (handles minor word-count diffs) + overlap = len(shorter & longer) + if overlap / len(shorter) >= 0.6: + return True + + return False + + +def create_or_update_playlist(plex, songs, playlist_name, dry_run=False): + import re + music_library = plex.library.section("Music") + tracks = [] + seen_keys = set() + missing = [] + skipped = [] + added_songs = [] + duplicate_songs = [] + + for song in songs: + clean_title = re.sub(r"\([^)]*\)", "", song["title"]).strip() + clean_title = re.sub(r"\s+", " ", clean_title) + clean_artist = re.sub(r"\([^)]*\)", "", song["artist"]).strip() + clean_artist = re.sub(r"\s+", " ", clean_artist) + + if not clean_title or len(clean_title) < 3 or clean_title.lower() in {"by", "recently played", "now playing:", "search"}: + skipped.append({"title": song["title"], "artist": song["artist"], "reason": "invalid-title"}) + continue + if re.match(r"^[!?\s]+$", clean_title): + skipped.append({"title": song["title"], "artist": song["artist"], "reason": "punctuation-only-title"}) + continue + + search_title = re.sub(r"[!?]", "", clean_title).strip() + search_artist = re.sub(r"[!?]", "", clean_artist).strip() + # Expand & / + in title for Plex search (artist expansion handled by _artists_match) + search_title = re.sub(r"\s*[&+]\s*", " and ", search_title, flags=re.IGNORECASE) + + results = music_library.searchTracks(title=search_title) + if not results: + # Secondary search: try the raw title in case Plex stores it differently + results = music_library.searchTracks(title=clean_title) + if not results: + missing.append({"title": song["title"], "artist": song["artist"], "reason": "not-found"}) + continue + + matched = False + for track in results: + try: + track_artist_raw = track.artist().title + except Exception: + continue + + if _artists_match(search_artist, track_artist_raw): + if track.ratingKey not in seen_keys: + tracks.append((track, song)) + seen_keys.add(track.ratingKey) + matched = True + break + + if not matched: + missing.append({"title": song["title"], "artist": song["artist"], "reason": "artist-mismatch"}) + + matched_count = len(tracks) + added_count = 0 + if tracks: + try: + try: + existing = plex.playlist(playlist_name) + existing_keys = {item.ratingKey for item in existing.items()} + new_tracks = [] + for track, song in tracks: + if track.ratingKey in existing_keys: + duplicate_songs.append({"title": track.title, "artist": track.artist().title, "reason": "already-in-playlist"}) + continue + new_tracks.append((track, song)) + + if new_tracks: + if not dry_run: + existing.addItems([item[0] for item in new_tracks]) + for track, song in new_tracks: + track.addLabel("Journey FM") + track.rate(5) + added_songs.append(f"{track.title} by {track.artist().title} ({song['source']})") + else: + for track, song in new_tracks: + added_songs.append(f"{track.title} by {track.artist().title} ({song['source']})") + added_count = len(new_tracks) + except Exception: + if not dry_run: + plex.createPlaylist(playlist_name, [item[0] for item in tracks]) + for track, song in tracks: + track.addLabel("Journey FM") + track.rate(5) + added_songs.append(f"{track.title} by {track.artist().title} ({song['source']})") + else: + for track, song in tracks: + added_songs.append(f"{track.title} by {track.artist().title} ({song['source']})") + added_count = len(tracks) + except Exception as exc: + raise PlexConnectionError(f"Failed updating playlist '{playlist_name}': {exc}") from exc + + return { + "matched_count": matched_count, + "added_count": added_count, + "added_songs": added_songs, + "missing_songs": missing, + "duplicate_count": len(duplicate_songs), + "duplicate_songs": duplicate_songs, + "skipped_songs": skipped, + } diff --git a/journeyfm/scraper_service.py b/journeyfm/scraper_service.py new file mode 100644 index 0000000..2622e13 --- /dev/null +++ b/journeyfm/scraper_service.py @@ -0,0 +1,452 @@ +import logging +import os +import re +import shutil +import time +from datetime import datetime +from pathlib import Path + +from bs4 import BeautifulSoup +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.chrome.service import Service +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait +from webdriver_manager.chrome import ChromeDriverManager +import requests + +from journeyfm.paths import data_path + +logger = logging.getLogger(__name__) +DEBUG_SCRAPE_DIR = data_path("debug_scrapes") + + +def detect_chrome_binary(): + 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"), + ]) + else: + 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 detect_chromedriver_path(): + env_driver = os.getenv("CHROMEDRIVER_PATH", "").strip() + if env_driver and os.path.exists(env_driver): + return env_driver + for candidate in ["/usr/bin/chromedriver", "/usr/local/bin/chromedriver"]: + if os.path.exists(candidate): + return candidate + return shutil.which("chromedriver") + + +def _write_debug_payload(station_key, payload, extension): + DEBUG_SCRAPE_DIR.mkdir(exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + file_path = DEBUG_SCRAPE_DIR / f"{station_key}_{timestamp}.{extension}" + with open(file_path, "w", encoding="utf-8") as file_handle: + file_handle.write(payload) + latest_path = DEBUG_SCRAPE_DIR / f"{station_key}_latest.{extension}" + with open(latest_path, "w", encoding="utf-8") as file_handle: + file_handle.write(payload) + return str(file_path) + + +class JourneyFMScraper: + station_key = "journey_fm" + display_name = "Journey FM" + url = "https://www.myjourneyfm.com/recently-played/" + + SELECTOR_PATTERNS = [ + { + "name": "rp-item", + "container": ("div", "rp-item"), + "title": ("h5", "song-title"), + "artist": ("p", "song-artist"), + }, + { + "name": "top-rp-list", + "container": ("div", "song-item"), + "title": ("div", "title-artist"), + "artist": ("span", None), + }, + ] + + @classmethod + def _extract_with_pattern(cls, soup, pattern): + songs = [] + container_tag, container_class = pattern["container"] + title_tag, title_class = pattern["title"] + artist_tag, artist_class = pattern["artist"] + + for item in soup.find_all(container_tag, class_=container_class): + if title_class: + title_elem = item.find(title_tag, class_=title_class) + else: + title_elem = item.find(title_tag) + if not title_elem: + continue + + if pattern["name"] == "top-rp-list": + paragraph = title_elem.find("p") + if not paragraph: + continue + combined = paragraph.get_text(" ", strip=True) + parts = re.split(r"\s+by\s+", combined, maxsplit=1, flags=re.IGNORECASE) + if len(parts) != 2: + continue + title = parts[0].strip() + artist = parts[1].strip() + else: + if artist_class: + artist_elem = item.find(artist_tag, class_=artist_class) + else: + artist_elem = item.find(artist_tag) + if not artist_elem: + continue + title = title_elem.get_text(" ", strip=True).strip() + artist = artist_elem.get_text(" ", strip=True).strip() + + if title and artist: + songs.append({"title": title, "artist": artist, "source": cls.display_name}) + + return songs + + @classmethod + def parse_html_with_telemetry(cls, html): + soup = BeautifulSoup(html, "html.parser") + for pattern in cls.SELECTOR_PATTERNS: + songs = cls._extract_with_pattern(soup, pattern) + if songs: + return songs, pattern["name"] + return [], "none" + + @staticmethod + def parse_html(html): + songs, _pattern = JourneyFMScraper.parse_html_with_telemetry(html) + return songs + + @classmethod + def scrape(cls, driver): + driver.get(cls.url) + WebDriverWait(driver, 15).until( + lambda _driver: ( + bool(_driver.find_elements(By.CSS_SELECTOR, "div.rp-item")) + or bool(_driver.find_elements(By.CSS_SELECTOR, "div.song-item")) + ) + ) + try: + more_button = WebDriverWait(driver, 5).until(EC.element_to_be_clickable((By.ID, "moreSongs"))) + more_button.click() + time.sleep(2) + except Exception: + pass + html = driver.page_source + raw_path = _write_debug_payload(cls.station_key, html, "html") + songs, parse_pattern = cls.parse_html_with_telemetry(html) + return songs, raw_path, parse_pattern + + @classmethod + def scrape_without_driver(cls): + response = requests.get(cls.url, timeout=20) + response.raise_for_status() + html = response.text + raw_path = _write_debug_payload(cls.station_key, html, "html") + songs, parse_pattern = cls.parse_html_with_telemetry(html) + return songs, raw_path, f"{parse_pattern}-requests-fallback" + + +class SpiritFMScraper: + station_key = "spirit_fm" + display_name = "Spirit FM" + url = "https://spiritfm.com/ajax/now_playing_history.txt" + + @staticmethod + def parse_text(text): + songs = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + match = re.match(r"^\w+\s+\d+:\d+[AP]M\s+(.+?)\s+-\s+(.+)$", line) + if not match: + continue + artist = match.group(1).strip().replace("&", "&") + title = match.group(2).strip().replace("&", "&") + if title and artist: + songs.append({"title": title, "artist": artist, "source": SpiritFMScraper.display_name}) + return songs + + @classmethod + def scrape(cls, driver): + driver.get(cls.url) + time.sleep(2) + text = BeautifulSoup(driver.page_source, "html.parser").get_text() + raw_path = _write_debug_payload(cls.station_key, text, "txt") + return cls.parse_text(text), raw_path, "plain-text-regex" + + @classmethod + def scrape_without_driver(cls): + response = requests.get(cls.url, timeout=20) + response.raise_for_status() + text = response.text + raw_path = _write_debug_payload(cls.station_key, text, "txt") + return cls.parse_text(text), raw_path, "plain-text-regex-requests-fallback" + + +class KLOVEScraper: + station_key = "klove" + display_name = "K-LOVE" + url = "https://www.klove.com/music/songs" + # K-LOVE is a JS-rendered React app; Selenium is the primary path. + # The requests fallback may still return partial SSR content worth parsing. + _LOAD_SELECTOR = "a[href*='/music/artists/']" + + @staticmethod + def parse_html(html): + songs, _pattern = KLOVEScraper.parse_html_with_telemetry(html) + return songs + + @staticmethod + def parse_html_with_telemetry(html): + soup = BeautifulSoup(html, "html.parser") + songs = [] + seen = set() + + # Collect every anchor that points at /music/artists/{artist}/{song} + # The page renders each card as two anchors (art + title) — use hrefs + # as a dedup key so we only process each song once. + processed_hrefs = set() + for anchor in soup.find_all("a", href=re.compile(r"/music/artists/[^/]+/[^/]+")): + href = anchor.get("href", "").strip().rstrip("/") + parts = href.strip("/").split("/") + # Expect: music / artists / artist-slug / song-slug + if len(parts) < 4: + continue + + title_text = anchor.get_text(" ", strip=True) + if not title_text or len(title_text) < 2: + # Image-only anchor (cover art link) — skip it; the title anchor + # for this same href will appear later in the DOM with real text. + continue + + # Now that we have a text-bearing anchor, dedup by href + if href in processed_hrefs: + continue + processed_hrefs.add(href) + + # Skip navigation / album-only links (e.g. artist page without song) + song_slug = parts[-1] + artist_slug = parts[-2] if len(parts) >= 4 else "" + + # Find "By {artist}" text in nearby DOM — check siblings and parent + artist_text = "" + parent = anchor.parent + if parent: + full_text = parent.get_text(" ", strip=True) + by_match = re.search(r"\bBy\s+(.+)", full_text) + if by_match: + artist_text = by_match.group(1).strip() + + if not artist_text and parent and parent.parent: + grandparent_text = parent.parent.get_text(" ", strip=True) + by_match = re.search(r"\bBy\s+(.+?)(?:\bBy\b|$)", grandparent_text) + if by_match: + artist_text = by_match.group(1).strip() + + if not artist_text: + # Derive artist from URL slug as last resort + artist_text = " ".join(w.capitalize() for w in artist_slug.split("-")) + + # Strip leftover boilerplate that sometimes bleeds in + artist_text = re.sub(r"\s*(feat\.|ft\.|featuring|Play Sample|Image).*$", "", + artist_text, flags=re.IGNORECASE).strip() + + if title_text and artist_text: + key = (title_text.lower(), artist_text.lower()) + if key not in seen: + seen.add(key) + songs.append({"title": title_text, "artist": artist_text, + "source": KLOVEScraper.display_name}) + + if songs: + return songs, "klove-anchor-by" + return [], "none" + + @classmethod + def scrape(cls, driver): + driver.get(cls.url) + # Wait for song anchor links to appear (JS-rendered) + WebDriverWait(driver, 20).until( + EC.presence_of_element_located((By.CSS_SELECTOR, cls._LOAD_SELECTOR)) + ) + time.sleep(2) # Allow lazy-load tiles to settle + html = driver.page_source + raw_path = _write_debug_payload(cls.station_key, html, "html") + songs, parse_pattern = cls.parse_html_with_telemetry(html) + return songs, raw_path, parse_pattern + + @classmethod + def scrape_without_driver(cls): + headers = { + "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" + ) + } + response = requests.get(cls.url, headers=headers, timeout=20) + response.raise_for_status() + html = response.text + raw_path = _write_debug_payload(cls.station_key, html, "html") + songs, parse_pattern = cls.parse_html_with_telemetry(html) + return songs, raw_path, f"{parse_pattern}-requests-fallback" + + +SCRAPERS = { + JourneyFMScraper.station_key: JourneyFMScraper, + SpiritFMScraper.station_key: SpiritFMScraper, + KLOVEScraper.station_key: KLOVEScraper, +} + + +def build_driver(): + options = Options() + options.add_argument("--headless=new") + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("--disable-gpu") + options.add_argument("--window-size=1920,1080") + options.add_argument("--disable-blink-features=AutomationControlled") + 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) + + chrome_binary = detect_chrome_binary() + if chrome_binary: + options.binary_location = chrome_binary + chromedriver_path = detect_chromedriver_path() + if chromedriver_path: + service = Service(chromedriver_path) + else: + service = Service(ChromeDriverManager().install()) + return webdriver.Chrome(service=service, options=options) + + +def scrape_recently_played(selected_stations=None): + selected_stations = selected_stations or ["journey_fm", "spirit_fm"] + driver = None + driver_start_error = "" + all_songs = [] + seen = set() + station_results = [] + + try: + try: + driver = build_driver() + except Exception as exc: + driver = None + driver_start_error = str(exc) + logger.warning("Browser scraper unavailable; using fallback mode: %s", exc) + for station_key in selected_stations: + scraper_cls = SCRAPERS.get(station_key) + if scraper_cls is None: + station_results.append({ + "station": station_key, + "display_name": station_key, + "success": False, + "error": "unsupported-station", + "scraped_count": 0, + "raw_path": "", + }) + continue + try: + if driver is not None: + try: + songs, raw_path, parse_pattern = scraper_cls.scrape(driver) + except Exception as scrape_error: + logger.warning( + "Selenium scrape failed for %s, retrying fallback mode: %s", + scraper_cls.display_name, + scrape_error, + ) + songs, raw_path, parse_pattern = scraper_cls.scrape_without_driver() + else: + songs, raw_path, parse_pattern = scraper_cls.scrape_without_driver() + raw_payload_bytes = 0 + if raw_path and os.path.exists(raw_path): + try: + raw_payload_bytes = os.path.getsize(raw_path) + except Exception: + raw_payload_bytes = 0 + added_for_station = 0 + for song in songs: + dedupe_key = (song["title"].lower(), song["artist"].lower()) + if dedupe_key in seen: + continue + seen.add(dedupe_key) + all_songs.append(song) + added_for_station += 1 + logger.info(" Found: %s by %s", song["title"], song["artist"]) + station_results.append({ + "station": station_key, + "display_name": scraper_cls.display_name, + "success": True, + "error": "", + "scraped_count": added_for_station, + "raw_path": raw_path, + "raw_payload_bytes": raw_payload_bytes, + "parse_pattern": parse_pattern, + }) + except Exception as exc: + combined_error = str(exc) + if driver is None and driver_start_error: + combined_error = f"{combined_error}; browser init: {driver_start_error}" + station_results.append({ + "station": station_key, + "display_name": scraper_cls.display_name, + "success": False, + "error": combined_error, + "scraped_count": 0, + "raw_path": "", + "raw_payload_bytes": 0, + "parse_pattern": "error", + }) + logger.error("Error scraping %s: %s", scraper_cls.display_name, exc) + finally: + if driver is not None: + driver.quit() + + logger.info("Total unique songs found: %s", len(all_songs)) + return {"songs": all_songs, "station_results": station_results} diff --git a/journeyfm/update_service.py b/journeyfm/update_service.py new file mode 100644 index 0000000..b96c57b --- /dev/null +++ b/journeyfm/update_service.py @@ -0,0 +1,138 @@ +import logging +import os +import urllib.parse + +from journeyfm.config_store import load_runtime_config +from journeyfm.history_service import init_history_db, save_history_entry +from journeyfm.paths import data_path +from journeyfm.plex_service import PlexConnectionError, connect_to_plex_server, create_or_update_playlist +from journeyfm.scraper_service import scrape_recently_played + +logger = logging.getLogger(__name__) + + +def update_buy_list(missing_songs, buy_list_path=None): + buy_list_path = buy_list_path or data_path("amazon_buy_list.txt") + if not missing_songs: + logger.info("No new songs to add to buy list.") + return [] + + existing_songs = set() + if os.path.exists(buy_list_path): + try: + with open(buy_list_path, "r", encoding="utf-8") as file_handle: + lines = file_handle.read().splitlines() + for line in lines: + if line.strip() and not line.startswith("http") and " - " in line: + artist, title = line.split(" - ", 1) + existing_songs.add((artist.strip(), title.strip())) + except Exception: + pass + + new_missing = [] + for song in missing_songs: + key = (song["artist"], song["title"]) + if key in existing_songs: + continue + existing_songs.add(key) + new_missing.append(song) + + if not new_missing: + logger.info("No new songs to add to buy list.") + return [] + + file_exists = os.path.exists(buy_list_path) + file_empty = not file_exists or os.path.getsize(buy_list_path) == 0 + with open(buy_list_path, "a", encoding="utf-8") as file_handle: + if file_empty: + file_handle.write("Songs not in your library - Amazon search links:\n\n") + for song in new_missing: + query = urllib.parse.quote(f"{song['artist']} {song['title']}") + file_handle.write(f"{song['artist']} - {song['title']}\n") + file_handle.write(f"https://www.amazon.com/s?k={query}&i=digital-music\n\n") + + logger.info("Buy list updated.") + return new_missing + + +def format_result_summary(result): + station_bits = [] + for station in result.get("station_breakdown", []): + if station.get("success"): + pattern = station.get("parse_pattern", "unknown") + payload_bytes = int(station.get("raw_payload_bytes", 0) or 0) + payload_kib = payload_bytes / 1024.0 + station_bits.append( + f"{station['display_name']}: {station['scraped_count']} [{pattern}, {payload_kib:.1f} KiB]" + ) + else: + station_bits.append(f"{station['display_name']}: failed") + station_summary = ", ".join(station_bits) or "No station data" + + lines = [ + f"Scraped {result.get('scraped_count', 0)} songs ({station_summary})", + f"Matched {result.get('matched_count', 0)} in Plex", + f"Added {result.get('added_count', 0)} to playlist", + f"Skipped {result.get('skipped_count', 0)} invalid entries", + f"Suppressed {result.get('duplicate_count', 0)} duplicates already in playlist", + f"Missing {result.get('missing_count', 0)} from Plex library", + ] + if result.get("status") == "error": + lines.append(f"Error: {result.get('error_message', 'Unknown error')}") + return "\n".join(lines) + + +def run_update_job(config=None, dry_run=False, persist_history=True, write_buy_list=True): + config = config or load_runtime_config() + result = { + "status": "success", + "error_message": "", + "station_breakdown": [], + "scraped_count": 0, + "matched_count": 0, + "added_count": 0, + "added_songs": [], + "missing_count": 0, + "missing_songs": [], + "duplicate_count": 0, + "duplicate_songs": [], + "skipped_count": 0, + "skipped_songs": [], + } + + token = config.get("PLEX_TOKEN", "").strip() + server_ip = config.get("SERVER_IP", "").strip() + playlist_name = config.get("PLAYLIST_NAME", "Journey FM Recently Played").strip() + selected_stations = config.get("SELECTED_STATIONS", ["journey_fm", "spirit_fm"]) + + if not token or not server_ip: + result["status"] = "error" + result["error_message"] = "Missing configuration: set Plex token and server IP before running updates" + return result + + if persist_history: + init_history_db() + + scrape_result = scrape_recently_played(selected_stations) + songs = scrape_result["songs"] + result["station_breakdown"] = scrape_result["station_results"] + result["scraped_count"] = len(songs) + + try: + plex = connect_to_plex_server(token, server_ip) + playlist_result = create_or_update_playlist(plex, songs, playlist_name, dry_run=dry_run) + result.update(playlist_result) + result["missing_count"] = len(result.get("missing_songs", [])) + result["skipped_count"] = len(result.get("skipped_songs", [])) + if write_buy_list and not dry_run: + update_buy_list(result.get("missing_songs", [])) + except PlexConnectionError as exc: + result["status"] = "error" + result["error_message"] = str(exc) + except Exception as exc: + result["status"] = "error" + result["error_message"] = f"Unexpected update failure: {exc}" + + if persist_history: + save_history_entry(result) + return result diff --git a/main.py b/main.py index 8c6b2c8..101cad5 100644 --- a/main.py +++ b/main.py @@ -1,46 +1,23 @@ #!/usr/bin/env python3 -""" -Journey FM Playlist Creator +"""Journey FM Playlist Creator CLI entrypoint.""" -This script scrapes recently played songs from myjourneyfm.com, -checks if they exist in the local music library, -creates a playlist of matching songs, and imports it into Plex. -""" - -import time -import sys -from selenium import webdriver -from selenium.webdriver.chrome.options import Options -from selenium.webdriver.chrome.service import Service -from webdriver_manager.chrome import ChromeDriverManager -from selenium.webdriver.common.by import By -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC -from bs4 import BeautifulSoup -import os -from plexapi.myplex import MyPlexAccount -from mutagen.easyid3 import EasyID3 -import re -from datetime import datetime -import json -import subprocess -import urllib.parse -import sqlite3 import logging -import shutil +import sys + +from journeyfm.config_store import load_runtime_config +from journeyfm.history_service import init_history_db +from journeyfm.paths import data_path +from journeyfm.plex_service import connect_to_plex_server +from journeyfm.update_service import format_result_summary, run_update_job -# 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() def configure_logging(): - """Configure logging with safe handlers for pythonw/GUI environments.""" + """Configure logging with quiet defaults for CLI and GUI entrypoints.""" log_format = '%(asctime)s - %(levelname)s - %(message)s' handlers = [] try: - handlers.append(logging.FileHandler('playlist_log.txt', encoding='utf-8')) + handlers.append(logging.FileHandler(data_path('playlist_log.txt'), encoding='utf-8')) except Exception: pass @@ -49,494 +26,34 @@ def configure_logging(): handlers.append(logging.StreamHandler(stream)) logging.basicConfig(level=logging.INFO, format=log_format, handlers=handlers, force=True) - for noisy_logger in ('webdriver_manager', 'matplotlib', 'urllib3', 'plexapi'): + for noisy_logger in ('webdriver_manager', 'WDM', 'matplotlib', 'urllib3', 'plexapi'): logging.getLogger(noisy_logger).setLevel(logging.WARNING) configure_logging() 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""" - if selected_stations is None: - selected_stations = ['journey_fm', 'spirit_fm'] - - options = Options() - options.add_argument("--headless=new") - options.add_argument("--no-sandbox") - options.add_argument("--disable-dev-shm-usage") - options.add_argument("--disable-gpu") - options.add_argument("--window-size=1920,1080") - options.add_argument("--disable-blink-features=AutomationControlled") - 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) - 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: - logger.error("Failed to start Chrome: %s", e) - return [] - - all_songs = [] - seen = set() - - # Journey FM - uses for title, normal text for artist - if 'journey_fm' in selected_stations: - try: - logger.info("Scraping Journey FM...") - driver.get('https://www.myjourneyfm.com/recently-played/') - WebDriverWait(driver, 15).until( - EC.presence_of_element_located((By.CSS_SELECTOR, 'div.rp-item')) - ) - - # Click "View More" button if exists - try: - more_button = WebDriverWait(driver, 5).until( - EC.element_to_be_clickable((By.ID, "moreSongs")) - ) - more_button.click() - time.sleep(2) - except Exception: - pass - - soup = BeautifulSoup(driver.page_source, 'html.parser') - - # Find all song items using the proper class structure - song_items = soup.find_all('div', class_='rp-item') - - for item in song_items: - try: - title_elem = item.find('h5', class_='song-title') - artist_elem = item.find('p', class_='song-artist') - - if title_elem and artist_elem: - title = title_elem.get_text().strip() - artist = artist_elem.get_text().strip() - - if title and artist: - key = (title.lower(), artist.lower()) - if key not in seen: - all_songs.append({'title': title, 'artist': artist, 'source': 'Journey FM'}) - seen.add(key) - logger.info(" Found: %s by %s", title, artist) - except Exception: - continue - - except Exception as e: - logger.error("Error scraping Journey FM: %s", e) - - # Spirit FM - loads songs from iframe text file - if 'spirit_fm' in selected_stations: - try: - logger.info("Scraping Spirit FM...") - driver.get('https://spiritfm.com/ajax/now_playing_history.txt') - time.sleep(2) - - # Parse the plain text format: "Mon 01:45PM Artist - Title" - soup = BeautifulSoup(driver.page_source, 'html.parser') - text_content = soup.get_text() - - # Split into lines and parse each - for line in text_content.split('\n'): - line = line.strip() - if not line: - continue - - # Match pattern: Day HH:MM(AM/PM) Artist - Title - match = re.match(r'^\w+\s+\d+:\d+[AP]M\s+(.+?)\s+-\s+(.+)$', line) - if match: - artist = match.group(1).strip() - title = match.group(2).strip() - - # Clean up HTML entities - artist = artist.replace('&', '&') - title = title.replace('&', '&') - - if title and artist: - key = (title.lower(), artist.lower()) - if key not in seen: - all_songs.append({'title': title, 'artist': artist, 'source': 'Spirit FM'}) - seen.add(key) - logger.info(" Found: %s by %s", title, artist) - - except Exception as e: - logger.error("Error scraping Spirit FM: %s", e) - - if driver: - driver.quit() - - 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() - -def find_song_in_library(artist, title, library_path): - """Check if song exists in local library""" - for root, dirs, files in os.walk(library_path): - for file in files: - if file.endswith(('.mp3', '.flac', '.m4a')): # Add more formats if needed - try: - audio = EasyID3(os.path.join(root, file)) - file_artist = normalize_string(audio.get('artist', [''])[0]) - 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 Exception: - continue - return None - -def create_playlist_in_plex(plex, songs, playlist_name): - """Create playlist in Plex and return added count, added songs, and missing songs""" - # Assuming Plex has a music library named 'Music' - music_library = plex.library.section('Music') - tracks = [] - seen_keys = set() # Track which ratingKeys we've already added to prevent duplicates within this run - missing = [] - added_songs = [] - - for song in songs: - # Clean title and artist by removing text in parentheses and normalizing spaces - clean_title = re.sub(r'\([^)]*\)', '', song['title']).strip() - clean_title = re.sub(r'\s+', ' ', clean_title) # Replace multiple spaces with single space - clean_artist = re.sub(r'\([^)]*\)', '', song['artist']).strip() - clean_artist = re.sub(r'\s+', ' ', clean_artist) # Replace multiple spaces with single space - - # Skip invalid entries - if not clean_title or len(clean_title) < 3 or clean_title.lower() in ['by', 'recently played', 'now playing:', 'search']: - continue - - # Skip entries that are ONLY punctuation (but allow titles with punctuation in them) - if re.match(r'^[!?\\\\s]+$', clean_title): - continue - - # Remove only ! and ? for searching, but keep apostrophes - search_title = re.sub(r'[!?]', '', clean_title).strip() - search_artist = re.sub(r'[!?]', '', clean_artist).strip() - - # Normalize & and + to "and" for both title and artist - search_title = re.sub(r'\s*[&+]\s*', ' and ', search_title, flags=re.IGNORECASE) - search_artist = re.sub(r'\s*[&+]\s*', ' and ', search_artist, flags=re.IGNORECASE) - - # Search for tracks by cleaned title - results = music_library.searchTracks(title=search_title) - if results: - # Try to match by artist as well - matched = False - for track in results: - track_artist = track.artist().title.lower() - 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() - - # Normalize & and + in track artist as well - track_artist = re.sub(r'\s*[&+]\s*', ' and ', track_artist) - - # Check if artist matches (case-insensitive, partial match) - # Match if main artist name is in the track artist or vice versa - if (search_artist_main in track_artist or track_artist in search_artist_main or - search_artist_lower in track_artist or track_artist in search_artist_lower): - # Only add if we haven't already added this track in this run - if track.ratingKey not in seen_keys: - tracks.append((track, song)) - seen_keys.add(track.ratingKey) - matched = True - break - # If no artist match, skip this track (don't add wrong songs) - if not matched: - missing.append(song) - - added = 0 - if tracks: - try: - # Try to find existing playlist - try: - existing = plex.playlist(playlist_name) - # Get existing track rating keys to avoid duplicates - existing_keys = {item.ratingKey for item in existing.items()} - # Filter out tracks already in playlist - new_tracks = [track_song for track_song in tracks if track_song[0].ratingKey not in existing_keys] - - if new_tracks: - existing.addItems([ts[0] for ts in new_tracks]) - for track, song in new_tracks: - # Auto-tag added songs - track.addLabel("Journey FM") - track.rate(5) - added = len(new_tracks) - added_songs.extend([f"{track.title} by {track.artist().title} ({song['source']})" for track, song in new_tracks]) - else: - added = 0 - except Exception: - # Playlist doesn't exist, create it - plex.createPlaylist(playlist_name, [ts[0] for ts in tracks]) - for track, song in tracks: - # Auto-tag added songs - track.addLabel("Journey FM") - 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: - added = 0 - added_songs = [] - else: - logger.info("No matching tracks found in Plex.") - added = 0 - added_songs = [] - - return added, added_songs, missing - -def prompt_for_config(): - """Prompt user for configuration variables""" - print("Configuration not found. Please provide the following:") - plex_token = input("Enter your Plex token: ").strip() - server_ip = input("Enter your Plex server IP: ").strip() - playlist_name = input("Enter the playlist name: ").strip() - config = { - 'PLEX_TOKEN': plex_token, - 'SERVER_IP': server_ip, - 'PLAYLIST_NAME': playlist_name - } - with open('config.json', 'w') as f: - json.dump(config, f) - return config def 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'] - } + """Compatibility wrapper for existing callers.""" + return load_runtime_config() - 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') - c = conn.cursor() - c.execute('''CREATE TABLE IF NOT EXISTS history ( - id INTEGER PRIMARY KEY, - date TEXT, - added_count INTEGER, - added_songs TEXT, - missing_count INTEGER, - missing_songs TEXT - )''') - conn.commit() - conn.close() - -def save_history_entry(added_count, added_songs, missing_count, missing_songs): - """Save a history entry to the database""" - conn = sqlite3.connect('playlist_history.db') - c = conn.cursor() - date = datetime.now().isoformat() - c.execute('INSERT INTO history (date, added_count, added_songs, missing_count, missing_songs) VALUES (?, ?, ?, ?, ?)', - (date, added_count, json.dumps(added_songs), missing_count, json.dumps(missing_songs))) - conn.commit() - conn.close() - -def setup_scheduler(params): - """Run the setup_scheduler.ps1 with parameters""" - cmd = ['powershell.exe', '-File', 'setup_scheduler.ps1'] - for k, v in params.items(): - cmd.extend(['-' + k, str(v)]) - try: - subprocess.run(cmd, check=True) - logger.info("Scheduler setup completed.") - except subprocess.CalledProcessError as e: - logger.error("Scheduler setup failed: %s", e) def main(): - global PLEX_TOKEN, SERVER_IP, PLAYLIST_NAME - 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() - - # Initialize history database + """Run one update cycle and print a concise result summary.""" init_history_db() - - # Scrape songs with selected stations - songs = scrape_recently_played(selected_stations) - - # Connect to Plex via MyPlexAccount - 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 - for r in resources: - for conn in r.connections: - if SERVER_IP in conn.address: - server_resource = r - break - if server_resource: - break - - if not server_resource: - logger.error("Server not found at %s. Verify SERVER_IP and that Plex is reachable.", SERVER_IP) - return - - 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 - # For now, assume all scraped songs are to be added if found in Plex - - # Create playlist - if songs: - added, added_songs, missing = create_playlist_in_plex(plex, songs, PLAYLIST_NAME) - else: - logger.info("No songs found.") - added, added_songs, missing = 0, [], [] - - # Save to history - save_history_entry(added, added_songs, len(missing), missing) - - # Create Amazon buy list for missing songs - if missing: - - # Read existing buy list - existing_songs = set() - if os.path.exists('amazon_buy_list.txt'): - try: - with open('amazon_buy_list.txt', 'r') as f: - content = f.read() - lines = content.split('\n') - i = 0 - while i < len(lines): - if lines[i].startswith('Songs not in your library'): - i += 2 # Skip header - continue - if lines[i].strip() and not lines[i].startswith('http'): - if ' - ' in lines[i]: - artist, title = lines[i].split(' - ', 1) - existing_songs.add((artist.strip(), title.strip())) - i += 1 - except Exception as e: - logger.warning("Failed reading amazon_buy_list.txt: %s", e) - - # Filter new missing songs - new_missing = [] - for song in missing: - key = (song['artist'], song['title']) - if key not in existing_songs: - new_missing.append(song) - existing_songs.add(key) - - if new_missing: - with open('amazon_buy_list.txt', 'a') as f: # Append mode - if not os.path.exists('amazon_buy_list.txt') or os.path.getsize('amazon_buy_list.txt') == 0: - f.write("Songs not in your library - Amazon search links:\n\n") - for song in new_missing: - artist = song['artist'] - title = song['title'] - # Create Amazon search URL - query = urllib.parse.quote(f"{artist} {title}") - url = f"https://www.amazon.com/s?k={query}&i=digital-music" - f.write(f"{artist} - {title}\n{url}\n\n") - else: - logger.info("No new songs to add to buy list.") - - logger.info("Buy list updated.") - - # Log completion - end_time = datetime.now() - duration = (end_time - start_time).total_seconds() + result = run_update_job(load_runtime_config()) + summary = format_result_summary(result) + print(summary) + if result.get('status') == 'error': + logger.error(summary) + return result + def update_playlist(): - """Function to update playlist, callable from GUI""" - main() + """Compatibility wrapper used by the GUI.""" + return main() + if __name__ == '__main__': main() diff --git a/requirements.txt b/requirements.txt index b8a64e6..bc6e4de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ mutagen selenium webdriver-manager PySide6 -matplotlib \ No newline at end of file +matplotlib +keyring \ No newline at end of file diff --git a/run_container.py b/run_container.py new file mode 100644 index 0000000..7bae766 --- /dev/null +++ b/run_container.py @@ -0,0 +1,38 @@ +import logging +import os +import time + +from journeyfm.config_store import load_runtime_config +from journeyfm.update_service import format_result_summary, run_update_job + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + + +def get_sleep_seconds(): + interval = os.getenv("UPDATE_INTERVAL", "15").strip() + unit = os.getenv("UPDATE_UNIT", "Minutes").strip().lower() + try: + interval_value = max(1, int(interval)) + except ValueError: + interval_value = 15 + if unit.startswith("hour"): + return interval_value * 3600 + return interval_value * 60 + + +def main(): + os.environ.setdefault("JOURNEYFM_CONTAINER", "1") + run_mode = os.getenv("CONTAINER_RUN_MODE", "loop").strip().lower() + while True: + result = run_update_job(load_runtime_config()) + logger.info(format_result_summary(result)) + if run_mode == "once": + break + sleep_seconds = get_sleep_seconds() + logger.info("Sleeping %s seconds before next container sync", sleep_seconds) + time.sleep(sleep_seconds) + + +if __name__ == "__main__": + main() diff --git a/test_scrape.py b/test_scrape.py index b23ac0f..26aaed2 100644 --- a/test_scrape.py +++ b/test_scrape.py @@ -1,23 +1,60 @@ -import requests -from bs4 import BeautifulSoup +import unittest +from pathlib import Path -url = 'https://www.myjourneyfm.com/recently-played/' -response = requests.get(url) -soup = BeautifulSoup(response.content, 'html.parser') +from journeyfm.scraper_service import JourneyFMScraper, SpiritFMScraper, KLOVEScraper -# Try to find song elements -print("Song items:") -song_items = soup.find_all('div', class_='song-item') -for item in song_items: - print(item.prettify()) -print("\nScripts with 'song' or 'played':") -scripts = soup.find_all('script') -for script in scripts: - if script.string and ('song' in script.string.lower() or 'played' in script.string.lower()): - print(script.string[:2000]) +class ScraperParserTests(unittest.TestCase): + def fixture_text(self, name): + fixture_path = Path("tests") / "fixtures" / name + return fixture_path.read_text(encoding="utf-8") -print("\nAll links:") -for a in soup.find_all('a'): - if 'recently' in a.get('href', '').lower(): - print(a) \ No newline at end of file + def test_parse_journey_fm_html(self): + html = """ +
+
No Survivors
+

Jeremy Camp

+
+
+
Take It All Back
+

Tauren Wells

+
+ """ + songs = JourneyFMScraper.parse_html(html) + self.assertEqual(2, len(songs)) + self.assertEqual('No Survivors', songs[0]['title']) + self.assertEqual('Jeremy Camp', songs[0]['artist']) + + def test_parse_journey_fm_current_markup_fixture(self): + html = self.fixture_text("journeyfm_recent_markup.html") + songs, parse_pattern = JourneyFMScraper.parse_html_with_telemetry(html) + self.assertEqual("top-rp-list", parse_pattern) + self.assertEqual(2, len(songs)) + self.assertEqual('Have Your Way', songs[0]['title']) + self.assertEqual('Katy Nichole', songs[0]['artist']) + + def test_parse_spirit_fm_text(self): + text = """ + Mon 01:45PM Brandon Lake - Hard Fought Hallelujah + Mon 01:49PM Danny Gokey - Love God Love People + """ + songs = SpiritFMScraper.parse_text(text) + self.assertEqual(2, len(songs)) + self.assertEqual('Brandon Lake', songs[0]['artist']) + self.assertEqual('Hard Fought Hallelujah', songs[0]['title']) + + def test_parse_klove_fixture(self): + html = self.fixture_text("klove_recent_markup.html") + songs, parse_pattern = KLOVEScraper.parse_html_with_telemetry(html) + self.assertEqual("klove-anchor-by", parse_pattern) + self.assertGreaterEqual(len(songs), 3) + # Verify first song title and artist + titles = [s['title'] for s in songs] + artists = [s['artist'] for s in songs] + self.assertIn('Testimony', titles) + self.assertIn('Katy Nichole', artists) + self.assertIn('TobyMac', artists) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/fixtures/journeyfm_recent_markup.html b/tests/fixtures/journeyfm_recent_markup.html new file mode 100644 index 0000000..2d66b20 --- /dev/null +++ b/tests/fixtures/journeyfm_recent_markup.html @@ -0,0 +1,14 @@ +
+
+
+

Have Your Way by Katy Nichole

+
+

8:39 AM Today

+
+
+
+

Jesus Does by We The Kingdom

+
+

8:31 AM Today

+
+
diff --git a/tests/fixtures/klove_recent_markup.html b/tests/fixtures/klove_recent_markup.html new file mode 100644 index 0000000..3160ab0 --- /dev/null +++ b/tests/fixtures/klove_recent_markup.html @@ -0,0 +1,33 @@ + + + + +
+ + Testimony + +
+ Testimony + By Katy Nichole +
+
+
+ + I Believe + +
+ I Believe + By Phil Wickham +
+
+ + +