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.
115 lines
4.0 KiB
Python
Executable File
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())
|