PROP OUT: keep Location Assets mounted on the ProP Mac; SHARE NOT MOUNTED; hold remounts

- remote-access/mac-keep-mounted.sh: a user LaunchAgent that keeps a share mounted (every minute and at
  login; Keychain login; unmount + remount only when the share hangs), installed on NA ProP for
  Chronicles' Location Assets
- PROP OUT says SHARE NOT MOUNTED instead of NOT IN <weekend> when the share is gone; its hold (and
  auto-fix) starts the agent and waits for the share

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 11:22:07 -04:00
co-authored by Claude Opus 5.5
parent 11ac8aa8e0
commit e2add3b27e
8 changed files with 110 additions and 6 deletions
+1 -1
View File
@@ -131,7 +131,7 @@ HyperDeck is recording). **STATUS PAGE** (bottom left) goes back.
| row 0 (Ableton) | TRACKS | FOH MIX | BOH MIX (tap) | LIGHTS | MIDI | SONG KEYS | W TUNE | | row 0 (Ableton) | TRACKS | FOH MIX | BOH MIX (tap) | LIGHTS | MIDI | SONG KEYS | W TUNE |
| row 1 (ProP / Resolume) | PROP | TIMECODE | BKGDS | LED CROSS | LIGHT TIMING | every blinder flash and FX cue (notes 30-39 on `LIGHTING CUES`: Blinder Flash 1-3, Blinder FX 1-5, named from `Ableton/LIGHTING CUES MIDI`) lands on the hit in the `Drums (Live)` stem under it, even when that clip is deactivated because the drummer plays live. Each cue's moment in the stem comes from the tempo map; the Tracks Mac listens in 2.5 ms steps out to almost a 16th either way, finds the strongest hit and where it starts (halfway up its rise); what is under the cue is looked at from 10 ms before it to 25 ms after (the stems' hits start a little after the grid). Each song's cues that sit on solid hits set what "on time" is for that song (its stem's hits start 7-12 ms after the grid). **14 CUES OFF** (amber): a strong hit more than 25 ms from the cue while the cue sits on a quiet spot; a cue already on a solid hit (a double hit follows) is never named. The details say `JEHOVAH bar 405.3.2: Blinder Flash 2 is 145 ms after the hit (about a 16th)` (bars as Live shows them, 4/4). Listened to once per saved set. | moves every cue that is off onto its hit in the open set (all flash/FX notes on that 16th; onto the 16th grid when the hit is within 10 ms of it), keeping where each cue ends, then saves the set (File › Save Live Set in Live, clicked through System Events: `/usr/bin/osascript` has Accessibility on the Tracks Mac since 2026-10-22; `ready.light_autosave`), but only when the set open in Live is this weekend's. If it cannot save, the key says **SAVE SET (n MOVED)** until the set is saved; Cmd+Z in Live takes a move back. Never runs by itself. Uses the status panel's command in AbletonOSC (`tools/abletonosc`: `statuspanel.py` plus a hook at the end of AbletonOSC's `abletonosc/__init__.py`; an AbletonOSC update removes it, so run `tools/abletonosc/install.sh` again, then restart Companion's `ableton` connection) | | row 1 (ProP / Resolume) | PROP | TIMECODE | BKGDS | LED CROSS | LIGHT TIMING | every blinder flash and FX cue (notes 30-39 on `LIGHTING CUES`: Blinder Flash 1-3, Blinder FX 1-5, named from `Ableton/LIGHTING CUES MIDI`) lands on the hit in the `Drums (Live)` stem under it, even when that clip is deactivated because the drummer plays live. Each cue's moment in the stem comes from the tempo map; the Tracks Mac listens in 2.5 ms steps out to almost a 16th either way, finds the strongest hit and where it starts (halfway up its rise); what is under the cue is looked at from 10 ms before it to 25 ms after (the stems' hits start a little after the grid). Each song's cues that sit on solid hits set what "on time" is for that song (its stem's hits start 7-12 ms after the grid). **14 CUES OFF** (amber): a strong hit more than 25 ms from the cue while the cue sits on a quiet spot; a cue already on a solid hit (a double hit follows) is never named. The details say `JEHOVAH bar 405.3.2: Blinder Flash 2 is 145 ms after the hit (about a 16th)` (bars as Live shows them, 4/4). Listened to once per saved set. | moves every cue that is off onto its hit in the open set (all flash/FX notes on that 16th; onto the 16th grid when the hit is within 10 ms of it), keeping where each cue ends, then saves the set (File › Save Live Set in Live, clicked through System Events: `/usr/bin/osascript` has Accessibility on the Tracks Mac since 2026-10-22; `ready.light_autosave`), but only when the set open in Live is this weekend's. If it cannot save, the key says **SAVE SET (n MOVED)** until the set is saved; Cmd+Z in Live takes a move back. Never runs by itself. Uses the status panel's command in AbletonOSC (`tools/abletonosc`: `statuspanel.py` plus a hook at the end of AbletonOSC's `abletonosc/__init__.py`; an AbletonOSC update removes it, so run `tools/abletonosc/install.sh` again, then restart Companion's `ableton` connection) |
| row 2 (backstage) | LINEUP TV | ROUTING | IN EARS | | | | | | row 2 (backstage) | LINEUP TV | ROUTING | IN EARS | | | | |
| row 3 (outgoing) | TRACKS OUT | PROP OUT | | | | | READY | | row 3 (outgoing) | TRACKS OUT | PROP OUT | a `.probundle` is in this weekend's folder of the Chronicles share (`/Volumes/Location Assets/261025`, read on the ProPresenter Mac). **SHARE NOT MOUNTED**: the share itself is not mounted there (2026-10-22: it had dropped off and the key said NOT IN 261025 although the bundle was there). The ProP Mac keeps it mounted by itself: a LaunchAgent (`org.waterschurch.keep-mounted.location-assets`, from `remote-access/mac-keep-mounted.sh`) looks every minute and at login, mounts it again with the Chronicles login saved in the Keychain, and unmounts and remounts it when it hangs after sleep or a network drop (log: `~/Library/Logs/keep-mounted-location-assets.log` on the ProP Mac). | starts that agent now and waits for the share (about 6 s), then looks again. Also an auto-fix. |
The READY key at row 3 col 7 (and the one on the status page) shows how many keys still have something to do and The READY key at row 3 col 7 (and the one on the status page) shows how many keys still have something to do and
the first of them. the first of them.
+9 -2
View File
@@ -28,7 +28,8 @@
"atem_midi", "atem_midi",
"spotify_play", "spotify_play",
"ready_ltc", "ready_ltc",
"ready_ableton_mix" "ready_ableton_mix",
"ready_mount_share"
], ],
"max_tries": 2, "max_tries": 2,
"pause_after_network_seconds": 60, "pause_after_network_seconds": 60,
@@ -510,6 +511,12 @@
"never_auto": true, "never_auto": true,
"short": "CUES", "short": "CUES",
"label": "move the lighting cues that are off onto the drum hit in the open Ableton set (then save the set)" "label": "move the lighting cues that are off onto the drum hit in the open Ableton set (then save the set)"
},
"ready_mount_share": {
"action": "mount_share",
"kind": "ready",
"short": "MOUNT",
"label": "mount Location Assets again on the ProPresenter Mac (its keep-mounted agent), then look for the bundle again"
} }
}, },
"flap": { "flap": {
@@ -1396,7 +1403,7 @@
{ {
"checks": [ "checks": [
{ {
"fix": "ready_recheck", "fix": "ready_mount_share",
"item": "prop_bundle", "item": "prop_bundle",
"type": "ready" "type": "ready"
} }
+35 -2
View File
@@ -1655,8 +1655,12 @@ def eval_columns(rc, prop, found, get_page, comp):
BUNDLE_SCRIPT = r''' BUNDLE_SCRIPT = r'''
import glob, json, os import glob, json, os
P = json.loads(%r) P = json.loads(%r)
files = glob.glob(os.path.join(os.path.expanduser(P["folder"]), P["pattern"])) folder = os.path.expanduser(P["folder"])
print(json.dumps(sorted([[os.path.basename(f), os.path.getmtime(f)] for f in files], key=lambda x: -x[1])[:5])) files = glob.glob(os.path.join(folder, P["pattern"]))
parts = folder.split("/")
share = "/".join(parts[:3]) if folder.startswith("/Volumes/") and len(parts) > 2 else None # /Volumes/Location Assets
print(json.dumps({"files": sorted([[os.path.basename(f), os.path.getmtime(f)] for f in files], key=lambda x: -x[1])[:5],
"share": share, "mounted": os.path.ismount(share) if share else None}))
''' '''
@@ -1666,8 +1670,28 @@ def prop_bundle_folder(rc, plan):
return str(rc.get("prop_bundle_folder", "")).replace("{date}", datetime.fromtimestamp(plan["time"]).strftime("%y%m%d")) return str(rc.get("prop_bundle_folder", "")).replace("{date}", datetime.fromtimestamp(plan["time"]).strftime("%y%m%d"))
MOUNT_SCRIPT = r"""
import json, os, subprocess, time
P = json.loads(%r)
uid = os.getuid()
r = subprocess.run(["/bin/launchctl", "kickstart", "-k", "gui/%%d/%%s" %% (uid, P["label"])], capture_output=True, text=True)
for _ in range(60):
if os.path.ismount(P["share"]):
break
time.sleep(0.5)
print(json.dumps({"mounted": os.path.ismount(P["share"]), "no_agent": r.returncode != 0 and not os.path.ismount(P["share"])}))
"""
def eval_prop_bundle(rc, files, since, folder=None): def eval_prop_bundle(rc, files, since, folder=None):
line = Line() line = Line()
if isinstance(files, dict):
if files.get("share") and files.get("mounted") is False:
# 2026-10-22: Location Assets dropped off NA ProP; the bundle was there all along
line.fault(ERR, "SHARE NOT MOUNTED", "%s is not mounted on %s: hold PROP OUT to mount it again" % (
files["share"], rc.get("prop_bundle_host")))
return line
files = files.get("files") or []
if "{date}" in str(rc.get("prop_bundle_folder", "")): if "{date}" in str(rc.get("prop_bundle_folder", "")):
# This weekend's own folder: any bundle in it is this weekend's. # This weekend's own folder: any bundle in it is this weekend's.
if not files: if not files:
@@ -1943,6 +1967,15 @@ def run_action(cfg, action, chk=None, sleep=time.sleep):
return "NO SET TO COPY" return "NO SET TO COPY"
log.info("FIX tracks copy started to %s", ", ".join(r["started"]) or "nowhere (all there)") log.info("FIX tracks copy started to %s", ", ".join(r["started"]) or "nowhere (all there)")
return True return True
if action == "mount_share":
# The computer's keep-mounted agent (remote-access/mac-keep-mounted.sh) mounts it again, now.
label = "org.waterschurch.keep-mounted." + rc.get("prop_bundle_share_agent", "location-assets")
share = "/".join(str(rc.get("prop_bundle_folder", "")).split("/")[:3])
r = run_remote(cfg, rc["prop_bundle_host"], MOUNT_SCRIPT % json.dumps({"label": label, "share": share}), timeout=60)
if r.get("mounted"):
log.info("FIX %s mounted on %s", share, rc["prop_bundle_host"])
return True
return "NO AGENT" if r.get("no_agent") else "STILL NOT MOUNTED"
if action == "light_timing": if action == "light_timing":
return fix_light_timing(cfg, rc) return fix_light_timing(cfg, rc)
if action == "ableton_mix": if action == "ableton_mix":
Binary file not shown.
+55
View File
@@ -0,0 +1,55 @@
#!/bin/zsh
# Keeps a network share mounted on this Mac (2026-10-22: Location Assets dropped off NA ProP and PROP OUT
# could not see the bundle). Installs a LaunchAgent for the logged-in user that looks every minute and at
# login: mounted and answering = nothing; not mounted = mount it again with the login saved in the Keychain
# (tick "Remember this password" when connecting once by hand); mounted but dead (after sleep or a network
# drop) = unmount it, then mount it again. Never asks for a password and never shows a dialog.
#
# Run on the Mac, as the user who uses the share:
# zsh mac-keep-mounted.sh <name> <smb URL> <mount point>
# zsh mac-keep-mounted.sh location-assets "smb://Kenobi@Chronicles-NA._smb._tcp.local/Location%20Assets" "/Volumes/Location Assets"
# Undo: launchctl bootout gui/$(id -u)/org.waterschurch.keep-mounted.<name>; rm ~/Library/LaunchAgents/org.waterschurch.keep-mounted.<name>.plist ~/Library/Scripts/keep-mounted-<name>.sh
set -e
NAME=$1 URL=$2 MP=$3
[[ -n $NAME && -n $URL && -n $MP ]] || { echo "usage: zsh mac-keep-mounted.sh <name> <smb URL> <mount point>" >&2; exit 2; }
LABEL=org.waterschurch.keep-mounted.$NAME
SCRIPT=$HOME/Library/Scripts/keep-mounted-$NAME.sh
LOG=$HOME/Library/Logs/keep-mounted-$NAME.log
HOST=${${URL#*://}#*@}; HOST=${HOST%%/*}
mkdir -p "$HOME/Library/Scripts" "$HOME/Library/LaunchAgents" "$HOME/Library/Logs"
cat > "$SCRIPT" <<EOS
#!/bin/zsh
# Keep $MP mounted ($URL). Installed by the status panel's mac-keep-mounted.sh; see that file to undo.
MP="$MP"; URL="$URL"; HOST="$HOST"; LOG="$LOG"
say() { print -r -- "\$(date '+%Y-%m-%d %H:%M:%S') \$*" >> "\$LOG"; }
if mount | grep -qF " on \$MP ("; then
# Dead only when looking at it hangs (killed after 20 s: status 142). A refusal (macOS's privacy rules for
# a background job) or any other error leaves a working share alone.
/usr/bin/perl -e 'alarm 20; exec @ARGV' /bin/df "\$MP" >/dev/null 2>&1
[[ \$? -ne 142 ]] && exit 0
say "mounted but not answering (20 s): unmounting"
/usr/sbin/diskutil unmount force "\$MP" >> "\$LOG" 2>&1 || /sbin/umount -f "\$MP" >> "\$LOG" 2>&1 || true
fi
SERVER=\${HOST%%._smb._tcp.local}; SERVER=\${SERVER%%.local}
if ! /usr/bin/perl -e 'alarm 6; exec @ARGV' /usr/bin/nc -z -G 4 "\$SERVER.local" 445 >/dev/null 2>&1; then
exit 0 # the server is not on the network right now: try again in a minute
fi
say "not mounted: mounting \$URL"
/usr/bin/osascript -e 'with timeout of 40 seconds' -e "mount volume \"\$URL\"" -e 'end timeout' >> "\$LOG" 2>&1 || say "mount failed"
mount | grep -qF " on \$MP (" && say "mounted at \$MP" || say "not at \$MP after mounting: \$(mount | grep -i smbfs | tr '\n' ' ')"
EOS
chmod 755 "$SCRIPT"
cat > "$HOME/Library/LaunchAgents/$LABEL.plist" <<EOP
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>$LABEL</string>
<key>ProgramArguments</key><array><string>/bin/zsh</string><string>$SCRIPT</string></array>
<key>RunAtLoad</key><true/>
<key>StartInterval</key><integer>60</integer>
<key>ProcessType</key><string>Background</string>
</dict></plist>
EOP
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/$LABEL.plist"
echo "installed $LABEL: keeps $MP mounted (log: $LOG)"
+3
View File
@@ -438,6 +438,9 @@ field("ready.prop_host", "str", None, "The ProPresenter computer: its macros and
field("ready.prop_bundle_host", "str", None, "The computer that saves the ProPresenter bundle.", "Ready") field("ready.prop_bundle_host", "str", None, "The computer that saves the ProPresenter bundle.", "Ready")
field("ready.prop_bundle_folder", "path", "", "Where the bundle for the locations goes. {date} is this weekend's folder " field("ready.prop_bundle_folder", "path", "", "Where the bundle for the locations goes. {date} is this weekend's folder "
"(Location Assets/{date} -> Location Assets/261025): any bundle in it counts as sent.", "Ready") "(Location Assets/{date} -> Location Assets/261025): any bundle in it counts as sent.", "Ready")
field("ready.prop_bundle_share_agent", "str", "location-assets", "The keep-mounted agent on that computer that mounts "
"the share the bundle folder is on (remote-access/mac-keep-mounted.sh <name>): PROP OUT's hold starts it.", "Ready",
advanced=True)
field("ready.prop_bundle_pattern", "str", "*.probundle", "The bundle's file name pattern.", "Ready", advanced=True) field("ready.prop_bundle_pattern", "str", "*.probundle", "The bundle's file name pattern.", "Ready", advanced=True)
field("ready.vocal_positions", "list", ["Vocalist", "[Leader]"], "PCO positions that need a vocal mic.", "Ready") field("ready.vocal_positions", "list", ["Vocalist", "[Leader]"], "PCO positions that need a vocal mic.", "Ready")
field("ready.mic_positions", "dict", {}, "Other PCO positions and the mic they need (MC -> MC).", "Ready") field("ready.mic_positions", "dict", {}, "Other PCO positions and the mic they need (MC -> MC).", "Ready")
+1 -1
View File
@@ -819,7 +819,7 @@ def check_ready_manual(chk, snap, ctx):
# What readiness.py looks at (check type "ready") and what a "ready" fix can do. # What readiness.py looks at (check type "ready") and what a "ready" fix can do.
READY_ITEMS = ("tracks", "mix_foh", "lights", "light_timing", "keys", "tune", "routing", "ears", "prop", "timecode", "backgrounds", "led_cross", "lineup", "mics", READY_ITEMS = ("tracks", "mix_foh", "lights", "light_timing", "keys", "tune", "routing", "ears", "prop", "timecode", "backgrounds", "led_cross", "lineup", "mics",
"track_bundle", "prop_bundle") "track_bundle", "prop_bundle")
READY_ACTIONS = ("recheck", "lineup_sync", "led_cross", "copy_tracks", "ltc_to_pco", "ableton_mix", "light_timing") READY_ACTIONS = ("recheck", "lineup_sync", "led_cross", "copy_tracks", "ltc_to_pco", "ableton_mix", "light_timing", "mount_share")
CHECKS = { CHECKS = {
"conn": check_conn, "hyperdeck": check_hyperdeck, "dante": check_dante, "shure": check_shure, "conn": check_conn, "hyperdeck": check_hyperdeck, "dante": check_dante, "shure": check_shure,
+6
View File
@@ -623,6 +623,12 @@ class TestLineupCrossBundle(unittest.TestCase):
self.assertEqual(folder, "~/Shared drives/Location Assets/261025") self.assertEqual(folder, "~/Shared drives/Location Assets/261025")
self.assertEqual(msgs(rd.eval_prop_bundle(rc, [["WORSHIP BUNDLE.probundle", WEDNESDAY - 86400 * 10]], WEDNESDAY, folder)), []) self.assertEqual(msgs(rd.eval_prop_bundle(rc, [["WORSHIP BUNDLE.probundle", WEDNESDAY - 86400 * 10]], WEDNESDAY, folder)), [])
self.assertEqual(msgs(rd.eval_prop_bundle(rc, [], WEDNESDAY, folder)), ["NOT IN 261025"]) self.assertEqual(msgs(rd.eval_prop_bundle(rc, [], WEDNESDAY, folder)), ["NOT IN 261025"])
# the share the folder is on is not mounted on the ProP Mac (2026-10-22): said so, not "NOT IN"
gone = {"files": [], "share": "/Volumes/Location Assets", "mounted": False}
self.assertEqual(msgs(rd.eval_prop_bundle(rc, gone, WEDNESDAY, folder)), ["SHARE NOT MOUNTED"])
there = {"files": [["WORSHIP BUNDLE.probundle", WEDNESDAY]], "share": "/Volumes/Location Assets", "mounted": True}
self.assertEqual(msgs(rd.eval_prop_bundle(rc, there, WEDNESDAY, folder)), [])
compile(rd.MOUNT_SCRIPT % json.dumps({"label": "x", "share": "/Volumes/X"}), "remote", "exec")
def test_prop_bundle_this_week(self): def test_prop_bundle_this_week(self):
since = rd.week_start(WEDNESDAY) since = rd.week_start(WEDNESDAY)