Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f33d8bd5d |
No files matched your search
@@ -1,11 +0,0 @@
|
|||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.venv
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
*.pyo
|
|
||||||
*.pyd
|
|
||||||
*.log
|
|
||||||
*.db
|
|
||||||
nohup.out
|
|
||||||
icon.png
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -11,7 +11,6 @@ __pycache__/
|
|||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
playlist_log.txt
|
playlist_log.txt
|
||||||
app_log.txt
|
|
||||||
page.html
|
page.html
|
||||||
|
|
||||||
# Selenium/ChromeDriver
|
# Selenium/ChromeDriver
|
||||||
@@ -29,7 +28,3 @@ Thumbs.db
|
|||||||
|
|
||||||
# Environment variables (if you add a .env file later)
|
# Environment variables (if you add a .env file later)
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# Debug / local runtime outputs
|
|
||||||
debug_scrapes/
|
|
||||||
test_live_klove.py
|
|
||||||
-59
@@ -1,59 +0,0 @@
|
|||||||
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"]
|
|
||||||
@@ -1,373 +1,28 @@
|
|||||||
# Journey FM Playlist Creator
|
# Journey FM Playlist Creator
|
||||||
|
|
||||||
Desktop app + script that pulls recently played songs from Journey FM / Spirit FM and updates a Plex playlist.
|
This script automates the process of creating a Plex playlist from recently played songs on My Journey FM radio station.
|
||||||
|
|
||||||
## What It Does
|
## Features
|
||||||
|
|
||||||
- Scrapes recently played songs from:
|
- Scrapes recently played songs from https://www.myjourneyfm.com/recently-played/
|
||||||
- `https://www.myjourneyfm.com/recently-played/`
|
- Checks your local music library for matching songs
|
||||||
- `https://spiritfm.com/ajax/now_playing_history.txt`
|
- Creates a playlist in Plex with the found songs
|
||||||
- `https://www.klove.com/music/songs`
|
|
||||||
- Matches tracks in your Plex Music library with title/artist normalization.
|
## Setup
|
||||||
- Creates or updates your configured Plex playlist.
|
|
||||||
- Avoids adding duplicates already in the target playlist.
|
1. Install dependencies: `pip install -r requirements.txt`
|
||||||
- Tracks run history in `playlist_history.db`.
|
2. Configure the script:
|
||||||
- Writes missing songs to `amazon_buy_list.txt` with Amazon search links.
|
- Set `PLEX_URL` to your Plex server URL
|
||||||
- Provides a GUI with system tray support, settings, log viewer, history, stats, analytics, and CSV export.
|
- 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`
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.6+
|
||||||
- Plex account token + reachable Plex server
|
- Plex server
|
||||||
- A Chrome/Chromium browser for Selenium scraping
|
- Local music library with ID3 tags
|
||||||
|
|
||||||
Dependencies are listed in `requirements.txt` (GUI uses `PySide6`).
|
## Note
|
||||||
|
|
||||||
## Install
|
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.
|
||||||
|
|
||||||
### 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.
|
|
||||||
@@ -1,983 +0,0 @@
|
|||||||
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
@@ -1,36 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
#!/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")
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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:
|
|
||||||
-139
@@ -1,139 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
@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
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
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
File diff suppressed because it is too large.
Load diff
@@ -1,26 +0,0 @@
|
|||||||
#!/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 +0,0 @@
|
|||||||
"""Shared services for Journey FM Playlist Creator."""
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from selenium import webdriver
|
|
||||||
from selenium.webdriver.chrome.options import Options
|
|
||||||
from selenium.webdriver.chrome.service import Service
|
|
||||||
from selenium.webdriver.common.by import By
|
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
|
||||||
from webdriver_manager.chrome import ChromeDriverManager
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from journeyfm.paths import data_path
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
DEBUG_SCRAPE_DIR = data_path("debug_scrapes")
|
|
||||||
|
|
||||||
|
|
||||||
def detect_chrome_binary():
|
|
||||||
env_binary = os.getenv("CHROME_BINARY", "").strip()
|
|
||||||
if env_binary and os.path.exists(env_binary):
|
|
||||||
return env_binary
|
|
||||||
|
|
||||||
candidates = []
|
|
||||||
if os.name == "nt":
|
|
||||||
local_app_data = os.getenv("LOCALAPPDATA", "")
|
|
||||||
program_files = os.getenv("PROGRAMFILES", "")
|
|
||||||
program_files_x86 = os.getenv("PROGRAMFILES(X86)", "")
|
|
||||||
candidates.extend([
|
|
||||||
os.path.join(local_app_data, "Google", "Chrome", "Application", "chrome.exe"),
|
|
||||||
os.path.join(program_files, "Google", "Chrome", "Application", "chrome.exe"),
|
|
||||||
os.path.join(program_files_x86, "Google", "Chrome", "Application", "chrome.exe"),
|
|
||||||
os.path.join(program_files, "Chromium", "Application", "chrome.exe"),
|
|
||||||
os.path.join(program_files_x86, "Chromium", "Application", "chrome.exe"),
|
|
||||||
])
|
|
||||||
else:
|
|
||||||
candidates.extend([
|
|
||||||
"/usr/bin/google-chrome",
|
|
||||||
"/usr/bin/google-chrome-stable",
|
|
||||||
"/usr/bin/chromium",
|
|
||||||
"/usr/bin/chromium-browser",
|
|
||||||
"/opt/google/chrome/chrome",
|
|
||||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
||||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
||||||
])
|
|
||||||
|
|
||||||
for binary in candidates:
|
|
||||||
if binary and os.path.exists(binary):
|
|
||||||
return binary
|
|
||||||
|
|
||||||
for name in ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"]:
|
|
||||||
found = shutil.which(name)
|
|
||||||
if found:
|
|
||||||
return found
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def detect_chromedriver_path():
|
|
||||||
env_driver = os.getenv("CHROMEDRIVER_PATH", "").strip()
|
|
||||||
if env_driver and os.path.exists(env_driver):
|
|
||||||
return env_driver
|
|
||||||
for candidate in ["/usr/bin/chromedriver", "/usr/local/bin/chromedriver"]:
|
|
||||||
if os.path.exists(candidate):
|
|
||||||
return candidate
|
|
||||||
return shutil.which("chromedriver")
|
|
||||||
|
|
||||||
|
|
||||||
def _write_debug_payload(station_key, payload, extension):
|
|
||||||
DEBUG_SCRAPE_DIR.mkdir(exist_ok=True)
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
||||||
file_path = DEBUG_SCRAPE_DIR / f"{station_key}_{timestamp}.{extension}"
|
|
||||||
with open(file_path, "w", encoding="utf-8") as file_handle:
|
|
||||||
file_handle.write(payload)
|
|
||||||
latest_path = DEBUG_SCRAPE_DIR / f"{station_key}_latest.{extension}"
|
|
||||||
with open(latest_path, "w", encoding="utf-8") as file_handle:
|
|
||||||
file_handle.write(payload)
|
|
||||||
return str(file_path)
|
|
||||||
|
|
||||||
|
|
||||||
class JourneyFMScraper:
|
|
||||||
station_key = "journey_fm"
|
|
||||||
display_name = "Journey FM"
|
|
||||||
url = "https://www.myjourneyfm.com/recently-played/"
|
|
||||||
|
|
||||||
SELECTOR_PATTERNS = [
|
|
||||||
{
|
|
||||||
"name": "rp-item",
|
|
||||||
"container": ("div", "rp-item"),
|
|
||||||
"title": ("h5", "song-title"),
|
|
||||||
"artist": ("p", "song-artist"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "top-rp-list",
|
|
||||||
"container": ("div", "song-item"),
|
|
||||||
"title": ("div", "title-artist"),
|
|
||||||
"artist": ("span", None),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _extract_with_pattern(cls, soup, pattern):
|
|
||||||
songs = []
|
|
||||||
container_tag, container_class = pattern["container"]
|
|
||||||
title_tag, title_class = pattern["title"]
|
|
||||||
artist_tag, artist_class = pattern["artist"]
|
|
||||||
|
|
||||||
for item in soup.find_all(container_tag, class_=container_class):
|
|
||||||
if title_class:
|
|
||||||
title_elem = item.find(title_tag, class_=title_class)
|
|
||||||
else:
|
|
||||||
title_elem = item.find(title_tag)
|
|
||||||
if not title_elem:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if pattern["name"] == "top-rp-list":
|
|
||||||
paragraph = title_elem.find("p")
|
|
||||||
if not paragraph:
|
|
||||||
continue
|
|
||||||
combined = paragraph.get_text(" ", strip=True)
|
|
||||||
parts = re.split(r"\s+by\s+", combined, maxsplit=1, flags=re.IGNORECASE)
|
|
||||||
if len(parts) != 2:
|
|
||||||
continue
|
|
||||||
title = parts[0].strip()
|
|
||||||
artist = parts[1].strip()
|
|
||||||
else:
|
|
||||||
if artist_class:
|
|
||||||
artist_elem = item.find(artist_tag, class_=artist_class)
|
|
||||||
else:
|
|
||||||
artist_elem = item.find(artist_tag)
|
|
||||||
if not artist_elem:
|
|
||||||
continue
|
|
||||||
title = title_elem.get_text(" ", strip=True).strip()
|
|
||||||
artist = artist_elem.get_text(" ", strip=True).strip()
|
|
||||||
|
|
||||||
if title and artist:
|
|
||||||
songs.append({"title": title, "artist": artist, "source": cls.display_name})
|
|
||||||
|
|
||||||
return songs
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def parse_html_with_telemetry(cls, html):
|
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
|
||||||
for pattern in cls.SELECTOR_PATTERNS:
|
|
||||||
songs = cls._extract_with_pattern(soup, pattern)
|
|
||||||
if songs:
|
|
||||||
return songs, pattern["name"]
|
|
||||||
return [], "none"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_html(html):
|
|
||||||
songs, _pattern = JourneyFMScraper.parse_html_with_telemetry(html)
|
|
||||||
return songs
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def scrape(cls, driver):
|
|
||||||
driver.get(cls.url)
|
|
||||||
WebDriverWait(driver, 15).until(
|
|
||||||
lambda _driver: (
|
|
||||||
bool(_driver.find_elements(By.CSS_SELECTOR, "div.rp-item"))
|
|
||||||
or bool(_driver.find_elements(By.CSS_SELECTOR, "div.song-item"))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
more_button = WebDriverWait(driver, 5).until(EC.element_to_be_clickable((By.ID, "moreSongs")))
|
|
||||||
more_button.click()
|
|
||||||
time.sleep(2)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
html = driver.page_source
|
|
||||||
raw_path = _write_debug_payload(cls.station_key, html, "html")
|
|
||||||
songs, parse_pattern = cls.parse_html_with_telemetry(html)
|
|
||||||
return songs, raw_path, parse_pattern
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def scrape_without_driver(cls):
|
|
||||||
response = requests.get(cls.url, timeout=20)
|
|
||||||
response.raise_for_status()
|
|
||||||
html = response.text
|
|
||||||
raw_path = _write_debug_payload(cls.station_key, html, "html")
|
|
||||||
songs, parse_pattern = cls.parse_html_with_telemetry(html)
|
|
||||||
return songs, raw_path, f"{parse_pattern}-requests-fallback"
|
|
||||||
|
|
||||||
|
|
||||||
class SpiritFMScraper:
|
|
||||||
station_key = "spirit_fm"
|
|
||||||
display_name = "Spirit FM"
|
|
||||||
url = "https://spiritfm.com/ajax/now_playing_history.txt"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_text(text):
|
|
||||||
songs = []
|
|
||||||
for line in text.splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
match = re.match(r"^\w+\s+\d+:\d+[AP]M\s+(.+?)\s+-\s+(.+)$", line)
|
|
||||||
if not match:
|
|
||||||
continue
|
|
||||||
artist = match.group(1).strip().replace("&", "&")
|
|
||||||
title = match.group(2).strip().replace("&", "&")
|
|
||||||
if title and artist:
|
|
||||||
songs.append({"title": title, "artist": artist, "source": SpiritFMScraper.display_name})
|
|
||||||
return songs
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def scrape(cls, driver):
|
|
||||||
driver.get(cls.url)
|
|
||||||
time.sleep(2)
|
|
||||||
text = BeautifulSoup(driver.page_source, "html.parser").get_text()
|
|
||||||
raw_path = _write_debug_payload(cls.station_key, text, "txt")
|
|
||||||
return cls.parse_text(text), raw_path, "plain-text-regex"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def scrape_without_driver(cls):
|
|
||||||
response = requests.get(cls.url, timeout=20)
|
|
||||||
response.raise_for_status()
|
|
||||||
text = response.text
|
|
||||||
raw_path = _write_debug_payload(cls.station_key, text, "txt")
|
|
||||||
return cls.parse_text(text), raw_path, "plain-text-regex-requests-fallback"
|
|
||||||
|
|
||||||
|
|
||||||
class KLOVEScraper:
|
|
||||||
station_key = "klove"
|
|
||||||
display_name = "K-LOVE"
|
|
||||||
url = "https://www.klove.com/music/songs"
|
|
||||||
# K-LOVE is a JS-rendered React app; Selenium is the primary path.
|
|
||||||
# The requests fallback may still return partial SSR content worth parsing.
|
|
||||||
_LOAD_SELECTOR = "a[href*='/music/artists/']"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_html(html):
|
|
||||||
songs, _pattern = KLOVEScraper.parse_html_with_telemetry(html)
|
|
||||||
return songs
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_html_with_telemetry(html):
|
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
|
||||||
songs = []
|
|
||||||
seen = set()
|
|
||||||
|
|
||||||
# Collect every anchor that points at /music/artists/{artist}/{song}
|
|
||||||
# The page renders each card as two anchors (art + title) — use hrefs
|
|
||||||
# as a dedup key so we only process each song once.
|
|
||||||
processed_hrefs = set()
|
|
||||||
for anchor in soup.find_all("a", href=re.compile(r"/music/artists/[^/]+/[^/]+")):
|
|
||||||
href = anchor.get("href", "").strip().rstrip("/")
|
|
||||||
parts = href.strip("/").split("/")
|
|
||||||
# Expect: music / artists / artist-slug / song-slug
|
|
||||||
if len(parts) < 4:
|
|
||||||
continue
|
|
||||||
|
|
||||||
title_text = anchor.get_text(" ", strip=True)
|
|
||||||
if not title_text or len(title_text) < 2:
|
|
||||||
# Image-only anchor (cover art link) — skip it; the title anchor
|
|
||||||
# for this same href will appear later in the DOM with real text.
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Now that we have a text-bearing anchor, dedup by href
|
|
||||||
if href in processed_hrefs:
|
|
||||||
continue
|
|
||||||
processed_hrefs.add(href)
|
|
||||||
|
|
||||||
# Skip navigation / album-only links (e.g. artist page without song)
|
|
||||||
song_slug = parts[-1]
|
|
||||||
artist_slug = parts[-2] if len(parts) >= 4 else ""
|
|
||||||
|
|
||||||
# Find "By {artist}" text in nearby DOM — check siblings and parent
|
|
||||||
artist_text = ""
|
|
||||||
parent = anchor.parent
|
|
||||||
if parent:
|
|
||||||
full_text = parent.get_text(" ", strip=True)
|
|
||||||
by_match = re.search(r"\bBy\s+(.+)", full_text)
|
|
||||||
if by_match:
|
|
||||||
artist_text = by_match.group(1).strip()
|
|
||||||
|
|
||||||
if not artist_text and parent and parent.parent:
|
|
||||||
grandparent_text = parent.parent.get_text(" ", strip=True)
|
|
||||||
by_match = re.search(r"\bBy\s+(.+?)(?:\bBy\b|$)", grandparent_text)
|
|
||||||
if by_match:
|
|
||||||
artist_text = by_match.group(1).strip()
|
|
||||||
|
|
||||||
if not artist_text:
|
|
||||||
# Derive artist from URL slug as last resort
|
|
||||||
artist_text = " ".join(w.capitalize() for w in artist_slug.split("-"))
|
|
||||||
|
|
||||||
# Strip leftover boilerplate that sometimes bleeds in
|
|
||||||
artist_text = re.sub(r"\s*(feat\.|ft\.|featuring|Play Sample|Image).*$", "",
|
|
||||||
artist_text, flags=re.IGNORECASE).strip()
|
|
||||||
|
|
||||||
if title_text and artist_text:
|
|
||||||
key = (title_text.lower(), artist_text.lower())
|
|
||||||
if key not in seen:
|
|
||||||
seen.add(key)
|
|
||||||
songs.append({"title": title_text, "artist": artist_text,
|
|
||||||
"source": KLOVEScraper.display_name})
|
|
||||||
|
|
||||||
if songs:
|
|
||||||
return songs, "klove-anchor-by"
|
|
||||||
return [], "none"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def scrape(cls, driver):
|
|
||||||
driver.get(cls.url)
|
|
||||||
# Wait for song anchor links to appear (JS-rendered)
|
|
||||||
WebDriverWait(driver, 20).until(
|
|
||||||
EC.presence_of_element_located((By.CSS_SELECTOR, cls._LOAD_SELECTOR))
|
|
||||||
)
|
|
||||||
time.sleep(2) # Allow lazy-load tiles to settle
|
|
||||||
html = driver.page_source
|
|
||||||
raw_path = _write_debug_payload(cls.station_key, html, "html")
|
|
||||||
songs, parse_pattern = cls.parse_html_with_telemetry(html)
|
|
||||||
return songs, raw_path, parse_pattern
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def scrape_without_driver(cls):
|
|
||||||
headers = {
|
|
||||||
"User-Agent": (
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Chrome/120.0.0.0 Safari/537.36"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
response = requests.get(cls.url, headers=headers, timeout=20)
|
|
||||||
response.raise_for_status()
|
|
||||||
html = response.text
|
|
||||||
raw_path = _write_debug_payload(cls.station_key, html, "html")
|
|
||||||
songs, parse_pattern = cls.parse_html_with_telemetry(html)
|
|
||||||
return songs, raw_path, f"{parse_pattern}-requests-fallback"
|
|
||||||
|
|
||||||
|
|
||||||
SCRAPERS = {
|
|
||||||
JourneyFMScraper.station_key: JourneyFMScraper,
|
|
||||||
SpiritFMScraper.station_key: SpiritFMScraper,
|
|
||||||
KLOVEScraper.station_key: KLOVEScraper,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_driver():
|
|
||||||
options = Options()
|
|
||||||
options.add_argument("--headless=new")
|
|
||||||
options.add_argument("--no-sandbox")
|
|
||||||
options.add_argument("--disable-dev-shm-usage")
|
|
||||||
options.add_argument("--disable-gpu")
|
|
||||||
options.add_argument("--window-size=1920,1080")
|
|
||||||
options.add_argument("--disable-blink-features=AutomationControlled")
|
|
||||||
options.add_argument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
|
||||||
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
|
||||||
options.add_experimental_option("useAutomationExtension", False)
|
|
||||||
|
|
||||||
chrome_binary = detect_chrome_binary()
|
|
||||||
if chrome_binary:
|
|
||||||
options.binary_location = chrome_binary
|
|
||||||
chromedriver_path = detect_chromedriver_path()
|
|
||||||
if chromedriver_path:
|
|
||||||
service = Service(chromedriver_path)
|
|
||||||
else:
|
|
||||||
service = Service(ChromeDriverManager().install())
|
|
||||||
return webdriver.Chrome(service=service, options=options)
|
|
||||||
|
|
||||||
|
|
||||||
def scrape_recently_played(selected_stations=None):
|
|
||||||
selected_stations = selected_stations or ["journey_fm", "spirit_fm"]
|
|
||||||
driver = None
|
|
||||||
driver_start_error = ""
|
|
||||||
all_songs = []
|
|
||||||
seen = set()
|
|
||||||
station_results = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
driver = build_driver()
|
|
||||||
except Exception as exc:
|
|
||||||
driver = None
|
|
||||||
driver_start_error = str(exc)
|
|
||||||
logger.warning("Browser scraper unavailable; using fallback mode: %s", exc)
|
|
||||||
for station_key in selected_stations:
|
|
||||||
scraper_cls = SCRAPERS.get(station_key)
|
|
||||||
if scraper_cls is None:
|
|
||||||
station_results.append({
|
|
||||||
"station": station_key,
|
|
||||||
"display_name": station_key,
|
|
||||||
"success": False,
|
|
||||||
"error": "unsupported-station",
|
|
||||||
"scraped_count": 0,
|
|
||||||
"raw_path": "",
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
if driver is not None:
|
|
||||||
try:
|
|
||||||
songs, raw_path, parse_pattern = scraper_cls.scrape(driver)
|
|
||||||
except Exception as scrape_error:
|
|
||||||
logger.warning(
|
|
||||||
"Selenium scrape failed for %s, retrying fallback mode: %s",
|
|
||||||
scraper_cls.display_name,
|
|
||||||
scrape_error,
|
|
||||||
)
|
|
||||||
songs, raw_path, parse_pattern = scraper_cls.scrape_without_driver()
|
|
||||||
else:
|
|
||||||
songs, raw_path, parse_pattern = scraper_cls.scrape_without_driver()
|
|
||||||
raw_payload_bytes = 0
|
|
||||||
if raw_path and os.path.exists(raw_path):
|
|
||||||
try:
|
|
||||||
raw_payload_bytes = os.path.getsize(raw_path)
|
|
||||||
except Exception:
|
|
||||||
raw_payload_bytes = 0
|
|
||||||
added_for_station = 0
|
|
||||||
for song in songs:
|
|
||||||
dedupe_key = (song["title"].lower(), song["artist"].lower())
|
|
||||||
if dedupe_key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(dedupe_key)
|
|
||||||
all_songs.append(song)
|
|
||||||
added_for_station += 1
|
|
||||||
logger.info(" Found: %s by %s", song["title"], song["artist"])
|
|
||||||
station_results.append({
|
|
||||||
"station": station_key,
|
|
||||||
"display_name": scraper_cls.display_name,
|
|
||||||
"success": True,
|
|
||||||
"error": "",
|
|
||||||
"scraped_count": added_for_station,
|
|
||||||
"raw_path": raw_path,
|
|
||||||
"raw_payload_bytes": raw_payload_bytes,
|
|
||||||
"parse_pattern": parse_pattern,
|
|
||||||
})
|
|
||||||
except Exception as exc:
|
|
||||||
combined_error = str(exc)
|
|
||||||
if driver is None and driver_start_error:
|
|
||||||
combined_error = f"{combined_error}; browser init: {driver_start_error}"
|
|
||||||
station_results.append({
|
|
||||||
"station": station_key,
|
|
||||||
"display_name": scraper_cls.display_name,
|
|
||||||
"success": False,
|
|
||||||
"error": combined_error,
|
|
||||||
"scraped_count": 0,
|
|
||||||
"raw_path": "",
|
|
||||||
"raw_payload_bytes": 0,
|
|
||||||
"parse_pattern": "error",
|
|
||||||
})
|
|
||||||
logger.error("Error scraping %s: %s", scraper_cls.display_name, exc)
|
|
||||||
finally:
|
|
||||||
if driver is not None:
|
|
||||||
driver.quit()
|
|
||||||
|
|
||||||
logger.info("Total unique songs found: %s", len(all_songs))
|
|
||||||
return {"songs": all_songs, "station_results": station_results}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
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
@@ -1,89 +1,260 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Journey FM Playlist Creator CLI entrypoint."""
|
"""
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
import argparse
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
import time
|
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
|
||||||
|
|
||||||
from journeyfm.config_store import load_runtime_config
|
# Configuration
|
||||||
from journeyfm.history_service import init_history_db
|
PLEX_TOKEN = 'pU-m3HWYUZU6iXJFhJyA' # Your Plex.tv token
|
||||||
from journeyfm.paths import data_path
|
SERVER_IP = '172.16.16.106' # Your local server IP
|
||||||
from journeyfm.plex_service import connect_to_plex_server
|
PLAYLIST_NAME = 'Journey FM Recently Played'
|
||||||
from journeyfm.update_service import format_result_summary, run_update_job
|
|
||||||
from journeyfm.web_service import get_dashboard_url, start_dashboard_server
|
|
||||||
|
|
||||||
|
def scrape_recently_played():
|
||||||
|
"""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)
|
||||||
|
|
||||||
def configure_logging():
|
url = 'https://www.myjourneyfm.com/recently-played/'
|
||||||
"""Configure logging with quiet defaults for CLI and GUI entrypoints."""
|
driver.get(url)
|
||||||
log_format = '%(asctime)s - %(levelname)s - %(message)s'
|
|
||||||
handlers = []
|
|
||||||
|
|
||||||
|
# Wait for initial load
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# Click "View More" button if exists
|
||||||
try:
|
try:
|
||||||
handlers.append(logging.FileHandler(data_path('playlist_log.txt'), encoding='utf-8'))
|
more_button = driver.find_element(By.ID, "moreSongs")
|
||||||
except Exception:
|
more_button.click()
|
||||||
pass
|
time.sleep(5) # Wait for more songs to load
|
||||||
|
except:
|
||||||
|
pass # Button not found or already loaded
|
||||||
|
|
||||||
stream = getattr(sys, 'stderr', None) or getattr(sys, 'stdout', None)
|
html = driver.page_source
|
||||||
if stream is not None:
|
driver.quit()
|
||||||
handlers.append(logging.StreamHandler(stream))
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format=log_format, handlers=handlers, force=True)
|
# Optional: save HTML to file for debugging (comment out if not needed)
|
||||||
for noisy_logger in ('webdriver_manager', 'WDM', 'matplotlib', 'urllib3', 'plexapi'):
|
# with open('page.html', 'w', encoding='utf-8') as f:
|
||||||
logging.getLogger(noisy_logger).setLevel(logging.WARNING)
|
# f.write(html)
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html, 'html.parser')
|
||||||
|
|
||||||
configure_logging()
|
songs = []
|
||||||
logger = logging.getLogger(__name__)
|
# 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
|
||||||
|
|
||||||
def load_config():
|
def normalize_string(s):
|
||||||
"""Compatibility wrapper for existing callers."""
|
"""Normalize string for comparison"""
|
||||||
return load_runtime_config()
|
return re.sub(r'[^\w\s]', '', s).lower().strip()
|
||||||
|
|
||||||
|
def find_song_in_library(artist, title, library_path):
|
||||||
|
"""Check if song exists in local library"""
|
||||||
|
for root, dirs, files in os.walk(library_path):
|
||||||
|
for file in files:
|
||||||
|
if file.endswith(('.mp3', '.flac', '.m4a')): # Add more formats if needed
|
||||||
|
try:
|
||||||
|
audio = EasyID3(os.path.join(root, file))
|
||||||
|
file_artist = normalize_string(audio.get('artist', [''])[0])
|
||||||
|
file_title = normalize_string(audio.get('title', [''])[0])
|
||||||
|
if normalize_string(artist) == file_artist and normalize_string(title) == file_title:
|
||||||
|
return os.path.join(root, file)
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_playlist_in_plex(plex, songs, playlist_name):
|
||||||
|
"""Create playlist in Plex"""
|
||||||
|
# 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():
|
def main():
|
||||||
"""Run one update cycle and print a concise result summary."""
|
# Log start time
|
||||||
init_history_db()
|
start_time = datetime.now()
|
||||||
result = run_update_job(load_runtime_config())
|
print(f"\n{'='*60}")
|
||||||
summary = format_result_summary(result)
|
print(f"Journey FM Playlist Update - {start_time.strftime('%Y-%m-%d %I:%M:%S %p')}")
|
||||||
print(summary)
|
print(f"{'='*60}\n")
|
||||||
if result.get('status') == 'error':
|
|
||||||
logger.error(summary)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
# Scrape songs
|
||||||
|
songs = scrape_recently_played()
|
||||||
|
print(f"Found {len(songs)} recently played songs.")
|
||||||
|
|
||||||
def update_playlist():
|
# Connect to Plex via MyPlexAccount
|
||||||
"""Compatibility wrapper used by the GUI."""
|
account = MyPlexAccount(token=PLEX_TOKEN)
|
||||||
return main()
|
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
|
||||||
|
|
||||||
def main_cli():
|
if not server_resource:
|
||||||
parser = argparse.ArgumentParser(description='Journey FM Playlist CLI')
|
print(f"Server at {SERVER_IP} not found in your claimed resources.")
|
||||||
parser.add_argument('--serve-web', action='store_true', help='Start local web dashboard server')
|
print("Please claim the server: Open http://172.16.16.106:32400/web, sign in with your Plex account, and follow the claim prompts.")
|
||||||
parser.add_argument('--host', default='127.0.0.1', help='Dashboard host (default 127.0.0.1)')
|
return
|
||||||
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:
|
plex = server_resource.connect()
|
||||||
init_history_db()
|
print(f"Connected to server: {plex.friendlyName}, ID: {plex.machineIdentifier}")
|
||||||
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()
|
# 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")
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main_cli()
|
main()
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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,6 +4,3 @@ plexapi
|
|||||||
mutagen
|
mutagen
|
||||||
selenium
|
selenium
|
||||||
webdriver-manager
|
webdriver-manager
|
||||||
PySide6
|
|
||||||
matplotlib
|
|
||||||
keyring
|
|
||||||
-13
@@ -1,13 +0,0 @@
|
|||||||
@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
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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()
|
|
||||||
+21
-17
@@ -1,26 +1,30 @@
|
|||||||
# Setup Task Scheduler for Journey FM Playlist Updater
|
# Setup Task Scheduler for Journey FM Playlist Updater
|
||||||
# Run this script as Administrator to create scheduled tasks
|
# 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
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
$psScript = "$scriptDir\run_journey_playlist.ps1"
|
$psScript = "$scriptDir\run_journey_playlist.ps1"
|
||||||
|
|
||||||
# Task 1: Run every 15 minutes
|
if ($Type -eq "Interval" -or $Type -eq "Both") {
|
||||||
$action1 = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$psScript`""
|
$action = 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)
|
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -$Unit $Interval)
|
||||||
$settings1 = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
$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
|
||||||
|
}
|
||||||
|
|
||||||
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
|
if ($Type -eq "Startup" -or $Type -eq "Both") {
|
||||||
|
$action = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$psScript`""
|
||||||
# Task 2: Run at startup
|
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||||
$action2 = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$psScript`""
|
$trigger.Delay = "PT1M" # 1 minute delay
|
||||||
$trigger2 = New-ScheduledTaskTrigger -AtStartup
|
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||||
$trigger2.Delay = "PT1M" # 1 minute delay after startup
|
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"
|
||||||
$settings2 = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
Write-Host "Created task: At Startup" -ForegroundColor Green
|
||||||
|
}
|
||||||
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 "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
|
|
||||||
+19
-56
@@ -1,60 +1,23 @@
|
|||||||
import unittest
|
import requests
|
||||||
from pathlib import Path
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from journeyfm.scraper_service import JourneyFMScraper, SpiritFMScraper, KLOVEScraper
|
url = 'https://www.myjourneyfm.com/recently-played/'
|
||||||
|
response = requests.get(url)
|
||||||
|
soup = BeautifulSoup(response.content, 'html.parser')
|
||||||
|
|
||||||
|
# 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())
|
||||||
|
|
||||||
class ScraperParserTests(unittest.TestCase):
|
print("\nScripts with 'song' or 'played':")
|
||||||
def fixture_text(self, name):
|
scripts = soup.find_all('script')
|
||||||
fixture_path = Path("tests") / "fixtures" / name
|
for script in scripts:
|
||||||
return fixture_path.read_text(encoding="utf-8")
|
if script.string and ('song' in script.string.lower() or 'played' in script.string.lower()):
|
||||||
|
print(script.string[:2000])
|
||||||
|
|
||||||
def test_parse_journey_fm_html(self):
|
print("\nAll links:")
|
||||||
html = """
|
for a in soup.find_all('a'):
|
||||||
<div class='rp-item'>
|
if 'recently' in a.get('href', '').lower():
|
||||||
<h5 class='song-title'>No Survivors</h5>
|
print(a)
|
||||||
<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
@@ -1,14 +0,0 @@
|
|||||||
<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
@@ -1,33 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
#!/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)."
|
|
||||||
Reference in new issue
Block a user