README has started! Added some instructions and limitations to be aware of with the app. Add more logging messages. Added some testing functions to test the various webhook and user states that may be configured. Added an environmental variable for downloading some or all songs. Unfortunately, due to the various ways certain songs may be named (see comments and README), this is proving harder than usual. Added a function for applying the metadata to the final downloaded songs so that it can be picked up by Navidrome/Subsonic API. Split up a few functions into smaller functions. Tried adding some better logging and messaging around songs that are marked as non-downloadable and other messages.

This commit is contained in:
Norm Rasmussen
2025-06-18 21:17:32 -04:00
parent b8c9bb0ad1
commit 721f27fe08
7 changed files with 229 additions and 39 deletions

View File

@ -1,3 +1,4 @@
# Please go to https://api.planningcenteronline.com/oauth/applications # Please go to https://api.planningcenteronline.com/oauth/applications
# Create new Personal Access Token # Create new Personal Access Token
# Paste them below # Paste them below
@ -8,7 +9,16 @@ SECRET = ""
# Just grab the numbers, not the two letters before it. # Just grab the numbers, not the two letters before it.
USER_ID = "" USER_ID = ""
# DIR is the final resting place of your music files from PCO. Should be accessible on the same device/VM as this flask app is running. # DIR is the final resting place of your music files from PCO. Should be accessible on the same device/VM as this flask app is running.
# TODO: Add ssh possibility # TODO: Add option for transferring files between services via ssh
DIR = "" DIR = "/path/to/your/music"
# Options include DEBUG, INFO, ERROR # Options include DEBUG, INFO
# DEBUG will print the entire payloads to log files.
LOGLEVEL = "INFO" LOGLEVEL = "INFO"
# Download All Files or only the ones that match Track Names? default=false
# WARN: For the time being, keep ALLFILESBOOK as True as I am unsure what will happen with False.
# While the goal was to give end users the option to only download specified tracks, there doesn't seem to be consistency in the file names to make this possible. My church has stuff like "PTTL" for Praise to the Lord (full band) and "IKAN_EG1_rehearsal" for I Know a Name - Guitar 1 rehearsal track. Plus, we don't use attachment_types, which is where a PCO admin might administer instruments or otherwise. I can't see right now how I'd scale this across the many churches implementing PCO.
# TODO: Add option for specific instrument files to download.
ALLFILESBOOL = "True"
# Enter the artist name you want for the id3v2 tag so that the Subsonic API picks it up.
ARTIST = ""

38
README.md Normal file
View File

@ -0,0 +1,38 @@
# Download Songs and Create Playlist for your upcoming Planning Center Service
<------ THIS IS UNDER ACTIVE DEVELOPMENT AND NOT EVERYTHING HAS BEEN TESTED. I'VE BEEN PUSHING COMMITS AS I GO TO ENSURE I CAN ROLL BACK TO A WORKING COMMIT ---------->
## Background and Reasoning
Honestly, this thing may be overkill. But I'm into DIY and Self-Hosted stuff and I listen to a lot of my music from my local machine/server using Navidrome & Music Assistant. Instead of opening PCO every time I want to listen to music
casually - not practice - I want the music to be in the app that I'm already using.
This has been tested on Unix systems (built on Mac, Music Assistant server on Linux), so I am not sure if this will work at all on Windows.
## How to set up
1. Login to [PLanning Center's Developer Account](https://api.planningcenteronline.com/oauth/applications).
2. Create a Personal Access Token as this is just for you and you may also not be an Org Admin.
4. Enter your Client ID and Secret into the `.env` file under appropriate variables.
5. On your machine of choice either [build from source](#buildfromsource) or [run in a Docker container](#docker).
## Build from Source
COMING SOON
## Docker
COMING SOON
## Key things to Consider! Important!
I don't work for a church nor have admin access to a PCO organization. I am just on the worship team. That means there are some key things that my church does that yours may not.
* When looking for media attachments, there are a few `filetype` values that can be present. This application is just looking for `audio` within that value. If your church uses something different, please open an issue or create a PR if you'd like to contribute to this yourself.
* I am one person so I am just looking for one user in PCO! (See `env.example` file)
* There is this value in the attachments payload: `services.Plan.Attachments.attributes.allow_mp3_download`. If an attachment is not an audio file type (i.e Chord Charts), then the value is `False` and I'm skipping over it. I don't plan on adding functionality for downloading other file types.
* The application is checking if the incoming webhook's `service_id` and `plan_id` exist under the `services.PlanPerson` object. In other words, if you are part of a service and plan in PCO and the plan was updated, the application will continue running. There is no current functionality to grab files from plans or services you're not part of.
* That being said, I did some testing with non-user specific services and plans, so I may add that as a boolean.
* Recently, my church has begun inserting instrument-specific rehearsal tracks. I've added an `.env` variable (`ALLFILESBOOL`) that you can use if you'd like to download all the rehearsal tracks or just the main/full-band track.
* Please leave that boolean to True. False isn't quite working yet as there seems to be little consistency in file names and my church isn't using `attachment_types`.
* I tested this with the few payload types that could apply to my use-case. If there are other unique use-cases that apply to you, please open an issue and I'm happy to try and include it. My tests were:
* My service in `PlanPerson` without attachments
* My service in `PlanPerson` with attachments
* My service in `PlanPerson` with future plan
* My service in `PlanPerson` with past plan
* My service not in `PlanPerson`

Binary file not shown.

15
envvars.py Normal file
View File

@ -0,0 +1,15 @@
from dotenv import load_dotenv
import os
load_dotenv(override=True)
# Environmental Variables
CLIENTSEC = os.getenv("CLIENT")
SECRET = os.getenv("SECRET")
USER_ID = os.getenv("USER_ID")
SAVE_DIR = os.getenv("DIR")
BASE = "https://api.planningcenteronline.com"
LOGLEVEL = os.getenv("LOGLEVEL")
ALLFILES = os.getenv("ALLFILESBOOL")
ARTIST = os.getenv("ARTIST")

174
main.py
View File

@ -1,22 +1,50 @@
import requests import requests
import os
import subprocess
import json import json
import pprint import pprint
from flask import Flask, request from flask import Flask, request
from dotenv import load_dotenv
import os
from utilfuncs import Utils from utilfuncs import Utils
from envvars import (
CLIENTSEC,
SECRET,
USER_ID,
SAVE_DIR,
BASE,
LOGLEVEL,
ALLFILES,
ARTIST,
)
load_dotenv(override=True) # Instantiating libraries and other parameters
app = Flask(__name__) app = Flask(__name__)
pp = pprint.PrettyPrinter(indent=4) pp = pprint.PrettyPrinter(indent=4)
CLIENTSEC = os.getenv("CLIENT") app.json.compact = False
SECRET = os.getenv("SECRET")
USER_ID = os.getenv("USER_ID") # This function is for production.
SAVE_DIR = os.getenv("DIR") # @app.route("/webhook", methods=["POST"])
BASE = "https://api.planningcenteronline.com" # def accept_webhook():
LOGLEVEL = os.getenv("LOGLEVEL") # logging.info("Webhook page prepared and ready to receive webhook.")
# if request.method == "POST":
# logging.info("POST call received")
# payload = json.loads(request.json)
# logging.debug(f"***** DEBUG PAYLOAD *****\n{payload}\n************")
# try:
# service_date, webhook_ids = parse_payload(payload)
# if service_date:
# service_id, plan_id = get_my_plans(webhook_ids)
# if service_id is None or plan_id is None:
# pass
# else:
# return get_plans(service_id, plan_id, service_date)
# except TypeError as e:
# print(e)
# finally:
# return "End of app"
# This function is for testing and doesn't compare the user's plan/service ids to the webhook.
# TODO: Use the parts of this function that skip over `get_my_plans` as part of a env variable to allow users to download music outside of their assigned service and plans.
@app.route("/webhook", methods=["POST"]) @app.route("/webhook", methods=["POST"])
def accept_webhook(): def accept_webhook():
logging.info("Webhook page prepared and ready to receive webhook.") logging.info("Webhook page prepared and ready to receive webhook.")
@ -27,12 +55,13 @@ def accept_webhook():
try: try:
service_date, webhook_ids = parse_payload(payload) service_date, webhook_ids = parse_payload(payload)
if service_date: if service_date:
service_id, plan_id = get_my_plans(webhook_ids) service_id = webhook_ids[1]
if service_id is None or plan_id is None: plan_id = webhook_ids[0]
pass get_plans(service_id, plan_id, service_date)
else: return "Try loop completed"
return get_plans(service_id, plan_id, service_date)
except TypeError as e: except TypeError as e:
print(service_id, plan_id)
print("This is a type error")
print(e) print(e)
finally: finally:
return "End of app" return "End of app"
@ -40,27 +69,33 @@ def accept_webhook():
def parse_payload(payload): def parse_payload(payload):
for item in payload["data"]: for item in payload["data"]:
# event_type = item['attributes']['name'] # event_type lives at item['attributes']['name']
webhook_ids = ( webhook_ids = (
item["attributes"]["payload"]["data"]["id"], item["attributes"]["payload"]["data"]["id"],
item["attributes"]["payload"]["data"]["relationships"]["service_type"][ item["attributes"]["payload"]["data"]["relationships"]["service_type"][
"data" "data"
]["id"], ]["id"],
) )
# Webhook IDs tuple are: (plan id, service id)
service_date = item["attributes"]["payload"]["data"]["attributes"]["dates"] service_date = item["attributes"]["payload"]["data"]["attributes"]["dates"]
# Check if service date is in the future before proceeding # This is just for testing purposes as the date had multiple rehearsal tracks
if not Utils.is_future_date(service_date): if service_date == "June 15, 2025":
logging.info(f"Service date {service_date} is in the past. Exiting.") print(f"Special consideration for {service_date}")
return f"Service date {service_date} is in the past. Exiting." else:
# Check if service date is in the future before proceeding
if not Utils.is_future_date(service_date):
logging.info(f"Service date {service_date} is in the past. Exiting.")
return f"Service date {service_date} is in the past. Exiting."
return service_date, webhook_ids return service_date, webhook_ids
def get_my_plans(incoming_ids): def get_my_plans(incoming_ids):
""" """
This is the url for all the plans the user is part of This is the url for all the plans the user is part of
""" """
url = f"{BASE}/services/v2/people/{USER_ID}/plan_people" url = f"{BASE}/services/v2/people/{USER_ID}/plan_people"
# TODO: Move all calls to the utils file
data = requests.get(url, auth=(CLIENTSEC, SECRET)).json() data = requests.get(url, auth=(CLIENTSEC, SECRET)).json()
plan_ids = [] plan_ids = []
for items in data["data"]: for items in data["data"]:
@ -77,20 +112,25 @@ def get_my_plans(incoming_ids):
if plan == incoming_ids: if plan == incoming_ids:
return incoming_ids[1], incoming_ids[0] return incoming_ids[1], incoming_ids[0]
else: else:
return None return "Plan doesn't exist in PlanPerson payloads"
else: else:
if plan_ids[0] == incoming_ids: if plan_ids[0] == incoming_ids:
return incoming_ids[1], incoming_ids[0] return incoming_ids[1], incoming_ids[0]
else: else:
return None return "Plan doesn't exist in PlanPerson payloads"
def get_plans(service_type_id, plan_id, service_date): def get_plans(service_type_id, plan_id, service_date):
filenames = []
planurl = ( planurl = (
f"{BASE}/services/v2/service_types/{service_type_id}/plans/{plan_id}/items" f"{BASE}/services/v2/service_types/{service_type_id}/plans/{plan_id}/items"
) )
reqdata = requests.get(planurl, auth=(CLIENTSEC, SECRET)).json() reqdata = requests.get(planurl, auth=(CLIENTSEC, SECRET)).json()
logging.debug(reqdata)
get_file_key(reqdata, service_type_id, plan_id, planurl, service_date)
def get_file_key(reqdata, service_type_id, plan_id, planurl, service_date):
filenames = []
for items in reqdata["data"]: for items in reqdata["data"]:
if items["attributes"]["item_type"] == "song": if items["attributes"]["item_type"] == "song":
item_id = items["id"] item_id = items["id"]
@ -100,26 +140,55 @@ def get_plans(service_type_id, plan_id, service_date):
key = getkey["data"]["attributes"]["starting_key"] key = getkey["data"]["attributes"]["starting_key"]
filename = f"{items['attributes']['title']} - {key}" filename = f"{items['attributes']['title']} - {key}"
filenames.append(filename) filenames.append(filename)
planattachments = f"{BASE}/services/v2/service_types/{service_type_id}/plans/{plan_id}/items/{item_id}/media"
print(f"Here are the filenames: {filenames}")
get_attachments(filenames, service_type_id, plan_id, service_date)
def get_attachments(filenames, service_type_id, plan_id, service_date):
# I've added the payload to logging so please check this payload to see what your church will/won't allow.
planattachments = f"{BASE}/services/v2/service_types/{service_type_id}/plans/{plan_id}/all_attachments" planattachments = f"{BASE}/services/v2/service_types/{service_type_id}/plans/{plan_id}/all_attachments"
plana = requests.get(planattachments, auth=(CLIENTSEC, SECRET)).json() plana = requests.get(planattachments, auth=(CLIENTSEC, SECRET)).json()
pp.pprint(plana)
logging.info("Looking for attachments") logging.info("Looking for attachments")
non_downloadables = []
if plana["data"] == []: if plana["data"] == []:
logging.info(f"No attachments available for {service_date}") logging.info(f"No attachments available for {service_date}")
else: else:
logging.info(f"Attachments available for {service_date}") logging.info(f"Attachments available for {service_date}")
for attachments in plana["data"]: for attachments in plana["data"]:
if attachments["attributes"]["content_type"] == "audio/mpeg": if attachments["attributes"]["allow_mp3_download"]:
# attachment_id = attachments["id"] if (
file = attachments["attributes"]["filename"] attachments["attributes"]["content_type"] is not None
# close_match = Utils.find_close_matches(file, filenames) and "audio" in attachments["attributes"]["content_type"]
# I know this works outside of a flask app with a specific payload, but I haven't gotten this far to test functions across all possible webhook payloads ):
# return get_download_url(attachment_id, close_match) attachment_id = attachments["id"]
return "Next function is the get_download_url" file = attachments["attributes"]["filename"]
if ALLFILES == "False":
print(
f"Grabbing only track name matches as ALLFILES is {ALLFILES}"
)
print(file)
print(filenames)
close_match = Utils.find_close_matches(file, filenames)
# TODO: Figure out how to run the close matches with non-obvious file/content names
get_download_url(attachment_id, close_match, ALLFILES)
elif ALLFILES == "True":
print(f"Grabbing all files as ALLFILES is {ALLFILES}")
get_download_url(attachment_id, file, ALLFILES)
else:
continue
else:
non_downloadables.append(attachments["attributes"]["filename"])
continue
if len(non_downloadables) > 0:
logging.info(
f"The following audio content-types files do not allow for downloading: {non_downloadables}"
)
return "Returning this statement."
def get_download_url(uuid, file): def get_download_url(uuid, file, allfiles):
""" """
Has not been fully tested yet. Has not been fully tested yet.
To get the s3 bucket download url, this is a sample curl url: To get the s3 bucket download url, this is a sample curl url:
@ -131,11 +200,46 @@ def get_download_url(uuid, file):
s3url = downdata["data"]["attributes"]["attachment_url"] s3url = downdata["data"]["attributes"]["attachment_url"]
s3response = requests.get(s3url) s3response = requests.get(s3url)
with open(f"{str(file)[2:-2]}.mp3", "wb") as f: print(s3response.status_code)
f.write(s3response.content) if s3response.status_code == 200:
with open(f"{str(file)}", "wb") as f:
f.write(s3response.content)
# if allfiles == "False":
# # with open(f"{str(file)[2:-2]}.mp3", "wb") as f:
# with open(f"{str(file)}", "wb") as f:
# f.write(s3response.content)
# else:
# with open(f"{str(file)}", "wb") as f:
# f.write(s3response.content)
edit_metadata(file)
return "Successfully downloaded"
else:
return "Something went wrong with the download"
def edit_metadata(file):
print(file)
print("******")
command = [
"id3v2",
"-g",
"Christian",
# TODO: Add environmental variable for people to change their own genre.
"-A",
"PCO",
"-a",
ARTIST,
"-t",
file,
file,
]
subprocess.call(command)
if __name__ == "__main__": if __name__ == "__main__":
import logging import logging
numeric_level = getattr(logging, LOGLEVEL.upper(), None) numeric_level = getattr(logging, LOGLEVEL.upper(), None)
logging.basicConfig(filename='record.log', level=numeric_level) logging.basicConfig(filename="record.log", level=numeric_level)
app.run(host="0.0.0.0", port=8000, debug=True) app.run(host="0.0.0.0", port=8000, debug=True)

View File

@ -5,6 +5,10 @@ import difflib
class Utils: class Utils:
not_close = []
def __init__(self):
self.not_close_matches = []
def is_future_date(service_date): def is_future_date(service_date):
""" """
Converts service_date in format "Mon Day, Year" to epoch time Converts service_date in format "Mon Day, Year" to epoch time
@ -33,7 +37,26 @@ class Utils:
Returns: Returns:
list: A list of close matches. list: A list of close matches.
""" """
return difflib.get_close_matches(word, possibilities, cutoff=cutoff) try:
x = difflib.get_close_matches(word, possibilities, cutoff=cutoff)
print(word)
if x == []:
poss_acronyms = []
type_dropped = word[:-4]
for i in possibilities:
acronym = i.split(' ')
poss_acronyms.append(''.join([item[0] for item in acronym]))
if difflib.get_close_matches(type_dropped, poss_acronyms, n=1, cutoff=0) != []:
close_match = word
else:
close_match = difflib.get_close_matches(word, possibilities, cutoff=cutoff)
close_match = word
except Exception as e:
print("Close Matches Error")
print(e)
finally:
return close_match
def load_json_from_file(file_path): def load_json_from_file(file_path):
""" """