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
nmemmert 6ea9c6647a Merge pull request #1 from nmemmert/copilot/add-playlist-name-customization 2025-11-28 11:35:12 -05:00
copilot-swe-agent[bot]andnmemmert f0d9b06ef4 Add validation for station selection to require at least one station
Co-authored-by: nmemmert <67961292+nmemmert@users.noreply.github.com>
2025-11-28 16:31:36 +00:00
copilot-swe-agent[bot]andnmemmert 013edb952f Add station selection feature to SetupWizard and scrape function
Co-authored-by: nmemmert <67961292+nmemmert@users.noreply.github.com>
2025-11-28 16:29:29 +00:00
copilot-swe-agent[bot] d5da0f710e Initial plan 2025-11-28 16:25:25 +00:00
nmemmert 2d027d339a Use custom icon.png and fix buy list path to use absolute path 2025-11-17 14:04:24 -05:00
nmemmert dd985796e6 Fix Spirit FM parsing - bypass Cloudflare and use direct text file 2025-11-17 13:47:41 -05:00
nmemmert e762c4252d Fix song scraping - use proper HTML structure parsing 2025-11-17 13:41:25 -05:00
nmemmert b50b430a5f Switch back to Chrome webdriver, user should run with xvfb-run for headless support 2025-11-17 13:12:14 -05:00
nmemmert 8b68b382ff Switch to Firefox webdriver to avoid Chrome DevTools issues 2025-11-17 13:09:16 -05:00
nmemmert 8393e5d659 Fix Selenium Chrome session issues by simplifying options and adding error handling 2025-11-17 13:01:35 -05:00
nmemmert bf7aa42460 Improve song parsing for Journey FM and Spirit FM, fix app startup issues 2025-11-17 12:56:21 -05:00
nmemmert 59689dcbf6 windows 2025-11-17 12:18:25 -05:00
nmemmert 38131fb653 Fix buy list label to update on filter 2025-11-17 12:11:24 -05:00
nmemmert 5066b0b5e9 Fix syntax error: add missing newline after return statement 2025-11-17 12:05:47 -05:00
nmemmert cdb6e53fb6 Add debug prints to track GUI startup progress 2025-11-17 12:05:02 -05:00
nmemmert f89b970c94 Add display environment check and better error handling for GUI startup 2025-11-17 12:03:53 -05:00
nmemmert 62174895ff Add source tracking for scraped songs and display station in logs and history 2025-11-17 12:02:54 -05:00
nmemmert 723e12fe11 Modify buy list generation to append new missing songs instead of overwriting 2025-11-17 12:01:37 -05:00
nmemmert 2dcd4be86d Add missing QSplitter import to QtWidgets 2025-11-17 11:59:19 -05:00
nmemmert fbe0c4a8e1 Fix QAction import - move from QtWidgets to QtGui 2025-11-17 11:58:35 -05:00
nmemmert 30c2e1c907 Fix matplotlib backend import for PySide6 (Qt6) compatibility 2025-11-17 11:57:40 -05:00
nmemmert 32d87cf0c2 Add scraping from Spirit FM recently played list, combine with Journey FM and deduplicate songs 2025-11-17 11:56:26 -05:00
nmemmert 7e7754f533 Add Playlist Analytics Dashboard with charts for growth, top artists, and update frequency using matplotlib 2025-11-17 11:53:32 -05:00
nmemmert 1339365941 Add auto-playlist tagging: automatically add 'Journey FM' label and 5-star rating to songs added to Plex playlist 2025-11-17 11:49:27 -05:00
nmemmert 43e93c5abc Split artist and song into separate columns in history table 2025-11-17 11:46:15 -05:00
nmemmert eb61051c7c Redesign history table to show one song per row for better readability 2025-11-17 11:44:52 -05:00
nmemmert 3d18efb88f Improve history dialog to show full list of added and missing songs with word wrap and auto row sizing 2025-11-17 11:43:29 -05:00
nmemmert f958ea0983 Fix QListWidgetItem checkState usage to use Qt.CheckState enum instead of int 2025-11-17 11:40:44 -05:00
nmemmert 2da02dc7df Add debug message and better error handling for buy list dialog 2025-11-17 11:39:52 -05:00
nmemmert 3f377e165d Fix buy list dialog visibility by ensuring it shows on top and add song count label 2025-11-17 11:37:26 -05:00
nmemmert e3085f22ae Add playlist export, enhanced buy list with search/filter/remove, and statistics dashboard 2025-11-17 11:32:25 -05:00
nmemmert e643bb3c98 Add playlist history tracking with SQLite database and GUI dialog 2025-11-17 11:30:20 -05:00
nmemmert 9a90811dea Change to QTextBrowser for better HTML and link display 2025-11-17 11:24:51 -05:00
nmemmert f12b50f9d0 Fix buy list dialog to show properly with modal exec and proper HTML 2025-11-17 11:24:06 -05:00
nmemmert 589cbca2f6 Add debug message to buy list button 2025-11-17 11:23:25 -05:00
nmemmert 3ff79549d9 Add buy list button and dialog to GUI 2025-11-17 11:22:23 -05:00
nmemmert e74845cab6 Fix Amazon URL encoding and update buy list 2025-11-17 11:21:01 -05:00
nmemmert 2893487134 Add Amazon buy list feature for missing songs 2025-11-17 11:17:43 -05:00
nmemmert be9a1a1b64 linux fix app launch 2025-11-17 09:29:21 -05:00
nmemmert d5f2aa0dfd Update README with Linux installer and detailed Plex token instructions; add help button in app for Plex token 2025-11-17 09:26:47 -05:00
nmemmert d357405fb7 Merge branch 'linux' 2025-11-17 09:24:35 -05:00
nmemmert 5f88cdd92b Fix ChromeDriver compatibility and update desktop icon 2025-11-17 09:24:16 -05:00
nmemmert 9b63031c1e Add Linux installer script 2025-11-17 09:18:32 -05:00
nmemmert 5e891ad139 Update Linux app to use Firefox and auto-install dependencies 2025-11-17 08:24:50 -05:00
Nate Emmert c2b91f3f47 windows updaate 2025-11-17 08:22:25 -05:00
Nate Emmert ce5175d8db Add desktop GUI application with system tray support
- Created PySide6-based desktop application
- Added system tray integration for Windows and Linux
- Built-in log viewer and configuration wizard
- Easy setup and automatic playlist updates
- Cross-platform compatibility
- Updated README with comprehensive instructions
2025-11-17 08:08:20 -05:00
nmemmert 345c40f40d Update main.py with a new script 2025-11-15 08:49:41 -05:00
36 changed files with 6836 additions and 307 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"]
+364 -19
View File
@@ -1,28 +1,373 @@
# Journey FM Playlist Creator
This script automates the process of creating a Plex playlist 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.
## Features
## What It Does
- Scrapes recently played songs from https://www.myjourneyfm.com/recently-played/
- Checks your local music library for matching songs
- Creates a playlist in Plex with the found songs
## Setup
1. Install dependencies: `pip install -r requirements.txt`
2. Configure the script:
- Set `PLEX_URL` to your Plex server URL
- Set `PLEX_TOKEN` to your Plex authentication token
- Set `MUSIC_LIBRARY_PATH` to the path of your music library
3. Run the script: `python main.py`
- 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.
## Requirements
- Python 3.6+
- Plex server
- Local music library with ID3 tags
- Python 3.10+
- Plex account token + reachable Plex server
- A Chrome/Chromium browser for Selenium scraping
## Note
Dependencies are listed in `requirements.txt` (GUI uses `PySide6`).
The HTML scraping part may need adjustment based on the actual structure of the website. Inspect the page to find the correct selectors for artist and title.
## Install
### Windows
Simple install from GitHub (recommended):
1. Open **PowerShell**.
2. Run:
```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
Quick setup:
```bash
./install.sh
```
Interactive install now captures defaults and writes `.env` (auto with optional values):
```bash
./install.sh
```
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
Web dashboard defaults to enabled with start at http://localhost:8765 (or `DASHBOARD_PORT`).
Manual setup:
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python journey_fm_app.py
```
## Configuration
Use the GUI setup wizard (first launch) or edit `config.json` directly.
The Plex token is now stored in your OS credential store via `keyring` when available.
On Windows this means the app no longer needs to persist `PLEX_TOKEN` in `config.json`.
Example `config.json`:
```json
{
"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"
}
```
You can also provide runtime env vars:
- `PLEX_TOKEN`
- `SERVER_IP`
- `PLAYLIST_NAME`
- `AUTO_UPDATE`
- `UPDATE_INTERVAL`
- `UPDATE_UNIT`
- `SELECTED_STATIONS`
Optional browser override:
- `CHROME_BINARY` (absolute path to Chrome/Chromium executable)
If `CHROME_BINARY` is not set, the app auto-detects browser locations for Windows/Linux/macOS and then falls back to `PATH`.
## Running Without GUI
Run one update cycle from CLI:
```bash
python main.py
```
Run one update cycle and start a local web dashboard for live counts and station metrics:
```bash
python main.py --serve-web
```
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
python main.py --serve-web --host 0.0.0.0 --port 8765 --no-open
```
## Dashboard: station and song playback counts
Once the web dashboard is running at `http://localhost:8765`, you'll see:
- station-level scrape totals (`station_breakdown` aggregated)
- overall count metrics (scraped, matched, added, missing, duplicates)
- per-station top tracks by play count (history-aware)
### API endpoints
- `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
### Force refresh from CLI
```bash
curl http://localhost:8765/api/refresh
```
### View raw stats
```bash
curl http://localhost:8765/api/stats | jq .song_counts
```
## Update script (docker + podman)
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.
+983
View File
@@ -0,0 +1,983 @@
Songs not in your library - Amazon search links:
Danny Gokey - I Got You
https://www.amazon.com/s?k=Danny%20Gokey%20I%20Got%20You&i=digital-music
Have For You Colton Dixon - The Love I
https://www.amazon.com/s?k=Have%20%20For%20%20You%20Colton%20%20Dixon%20The%20%20Love%20%20I%20&i=digital-music
Done Tasha Layton - Look What You've
https://www.amazon.com/s?k=Done%20Tasha%20%20Layton%20Look%20%20What%20%20You%27ve%20&i=digital-music
Cochren & Co. - Who Can
https://www.amazon.com/s?k=Cochren%20%26%20%20Co.%20Who%20%20Can&i=digital-music
Have For You Colton Dixon - The Love I
https://www.amazon.com/s?k=Have%20%20For%20%20You%20Colton%20%20Dixon%20The%20%20Love%20%20I%20&i=digital-music
Done Tasha Layton - Look What You've
https://www.amazon.com/s?k=Done%20Tasha%20%20Layton%20Look%20%20What%20%20You%27ve%20&i=digital-music
Church Sing Tauren Wells - Let The
https://www.amazon.com/s?k=Church%20%20Sing%20Tauren%20%20Wells%20Let%20%20The%20&i=digital-music
You Danny Gokey - I Got
https://www.amazon.com/s?k=You%20Danny%20%20Gokey%20I%20%20Got%20&i=digital-music
Andrew Ripp - Breakdown
https://www.amazon.com/s?k=Andrew%20%20Ripp%20Breakdown&i=digital-music
You've Done Tasha Layton - Look What
https://www.amazon.com/s?k=You%27ve%20%20Done%20Tasha%20%20Layton%20Look%20%20What%20&i=digital-music
Crowder - Still
https://www.amazon.com/s?k=Crowder%20Still&i=digital-music
Thing About Praise Benjamin William Hastings - That's The
https://www.amazon.com/s?k=Thing%20%20About%20%20Praise%20Benjamin%20%20William%20%20Hastings%20That%27s%20%20The%20&i=digital-music
Love Feat. Bear Rinehar We The Kingdom - Child Of
https://www.amazon.com/s?k=Love%20%20Feat.%20%20Bear%20%20Rinehar%20We%20%20The%20%20Kingdom%20Child%20%20Of%20&i=digital-music
Am They - Scars I
https://www.amazon.com/s?k=Am%20%20They%20Scars%20I%20&i=digital-music
Church Sing Tauren Wells - Let The
https://www.amazon.com/s?k=Church%20%20Sing%20Tauren%20%20Wells%20Let%20%20The%20&i=digital-music
You Danny Gokey - I Got
https://www.amazon.com/s?k=You%20Danny%20%20Gokey%20I%20%20Got%20&i=digital-music
You've Done Tasha Layton - Look What
https://www.amazon.com/s?k=You%27ve%20%20Done%20Tasha%20%20Layton%20Look%20%20What%20&i=digital-music
Am They - Scars I
https://www.amazon.com/s?k=Am%20%20They%20Scars%20I%20&i=digital-music
Church Sing Tauren Wells - Let The
https://www.amazon.com/s?k=Church%20%20Sing%20Tauren%20%20Wells%20Let%20%20The%20&i=digital-music
You Danny Gokey - I Got
https://www.amazon.com/s?k=You%20Danny%20%20Gokey%20I%20%20Got%20&i=digital-music
You've Done Tasha Layton - Look What
https://www.amazon.com/s?k=You%27ve%20%20Done%20Tasha%20%20Layton%20Look%20%20What%20&i=digital-music
Am They - Scars I
https://www.amazon.com/s?k=Am%20%20They%20Scars%20I%20&i=digital-music
Church Sing Tauren Wells - Let The
https://www.amazon.com/s?k=Church%20%20Sing%20Tauren%20%20Wells%20Let%20%20The%20&i=digital-music
You Danny Gokey - I Got
https://www.amazon.com/s?k=You%20Danny%20%20Gokey%20I%20%20Got%20&i=digital-music
You've Done Tasha Layton - Look What
https://www.amazon.com/s?k=You%27ve%20%20Done%20Tasha%20%20Layton%20Look%20%20What%20&i=digital-music
Am They - Scars I
https://www.amazon.com/s?k=Am%20%20They%20Scars%20I%20&i=digital-music
Church Sing Tauren Wells - Let The
https://www.amazon.com/s?k=Church%20%20Sing%20Tauren%20%20Wells%20Let%20%20The%20&i=digital-music
You Danny Gokey - I Got
https://www.amazon.com/s?k=You%20Danny%20%20Gokey%20I%20%20Got%20&i=digital-music
You've Done Tasha Layton - Look What
https://www.amazon.com/s?k=You%27ve%20%20Done%20Tasha%20%20Layton%20Look%20%20What%20&i=digital-music
Am They - Scars I
https://www.amazon.com/s?k=Am%20%20They%20Scars%20I%20&i=digital-music
Church Sing Tauren Wells - Let The
https://www.amazon.com/s?k=Church%20%20Sing%20Tauren%20%20Wells%20Let%20%20The%20&i=digital-music
You Danny Gokey - I Got
https://www.amazon.com/s?k=You%20Danny%20%20Gokey%20I%20%20Got%20&i=digital-music
You've Done Tasha Layton - Look What
https://www.amazon.com/s?k=You%27ve%20%20Done%20Tasha%20%20Layton%20Look%20%20What%20&i=digital-music
Am They - Scars I
https://www.amazon.com/s?k=Am%20%20They%20Scars%20I%20&i=digital-music
Church Sing Tauren Wells - Let The
https://www.amazon.com/s?k=Church%20%20Sing%20Tauren%20%20Wells%20Let%20%20The%20&i=digital-music
You Danny Gokey - I Got
https://www.amazon.com/s?k=You%20Danny%20%20Gokey%20I%20%20Got%20&i=digital-music
You've Done Tasha Layton - Look What
https://www.amazon.com/s?k=You%27ve%20%20Done%20Tasha%20%20Layton%20Look%20%20What%20&i=digital-music
Thing About Praise Benjamin William Hastings - That's The
https://www.amazon.com/s?k=Thing%20%20About%20%20Praise%20Benjamin%20%20William%20%20Hastings%20That%27s%20%20The%20&i=digital-music
Love Feat. Bear Rinehar We The Kingdom - Child Of
https://www.amazon.com/s?k=Love%20%20Feat.%20%20Bear%20%20Rinehar%20We%20%20The%20%20Kingdom%20Child%20%20Of%20&i=digital-music
Am They - Scars I
https://www.amazon.com/s?k=Am%20%20They%20Scars%20I%20&i=digital-music
Church Sing Tauren Wells - Let The
https://www.amazon.com/s?k=Church%20%20Sing%20Tauren%20%20Wells%20Let%20%20The%20&i=digital-music
You Danny Gokey - I Got
https://www.amazon.com/s?k=You%20Danny%20%20Gokey%20I%20%20Got%20&i=digital-music
You've Done Tasha Layton - Look What
https://www.amazon.com/s?k=You%27ve%20%20Done%20Tasha%20%20Layton%20Look%20%20What%20&i=digital-music
Am They - Scars I
https://www.amazon.com/s?k=Am%20%20They%20Scars%20I%20&i=digital-music
Church Sing Tauren Wells - Let The
https://www.amazon.com/s?k=Church%20%20Sing%20Tauren%20%20Wells%20Let%20%20The%20&i=digital-music
You Danny Gokey - I Got
https://www.amazon.com/s?k=You%20Danny%20%20Gokey%20I%20%20Got%20&i=digital-music
You've Done Tasha Layton - Look What
https://www.amazon.com/s?k=You%27ve%20%20Done%20Tasha%20%20Layton%20Look%20%20What%20&i=digital-music
Zach Williams - Jesus Loves
https://www.amazon.com/s?k=Zach%20Williams%20Jesus%20Loves&i=digital-music
Zach Williams - Jesus Loves
https://www.amazon.com/s?k=Zach%20%20Williams%20Jesus%20%20Loves&i=digital-music
Am They - Scars I
https://www.amazon.com/s?k=Am%20%20They%20Scars%20I%20&i=digital-music
Church Sing Tauren Wells - Let The
https://www.amazon.com/s?k=Church%20%20Sing%20Tauren%20%20Wells%20Let%20%20The%20&i=digital-music
You Danny Gokey - I Got
https://www.amazon.com/s?k=You%20Danny%20%20Gokey%20I%20%20Got%20&i=digital-music
Cochren & Co. - Church (Take Me Back)
https://www.amazon.com/s?k=Cochren%20%26%20Co.%20Church%20%28Take%20Me%20Back%29&i=digital-music
Blessing Offor - Your Love
https://www.amazon.com/s?k=Blessing%20Offor%20Your%20Love&i=digital-music
Anne Wilson - My Jesus
https://www.amazon.com/s?k=Anne%20Wilson%20My%20Jesus&i=digital-music
Sidewalk Prophets - You Love Me Anyway
https://www.amazon.com/s?k=Sidewalk%20Prophets%20You%20Love%20Me%20Anyway&i=digital-music
Casting Crowns (ft Matthew West) - Nobody
https://www.amazon.com/s?k=Casting%20Crowns%20%28ft%20Matthew%20West%29%20Nobody&i=digital-music
Phil Wickham - This Is Amazing Grace
https://www.amazon.com/s?k=Phil%20Wickham%20This%20Is%20Amazing%20Grace&i=digital-music
Crowder - STILL
https://www.amazon.com/s?k=Crowder%20STILL&i=digital-music
Josh Baldwin - Made for More
https://www.amazon.com/s?k=Josh%20Baldwin%20Made%20for%20More&i=digital-music
Leanna Crawford - Honest
https://www.amazon.com/s?k=Leanna%20Crawford%20Honest&i=digital-music
We The Kingdom - God So Loved
https://www.amazon.com/s?k=We%20The%20Kingdom%20God%20So%20Loved&i=digital-music
Dan Bremnes - Call Your Name
https://www.amazon.com/s?k=Dan%20Bremnes%20Call%20Your%20Name&i=digital-music
Big Daddy Weave - I Know
https://www.amazon.com/s?k=Big%20Daddy%20Weave%20I%20Know&i=digital-music
The Afters - God Is With Us
https://www.amazon.com/s?k=The%20Afters%20God%20Is%20With%20Us&i=digital-music
CAIN - Rise Up (Lazarus)
https://www.amazon.com/s?k=CAIN%20Rise%20Up%20%28Lazarus%29&i=digital-music
Katy Nichole - Have Your Way
https://www.amazon.com/s?k=Katy%20Nichole%20Have%20Your%20Way&i=digital-music
Ryan Stevenson - Preach
https://www.amazon.com/s?k=Ryan%20Stevenson%20Preach&i=digital-music
Elevation Worship - Praise
https://www.amazon.com/s?k=Elevation%20Worship%20Praise&i=digital-music
Andrew Ripp - Breakdown
https://www.amazon.com/s?k=Andrew%20Ripp%20Breakdown&i=digital-music
Chris Tomlin - Amazing Grace (My Chains Are Gon
https://www.amazon.com/s?k=Chris%20Tomlin%20Amazing%20Grace%20%28My%20Chains%20Are%20Gon&i=digital-music
Tauren Wells - Known
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
+36
View File
@@ -0,0 +1,36 @@
2025-11-17 12:40:16.417598: Starting Journey FM Playlist app...
2025-11-17 12:40:16.417787: DISPLAY set, initializing GUI
2025-11-17 12:40:16.553130: Failed to start GUI application: 'MainWindow' object has no attribute 'update_playlist'
2025-11-17 12:42:52.626588: Starting Journey FM Playlist app...
2025-11-17 12:42:52.626688: DISPLAY set, initializing GUI
2025-11-17 12:42:52.761510: App initialized, starting event loop
2025-11-17 12:56:44.139926: Starting Journey FM Playlist app...
2025-11-17 12:56:44.140042: DISPLAY set, initializing GUI
2025-11-17 12:56:44.381412: App initialized, starting event loop
2025-11-17 13:01:57.291192: Starting Journey FM Playlist app...
2025-11-17 13:01:57.291370: DISPLAY set, initializing GUI
2025-11-17 13:01:57.440291: App initialized, starting event loop
2025-11-17 13:09:29.113472: Starting Journey FM Playlist app...
2025-11-17 13:09:29.113563: DISPLAY set, initializing GUI
2025-11-17 13:09:29.245236: App initialized, starting event loop
2025-11-17 13:12:14.103261: Starting Journey FM Playlist app...
2025-11-17 13:12:14.103373: DISPLAY set, initializing GUI
2025-11-17 13:12:14.295725: App initialized, starting event loop
2025-11-17 13:48:09.668000: Starting Journey FM Playlist app...
2025-11-17 13:48:09.668117: DISPLAY set, initializing GUI
2025-11-17 13:48:10.016465: App initialized, starting event loop
2025-11-17 13:55:31.548889: Starting Journey FM Playlist app...
2025-11-17 13:55:31.549017: DISPLAY set, initializing GUI
2025-11-17 13:55:31.715072: App initialized, starting event loop
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"
}
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""
Create application icon
"""
from PySide6.QtGui import QIcon, QPixmap, QPainter, QColor, QFont
from PySide6.QtCore import QSize
def create_icon():
"""Create a simple application icon"""
pixmap = QPixmap(64, 64)
pixmap.fill(QColor(30, 144, 255)) # Dodger blue background
painter = QPainter(pixmap)
painter.setPen(QColor(255, 255, 255))
painter.setFont(QFont("Arial", 32, QFont.Weight.Bold))
# Draw music note symbol
painter.drawText(16, 48, "♪")
painter.end()
icon = QIcon(pixmap)
return icon
if __name__ == "__main__":
from PySide6.QtWidgets import QApplication
import sys
app = QApplication(sys.argv)
icon = create_icon()
pixmap = icon.pixmap(QSize(64, 64))
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:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Executable
+139
View File
@@ -0,0 +1,139 @@
#!/bin/bash
# Journey FM Playlist Creator - Linux Installer
set -e
echo "Journey FM Playlist Creator - Linux Installer"
echo "=============================================="
# Get the directory where this script is located
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..."
sudo apt update && sudo apt install -y python3 python3-pip python3-venv
fi
# Create virtual environment
echo "Creating virtual environment..."
python3 -m venv "$DIR/.venv"
# Activate virtual environment
source "$DIR/.venv/bin/activate"
# Install Python dependencies
echo "Installing Python dependencies..."
pip install -r "$DIR/requirements.txt"
# 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
# Create desktop entry
echo "Creating desktop entry..."
mkdir -p ~/.local/share/applications
cat > ~/.local/share/applications/journey-fm.desktop << EOF
[Desktop Entry]
Name=Journey FM Playlist
Exec=$DIR/journey_fm_app.sh
Icon=$DIR/icon.png
Type=Application
Categories=Utility;
EOF
chmod +x ~/.local/share/applications/journey-fm.desktop
# Update desktop database
echo "Updating desktop database..."
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 "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
+2086
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
# Journey FM Playlist Creator - Linux Startup Script
# Get the directory where this script is located
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
# Create virtual environment if it doesn't exist
if [ ! -d "$DIR/.venv" ]; then
python3 -m venv "$DIR/.venv"
fi
# Activate virtual environment
source "$DIR/.venv/bin/activate"
# Install dependencies
pip install -r "$DIR/requirements.txt"
# Check if Chromium is installed, install if not
if ! command -v chromium-browser > /dev/null 2>&1; then
echo "Chromium not found. Installing Chromium..."
sudo apt update && sudo apt install -y chromium-browser
fi
# Run the application
cd "$DIR"
python3 journey_fm_app.py
+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
+76 -247
View File
@@ -1,260 +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 selenium.webdriver.common.by import By
from webdriver_manager.chrome import ChromeDriverManager
from bs4 import BeautifulSoup
import os
from plexapi.myplex import MyPlexAccount
from mutagen.easyid3 import EasyID3
import re
from datetime import datetime
# Configuration
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 configure_logging():
"""Configure logging with quiet defaults for CLI and GUI entrypoints."""
log_format = '%(asctime)s - %(levelname)s - %(message)s'
handlers = []
def scrape_recently_played():
"""Scrape recently played songs from myjourneyfm.com using Selenium"""
options = Options()
options.add_argument("--headless")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--disable-gpu")
service = Service(ChromeDriverManager().install())
driver = webdriver.Chrome(service=service, options=options)
url = 'https://www.myjourneyfm.com/recently-played/'
driver.get(url)
# Wait for initial load
time.sleep(5)
# Click "View More" button if exists
try:
more_button = driver.find_element(By.ID, "moreSongs")
more_button.click()
time.sleep(5) # Wait for more songs to load
except:
pass # Button not found or already loaded
html = driver.page_source
driver.quit()
# Optional: save HTML to file for debugging (comment out if not needed)
# with open('page.html', 'w', encoding='utf-8') as f:
# f.write(html)
soup = BeautifulSoup(html, 'html.parser')
songs = []
# Find elements (div, li) that contain time in their text
song_elements = soup.find_all(lambda tag: tag.name in ['div', 'li', 'p'] and re.search(r'\d+:\d+ [AP]M', tag.get_text()))
for element in song_elements:
spans = element.find_all('span')
if len(spans) >= 3:
title = spans[0].get_text().strip()
artist = spans[1].get_text().strip()
# Ignore time
songs.append({'artist': artist, 'title': title})
else:
# Fallback to text parsing
full_text = element.get_text().strip()
match = re.search(r'(\d+:\d+ [AP]M)', full_text)
if match:
play_time = match.group(1)
before = full_text.split(play_time)[0].strip()
if ' by ' in before:
title, artist = before.split(' by ', 1)
title = title.strip()
artist = artist.strip()
else:
# Split on capital letters
parts = re.findall(r'[A-Z][^A-Z]*', before)
if len(parts) == 4:
title = ' '.join(parts[:2])
artist = ' '.join(parts[2:])
else:
title = ' '.join(parts[:3])
artist = ' '.join(parts[3:])
if title and artist:
songs.append({'artist': artist, 'title': title})
return songs
handlers.append(logging.FileHandler(data_path('playlist_log.txt'), encoding='utf-8'))
except Exception:
pass
def normalize_string(s):
"""Normalize string for comparison"""
return re.sub(r'[^\w\s]', '', s).lower().strip()
stream = getattr(sys, 'stderr', None) or getattr(sys, 'stdout', None)
if stream is not None:
handlers.append(logging.StreamHandler(stream))
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
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)
configure_logging()
logger = logging.getLogger(__name__)
def load_config():
"""Compatibility wrapper for existing callers."""
return load_runtime_config()
def create_playlist_in_plex(plex, songs, playlist_name):
"""Create playlist in Plex"""
# 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
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)
seen_keys.add(track.ratingKey)
matched = True
break
# If no artist match, skip this track (don't add wrong songs)
if not matched:
pass # Don't add if artist doesn't match # Take the first match
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 for track in tracks if track.ratingKey not in existing_keys]
if new_tracks:
existing.addItems(new_tracks)
print(f"Added {len(new_tracks)} new songs to existing playlist '{playlist_name}':")
for track in new_tracks:
print(f" + '{track.title}' by {track.artist().title}")
else:
print(f"No new songs to add - all {len(tracks)} matching songs already in playlist.")
except:
# Playlist doesn't exist, create it
plex.createPlaylist(playlist_name, tracks)
print(f"Created playlist '{playlist_name}' with {len(tracks)} songs:")
for track in tracks:
print(f" + '{track.title}' by {track.artist().title}")
except Exception as e:
print(f"Error with playlist: {e}")
else:
print("No matching tracks found in Plex.")
def main():
# Log start time
start_time = datetime.now()
print(f"\n{'='*60}")
print(f"Journey FM Playlist Update - {start_time.strftime('%Y-%m-%d %I:%M:%S %p')}")
print(f"{'='*60}\n")
# Scrape songs
songs = scrape_recently_played()
print(f"Found {len(songs)} recently played songs.")
# 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(f"Server at {SERVER_IP} not found in your claimed resources.")
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()
print(f"Connected to server: {plex.friendlyName}, ID: {plex.machineIdentifier}")
# Debug: list library sections
print(f"Library sections: {[s.title for s in plex.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]
print(f"Existing playlists: {len(playlists)} total")
# 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:
create_playlist_in_plex(plex, songs, PLAYLIST_NAME)
else:
print("No songs found.")
# Log completion
end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
print(f"\nCompleted in {duration:.1f} seconds at {end_time.strftime('%I:%M:%S %p')}")
print(f"{'='*60}\n")
"""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
def update_playlist():
"""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()
+28
View File
@@ -0,0 +1,28 @@
Requirement already satisfied: requests in ./.venv/lib/python3.13/site-packages (from -r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 1)) (2.32.5)
Requirement already satisfied: beautifulsoup4 in ./.venv/lib/python3.13/site-packages (from -r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 2)) (4.14.2)
Requirement already satisfied: plexapi in ./.venv/lib/python3.13/site-packages (from -r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 3)) (4.17.1)
Requirement already satisfied: mutagen in ./.venv/lib/python3.13/site-packages (from -r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 4)) (1.47.0)
Requirement already satisfied: selenium in ./.venv/lib/python3.13/site-packages (from -r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (4.38.0)
Requirement already satisfied: webdriver-manager in ./.venv/lib/python3.13/site-packages (from -r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 6)) (4.0.2)
Requirement already satisfied: PySide6 in ./.venv/lib/python3.13/site-packages (from -r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 7)) (6.10.0)
Requirement already satisfied: charset_normalizer<4,>=2 in ./.venv/lib/python3.13/site-packages (from requests->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 1)) (3.4.4)
Requirement already satisfied: idna<4,>=2.5 in ./.venv/lib/python3.13/site-packages (from requests->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 1)) (3.11)
Requirement already satisfied: urllib3<3,>=1.21.1 in ./.venv/lib/python3.13/site-packages (from requests->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 1)) (2.5.0)
Requirement already satisfied: certifi>=2017.4.17 in ./.venv/lib/python3.13/site-packages (from requests->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 1)) (2025.11.12)
Requirement already satisfied: soupsieve>1.2 in ./.venv/lib/python3.13/site-packages (from beautifulsoup4->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 2)) (2.8)
Requirement already satisfied: typing-extensions>=4.0.0 in ./.venv/lib/python3.13/site-packages (from beautifulsoup4->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 2)) (4.15.0)
Requirement already satisfied: trio<1.0,>=0.31.0 in ./.venv/lib/python3.13/site-packages (from selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (0.32.0)
Requirement already satisfied: trio-websocket<1.0,>=0.12.2 in ./.venv/lib/python3.13/site-packages (from selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (0.12.2)
Requirement already satisfied: websocket-client<2.0,>=1.8.0 in ./.venv/lib/python3.13/site-packages (from selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (1.9.0)
Requirement already satisfied: attrs>=23.2.0 in ./.venv/lib/python3.13/site-packages (from trio<1.0,>=0.31.0->selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (25.4.0)
Requirement already satisfied: sortedcontainers in ./.venv/lib/python3.13/site-packages (from trio<1.0,>=0.31.0->selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (2.4.0)
Requirement already satisfied: outcome in ./.venv/lib/python3.13/site-packages (from trio<1.0,>=0.31.0->selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (1.3.0.post0)
Requirement already satisfied: sniffio>=1.3.0 in ./.venv/lib/python3.13/site-packages (from trio<1.0,>=0.31.0->selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (1.3.1)
Requirement already satisfied: wsproto>=0.14 in ./.venv/lib/python3.13/site-packages (from trio-websocket<1.0,>=0.12.2->selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (1.3.1)
Requirement already satisfied: pysocks!=1.5.7,<2.0,>=1.5.6 in ./.venv/lib/python3.13/site-packages (from urllib3[socks]<3.0,>=2.5.0->selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (1.7.1)
Requirement already satisfied: python-dotenv in ./.venv/lib/python3.13/site-packages (from webdriver-manager->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 6)) (1.2.1)
Requirement already satisfied: packaging in ./.venv/lib/python3.13/site-packages (from webdriver-manager->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 6)) (25.0)
Requirement already satisfied: shiboken6==6.10.0 in ./.venv/lib/python3.13/site-packages (from PySide6->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 7)) (6.10.0)
Requirement already satisfied: PySide6_Essentials==6.10.0 in ./.venv/lib/python3.13/site-packages (from PySide6->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 7)) (6.10.0)
Requirement already satisfied: PySide6_Addons==6.10.0 in ./.venv/lib/python3.13/site-packages (from PySide6->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 7)) (6.10.0)
Requirement already satisfied: h11<1,>=0.16.0 in ./.venv/lib/python3.13/site-packages (from wsproto>=0.14->trio-websocket<1.0,>=0.12.2->selenium->-r /home/nmemmert/journey/journey-fm-playlist/requirements.txt (line 5)) (0.16.0)
Binary file not shown.
+4 -1
View File
@@ -3,4 +3,7 @@ beautifulsoup4
plexapi
mutagen
selenium
webdriver-manager
webdriver-manager
PySide6
matplotlib
keyring
+13
View File
@@ -0,0 +1,13 @@
@echo off
REM Journey FM Playlist Creator - Windows Launcher
cd /d "%~dp0"
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 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()
+17 -21
View File
@@ -1,30 +1,26 @@
# Setup Task Scheduler for Journey FM Playlist Updater
# Run this script as Administrator to create scheduled tasks
param(
[string]$Type = "Both", # Interval, Startup, Both
[int]$Interval = 15,
[string]$Unit = "Minutes" # Minutes, Hours, Days
)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$psScript = "$scriptDir\run_journey_playlist.ps1"
if ($Type -eq "Interval" -or $Type -eq "Both") {
$action = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$psScript`""
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -$Unit $Interval)
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName "Journey FM Playlist Updater - Every $Interval $Unit" -Action $action -Trigger $trigger -Settings $settings -Description "Updates Plex playlist with recently played songs every $Interval $Unit"
Write-Host "Created task: Every $Interval $Unit" -ForegroundColor Green
}
# Task 1: Run every 15 minutes
$action1 = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$psScript`""
$trigger1 = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 15)
$settings1 = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
if ($Type -eq "Startup" -or $Type -eq "Both") {
$action = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$psScript`""
$trigger = New-ScheduledTaskTrigger -AtStartup
$trigger.Delay = "PT1M" # 1 minute delay
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName "Journey FM Playlist Updater - At Startup" -Action $action -Trigger $trigger -Settings $settings -Description "Updates Plex playlist with recently played songs at startup"
Write-Host "Created task: At Startup" -ForegroundColor Green
}
Register-ScheduledTask -TaskName "Journey FM Playlist Updater - Every 15 Minutes" -Action $action1 -Trigger $trigger1 -Settings $settings1 -Description "Updates Plex playlist with recently played songs from Journey FM every 15 minutes" -Force
# Task 2: Run at startup
$action2 = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$psScript`""
$trigger2 = New-ScheduledTaskTrigger -AtStartup
$trigger2.Delay = "PT1M" # 1 minute delay after startup
$settings2 = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName "Journey FM Playlist Updater - At Startup" -Action $action2 -Trigger $trigger2 -Settings $settings2 -Description "Updates Plex playlist with recently played songs from Journey FM at computer startup" -Force
Write-Host "Scheduled tasks created successfully!" -ForegroundColor Green
Write-Host "Task 1: Runs every 15 minutes" -ForegroundColor Cyan
Write-Host "Task 2: Runs at computer startup (with 60 second delay)" -ForegroundColor Cyan
Write-Host ""
Write-Host "You can view and manage these tasks in Task Scheduler." -ForegroundColor Yellow
+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)."