Add K-LOVE support and Podman-friendly container deployment

This commit is contained in:
Nate Emmert committed 2026-03-13 09:31:16 -04:00
1 parent c0ebe118cb
commit 18f777e3e7
19 files changed
+2560 -682

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Shared services for Journey FM Playlist Creator."""
+170
View File
@@ -0,0 +1,170 @@
import json
import os
from pathlib import Path
from journeyfm.paths import data_path
try:
import keyring
except Exception:
keyring = None
CONFIG_PATH = data_path("config.json")
KEYRING_SERVICE = "JourneyFMPlaylistCreator"
DEFAULT_CONFIG = {
"PLEX_TOKEN": "",
"SERVER_IP": "",
"PLAYLIST_NAME": "Journey FM Recently Played",
"AUTO_UPDATE": False,
"UPDATE_INTERVAL": 15,
"UPDATE_UNIT": "Minutes",
"SELECTED_STATIONS": ["journey_fm", "spirit_fm"],
}
NON_SECRET_KEYS = {
"SERVER_IP",
"PLAYLIST_NAME",
"AUTO_UPDATE",
"UPDATE_INTERVAL",
"UPDATE_UNIT",
"SELECTED_STATIONS",
}
def is_containerized():
return os.path.exists("/.dockerenv") or os.getenv("JOURNEYFM_CONTAINER") == "1"
def _normalize_selected_stations(value):
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
return list(DEFAULT_CONFIG["SELECTED_STATIONS"])
def _read_config_file(config_path=CONFIG_PATH):
if not Path(config_path).exists():
return {}
try:
with open(config_path, "r", encoding="utf-8") as file_handle:
return json.load(file_handle)
except Exception:
return {}
def _write_config_file(config, config_path=CONFIG_PATH):
with open(config_path, "w", encoding="utf-8") as file_handle:
json.dump(config, file_handle, indent=2)
def get_secret(key):
env_value = os.getenv(key, "").strip()
if env_value:
return env_value
if not is_containerized() and keyring is not None:
try:
stored = keyring.get_password(KEYRING_SERVICE, key)
if stored:
return stored
except Exception:
pass
file_config = _read_config_file()
return str(file_config.get(key, "")).strip()
def set_secret(key, value):
if not value:
delete_secret(key)
return
if not is_containerized() and keyring is not None:
try:
keyring.set_password(KEYRING_SERVICE, key, value)
return
except Exception:
pass
file_config = _read_config_file()
file_config[key] = value
_write_config_file(file_config)
def delete_secret(key):
if not is_containerized() and keyring is not None:
try:
keyring.delete_password(KEYRING_SERVICE, key)
except Exception:
pass
file_config = _read_config_file()
if key in file_config:
del file_config[key]
_write_config_file(file_config)
def migrate_legacy_secrets(config_path=CONFIG_PATH):
file_config = _read_config_file(config_path)
token = str(file_config.get("PLEX_TOKEN", "")).strip()
if token and not is_containerized() and keyring is not None:
try:
keyring.set_password(KEYRING_SERVICE, "PLEX_TOKEN", token)
del file_config["PLEX_TOKEN"]
_write_config_file(file_config, config_path)
except Exception:
pass
def load_runtime_config(config_path=CONFIG_PATH):
migrate_legacy_secrets(config_path)
config = dict(DEFAULT_CONFIG)
file_config = _read_config_file(config_path)
config.update({key: value for key, value in file_config.items() if key in NON_SECRET_KEYS})
config["PLEX_TOKEN"] = get_secret("PLEX_TOKEN")
env_server = os.getenv("SERVER_IP", "").strip()
env_playlist = os.getenv("PLAYLIST_NAME", "").strip()
env_stations = os.getenv("SELECTED_STATIONS", "").strip()
env_auto_update = os.getenv("AUTO_UPDATE", "").strip()
env_interval = os.getenv("UPDATE_INTERVAL", "").strip()
env_unit = os.getenv("UPDATE_UNIT", "").strip()
if env_server:
config["SERVER_IP"] = env_server
if env_playlist:
config["PLAYLIST_NAME"] = env_playlist
if env_stations:
config["SELECTED_STATIONS"] = _normalize_selected_stations(env_stations)
else:
config["SELECTED_STATIONS"] = _normalize_selected_stations(config.get("SELECTED_STATIONS"))
if env_auto_update:
config["AUTO_UPDATE"] = env_auto_update.lower() in {"1", "true", "yes", "on"}
if env_interval:
try:
config["UPDATE_INTERVAL"] = int(env_interval)
except ValueError:
pass
if env_unit:
config["UPDATE_UNIT"] = env_unit
return config
def save_runtime_config(config, config_path=CONFIG_PATH):
config = dict(config)
token = str(config.pop("PLEX_TOKEN", "")).strip()
if token:
set_secret("PLEX_TOKEN", token)
persisted = {}
for key in NON_SECRET_KEYS:
if key not in config:
continue
value = config[key]
if isinstance(value, list):
persisted[key] = ",".join(value)
else:
persisted[key] = value
_write_config_file(persisted, config_path)
def get_display_config(config_path=CONFIG_PATH):
config = load_runtime_config(config_path)
config["PLEX_TOKEN"] = get_secret("PLEX_TOKEN")
return config
+73
View File
@@ -0,0 +1,73 @@
import json
import sqlite3
from datetime import datetime
from journeyfm.paths import data_path
HISTORY_COLUMNS = {
"status": "TEXT DEFAULT 'success'",
"scraped_count": "INTEGER DEFAULT 0",
"matched_count": "INTEGER DEFAULT 0",
"duplicate_count": "INTEGER DEFAULT 0",
"skipped_count": "INTEGER DEFAULT 0",
"station_breakdown": "TEXT DEFAULT '[]'",
"skipped_songs": "TEXT DEFAULT '[]'",
"error_message": "TEXT DEFAULT ''",
}
def init_history_db(db_path=None):
db_path = db_path or data_path("playlist_history.db")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY,
date TEXT,
added_count INTEGER,
added_songs TEXT,
missing_count INTEGER,
missing_songs TEXT
)
"""
)
cursor.execute("PRAGMA table_info(history)")
existing_columns = {row[1] for row in cursor.fetchall()}
for column_name, definition in HISTORY_COLUMNS.items():
if column_name not in existing_columns:
cursor.execute(f"ALTER TABLE history ADD COLUMN {column_name} {definition}")
conn.commit()
conn.close()
def save_history_entry(result, db_path=None):
db_path = db_path or data_path("playlist_history.db")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO history (
date, added_count, added_songs, missing_count, missing_songs,
status, scraped_count, matched_count, duplicate_count, skipped_count,
station_breakdown, skipped_songs, error_message
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
datetime.now().isoformat(),
result.get("added_count", 0),
json.dumps(result.get("added_songs", [])),
result.get("missing_count", 0),
json.dumps(result.get("missing_songs", [])),
result.get("status", "success"),
result.get("scraped_count", 0),
result.get("matched_count", 0),
result.get("duplicate_count", 0),
result.get("skipped_count", 0),
json.dumps(result.get("station_breakdown", [])),
json.dumps(result.get("skipped_songs", [])),
result.get("error_message", ""),
),
)
conn.commit()
conn.close()
+16
View File
@@ -0,0 +1,16 @@
import os
from pathlib import Path
def get_data_dir() -> Path:
data_dir = os.getenv("JOURNEYFM_DATA_DIR", "").strip()
if data_dir:
path = Path(data_dir)
else:
path = Path(".")
path.mkdir(parents=True, exist_ok=True)
return path
def data_path(*parts: str) -> Path:
return get_data_dir().joinpath(*parts)
+273
View File
@@ -0,0 +1,273 @@
import urllib.parse
from plexapi.myplex import MyPlexAccount
from plexapi.server import PlexServer
class PlexConnectionError(RuntimeError):
pass
def normalize_server_target(server_ip):
target = (server_ip or "").strip()
if not target:
return ""
if "://" not in target:
target = f"http://{target}"
parsed = urllib.parse.urlparse(target)
return (parsed.hostname or "").strip().lower()
def resolve_plex_server_url(token, server_ip):
target_host = normalize_server_target(server_ip)
if not token:
raise PlexConnectionError("Missing Plex token")
if not target_host:
raise PlexConnectionError("Missing Plex server address")
try:
account = MyPlexAccount(token=token)
except Exception as exc:
raise PlexConnectionError(f"Failed to authenticate with Plex: {exc}") from exc
for resource in account.resources():
for connection in resource.connections:
connection_host = normalize_server_target(connection.uri)
address = (connection.address or "").strip().lower()
if connection_host == target_host or target_host == address or target_host in address:
return connection.uri
raise PlexConnectionError(
f"Server not found at {server_ip}. Verify the configured address and that Plex is reachable."
)
def connect_to_plex_server(token, server_ip):
server_url = resolve_plex_server_url(token, server_ip)
try:
return PlexServer(server_url, token)
except Exception as exc:
raise PlexConnectionError(f"Failed to connect to Plex server at {server_url}: {exc}") from exc
def playlist_item_count(playlist):
leaf_count = getattr(playlist, "leafCount", None)
if isinstance(leaf_count, int):
return leaf_count
try:
return len(playlist.items())
except Exception:
return 0
def fetch_playlists(token, server_ip, music_only=False):
plex = connect_to_plex_server(token, server_ip)
return fetch_playlists_for_plex(plex, music_only)
def fetch_playlists_for_plex(plex, music_only=False):
playlists = []
for playlist in plex.playlists():
playlist_type = getattr(playlist, "playlistType", "unknown")
if music_only and playlist_type != "audio":
continue
playlists.append(
{
"title": playlist.title,
"playlist_type": playlist_type,
"item_count": playlist_item_count(playlist),
}
)
playlists.sort(key=lambda item: (item["title"].lower(), item["playlist_type"]))
return playlists
def validate_playlist_target(token, server_ip, playlist_name, music_only=False):
plex = connect_to_plex_server(token, server_ip)
playlist_name = (playlist_name or "").strip()
if not playlist_name:
raise PlexConnectionError("Playlist name is required")
playlists = fetch_playlists_for_plex(plex, music_only=False)
for playlist in playlists:
if playlist["title"].strip().lower() == playlist_name.lower():
if music_only and playlist["playlist_type"] != "audio":
raise PlexConnectionError("Selected playlist is not a music playlist")
return {
"exists": True,
"playlist": playlist,
"message": f"Ready to write to existing playlist '{playlist_name}'",
}
try:
plex.library.section("Music")
except Exception as exc:
raise PlexConnectionError(f"Music library is not accessible on this server: {exc}") from exc
return {
"exists": False,
"playlist": {
"title": playlist_name,
"playlist_type": "audio",
"item_count": 0,
},
"message": f"Playlist '{playlist_name}' will be created on first sync",
}
def _normalize_artist(name):
"""Return a simplified artist token for fuzzy comparison."""
import re
name = name.lower()
# Drop featured artists — everything after feat/ft/with/w/
name = re.sub(r"\s+(feat\.?|ft\.?|featuring|w/|with)\s+.*", "", name)
# Drop parenthetical / bracketed suffixes
name = re.sub(r"[\(\[].*?[\)\]]", "", name)
# Expand & / + to "and"
name = re.sub(r"\s*[&+]\s*", " and ", name)
# Collapse articles at start: "the foo" → "foo"
name = re.sub(r"^(the|a|an)\s+", "", name)
# Remove common punctuation that Plex sometimes strips
name = re.sub(r"['\.\-,]", "", name)
# Collapse whitespace
name = re.sub(r"\s+", " ", name).strip()
return name
def _artists_match(search_artist, track_artist_raw):
"""
Return True if the station artist string plausibly refers to the same
artist stored in Plex, using several normalisation / containment passes.
"""
sa = _normalize_artist(search_artist)
ta = _normalize_artist(track_artist_raw)
if not sa or not ta:
return False
# Exact normalised match
if sa == ta:
return True
# One fully contains the other (e.g. "Brandon Lake" in "Brandon Lake & Maverick")
if sa in ta or ta in sa:
return True
# Token-set overlap: every token in the shorter name appears in the longer
sa_tokens = set(sa.split())
ta_tokens = set(ta.split())
if sa_tokens and ta_tokens:
shorter = sa_tokens if len(sa_tokens) <= len(ta_tokens) else ta_tokens
longer = ta_tokens if len(sa_tokens) <= len(ta_tokens) else sa_tokens
# All tokens of the shorter name found in the longer (handles "for king country" ↔ "for king and country")
if shorter <= longer:
return True
# At least >60 % of tokens match (handles minor word-count diffs)
overlap = len(shorter & longer)
if overlap / len(shorter) >= 0.6:
return True
return False
def create_or_update_playlist(plex, songs, playlist_name, dry_run=False):
import re
music_library = plex.library.section("Music")
tracks = []
seen_keys = set()
missing = []
skipped = []
added_songs = []
duplicate_songs = []
for song in songs:
clean_title = re.sub(r"\([^)]*\)", "", song["title"]).strip()
clean_title = re.sub(r"\s+", " ", clean_title)
clean_artist = re.sub(r"\([^)]*\)", "", song["artist"]).strip()
clean_artist = re.sub(r"\s+", " ", clean_artist)
if not clean_title or len(clean_title) < 3 or clean_title.lower() in {"by", "recently played", "now playing:", "search"}:
skipped.append({"title": song["title"], "artist": song["artist"], "reason": "invalid-title"})
continue
if re.match(r"^[!?\s]+$", clean_title):
skipped.append({"title": song["title"], "artist": song["artist"], "reason": "punctuation-only-title"})
continue
search_title = re.sub(r"[!?]", "", clean_title).strip()
search_artist = re.sub(r"[!?]", "", clean_artist).strip()
# Expand & / + in title for Plex search (artist expansion handled by _artists_match)
search_title = re.sub(r"\s*[&+]\s*", " and ", search_title, flags=re.IGNORECASE)
results = music_library.searchTracks(title=search_title)
if not results:
# Secondary search: try the raw title in case Plex stores it differently
results = music_library.searchTracks(title=clean_title)
if not results:
missing.append({"title": song["title"], "artist": song["artist"], "reason": "not-found"})
continue
matched = False
for track in results:
try:
track_artist_raw = track.artist().title
except Exception:
continue
if _artists_match(search_artist, track_artist_raw):
if track.ratingKey not in seen_keys:
tracks.append((track, song))
seen_keys.add(track.ratingKey)
matched = True
break
if not matched:
missing.append({"title": song["title"], "artist": song["artist"], "reason": "artist-mismatch"})
matched_count = len(tracks)
added_count = 0
if tracks:
try:
try:
existing = plex.playlist(playlist_name)
existing_keys = {item.ratingKey for item in existing.items()}
new_tracks = []
for track, song in tracks:
if track.ratingKey in existing_keys:
duplicate_songs.append({"title": track.title, "artist": track.artist().title, "reason": "already-in-playlist"})
continue
new_tracks.append((track, song))
if new_tracks:
if not dry_run:
existing.addItems([item[0] for item in new_tracks])
for track, song in new_tracks:
track.addLabel("Journey FM")
track.rate(5)
added_songs.append(f"{track.title} by {track.artist().title} ({song['source']})")
else:
for track, song in new_tracks:
added_songs.append(f"{track.title} by {track.artist().title} ({song['source']})")
added_count = len(new_tracks)
except Exception:
if not dry_run:
plex.createPlaylist(playlist_name, [item[0] for item in tracks])
for track, song in tracks:
track.addLabel("Journey FM")
track.rate(5)
added_songs.append(f"{track.title} by {track.artist().title} ({song['source']})")
else:
for track, song in tracks:
added_songs.append(f"{track.title} by {track.artist().title} ({song['source']})")
added_count = len(tracks)
except Exception as exc:
raise PlexConnectionError(f"Failed updating playlist '{playlist_name}': {exc}") from exc
return {
"matched_count": matched_count,
"added_count": added_count,
"added_songs": added_songs,
"missing_songs": missing,
"duplicate_count": len(duplicate_songs),
"duplicate_songs": duplicate_songs,
"skipped_songs": skipped,
}
+452
View File
@@ -0,0 +1,452 @@
import logging
import os
import re
import shutil
import time
from datetime import datetime
from pathlib import Path
from bs4 import BeautifulSoup
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
from webdriver_manager.chrome import ChromeDriverManager
import requests
from journeyfm.paths import data_path
logger = logging.getLogger(__name__)
DEBUG_SCRAPE_DIR = data_path("debug_scrapes")
def detect_chrome_binary():
env_binary = os.getenv("CHROME_BINARY", "").strip()
if env_binary and os.path.exists(env_binary):
return env_binary
candidates = []
if os.name == "nt":
local_app_data = os.getenv("LOCALAPPDATA", "")
program_files = os.getenv("PROGRAMFILES", "")
program_files_x86 = os.getenv("PROGRAMFILES(X86)", "")
candidates.extend([
os.path.join(local_app_data, "Google", "Chrome", "Application", "chrome.exe"),
os.path.join(program_files, "Google", "Chrome", "Application", "chrome.exe"),
os.path.join(program_files_x86, "Google", "Chrome", "Application", "chrome.exe"),
os.path.join(program_files, "Chromium", "Application", "chrome.exe"),
os.path.join(program_files_x86, "Chromium", "Application", "chrome.exe"),
])
else:
candidates.extend([
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/opt/google/chrome/chrome",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
])
for binary in candidates:
if binary and os.path.exists(binary):
return binary
for name in ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"]:
found = shutil.which(name)
if found:
return found
return None
def detect_chromedriver_path():
env_driver = os.getenv("CHROMEDRIVER_PATH", "").strip()
if env_driver and os.path.exists(env_driver):
return env_driver
for candidate in ["/usr/bin/chromedriver", "/usr/local/bin/chromedriver"]:
if os.path.exists(candidate):
return candidate
return shutil.which("chromedriver")
def _write_debug_payload(station_key, payload, extension):
DEBUG_SCRAPE_DIR.mkdir(exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
file_path = DEBUG_SCRAPE_DIR / f"{station_key}_{timestamp}.{extension}"
with open(file_path, "w", encoding="utf-8") as file_handle:
file_handle.write(payload)
latest_path = DEBUG_SCRAPE_DIR / f"{station_key}_latest.{extension}"
with open(latest_path, "w", encoding="utf-8") as file_handle:
file_handle.write(payload)
return str(file_path)
class JourneyFMScraper:
station_key = "journey_fm"
display_name = "Journey FM"
url = "https://www.myjourneyfm.com/recently-played/"
SELECTOR_PATTERNS = [
{
"name": "rp-item",
"container": ("div", "rp-item"),
"title": ("h5", "song-title"),
"artist": ("p", "song-artist"),
},
{
"name": "top-rp-list",
"container": ("div", "song-item"),
"title": ("div", "title-artist"),
"artist": ("span", None),
},
]
@classmethod
def _extract_with_pattern(cls, soup, pattern):
songs = []
container_tag, container_class = pattern["container"]
title_tag, title_class = pattern["title"]
artist_tag, artist_class = pattern["artist"]
for item in soup.find_all(container_tag, class_=container_class):
if title_class:
title_elem = item.find(title_tag, class_=title_class)
else:
title_elem = item.find(title_tag)
if not title_elem:
continue
if pattern["name"] == "top-rp-list":
paragraph = title_elem.find("p")
if not paragraph:
continue
combined = paragraph.get_text(" ", strip=True)
parts = re.split(r"\s+by\s+", combined, maxsplit=1, flags=re.IGNORECASE)
if len(parts) != 2:
continue
title = parts[0].strip()
artist = parts[1].strip()
else:
if artist_class:
artist_elem = item.find(artist_tag, class_=artist_class)
else:
artist_elem = item.find(artist_tag)
if not artist_elem:
continue
title = title_elem.get_text(" ", strip=True).strip()
artist = artist_elem.get_text(" ", strip=True).strip()
if title and artist:
songs.append({"title": title, "artist": artist, "source": cls.display_name})
return songs
@classmethod
def parse_html_with_telemetry(cls, html):
soup = BeautifulSoup(html, "html.parser")
for pattern in cls.SELECTOR_PATTERNS:
songs = cls._extract_with_pattern(soup, pattern)
if songs:
return songs, pattern["name"]
return [], "none"
@staticmethod
def parse_html(html):
songs, _pattern = JourneyFMScraper.parse_html_with_telemetry(html)
return songs
@classmethod
def scrape(cls, driver):
driver.get(cls.url)
WebDriverWait(driver, 15).until(
lambda _driver: (
bool(_driver.find_elements(By.CSS_SELECTOR, "div.rp-item"))
or bool(_driver.find_elements(By.CSS_SELECTOR, "div.song-item"))
)
)
try:
more_button = WebDriverWait(driver, 5).until(EC.element_to_be_clickable((By.ID, "moreSongs")))
more_button.click()
time.sleep(2)
except Exception:
pass
html = driver.page_source
raw_path = _write_debug_payload(cls.station_key, html, "html")
songs, parse_pattern = cls.parse_html_with_telemetry(html)
return songs, raw_path, parse_pattern
@classmethod
def scrape_without_driver(cls):
response = requests.get(cls.url, timeout=20)
response.raise_for_status()
html = response.text
raw_path = _write_debug_payload(cls.station_key, html, "html")
songs, parse_pattern = cls.parse_html_with_telemetry(html)
return songs, raw_path, f"{parse_pattern}-requests-fallback"
class SpiritFMScraper:
station_key = "spirit_fm"
display_name = "Spirit FM"
url = "https://spiritfm.com/ajax/now_playing_history.txt"
@staticmethod
def parse_text(text):
songs = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
match = re.match(r"^\w+\s+\d+:\d+[AP]M\s+(.+?)\s+-\s+(.+)$", line)
if not match:
continue
artist = match.group(1).strip().replace("&amp;", "&")
title = match.group(2).strip().replace("&amp;", "&")
if title and artist:
songs.append({"title": title, "artist": artist, "source": SpiritFMScraper.display_name})
return songs
@classmethod
def scrape(cls, driver):
driver.get(cls.url)
time.sleep(2)
text = BeautifulSoup(driver.page_source, "html.parser").get_text()
raw_path = _write_debug_payload(cls.station_key, text, "txt")
return cls.parse_text(text), raw_path, "plain-text-regex"
@classmethod
def scrape_without_driver(cls):
response = requests.get(cls.url, timeout=20)
response.raise_for_status()
text = response.text
raw_path = _write_debug_payload(cls.station_key, text, "txt")
return cls.parse_text(text), raw_path, "plain-text-regex-requests-fallback"
class KLOVEScraper:
station_key = "klove"
display_name = "K-LOVE"
url = "https://www.klove.com/music/songs"
# K-LOVE is a JS-rendered React app; Selenium is the primary path.
# The requests fallback may still return partial SSR content worth parsing.
_LOAD_SELECTOR = "a[href*='/music/artists/']"
@staticmethod
def parse_html(html):
songs, _pattern = KLOVEScraper.parse_html_with_telemetry(html)
return songs
@staticmethod
def parse_html_with_telemetry(html):
soup = BeautifulSoup(html, "html.parser")
songs = []
seen = set()
# Collect every anchor that points at /music/artists/{artist}/{song}
# The page renders each card as two anchors (art + title) — use hrefs
# as a dedup key so we only process each song once.
processed_hrefs = set()
for anchor in soup.find_all("a", href=re.compile(r"/music/artists/[^/]+/[^/]+")):
href = anchor.get("href", "").strip().rstrip("/")
parts = href.strip("/").split("/")
# Expect: music / artists / artist-slug / song-slug
if len(parts) < 4:
continue
title_text = anchor.get_text(" ", strip=True)
if not title_text or len(title_text) < 2:
# Image-only anchor (cover art link) — skip it; the title anchor
# for this same href will appear later in the DOM with real text.
continue
# Now that we have a text-bearing anchor, dedup by href
if href in processed_hrefs:
continue
processed_hrefs.add(href)
# Skip navigation / album-only links (e.g. artist page without song)
song_slug = parts[-1]
artist_slug = parts[-2] if len(parts) >= 4 else ""
# Find "By {artist}" text in nearby DOM — check siblings and parent
artist_text = ""
parent = anchor.parent
if parent:
full_text = parent.get_text(" ", strip=True)
by_match = re.search(r"\bBy\s+(.+)", full_text)
if by_match:
artist_text = by_match.group(1).strip()
if not artist_text and parent and parent.parent:
grandparent_text = parent.parent.get_text(" ", strip=True)
by_match = re.search(r"\bBy\s+(.+?)(?:\bBy\b|$)", grandparent_text)
if by_match:
artist_text = by_match.group(1).strip()
if not artist_text:
# Derive artist from URL slug as last resort
artist_text = " ".join(w.capitalize() for w in artist_slug.split("-"))
# Strip leftover boilerplate that sometimes bleeds in
artist_text = re.sub(r"\s*(feat\.|ft\.|featuring|Play Sample|Image).*$", "",
artist_text, flags=re.IGNORECASE).strip()
if title_text and artist_text:
key = (title_text.lower(), artist_text.lower())
if key not in seen:
seen.add(key)
songs.append({"title": title_text, "artist": artist_text,
"source": KLOVEScraper.display_name})
if songs:
return songs, "klove-anchor-by"
return [], "none"
@classmethod
def scrape(cls, driver):
driver.get(cls.url)
# Wait for song anchor links to appear (JS-rendered)
WebDriverWait(driver, 20).until(
EC.presence_of_element_located((By.CSS_SELECTOR, cls._LOAD_SELECTOR))
)
time.sleep(2) # Allow lazy-load tiles to settle
html = driver.page_source
raw_path = _write_debug_payload(cls.station_key, html, "html")
songs, parse_pattern = cls.parse_html_with_telemetry(html)
return songs, raw_path, parse_pattern
@classmethod
def scrape_without_driver(cls):
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
)
}
response = requests.get(cls.url, headers=headers, timeout=20)
response.raise_for_status()
html = response.text
raw_path = _write_debug_payload(cls.station_key, html, "html")
songs, parse_pattern = cls.parse_html_with_telemetry(html)
return songs, raw_path, f"{parse_pattern}-requests-fallback"
SCRAPERS = {
JourneyFMScraper.station_key: JourneyFMScraper,
SpiritFMScraper.station_key: SpiritFMScraper,
KLOVEScraper.station_key: KLOVEScraper,
}
def build_driver():
options = Options()
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--disable-gpu")
options.add_argument("--window-size=1920,1080")
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_argument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option("useAutomationExtension", False)
chrome_binary = detect_chrome_binary()
if chrome_binary:
options.binary_location = chrome_binary
chromedriver_path = detect_chromedriver_path()
if chromedriver_path:
service = Service(chromedriver_path)
else:
service = Service(ChromeDriverManager().install())
return webdriver.Chrome(service=service, options=options)
def scrape_recently_played(selected_stations=None):
selected_stations = selected_stations or ["journey_fm", "spirit_fm"]
driver = None
driver_start_error = ""
all_songs = []
seen = set()
station_results = []
try:
try:
driver = build_driver()
except Exception as exc:
driver = None
driver_start_error = str(exc)
logger.warning("Browser scraper unavailable; using fallback mode: %s", exc)
for station_key in selected_stations:
scraper_cls = SCRAPERS.get(station_key)
if scraper_cls is None:
station_results.append({
"station": station_key,
"display_name": station_key,
"success": False,
"error": "unsupported-station",
"scraped_count": 0,
"raw_path": "",
})
continue
try:
if driver is not None:
try:
songs, raw_path, parse_pattern = scraper_cls.scrape(driver)
except Exception as scrape_error:
logger.warning(
"Selenium scrape failed for %s, retrying fallback mode: %s",
scraper_cls.display_name,
scrape_error,
)
songs, raw_path, parse_pattern = scraper_cls.scrape_without_driver()
else:
songs, raw_path, parse_pattern = scraper_cls.scrape_without_driver()
raw_payload_bytes = 0
if raw_path and os.path.exists(raw_path):
try:
raw_payload_bytes = os.path.getsize(raw_path)
except Exception:
raw_payload_bytes = 0
added_for_station = 0
for song in songs:
dedupe_key = (song["title"].lower(), song["artist"].lower())
if dedupe_key in seen:
continue
seen.add(dedupe_key)
all_songs.append(song)
added_for_station += 1
logger.info(" Found: %s by %s", song["title"], song["artist"])
station_results.append({
"station": station_key,
"display_name": scraper_cls.display_name,
"success": True,
"error": "",
"scraped_count": added_for_station,
"raw_path": raw_path,
"raw_payload_bytes": raw_payload_bytes,
"parse_pattern": parse_pattern,
})
except Exception as exc:
combined_error = str(exc)
if driver is None and driver_start_error:
combined_error = f"{combined_error}; browser init: {driver_start_error}"
station_results.append({
"station": station_key,
"display_name": scraper_cls.display_name,
"success": False,
"error": combined_error,
"scraped_count": 0,
"raw_path": "",
"raw_payload_bytes": 0,
"parse_pattern": "error",
})
logger.error("Error scraping %s: %s", scraper_cls.display_name, exc)
finally:
if driver is not None:
driver.quit()
logger.info("Total unique songs found: %s", len(all_songs))
return {"songs": all_songs, "station_results": station_results}
+138
View File
@@ -0,0 +1,138 @@
import logging
import os
import urllib.parse
from journeyfm.config_store import load_runtime_config
from journeyfm.history_service import init_history_db, save_history_entry
from journeyfm.paths import data_path
from journeyfm.plex_service import PlexConnectionError, connect_to_plex_server, create_or_update_playlist
from journeyfm.scraper_service import scrape_recently_played
logger = logging.getLogger(__name__)
def update_buy_list(missing_songs, buy_list_path=None):
buy_list_path = buy_list_path or data_path("amazon_buy_list.txt")
if not missing_songs:
logger.info("No new songs to add to buy list.")
return []
existing_songs = set()
if os.path.exists(buy_list_path):
try:
with open(buy_list_path, "r", encoding="utf-8") as file_handle:
lines = file_handle.read().splitlines()
for line in lines:
if line.strip() and not line.startswith("http") and " - " in line:
artist, title = line.split(" - ", 1)
existing_songs.add((artist.strip(), title.strip()))
except Exception:
pass
new_missing = []
for song in missing_songs:
key = (song["artist"], song["title"])
if key in existing_songs:
continue
existing_songs.add(key)
new_missing.append(song)
if not new_missing:
logger.info("No new songs to add to buy list.")
return []
file_exists = os.path.exists(buy_list_path)
file_empty = not file_exists or os.path.getsize(buy_list_path) == 0
with open(buy_list_path, "a", encoding="utf-8") as file_handle:
if file_empty:
file_handle.write("Songs not in your library - Amazon search links:\n\n")
for song in new_missing:
query = urllib.parse.quote(f"{song['artist']} {song['title']}")
file_handle.write(f"{song['artist']} - {song['title']}\n")
file_handle.write(f"https://www.amazon.com/s?k={query}&i=digital-music\n\n")
logger.info("Buy list updated.")
return new_missing
def format_result_summary(result):
station_bits = []
for station in result.get("station_breakdown", []):
if station.get("success"):
pattern = station.get("parse_pattern", "unknown")
payload_bytes = int(station.get("raw_payload_bytes", 0) or 0)
payload_kib = payload_bytes / 1024.0
station_bits.append(
f"{station['display_name']}: {station['scraped_count']} [{pattern}, {payload_kib:.1f} KiB]"
)
else:
station_bits.append(f"{station['display_name']}: failed")
station_summary = ", ".join(station_bits) or "No station data"
lines = [
f"Scraped {result.get('scraped_count', 0)} songs ({station_summary})",
f"Matched {result.get('matched_count', 0)} in Plex",
f"Added {result.get('added_count', 0)} to playlist",
f"Skipped {result.get('skipped_count', 0)} invalid entries",
f"Suppressed {result.get('duplicate_count', 0)} duplicates already in playlist",
f"Missing {result.get('missing_count', 0)} from Plex library",
]
if result.get("status") == "error":
lines.append(f"Error: {result.get('error_message', 'Unknown error')}")
return "\n".join(lines)
def run_update_job(config=None, dry_run=False, persist_history=True, write_buy_list=True):
config = config or load_runtime_config()
result = {
"status": "success",
"error_message": "",
"station_breakdown": [],
"scraped_count": 0,
"matched_count": 0,
"added_count": 0,
"added_songs": [],
"missing_count": 0,
"missing_songs": [],
"duplicate_count": 0,
"duplicate_songs": [],
"skipped_count": 0,
"skipped_songs": [],
}
token = config.get("PLEX_TOKEN", "").strip()
server_ip = config.get("SERVER_IP", "").strip()
playlist_name = config.get("PLAYLIST_NAME", "Journey FM Recently Played").strip()
selected_stations = config.get("SELECTED_STATIONS", ["journey_fm", "spirit_fm"])
if not token or not server_ip:
result["status"] = "error"
result["error_message"] = "Missing configuration: set Plex token and server IP before running updates"
return result
if persist_history:
init_history_db()
scrape_result = scrape_recently_played(selected_stations)
songs = scrape_result["songs"]
result["station_breakdown"] = scrape_result["station_results"]
result["scraped_count"] = len(songs)
try:
plex = connect_to_plex_server(token, server_ip)
playlist_result = create_or_update_playlist(plex, songs, playlist_name, dry_run=dry_run)
result.update(playlist_result)
result["missing_count"] = len(result.get("missing_songs", []))
result["skipped_count"] = len(result.get("skipped_songs", []))
if write_buy_list and not dry_run:
update_buy_list(result.get("missing_songs", []))
except PlexConnectionError as exc:
result["status"] = "error"
result["error_message"] = str(exc)
except Exception as exc:
result["status"] = "error"
result["error_message"] = f"Unexpected update failure: {exc}"
if persist_history:
save_history_entry(result)
return result