Files
dotfiles/hosts/mainframe/bin/movie_recs_notify
T
connor ed99ce6a2a movie_recs_notify: resolve movie_recs next to itself
RECS was the absolute path /home/connor/.local/bin/movie_recs. That worked
while ~/.local/bin was a dotbot link into hosts/<host>/bin, but base.conf.yaml
now *creates* it as a real directory owned by the package managers, so the
movie_recs link went away with it. Every timer run since died on
FileNotFoundError before reaching LiteLLM, which is why the ntfy push went
quiet rather than reporting a failure.

The unit already invokes this script as %h/.dotfiles/hosts/mainframe/bin/...,
so deriving the sibling path from __file__ cannot drift from however the unit
is spelled, and does not care what ~/.local/bin becomes next.
2026-09-17 12:33:10 -04:00

115 lines
4.0 KiB
Python
Executable File

#!/usr/bin/python3
"""Run movie_recs and push the result to ntfy as a markdown notification.
Intended for the movie-recs.timer systemd unit. Failures are pushed too --
a recommendation job that silently stops producing is worse than a noisy one.
"""
import base64
import json
import os
import subprocess
import sys
import urllib.request
NTFY = "https://ntfy.rcjohnstone.com/movies"
# ntfy is auth-default-access: deny-all, so publishing needs the `bot`
# credential. Mirrored from the rbw entry `ntfy-bot`; rbw is the source of
# truth. Kept in a 0600 file because this runs unattended from a timer.
NTFY_ENV = os.path.expanduser("~/.config/ntfy/publish.env")
# Resolved next to this script, not via ~/.local/bin. That directory used to
# be a dotbot link into hosts/<host>/bin; it is now a real directory owned by
# the package managers (meta/base.conf.yaml), so the movie_recs link vanished
# and every run since died with FileNotFoundError. The sibling path is what
# the unit already uses for this script itself and cannot drift from it.
RECS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "movie_recs")
COUNT = "10"
def _auth_header():
user, pw = os.environ.get("NTFY_USER"), os.environ.get("NTFY_PASS")
if not (user and pw):
try:
with open(NTFY_ENV) as fh:
vals = dict(
line.strip().split("=", 1)
for line in fh if "=" in line and not line.startswith("#"))
user, pw = vals.get("NTFY_USER"), vals.get("NTFY_PASS")
except OSError:
return None
if not (user and pw):
return None
token = base64.b64encode(f"{user}:{pw}".encode()).decode()
return f"Basic {token}"
def push(title, body, tags, priority="default", markdown=True):
headers = {
"Title": title,
"Tags": tags,
"Priority": priority,
"Content-Type": "text/plain; charset=utf-8",
}
auth = _auth_header()
if auth:
headers["Authorization"] = auth
if markdown:
headers["Markdown"] = "yes"
req = urllib.request.Request(
NTFY, data=body.encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=30) as r:
return r.status
def main():
try:
proc = subprocess.run(
[RECS, "-n", COUNT, "--json"],
capture_output=True, text=True, timeout=2400)
except subprocess.TimeoutExpired:
push("Movie recs failed", "movie_recs timed out after 40 minutes.",
"warning", "high", markdown=False)
return 1
if proc.returncode != 0:
tail = (proc.stderr or "no stderr").strip().splitlines()[-6:]
push("Movie recs failed",
"exit %d\n\n%s" % (proc.returncode, "\n".join(tail)),
"warning", "high", markdown=False)
return 1
try:
data = json.loads(proc.stdout)
except json.JSONDecodeError:
push("Movie recs failed", "could not parse output as JSON",
"warning", "high", markdown=False)
return 1
recs = data.get("recommendations", [])
if not recs:
push("Movie recs: nothing today",
"The run completed but every candidate was filtered out.",
"warning", "default", markdown=False)
return 0
lines = []
for r in recs:
t = r["tmdb"]
lines.append("**%s** (%s) · %s/10, %sk votes"
% (t["title"], t["year"], t["score"], t["votes"] // 1000))
lines.append("%s" % r.get("reason", "").strip())
lines.append("*from: %s*" % ", ".join(r.get("anchors", [])))
lines.append("")
dropped = (len(data.get("rejected_not_in_tmdb", []))
+ len(data.get("rejected_already_owned", []))
+ len(data.get("rejected_below_min_votes", []))
+ len(data.get("rejected_invented_cluster", [])))
lines.append("_%d shown, %d candidates filtered_" % (len(recs), dropped))
push("%d film recommendations" % len(recs), "\n".join(lines), "clapper")
return 0
if __name__ == "__main__":
sys.exit(main())