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:
16
.env.example
16
.env.example
@ -1,3 +1,4 @@
|
||||
|
||||
# Please go to https://api.planningcenteronline.com/oauth/applications
|
||||
# Create new Personal Access Token
|
||||
# Paste them below
|
||||
@ -8,7 +9,16 @@ SECRET = ""
|
||||
# Just grab the numbers, not the two letters before it.
|
||||
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.
|
||||
# TODO: Add ssh possibility
|
||||
DIR = ""
|
||||
# Options include DEBUG, INFO, ERROR
|
||||
# TODO: Add option for transferring files between services via ssh
|
||||
DIR = "/path/to/your/music"
|
||||
# Options include DEBUG, INFO
|
||||
# DEBUG will print the entire payloads to log files.
|
||||
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
38
README.md
Normal 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`
|
||||
BIN
__pycache__/envvars.cpython-313.pyc
Normal file
BIN
__pycache__/envvars.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
15
envvars.py
Normal file
15
envvars.py
Normal 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
174
main.py
@ -1,22 +1,50 @@
|
||||
import requests
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
import pprint
|
||||
from flask import Flask, request
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
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__)
|
||||
pp = pprint.PrettyPrinter(indent=4)
|
||||
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")
|
||||
app.json.compact = False
|
||||
|
||||
# This function is for production.
|
||||
# @app.route("/webhook", methods=["POST"])
|
||||
# def accept_webhook():
|
||||
# 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"])
|
||||
def accept_webhook():
|
||||
logging.info("Webhook page prepared and ready to receive webhook.")
|
||||
@ -27,12 +55,13 @@ def accept_webhook():
|
||||
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)
|
||||
service_id = webhook_ids[1]
|
||||
plan_id = webhook_ids[0]
|
||||
get_plans(service_id, plan_id, service_date)
|
||||
return "Try loop completed"
|
||||
except TypeError as e:
|
||||
print(service_id, plan_id)
|
||||
print("This is a type error")
|
||||
print(e)
|
||||
finally:
|
||||
return "End of app"
|
||||
@ -40,27 +69,33 @@ def accept_webhook():
|
||||
|
||||
def parse_payload(payload):
|
||||
for item in payload["data"]:
|
||||
# event_type = item['attributes']['name']
|
||||
# event_type lives at item['attributes']['name']
|
||||
webhook_ids = (
|
||||
item["attributes"]["payload"]["data"]["id"],
|
||||
item["attributes"]["payload"]["data"]["relationships"]["service_type"][
|
||||
"data"
|
||||
]["id"],
|
||||
)
|
||||
# Webhook IDs tuple are: (plan id, service id)
|
||||
service_date = item["attributes"]["payload"]["data"]["attributes"]["dates"]
|
||||
|
||||
# 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."
|
||||
|
||||
# This is just for testing purposes as the date had multiple rehearsal tracks
|
||||
if service_date == "June 15, 2025":
|
||||
print(f"Special consideration for {service_date}")
|
||||
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
|
||||
|
||||
|
||||
def get_my_plans(incoming_ids):
|
||||
"""
|
||||
This is the url for all the plans the user is part of
|
||||
"""
|
||||
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()
|
||||
plan_ids = []
|
||||
for items in data["data"]:
|
||||
@ -77,20 +112,25 @@ def get_my_plans(incoming_ids):
|
||||
if plan == incoming_ids:
|
||||
return incoming_ids[1], incoming_ids[0]
|
||||
else:
|
||||
return None
|
||||
return "Plan doesn't exist in PlanPerson payloads"
|
||||
else:
|
||||
if plan_ids[0] == incoming_ids:
|
||||
return incoming_ids[1], incoming_ids[0]
|
||||
else:
|
||||
return None
|
||||
return "Plan doesn't exist in PlanPerson payloads"
|
||||
|
||||
|
||||
def get_plans(service_type_id, plan_id, service_date):
|
||||
filenames = []
|
||||
planurl = (
|
||||
f"{BASE}/services/v2/service_types/{service_type_id}/plans/{plan_id}/items"
|
||||
)
|
||||
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"]:
|
||||
if items["attributes"]["item_type"] == "song":
|
||||
item_id = items["id"]
|
||||
@ -100,26 +140,55 @@ def get_plans(service_type_id, plan_id, service_date):
|
||||
key = getkey["data"]["attributes"]["starting_key"]
|
||||
filename = f"{items['attributes']['title']} - {key}"
|
||||
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"
|
||||
plana = requests.get(planattachments, auth=(CLIENTSEC, SECRET)).json()
|
||||
pp.pprint(plana)
|
||||
logging.info("Looking for attachments")
|
||||
non_downloadables = []
|
||||
if plana["data"] == []:
|
||||
logging.info(f"No attachments available for {service_date}")
|
||||
else:
|
||||
logging.info(f"Attachments available for {service_date}")
|
||||
for attachments in plana["data"]:
|
||||
if attachments["attributes"]["content_type"] == "audio/mpeg":
|
||||
# attachment_id = attachments["id"]
|
||||
file = attachments["attributes"]["filename"]
|
||||
# close_match = Utils.find_close_matches(file, filenames)
|
||||
# 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)
|
||||
return "Next function is the get_download_url"
|
||||
if attachments["attributes"]["allow_mp3_download"]:
|
||||
if (
|
||||
attachments["attributes"]["content_type"] is not None
|
||||
and "audio" in attachments["attributes"]["content_type"]
|
||||
):
|
||||
attachment_id = attachments["id"]
|
||||
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.
|
||||
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"]
|
||||
|
||||
s3response = requests.get(s3url)
|
||||
with open(f"{str(file)[2:-2]}.mp3", "wb") as f:
|
||||
f.write(s3response.content)
|
||||
print(s3response.status_code)
|
||||
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__":
|
||||
import logging
|
||||
|
||||
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)
|
||||
|
||||
25
utilfuncs.py
25
utilfuncs.py
@ -5,6 +5,10 @@ import difflib
|
||||
|
||||
|
||||
class Utils:
|
||||
not_close = []
|
||||
def __init__(self):
|
||||
self.not_close_matches = []
|
||||
|
||||
def is_future_date(service_date):
|
||||
"""
|
||||
Converts service_date in format "Mon Day, Year" to epoch time
|
||||
@ -33,7 +37,26 @@ class Utils:
|
||||
Returns:
|
||||
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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user