Some prep work needs to be done to make the individual hymn pdf files into a combined pdf compatible with this CSV file.
- The zip file needs to be extracted to a new directory.
- The zip file of pdfs is missing several songs that the CSVFile has entries for. (Hymns number 12, 15, 54, 62, 82, 86, 124, 143, 219, 284, and 299) These need to be scanned and added to the new directory. They also need to be named so that they end up in the right spots when the PDF files are combined.
- Currently, the PDF of hymn 160 has an extra page. It needs to be deleted.
- Finally, all of the individual pdfs need to be combined into one large pdf with all of the hymns in order. The combined PDF should be 431 pages long. (Not to be confused with the 341 hymns in the book).
At this point the CSV file can be used to import the combined PDF file. The albums and collections that the hymnbook is automatically added to can be set to 'manual' sort and all of the hymns will be in order (1-341)
A few items to note:
- Tune names have been added to the 'albums' category
- Meters have been added to the 'collections' category. I may have missed a few but most should be there.
- The hymn number has been included at the end of the title (i.e. Hymn Title - #) to make it easier to look up hymns by number.
- The tempo data is the low end of the range indicated in the music.
- The duration is approximately the time it takes to sing/play all of the verses (shown in the staff) at the low tempo setting with a 15sec intro.
One final thing (for those who have already scanned their hardcopy hymnbook):
Four additional columns of page range data has been added to the CSV file to accommodate people with scans of hardcopy books. These ranges will not import by default.
To use them, the headings need to be renamed.
- rename 'pages' heading to something MSP won't recognizes like 'Not Used pages'
- if hymn #1 starts on page 1 of the scanned PDF, rename the heading just to the right of 'duration' ('HB Hymn1 at pg1') to 'pages' (last hymn should be on page 372)
- if hymn #1 starts on page 7, 9, or 14, rename the appropriate heading (in any of the next three columns) to 'pages' (last hymn on page 378, 380, or 385, respectively)
- make sure there is one and only one heading labeled 'pages'
- save the changes to the CSV file
The modified CSV file can then be used to import the songs from a scanned copy of the Hymnbook.
Hope this helps. Let me know if any typos are found.
03-14-2026, 04:43 AM (This post was last modified: 03-14-2026, 06:47 AM by zediiiii.
Edit Reason: Updated code and added readme
)
I've just created a script that pulls ALL of the Children's friend music with nice names. You'll need Python and the dependencies listed at the top to run it. Use your favorite ai helper if you can get the code to run.
DOWNLOADER
Code:
"""
Church of Jesus Christ - Friend Music Sheet Music Downloader
Naming convention: YYYY-MM-Song Title.pdf / YYYY-MM-Song Title Simplified.pdf
Also writes a MobileSheets-compatible CSV for import.
"""
import time
import os
import re
import sys
import csv
import requests
import logging
from pypdf import PdfReader
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, WebDriverException
from webdriver_manager.chrome import ChromeDriverManager
# ---------------------------------------------------------------------------
# Logging - explicit UTF-8 on both handlers to prevent cp1252 crash on Windows
# ---------------------------------------------------------------------------
_fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
_file_handler = logging.FileHandler("downloader.log", encoding="utf-8")
_file_handler.setFormatter(_fmt)
_con_handler = logging.StreamHandler(stream=open(
sys.stdout.fileno(), mode='w', encoding='utf-8', closefd=False
))
_con_handler.setFormatter(_fmt)
log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
log.addHandler(_file_handler)
log.addHandler(_con_handler)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
COLLECTION_URL = (
"https://www.churchofjesuschrist.org/media/music/collections/"
"music-from-the-friend?lang=eng"
)
DOWNLOAD_DIR = os.path.join(os.getcwd(), "sheet_music")
CSV_PATH = os.path.join(os.getcwd(), "friend_music_mobilesheets.csv")
PAGE_LOAD_WAIT = 12 # seconds to wait for page elements
DOWNLOAD_RETRY = 3 # attempts per file before giving up
SONG_DELAY = 1.5 # seconds between songs
# MobileSheets CSV columns - only fields we have data for.
# 'filename' is the relative path to the PDF from wherever MobileSheets
# is pointed. Adjust the DOWNLOAD_DIR name if you move the files.
CSV_FIELDNAMES = ['title', 'pages', 'years', 'collections', 'source_types', 'filename']
COLLECTION_NAME = "Friend Music"
SOURCE_TYPE = "churchofjesuschrist.org"
MONTH_MAP = {
'january': 1, 'february': 2, 'march': 3, 'april': 4,
'may': 5, 'june': 6, 'july': 7, 'august': 8,
'september': 9, 'october': 10, 'november': 11, 'december': 12,
'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4,
'jun': 6, 'jul': 7, 'aug': 8, 'sep': 9, 'sept': 9,
'oct': 10, 'nov': 11, 'dec': 12,
}
DATE_RE = re.compile(
r'\b(january|february|march|april|may|june|july|august|september|'
r'october|november|december|jan|feb|mar|apr|jun|jul|aug|sept?|oct|nov|dec)'
r'\b[^\d]*(\d{4})',
re.IGNORECASE
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def sanitize_filename(name: str) -> str:
name = re.sub(r'[<>:"/\\|?*]', '', name)
name = re.sub(r'\s+', ' ', name).strip()
return name[:180]
def slug_to_title(slug: str) -> str:
slug = re.sub(r'-\d{4}$', '', slug)
return slug.replace('-', ' ').title()
def parse_date(text: str) -> tuple[int, int]:
match = DATE_RE.search(text)
if match:
return int(match.group(2)), MONTH_MAP.get(match.group(1).lower(), 0)
return 0, 0
def count_pdf_pages(filepath: str) -> int:
"""Return the number of pages in a PDF, or 1 on any error."""
try:
return len(PdfReader(filepath).pages)
except Exception:
return 1
def write_csv_row(writer, song: dict, filename: str, filepath: str):
"""Append one row to the MobileSheets CSV."""
page_count = count_pdf_pages(filepath)
page_range = f"1-{page_count}"
# Relative path: just the folder/filename so MobileSheets can find it
# regardless of drive letter. Adjust if your MobileSheets root differs.
relative_path = os.path.join("sheet_music", filename).replace("\\", "/")
writer.writerow({
'title': song['title'],
'pages': page_range,
'years': str(song['year']) if song['year'] > 0 else '',
'collections': COLLECTION_NAME,
'source_types': SOURCE_TYPE,
'filename': relative_path,
})
# ---------------------------------------------------------------------------
# Driver
# ---------------------------------------------------------------------------
def build_driver() -> webdriver.Chrome:
options = Options()
# options.add_argument("--headless=new") # uncomment to run invisibly
options.add_argument("--disable-gpu")
options.add_argument("--window-size=1920,1080")
options.add_argument("--log-level=3")
return webdriver.Chrome(
service=Service(ChromeDriverManager().install()), options=options
)
# ---------------------------------------------------------------------------
# Catalog building
# ---------------------------------------------------------------------------
def extract_song_metadata(driver) -> list[dict]:
script = """
let seen = new Set();
let results = [];
document.querySelectorAll("a[href*='/media/music/songs/']").forEach(link => {
let url = link.href.split('?')[0];
if (seen.has(url)) return;
seen.add(url);
let card = link.closest('li, article, div[class*="card"], div[class*="item"]');
let cardText = card ? card.textContent : link.parentElement.textContent;
results.push({ url: url, cardText: cardText.toLowerCase() });
});
return results;
"""
return driver.execute_script(script)
def build_catalog(raw_songs: list[dict]) -> dict:
catalog = {}
for item in raw_songs:
url = item['url']
card_text = item['cardText']
slug = url.split('/')[-1]
is_simplified = 'simplified' in card_text or 'simplified' in slug
title = sanitize_filename(slug_to_title(slug))
if is_simplified:
title = re.sub(r'\bSimplified\b', '', title, flags=re.IGNORECASE).strip()
year, month = parse_date(card_text)
key = f"{slug}-simplified" if is_simplified else slug
entry = {
'url': url, 'slug': slug, 'title': title,
'year': year, 'month': month, 'is_simplified': is_simplified,
}
if key in catalog:
ex = catalog[key]
if year > ex['year'] or (year == ex['year'] and month > ex['month']):
catalog[key] = entry
else:
catalog[key] = entry
log.info(f"Catalog built: {len(catalog)} unique songs.")
return catalog
def build_filename(song: dict) -> str:
y = f"{song['year']:04d}" if song['year'] > 0 else "0000"
m = f"{song['month']:02d}" if song['month'] > 0 else "00"
suffix = " Simplified" if song['is_simplified'] else ""
return f"{y}-{m}-{song['title']}{suffix}.pdf"
# ---------------------------------------------------------------------------
# Download
# ---------------------------------------------------------------------------
def sync_cookies(driver, session: requests.Session):
session.cookies.clear()
for c in driver.get_cookies():
session.cookies.set(c['name'], c['value'], domain=c.get('domain', ''))
def find_pdf_url_in_source(driver) -> str | None:
source = driver.page_source
SKIP = ('privacy', 'terms', 'legal', 'policy', 'help', '/imgs/', 'image')
abs_matches = re.findall(
r'["\' ](https?://[^"\'<>\s]+\.pdf)["\' \s,<]',
source, re.IGNORECASE
)
rel_matches = re.findall(
r'["\'] (/[^"\'<>\s]+\.pdf)["\'\s,<]',
source, re.IGNORECASE
)
all_urls = abs_matches + [
f"https://www.churchofjesuschrist.org{p}" for p in rel_matches
]
filtered = [u for u in all_urls if not any(s in u.lower() for s in SKIP)]
cdn = [u for u in filtered if 'assets.churchofjesuschrist.org' in u]
return cdn[0] if cdn else (filtered[0] if filtered else None)
def find_pdf_url_in_dom(driver) -> str | None:
return driver.execute_script("""
function allAnchors(root) {
let found = [];
root.querySelectorAll('*').forEach(el => {
if (el.tagName === 'A' && el.href) found.push(el);
if (el.shadowRoot) found = found.concat(allAnchors(el.shadowRoot));
});
return found;
}
let anchors = allAnchors(document);
// Strategy 1: sheet+pdf text AND href must actually contain .pdf
for (let a of anchors) {
let txt = (a.textContent || '').toLowerCase();
let href = a.href.toLowerCase();
if (txt.includes('sheet') && txt.includes('pdf') && href.includes('.pdf')) {
return a.href;
}
}
// Strategy 2: any href with .pdf
for (let a of anchors) {
if (a.href.toLowerCase().includes('.pdf')) return a.href;
}
return null;
""")
def get_page_title(driver) -> str | None:
"""
Return only the first non-empty line of the H1.
The site appends arranger/copyright names as a second line inside the
same H1 element e.g. "Come Come Ye Saints\nRichards". Taking only the
first line strips those trailing names cleanly.
"""
return driver.execute_script("""
let h = document.querySelector('h1');
if (!h) return null;
let lines = h.textContent.split(/[\\n\\r]+/).map(l => l.trim()).filter(l => l);
return lines.length > 0 ? lines[0] : null;
""")
def download_pdf(session: requests.Session, pdf_url: str, filepath: str) -> bool:
for attempt in range(1, DOWNLOAD_RETRY + 1):
try:
r = session.get(pdf_url, timeout=30, stream=True)
if r.status_code == 200:
with open(filepath, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
return True
log.warning(f" Attempt {attempt}: HTTP {r.status_code}")
except requests.RequestException as e:
log.warning(f" Attempt {attempt}: Network error - {e}")
time.sleep(2 ** attempt)
return False
def safe_get(driver_ref: list, url: str):
driver = driver_ref[0]
try:
driver.get(url)
except WebDriverException as e:
log.warning(f"WebDriver died ({e}). Restarting Chrome...")
try:
driver.quit()
except Exception:
pass
driver_ref[0] = build_driver()
driver_ref[0].get(url)
def process_songs(driver_ref: list, catalog: dict, csv_writer):
session = requests.Session()
session.headers.update({
'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'
)
})
total = len(catalog)
for idx, (key, song) in enumerate(catalog.items(), 1):
safe_get(driver_ref, song['url'])
driver = driver_ref[0]
# Resolve authoritative title from H1
page_title = get_page_title(driver)
if page_title:
clean = sanitize_filename(page_title)
if clean and clean.lower() not in ('simplified', ''):
clean = re.sub(
r'\s*[-]\s*simplified\s*$', '', clean, flags=re.IGNORECASE
).strip()
song['title'] = clean
filename = build_filename(song)
filepath = os.path.join(DOWNLOAD_DIR, filename)
log.info(f"[{idx}/{total}] {filename}")
# If already downloaded, still write CSV row (idempotent re-runs)
if os.path.exists(filepath) and os.path.getsize(filepath) > 0:
log.info(" [SKIP] Already downloaded")
write_csv_row(csv_writer, song, filename, filepath)
continue
sync_cookies(driver, session)
try:
btn = WebDriverWait(driver, PAGE_LOAD_WAIT).until(
EC.presence_of_element_located((By.XPATH,
"//button[contains("
"translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')"
", 'download')]"
))
)
driver.execute_script("arguments[0].click();", btn)
def get_pdf(d):
return find_pdf_url_in_dom(d) or find_pdf_url_in_source(d)
pdf_url = WebDriverWait(driver, PAGE_LOAD_WAIT).until(get_pdf)
log.debug(f" PDF URL: {pdf_url[:100]}")
success = download_pdf(session, pdf_url, filepath)
if success:
log.info(" [OK] Saved")
write_csv_row(csv_writer, song, filename, filepath)
else:
log.error(f" [FAIL] Download failed after {DOWNLOAD_RETRY} attempts")
if os.path.exists(filepath):
os.remove(filepath)
except TimeoutException:
log.info(" [SKIP] No PDF found (likely MP3-only song)")
except WebDriverException as e:
log.warning(f" [CRASH] WebDriver error - restarting Chrome. Detail: {e}")
try:
driver.quit()
except Exception:
pass
driver_ref[0] = build_driver()
except Exception as e:
log.error(f" [ERROR] {song['slug']}: {e}")
time.sleep(SONG_DELAY)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
log.info(f"Download directory: {DOWNLOAD_DIR}")
log.info(f"CSV output: {CSV_PATH}")
driver_ref = [build_driver()]
# Open CSV once - newline='' is required by Python's csv module on Windows
with open(CSV_PATH, 'w', newline='', encoding='utf-8') as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDNAMES)
writer.writeheader()
try:
log.info("Loading collection page...")
driver_ref[0].get(COLLECTION_URL)
time.sleep(3)
raw_songs = extract_song_metadata(driver_ref[0])
log.info(f"Raw links found: {len(raw_songs)}")
catalog = build_catalog(raw_songs)
process_songs(driver_ref, catalog, writer)
finally:
try:
driver_ref[0].quit()
except Exception:
pass
log.info(f"Done. PDFs in 'sheet_music/' | CSV at '{CSV_PATH}'")
if __name__ == "__main__":
main()
Code:
# Friend Music Sheet Music Downloader README
Downloads sheet music PDFs from [The Friend](https://www.churchofjesuschrist.org/media/music/collections/music-from-the-friend?lang=eng) and builds a MobileSheets import file.
---
## What You Need First
### 1. Python 3.10 or newer
Download from https://www.python.org/downloads/
During install, **check the box that says "Add Python to PATH"** — this is easy to miss.
### 2. Google Chrome
Must already be installed. The scripts control Chrome automatically.
### 3. Required Python packages
Open **PowerShell** or **Command Prompt** in the grabber folder and run:
```
pip install selenium webdriver-manager requests pypdf
```
---
## The Scripts
| File | What it does |
|---|---|
| `grabber.py` | Downloads all PDFs |
| `rename_files.py` | Fixes existing filenames if they have arranger names in them |
---
## First Time: Full Download
1. Open **PowerShell** in the grabber folder
*(Right-click inside the folder → "Open in Terminal" or "Open PowerShell window here")*
2. Run:
```
.\grabber.py
```
3. A Chrome window will open and run automatically. **Don't close it.**
It visits every song page and downloads the PDF. With ~350 songs this takes **1-2 hours**.
4. When finished you'll have:
- A `sheet_music/` folder full of PDFs named like `2026-02-The Day That Changed Everything.pdf`
---
---