diff --git a/README.md b/README.md index 161b315..e9e2a73 100644 --- a/README.md +++ b/README.md @@ -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 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 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 first of them. diff --git a/config.json b/config.json index 036bd90..47fa8b0 100644 --- a/config.json +++ b/config.json @@ -28,7 +28,8 @@ "atem_midi", "spotify_play", "ready_ltc", - "ready_ableton_mix" + "ready_ableton_mix", + "ready_mount_share" ], "max_tries": 2, "pause_after_network_seconds": 60, @@ -510,6 +511,12 @@ "never_auto": true, "short": "CUES", "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": { @@ -1396,7 +1403,7 @@ { "checks": [ { - "fix": "ready_recheck", + "fix": "ready_mount_share", "item": "prop_bundle", "type": "ready" } diff --git a/readiness.py b/readiness.py index f548d41..1986918 100644 --- a/readiness.py +++ b/readiness.py @@ -1655,8 +1655,12 @@ def eval_columns(rc, prop, found, get_page, comp): BUNDLE_SCRIPT = r''' import glob, json, os P = json.loads(%r) -files = glob.glob(os.path.join(os.path.expanduser(P["folder"]), P["pattern"])) -print(json.dumps(sorted([[os.path.basename(f), os.path.getmtime(f)] for f in files], key=lambda x: -x[1])[:5])) +folder = os.path.expanduser(P["folder"]) +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")) +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): 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", "")): # This weekend's own folder: any bundle in it is this weekend's. if not files: @@ -1943,6 +1967,15 @@ def run_action(cfg, action, chk=None, sleep=time.sleep): return "NO SET TO COPY" log.info("FIX tracks copy started to %s", ", ".join(r["started"]) or "nowhere (all there)") 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": return fix_light_timing(cfg, rc) if action == "ableton_mix": diff --git a/ready-page.companionconfig b/ready-page.companionconfig index ef5ec0e..1b1f412 100644 Binary files a/ready-page.companionconfig and b/ready-page.companionconfig differ diff --git a/remote-access/mac-keep-mounted.sh b/remote-access/mac-keep-mounted.sh new file mode 100644 index 0000000..c526365 --- /dev/null +++ b/remote-access/mac-keep-mounted.sh @@ -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 +# 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.; rm ~/Library/LaunchAgents/org.waterschurch.keep-mounted..plist ~/Library/Scripts/keep-mounted-.sh +set -e +NAME=$1 URL=$2 MP=$3 +[[ -n $NAME && -n $URL && -n $MP ]] || { echo "usage: zsh mac-keep-mounted.sh " >&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" <> "\$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" < + + + Label$LABEL + ProgramArguments/bin/zsh$SCRIPT + RunAtLoad + StartInterval60 + ProcessTypeBackground + +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)" diff --git a/schema.py b/schema.py index 5c5b438..0bd9090 100644 --- a/schema.py +++ b/schema.py @@ -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_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") +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 ): 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.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") diff --git a/status_panel.py b/status_panel.py index 684602f..0da3265 100644 --- a/status_panel.py +++ b/status_panel.py @@ -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. READY_ITEMS = ("tracks", "mix_foh", "lights", "light_timing", "keys", "tune", "routing", "ears", "prop", "timecode", "backgrounds", "led_cross", "lineup", "mics", "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 = { "conn": check_conn, "hyperdeck": check_hyperdeck, "dante": check_dante, "shure": check_shure, diff --git a/tests/test_readiness.py b/tests/test_readiness.py index 17fb03a..3088cf3 100644 --- a/tests/test_readiness.py +++ b/tests/test_readiness.py @@ -623,6 +623,12 @@ class TestLineupCrossBundle(unittest.TestCase): 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, [], 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): since = rd.week_start(WEDNESDAY)