29 Commits
Author SHA1 Message Date
nmemmert f41ca1d55a Merge branch 'main' of https://github.com/nmemmert/journey-fm-playlist 2026-08-26 09:06:22 -04:00
nmemmert 031c92b148 Merge branch 'master' 2026-08-26 09:06:06 -04:00
nmemmert b373f29b67 Merge pull request #4 from nmemmert/master
Master
2026-08-26 09:05:23 -04:00
nmemmertandClaude Sonnet 4.6 7efb9265e6 Add per-playlist station selection
Each playlist now has its own station checkboxes in Settings.
Sync scrapes the union of all stations once, then filters songs
by source before syncing each playlist separately.

Config stored as PLAYLISTS JSON: [{name, stations}, ...].
Backward-compatible with flat PLAYLIST_NAME + SELECTED_STATIONS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 09:04:34 -04:00
nmemmertandClaude Sonnet 4.6 ff174a70ce Add multiple playlist support; fix settings save in container
- PLAYLIST_NAMES config key (comma-separated list); falls back to PLAYLIST_NAME
- run_update_job syncs songs to every configured playlist, merges results
- Settings form: dynamic add/remove playlist rows instead of single input
- get_web_config reads config.json directly so form is consistent in
  container environments where env vars override runtime config
- Playlist tab: dropdown to pick which playlist to browse when multiple exist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 08:57:00 -04:00
nmemmert eed3eabfc4 Merge branch 'master' 2026-08-25 16:23:24 -04:00
nmemmertandClaude Sonnet 4.6 389376e43e Fix Top Songs: accumulate play counts per scrape, deduplicate only stat card
song_counts tallies how many times each song appears across all runs
(powers the Top Songs ranking). total_scraped uses the unique seen
set separately so the stat card stays deduplicated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-25 16:22:27 -04:00
nmemmertandClaude Sonnet 4.6 8f28d9c9ce Deduplicate missing/scraped stats; show matched from latest run
- total_scraped: unique songs ever seen playing (not per-run sum)
- total_missing: unique songs ever reported not in Plex library
- total_matched: matched_count from most recent successful run
- Added (144) stays cumulative — each add is a real playlist event

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-25 13:45:10 -04:00
nmemmert cb31f97e27 Merge pull request #3 from nmemmert/master
Count unique scraped songs instead of cumulative per-run totals
2026-08-25 13:35:58 -04:00
nmemmertandClaude Sonnet 4.6 b394416ed7 Count unique scraped songs instead of cumulative per-run totals
total_scraped and station_counts now reflect distinct songs seen
across all history, not the sum of scraped_count per run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-25 13:35:24 -04:00
nmemmert 170f784365 Merge pull request #2 from nmemmert/master
Add live sync log, playlist viewer, donut chart, history expand, buy …
2026-08-25 12:28:59 -04:00
nmemmertandClaude Sonnet 4.6 12b6b15206 Add live sync log, playlist viewer, donut chart, history expand, buy list dedup, and date filter
- SSE-based live sync log drawer (ThreadingHTTPServer + queue.Queue log handler)
- Playlist tab: load tracks from Plex, sort, per-track remove
- Match rate donut chart alongside station bar on Overview
- Expandable history rows showing per-station breakdown chips
- Buy list dedup (POST /api/buy-list-dedup) + Copy All Links button
- Date range filter on History run log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-25 12:18:02 -04:00
nate.emmert ad672b201d Deduplicate top song counting across refreshes 2026-03-26 09:08:26 -04:00
nate.emmert 2d5b23f5bb Disable auto meta-refresh to keep Buy List/History tabs stable 2026-03-26 09:04:18 -04:00
nate.emmert 283d683b29 Add error trace output for /api/refresh and friendly UI display 2026-03-26 08:58:17 -04:00
nate.emmert 88b9d96af5 Integrate desktop features into web UI tabs with API endpoints 2026-03-26 08:46:30 -04:00
nate.emmert 8b06b4d277 Add tabbed dashboard nav + raw API section 2026-03-26 08:36:41 -04:00
nate.emmert 1f2b7129b1 Document song counts + web API + update.sh in README 2026-03-26 08:30:32 -04:00
nate.emmert 81e25f47dd Add update.sh for docker/podman auto-detect and refresh 2026-03-26 08:25:16 -04:00
nate.emmert 21d34b7562 Add per-song per-station playback counts to dashboard 2026-03-26 08:24:41 -04:00
nate.emmert fac4b3ebb8 Add interactive install.sh defaults + run_container webserver defaults and compose updates 2026-03-26 08:14:08 -04:00
nate.emmert 946e8cd92c Add web dashboard with live refresh + fade transitions and CLI/GU integration 2026-03-26 08:00:24 -04:00
Nate Emmert cb966beca2 Fix GitHub Actions workflow YAML compatibility 2026-03-13 09:40:51 -04:00
Nate Emmert ea006795b0 Add GitHub container build workflow 2026-03-13 09:37:30 -04:00
Nate Emmert 6e61d66869 Add Podman env template 2026-03-13 09:34:59 -04:00
Nate Emmert 18f777e3e7 Add K-LOVE support and Podman-friendly container deployment 2026-03-13 09:31:16 -04:00
nmemmert c0ebe118cb Fix pythonw logging handler crashes and reduce noisy dependency logs 2026-02-25 18:11:40 +00:00
nmemmert 3e4ce93824 Improve Windows installer, shortcuts, GUI reliability, and README 2026-02-25 17:55:44 +00:00
nmemmert dab676787d Fix config.json sync - GUI settings now persist to main.py 2025-11-29 11:56:43 -05:00
32 changed files with 5477 additions and 769 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
.git
.gitignore
.venv
__pycache__/
*.pyc
*.pyo
*.pyd
*.log
*.db
nohup.out
icon.png
+18
View File
@@ -0,0 +1,18 @@
PLEX_TOKEN=pU-m3HWYUZU6iXJFhJyA
SERVER_IP=172.16.16.106
PLAYLIST_NAME=Radio
SELECTED_STATIONS=journey_fm,spirit_fm,klove
AUTO_UPDATE=true
UPDATE_INTERVAL=15
UPDATE_UNIT=Minutes
CONTAINER_RUN_MODE=loop
ENABLE_WEB_DASHBOARD=true
DASHBOARD_PORT=8765
JOURNEYFM_DATA_DIR=/data
CHROME_BINARY=/usr/bin/chromium
CHROMEDRIVER_PATH=/usr/bin/chromedriver
JOURNEYFM_CONTAINER=1
PYTHONUNBUFFERED=1
PYTHONDONTWRITEBYTECODE=1
LANG=C.UTF-8
TERM=xterm
+60
View File
@@ -0,0 +1,60 @@
name: Container Image
on:
pull_request:
branches:
- main
push:
branches:
- main
tags:
- 'v*'
workflow_dispatch: {}
env:
REGISTRY: ghcr.io
IMAGE_NAME: "${{ github.repository }}"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: "${{ github.event_name != 'pull_request' }}"
uses: docker/login-action@v3
with:
registry: "${{ env.REGISTRY }}"
username: "${{ github.actor }}"
password: "${{ secrets.GITHUB_TOKEN }}"
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and publish image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: "${{ github.event_name != 'pull_request' }}"
tags: "${{ steps.meta.outputs.tags }}"
labels: "${{ steps.meta.outputs.labels }}"
cache-from: type=gha
cache-to: type=gha,mode=max
+5
View File
@@ -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
+59
View File
@@ -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"]
+318 -159
View File
@@ -1,214 +1,373 @@
# Journey FM Playlist Creator
A cross-platform desktop application that automatically creates Plex playlists from recently played songs on My Journey FM radio station.
Desktop app + script that pulls recently played songs from Journey FM / Spirit FM and updates a Plex playlist.
![Application Screenshot](screenshot.png)
## What It Does
## ✨ Features
- Scrapes recently played songs from:
- `https://www.myjourneyfm.com/recently-played/`
- `https://spiritfm.com/ajax/now_playing_history.txt`
- `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.
- Tracks run history in `playlist_history.db`.
- Writes missing songs to `amazon_buy_list.txt` with Amazon search links.
- Provides a GUI with system tray support, settings, log viewer, history, stats, analytics, and CSV export.
- **System Tray Integration**: Runs minimized in the taskbar/system tray
- **Automatic Updates**: Configurable automatic playlist updates
- **Cross-Platform**: Works on Windows and Linux
- **Log Viewer**: Built-in log viewer for monitoring updates
- **Easy Setup**: Guided setup wizard for initial configuration
- **Duplicate Prevention**: Smart duplicate detection and prevention
- **Artist Matching**: Handles featured artists, punctuation, and name variations
## Requirements
## 🚀 Quick Start
- Python 3.10+
- Plex account token + reachable Plex server
- A Chrome/Chromium browser for Selenium scraping
Dependencies are listed in `requirements.txt` (GUI uses `PySide6`).
## Install
### Windows
1. **Install Dependencies**
```bash
pip install -r requirements.txt
```
Simple install from GitHub (recommended):
2. **Create Application Icon**
```bash
python create_icon.py
```
1. Open **PowerShell**.
2. Run:
3. **Run the Application**
```bash
python journey_fm_app.py
```
Or use the batch file: `run_app.bat`
```powershell
git clone https://github.com/nmemmert/journey-fm-playlist.git
cd journey-fm-playlist
.\install_windows.bat
```
3. Launch from:
- Start Menu: **Journey FM Playlist Creator**
- Desktop shortcut: **Journey FM Playlist Creator**
No Git? Download ZIP from GitHub (**Code -> Download ZIP**), extract, open PowerShell in that folder, and run:
```powershell
.\install_windows.bat
```
If script execution is blocked:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\install_windows.ps1
```
Optional installer switches:
- `-NoDesktopShortcut` skips Desktop shortcut creation
- `-NoStartMenuShortcut` skips Start Menu shortcut creation
Manual install (advanced):
```powershell
py -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
python journey_fm_app.py
```
Installer files:
- `install_windows.bat` - one-click installer launcher
- `install_windows.ps1` - installer logic
### Linux
1. **Run the Installer**
```bash
./install.sh
```
This will:
- Install Python dependencies
- Set up virtual environment
- Install Chromium browser
- Create desktop icon
- Add to applications menu
Quick setup:
2. **Launch from Menu**
- Search for "Journey FM Playlist" in your applications menu
- Or run: `./journey_fm_app.sh`
```bash
./install.sh
```
## ⚙️ Configuration
Interactive install now captures defaults and writes `.env` (auto with optional values):
### First-Time Setup
```bash
./install.sh
```
When you first run the application, a setup wizard will guide you through:
The installer sets defaults:
- UPDATE_UNIT=Minutes
- CONTAINER_RUN_MODE=loop
- SERVER_IP=172.16.16.106
- PLAYLIST_NAME=Radio
- SELECTED_STATIONS=journey_fm,spirit_fm,klove
- UPDATE_INTERVAL=15
- AUTO_UPDATE=true
- PLEX_TOKEN=pU-m3HWYUZU6iXJFhJyA
- PYTHONUNBUFFERED=1
- JOURNEYFM_DATA_DIR=/data
- TERM=xterm
- CHROME_BINARY=/usr/bin/chromium
- JOURNEYFM_CONTAINER=1
- LANG=C.UTF-8
- CHROMEDRIVER_PATH=/usr/bin/chromedriver
1. **Plex Token**: See below for how to get your Plex token
2. **Server IP**: Your Plex server's local IP address (e.g., 192.168.1.100)
3. **Playlist Name**: Name for your Journey FM playlist (default: "Journey FM Recently Played")
4. **Auto-Update Settings**: Enable automatic updates and set intervals
Web dashboard defaults to enabled with start at http://localhost:8765 (or `DASHBOARD_PORT`).
#### How to Get Your Plex Token
Manual setup:
1. Go to [https://plex.tv/claim](https://plex.tv/claim) in your web browser
2. Sign in with your Plex account
3. Copy the claim token from the page (it looks like: `pU-mHWYUZU6iXJFhJyA`)
4. Paste it into the application setup
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python journey_fm_app.py
```
**Note**: This token is used to authenticate with your Plex server. Keep it secure and don't share it.
## Configuration
### Manual Configuration
Use the GUI setup wizard (first launch) or edit `config.json` directly.
You can also edit settings anytime through the Settings menu in the application.
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`.
## 📱 System Tray Features
Example `config.json`:
- **Minimize to Tray**: The app minimizes to system tray instead of closing
- **Right-click Menu**:
- Show: Restore the main window
- Update Now: Manually trigger playlist update
- Quit: Exit the application
- **Double-click**: Restore the main window
- **Notifications**: Get notified when automatic updates complete
```json
{
"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,klove"
}
```
## 📋 Log Viewer
You can also provide runtime env vars:
The built-in log viewer shows:
- Update timestamps
- Songs added to playlist
- Error messages
- Update duration
- Connection status
- `PLEX_TOKEN`
- `SERVER_IP`
- `PLAYLIST_NAME`
- `AUTO_UPDATE`
- `UPDATE_INTERVAL`
- `UPDATE_UNIT`
- `SELECTED_STATIONS`
Logs are automatically saved to `playlist_log.txt` for external viewing.
Optional browser override:
## 🔄 Automatic Updates
- `CHROME_BINARY` (absolute path to Chrome/Chromium executable)
Configure automatic updates to run every:
- 5-120 minutes
- Hours (up to several hours)
If `CHROME_BINARY` is not set, the app auto-detects browser locations for Windows/Linux/macOS and then falls back to `PATH`.
The app will:
- Run in the background
- Show tray notifications when updates complete
- Prevent duplicate songs
- Handle connection errors gracefully
## Running Without GUI
## 🛠️ Troubleshooting
Run one update cycle from CLI:
### Common Issues
**"Server not found"**
- Ensure your Plex server is running
- Check that you've claimed the server at http://[SERVER_IP]:32400/web
- Verify the server IP address in settings
**"No matching tracks found"**
- Ensure your music library is properly scanned in Plex
- Check that song metadata matches (artist names, titles)
- The app handles variations like "&" vs "and", featured artists, etc.
**System tray not working**
- On Linux, ensure you have a system tray (like KDE/GNOME)
- Some desktop environments may need additional packages
### Manual Testing
Test the core functionality:
```bash
python main.py
```
This runs the update once without the GUI.
Run one update cycle and start a local web dashboard for live counts and station metrics:
## 📁 Project Structure
```
journey-fm-playlist/
├── journey_fm_app.py # Main GUI application
├── main.py # Core update logic
├── create_icon.py # Icon generator
├── run_app.bat # Windows launcher
├── journey_fm_app.sh # Linux launcher
├── requirements.txt # Python dependencies
├── config.json # Configuration (auto-generated)
├── playlist_log.txt # Update logs
├── icon.png # Application icon
└── README.md # This file
```
## 🔧 Development
### Adding Features
The application is built with PyQt6 for cross-platform compatibility. Key components:
- `MainWindow`: Main application window
- `SystemTrayApp`: Tray icon and menu
- `SetupWizard`: Configuration wizard
- `LogViewer`: Log display widget
- `UpdateWorker`: Background update thread
### Building Standalone Executables
**Windows (PyInstaller)**
```bash
pip install pyinstaller
pyinstaller --onefile --windowed --icon=icon.ico journey_fm_app.py
python main.py --serve-web
```
**Linux (PyInstaller)**
Advanced options:
- `--host`: Web dashboard listen host (default `127.0.0.1`)
- `--port`: Web dashboard listen port (default `8765`)
- `--no-open`: Do not open default browser automatically
Example:
```bash
pip install pyinstaller
pyinstaller --onefile --icon=icon.png journey_fm_app.py
python main.py --serve-web --host 0.0.0.0 --port 8765 --no-open
```
## 📋 Requirements
## Dashboard: station and song playback counts
- **Python**: 3.8+
- **PyQt6**: GUI framework
- **Selenium**: Web scraping
- **PlexAPI**: Plex integration
- **BeautifulSoup4**: HTML parsing
Once the web dashboard is running at `http://localhost:8765`, you'll see:
### System Requirements
- station-level scrape totals (`station_breakdown` aggregated)
- overall count metrics (scraped, matched, added, missing, duplicates)
- per-station top tracks by play count (history-aware)
- **Windows**: 10+ with system tray support
- **Linux**: Modern desktop environment with system tray
- **Plex Server**: Local or remote Plex server access
### API endpoints
## 🤝 Contributing
- `GET /api/stats` - returns JSON:
- `total_updates`, `total_scraped`, etc.
- `station_counts` by station
- `song_counts` nested `station -> song -> count`
- `GET /api/refresh` - triggers a full scrape + playlist update and returns run result
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test on both Windows and Linux
5. Submit a pull request
### Force refresh from CLI
## 📄 License
```bash
curl http://localhost:8765/api/refresh
```
This project is open source. Feel free to use and modify as needed.
### View raw stats
## 🆘 Support
```bash
curl http://localhost:8765/api/stats | jq .song_counts
```
If you encounter issues:
1. Check the application logs
2. Verify your Plex server connection
3. Ensure all dependencies are installed
4. Test with `python main.py` first
## Update script (docker + podman)
For bugs or feature requests, please create an issue on GitHub.
A helper script is now included: `update.sh`
Usage:
```bash
chmod +x update.sh
./update.sh
```
What it does:
1. Detects Docker or Podman (prompts if both are present)
2. `git pull origin main`
3. Rebuilds compose images (`docker compose build` or `podman compose build`)
4. Starts services (`compose up -d`)
5. Prints `compose ps`
## Container Deployment
## 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. Copy `.env.example` to `.env` and fill in your Plex values.
```bash
cp .env.example .env
```
2. Edit `.env` 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
```
3. Build and run with Docker Compose:
```bash
docker-compose up -d --build
```
Podman equivalent:
```bash
podman compose up -d --build
```
4. 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
```
## GitHub Container Build
GitHub Actions now builds the container on every pull request to `main` and on every push to `main`.
Pushes to `main` also publish the image to GitHub Container Registry.
Image location:
```text
ghcr.io/nmemmert/journey-fm-playlist
```
Pull with Podman:
```bash
podman pull ghcr.io/nmemmert/journey-fm-playlist:latest
```
If the package is private, authenticate first:
```bash
podman login ghcr.io
```
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.
### Podman Quick Start
On a Podman host, the shortest path is:
```bash
git clone https://github.com/nmemmert/journey-fm-playlist.git
cd journey-fm-playlist
cp .env.example .env
podman compose up -d --build
```
Useful follow-up commands:
```bash
podman compose logs -f journey-fm-playlist
podman compose run --rm -e CONTAINER_RUN_MODE=once journey-fm-playlist
podman compose down
```
## 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` - 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
- **Server not found**: verify `SERVER_IP`, Plex availability, and that you are signed in with the correct account token.
- **No songs added**: confirm scraped songs exist in your Plex library and metadata is correct.
- **Browser startup errors**: install Chrome/Chromium or set `CHROME_BINARY` explicitly.
- **No tray icon**: some desktop environments disable or hide legacy trays.
- **GUI fails at startup (`libGL.so.1` / PySide6 import errors)**: install missing system graphics libs (Linux example: `sudo apt install libgl1`) and reinstall Python deps with `pip install -r requirements.txt`.
## Core Files
- `journey_fm_app.py` - desktop GUI and tray app
- `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 `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.
- For container mode, run `docker-compose run --rm -e CONTAINER_RUN_MODE=once journey-fm-playlist` and verify logs/history output.
+762
View File
@@ -219,3 +219,765 @@ https://www.amazon.com/s?k=Tauren%20Wells%20Known&i=digital-music
Big Daddy Weave - All Things New (Single Mix)
https://www.amazon.com/s?k=Big%20Daddy%20Weave%20All%20Things%20New%20%28Single%20Mix%29&i=digital-music
Chris Tomlin - How Great Is Our God
https://www.amazon.com/s?k=Chris%20Tomlin%20How%20Great%20Is%20Our%20God&i=digital-music
Katy Nichole - Hold On
https://www.amazon.com/s?k=Katy%20Nichole%20Hold%20On&i=digital-music
Matthew West - Hello, My Name Is
https://www.amazon.com/s?k=Matthew%20West%20Hello%2C%20My%20Name%20Is&i=digital-music
Cody Carnes - Firm Foundation (He Won't)
https://www.amazon.com/s?k=Cody%20Carnes%20Firm%20Foundation%20%28He%20Won%27t%29&i=digital-music
Evan Craft feat. Danny Gokey - Be Alright
https://www.amazon.com/s?k=Evan%20Craft%20feat.%20Danny%20Gokey%20Be%20Alright&i=digital-music
Crowder - Grave Robber
https://www.amazon.com/s?k=Crowder%20Grave%20Robber&i=digital-music
Anne Wilson ft. Chris Tomlin - The Cross
https://www.amazon.com/s?k=Anne%20Wilson%20ft.%20Chris%20Tomlin%20The%20Cross&i=digital-music
Ryan Stevenson - Preach (Feat. Matt Hammitt)
https://www.amazon.com/s?k=Ryan%20Stevenson%20Preach%20%28Feat.%20Matt%20Hammitt%29&i=digital-music
Rhett Walker - Believer
https://www.amazon.com/s?k=Rhett%20Walker%20Believer&i=digital-music
Lauren Daigle - Trust In You
https://www.amazon.com/s?k=Lauren%20Daigle%20Trust%20In%20You&i=digital-music
7eventh Time Down - By Faith
https://www.amazon.com/s?k=7eventh%20Time%20Down%20By%20Faith&i=digital-music
Crowder - In The House
https://www.amazon.com/s?k=Crowder%20In%20The%20House&i=digital-music
Ben Fuller - Who I Am
https://www.amazon.com/s?k=Ben%20Fuller%20Who%20I%20Am&i=digital-music
Chris Tomlin - At The Cross (Love Ran Red)
https://www.amazon.com/s?k=Chris%20Tomlin%20At%20The%20Cross%20%28Love%20Ran%20Red%29&i=digital-music
Phil Wickham - I Believe
https://www.amazon.com/s?k=Phil%20Wickham%20I%20Believe&i=digital-music
Switch - Symphony
https://www.amazon.com/s?k=Switch%20Symphony&i=digital-music
Michael W. Smith feat. Vanessa C - Waymaker
https://www.amazon.com/s?k=Michael%20W.%20Smith%20feat.%20Vanessa%20C%20Waymaker&i=digital-music
UNITED - Oceans (Where Feet May Fail)
https://www.amazon.com/s?k=UNITED%20Oceans%20%28Where%20Feet%20May%20Fail%29&i=digital-music
One Sonic Society - Great Are You Lord
https://www.amazon.com/s?k=One%20Sonic%20Society%20Great%20Are%20You%20Lord&i=digital-music
MERCYME - GOD WITH US
https://www.amazon.com/s?k=MERCYME%20GOD%20WITH%20US&i=digital-music
Cody Carnes - Take You At Your Word
https://www.amazon.com/s?k=Cody%20Carnes%20Take%20You%20At%20Your%20Word&i=digital-music
Andrew Ripp - For The Love of God
https://www.amazon.com/s?k=Andrew%20Ripp%20For%20The%20Love%20of%20God&i=digital-music
Jordan St. Cyr - Rescue
https://www.amazon.com/s?k=Jordan%20St.%20Cyr%20Rescue&i=digital-music
CAIN - Friend in Jesus
https://www.amazon.com/s?k=CAIN%20Friend%20in%20Jesus&i=digital-music
Charity Gayle - I Believe
https://www.amazon.com/s?k=Charity%20Gayle%20I%20Believe&i=digital-music
Shane & Shane - Creed
https://www.amazon.com/s?k=Shane%20%26%20Shane%20Creed&i=digital-music
Andrew Ripp - For The Love Of God
https://www.amazon.com/s?k=Andrew%20Ripp%20For%20The%20Love%20Of%20God&i=digital-music
Cain - Rise Up (Lazarus)
https://www.amazon.com/s?k=Cain%20Rise%20Up%20%28Lazarus%29&i=digital-music
Tasha Layton - How Far (Album)
https://www.amazon.com/s?k=Tasha%20Layton%20How%20Far%20%28Album%29&i=digital-music
Tobymac - Me Without You
https://www.amazon.com/s?k=Tobymac%20Me%20Without%20You&i=digital-music
Lauren Daigle - Rescue
https://www.amazon.com/s?k=Lauren%20Daigle%20Rescue&i=digital-music
Tasha Layton - Never
https://www.amazon.com/s?k=Tasha%20Layton%20Never&i=digital-music
Evan Craft - Chances
https://www.amazon.com/s?k=Evan%20Craft%20Chances&i=digital-music
Hillsong Worship - What A Beautiful Name
https://www.amazon.com/s?k=Hillsong%20Worship%20What%20A%20Beautiful%20Name&i=digital-music
Caleb & John feat. CAIN - Somebody Like Me
https://www.amazon.com/s?k=Caleb%20%26%20John%20feat.%20CAIN%20Somebody%20Like%20Me&i=digital-music
Francesca Battistelli - Holy Spirit
https://www.amazon.com/s?k=Francesca%20Battistelli%20Holy%20Spirit&i=digital-music
Jadon Lavik - O Come O Come Emmanuel
https://www.amazon.com/s?k=Jadon%20Lavik%20O%20Come%20O%20Come%20Emmanuel&i=digital-music
Denver & The Mile High Orchestra - Most Wonderful Time Of The Year
https://www.amazon.com/s?k=Denver%20%26%20The%20Mile%20High%20Orchestra%20Most%20Wonderful%20Time%20Of%20The%20Year&i=digital-music
Mannheim Steamroller - Deck The Halls
https://www.amazon.com/s?k=Mannheim%20Steamroller%20Deck%20The%20Halls&i=digital-music
Mercyme - Sleigh Ride
https://www.amazon.com/s?k=Mercyme%20Sleigh%20Ride&i=digital-music
Needtobreathe - Go Tell It On The Mountain
https://www.amazon.com/s?k=Needtobreathe%20Go%20Tell%20It%20On%20The%20Mountain&i=digital-music
Bing Crosby - Little Drummer Boy
https://www.amazon.com/s?k=Bing%20Crosby%20Little%20Drummer%20Boy&i=digital-music
Perry Como - Home For The Holidays
https://www.amazon.com/s?k=Perry%20Como%20Home%20For%20The%20Holidays&i=digital-music
Nat King Cole - Caroling, Caroling
https://www.amazon.com/s?k=Nat%20King%20Cole%20Caroling%2C%20Caroling&i=digital-music
The Carpenters - I'll Be Home For Christmas
https://www.amazon.com/s?k=The%20Carpenters%20I%27ll%20Be%20Home%20For%20Christmas&i=digital-music
Johnny Mathis - It Came Upon A Midnight Clear
https://www.amazon.com/s?k=Johnny%20Mathis%20It%20Came%20Upon%20A%20Midnight%20Clear&i=digital-music
Jadon Lavik - Christmas Time Is Here
https://www.amazon.com/s?k=Jadon%20Lavik%20Christmas%20Time%20Is%20Here&i=digital-music
For King & Country - Little Drummer Boy
https://www.amazon.com/s?k=For%20King%20%26%20Country%20Little%20Drummer%20Boy&i=digital-music
Brenda Lee - Rockin Around The Christmas Tree
https://www.amazon.com/s?k=Brenda%20Lee%20Rockin%20Around%20The%20Christmas%20Tree&i=digital-music
Matthew West - O Little Town Of Bethlehem
https://www.amazon.com/s?k=Matthew%20West%20O%20Little%20Town%20Of%20Bethlehem&i=digital-music
Brandon Heath - The Christmas Song
https://www.amazon.com/s?k=Brandon%20Heath%20The%20Christmas%20Song&i=digital-music
Matt Maher - O Come, O Come, Emmanuel
https://www.amazon.com/s?k=Matt%20Maher%20O%20Come%2C%20O%20Come%2C%20Emmanuel&i=digital-music
Ben Rector - Frosty the Snowman
https://www.amazon.com/s?k=Ben%20Rector%20Frosty%20the%20Snowman&i=digital-music
Kutless - It Came Upon A Midnight Clear
https://www.amazon.com/s?k=Kutless%20It%20Came%20Upon%20A%20Midnight%20Clear&i=digital-music
Tasha Layton - This Christmas
https://www.amazon.com/s?k=Tasha%20Layton%20This%20Christmas&i=digital-music
Phil Wickham - Angels (Glory To God)
https://www.amazon.com/s?k=Phil%20Wickham%20Angels%20%28Glory%20To%20God%29&i=digital-music
Carpenters - Have Yourself A Merry Little Ch
https://www.amazon.com/s?k=Carpenters%20Have%20Yourself%20%20A%20Merry%20Little%20Ch&i=digital-music
Drifters - White Christmas
https://www.amazon.com/s?k=Drifters%20White%20Christmas&i=digital-music
Jason Crabb - Mary Did You Know
https://www.amazon.com/s?k=Jason%20Crabb%20Mary%20Did%20You%20Know&i=digital-music
Vince Guaraldi Trio - O Tannenbaum
https://www.amazon.com/s?k=Vince%20Guaraldi%20Trio%20O%20Tannenbaum&i=digital-music
Bobby Helms - Jingle Bell Rock
https://www.amazon.com/s?k=Bobby%20Helms%20Jingle%20Bell%20Rock&i=digital-music
We The Kingdom - Silent Night (Heavenly Peace)
https://www.amazon.com/s?k=We%20The%20Kingdom%20Silent%20Night%20%28Heavenly%20Peace%29&i=digital-music
Jeremy Rosado - Silver Bells
https://www.amazon.com/s?k=Jeremy%20Rosado%20Silver%20Bells&i=digital-music
Toby Mac & Jamie Grace - Mary's Boy Child
https://www.amazon.com/s?k=Toby%20Mac%20%26%20Jamie%20Grace%20Mary%27s%20Boy%20Child&i=digital-music
Leroy Anderson - Sleigh Ride
https://www.amazon.com/s?k=Leroy%20Anderson%20Sleigh%20Ride&i=digital-music
Mark Schultz - The First Noel
https://www.amazon.com/s?k=Mark%20Schultz%20The%20First%20Noel&i=digital-music
Bryan Duncan - Please Come Home for Christmas
https://www.amazon.com/s?k=Bryan%20Duncan%20Please%20Come%20Home%20for%20Christmas&i=digital-music
Steven Curtis Chapman - God Rest Ye Merry Gentlemen
https://www.amazon.com/s?k=Steven%20Curtis%20Chapman%20God%20Rest%20Ye%20Merry%20Gentlemen&i=digital-music
Kim Walker-Smith - Carol of the Bells
https://www.amazon.com/s?k=Kim%20Walker-Smith%20Carol%20of%20the%20Bells&i=digital-music
Jimmy Durante - Frosty The Snowman
https://www.amazon.com/s?k=Jimmy%20Durante%20Frosty%20The%20Snowman&i=digital-music
Amy Grant - Breath Of Heaven (Mary's Song)
https://www.amazon.com/s?k=Amy%20Grant%20Breath%20Of%20Heaven%20%28Mary%27s%20Song%29&i=digital-music
Matt Maher - Hark The Herald Angels Sing
https://www.amazon.com/s?k=Matt%20Maher%20Hark%20The%20Herald%20Angels%20Sing&i=digital-music
Hannah Kerr - I'll Be Home For Christmas
https://www.amazon.com/s?k=Hannah%20Kerr%20I%27ll%20Be%20Home%20For%20Christmas&i=digital-music
Andy Williams - The Most Wonderful Time
https://www.amazon.com/s?k=Andy%20Williams%20The%20Most%20Wonderful%20Time&i=digital-music
DOE - O Come, O Come Emmanuel
https://www.amazon.com/s?k=DOE%20O%20Come%2C%20O%20Come%20Emmanuel&i=digital-music
Bryan Duncan - Have Yourself A Merry Little Chr
https://www.amazon.com/s?k=Bryan%20Duncan%20Have%20Yourself%20A%20Merry%20Little%20Chr&i=digital-music
Amy Grant - Winter Wonderland
https://www.amazon.com/s?k=Amy%20Grant%20Winter%20Wonderland&i=digital-music
Celine Dion - O Holy Night
https://www.amazon.com/s?k=Celine%20Dion%20O%20Holy%20Night&i=digital-music
for KING & COUNTRY - Angels We Have Heard On High
https://www.amazon.com/s?k=for%20KING%20%26%20COUNTRY%20Angels%20We%20Have%20Heard%20On%20High&i=digital-music
Tedashii - The Christmas Song
https://www.amazon.com/s?k=Tedashii%20The%20Christmas%20Song&i=digital-music
Third Day - O Come All Ye Faithful
https://www.amazon.com/s?k=Third%20Day%20O%20Come%20All%20Ye%20Faithful&i=digital-music
JJ Heller - Jingle Bells
https://www.amazon.com/s?k=JJ%20Heller%20Jingle%20Bells&i=digital-music
Jeremy Camp - O Come All Ye Faithful
https://www.amazon.com/s?k=Jeremy%20Camp%20O%20Come%20All%20Ye%20Faithful&i=digital-music
Maverick City Music - Mary, Did You Know
https://www.amazon.com/s?k=Maverick%20City%20Music%20Mary%2C%20Did%20You%20Know&i=digital-music
Mannheim Steamroller - Carol Of The Bells
https://www.amazon.com/s?k=Mannheim%20Steamroller%20Carol%20Of%20The%20Bells&i=digital-music
Lauren Daigle - Noel
https://www.amazon.com/s?k=Lauren%20Daigle%20Noel&i=digital-music
Nichole Nordeman - Do You Hear What I Hear
https://www.amazon.com/s?k=Nichole%20Nordeman%20Do%20You%20Hear%20What%20I%20Hear&i=digital-music
Anne Wilson - The Manger
https://www.amazon.com/s?k=Anne%20Wilson%20The%20Manger&i=digital-music
Jordan St. Cyr - Christmas Eve
https://www.amazon.com/s?k=Jordan%20St.%20Cyr%20Christmas%20Eve&i=digital-music
MercyMe - What Child Is This
https://www.amazon.com/s?k=MercyMe%20What%20Child%20Is%20This&i=digital-music
Carpenters - The Christmas Song
https://www.amazon.com/s?k=Carpenters%20The%20Christmas%20Song&i=digital-music
for KING & COUNTRY - Glorious
https://www.amazon.com/s?k=for%20KING%20%26%20COUNTRY%20Glorious&i=digital-music
Sanctus Real - What Christmas Means To Me
https://www.amazon.com/s?k=Sanctus%20Real%20What%20Christmas%20Means%20To%20Me&i=digital-music
Phil Wickham - Hark The Herald Angels Sing
https://www.amazon.com/s?k=Phil%20Wickham%20Hark%20The%20Herald%20Angels%20Sing&i=digital-music
Dean Martin - Let It Snow! Let It Snow! Let It
https://www.amazon.com/s?k=Dean%20Martin%20Let%20It%20Snow%21%20Let%20It%20Snow%21%20Let%20It&i=digital-music
Hannah Kerr and Austin French - It Came Upon A Midnight Clear
https://www.amazon.com/s?k=Hannah%20Kerr%20and%20Austin%20French%20It%20Came%20Upon%20A%20Midnight%20Clear&i=digital-music
Zach Williams - Go Tell It On The Mountain
https://www.amazon.com/s?k=Zach%20Williams%20Go%20Tell%20It%20On%20The%20Mountain&i=digital-music
Perry Como - O Holy Night
https://www.amazon.com/s?k=Perry%20Como%20O%20Holy%20Night&i=digital-music
Mac Powell - Have Yourself A Merry Little
https://www.amazon.com/s?k=Mac%20Powell%20Have%20Yourself%20A%20Merry%20Little&i=digital-music
Cheri Keaggy - What Child Is This
https://www.amazon.com/s?k=Cheri%20Keaggy%20What%20Child%20Is%20This&i=digital-music
for KING & COUNTRY - Go Tell it On The Mountain
https://www.amazon.com/s?k=for%20KING%20%26%20COUNTRY%20Go%20Tell%20it%20On%20The%20Mountain&i=digital-music
Carpenters - Sleigh Ride
https://www.amazon.com/s?k=Carpenters%20Sleigh%20Ride&i=digital-music
We Are Messengers - Hark! The Herald Angels Sing
https://www.amazon.com/s?k=We%20Are%20Messengers%20Hark%21%20The%20Herald%20Angels%20Sing&i=digital-music
Matthew West - Winter Wonderland
https://www.amazon.com/s?k=Matthew%20West%20Winter%20Wonderland&i=digital-music
Third Day - Angels We Have Heard On High
https://www.amazon.com/s?k=Third%20Day%20Angels%20We%20Have%20Heard%20On%20High&i=digital-music
Aaron Shust - Rejoice
https://www.amazon.com/s?k=Aaron%20Shust%20Rejoice&i=digital-music
Natalie Grant - Let It Snow, Let It Snow, Let It
https://www.amazon.com/s?k=Natalie%20Grant%20Let%20It%20Snow%2C%20Let%20It%20Snow%2C%20Let%20It&i=digital-music
Marc Martel with Amy Grant - Silver Bells
https://www.amazon.com/s?k=Marc%20Martel%20with%20Amy%20Grant%20Silver%20Bells&i=digital-music
Burl Ives - Rudolph The Red Nosed Reindeer
https://www.amazon.com/s?k=Burl%20Ives%20Rudolph%20The%20Red%20Nosed%20Reindeer&i=digital-music
Marc Martel With Amy Grant & Mws - Silver Bells
https://www.amazon.com/s?k=Marc%20Martel%20With%20Amy%20Grant%20%26%20Mws%20Silver%20Bells&i=digital-music
Johnny Mathis - O Holy Night
https://www.amazon.com/s?k=Johnny%20Mathis%20O%20Holy%20Night&i=digital-music
The Ronettes - Sleigh Ride
https://www.amazon.com/s?k=The%20Ronettes%20Sleigh%20Ride&i=digital-music
Lauren Daigle - Winter Wonderland
https://www.amazon.com/s?k=Lauren%20Daigle%20Winter%20Wonderland&i=digital-music
David Foster - Carol Of The Bells
https://www.amazon.com/s?k=David%20Foster%20Carol%20Of%20The%20Bells&i=digital-music
Caedmon's Call - It Came Upon A Midnight Clear
https://www.amazon.com/s?k=Caedmon%27s%20Call%20It%20Came%20Upon%20A%20Midnight%20Clear&i=digital-music
Amy Grant - Most Wonderful Time Of The Year
https://www.amazon.com/s?k=Amy%20Grant%20Most%20Wonderful%20Time%20Of%20%20The%20Year&i=digital-music
Crowder - O Holy Night
https://www.amazon.com/s?k=Crowder%20O%20Holy%20Night&i=digital-music
Nat King Cole - The Christmas Song
https://www.amazon.com/s?k=Nat%20King%20Cole%20The%20Christmas%20Song&i=digital-music
Crowder - White Christmas
https://www.amazon.com/s?k=Crowder%20White%20Christmas&i=digital-music
Johnny Mathis - The Most Wonderful Time Of The
https://www.amazon.com/s?k=Johnny%20Mathis%20The%20Most%20Wonderful%20Time%20Of%20The&i=digital-music
Mercyme - I'll Be Home For Christmas
https://www.amazon.com/s?k=Mercyme%20I%27ll%20Be%20Home%20For%20Christmas&i=digital-music
DecembeRadio - God Rest Ye Merry Gentlemen
https://www.amazon.com/s?k=DecembeRadio%20God%20Rest%20Ye%20Merry%20Gentlemen&i=digital-music
NEEDTOBREATHE - Go Tell It On The Mountain
https://www.amazon.com/s?k=NEEDTOBREATHE%20Go%20Tell%20It%20On%20The%20Mountain&i=digital-music
Bing Crosby - Do You Hear What I Hear
https://www.amazon.com/s?k=Bing%20Crosby%20Do%20You%20Hear%20What%20I%20Hear&i=digital-music
Natalie Grant - Jingle Bells
https://www.amazon.com/s?k=Natalie%20Grant%20Jingle%20Bells&i=digital-music
Michael W. Smith - Christmas Time
https://www.amazon.com/s?k=Michael%20W.%20Smith%20Christmas%20Time&i=digital-music
Blanca - Winter Wonderland
https://www.amazon.com/s?k=Blanca%20Winter%20Wonderland&i=digital-music
Amy Grant - Sleigh Ride
https://www.amazon.com/s?k=Amy%20Grant%20Sleigh%20Ride&i=digital-music
Lauren Daigle - What Child Is This
https://www.amazon.com/s?k=Lauren%20Daigle%20What%20Child%20Is%20This&i=digital-music
Steven Curtis Chapman - O Come All Ye Faithful
https://www.amazon.com/s?k=Steven%20Curtis%20Chapman%20O%20Come%20All%20Ye%20Faithful&i=digital-music
Mathew West - Let It Snow, Let It Snow, Let It
https://www.amazon.com/s?k=Mathew%20West%20Let%20It%20Snow%2C%20Let%20It%20Snow%2C%20Let%20It&i=digital-music
Sixpence None The Richer - Christmas Time Is Here
https://www.amazon.com/s?k=Sixpence%20None%20The%20Richer%20Christmas%20Time%20Is%20Here&i=digital-music
Nicole C Mullen - O Come, O Come, Emmanuel
https://www.amazon.com/s?k=Nicole%20C%20Mullen%20O%20Come%2C%20O%20Come%2C%20Emmanuel&i=digital-music
Bing Crosby - Winter Wonderland
https://www.amazon.com/s?k=Bing%20Crosby%20Winter%20Wonderland&i=digital-music
Brenda Lee - Rockin Around the Christmas Tree
https://www.amazon.com/s?k=Brenda%20Lee%20Rockin%20Around%20the%20Christmas%20Tree&i=digital-music
Tauren Wells - O Holy Night
https://www.amazon.com/s?k=Tauren%20Wells%20O%20Holy%20Night&i=digital-music
Steven Curtis Chapman - Let It Snow, Let It Snow, Let It
https://www.amazon.com/s?k=Steven%20Curtis%20Chapman%20Let%20It%20Snow%2C%20Let%20It%20Snow%2C%20Let%20It&i=digital-music
Barlowgirl - Carol Of The Bells
https://www.amazon.com/s?k=Barlowgirl%20Carol%20Of%20The%20Bells&i=digital-music
Natalie Grant - What Christmas Means To Me
https://www.amazon.com/s?k=Natalie%20Grant%20What%20Christmas%20Means%20To%20Me&i=digital-music
Cade Thompson - Angels We Have Heard On High
https://www.amazon.com/s?k=Cade%20Thompson%20Angels%20We%20Have%20Heard%20On%20High&i=digital-music
Danny Gokey - Joy To The World
https://www.amazon.com/s?k=Danny%20Gokey%20Joy%20To%20The%20World&i=digital-music
Boston Pops - Sleigh Ride
https://www.amazon.com/s?k=Boston%20Pops%20Sleigh%20Ride&i=digital-music
Pentatonix - Little Drummer Boy
https://www.amazon.com/s?k=Pentatonix%20Little%20Drummer%20Boy&i=digital-music
Carpenters - Home for the Holidays
https://www.amazon.com/s?k=Carpenters%20Home%20for%20the%20Holidays&i=digital-music
The Carpenters - Home For The Holidays
https://www.amazon.com/s?k=The%20Carpenters%20Home%20For%20The%20Holidays&i=digital-music
Ellie Holcomb - Jingle Bells
https://www.amazon.com/s?k=Ellie%20Holcomb%20Jingle%20Bells&i=digital-music
Harry Simeone Chorale - The Little Drummer Boy
https://www.amazon.com/s?k=Harry%20Simeone%20Chorale%20The%20Little%20Drummer%20Boy&i=digital-music
Jadon Lavik - Have Yourself A Merry Little Chr
https://www.amazon.com/s?k=Jadon%20Lavik%20Have%20Yourself%20A%20Merry%20Little%20Chr&i=digital-music
Natalie Grant - O Come All Ye Faithful
https://www.amazon.com/s?k=Natalie%20Grant%20O%20Come%20All%20Ye%20Faithful&i=digital-music
Michael W. Smith - Gloria
https://www.amazon.com/s?k=Michael%20W.%20Smith%20Gloria&i=digital-music
Josh Groban & Faith Hill - The First Noel
https://www.amazon.com/s?k=Josh%20Groban%20%26%20Faith%20Hill%20The%20First%20Noel&i=digital-music
David Ian & Acacia - Angels We Have Heard On High
https://www.amazon.com/s?k=David%20Ian%20%26%20Acacia%20Angels%20We%20Have%20Heard%20On%20High&i=digital-music
Pentatonix - O Come All Ye
https://www.amazon.com/s?k=Pentatonix%20O%20Come%20All%20Ye&i=digital-music
Third Day - Do You Hear What I Hear
https://www.amazon.com/s?k=Third%20Day%20Do%20You%20Hear%20What%20I%20Hear&i=digital-music
Steven Curtis Chapman - Carol Of The Bells
https://www.amazon.com/s?k=Steven%20Curtis%20Chapman%20Carol%20Of%20The%20Bells&i=digital-music
Frank Sinatra - White Christmas
https://www.amazon.com/s?k=Frank%20Sinatra%20White%20Christmas&i=digital-music
Andy Williams - Do You Hear What I Hear
https://www.amazon.com/s?k=Andy%20Williams%20Do%20You%20Hear%20What%20I%20Hear&i=digital-music
Bing Crosby - Joy To The World
https://www.amazon.com/s?k=Bing%20Crosby%20Joy%20To%20The%20World&i=digital-music
Amy Grant - O Come All Ye Faithful
https://www.amazon.com/s?k=Amy%20Grant%20O%20Come%20All%20Ye%20Faithful&i=digital-music
33Miles - Joy To The World
https://www.amazon.com/s?k=33Miles%20Joy%20To%20The%20World&i=digital-music
Denver & The Mile High Orchestra - Jingle Bells
https://www.amazon.com/s?k=Denver%20%26%20The%20Mile%20High%20Orchestra%20Jingle%20Bells&i=digital-music
NEEDTOBREATHE - Silent Night
https://www.amazon.com/s?k=NEEDTOBREATHE%20Silent%20Night&i=digital-music
Michael Buble - I'll Be Home For Christmas
https://www.amazon.com/s?k=Michael%20Buble%20I%27ll%20Be%20Home%20For%20Christmas&i=digital-music
Big Daddy Weave - Go Tell It On The Mountain
https://www.amazon.com/s?k=Big%20Daddy%20Weave%20Go%20Tell%20It%20On%20The%20Mountain&i=digital-music
Mannheim Steamroller - Deck The Halls (Welsh Ayre)
https://www.amazon.com/s?k=Mannheim%20Steamroller%20Deck%20The%20Halls%20%28Welsh%20Ayre%29&i=digital-music
Plumb - Silver Bells
https://www.amazon.com/s?k=Plumb%20Silver%20Bells&i=digital-music
Jars Of Clay - Drummer Boy
https://www.amazon.com/s?k=Jars%20Of%20Clay%20Drummer%20Boy&i=digital-music
Nat King Cole - O Come All Ye Faithful
https://www.amazon.com/s?k=Nat%20King%20Cole%20O%20Come%20All%20Ye%20Faithful&i=digital-music
Unspoken - Good News
https://www.amazon.com/s?k=Unspoken%20Good%20News&i=digital-music
Third Day - What Child Is This
https://www.amazon.com/s?k=Third%20Day%20What%20Child%20Is%20This&i=digital-music
Frank Sinatra - Have Yourself A Merry Little Chr
https://www.amazon.com/s?k=Frank%20Sinatra%20Have%20Yourself%20A%20Merry%20Little%20Chr&i=digital-music
Olivia Lane - Silent Night
https://www.amazon.com/s?k=Olivia%20Lane%20Silent%20Night&i=digital-music
Michael W Smith & Vince Gill - Christmas Time Is Here
https://www.amazon.com/s?k=Michael%20W%20Smith%20%26%20Vince%20Gill%20Christmas%20Time%20Is%20Here&i=digital-music
Stars Go Dim - The First Noel
https://www.amazon.com/s?k=Stars%20Go%20Dim%20The%20First%20Noel&i=digital-music
Burl Ives - Silver Bells
https://www.amazon.com/s?k=Burl%20Ives%20Silver%20Bells&i=digital-music
Mandisa - O Holy Night
https://www.amazon.com/s?k=Mandisa%20O%20Holy%20Night&i=digital-music
Bing Crosby - The First Noel
https://www.amazon.com/s?k=Bing%20Crosby%20The%20First%20Noel&i=digital-music
Switch - Joy
https://www.amazon.com/s?k=Switch%20Joy&i=digital-music
Todd Agnew - Did You Know?
https://www.amazon.com/s?k=Todd%20Agnew%20Did%20You%20Know%3F&i=digital-music
Steven Curtis Chapman - Away In A Manger
https://www.amazon.com/s?k=Steven%20Curtis%20Chapman%20Away%20In%20A%20Manger&i=digital-music
Leanna Crawford - Breath of Heaven (Mary's Song)
https://www.amazon.com/s?k=Leanna%20Crawford%20Breath%20of%20Heaven%20%28Mary%27s%20Song%29&i=digital-music
All Things New - Go Tell It On The Mountain
https://www.amazon.com/s?k=All%20Things%20New%20Go%20Tell%20It%20On%20The%20Mountain&i=digital-music
Bing Crosby - White Christmas
https://www.amazon.com/s?k=Bing%20Crosby%20White%20Christmas&i=digital-music
Mandisa - Angels We Have Heard On High
https://www.amazon.com/s?k=Mandisa%20Angels%20We%20Have%20Heard%20On%20High&i=digital-music
Danny Gokey - Mary, Did You Know
https://www.amazon.com/s?k=Danny%20Gokey%20Mary%2C%20Did%20You%20Know&i=digital-music
The Carpenters - The Christmas Song
https://www.amazon.com/s?k=The%20Carpenters%20The%20Christmas%20Song&i=digital-music
Amy Grant - Hark! The Herald Angels Sing
https://www.amazon.com/s?k=Amy%20Grant%20Hark%21%20The%20Herald%20Angels%20Sing&i=digital-music
Frank Sinatra - Silent Night
https://www.amazon.com/s?k=Frank%20Sinatra%20Silent%20Night&i=digital-music
Mandisa - Mary Little Boy Child
https://www.amazon.com/s?k=Mandisa%20Mary%20Little%20Boy%20Child&i=digital-music
Mercyme - What Child Is This?
https://www.amazon.com/s?k=Mercyme%20What%20Child%20Is%20This%3F&i=digital-music
Anne Wilson - O Holy Night
https://www.amazon.com/s?k=Anne%20Wilson%20O%20Holy%20Night&i=digital-music
Riley Clemmons - Silent Night
https://www.amazon.com/s?k=Riley%20Clemmons%20Silent%20Night&i=digital-music
Tobymac & Jamie Grace - Mary's Boy Child
https://www.amazon.com/s?k=Tobymac%20%26%20Jamie%20Grace%20Mary%27s%20Boy%20Child&i=digital-music
Danny Gokey - Angels We Have Heard On High
https://www.amazon.com/s?k=Danny%20Gokey%20Angels%20We%20Have%20Heard%20On%20High&i=digital-music
Mandisa - It's Christmas
https://www.amazon.com/s?k=Mandisa%20It%27s%20Christmas&i=digital-music
David Ian & Acacia - Hark The Herald Angels Sing
https://www.amazon.com/s?k=David%20Ian%20%26%20Acacia%20Hark%20The%20Herald%20Angels%20Sing&i=digital-music
Mandisa - Joy To The World
https://www.amazon.com/s?k=Mandisa%20Joy%20To%20The%20World&i=digital-music
Seph Schlueter & Leanna Crawford - We Sing (Joy to the World)
https://www.amazon.com/s?k=Seph%20Schlueter%20%26%20Leanna%20Crawford%20We%20Sing%20%28Joy%20to%20the%20World%29&i=digital-music
North Point - Emmanuel
https://www.amazon.com/s?k=North%20Point%20Emmanuel&i=digital-music
Tenth Avenue North - Deck The Halls
https://www.amazon.com/s?k=Tenth%20Avenue%20North%20Deck%20The%20Halls&i=digital-music
Josh Groban - I'll Be Home For Christmas
https://www.amazon.com/s?k=Josh%20Groban%20I%27ll%20Be%20Home%20For%20Christmas&i=digital-music
Terrian - God With Us (Emmanuel)
https://www.amazon.com/s?k=Terrian%20God%20With%20Us%20%28Emmanuel%29&i=digital-music
Bing Crosby & Carol Richards - Silver Bells
https://www.amazon.com/s?k=Bing%20Crosby%20%26%20Carol%20Richards%20Silver%20Bells&i=digital-music
Third Day - What Child Is This?
https://www.amazon.com/s?k=Third%20Day%20What%20Child%20Is%20This%3F&i=digital-music
Francesca Battistelli - Have Yourself A Merry Little Chr
https://www.amazon.com/s?k=Francesca%20Battistelli%20Have%20Yourself%20A%20Merry%20Little%20Chr&i=digital-music
We Are Messengers - We Three Kings
https://www.amazon.com/s?k=We%20Are%20Messengers%20We%20Three%20Kings&i=digital-music
Matthew West - Oh Come All Ye Faithful
https://www.amazon.com/s?k=Matthew%20West%20Oh%20Come%20All%20Ye%20Faithful&i=digital-music
Rend Collective - Mary Did You Know
https://www.amazon.com/s?k=Rend%20Collective%20Mary%20Did%20You%20Know&i=digital-music
Sidewalk Prophets - White Christmas
https://www.amazon.com/s?k=Sidewalk%20Prophets%20White%20Christmas&i=digital-music
Marc Martel - The Christmas Waltz
https://www.amazon.com/s?k=Marc%20Martel%20The%20Christmas%20Waltz&i=digital-music
For King & Country - Angels We Have Heard On High
https://www.amazon.com/s?k=For%20King%20%26%20Country%20Angels%20We%20Have%20Heard%20On%20High&i=digital-music
Jay DeMarcus and Friends - Joy To The World
https://www.amazon.com/s?k=Jay%20DeMarcus%20and%20Friends%20Joy%20To%20The%20World&i=digital-music
Rhett Walker - All I Want For Christmas Is You
https://www.amazon.com/s?k=Rhett%20Walker%20All%20I%20Want%20For%20Christmas%20Is%20You&i=digital-music
David Ian & Acacia - Have Yourself A Merry Little Chr
https://www.amazon.com/s?k=David%20Ian%20%26%20Acacia%20Have%20Yourself%20A%20Merry%20Little%20Chr&i=digital-music
Jj Heller - Away In A Manger
https://www.amazon.com/s?k=Jj%20Heller%20Away%20In%20A%20Manger&i=digital-music
Josh Wilson - Angels We Have Heard On High
https://www.amazon.com/s?k=Josh%20Wilson%20Angels%20We%20Have%20Heard%20On%20High&i=digital-music
Ray Conniff Singers - Frosty The Snowman
https://www.amazon.com/s?k=Ray%20Conniff%20Singers%20Frosty%20The%20Snowman&i=digital-music
David Crowder Band - The First Noel
https://www.amazon.com/s?k=David%20Crowder%20Band%20The%20First%20Noel&i=digital-music
Mercyme - Do You Hear What I Hear
https://www.amazon.com/s?k=Mercyme%20Do%20You%20Hear%20What%20I%20Hear&i=digital-music
Point Of Grace - Hark! The Herald Angels Sing
https://www.amazon.com/s?k=Point%20Of%20Grace%20Hark%21%20The%20Herald%20Angels%20Sing&i=digital-music
Johnny Mathis - It's The Most Wonderful Time Of
https://www.amazon.com/s?k=Johnny%20Mathis%20It%27s%20The%20Most%20Wonderful%20Time%20Of&i=digital-music
for KING & COUNTRY - Joy To The World
https://www.amazon.com/s?k=for%20KING%20%26%20COUNTRY%20Joy%20To%20The%20World&i=digital-music
Bing Crosby - I'll Be Home For Christmas
https://www.amazon.com/s?k=Bing%20Crosby%20I%27ll%20Be%20Home%20For%20Christmas&i=digital-music
Sarah Reeves - Angels We Have Heard On High
https://www.amazon.com/s?k=Sarah%20Reeves%20Angels%20We%20Have%20Heard%20On%20High&i=digital-music
Hannah Kerr - Have Yourself A Merry Little Chr
https://www.amazon.com/s?k=Hannah%20Kerr%20Have%20Yourself%20A%20Merry%20Little%20Chr&i=digital-music
Michael W. Smith and Mandisa - Christmas Day
https://www.amazon.com/s?k=Michael%20W.%20Smith%20and%20Mandisa%20Christmas%20Day&i=digital-music
Matt Maher - Glory (Let There Be Peace)
https://www.amazon.com/s?k=Matt%20Maher%20Glory%20%28Let%20There%20Be%20Peace%29&i=digital-music
Britt Nicole - O Come All Ye Faithful
https://www.amazon.com/s?k=Britt%20Nicole%20O%20Come%20All%20Ye%20Faithful&i=digital-music
Andy Williams - It's The Most Wonderful Time
https://www.amazon.com/s?k=Andy%20Williams%20It%27s%20The%20Most%20Wonderful%20Time&i=digital-music
for KING & COUNTRY - O Come, O Come Emmanuel
https://www.amazon.com/s?k=for%20KING%20%26%20COUNTRY%20O%20Come%2C%20O%20Come%20Emmanuel&i=digital-music
Amy Grant - Love Has Come
https://www.amazon.com/s?k=Amy%20Grant%20Love%20Has%20Come&i=digital-music
Matthew West - Unto Us
https://www.amazon.com/s?k=Matthew%20West%20Unto%20Us&i=digital-music
John Tesh - Carol Of The Bells
https://www.amazon.com/s?k=John%20Tesh%20Carol%20Of%20The%20Bells&i=digital-music
Ben Fuller - O Come All Ye Faithful
https://www.amazon.com/s?k=Ben%20Fuller%20O%20Come%20All%20Ye%20Faithful&i=digital-music
Bing Crosby & Andrews Sisters - Jingle Bells
https://www.amazon.com/s?k=Bing%20Crosby%20%26%20Andrews%20Sisters%20Jingle%20Bells&i=digital-music
The Carpenters - Sleigh Ride
https://www.amazon.com/s?k=The%20Carpenters%20Sleigh%20Ride&i=digital-music
Tasha Layton - O Holy Night
https://www.amazon.com/s?k=Tasha%20Layton%20O%20Holy%20Night&i=digital-music
Hannah Kerr - Sleigh Ride
https://www.amazon.com/s?k=Hannah%20Kerr%20Sleigh%20Ride&i=digital-music
Francesca Battistelli - Christmas Dreams
https://www.amazon.com/s?k=Francesca%20Battistelli%20Christmas%20Dreams&i=digital-music
Nat King Cole - O Little Town Of Bethlehem
https://www.amazon.com/s?k=Nat%20King%20Cole%20O%20Little%20Town%20Of%20Bethlehem&i=digital-music
Frank Sinatra - Jingle Bells
https://www.amazon.com/s?k=Frank%20Sinatra%20Jingle%20Bells&i=digital-music
Mathew West - The Heart Of Christmas
https://www.amazon.com/s?k=Mathew%20West%20The%20Heart%20Of%20Christmas&i=digital-music
Lady A&michael W. Smith - White Christmas
https://www.amazon.com/s?k=Lady%20A%26michael%20W.%20Smith%20White%20Christmas&i=digital-music
Lauren Daigle - Light Of The World
https://www.amazon.com/s?k=Lauren%20Daigle%20Light%20Of%20The%20World&i=digital-music
Love & The Outcome - Emmanuel
https://www.amazon.com/s?k=Love%20%26%20The%20Outcome%20Emmanuel&i=digital-music
for King & Country - Baby Boy
https://www.amazon.com/s?k=for%20King%20%26%20Country%20Baby%20Boy&i=digital-music
JOSH GROBAN - OH HOLY NIGHT
https://www.amazon.com/s?k=JOSH%20GROBAN%20OH%20HOLY%20NIGHT&i=digital-music
Amy Grant - Breath Of Heaven
https://www.amazon.com/s?k=Amy%20Grant%20Breath%20Of%20Heaven&i=digital-music
Mercyme - Little Drummer Boy
https://www.amazon.com/s?k=Mercyme%20Little%20Drummer%20Boy&i=digital-music
Tenth Avenue North f/Sarah Reeve - God Rest Ye Merry Gentlemen
https://www.amazon.com/s?k=Tenth%20Avenue%20North%20f/Sarah%20Reeve%20God%20Rest%20Ye%20Merry%20Gentlemen&i=digital-music
Jordan Smith - O Come (Let Us Adore)
https://www.amazon.com/s?k=Jordan%20Smith%20O%20Come%20%28Let%20Us%20Adore%29&i=digital-music
Big Daddy Weave - Christ is Come
https://www.amazon.com/s?k=Big%20Daddy%20Weave%20Christ%20is%20Come&i=digital-music
Brenda Lee - Jingle Bell Rock
https://www.amazon.com/s?k=Brenda%20Lee%20Jingle%20Bell%20Rock&i=digital-music
Nat King Cole - The First Noel
https://www.amazon.com/s?k=Nat%20King%20Cole%20The%20First%20Noel&i=digital-music
+9
View File
@@ -25,3 +25,12 @@
2025-11-17 14:02:10.571449: Starting Journey FM Playlist app...
2025-11-17 14:02:10.571573: DISPLAY set, initializing GUI
2025-11-17 14:02:10.700170: App initialized, starting event loop
2025-11-28 12:26:49.356378: Starting Journey FM Playlist app...
2025-11-28 12:26:49.357114: DISPLAY set, initializing GUI
2025-11-28 12:26:49.789296: App initialized, starting event loop
2025-11-28 12:35:16.223105: Starting Journey FM Playlist app...
2025-11-28 12:35:16.223430: DISPLAY set, initializing GUI
2025-11-28 12:35:16.545843: App initialized, starting event loop
2025-11-29 11:55:37.214182: Starting Journey FM Playlist app...
2025-11-29 11:55:37.214699: DISPLAY set, initializing GUI
2025-11-29 11:55:38.037895: App initialized, starting event loop
+9
View File
@@ -0,0 +1,9 @@
{
"PLEX_TOKEN": "pU-m3HWYUZU6iXJFhJyA",
"SERVER_IP": "172.16.16.106",
"PLAYLIST_NAME": "Journey FM Holiday",
"AUTO_UPDATE": true,
"UPDATE_INTERVAL": 15,
"UPDATE_UNIT": "Minutes",
"SELECTED_STATIONS": "journey_fm"
}
+7 -1
View File
@@ -29,5 +29,11 @@ if __name__ == "__main__":
app = QApplication(sys.argv)
icon = create_icon()
pixmap = icon.pixmap(QSize(64, 64))
pixmap.save("icon.png")
png_ok = pixmap.save("icon.png")
ico_ok = pixmap.save("icon.ico")
if png_ok:
print("Icon created: icon.png")
if ico_ok:
print("Icon created: icon.ico")
if not png_ok and not ico_ok:
print("Failed to create icon files")
+33
View File
@@ -0,0 +1,33 @@
services:
journey-fm-playlist:
build:
context: .
dockerfile: Dockerfile
container_name: journey-fm-playlist
restart: unless-stopped
ports:
- "8765:8765"
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,klove}
AUTO_UPDATE: ${AUTO_UPDATE:-true}
UPDATE_INTERVAL: ${UPDATE_INTERVAL:-15}
UPDATE_UNIT: ${UPDATE_UNIT:-Minutes}
CONTAINER_RUN_MODE: ${CONTAINER_RUN_MODE:-loop}
ENABLE_WEB_DASHBOARD: ${ENABLE_WEB_DASHBOARD:-true}
DASHBOARD_PORT: ${DASHBOARD_PORT:-8765}
CHROME_BINARY: /usr/bin/chromium
CHROMEDRIVER_PATH: /usr/bin/chromedriver
JOURNEYFM_DATA_DIR: /data
JOURNEYFM_CONTAINER: 1
PYTHONUNBUFFERED: 1
PYTHONDONTWRITEBYTECODE: 1
LANG: C.UTF-8
TERM: xterm
volumes:
- journey-fm-data:/data
volumes:
journey-fm-data:
+85 -5
View File
@@ -1,6 +1,8 @@
#!/bin/bash
# Journey FM Playlist Creator - Linux Installer
set -e
echo "Journey FM Playlist Creator - Linux Installer"
echo "=============================================="
@@ -9,6 +11,82 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
echo "Installing in: $DIR"
# Default configuration values
DEFAULT_PLEX_TOKEN="pU-m3HWYUZU6iXJFhJyA"
DEFAULT_SERVER_IP="172.16.16.106"
DEFAULT_PLAYLIST_NAME="Radio"
DEFAULT_SELECTED_STATIONS="journey_fm,spirit_fm,klove"
DEFAULT_AUTO_UPDATE="true"
DEFAULT_UPDATE_INTERVAL="15"
DEFAULT_UPDATE_UNIT="Minutes"
DEFAULT_CONTAINER_RUN_MODE="loop"
DEFAULT_ENABLE_WEB_DASHBOARD="true"
DEFAULT_DASHBOARD_PORT="8765"
DEFAULT_JOURNEYFM_DATA_DIR="/data"
DEFAULT_CHROME_BINARY="/usr/bin/chromium"
DEFAULT_CHROMEDRIVER_PATH="/usr/bin/chromedriver"
DEFAULT_JOURNEYFM_CONTAINER="1"
DEFAULT_PYTHONUNBUFFERED="1"
DEFAULT_PYTHONDONTWRITEBYTECODE="1"
DEFAULT_LANG="C.UTF-8"
DEFAULT_TERM="xterm"
# Helper for interactive prompts
ask_value() {
local prompt="$1"
local default="$2"
local var
read -p "$prompt [$default]: " var
if [ -z "$var" ]; then
var="$default"
fi
echo "$var"
}
echo "Configuring environment for .env file (press Enter to accept defaults)."
PLEX_TOKEN=$(ask_value "Plex token" "$DEFAULT_PLEX_TOKEN")
SERVER_IP=$(ask_value "Plex server IP" "$DEFAULT_SERVER_IP")
PLAYLIST_NAME=$(ask_value "Playlist name" "$DEFAULT_PLAYLIST_NAME")
SELECTED_STATIONS=$(ask_value "Selected stations" "$DEFAULT_SELECTED_STATIONS")
AUTO_UPDATE=$(ask_value "Auto update" "$DEFAULT_AUTO_UPDATE")
UPDATE_INTERVAL=$(ask_value "Update interval" "$DEFAULT_UPDATE_INTERVAL")
UPDATE_UNIT=$(ask_value "Update unit (Minutes/Hours)" "$DEFAULT_UPDATE_UNIT")
CONTAINER_RUN_MODE=$(ask_value "Container run mode (loop/once)" "$DEFAULT_CONTAINER_RUN_MODE")
ENABLE_WEB_DASHBOARD=$(ask_value "Enable web dashboard" "$DEFAULT_ENABLE_WEB_DASHBOARD")
DASHBOARD_PORT=$(ask_value "Dashboard port" "$DEFAULT_DASHBOARD_PORT")
JOURNEYFM_DATA_DIR=$(ask_value "Data volume path" "$DEFAULT_JOURNEYFM_DATA_DIR")
CHROME_BINARY=$(ask_value "Chrome binary path" "$DEFAULT_CHROME_BINARY")
CHROMEDRIVER_PATH=$(ask_value "Chromedriver path" "$DEFAULT_CHROMEDRIVER_PATH")
cat > "$DIR/.env" << EOF
PLEX_TOKEN=$PLEX_TOKEN
SERVER_IP=$SERVER_IP
PLAYLIST_NAME=$PLAYLIST_NAME
SELECTED_STATIONS=$SELECTED_STATIONS
AUTO_UPDATE=$AUTO_UPDATE
UPDATE_INTERVAL=$UPDATE_INTERVAL
UPDATE_UNIT=$UPDATE_UNIT
CONTAINER_RUN_MODE=$CONTAINER_RUN_MODE
ENABLE_WEB_DASHBOARD=$ENABLE_WEB_DASHBOARD
DASHBOARD_PORT=$DASHBOARD_PORT
JOURNEYFM_DATA_DIR=$JOURNEYFM_DATA_DIR
CHROME_BINARY=$CHROME_BINARY
CHROMEDRIVER_PATH=$CHROMEDRIVER_PATH
JOURNEYFM_CONTAINER=$DEFAULT_JOURNEYFM_CONTAINER
PYTHONUNBUFFERED=$DEFAULT_PYTHONUNBUFFERED
PYTHONDONTWRITEBYTECODE=$DEFAULT_PYTHONDONTWRITEBYTECODE
LANG=$DEFAULT_LANG
TERM=$DEFAULT_TERM
EOF
echo ".env file created at $DIR/.env"
# Ensure Docker compose exists
if ! command -v docker-compose > /dev/null 2>&1 && ! command -v docker > /dev/null 2>&1; then
echo "docker-compose/docker not found. install docker and docker-compose before running container mode." >&2
fi
# Check if Python 3 is installed
if ! command -v python3 > /dev/null 2>&1; then
echo "Python 3 not found. Installing Python 3..."
@@ -26,8 +104,8 @@ source "$DIR/.venv/bin/activate"
echo "Installing Python dependencies..."
pip install -r "$DIR/requirements.txt"
# Install Chromium browser
if ! command -v chromium-browser > /dev/null 2>&1; then
# Install Chromium browser if not available
if ! command -v chromium-browser > /dev/null 2>&1 && ! command -v chromium > /dev/null 2>&1; then
echo "Installing Chromium browser..."
sudo apt update && sudo apt install -y chromium-browser
fi
@@ -49,11 +127,13 @@ chmod +x ~/.local/share/applications/journey-fm.desktop
# Update desktop database
echo "Updating desktop database..."
update-desktop-database ~/.local/share/applications
update-desktop-database ~/.local/share/applications || true
# Make scripts executable
chmod +x "$DIR/journey_fm_app.sh"
# Optionally start web dashboard and/or app
echo "Installation complete!"
echo "You can now find 'Journey FM Playlist' in your applications menu."
echo "Or run: $DIR/journey_fm_app.sh"
echo "Run with Docker compose (server at http://localhost:$DASHBOARD_PORT):"
echo " docker compose up -d"
echo "To start GUI app: $DIR/journey_fm_app.sh"
+16
View File
@@ -0,0 +1,16 @@
@echo off
REM Journey FM Playlist Creator - Windows Installer Launcher
cd /d "%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install_windows.ps1"
if errorlevel 1 (
echo.
echo Installation failed. See output above for details.
pause
exit /b 1
)
echo.
echo Installation finished successfully.
pause
+129
View File
@@ -0,0 +1,129 @@
param(
[switch]$NoDesktopShortcut,
[switch]$NoStartMenuShortcut
)
$ErrorActionPreference = "Stop"
Write-Host "Journey FM Playlist Creator - Windows Installer" -ForegroundColor Cyan
Write-Host "================================================" -ForegroundColor Cyan
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $scriptDir
function Get-PythonCommand {
if (Get-Command py -ErrorAction SilentlyContinue) {
return @("py", "-3")
}
if (Get-Command python -ErrorAction SilentlyContinue) {
return @("python")
}
throw "Python 3 is not installed or not available on PATH. Install Python 3.10+ and try again."
}
function Invoke-Python {
param(
[string[]]$BaseCommand,
[string[]]$Arguments
)
$command = $BaseCommand[0]
$prefixArgs = @()
if ($BaseCommand.Count -gt 1) {
$prefixArgs = $BaseCommand[1..($BaseCommand.Count - 1)]
}
& $command @prefixArgs @Arguments
if ($LASTEXITCODE -ne 0) {
throw "Command failed: $command $($prefixArgs -join ' ') $($Arguments -join ' ')"
}
}
$pythonCmd = Get-PythonCommand
Write-Host "Using Python command: $($pythonCmd -join ' ')" -ForegroundColor Yellow
$venvDir = Join-Path $scriptDir ".venv"
$venvPython = Join-Path $venvDir "Scripts\python.exe"
if (-not (Test-Path $venvPython)) {
Write-Host "Creating virtual environment..." -ForegroundColor Yellow
Invoke-Python -BaseCommand $pythonCmd -Arguments @("-m", "venv", ".venv")
} else {
Write-Host "Virtual environment already exists." -ForegroundColor Yellow
}
Write-Host "Upgrading pip..." -ForegroundColor Yellow
& $venvPython -m pip install --upgrade pip
if ($LASTEXITCODE -ne 0) {
throw "Failed to upgrade pip."
}
Write-Host "Installing dependencies from requirements.txt..." -ForegroundColor Yellow
& $venvPython -m pip install -r requirements.txt
if ($LASTEXITCODE -ne 0) {
throw "Failed to install dependencies from requirements.txt."
}
# Create icon assets for shortcuts
try {
Write-Host "Ensuring icon assets are available..." -ForegroundColor Yellow
& $venvPython create_icon.py
}
catch {
Write-Warning "Icon generation failed: $($_.Exception.Message)"
}
$shell = New-Object -ComObject WScript.Shell
$targetPath = Join-Path $scriptDir "run_app.bat"
$iconIco = Join-Path $scriptDir "icon.ico"
$iconPng = Join-Path $scriptDir "icon.png"
$iconLocation = "$env:SystemRoot\System32\shell32.dll,220"
if (Test-Path $iconIco) {
$iconLocation = $iconIco
} elseif (Test-Path $iconPng) {
$iconLocation = $iconPng
}
function New-AppShortcut {
param(
[string]$ShortcutPath
)
$shortcut = $shell.CreateShortcut($ShortcutPath)
$shortcut.TargetPath = $targetPath
$shortcut.WorkingDirectory = $scriptDir
$shortcut.IconLocation = $iconLocation
$shortcut.Description = "Launch Journey FM Playlist Creator"
$shortcut.Save()
}
if (-not $NoDesktopShortcut) {
try {
Write-Host "Creating desktop shortcut..." -ForegroundColor Yellow
$desktop = [Environment]::GetFolderPath("Desktop")
$shortcutPath = Join-Path $desktop "Journey FM Playlist Creator.lnk"
New-AppShortcut -ShortcutPath $shortcutPath
}
catch {
Write-Warning "Desktop shortcut creation failed: $($_.Exception.Message)"
}
}
if (-not $NoStartMenuShortcut) {
try {
Write-Host "Creating Start Menu shortcut..." -ForegroundColor Yellow
$programs = [Environment]::GetFolderPath("Programs")
$shortcutPath = Join-Path $programs "Journey FM Playlist Creator.lnk"
New-AppShortcut -ShortcutPath $shortcutPath
}
catch {
Write-Warning "Start Menu shortcut creation failed: $($_.Exception.Message)"
}
}
Write-Host ""
Write-Host "Installation complete." -ForegroundColor Green
Write-Host "Launch the app with: run_app.bat" -ForegroundColor Green
Write-Host "Or run the GUI directly with: .venv\Scripts\pythonw.exe journey_fm_app.py" -ForegroundColor Green
+1200 -164
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
"""Shared services for Journey FM Playlist Creator."""
+172
View File
@@ -0,0 +1,172 @@
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",
"PLAYLIST_NAMES",
"PLAYLISTS",
"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
+79
View File
@@ -0,0 +1,79 @@
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 '[]'",
"scraped_songs": "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}")
# store scraped song detail for per-song/per-station analysis
if 'scraped_songs' not in existing_columns:
cursor.execute("ALTER TABLE history ADD COLUMN scraped_songs TEXT DEFAULT '[]'")
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, scraped_songs, 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("scraped_songs", [])),
json.dumps(result.get("skipped_songs", [])),
result.get("error_message", ""),
),
)
conn.commit()
conn.close()
+16
View File
@@ -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)
+273
View File
@@ -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,
}
+452
View File
@@ -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("&amp;", "&")
title = match.group(2).strip().replace("&amp;", "&")
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}
+176
View File
@@ -0,0 +1,176 @@
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": [],
}
import json as _json
# station_key → display name used in song["source"]
STATION_SOURCE = {"journey_fm": "Journey FM", "spirit_fm": "Spirit FM", "klove": "K-LOVE"}
token = config.get("PLEX_TOKEN", "").strip()
server_ip = config.get("SERVER_IP", "").strip()
# Build playlist configs: [{name, stations}]
playlists_raw = config.get("PLAYLISTS")
if playlists_raw:
try:
playlists = _json.loads(playlists_raw) if isinstance(playlists_raw, str) else playlists_raw
except Exception:
playlists = []
else:
playlists = []
if not playlists:
# Fall back to flat PLAYLIST_NAMES / PLAYLIST_NAME + SELECTED_STATIONS
raw_names = config.get("PLAYLIST_NAMES") or config.get("PLAYLIST_NAME", "Journey FM Recently Played")
if isinstance(raw_names, list):
names = raw_names
else:
names = [n.strip() for n in str(raw_names).split(",") if n.strip()]
stations = config.get("SELECTED_STATIONS", ["journey_fm"])
if isinstance(stations, str):
stations = [s.strip() for s in stations.split(",") if s.strip()]
playlists = [{"name": n, "stations": list(stations)} for n in (names or ["Journey FM Recently Played"])]
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 the union of all stations across all playlists (no duplicate scrapes)
all_station_keys = list({s for pl in playlists for s in pl.get("stations", [])})
scrape_result = scrape_recently_played(all_station_keys)
all_songs = scrape_result["songs"]
result["station_breakdown"] = scrape_result["station_results"]
result["scraped_count"] = len(all_songs)
result["scraped_songs"] = all_songs
try:
plex = connect_to_plex_server(token, server_ip)
merged = {}
for pl in playlists:
pl_sources = {STATION_SOURCE.get(s, s) for s in pl.get("stations", [])}
pl_songs = [s for s in all_songs if s.get("source", "") in pl_sources] if pl_sources else all_songs
pr = create_or_update_playlist(plex, pl_songs, pl["name"], dry_run=dry_run)
if not merged:
merged = pr
else:
merged["added_count"] = merged.get("added_count", 0) + pr.get("added_count", 0)
merged["added_songs"] = merged.get("added_songs", []) + pr.get("added_songs", [])
merged["duplicate_count"] = merged.get("duplicate_count", 0) + pr.get("duplicate_count", 0)
result.update(merged)
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
File diff suppressed because it is too large. Load diff
+65 -417
View File
@@ -1,441 +1,89 @@
#!/usr/bin/env python3
"""
Journey FM Playlist Creator
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.
"""
"""Journey FM Playlist Creator CLI entrypoint."""
import argparse
import logging
import sys
import time
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
from selenium.webdriver.common.by import By
from 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 urllib.parse
# Configuration (defaults, will be overridden by config.json)
PLEX_TOKEN = 'pU-m3HWYUZU6iXJFhJyA' # Your Plex.tv token
SERVER_IP = '172.16.16.106' # Your local server IP
PLAYLIST_NAME = 'Journey FM Recently Played'
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
from journeyfm.web_service import get_dashboard_url, start_dashboard_server
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)
options.binary_location = "/usr/bin/google-chrome-stable"
service = Service(ChromeDriverManager().install())
def configure_logging():
"""Configure logging with quiet defaults for CLI and GUI entrypoints."""
log_format = '%(asctime)s - %(levelname)s - %(message)s'
handlers = []
try:
driver = webdriver.Chrome(service=service, options=options)
except Exception as e:
print(f"Failed to start Chrome: {e}")
return []
all_songs = []
seen = set()
# Journey FM - uses <strong> for title, normal text for artist
if 'journey_fm' in selected_stations:
try:
print("Scraping Journey FM...")
driver.get('https://www.myjourneyfm.com/recently-played/')
time.sleep(5)
# Click "View More" button if exists
try:
more_button = driver.find_element(By.ID, "moreSongs")
more_button.click()
time.sleep(5)
except:
handlers.append(logging.FileHandler(data_path('playlist_log.txt'), encoding='utf-8'))
except Exception:
pass
soup = BeautifulSoup(driver.page_source, 'html.parser')
stream = getattr(sys, 'stderr', None) or getattr(sys, 'stdout', None)
if stream is not None:
handlers.append(logging.StreamHandler(stream))
# Find all song items using the proper class structure
song_items = soup.find_all('div', class_='rp-item')
logging.basicConfig(level=logging.INFO, format=log_format, handlers=handlers, force=True)
for noisy_logger in ('webdriver_manager', 'WDM', 'matplotlib', 'urllib3', 'plexapi'):
logging.getLogger(noisy_logger).setLevel(logging.WARNING)
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()
configure_logging()
logger = logging.getLogger(__name__)
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)
print(f" Found: {title} by {artist}")
except Exception as e:
continue
except Exception as e:
print(f"Error scraping Journey FM: {e}")
def load_config():
"""Compatibility wrapper for existing callers."""
return load_runtime_config()
# Spirit FM - loads songs from iframe text file
if 'spirit_fm' in selected_stations:
try:
print("Scraping Spirit FM...")
driver.get('https://spiritfm.com/ajax/now_playing_history.txt')
time.sleep(3)
# 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('&amp;', '&')
title = title.replace('&amp;', '&')
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)
print(f" Found: {title} by {artist}")
except Exception as e:
print(f"Error scraping Spirit FM: {e}")
driver.quit()
print(f"\nTotal unique songs found: {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:
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:
# 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 as e:
added = 0
added_songs = []
else:
print("No matching tracks found in Plex.")
added = 0
added_songs = []
return added, added_songs, missing
def prompt_for_config():
"""Prompt user for configuration variables"""
print("Configuration not found. Please provide the following:")
plex_token = input("Enter your Plex token: ").strip()
server_ip = input("Enter your Plex server IP: ").strip()
playlist_name = input("Enter the playlist name: ").strip()
config = {
'PLEX_TOKEN': plex_token,
'SERVER_IP': server_ip,
'PLAYLIST_NAME': playlist_name
}
with open('config.json', 'w') as f:
json.dump(config, f)
return config
def init_history_db():
"""Initialize the history database"""
conn = sqlite3.connect('playlist_history.db')
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY,
date TEXT,
added_count INTEGER,
added_songs TEXT,
missing_count INTEGER,
missing_songs TEXT
)''')
conn.commit()
conn.close()
def save_history_entry(added_count, added_songs, missing_count, missing_songs):
"""Save a history entry to the database"""
conn = sqlite3.connect('playlist_history.db')
c = conn.cursor()
date = datetime.now().isoformat()
c.execute('INSERT INTO history (date, added_count, added_songs, missing_count, missing_songs) VALUES (?, ?, ?, ?, ?)',
(date, added_count, json.dumps(added_songs), missing_count, json.dumps(missing_songs)))
conn.commit()
conn.close()
def setup_scheduler(params):
"""Run the setup_scheduler.ps1 with parameters"""
cmd = ['powershell.exe', '-File', 'setup_scheduler.ps1']
for k, v in params.items():
cmd.extend(['-' + k, str(v)])
try:
subprocess.run(cmd, check=True)
print("Scheduler setup completed.")
except subprocess.CalledProcessError as e:
print(f"Scheduler setup failed: {e}")
def main():
global PLEX_TOKEN, SERVER_IP, PLAYLIST_NAME
# Load or prompt for config
selected_stations = ['journey_fm', 'spirit_fm'] # default
if os.path.exists('config.json'):
with open('config.json', 'r') as f:
config = json.load(f)
PLEX_TOKEN = config.get('PLEX_TOKEN', PLEX_TOKEN)
SERVER_IP = config.get('SERVER_IP', SERVER_IP)
PLAYLIST_NAME = config.get('PLAYLIST_NAME', PLAYLIST_NAME)
# Load selected stations
selected_stations_str = config.get('SELECTED_STATIONS', 'journey_fm,spirit_fm')
if isinstance(selected_stations_str, str) and selected_stations_str:
selected_stations = [s for s in selected_stations_str.split(',') if s]
# If empty or invalid, keep default
else:
# If no config file, use defaults (GUI should handle this)
pass
# Log start time
start_time = datetime.now()
# Initialize history database
"""Run one update cycle and print a concise result summary."""
init_history_db()
result = run_update_job(load_runtime_config())
summary = format_result_summary(result)
print(summary)
if result.get('status') == 'error':
logger.error(summary)
return result
# Scrape songs with selected stations
songs = scrape_recently_played(selected_stations)
# Connect to Plex via MyPlexAccount
account = MyPlexAccount(token=PLEX_TOKEN)
resources = account.resources()
# 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:
print("Please claim the server: Open http://172.16.16.106:32400/web, sign in with your Plex account, and follow the claim prompts.")
return
plex = server_resource.connect()
# Debug: list library sections
# Debug: list existing playlists (handle unicode safely)
playlists = plex.playlists()
playlist_names = [p.title.encode('ascii', 'ignore').decode('ascii') if not p.title.isascii() else p.title for p in playlists]
# 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:
print("No songs found.")
added, added_songs, missing = 0, [], []
# Save to history
save_history_entry(added, added_songs, len(missing), missing)
# Create Amazon buy list for missing songs
if missing:
# 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:
pass
# 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:
print("No new songs to add to buy list.")
print("Buy list updated.")
# Log completion
end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
def update_playlist():
"""Function to update playlist, callable from GUI"""
main()
"""Compatibility wrapper used by the GUI."""
return main()
def main_cli():
parser = argparse.ArgumentParser(description='Journey FM Playlist CLI')
parser.add_argument('--serve-web', action='store_true', help='Start local web dashboard server')
parser.add_argument('--host', default='127.0.0.1', help='Dashboard host (default 127.0.0.1)')
parser.add_argument('--port', type=int, default=8765, help='Dashboard port (default 8765)')
parser.add_argument('--no-open', action='store_true', help='Do not automatically open browser when serving web dashboard')
args = parser.parse_args()
if args.serve_web:
init_history_db()
result = run_update_job(load_runtime_config())
summary = format_result_summary(result)
print(summary)
httpd, thread = start_dashboard_server(host=args.host, port=args.port, open_browser_if_possible=not args.no_open)
print('Web dashboard URL:', get_dashboard_url(args.host, args.port))
print('Press Ctrl+C to stop the web server.')
try:
while thread.is_alive():
time.sleep(0.5)
except KeyboardInterrupt:
print('Shutting down dashboard server...')
httpd.shutdown()
return result
return main()
if __name__ == '__main__':
main()
main_cli()
Binary file not shown.
+1
View File
@@ -6,3 +6,4 @@ selenium
webdriver-manager
PySide6
matplotlib
keyring
+5 -4
View File
@@ -3,10 +3,11 @@ REM Journey FM Playlist Creator - Windows Launcher
cd /d "%~dp0"
REM Activate virtual environment if it exists
if exist .venv\Scripts\activate.bat (
call .venv\Scripts\activate.bat
REM Prefer virtual environment pythonw if it exists
if exist .venv\Scripts\pythonw.exe (
.venv\Scripts\pythonw.exe journey_fm_app.py
exit /b %errorlevel%
)
REM Run the application without console window
REM Fallback to PATH pythonw
pythonw journey_fm_app.py
+46
View File
@@ -0,0 +1,46 @@
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
from journeyfm.web_service import start_dashboard_server
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")
# Start the optional local web dashboard by default in container mode
web_mode = os.getenv("ENABLE_WEB_DASHBOARD", "true").strip().lower()
if web_mode not in ("false", "0", "no", "off"):
start_dashboard_server(host="0.0.0.0", port=int(os.getenv("DASHBOARD_PORT", "8765")), open_browser_if_possible=False)
logger.info("Started local web dashboard on port %s", os.getenv("DASHBOARD_PORT", "8765"))
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()
+56 -19
View File
@@ -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)
def test_parse_journey_fm_html(self):
html = """
<div class='rp-item'>
<h5 class='song-title'>No Survivors</h5>
<p class='song-artist'>Jeremy Camp</p>
</div>
<div class='rp-item'>
<h5 class='song-title'>Take It All Back</h5>
<p class='song-artist'>Tauren Wells</p>
</div>
"""
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()
+14
View File
@@ -0,0 +1,14 @@
<div class="top-rp-list" tabindex="0">
<div class="song-item">
<div class="title-artist">
<p>Have Your Way <span> by </span> Katy Nichole</p>
</div>
<p class="song-time">8:39 AM Today</p>
</div>
<div class="song-item">
<div class="title-artist">
<p>Jesus Does <span> by </span> We The Kingdom</p>
</div>
<p class="song-time">8:31 AM Today</p>
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<body>
<!-- Minimal representative fixture matching K-LOVE's live page structure -->
<div class="song-card">
<a href="/music/artists/katy-nichole/testimony">
<img alt="Testimony" />
</a>
<div class="song-info">
<a href="/music/artists/katy-nichole/testimony">Testimony</a>
<span>By Katy Nichole</span>
</div>
</div>
<div class="song-card">
<a href="/music/artists/phil-wickham/i-believe">
<img alt="I Believe" />
</a>
<div class="song-info">
<a href="/music/artists/phil-wickham/i-believe">I Believe</a>
<span>By Phil Wickham</span>
</div>
</div>
<div class="song-card">
<a href="/music/artists/tobymac/cornerstone-feat-zach-williams">
<img alt="Cornerstone feat. Zach Williams" />
</a>
<div class="song-info">
<a href="/music/artists/tobymac/cornerstone-feat-zach-williams">Cornerstone feat. Zach Williams</a>
<span>By TobyMac</span>
</div>
</div>
</body>
</html>
Executable
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
# Update script for Journey FM Playlist project.
# - pulls latest code from GitHub
# - rebuilds and restarts container
# - supports docker or podman (auto-detect or ask)
set -e
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
cd "$REPO_DIR"
if [ ! -d .git ]; then
echo "Error: this directory is not a git repository. Run this script from repository root."
exit 1
fi
# Determine container tool
CONTAINER_TOOL=""
if command -v docker >/dev/null 2>&1; then
CONTAINER_TOOL="docker"
fi
if command -v podman >/dev/null 2>&1; then
if [ -n "$CONTAINER_TOOL" ]; then
echo "Both docker and podman are installed. Choose one:"
select t in docker podman; do
[ -n "$t" ] && CONTAINER_TOOL="$t" && break
echo "Invalid choice"
done
else
CONTAINER_TOOL="podman"
fi
fi
if [ -z "$CONTAINER_TOOL" ]; then
echo "No docker or podman binary found. Please install one and retry."
exit 1
fi
COMPOSE_CMD="$CONTAINER_TOOL compose"
# Pull latest branch
echo "Pulling latest code from origin/main..."
git fetch origin
if git rev-parse --abbrev-ref HEAD | grep -q "^main$"; then
git reset --hard origin/main
else
echo "Not on main branch; pulling and fast-forwarding current branch if possible..."
git pull --ff-only
fi
# Rebuild and restart in compose
echo "Building container with $COMPOSE_CMD..."
$COMPOSE_CMD build
echo "Bringing container up..."
$COMPOSE_CMD up -d
# Provide status
echo "Container status:"
$COMPOSE_CMD ps
echo "Done. Web dashboard is available at http://localhost:8765 (if enabled)."