Rebuild dotfiles as one branch with per-host layers
Replaces the previous repo, which had split into two histories that never met (mainframe on a dead GitLab remote, the laptops on Gitea) with 146 dirty files across three machines and the NAS never enrolled at all. Branch-per-machine is gone. One main, with host differences expressed as small files under hosts/<hostname>/ rather than as branches, so there is nothing to merge. The reconciled zsh layer reduces 15-33 line forks to 1-7 effective lines per host; distro differences (oh-my-zsh prefix, syntax-highlighting path, fd vs fdfind) are probed in common/ instead. Fresh history: the old one carried six plaintext credentials, 45 MB of mail caches, browser caches and vendored binaries. 5,096 tracked files and 144 MB become 462 files and 2.6 MB. The .gitignore is now an allowlist, which is what keeps that true. Root cause of the rot: ~/.local/bin was a symlink to scripts/ with GOPATH inside it, so every go install wrote into version control (2.2 GB on the work laptop). PATH now points at the repo instead of the reverse. Also: Hyprland replaces sway and is sourced in two halves so $browser is defined before use; singleton automations carry ConditionHost= alongside host-layer-only placement; ddns moves from cron to a guarded timer; package manifests and pkg-snapshot/pkg-restore replace the X11-era install_scripts/; networkmanager-dmenu added to system76 (the binding always existed, the package never did).
This commit is contained in:
Executable
+665
@@ -0,0 +1,665 @@
|
||||
#!/usr/bin/python3
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from sqlite3 import Error
|
||||
import urllib.request
|
||||
import json
|
||||
import logging
|
||||
import argparse
|
||||
import requests
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from string import ascii_letters, digits
|
||||
from rich import print
|
||||
from argparse import RawTextHelpFormatter
|
||||
|
||||
homefilepath = Path.home()
|
||||
filepath = homefilepath.joinpath('.config/ddns')
|
||||
database = filepath.joinpath('ddns.db')
|
||||
logfile = filepath.joinpath('ddns.log')
|
||||
logging.basicConfig(filename=logfile,level=logging.INFO,format='%(message)s')
|
||||
app_version = '0.5.2'
|
||||
|
||||
|
||||
def get_ip():
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT COUNT(ip4_server) FROM ipservers')
|
||||
count = cursor.fetchone()[0]
|
||||
if count != 0:
|
||||
cursor.execute('SELECT ip4_server from ipservers')
|
||||
server = cursor.fetchone()
|
||||
return server[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def connect_database():
|
||||
Path(filepath).mkdir(parents=True, exist_ok=True)
|
||||
conn = None
|
||||
try:
|
||||
conn = sqlite3.connect(database)
|
||||
except Error as e:
|
||||
logging.error(time.strftime("%Y-%m-%d %H:%M") + ' - Error : ' + str(e))
|
||||
print(e)
|
||||
finally:
|
||||
if conn:
|
||||
c = conn.cursor()
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS apikey
|
||||
(id integer NOT NULL PRIMARY KEY,
|
||||
api text NOT NULL)''')
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS ipservers
|
||||
(id integer NOT NULL PRIMARY KEY,
|
||||
ip4_server text NOT NULL,
|
||||
ip6_server text)''')
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS domains
|
||||
(id integer PRIMARY KEY,
|
||||
name text NOT NULL)''')
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS subdomains
|
||||
(id integer PRIMARY KEY,
|
||||
main_id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
current_ip4 text NOT NULL,
|
||||
current_ip6 text NULL)''')
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
def get_api():
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT COUNT(*) FROM apikey')
|
||||
count = cursor.fetchone()[0]
|
||||
if count == 0:
|
||||
return None
|
||||
else:
|
||||
cursor.execute('SELECT * FROM apikey')
|
||||
rows = cursor.fetchone()
|
||||
return rows[1]
|
||||
|
||||
|
||||
def api(api_value):
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT COUNT(*) FROM apikey')
|
||||
count = cursor.fetchone()[0]
|
||||
if count == 0:
|
||||
cursor.execute('INSERT INTO apikey values(?,?)', (1, api_value))
|
||||
logging.info(time.strftime("%Y-%m-%d %H:%M") + ' - Info : API key added')
|
||||
print('Your API key has been added.')
|
||||
else:
|
||||
cursor.execute('UPDATE apikey SET api = ? WHERE id = 1',(api_value,))
|
||||
logging.info(time.strftime("%Y-%m-%d %H:%M") + ' - Info : API key updated')
|
||||
print('Your API key has been updated.')
|
||||
conn.commit()
|
||||
|
||||
|
||||
def add_domian(domain):
|
||||
apikey = get_api()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT COUNT(*) FROM domains WHERE name like ?',(domain,))
|
||||
count = cursor.fetchone()[0]
|
||||
if count != 0:
|
||||
print('[red]Error:[/red] Domain name (%s) already in database!' % (domain))
|
||||
else:
|
||||
if apikey != None:
|
||||
headers = {'Authorization': 'Bearer ' + apikey, "Content-Type": "application/json"}
|
||||
response = requests.get('https://api.digitalocean.com/v2/domains/' + domain, headers=headers)
|
||||
response_data = response.json()
|
||||
|
||||
if 'id' in response_data:
|
||||
print('[red]Error: [/red]The domain does not exist in your DigitalOcean account.\nPlease add the domain from your control panel [b]https://cloud.digitalocean.com/networking/domains/[/b]')
|
||||
else:
|
||||
cursor.execute('INSERT INTO domains values(?,?)', (None, domain,))
|
||||
print('The domain [b]%s[/b] has been added to the DB' % (domain))
|
||||
logging.info(time.strftime("%Y-%m-%d %H:%M") + ' - Info : Domain %s added' %(domain))
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
||||
def add_subdomain(domain):
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
if set(domain).difference(ascii_letters + '.' + digits + '-' + '@'):
|
||||
print('[red]Error:[/red] Give the domain name in simple form e.g. [b]test.domain.com[/b]')
|
||||
else:
|
||||
parts = domain.split('.')
|
||||
if len(parts) > 3:
|
||||
top = parts[1] + '.' + parts[2] + '.' + parts[3]
|
||||
sub = parts[0]
|
||||
else:
|
||||
sub = parts[0]
|
||||
top = parts[1] + '.' + parts[2]
|
||||
apikey = get_api()
|
||||
if apikey == None:
|
||||
print("[red]Error:[/red] Missing APIkey. Please add one!")
|
||||
else:
|
||||
ip = get_ip()
|
||||
if ip == None or 'urlopen error' in ip:
|
||||
print('[red]Error:[/red] Failed to get public IP. Do you have a typo in your URI? [red]Error %s.[/red]' % (ip))
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT COUNT(*) FROM domains WHERE name like ?',(top,))
|
||||
count = cursor.fetchone()[0]
|
||||
if count == 0:
|
||||
print('[red]Error:[/red] Top domain [bold]%s[/bold] does not exist in the DB. Please add it with [i]ddns -t %s[/i].' % (top,top))
|
||||
else:
|
||||
cursor.execute('SELECT id,name FROM domains WHERE name LIKE ?',(top,))
|
||||
topdomain = cursor.fetchone()
|
||||
topdomain_id = topdomain[0]
|
||||
topdomain_name = topdomain[1]
|
||||
cursor.execute('SELECT count(*) FROM subdomains WHERE main_id LIKE ? AND name like ?',(topdomain_id,sub,))
|
||||
count = cursor.fetchone()[0]
|
||||
if count != 0:
|
||||
print('[red]Error:[/red] [bold]%s[/bold] already exists.' % (domain))
|
||||
else:
|
||||
data = {'name': sub,'data': ip,'type': "A",'ttl': 3600}
|
||||
headers = {'Authorization': 'Bearer ' + apikey, "Content-Type": "application/json"}
|
||||
response = requests.post('https://api.digitalocean.com/v2/domains/' + top + '/records',
|
||||
data=json.dumps(data), headers=headers)
|
||||
if str(response) == '<Response [201]>':
|
||||
if response != 'Fail':
|
||||
response_data = response.json()
|
||||
domainid = str(response_data['domain_record']['id'])
|
||||
cursor.execute('INSERT INTO subdomains values(?,?,?,?,?,?,?,?,?)',(domainid,topdomain_id,sub,ip,None,now,now,now,1,))
|
||||
conn.commit()
|
||||
print('The domain %s has been added.' % (domain))
|
||||
logging.info(time.strftime("%Y-%m-%d %H:%M") + ' - Info : subdomain %s added'%(domain))
|
||||
else:
|
||||
print(f"Failed to add subdomain: {response}, {response.json()}")
|
||||
return '[red]Error: %s [/red]' % (str(response))
|
||||
|
||||
|
||||
def remove_subdomain(domain):
|
||||
if set(domain).difference(ascii_letters + '.' + digits + '-' + '@'):
|
||||
print('[red]Error:[/red] Give the domain name in simple form e.g. [b]test.domain.com[/b]')
|
||||
else:
|
||||
parts = domain.split('.')
|
||||
if len(parts) > 3:
|
||||
top = parts[1] + '.' + parts[2] + '.' + parts[3]
|
||||
sub = parts[0]
|
||||
else:
|
||||
sub = parts[0]
|
||||
top = parts[1] + '.' + parts[2]
|
||||
longtop=sub+'.'+top
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT COUNT(*) FROM domains WHERE name like ? or name like ?',(top,longtop,))
|
||||
count = cursor.fetchone()[0]
|
||||
if count == 0:
|
||||
print('[red]Error:[/red] Top domain [bold]%s[/bold] does not exist in the DB. So I\'m giving up!.' % (top))
|
||||
else:
|
||||
cursor.execute('SELECT COUNT(*) FROM subdomains WHERE name like ? and main_id=(SELECT id from domains WHERE name like ? or name like ?)',(sub,top,longtop,))
|
||||
count = cursor.fetchone()[0]
|
||||
if count == 0:
|
||||
print('[red]Error:[/red] Domain [bold]%s[/bold] does not exist in the DB. So I\'m giving up!.' % (domain))
|
||||
else:
|
||||
apikey = get_api()
|
||||
if apikey == None:
|
||||
print("[red]Error:[/red] Missing APIkey. Please add one!")
|
||||
else:
|
||||
cursor.execute('SELECT id FROM subdomains WHERE name like ? and main_id=(SELECT id from domains WHERE name like ? or name like ?)',(sub,top,longtop,))
|
||||
subdomain_id = str(cursor.fetchone()[0])
|
||||
headers = {'Authorization': 'Bearer ' + apikey, "Content-Type": "application/json"}
|
||||
response = requests.delete('https://api.digitalocean.com/v2/domains/'+top+'/records/' + subdomain_id, headers=headers)
|
||||
if str(response) == '<Response [204]>':
|
||||
cursor.execute('DELETE from subdomains where id=?',(subdomain_id,))
|
||||
logging.info(time.strftime("%Y-%m-%d %H:%M") + ' - Info : Subdomain %s removed' %(domain))
|
||||
conn.commit()
|
||||
else:
|
||||
print('[red]Error: [/red]An error occurred! Please try again later!')
|
||||
|
||||
|
||||
|
||||
def edit_subdomain(domain):
|
||||
if set(domain).difference(ascii_letters + '.' + digits + '-' + '@'):
|
||||
print('[red]Error:[/red] Give the domain name in simple form e.g. [b]test.domain.com[/b]')
|
||||
else:
|
||||
parts = domain.split('.')
|
||||
if len(parts) > 3:
|
||||
top = parts[1] + '.' + parts[2] + '.' + parts[3]
|
||||
sub = parts[0]
|
||||
else:
|
||||
sub = parts[0]
|
||||
top = parts[1] + '.' + parts[2]
|
||||
longtop=sub+'.'+top
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT COUNT(*) FROM domains WHERE name like ? or name like ?',(top,longtop,))
|
||||
count = cursor.fetchone()[0]
|
||||
if count == 0:
|
||||
print('[red]Error:[/red] Top domain [bold]%s[/bold] does not exist in the DB. So I\'m giving up!.' % (top))
|
||||
else:
|
||||
cursor.execute('SELECT COUNT(*) FROM subdomains WHERE name like ? and main_id=(SELECT id from domains WHERE name like ? or name like ?)',(sub,top,longtop))
|
||||
count = cursor.fetchone()[0]
|
||||
if count == 0:
|
||||
print('[red]Error:[/red] Domain [bold]%s[/bold] does not exist in the DB. So I\'m giving up!.' % (domain))
|
||||
else:
|
||||
apikey = get_api()
|
||||
if apikey == None:
|
||||
print("[red]Error:[/red] Missing APIkey. Please add one!")
|
||||
else:
|
||||
cursor.execute('SELECT id,active FROM subdomains WHERE name like ? and main_id=(SELECT id from domains WHERE name like ? or name like ?)',(sub,top,longtop))
|
||||
domain_info = cursor.fetchone()
|
||||
subdomain_id = str(domain_info[0])
|
||||
status = domain_info[1]
|
||||
if status == 1:
|
||||
status = 0
|
||||
else:
|
||||
status = 1
|
||||
cursor.execute('UPDATE subdomains SET active = ? WHERE id = ?',(status,subdomain_id,))
|
||||
logging.info(time.strftime("%Y-%m-%d %H:%M") + ' - Info : Status for domain %s changed' %(domain))
|
||||
print('Status for domain %s changed' %(domain))
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
||||
def show_all_top_domains():
|
||||
cursor = conn.cursor()
|
||||
apikey = get_api()
|
||||
if apikey != None:
|
||||
req = urllib.request.Request('https://api.digitalocean.com/v2/domains/?per_page=200')
|
||||
req.add_header('Content-Type', 'application/json')
|
||||
req.add_header('Authorization', 'Bearer ' + apikey)
|
||||
current = urllib.request.urlopen(req)
|
||||
remote = current.read().decode('utf-8')
|
||||
remoteData = json.loads(remote)
|
||||
print('Domains in database are marked with a [*]')
|
||||
print('================================================')
|
||||
for k in remoteData["domains"]:
|
||||
cursor.execute('SELECT COUNT(*) FROM domains WHERE name like ?',(k['name'],))
|
||||
count = cursor.fetchone()[0]
|
||||
if count != 0:
|
||||
print('Name : [bold]'+k['name']+ ' [*][/bold]')
|
||||
else:
|
||||
print('Name : '+k['name'])
|
||||
|
||||
else:
|
||||
print("[red]Error:[/red] Missing APIkey. Please add one!")
|
||||
|
||||
|
||||
|
||||
def list_sub_domains(domain):
|
||||
apikey = get_api()
|
||||
cursor = conn.cursor()
|
||||
if apikey == None:
|
||||
print("[red]Error:[/red] Missing APIkey. Please add one!")
|
||||
else:
|
||||
cursor.execute('SELECT COUNT(*) FROM domains WHERE name LIKE ?',(domain,))
|
||||
count = cursor.fetchone()[0]
|
||||
if count == 0:
|
||||
print("[red]Error: [/red]No such domain. Check spelling or use ddns -d to show all top domains.")
|
||||
else:
|
||||
print('\n\nCurrent sub domains for [b]%s[/b]\n\n' % (domain))
|
||||
print('Domain\t\t\t\tCreated\t\t\tUpdated\t\t\tChecked\t\t\tActive')
|
||||
print('==================================================================================================================')
|
||||
cursor.execute('SELECT id FROM domains WHERE name LIKE ?', (domain,))
|
||||
topdomain_id = cursor.fetchone()[0]
|
||||
cursor.execute('SELECT COUNT(*) FROM subdomains WHERE main_id LIKE ?',(topdomain_id,))
|
||||
count = cursor.fetchone()[0]
|
||||
if count == 0:
|
||||
print('[red]Error:[/red] No sub domains for [b]%s[/b]' % (domain))
|
||||
else:
|
||||
cursor.execute('SELECT name,last_updated,last_checked,created,active FROM subdomains WHERE main_id LIKE ?',(topdomain_id,) )
|
||||
subdomains = cursor.fetchall()
|
||||
for i in subdomains:
|
||||
if i[4] == 1:
|
||||
active = 'True'
|
||||
else:
|
||||
active = 'False'
|
||||
topdomain = i[0]+'.'+domain
|
||||
topdomain = "{:<25}".format(topdomain)
|
||||
print(topdomain+'\t'+i[3]+'\t'+i[1]+'\t'+i[2]+'\t'+active)
|
||||
print('\n')
|
||||
|
||||
|
||||
def list_do_sub_domains(domain):
|
||||
apikey = get_api()
|
||||
cursor = conn.cursor()
|
||||
if apikey == None:
|
||||
print("[red]Error:[/red] Missing APIkey. Please add one!")
|
||||
else:
|
||||
req = urllib.request.Request('https://api.digitalocean.com/v2/domains/'+domain+'/records?type="A"/?per_page=200')
|
||||
req.add_header('Content-Type', 'application/json')
|
||||
req.add_header('Authorization', 'Bearer ' + apikey)
|
||||
current = urllib.request.urlopen(req)
|
||||
remote = current.read().decode('utf-8')
|
||||
remoteData = json.loads(remote)
|
||||
print('Domains in your DigitalOcean account not in ddns DB for [b]%s[/b]' % (domain))
|
||||
print('===================================================================')
|
||||
for k in remoteData["domain_records"]:
|
||||
if k['type'] == 'A':
|
||||
cursor.execute('SELECT COUNT(*) FROM subdomains WHERE id like ?',(str(k['id']),))
|
||||
count = cursor.fetchone()[0]
|
||||
if count == 0:
|
||||
print(k['name']+'.'+domain+'\t\tID : '+str(k['id']))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def domaininfo(domain):
|
||||
apikey = get_api()
|
||||
local_ip = get_ip()
|
||||
cursor = conn.cursor()
|
||||
if set(domain).difference(ascii_letters + '.' + digits + '@' + '-'):
|
||||
print('[red]Error:[/red]. Give the domain name in simple form e.g. [bold]test.domain.com[/bold]')
|
||||
else:
|
||||
parts = domain.split('.')
|
||||
if len(parts) > 3:
|
||||
top = parts[1] + '.' + parts[2] + '.' + parts[3]
|
||||
sub = parts[0]
|
||||
else:
|
||||
sub = parts[0]
|
||||
top = parts[1] + '.' + parts[2]
|
||||
cursor.execute('SELECT id FROM domains WHERE name like ?', (top,))
|
||||
domainid = cursor.fetchone()[0]
|
||||
cursor.execute('SELECT * FROM subdomains WHERE main_id like ?', (domainid,))
|
||||
domains = cursor.fetchall()
|
||||
if local_ip != domains[0][3]:
|
||||
localip = '[red]%s[/red]' % (local_ip)
|
||||
else:
|
||||
localip = local_ip
|
||||
print ('The domain [bold]%s[/bold] has the IP [bold]%s[/bold]. Your public IP is [bold]%s[/bold]' % (domain,domains[0][3],localip))
|
||||
|
||||
|
||||
|
||||
def show_current_info():
|
||||
ipserver = None
|
||||
API = get_api()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT COUNT(ip4_server) FROM ipservers')
|
||||
count = cursor.fetchone()[0]
|
||||
if count == 0:
|
||||
ipserver = '[red]Error:[/red] No IP resolvers in DB'
|
||||
else:
|
||||
cursor.execute('SELECT * FROM ipservers')
|
||||
ipservers = cursor.fetchall()
|
||||
ip4server = ipservers[0][1]
|
||||
ip6server = ipservers[0][2]
|
||||
|
||||
if API == None:
|
||||
API = '[red]Error:[/red] API key not stored in DB'
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM domains')
|
||||
topdomains = cursor.fetchone()[0]
|
||||
cursor.execute('SELECT COUNT(*) FROM subdomains')
|
||||
subdomains = cursor.fetchone()[0]
|
||||
|
||||
|
||||
print('\n[b]ddns[/b] - a DigitalOcean dynamic DNS solution.')
|
||||
print('===================================================')
|
||||
print('API key : [b]%s[/b]' % (API))
|
||||
print('IP v4 resolver : [b]%s[/b]' % (ip4server))
|
||||
print('IP v6 resolver : [b]%s[/b]' % (ip6server))
|
||||
print('Logfile : [b]%s[/b]' % (logfile))
|
||||
print('Top domains : [b]%s[/b]' % (topdomains))
|
||||
print('sub domains : [b]%s[/b]' % (subdomains))
|
||||
print('')
|
||||
print('App version : [b]%s[/b] (https://gitlab.pm/rune/ddns)' % (app_version))
|
||||
print('')
|
||||
print('[i]IPv6 is not supported and not listed here.[/i]')
|
||||
|
||||
|
||||
def ip_server(ipserver, ip_type):
|
||||
cursor = conn.cursor()
|
||||
if ip_type == '4':
|
||||
cursor.execute('SELECT COUNT(ip4_server) FROM ipservers')
|
||||
count = cursor.fetchone()[0]
|
||||
if count == 0:
|
||||
cursor.execute('INSERT INTO ipservers values(?,?,?)', (None, ipserver,None))
|
||||
conn.commit()
|
||||
print('New IP resolver (%s) for ipv%s added.' % (ipserver, ip_type))
|
||||
else:
|
||||
cursor.execute('UPDATE ipservers SET ip4_server = ? WHERE id = 1',(ipserver,))
|
||||
print('IP resolver (%s) for ipv%s updated.' % (ipserver, ip_type))
|
||||
logging.info(time.strftime("%Y-%m-%d %H:%M")+' - Info : IP resolver (%s) for ipv%s updated.' % (ipserver, ip_type))
|
||||
conn.commit()
|
||||
elif ip_type == '6':
|
||||
cursor.execute('SELECT COUNT(ip6_server) FROM ipservers')
|
||||
count = cursor.fetchone()[0]
|
||||
if count == 0:
|
||||
cursor.execute('INSERT INTO ipservers values(?,?,?)', (None, None,ipserver))
|
||||
conn.commit()
|
||||
print('New IP resolver (%s) for ipv%s added. \n\r This IP version is not supported.' % (ipserver, ip_type))
|
||||
else:
|
||||
cursor.execute('UPDATE ipservers SET ip6_server = ? WHERE id = 1',(ipserver,))
|
||||
print('IP resolver (%s) for ipv%s updated. \n\r This IP version is not supported.' % (ipserver, ip_type))
|
||||
logging.info(time.strftime("%Y-%m-%d %H:%M") + ' - Info : IP resolver (%s) for ipv%s updated.' % (ipserver, ip_type))
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
||||
def updateip(force):
|
||||
apikey = get_api()
|
||||
current_ip = get_ip()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT COUNT(*) FROM subdomains')
|
||||
count = cursor.fetchone()[0]
|
||||
now = datetime.now().strftime("%d-%m-%Y %H:%M")
|
||||
updated = None
|
||||
if count == 0:
|
||||
print('[red]Error: [/red]There are no dynamic domains active.'\
|
||||
' Start by adding a new domain with [i]ddns -s test.example.com[/i]')
|
||||
else:
|
||||
cursor.execute('SELECT id,active FROM subdomains')
|
||||
rows = cursor.fetchall()
|
||||
for i in rows:
|
||||
cursor.execute('SELECT name FROM domains WHERE id like (SELECT main_id from subdomains WHERE id = ?)',(i[0],))
|
||||
domain_info = cursor.fetchone()
|
||||
domain_name = str(domain_info[0])
|
||||
domain_status = i[1]
|
||||
subdomain_id = str(i[0])
|
||||
# Chek if an update is required
|
||||
if domain_status == 1:
|
||||
req = urllib.request.Request('https://api.digitalocean.com/v2/domains/' + domain_name + '/records/' + subdomain_id)
|
||||
req.add_header('Content-Type', 'application/json')
|
||||
req.add_header('Authorization', 'Bearer ' + apikey)
|
||||
# A dead record id (404) must not abort the whole run: log it,
|
||||
# leave the row alone, and carry on with the other subdomains.
|
||||
try:
|
||||
current = urllib.request.urlopen(req)
|
||||
remote = current.read().decode('utf-8')
|
||||
except Exception as e:
|
||||
cursor.execute('SELECT name FROM subdomains WHERE id = ?',(subdomain_id,))
|
||||
row = cursor.fetchone()
|
||||
sub_name = row[0] if row else '?'
|
||||
logging.error(time.strftime("%Y-%m-%d %H:%M")
|
||||
+ ' - Error : could not read record for %s.%s (id %s) : %s'
|
||||
% (sub_name, domain_name, subdomain_id, e))
|
||||
print('[red]Error:[/red] could not read record for %s.%s (id %s): %s'
|
||||
% (sub_name, domain_name, subdomain_id, e))
|
||||
continue
|
||||
remoteData = json.loads(remote)
|
||||
remoteIP4 = remoteData['domain_record']['data']
|
||||
domainname = str(remoteData['domain_record']['name'])
|
||||
if remoteIP4 != current_ip or force == True and domain_status == 1:
|
||||
updated = True
|
||||
data = {'type': 'A', 'data': current_ip}
|
||||
headers = {'Authorization': 'Bearer ' + apikey, "Content-Type": "application/json"}
|
||||
response = requests.patch('https://api.digitalocean.com/v2/domains/'+domain_name+'/records/' + subdomain_id, data=json.dumps(data), headers=headers)
|
||||
if str(response) != '<Response [200]>':
|
||||
logging.error(time.strftime("%Y-%m-%d %H:%M")+' - Error updating ('+str(domain_name)+') : ' + str(response.content))
|
||||
else:
|
||||
cursor.execute('UPDATE subdomains SET current_ip4=? WHERE id = ?',(current_ip,subdomain_id,))
|
||||
cursor.execute('UPDATE subdomains SET last_updated=? WHERE id = ?',(now,subdomain_id,))
|
||||
cursor.execute('UPDATE subdomains SET last_checked=? WHERE id = ?',(now,subdomain_id,))
|
||||
conn.commit()
|
||||
else:
|
||||
cursor.execute('UPDATE subdomains SET last_checked=? WHERE id = ?',(now,subdomain_id,))
|
||||
conn.commit()
|
||||
|
||||
if updated == None:
|
||||
logging.info(time.strftime("%Y-%m-%d %H:%M") + ' - Info : No updated necessary')
|
||||
else:
|
||||
logging.info(time.strftime("%Y-%m-%d %H:%M") + ' - Info : Updates done. Use ddns -l domain.com to check domain')
|
||||
|
||||
|
||||
|
||||
def local_add_subdomain(domain,domainid):
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
if set(domain).difference(ascii_letters + '.' + digits + '-' + '@'):
|
||||
print('[red]Error:[/red] Give the domain name in simple form e.g. [b]test.domain.com[/b]')
|
||||
else:
|
||||
parts = domain.split('.')
|
||||
if len(parts) > 3:
|
||||
top = parts[1] + '.' + parts[2] + '.' + parts[3]
|
||||
sub = parts[0]
|
||||
|
||||
else:
|
||||
sub = parts[0]
|
||||
top = parts[1] + '.' + parts[2]
|
||||
apikey = get_api()
|
||||
longtop=sub+'.'+top
|
||||
if apikey == None:
|
||||
print("[red]Error:[/red] Missing APIkey. Please add one!")
|
||||
else:
|
||||
ip = get_ip()
|
||||
if ip == None or 'urlopen error' in ip:
|
||||
print('[red]Error:[/red] Failed to get public IP. Do you have a typo in your URI? [red]Error %s.[/red]' % (ip))
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT COUNT(*) FROM domains WHERE name like ? or name like ?',(top,longtop,))
|
||||
count = cursor.fetchone()[0]
|
||||
if count == 0:
|
||||
print('[red]Error:[/red] Top domain [bold]%s[/bold] does not exist in the DB. Please add it with [i]ddns -t %s[/i].' % (top,top))
|
||||
else:
|
||||
cursor.execute('SELECT id FROM domains WHERE name LIKE ? or name like ?',(top,longtop,))
|
||||
topdomain_id = cursor.fetchone()
|
||||
topdomain_id = topdomain_id[0]
|
||||
cursor.execute('SELECT count(*) FROM subdomains WHERE main_id LIKE ? AND name like ?',(topdomain_id,sub,))
|
||||
count = cursor.fetchone()[0]
|
||||
if count != 0:
|
||||
print('[red]Error:[/red] [bold]%s[/bold] already exists.' % (domain))
|
||||
else:
|
||||
cursor.execute('INSERT INTO subdomains values(?,?,?,?,?,?,?,?,?)',(domainid,topdomain_id,sub,ip,None,now,now,now,1,))
|
||||
conn.commit()
|
||||
print('The domain %s has been added.' % (domain))
|
||||
|
||||
|
||||
def show_log():
|
||||
log_file = open(logfile, 'r')
|
||||
content = log_file.read()
|
||||
print(content)
|
||||
log_file.close()
|
||||
|
||||
|
||||
def updatedb():
|
||||
# Update DB with new column 20.03.23
|
||||
# Add last updated field for subdomains
|
||||
new_column = 'last_updated'
|
||||
info = conn.execute("PRAGMA table_info('subdomains')").fetchall()
|
||||
if not any(new_column in word for word in info):
|
||||
add_column = "ALTER TABLE subdomains ADD COLUMN last_updated text default 'N/A'"
|
||||
conn.execute(add_column)
|
||||
conn.commit()
|
||||
logging.info(time.strftime("%Y-%m-%d %H:%M")+' - Info : Database updated')
|
||||
|
||||
new_column = 'last_checked'
|
||||
info = conn.execute("PRAGMA table_info('subdomains')").fetchall()
|
||||
if not any(new_column in word for word in info):
|
||||
add_column = "ALTER TABLE subdomains ADD COLUMN last_checked text default 'N/A'"
|
||||
conn.execute(add_column)
|
||||
conn.commit()
|
||||
logging.info(time.strftime("%Y-%m-%d %H:%M") + ' - Info : Database updated')
|
||||
|
||||
new_column = 'created'
|
||||
info = conn.execute("PRAGMA table_info('subdomains')").fetchall()
|
||||
if not any(new_column in word for word in info):
|
||||
add_column = "ALTER TABLE subdomains ADD COLUMN created text default '[b]Unknown Info[/b]'"
|
||||
conn.execute(add_column)
|
||||
conn.commit()
|
||||
logging.info(time.strftime("%Y-%m-%d %H:%M") + ' - Info : Database updated')
|
||||
|
||||
new_column = 'active'
|
||||
info = conn.execute("PRAGMA table_info('subdomains')").fetchall()
|
||||
if not any(new_column in word for word in info):
|
||||
add_column = "ALTER TABLE subdomains ADD COLUMN active integer default 1"
|
||||
conn.execute(add_column)
|
||||
conn.commit()
|
||||
logging.info(time.strftime("%Y-%m-%d %H:%M") + ' - Info : Database updated')
|
||||
|
||||
|
||||
|
||||
# Commandline arguments
|
||||
conn = connect_database()
|
||||
updatedb()
|
||||
|
||||
parser = argparse.ArgumentParser(prog='ddns',
|
||||
description='Application to use domains from DigitalOcean account as dynamic '\
|
||||
'DNS domain(s).\nThe app only supports IP4. IPv6 is planned for a later release!'\
|
||||
'\nYou\'ll always find the latest version on https://gitlab.pm/rune/ddns\n\n'\
|
||||
'For bugs, suggestions, pull requests visit https://gitlab.pm/rune/ddns/issues',
|
||||
formatter_class=RawTextHelpFormatter,
|
||||
epilog='Making Selfhosting easier...')
|
||||
|
||||
parser.add_argument('-a', '--api', help='Add/Change API key.\n\n',
|
||||
nargs=1, metavar=('APIkey'), required=False, action="append")
|
||||
|
||||
parser.add_argument('-f', '--force', help='Force update of IP address for all domains.\n\n',
|
||||
required=False, action="store_true")
|
||||
|
||||
parser.add_argument('-l', '--list', help='List subdomains for supplied domain.\n\n',
|
||||
nargs=1, metavar=('domain'), required=False, action="append")
|
||||
|
||||
parser.add_argument('-o', '--serverdomains', help='List subdomains for supplied domain not in ddns DB.\n\n',
|
||||
nargs=1, metavar=('domain'), required=False, action="append")
|
||||
|
||||
parser.add_argument('-d', '--domains', help='List top domains in your DigitalOcean account.\n\n',
|
||||
required=False, action="store_true")
|
||||
|
||||
parser.add_argument('-c', '--current', help='List the current IP address for the sub-domain given\n\n',
|
||||
required=False, nargs=1, action="append")
|
||||
|
||||
parser.add_argument('-t', '--top', help='Add a new domain from your DigitalOcean account to use as a dynamic DNS domain\n\n',
|
||||
required=False, nargs=1, metavar=('domain'), action='append')
|
||||
|
||||
parser.add_argument('-s', '--sub', help='Add a new subdomain to your DigitalOcean account and use as dynamic DNS.\n\n\n',
|
||||
required=False, nargs=1, metavar=('domain'), action='append')
|
||||
|
||||
parser.add_argument('-k', '--local', help='Add an existing DigitalOcean subdomain to your ddns DB and use as dynamic DNS.\n\n',
|
||||
required=False, nargs=2, metavar=('domain','domainid'), action='append')
|
||||
|
||||
parser.add_argument('-r', '--remove', help='Remove a subdomain from your DigitalOcean account and ddns.\n\n',
|
||||
required=False, nargs=1, metavar=('domain'), action='append')
|
||||
|
||||
parser.add_argument('-v', '--version', help='Show current version and config info\n\n',
|
||||
required=False, action='store_true')
|
||||
|
||||
parser.add_argument('-q', '--log', help=argparse.SUPPRESS, required=False, action='store_true')
|
||||
|
||||
|
||||
parser.add_argument('-p', '--ipserver', help='Sets or updates IP server lookup to use. Indicate 4 or 6 for IP type.\n\n',
|
||||
required=False, nargs=2, metavar=('ip4.iurl.no', '4'), action="append")
|
||||
|
||||
parser.add_argument('-e', '--edit', help='Changes domain from active to inactive or the other way around...',
|
||||
required=False, nargs=1, metavar=('test.example.com'), action="append")
|
||||
args = vars(parser.parse_args())
|
||||
|
||||
if args['list']:
|
||||
list_sub_domains(args['list'][0][0])
|
||||
elif args['domains']:
|
||||
show_all_top_domains()
|
||||
elif args['serverdomains']:
|
||||
list_do_sub_domains(args['serverdomains'][0][0])
|
||||
elif args['current']:
|
||||
domaininfo(args['current'][0][0])
|
||||
elif args['top']:
|
||||
add_domian(args['top'][0][0])
|
||||
elif args['sub']:
|
||||
add_subdomain(args['sub'][0][0])
|
||||
elif args['version']:
|
||||
show_current_info()
|
||||
elif args['force']:
|
||||
updateip(True)
|
||||
elif args['log']:
|
||||
show_log()
|
||||
elif args['ipserver']:
|
||||
ip_server(args['ipserver'][0][0],args['ipserver'][0][1])
|
||||
elif args['api']:
|
||||
api(args['api'][0][0])
|
||||
elif args['remove']:
|
||||
remove_subdomain(args['remove'][0][0])
|
||||
elif args['edit']:
|
||||
edit_subdomain(args['edit'][0][0])
|
||||
elif args['local']:
|
||||
local_add_subdomain(args['local'][0][0],args['local'][0][1])
|
||||
else:
|
||||
updateip(None)
|
||||
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/bin/zsh
|
||||
#
|
||||
# Publish public DNS records for the Caddy site blocks that are explicitly
|
||||
# marked public, and refresh the A record for the current WAN IP.
|
||||
#
|
||||
# A site block is published ONLY if the line immediately above it is exactly
|
||||
#
|
||||
# # ddns: public
|
||||
#
|
||||
# Anything unmarked stays off public DNS. That default matters: this script
|
||||
# used to scrape *every* site block, which is how internal-only services ended
|
||||
# up with public records (and, via Certificate Transparency, public hostnames).
|
||||
# Forgetting a marker now fails closed.
|
||||
|
||||
CADDYFILE=/home/connor/Caddyfile
|
||||
NTFY_ENV=/home/connor/.config/ntfy/publish.env
|
||||
NTFY_URL=https://ntfy.rcjohnstone.com/infra
|
||||
|
||||
notify() {
|
||||
# Best-effort: never let a notification failure fail the run.
|
||||
[[ -r $NTFY_ENV ]] || return 0
|
||||
local user pass
|
||||
user=$(sed -n 's/^NTFY_USER=//p' $NTFY_ENV)
|
||||
pass=$(sed -n 's/^NTFY_PASS=//p' $NTFY_ENV)
|
||||
[[ -n $user && -n $pass ]] || return 0
|
||||
curl -sS -m 15 -u "$user:$pass" \
|
||||
-H "Title: $1" -H "Priority: ${3:-default}" -H "Tags: ${4:-warning}" \
|
||||
-d "$2" $NTFY_URL >/dev/null 2>&1
|
||||
return 0
|
||||
}
|
||||
|
||||
fail() {
|
||||
print -u2 "ddns_update: $1"
|
||||
notify "DDNS update failed" "$1" high rotating_light
|
||||
exit 1
|
||||
}
|
||||
|
||||
ip=$(curl -sq4 -m 20 ifconfig.me)
|
||||
if [[ -z "$ip" ]]; then
|
||||
fail "could not determine public IPv4, aborting"
|
||||
fi
|
||||
|
||||
if ! out=$(ddns -p $ip 4 2>&1); then
|
||||
fail "ddns -p failed:\n$out"
|
||||
fi
|
||||
|
||||
# Emit the hostname from each site block preceded by the marker. Blank lines
|
||||
# between the marker and the block are tolerated; anything else resets it.
|
||||
subdomains=$(awk '
|
||||
/^[[:space:]]*#[[:space:]]*ddns:[[:space:]]*public[[:space:]]*$/ { pub=1; next }
|
||||
/^[[:space:]]*$/ { next }
|
||||
/^[^[:space:]#].*\{[[:space:]]*$/ {
|
||||
if (pub) { name=$0; sub(/[{,].*/, "", name); gsub(/[[:space:]]/, "", name); print name }
|
||||
pub=0; next
|
||||
}
|
||||
{ pub=0 }
|
||||
' $CADDYFILE \
|
||||
| grep -E '(^|\.)rcjohnstone\.com$' \
|
||||
| sed 's/^rcjohnstone\.com$/@.rcjohnstone.com/' \
|
||||
| sort -u)
|
||||
|
||||
if [[ -z "$subdomains" ]]; then
|
||||
fail "no '# ddns: public' markers found in $CADDYFILE -- refusing to continue"
|
||||
fi
|
||||
|
||||
added=()
|
||||
for subdomain in ${(f)subdomains}
|
||||
do
|
||||
# already-tracked names just print "already exists"; that is not an error
|
||||
if out=$(ddns -s $subdomain 2>&1); then
|
||||
print -r -- "$out" | grep -v 'already exists'
|
||||
print -r -- "$out" | grep -q 'already exists' || added+=$subdomain
|
||||
else
|
||||
print -u2 "ddns_update: failed to add $subdomain:\n$out"
|
||||
fi
|
||||
done
|
||||
|
||||
if (( ${#added} )); then
|
||||
notify "DDNS: new public records" \
|
||||
"Now publicly resolvable:\n${(F)added}" default globe_with_meridians
|
||||
fi
|
||||
|
||||
# Reconcile. The bare `ddns` below refreshes every subdomain in ddns.db, not
|
||||
# just the ones we asked for -- that is how `spanish` acquired a public record
|
||||
# despite having no marker. Anything tracked locally but no longer marked is
|
||||
# drift, and drift here means a service is on the internet that we did not
|
||||
# intend to publish. Report it loudly rather than silently republishing.
|
||||
tracked=$(python3 - <<'EOF' 2>/dev/null
|
||||
import sqlite3
|
||||
try:
|
||||
c = sqlite3.connect('/home/connor/.config/ddns/ddns.db')
|
||||
print("\n".join(sorted(r[0] for r in c.execute("select name from subdomains"))))
|
||||
except Exception:
|
||||
pass
|
||||
EOF
|
||||
)
|
||||
if [[ -n "$tracked" ]]; then
|
||||
marked=$(print -r -- "$subdomains" | sed 's/\.rcjohnstone\.com$//' | sed 's/^@$/@/' | sort -u)
|
||||
drift=$(comm -23 <(print -r -- "$tracked") <(print -r -- "$marked"))
|
||||
if [[ -n "$drift" ]]; then
|
||||
print -u2 "ddns_update: tracked but NOT marked public in the Caddyfile:\n$drift"
|
||||
notify "DDNS drift: unmarked names still published" \
|
||||
"These have DNS records but no '# ddns: public' marker:\n$drift\n\nRemove them from DigitalOcean and from ddns.db, or add the marker." \
|
||||
high warning
|
||||
fi
|
||||
fi
|
||||
|
||||
ddns
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/python3
|
||||
"""Import Dreaming Spanish progress into Traggo as tagged time spans.
|
||||
|
||||
ds_to_traggo [--apply] [--from YYYY-MM-DD] [--include-initial]
|
||||
|
||||
Dry-run unless --apply is given. Safe to re-run: spans this tool created are
|
||||
marked in their note and are not recreated.
|
||||
|
||||
How the two DS endpoints relate (verified against live data):
|
||||
dayWatchedTime is the daily total of EVERYTHING -- platform videos plus
|
||||
every external entry, including 'talking'.
|
||||
externalTime is the itemised list of off-platform entries.
|
||||
So listening must be derived by subtraction, not by adding the two together:
|
||||
mode:speaking = external 'talking'
|
||||
mode:listening = dayWatchedTime - talking (platform + watching + listening)
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
DS_BASE = "https://app.dreaming.com/.netlify/functions"
|
||||
DS_TOKEN_FILE = "/tmp/dreaming_token"
|
||||
TRAGGO = "https://time.rcjohnstone.com/graphql"
|
||||
TZ = ZoneInfo("America/Louisville")
|
||||
MARKER = "[ds-import]"
|
||||
TAGS = {"language": "#4a90d9", "mode": "#7ab317"}
|
||||
|
||||
|
||||
def ds_get(endpoint, token):
|
||||
req = urllib.request.Request(
|
||||
"%s/%s" % (DS_BASE, endpoint), headers={"Authorization": "Bearer " + token})
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def gql(query, variables, token):
|
||||
body = json.dumps({"query": query, "variables": variables}).encode()
|
||||
req = urllib.request.Request(
|
||||
TRAGGO, data=body,
|
||||
headers={"Content-Type": "application/json",
|
||||
"Authorization": "traggo " + token})
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
out = json.loads(r.read())
|
||||
if out.get("errors"):
|
||||
raise RuntimeError(json.dumps(out["errors"])[:300])
|
||||
return out["data"]
|
||||
|
||||
|
||||
def traggo_login():
|
||||
pw = subprocess.run(["rbw", "get", "Traggo"],
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
if not pw:
|
||||
sys.exit("Could not read the Traggo password from Bitwarden (rbw get Traggo).")
|
||||
q = ("mutation($u:String!,$p:String!){login(username:$u,pass:$p,"
|
||||
"deviceName:\"ds-import\",type:NoExpiry,cookie:false){token}}")
|
||||
return gql(q, {"u": "connor", "p": pw}, "")["login"]["token"]
|
||||
|
||||
|
||||
def ensure_tags(token):
|
||||
have = {t["key"] for t in gql("{tags{key}}", {}, token)["tags"]}
|
||||
for key, color in TAGS.items():
|
||||
if key not in have:
|
||||
gql("mutation($k:String!,$c:String!){createTag(key:$k,color:$c){key}}",
|
||||
{"k": key, "c": color}, token)
|
||||
print(" created tag key: %s" % key)
|
||||
|
||||
|
||||
def existing(token, first, last):
|
||||
"""Dates already imported, as {(date, mode)}."""
|
||||
q = ("query($f:Time!,$t:Time!,$c:InputCursor){timeSpans(fromInclusive:$f,"
|
||||
"toInclusive:$t,cursor:$c){cursor{hasMore offset startId pageSize}"
|
||||
"timeSpans{start note tags{key value}}}}")
|
||||
seen, cursor = set(), {"offset": 0, "pageSize": 200}
|
||||
while True:
|
||||
page = gql(q, {"f": first, "t": last, "c": cursor}, token)["timeSpans"]
|
||||
for ts in page["timeSpans"]:
|
||||
if MARKER not in (ts.get("note") or ""):
|
||||
continue
|
||||
mode = next((t["value"] for t in ts["tags"] if t["key"] == "mode"), None)
|
||||
seen.add((ts["start"][:10], mode))
|
||||
c = page["cursor"]
|
||||
if not c["hasMore"]:
|
||||
return seen
|
||||
cursor = {"offset": c["offset"], "startId": c["startId"],
|
||||
"pageSize": c["pageSize"]}
|
||||
|
||||
|
||||
def build(ds_token, start_from, include_initial):
|
||||
ext = ds_get("externalTime", ds_token)["externalTimes"]
|
||||
day = {d["date"]: d["timeSeconds"] for d in ds_get("dayWatchedTime", ds_token)}
|
||||
|
||||
talk, notes = defaultdict(int), defaultdict(list)
|
||||
for e in ext:
|
||||
if e["type"] == "initial" and not include_initial:
|
||||
continue
|
||||
if e["type"] == "talking":
|
||||
talk[e["date"]] += e["timeSeconds"]
|
||||
if e.get("description"):
|
||||
notes[(e["date"], "speaking")].append(e["description"])
|
||||
elif e.get("description"):
|
||||
notes[(e["date"], "listening")].append(e["description"])
|
||||
|
||||
plan = []
|
||||
for date in sorted(day):
|
||||
if start_from and date < start_from:
|
||||
continue
|
||||
listening = day[date] - talk.get(date, 0)
|
||||
speaking = talk.get(date, 0)
|
||||
# Anchor at midnight local; speaking picks up where listening ends so
|
||||
# the two never overlap in the calendar view. DS records daily totals
|
||||
# only, so the clock times are synthetic either way.
|
||||
cursor = datetime(int(date[:4]), int(date[5:7]), int(date[8:10]),
|
||||
tzinfo=TZ)
|
||||
for mode, secs in (("listening", listening), ("speaking", speaking)):
|
||||
if secs <= 0:
|
||||
continue
|
||||
end = cursor + timedelta(seconds=secs)
|
||||
desc = ", ".join(dict.fromkeys(notes.get((date, mode), [])))
|
||||
plan.append({
|
||||
"date": date, "mode": mode, "seconds": secs,
|
||||
"start": cursor.isoformat(), "end": end.isoformat(),
|
||||
"note": ("%s %s" % (MARKER, desc)).strip(),
|
||||
})
|
||||
cursor = end
|
||||
return plan
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--apply", action="store_true")
|
||||
p.add_argument("--from", dest="start_from", metavar="YYYY-MM-DD")
|
||||
p.add_argument("--include-initial", action="store_true",
|
||||
help="also import the 50h 'time prior to Dreaming Spanish' entry")
|
||||
a = p.parse_args()
|
||||
|
||||
ds_token = open(DS_TOKEN_FILE).read().strip()
|
||||
plan = build(ds_token, a.start_from, a.include_initial)
|
||||
if not plan:
|
||||
print("Nothing to import.")
|
||||
return
|
||||
|
||||
token = traggo_login()
|
||||
ensure_tags(token)
|
||||
already = existing(token, plan[0]["start"], plan[-1]["end"])
|
||||
todo = [s for s in plan if (s["date"], s["mode"]) not in already]
|
||||
|
||||
hrs = lambda rows, m: sum(r["seconds"] for r in rows if r["mode"] == m) / 3600
|
||||
print(" planned : %d spans (%.1fh listening, %.1fh speaking) over %s..%s"
|
||||
% (len(plan), hrs(plan, "listening"), hrs(plan, "speaking"),
|
||||
plan[0]["date"], plan[-1]["date"]))
|
||||
print(" already : %d spans present from a previous run" % (len(plan) - len(todo)))
|
||||
print(" to create: %d spans (%.1fh listening, %.1fh speaking)"
|
||||
% (len(todo), hrs(todo, "listening"), hrs(todo, "speaking")))
|
||||
|
||||
if not a.apply:
|
||||
print("\n first 5:")
|
||||
for s in todo[:5]:
|
||||
print(" %s %-9s %5.0fm %s" % (s["date"], s["mode"],
|
||||
s["seconds"] / 60, s["note"][:48]))
|
||||
print("\n Dry run. Re-run with --apply to write these to Traggo.")
|
||||
return
|
||||
|
||||
q = ("mutation($s:Time!,$e:Time!,$t:[InputTimeSpanTag!],$n:String!){"
|
||||
"createTimeSpan(start:$s,end:$e,tags:$t,note:$n){id}}")
|
||||
made = 0
|
||||
for s in todo:
|
||||
tags = [{"key": "language", "value": "spanish"},
|
||||
{"key": "mode", "value": s["mode"]}]
|
||||
try:
|
||||
gql(q, {"s": s["start"], "e": s["end"], "t": tags, "n": s["note"]}, token)
|
||||
made += 1
|
||||
except Exception as err:
|
||||
print(" FAILED %s %s: %s" % (s["date"], s["mode"], err))
|
||||
print(" created %d/%d spans" % (made, len(todo)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+443
@@ -0,0 +1,443 @@
|
||||
#!/usr/bin/python3
|
||||
"""Archive stale unread mail out of the Proton inbox. Never deletes.
|
||||
|
||||
The inbox is meant to hold new mail plus read mail kept on purpose. Unread mail
|
||||
accumulates faster than it gets triaged, so anything still unread after a few
|
||||
days is moved to Archive.
|
||||
|
||||
Deliberately has no LLM in it. Of ~1200 inbox messages sampled, 81% carry a
|
||||
machine-readable bulk marker, and the ~19% residual is transactional rather
|
||||
than personal. Since transactional mail is archived on the same rule, every
|
||||
decision reduces to a flag test, a set membership test or a string match --
|
||||
deterministic, explainable in the log, and not improved by a model.
|
||||
|
||||
Safety, in order of importance:
|
||||
* nothing is ever deleted; the only operation is IMAP MOVE to Archive
|
||||
* BODY.PEEK everywhere, so nothing is ever marked read as a side effect
|
||||
* INBOX is the only mailbox ever opened read-write
|
||||
* every archived message is recorded with its Message-ID and the rule that
|
||||
fired, and any run can be undone
|
||||
* a message with no Message-ID is left alone, because it could not be undone
|
||||
* two interlocks abort the run if the mailbox does not look like itself
|
||||
"""
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import email
|
||||
import email.policy
|
||||
import email.utils
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import tomllib
|
||||
|
||||
# Shared with rent_utilities. sys.path rather than PYTHONPATH so this works
|
||||
# when run by hand as well as from the unit.
|
||||
sys.path.insert(0, os.path.expanduser("~/.local/lib/pymail"))
|
||||
from protonimap import ( # noqa: E402
|
||||
Fatal, imap_connect, imap_date, notify_failure, push, _quote)
|
||||
|
||||
CONFIG = os.path.expanduser("~/.config/inbox-tidy/tidy.toml")
|
||||
LOG = os.path.expanduser("~/.local/share/inbox-tidy/archived.jsonl")
|
||||
|
||||
HEADERS = ("FROM SUBJECT DATE MESSAGE-ID LIST-UNSUBSCRIBE LIST-ID PRECEDENCE "
|
||||
"AUTO-SUBMITTED FEEDBACK-ID X-CAMPAIGNID")
|
||||
BULK_HEADERS = ("List-Unsubscribe", "List-Id", "Feedback-ID", "X-CampaignID")
|
||||
|
||||
# Skip reasons, in evaluation order. Also the keys in the run summary.
|
||||
KEPT_FLAGGED = "flagged-or-answered"
|
||||
KEPT_SENDER = "protected-sender"
|
||||
KEPT_CORRESPONDENT = "known-correspondent"
|
||||
KEPT_SUBJECT = "protected-subject"
|
||||
KEPT_NO_MSGID = "no-message-id"
|
||||
ARCHIVE = "archive"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def load_config(path=None):
|
||||
path = path or CONFIG
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
return tomllib.load(fh)
|
||||
except (OSError, tomllib.TOMLDecodeError) as exc:
|
||||
raise Fatal("Cannot read %s: %s" % (path, exc))
|
||||
|
||||
|
||||
def addr_of(header_value):
|
||||
"""Bare lowercase address out of a From/To header, or ''."""
|
||||
if not header_value:
|
||||
return ""
|
||||
pairs = email.utils.getaddresses([str(header_value)])
|
||||
return pairs[0][1].lower().strip() if pairs and pairs[0][1] else ""
|
||||
|
||||
|
||||
def domain_of(addr):
|
||||
return addr.rsplit("@", 1)[-1] if "@" in addr else ""
|
||||
|
||||
|
||||
def sender_protected(addr, entries):
|
||||
"""An entry containing @ matches the address; otherwise the domain.
|
||||
|
||||
Domain entries match subdomains too, so `chase.com` also covers
|
||||
`fraudalert.chase.com` -- which is the case that actually matters.
|
||||
"""
|
||||
dom = domain_of(addr)
|
||||
for e in entries:
|
||||
e = e.lower().strip()
|
||||
if not e:
|
||||
continue
|
||||
if "@" in e:
|
||||
if addr == e:
|
||||
return True
|
||||
elif dom == e or dom.endswith("." + e):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def fetch_headers(conn, ids, extra="FLAGS"):
|
||||
"""[(uid, flags, email.Message)] for a list of sequence ids, batched.
|
||||
|
||||
BODY.PEEK, never BODY: fetching a body with BODY would set \\Seen on mail
|
||||
this job exists to leave unread.
|
||||
"""
|
||||
out = []
|
||||
for i in range(0, len(ids), 200):
|
||||
chunk = b",".join(ids[i:i + 200]).decode()
|
||||
typ, data = conn.fetch(
|
||||
chunk, "(UID %s BODY.PEEK[HEADER.FIELDS (%s)])" % (extra, HEADERS))
|
||||
if typ != "OK":
|
||||
continue
|
||||
for item in data:
|
||||
if not isinstance(item, tuple):
|
||||
continue
|
||||
meta = item[0].decode(errors="replace")
|
||||
m_uid = re.search(r"UID (\d+)", meta)
|
||||
m_fl = re.search(r"FLAGS \(([^)]*)\)", meta)
|
||||
if not m_uid:
|
||||
continue
|
||||
msg = email.message_from_bytes(item[1], policy=email.policy.default)
|
||||
out.append((m_uid.group(1), (m_fl.group(1) if m_fl else ""), msg))
|
||||
return out
|
||||
|
||||
|
||||
def correspondents(conn, cfg):
|
||||
"""Addresses this account has actually written to, from the Sent folder.
|
||||
|
||||
A behavioural allowlist: someone you have emailed is someone whose mail
|
||||
should not be swept up. Cheap to derive and needs no maintenance.
|
||||
"""
|
||||
box = cfg["imap"].get("sent_mailbox", "Sent")
|
||||
typ, _ = conn.select(_quote(box), readonly=True)
|
||||
if typ != "OK":
|
||||
raise Fatal("Cannot open the Sent mailbox %r to build the "
|
||||
"correspondent allowlist." % box)
|
||||
typ, data = conn.search(None, "ALL")
|
||||
ids = data[0].split() if typ == "OK" else []
|
||||
ids = ids[-cfg["imap"].get("sent_scan_limit", 3000):]
|
||||
found = set()
|
||||
for i in range(0, len(ids), 200):
|
||||
chunk = b",".join(ids[i:i + 200]).decode()
|
||||
typ, data = conn.fetch(
|
||||
chunk, "(BODY.PEEK[HEADER.FIELDS (TO CC BCC)])")
|
||||
if typ != "OK":
|
||||
continue
|
||||
for item in data:
|
||||
if not isinstance(item, tuple):
|
||||
continue
|
||||
h = email.message_from_bytes(item[1], policy=email.policy.default)
|
||||
for field in ("To", "Cc", "Bcc"):
|
||||
for _, a in email.utils.getaddresses(h.get_all(field, [])):
|
||||
if a and "@" in a:
|
||||
found.add(a.lower().strip())
|
||||
return found
|
||||
|
||||
|
||||
def decide(msg, flags, cfg, corr):
|
||||
"""-> (outcome, detail). Guards first; anything uncertain is kept."""
|
||||
if "\\Flagged" in flags or "\\Answered" in flags:
|
||||
return KEPT_FLAGGED, flags.strip()
|
||||
|
||||
frm = addr_of(msg.get("From"))
|
||||
if sender_protected(frm, cfg.get("protect_senders", [])):
|
||||
return KEPT_SENDER, frm
|
||||
if frm and frm in corr:
|
||||
return KEPT_CORRESPONDENT, frm
|
||||
|
||||
subject = str(msg.get("Subject", ""))
|
||||
for pat in cfg.get("protect_subjects", []):
|
||||
if re.search(pat, subject, re.I):
|
||||
return KEPT_SUBJECT, pat
|
||||
|
||||
# Undo works by Message-ID. Without one the move could not be reversed,
|
||||
# so it is not made -- a message stuck in the inbox is a far smaller
|
||||
# problem than one that cannot be brought back.
|
||||
if not str(msg.get("Message-ID", "")).strip():
|
||||
return KEPT_NO_MSGID, ""
|
||||
|
||||
return ARCHIVE, ""
|
||||
|
||||
|
||||
def bulk_markers(msg):
|
||||
return [h for h in BULK_HEADERS if msg.get(h)] + (
|
||||
["Precedence"] if (msg.get("Precedence") or "").lower().strip()
|
||||
in ("bulk", "list", "junk") else [])
|
||||
|
||||
|
||||
def log_records(records):
|
||||
with open(LOG, "a") as fh:
|
||||
for r in records:
|
||||
fh.write(json.dumps(r, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def read_log():
|
||||
try:
|
||||
with open(LOG) as fh:
|
||||
return [json.loads(l) for l in fh if l.strip()]
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# modes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def gather(conn, cfg, args):
|
||||
"""-> (decisions, corr) where decisions is [(uid, msg, outcome, detail)]."""
|
||||
corr = correspondents(conn, cfg)
|
||||
minc = cfg.get("min_correspondents", 25)
|
||||
if len(corr) < minc and not args.check:
|
||||
raise Fatal(
|
||||
"The correspondent allowlist has only %d address(es); %d are "
|
||||
"required.\n\n"
|
||||
"That guard is derived from the Sent mailbox, so a nearly empty "
|
||||
"Sent means the guard is silently doing nothing -- mail from "
|
||||
"people you actually write to would be archived like any "
|
||||
"circular. Refusing to run.\n\n"
|
||||
"If Bridge is still doing its first sync, wait for it to finish "
|
||||
"and try again:\n"
|
||||
" sudo podman logs --tail 5 connor_protonmail-bridge_1\n"
|
||||
"Lower `min_correspondents` in %s only if this account genuinely "
|
||||
"sends very little mail." % (len(corr), minc, CONFIG))
|
||||
|
||||
conn.select("INBOX", readonly=True)
|
||||
cutoff = dt.date.today() - dt.timedelta(days=cfg.get("age_days", 3))
|
||||
typ, data = conn.search(None, '(UNSEEN BEFORE "%s")' % imap_date(cutoff))
|
||||
if typ != "OK":
|
||||
raise Fatal("IMAP search of INBOX failed: %s" % typ)
|
||||
ids = data[0].split()
|
||||
|
||||
cap = cfg.get("expected_unread_max", 900)
|
||||
if len(ids) > cap and not args.check:
|
||||
raise Fatal(
|
||||
"%d messages in INBOX are unread and older than %d days, which is "
|
||||
"above the sanity limit of %d.\n\n"
|
||||
"This limit exists because Bridge's \\Seen flags did not initially "
|
||||
"match the Proton UI -- it reported 1249 unread where the UI "
|
||||
"showed 646. Archiving on wrong flags would move READ mail, which "
|
||||
"is exactly the mail the inbox is meant to keep.\n\n"
|
||||
"Compare `inbox_tidy --check` against the unread count in the "
|
||||
"Proton web UI. If they agree, this really is the backlog and "
|
||||
"`expected_unread_max` in %s should be raised. If they disagree, "
|
||||
"do not run this." % (len(ids), cfg.get("age_days", 3), cap, CONFIG))
|
||||
|
||||
decisions = []
|
||||
for uid, flags, msg in fetch_headers(conn, ids):
|
||||
outcome, detail = decide(msg, flags, cfg, corr)
|
||||
decisions.append((uid, msg, outcome, detail))
|
||||
return decisions, corr
|
||||
|
||||
|
||||
def do_check(conn, cfg):
|
||||
print("Mailbox state (compare INBOX unseen against the Proton web UI):\n")
|
||||
for box in ("INBOX", "Archive", cfg["imap"].get("sent_mailbox", "Sent")):
|
||||
typ, st = conn.status(_quote(box), "(MESSAGES UNSEEN)")
|
||||
print(" %-10s %s" % (box, st[0].decode() if typ == "OK" else "?"))
|
||||
corr = correspondents(conn, cfg)
|
||||
conn.select("INBOX", readonly=True)
|
||||
cutoff = dt.date.today() - dt.timedelta(days=cfg.get("age_days", 3))
|
||||
typ, data = conn.search(None, '(UNSEEN BEFORE "%s")' % imap_date(cutoff))
|
||||
n = len(data[0].split()) if typ == "OK" else 0
|
||||
print("\n correspondents from Sent : %d (minimum %d)"
|
||||
% (len(corr), cfg.get("min_correspondents", 25)))
|
||||
print(" candidates (unread >%dd) : %d (sanity limit %d)"
|
||||
% (cfg.get("age_days", 3), n, cfg.get("expected_unread_max", 900)))
|
||||
print(" enabled : %s" % cfg.get("enabled", False))
|
||||
print("\nProceed only if INBOX unseen matches the web UI and the "
|
||||
"correspondent count looks real.")
|
||||
return 0
|
||||
|
||||
|
||||
def do_run(conn, cfg, args):
|
||||
decisions, corr = gather(conn, cfg, args)
|
||||
to_move = [(u, m) for u, m, o, _ in decisions if o == ARCHIVE]
|
||||
limit = args.max if args.max is not None else cfg.get("max_per_run", 250)
|
||||
batch, held = to_move[:limit], to_move[limit:]
|
||||
|
||||
counts = {}
|
||||
for _, _, outcome, _ in decisions:
|
||||
counts[outcome] = counts.get(outcome, 0) + 1
|
||||
|
||||
if args.dry_run:
|
||||
for uid, msg, outcome, detail in decisions:
|
||||
print("%-20s %-34s %s" % (
|
||||
outcome, addr_of(msg.get("From"))[:34],
|
||||
str(msg.get("Subject", ""))[:60]) +
|
||||
((" [%s]" % detail) if detail and outcome != ARCHIVE else ""))
|
||||
print("\n%s" % summarise(counts, len(batch), len(held), None))
|
||||
return 0
|
||||
|
||||
if not cfg.get("enabled", False):
|
||||
raise Fatal(
|
||||
"`enabled` is false in %s.\n\nThe job is installed but not armed. "
|
||||
"Review `inbox_tidy --check` and `--dry-run` first, then set "
|
||||
"enabled = true." % CONFIG)
|
||||
|
||||
run_id = time.strftime("%Y%m%dT%H%M%S")
|
||||
moved = []
|
||||
if batch:
|
||||
conn.select("INBOX", readonly=False) # the ONLY read-write select
|
||||
for i in range(0, len(batch), 100):
|
||||
part = batch[i:i + 100]
|
||||
typ, _ = conn.uid("MOVE", ",".join(u for u, _ in part), '"Archive"')
|
||||
if typ != "OK":
|
||||
raise Fatal(
|
||||
"IMAP MOVE failed after %d message(s) of this run.\n\n"
|
||||
"Anything already moved is in Archive and is recorded in "
|
||||
"%s under run %s, so `inbox_tidy --undo %s` will bring it "
|
||||
"back." % (len(moved), LOG, run_id, run_id))
|
||||
for uid, msg in part:
|
||||
moved.append({
|
||||
"run": run_id,
|
||||
"at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"action": "archive",
|
||||
"uid": uid,
|
||||
"message_id": str(msg.get("Message-ID", "")).strip(),
|
||||
"from": addr_of(msg.get("From")),
|
||||
"subject": str(msg.get("Subject", ""))[:200],
|
||||
"date": str(msg.get("Date", "")),
|
||||
"bulk": bulk_markers(msg),
|
||||
"src": "INBOX", "dst": "Archive",
|
||||
})
|
||||
log_records(moved[-len(part):])
|
||||
|
||||
body = summarise(counts, len(moved), len(held), run_id)
|
||||
push(cfg, "Inbox tidy: %d archived" % len(moved), body, "broom")
|
||||
print(body)
|
||||
return 0
|
||||
|
||||
|
||||
def summarise(counts, n_moved, n_held, run_id):
|
||||
lines = ["**%d archived**" % n_moved]
|
||||
if n_held:
|
||||
lines.append("%d over the per-run cap, left for the next run." % n_held)
|
||||
lines.append("")
|
||||
lines.append("Kept:")
|
||||
for k in (KEPT_FLAGGED, KEPT_CORRESPONDENT, KEPT_SENDER, KEPT_SUBJECT,
|
||||
KEPT_NO_MSGID):
|
||||
if counts.get(k):
|
||||
lines.append(" %-22s %d" % (k, counts[k]))
|
||||
if not any(counts.get(k) for k in (KEPT_FLAGGED, KEPT_CORRESPONDENT,
|
||||
KEPT_SENDER, KEPT_SUBJECT,
|
||||
KEPT_NO_MSGID)):
|
||||
lines.append(" (nothing was held back by a guard)")
|
||||
if run_id:
|
||||
lines += ["", "Undo: `inbox_tidy --undo %s`" % run_id]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def do_undo(conn, cfg, run_id):
|
||||
recs = [r for r in read_log()
|
||||
if r.get("action") == "archive" and r.get("run") == run_id]
|
||||
if not recs:
|
||||
raise Fatal("No archive records for run %r in %s." % (run_id, LOG))
|
||||
|
||||
conn.select('"Archive"', readonly=False)
|
||||
restored, missing = [], []
|
||||
for r in recs:
|
||||
mid = r.get("message_id", "")
|
||||
if not mid:
|
||||
missing.append(r)
|
||||
continue
|
||||
typ, data = conn.search(None, '(HEADER MESSAGE-ID "%s")' % mid)
|
||||
ids = data[0].split() if typ == "OK" else []
|
||||
if not ids:
|
||||
missing.append(r)
|
||||
continue
|
||||
typ, d2 = conn.fetch(b",".join(ids).decode(), "(UID)")
|
||||
uids = [m.group(1) for m in
|
||||
(re.search(r"UID (\d+)", x.decode(errors="replace"))
|
||||
for x in d2 if isinstance(x, bytes)) if m]
|
||||
if not uids:
|
||||
missing.append(r)
|
||||
continue
|
||||
typ, _ = conn.uid("MOVE", ",".join(uids), "INBOX")
|
||||
(restored if typ == "OK" else missing).append(r)
|
||||
|
||||
log_records([{
|
||||
"run": run_id, "at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"action": "undo", "restored": len(restored), "missing": len(missing),
|
||||
}])
|
||||
body = ("**Undo of run %s**\n\n%d restored to INBOX.\n%d not found in "
|
||||
"Archive (moved or deleted by hand since)."
|
||||
% (run_id, len(restored), len(missing)))
|
||||
print(body)
|
||||
push(cfg, "Inbox tidy: undo %s" % run_id, body, "leftwards_arrow_with_hook")
|
||||
return 0 if not missing else 1
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--config", metavar="PATH",
|
||||
help="use an alternative config (for testing a rule change "
|
||||
"without touching the live one)")
|
||||
ap.add_argument("--check", action="store_true",
|
||||
help="report mailbox state and the guard inputs; change nothing")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="print the decision for every candidate; move nothing")
|
||||
ap.add_argument("--max", type=int, help="override the per-run cap")
|
||||
ap.add_argument("--undo", metavar="RUN_ID", help="restore a run from Archive")
|
||||
ap.add_argument("--undo-last", action="store_true",
|
||||
help="restore the most recent archive run")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
cfg = load_config(args.config)
|
||||
except Fatal as exc:
|
||||
print("inbox_tidy: %s" % exc, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = imap_connect(cfg["imap"])
|
||||
if args.undo or args.undo_last:
|
||||
run_id = args.undo
|
||||
if args.undo_last:
|
||||
runs = [r["run"] for r in read_log() if r.get("action") == "archive"]
|
||||
if not runs:
|
||||
raise Fatal("Nothing in %s to undo." % LOG)
|
||||
run_id = runs[-1]
|
||||
return do_undo(conn, cfg, run_id)
|
||||
if args.check:
|
||||
return do_check(conn, cfg)
|
||||
return do_run(conn, cfg, args)
|
||||
except Fatal as exc:
|
||||
body = "**Inbox tidy could not run.**\n\n%s" % exc
|
||||
if args.dry_run or args.check:
|
||||
print(body, file=sys.stderr)
|
||||
else:
|
||||
notify_failure(cfg, "Inbox tidy: FAILED", body)
|
||||
return 1
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+463
@@ -0,0 +1,463 @@
|
||||
#!/usr/bin/python3
|
||||
"""Recommend movies based on the whole Radarr library, via the local LLM stack.
|
||||
|
||||
movie_recs [-n COUNT] [-m MODEL] [--json] [--prompt-only]
|
||||
|
||||
Talks to LiteLLM (OpenAI-compatible) rather than Ollama, which was removed
|
||||
2026-08-28. Ollama and llama-server both wanted the whole 3060, and with
|
||||
gpt-oss-20b resident at ~11.3 GB of 11.9 GB there was under 1 GB left, so
|
||||
every Ollama request OOM'd. llama-swap now arbitrates a single GPU owner.
|
||||
|
||||
The model is the same weights as before (gemma3 12B instruct, Q4_K_M), just
|
||||
served by llama.cpp instead, so recommendations keep their prior character.
|
||||
|
||||
Reads every movie in Radarr, asks the model for titles that are NOT already
|
||||
in the library, and filters the answer against the library again (models
|
||||
reliably recommend things you already own).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections import Counter, defaultdict
|
||||
import sys
|
||||
import unicodedata
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
RADARR_CONFIG = "/home/connor/config/radarr/config.xml"
|
||||
# Router DNS override points this at the LAN, so it never leaves the network.
|
||||
RADARR_URL = "https://radarr.rcjohnstone.com"
|
||||
# LiteLLM's loopback publish -- no reason to make it a network round trip.
|
||||
# (Caddy at llm.rcjohnstone.com is the LAN/OpenVPN path; this runs on the host.)
|
||||
LITELLM_URL = "http://127.0.0.1:4000"
|
||||
# A LiteLLM virtual key scoped to just this model, NOT the master key: this
|
||||
# runs unattended from a timer, and a scoped key also gives the spend log a
|
||||
# per-consumer attribution instead of lumping every caller under "master".
|
||||
# 0600 file, same pattern as movie_recs_notify's ntfy credential.
|
||||
LITELLM_ENV = os.path.expanduser("~/.config/litellm/movie-recs.env")
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recommendations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"year": {"type": "integer"},
|
||||
"anchors": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 3,
|
||||
"maxItems": 5,
|
||||
},
|
||||
"pattern": {"type": "string"},
|
||||
"reason": {"type": "string"},
|
||||
},
|
||||
"required": ["title", "year", "anchors", "pattern", "reason"],
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["recommendations"],
|
||||
}
|
||||
|
||||
|
||||
def norm(t):
|
||||
"""Loose title key: fold accents, drop articles/punctuation/case."""
|
||||
t = unicodedata.normalize("NFKD", t)
|
||||
t = "".join(c for c in t if not unicodedata.combining(c)).lower()
|
||||
t = re.sub(r"[^a-z0-9 ]+", "", t)
|
||||
t = re.sub(r"^(the|a|an) ", "", t).strip()
|
||||
return re.sub(r"\s+", " ", t)
|
||||
|
||||
|
||||
def get_json(url, data=None, timeout=600, headers=None):
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(data).encode() if data is not None else None,
|
||||
headers={"Content-Type": "application/json", **(headers or {})},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def litellm_key():
|
||||
"""Read the virtual key, env first so a manual run can override."""
|
||||
key = os.environ.get("LITELLM_KEY")
|
||||
if key:
|
||||
return key
|
||||
try:
|
||||
with open(LITELLM_ENV) as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
if k.strip() == "LITELLM_KEY":
|
||||
return v.strip()
|
||||
except OSError:
|
||||
pass
|
||||
sys.exit("No LiteLLM key: set LITELLM_KEY or write it to %s" % LITELLM_ENV)
|
||||
|
||||
|
||||
def api_key():
|
||||
return ET.parse(RADARR_CONFIG).getroot().findtext("ApiKey")
|
||||
|
||||
|
||||
def library(key):
|
||||
movies = get_json("%s/api/v3/movie?apikey=%s" % (RADARR_URL, key), timeout=60)
|
||||
out = []
|
||||
for m in movies:
|
||||
out.append({
|
||||
"title": m.get("title", "?"),
|
||||
"year": m.get("year"),
|
||||
"genres": m.get("genres") or [],
|
||||
})
|
||||
return sorted(out, key=lambda x: (x["year"] or 0))
|
||||
|
||||
|
||||
def verify(key, title, year):
|
||||
"""Resolve a suggestion against TMDB via Radarr's own lookup.
|
||||
|
||||
Models at this size invent plausible-sounding films, so a title the
|
||||
model asserts is only accepted when TMDB agrees on both the name and
|
||||
(within a year) the release date. Returns the real record or None.
|
||||
"""
|
||||
# Models often glue the year onto the title ("Gone Girl (2014)"), which
|
||||
# would never compare equal to TMDB's bare title. Split it back out.
|
||||
m = re.search(r"\((\d{4})\)\s*$", title)
|
||||
if m:
|
||||
year = year or int(m.group(1))
|
||||
title = title[:m.start()].strip()
|
||||
|
||||
url = "%s/api/v3/movie/lookup?apikey=%s&term=%s" % (
|
||||
RADARR_URL, key, urllib.parse.quote(title))
|
||||
try:
|
||||
hits = get_json(url, timeout=30)
|
||||
except Exception:
|
||||
return None
|
||||
want = norm(title)
|
||||
matches = [h for h in hits[:10] if norm(h.get("title", "")) == want]
|
||||
if not matches and year:
|
||||
# Films are often listed under a longer official title ("The French
|
||||
# Dispatch" vs "The French Dispatch of the Liberty, Kansas Evening
|
||||
# Sun"). Accept a prefix, but only with the year agreeing, so this
|
||||
# stays tight enough to keep rejecting invented titles.
|
||||
matches = [h for h in hits[:10]
|
||||
if norm(h.get("title", "")).startswith(want + " ")
|
||||
and h.get("year") and abs(int(h["year"]) - int(year)) <= 1]
|
||||
if not matches:
|
||||
return None
|
||||
# The title existing is the real signal; models get years wrong on films
|
||||
# that are perfectly real. Use the year only to disambiguate remakes, and
|
||||
# let TMDB's value win.
|
||||
if year:
|
||||
matches.sort(key=lambda h: abs((h.get("year") or 0) - int(year)))
|
||||
h = matches[0]
|
||||
imdb = (h.get("ratings") or {}).get("imdb") or {}
|
||||
return {
|
||||
"title": h.get("title"),
|
||||
"year": h.get("year"),
|
||||
"tmdbId": h.get("tmdbId"),
|
||||
"overview": (h.get("overview") or "").strip(),
|
||||
"genres": h.get("genres") or [],
|
||||
"runtime": h.get("runtime"),
|
||||
"votes": imdb.get("votes") or 0,
|
||||
"score": imdb.get("value") or 0,
|
||||
}
|
||||
|
||||
|
||||
def decade_mix(lib):
|
||||
c = Counter((m["year"] // 10) * 10 for m in lib if m.get("year"))
|
||||
total = sum(c.values()) or 1
|
||||
return c, total
|
||||
|
||||
|
||||
def decade_targets(lib, n):
|
||||
"""How many of n picks each decade should get, mirroring the library."""
|
||||
c, total = decade_mix(lib)
|
||||
raw = {d: n * v / total for d, v in c.items()}
|
||||
base = {d: int(v) for d, v in raw.items()}
|
||||
for d, _ in sorted(raw.items(), key=lambda kv: -(kv[1] - int(kv[1]))):
|
||||
if sum(base.values()) >= n:
|
||||
break
|
||||
base[d] += 1
|
||||
return base
|
||||
|
||||
|
||||
def build_prompt(lib, n):
|
||||
# Newest first: the collection is weighted toward recent films, and a
|
||||
# list that opens in the 1940s drags recommendations into the past.
|
||||
ordered = sorted(lib, key=lambda m: -(m["year"] or 0))
|
||||
lines = ["%s (%s) [%s]" % (m["title"], m["year"], ", ".join(m["genres"][:3]))
|
||||
for m in ordered]
|
||||
c, total = decade_mix(lib)
|
||||
hist = ", ".join("%ss %d%%" % (d, round(100 * c[d] / total))
|
||||
for d in sorted(c, reverse=True))
|
||||
# Spell out the per-decade counts. Given only a percentage breakdown the
|
||||
# model swings to whichever end the wording emphasizes.
|
||||
tgt = decade_targets(lib, n)
|
||||
quota = "; ".join("%d from the %ss" % (v, d)
|
||||
for d, v in sorted(tgt.items(), reverse=True) if v)
|
||||
return (
|
||||
"Here is my complete personal film collection (%d titles):\n\n%s\n\n"
|
||||
"By decade my collection breaks down as: %s.\n\n"
|
||||
"Recommend exactly %d films that are NOT in that list.\n"
|
||||
"Match my collection's era spread. Give me approximately: %s.\n"
|
||||
"Count as you go and respect those per-decade numbers -- a list that is "
|
||||
"mostly old films is wrong, and so is one that is all recent films.\n\n"
|
||||
"Base them on the collection as a whole: the recurring directors, eras, "
|
||||
"genres, tones and preoccupations it reveals. Infer taste from what is "
|
||||
"there.\n"
|
||||
"I am trying to grow this collection, so a film being popular is fine -- "
|
||||
"widely loved films are widely loved for a reason. Aim for a spread: "
|
||||
"roughly half well-known films most people would recognize, and half "
|
||||
"less obvious picks. What I want to avoid is a list chosen purely by "
|
||||
"popularity that ignores what my collection actually says about my taste.\n"
|
||||
"Work from CLUSTERS, not single films. For each recommendation, first "
|
||||
"find at least three films in my list that share something real -- a "
|
||||
"director, a mood, a era, a recurring theme, a kind of storytelling -- "
|
||||
"then recommend a film that belongs with that group. A pick that merely "
|
||||
"resembles one film I own is not useful; a pick that sits in the middle "
|
||||
"of several is.\n"
|
||||
"Rules:\n"
|
||||
"- Never recommend a film already in the list. Check carefully.\n"
|
||||
"- Real, released films only, with correct release years.\n"
|
||||
"- 'title' must be the bare title only. Put the year in 'year', never in 'title'.\n"
|
||||
"- 'anchors': 3 to 5 titles copied EXACTLY from my list that form the cluster.\n"
|
||||
"- 'pattern': what those anchor films share, in one phrase.\n"
|
||||
"- 'reason': one sentence on why the recommendation belongs with them.\n"
|
||||
"- Use a different cluster for each recommendation.\n"
|
||||
% (len(lines), "\n".join(lines), hist, n, quota)
|
||||
)
|
||||
|
||||
|
||||
def ask(model, prompt, n, timeout=1800):
|
||||
# OpenAI-shaped, because the backend is LiteLLM -> llama-swap ->
|
||||
# llama-server now. Three things moved when Ollama went away:
|
||||
#
|
||||
# /api/chat -> /v1/chat/completions
|
||||
# "format": <schema> -> "response_format": {"type": "json_schema", ...}
|
||||
# llama.cpp compiles the schema to a GBNF grammar
|
||||
# and constrains sampling, same guarantee Ollama's
|
||||
# "format" gave.
|
||||
# "options".num_ctx -> gone. Context is a server-side flag now (-c
|
||||
# 32768 in config/llama-swap/config.yaml); a
|
||||
# client cannot resize it per request.
|
||||
#
|
||||
# max_tokens is explicit because the OpenAI schema defaults it to a finite
|
||||
# value on some backends, and 60 recommendations of five fields each is a
|
||||
# few thousand tokens of JSON. Truncated JSON fails the parse below rather
|
||||
# than silently returning a short list.
|
||||
body = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"stream": False,
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "movie_recommendations", "schema": SCHEMA},
|
||||
},
|
||||
"temperature": 0.85,
|
||||
"max_tokens": 16384,
|
||||
}
|
||||
try:
|
||||
resp = get_json(LITELLM_URL + "/v1/chat/completions", body,
|
||||
timeout=timeout,
|
||||
headers={"Authorization": "Bearer " + litellm_key()})
|
||||
except urllib.error.HTTPError as e:
|
||||
sys.exit("LiteLLM error %s: %s" % (e.code, e.read().decode()[:400]))
|
||||
choice = (resp.get("choices") or [{}])[0]
|
||||
content = (choice.get("message") or {}).get("content") or ""
|
||||
# A truncated generation yields JSON that will not parse, and the parse
|
||||
# error alone does not say why. Name the real cause.
|
||||
if choice.get("finish_reason") == "length":
|
||||
sys.exit("Model hit max_tokens before closing the JSON -- lower "
|
||||
"--oversample or raise max_tokens in ask().")
|
||||
content = re.sub(r"<think>.*?</think>", "", content, flags=re.S).strip()
|
||||
if not content:
|
||||
sys.exit("Model returned nothing (thinking-only response).")
|
||||
try:
|
||||
return json.loads(content).get("recommendations", [])
|
||||
except json.JSONDecodeError:
|
||||
m = re.search(r"\{.*\}", content, re.S)
|
||||
if not m:
|
||||
sys.exit("Could not parse model output:\n" + content[:600])
|
||||
return json.loads(m.group()).get("recommendations", [])
|
||||
|
||||
|
||||
def tier(votes):
|
||||
"""Rough 'how widely seen is this' bucket, by IMDb vote count."""
|
||||
if votes >= 200_000:
|
||||
return "widely seen"
|
||||
if votes >= 50_000:
|
||||
return "known"
|
||||
return "deeper cut"
|
||||
|
||||
|
||||
def select(cands, n, balance, targets):
|
||||
"""Choose n recommendations matching the library's era distribution.
|
||||
|
||||
Models drift toward the pre-1980 canon regardless of what the collection
|
||||
looks like, so era is enforced rather than requested: each decade gets a
|
||||
quota proportional to its share of the library. Within a decade, picks
|
||||
alternate between better- and lesser-known films so the result is not all
|
||||
blockbusters. Short buckets backfill from the most recent decades, since
|
||||
that is where the collection's mass sits.
|
||||
"""
|
||||
if not balance:
|
||||
return cands[:n]
|
||||
|
||||
buckets = defaultdict(list)
|
||||
for c in cands:
|
||||
buckets[((c["tmdb"]["year"] or 0) // 10) * 10].append(c)
|
||||
|
||||
for d, items in buckets.items():
|
||||
ranked = sorted(items, key=lambda c: -c["tmdb"]["votes"])
|
||||
half = max(len(ranked) // 2, 1)
|
||||
hi, lo = ranked[:half], ranked[half:]
|
||||
mixed = []
|
||||
while hi or lo:
|
||||
if hi:
|
||||
mixed.append(hi.pop(0))
|
||||
if lo:
|
||||
mixed.append(lo.pop(0))
|
||||
buckets[d] = mixed
|
||||
|
||||
out = []
|
||||
for d in sorted(targets, reverse=True):
|
||||
q = targets[d]
|
||||
out += buckets[d][:q]
|
||||
buckets[d] = buckets[d][q:]
|
||||
if len(out) < n:
|
||||
# Round-robin the shortfall across decades rather than draining the
|
||||
# newest bucket, which would just re-create the skew the quota fixes.
|
||||
order = sorted(buckets, key=lambda d: (-targets.get(d, 0), -d))
|
||||
while len(out) < n and any(buckets[d] for d in order):
|
||||
for d in order:
|
||||
if len(out) >= n:
|
||||
break
|
||||
if buckets[d]:
|
||||
out.append(buckets[d].pop(0))
|
||||
return sorted(out[:n], key=lambda c: -(c["tmdb"]["year"] or 0))
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("-n", "--count", type=int, default=15)
|
||||
p.add_argument("-m", "--model", default="gemma3-12b",
|
||||
help="LiteLLM model name (see /v1/models), not an Ollama tag")
|
||||
p.add_argument("--json", action="store_true")
|
||||
p.add_argument("--prompt-only", action="store_true")
|
||||
p.add_argument("--min-votes", type=int, default=5000,
|
||||
help="drop films below this many IMDb votes (0 disables)")
|
||||
p.add_argument("--no-balance", dest="balance", action="store_false",
|
||||
help="skip the popularity spread and take the model's order")
|
||||
p.add_argument("--timeout", type=int, default=1800,
|
||||
help="seconds to wait on LiteLLM; a cold model load into "
|
||||
"llama-swap costs ~30 s on top of generation")
|
||||
p.add_argument("--oversample", type=int, default=4,
|
||||
help="ask for COUNT*N candidates; higher fills thin decades")
|
||||
a = p.parse_args()
|
||||
|
||||
key = api_key()
|
||||
lib = library(key)
|
||||
# Over-generate: a chunk of any small model's output is fabricated or
|
||||
# already owned, and both get filtered below. Extra headroom also helps
|
||||
# fill the thinly-populated decades.
|
||||
n_candidates = a.count * a.oversample
|
||||
prompt = build_prompt(lib, n_candidates)
|
||||
if a.prompt_only:
|
||||
print(prompt)
|
||||
return
|
||||
|
||||
print("library: %d films | model: %s | asking for %d, keeping %d..."
|
||||
% (len(lib), a.model, n_candidates, a.count), file=sys.stderr)
|
||||
|
||||
recs = ask(a.model, prompt, n_candidates, a.timeout)
|
||||
owned = {norm(m["title"]) for m in lib}
|
||||
|
||||
by_norm = {norm(m["title"]): m["title"] for m in lib}
|
||||
cands, invented, already, obscure, thin = [], [], [], [], []
|
||||
seen = set()
|
||||
for r in recs:
|
||||
title = r.get("title", "")
|
||||
if norm(title) in owned:
|
||||
already.append(title)
|
||||
continue
|
||||
real = verify(key, title, r.get("year"))
|
||||
if not real:
|
||||
invented.append("%s (%s)" % (title, r.get("year")))
|
||||
continue
|
||||
if norm(real["title"]) in owned:
|
||||
already.append(real["title"])
|
||||
continue
|
||||
if real["tmdbId"] in seen: # models repeat themselves
|
||||
continue
|
||||
# Anchors are only meaningful if they name films actually in the
|
||||
# library -- otherwise the "cluster" is invented.
|
||||
anchors = []
|
||||
for anc in r.get("anchors", []):
|
||||
hit = by_norm.get(norm(re.sub(r"\s*\(\d{4}\)\s*$", "", anc)))
|
||||
if hit and hit not in anchors:
|
||||
anchors.append(hit)
|
||||
if len(anchors) < 2:
|
||||
thin.append("%s [%d/%d anchors real]"
|
||||
% (real["title"], len(anchors), len(r.get("anchors", []))))
|
||||
continue
|
||||
if a.min_votes and real["votes"] and real["votes"] < a.min_votes:
|
||||
obscure.append("%s (%s, %dk votes)"
|
||||
% (real["title"], real["year"], real["votes"] // 1000))
|
||||
continue
|
||||
seen.add(real["tmdbId"])
|
||||
cands.append({**r, "tmdb": real, "anchors": anchors})
|
||||
|
||||
kept = select(cands, a.count, a.balance, decade_targets(lib, a.count))
|
||||
|
||||
if a.json:
|
||||
print(json.dumps({"recommendations": kept,
|
||||
"rejected_not_in_tmdb": invented,
|
||||
"rejected_already_owned": already,
|
||||
"rejected_below_min_votes": obscure,
|
||||
"rejected_invented_cluster": thin},
|
||||
indent=2, ensure_ascii=False))
|
||||
return
|
||||
|
||||
for r in kept:
|
||||
t = r["tmdb"]
|
||||
print("\n\033[1m%s\033[0m (%s) \033[2m%s/10, %sk IMDb votes - %s\033[0m"
|
||||
% (t["title"], t["year"], t["score"], t["votes"] // 1000, tier(t["votes"])))
|
||||
print(" %s" % r.get("reason", "").strip())
|
||||
print(" \033[2mcluster (%s): %s\033[0m"
|
||||
% (r.get("pattern", "").strip(), ", ".join(r["anchors"])))
|
||||
|
||||
spread = Counter(tier(r["tmdb"]["votes"]) for r in kept)
|
||||
got = Counter(((r["tmdb"]["year"] or 0) // 10) * 10 for r in kept)
|
||||
want = decade_targets(lib, a.count)
|
||||
lib_years = sorted(m["year"] for m in lib if m.get("year"))
|
||||
rec_years = sorted(r["tmdb"]["year"] for r in kept if r["tmdb"]["year"])
|
||||
print("\npopularity: %s"
|
||||
% (", ".join("%s %d" % (k, v) for k, v in spread.items()) or "-"),
|
||||
file=sys.stderr)
|
||||
print("eras (got/target): %s"
|
||||
% ", ".join("%ss %d/%d" % (d, got.get(d, 0), want.get(d, 0))
|
||||
for d in sorted(set(got) | set(want), reverse=True)),
|
||||
file=sys.stderr)
|
||||
if rec_years:
|
||||
print("median year: recommendations %d vs library %d"
|
||||
% (rec_years[len(rec_years) // 2], lib_years[len(lib_years) // 2]),
|
||||
file=sys.stderr)
|
||||
print("%d shown | %d not real (%s) | %d owned | %d too obscure (%s) | %d invented cluster (%s)"
|
||||
% (len(kept),
|
||||
len(invented), ", ".join(invented[:3]) or "-",
|
||||
len(already),
|
||||
len(obscure), ", ".join(obscure[:3]) or "-",
|
||||
len(thin), ", ".join(thin[:3]) or "-"),
|
||||
file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/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")
|
||||
RECS = "/home/connor/.local/bin/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())
|
||||
Executable
+527
@@ -0,0 +1,527 @@
|
||||
#!/usr/bin/python3
|
||||
"""Assemble the monthly utilities message for the rental and push it to ntfy.
|
||||
|
||||
Run monthly from rent-utilities.timer. There are two ways this produces a
|
||||
number for a bill:
|
||||
|
||||
fetch mode -- [imap].enabled, reads the statement mail through Proton
|
||||
Bridge and regexes the amount out of the body.
|
||||
template mode -- Bridge not configured yet. Carries the last known figures
|
||||
and asks you to supply the real ones.
|
||||
|
||||
`--set` overrides either, for the months you would rather just read the two
|
||||
numbers off your phone and be done.
|
||||
|
||||
The one rule this script is built around: EVERY run ends in a push. It is
|
||||
either a message you can forward as-is, or a specific account of what stopped
|
||||
it. A monthly job that fails quietly is invisible for thirty days, by which
|
||||
point you have stopped expecting it -- which is the problem this was written
|
||||
to solve in the first place.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import datetime as dt
|
||||
import email
|
||||
import email.policy
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
# The Bridge/IMAP/ntfy layer is shared with inbox_tidy. sys.path rather than
|
||||
# PYTHONPATH so this still works when run by hand, not only from the unit.
|
||||
sys.path.insert(0, os.path.expanduser("~/.local/lib/pymail"))
|
||||
from protonimap import ( # noqa: E402
|
||||
Fatal, imap_connect, imap_date, mailboxes, notify_failure, push,
|
||||
_body_text, _quote, _sent_at)
|
||||
|
||||
CONFIG = os.path.expanduser("~/docs/leases/bills.toml")
|
||||
CSV_PATH = os.path.expanduser("~/docs/leases/utilities.csv")
|
||||
|
||||
OK, MISSING, AMBIGUOUS, ERROR = "ok", "missing", "ambiguous", "error"
|
||||
|
||||
# --discover sweeps back from today rather than around a month: it exists to
|
||||
# find senders, not to resolve a particular month's bill.
|
||||
DISCOVER_DAYS = 120
|
||||
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# small helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
def prev_month(today=None):
|
||||
today = today or dt.date.today()
|
||||
first = today.replace(day=1)
|
||||
last_prev = first - dt.timedelta(days=1)
|
||||
return "%04d-%02d" % (last_prev.year, last_prev.month)
|
||||
|
||||
|
||||
def money(x):
|
||||
return "%.2f" % x
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def month_window(month, slack_days):
|
||||
"""(since, before) bracketing the month a bill BELONGS to.
|
||||
|
||||
Anchored on the requested month, never on today. A plain "last N days"
|
||||
lookback makes --month a label only: ask for July in September and you get
|
||||
August's bills filed under July, with nothing looking wrong. The observed
|
||||
arrival days are AT&T 6th-7th, LG&E 10th-11th, water 14th-15th, so a few
|
||||
days of slack around the calendar month captures the right bills without
|
||||
reaching into the neighbouring month's.
|
||||
"""
|
||||
y, m = int(month[:4]), int(month[5:7])
|
||||
first = dt.date(y, m, 1)
|
||||
nxt = dt.date(y + (m == 12), (m % 12) + 1, 1)
|
||||
return (first - dt.timedelta(days=slack_days),
|
||||
nxt + dt.timedelta(days=slack_days))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# ntfy
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CSV
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def load_csv():
|
||||
"""-> {month: {utility: amount}}. Long format, one row per reading."""
|
||||
out = {}
|
||||
try:
|
||||
with open(CSV_PATH, newline="") as fh:
|
||||
for row in csv.DictReader(fh):
|
||||
out.setdefault(row["month"], {})[row["utility"]] = float(row["amount"])
|
||||
except FileNotFoundError:
|
||||
raise Fatal(
|
||||
"utilities.csv is missing.\n\n"
|
||||
"Expected it at %s. If this is a fresh checkout, restore it from "
|
||||
"utilities.csv.pre-automation in the same directory." % CSV_PATH)
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise Fatal(
|
||||
"utilities.csv could not be parsed: %s\n\n"
|
||||
"It should be long format with the header `month,utility,amount` "
|
||||
"and one row per reading. The pre-conversion copy is beside it as "
|
||||
"utilities.csv.pre-automation." % exc)
|
||||
return out
|
||||
|
||||
|
||||
def write_csv(table):
|
||||
rows = sorted(
|
||||
((m, u, money(a)) for m, us in table.items() for u, a in us.items()),
|
||||
key=lambda t: (t[0], t[1]))
|
||||
tmp = CSV_PATH + ".tmp"
|
||||
with open(tmp, "w", newline="") as fh:
|
||||
# lineterminator is explicit: csv.writer defaults to CRLF, which would
|
||||
# quietly convert a file that has always been LF.
|
||||
w = csv.writer(fh, lineterminator="\n")
|
||||
w.writerow(["month", "utility", "amount"])
|
||||
w.writerows(rows)
|
||||
os.replace(tmp, CSV_PATH)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# IMAP / Proton Bridge
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def message_bodies(conn, imap_cfg, sender, since, before):
|
||||
"""Most-recent-first list of (subject, body-text) from `sender`.
|
||||
|
||||
Ordered by the Date header across every mailbox searched, so "most
|
||||
recent" still means most recent when a bill was filed out of INBOX.
|
||||
"""
|
||||
found = []
|
||||
for box in mailboxes(imap_cfg):
|
||||
typ, _ = conn.select(_quote(box), readonly=True)
|
||||
if typ != "OK":
|
||||
continue # folder renamed or gone; the others still count
|
||||
typ, data = conn.search(
|
||||
None, '(SINCE "%s" BEFORE "%s" FROM "%s")'
|
||||
% (imap_date(since), imap_date(before), sender))
|
||||
if typ != "OK":
|
||||
continue
|
||||
for num in data[0].split():
|
||||
typ, raw = conn.fetch(num, "(RFC822)")
|
||||
if typ != "OK" or not raw or not raw[0]:
|
||||
continue
|
||||
msg = email.message_from_bytes(raw[0][1], policy=email.policy.default)
|
||||
found.append((_sent_at(msg),
|
||||
str(msg.get("Subject", "(no subject)")),
|
||||
_body_text(msg)))
|
||||
found.sort(key=lambda t: t[0], reverse=True)
|
||||
return [(subj, body) for _, subj, body in found]
|
||||
|
||||
|
||||
def mailbox_total(conn, imap_cfg, since, before):
|
||||
"""How many messages exist in the window at all, from anyone.
|
||||
|
||||
Distinguishes "the bill did not arrive" from "Bridge has not finished
|
||||
syncing yet". A first sync takes ~90 minutes, and a partially-synced
|
||||
mailbox answers searches successfully with incomplete results -- so a
|
||||
missing bill and an unsynced mailbox look identical unless this is
|
||||
checked. Reporting the wrong one would send you hunting for a sender that
|
||||
was right all along.
|
||||
"""
|
||||
total = 0
|
||||
try:
|
||||
for box in mailboxes(imap_cfg):
|
||||
typ, _ = conn.select(_quote(box), readonly=True)
|
||||
if typ != "OK":
|
||||
continue
|
||||
typ, data = conn.search(None, '(SINCE "%s" BEFORE "%s")'
|
||||
% (imap_date(since), imap_date(before)))
|
||||
if typ == "OK":
|
||||
total += len(data[0].split())
|
||||
return total
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def extract_amount(bodies, pattern):
|
||||
"""-> (status, value, detail)."""
|
||||
rx = re.compile(pattern)
|
||||
# Every matching message in the window is considered, not just the newest.
|
||||
# Taking the newest would quietly resolve a window that had slipped and
|
||||
# caught two months of bills -- the failure this must not have. Within a
|
||||
# correctly scoped month there is exactly one bill, so two DIFFERENT
|
||||
# figures means something is wrong and is worth refusing. The same figure
|
||||
# repeated is fine: LG&E sends the identical mail twice, and AT&T's
|
||||
# "Bill total" appears once per bill.
|
||||
found, where = {}, {}
|
||||
for subject, body in bodies:
|
||||
for m in rx.finditer(body):
|
||||
v = m.group(1).replace(",", "")
|
||||
found[v] = found.get(v, 0) + 1
|
||||
where.setdefault(v, subject)
|
||||
if len(found) == 1:
|
||||
v = next(iter(found))
|
||||
return OK, float(v), where[v]
|
||||
if len(found) > 1:
|
||||
vals = ", ".join("$%s (%r)" % (v, where[v][:44]) for v in sorted(found))
|
||||
return AMBIGUOUS, None, (
|
||||
"matched %d DIFFERENT amounts in the window -- %s. Refusing to "
|
||||
"guess which is this month's." % (len(found), vals))
|
||||
if bodies:
|
||||
return MISSING, None, (
|
||||
"%d message(s) from that sender, but the pattern matched no dollar "
|
||||
"amount in any of them. Most recent was %r"
|
||||
% (len(bodies), bodies[0][0]))
|
||||
return MISSING, None, "no message from that sender in the search window"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# rendering
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def render_message(cfg, amounts):
|
||||
"""The text to forward to them. Layout matches the original utilities.py.
|
||||
|
||||
One deliberate difference: the original's Total line printed `$$` because
|
||||
total_amount already carried a '$' and the f-string added another.
|
||||
"""
|
||||
split = cfg["split"]
|
||||
lines = []
|
||||
full = their = 0.0
|
||||
for bill in cfg["bills"]:
|
||||
name = bill["name"]
|
||||
amt = amounts[name]
|
||||
share = amt * split
|
||||
full += amt
|
||||
their += share
|
||||
lines.append("%-15s $%-8s Your Share = $%s"
|
||||
% (name + ":", money(amt) + ",", money(share)))
|
||||
lines.append("%-15s $%-8s Your Total Share = $%s"
|
||||
% ("Total:", money(full) + ",", money(their)))
|
||||
body = "\n".join(lines)
|
||||
greeting = cfg.get("greeting", "").strip()
|
||||
return (greeting + "\n\n" + body) if greeting else body
|
||||
|
||||
|
||||
def render_problems(month, problems, note=None):
|
||||
out = ["**Could not build the %s message.**" % month, ""]
|
||||
if note:
|
||||
out += [note, ""]
|
||||
for name, detail in problems:
|
||||
out.append("**%s**" % name)
|
||||
out.append(detail)
|
||||
out.append("")
|
||||
out.append("---")
|
||||
out.append("Nothing was written to utilities.csv. Once you have the real "
|
||||
"figures, run:")
|
||||
out.append("")
|
||||
out.append("```")
|
||||
# Join the continuations explicitly rather than appending a trailing "\\"
|
||||
# to every line -- the last one must NOT have it, or pasting the command
|
||||
# leaves the shell waiting on a continuation that never comes.
|
||||
cmd = ["rent_utilities --month %s" % month]
|
||||
cmd += [" --set '%s=0.00'" % name for name, _ in problems]
|
||||
out.append(" \\\n".join(cmd))
|
||||
out.append("```")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# modes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def discover(cfg):
|
||||
imap_cfg = cfg["imap"]
|
||||
conn = imap_connect(imap_cfg)
|
||||
try:
|
||||
conn.select(_quote(mailboxes(imap_cfg)[0]), readonly=True)
|
||||
since = dt.date.today() - dt.timedelta(days=DISCOVER_DAYS)
|
||||
typ, data = conn.search(None, '(SINCE "%s")' % imap_date(since))
|
||||
if typ != "OK":
|
||||
raise Fatal("IMAP search failed: %s" % typ)
|
||||
ids = data[0].split()
|
||||
print("%d messages since %s\n" % (len(ids), since))
|
||||
rx = re.compile(r"\$\s*([\d,]+\.\d{2})")
|
||||
for num in reversed(ids):
|
||||
typ, raw = conn.fetch(num, "(RFC822)")
|
||||
if typ != "OK" or not raw or not raw[0]:
|
||||
continue
|
||||
msg = email.message_from_bytes(raw[0][1], policy=email.policy.default)
|
||||
body = ""
|
||||
try:
|
||||
part = msg.get_body(preferencelist=("plain", "html"))
|
||||
body = part.get_content() if part is not None else ""
|
||||
except Exception:
|
||||
pass
|
||||
body = re.sub(r"<[^>]+>", " ", body)
|
||||
amounts = sorted({m.group(1) for m in rx.finditer(body)})
|
||||
print("from: %s" % msg.get("From", "?"))
|
||||
print("subject: %s" % msg.get("Subject", "?"))
|
||||
print("date: %s" % msg.get("Date", "?"))
|
||||
print("amounts: %s" % (", ".join("$" + a for a in amounts[:8]) or "none"))
|
||||
print()
|
||||
finally:
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def collect(cfg, month, overrides):
|
||||
"""-> (amounts, problems, used_fetch)."""
|
||||
amounts, problems = {}, []
|
||||
imap_cfg = cfg.get("imap", {})
|
||||
use_imap = imap_cfg.get("enabled", False)
|
||||
conn = None
|
||||
since, before = month_window(month, imap_cfg.get("window_slack_days", 5))
|
||||
|
||||
try:
|
||||
for bill in cfg["bills"]:
|
||||
name = bill["name"]
|
||||
|
||||
if name in overrides:
|
||||
amounts[name] = overrides[name]
|
||||
continue
|
||||
|
||||
if "fixed" in bill:
|
||||
amounts[name] = float(bill["fixed"])
|
||||
continue
|
||||
|
||||
if not use_imap:
|
||||
problems.append((name, "no amount supplied, and [imap].enabled "
|
||||
"is false so nothing was fetched."))
|
||||
continue
|
||||
|
||||
sender, pattern = bill.get("sender", ""), bill.get("pattern", "")
|
||||
if not sender or not pattern:
|
||||
problems.append((name, (
|
||||
"not configured for fetching: `sender` and/or `pattern` "
|
||||
"are empty in bills.toml. Run `rent_utilities --discover` "
|
||||
"to see the senders and candidate amounts in your recent "
|
||||
"mail, then fill them in.")))
|
||||
continue
|
||||
|
||||
if conn is None:
|
||||
conn = imap_connect(imap_cfg)
|
||||
try:
|
||||
bodies = message_bodies(conn, imap_cfg, sender, since, before)
|
||||
except Fatal:
|
||||
raise
|
||||
except Exception as exc:
|
||||
problems.append((name, "IMAP read failed: %r" % (exc,)))
|
||||
continue
|
||||
|
||||
status, value, detail = extract_amount(bodies, pattern)
|
||||
if status == OK:
|
||||
amounts[name] = value
|
||||
continue
|
||||
|
||||
note = ""
|
||||
if not bodies:
|
||||
total = mailbox_total(conn, imap_cfg, since, before)
|
||||
if total == 0:
|
||||
note = ("\n\n**The mailbox returned NO messages at all in "
|
||||
"this window, from anyone.** That is almost "
|
||||
"certainly Bridge still syncing rather than a "
|
||||
"missing bill -- a first sync takes ~90 minutes. "
|
||||
"Check with:\n"
|
||||
" sudo podman logs --tail 20 "
|
||||
"connor_protonmail-bridge_1\n"
|
||||
"Do not go changing the sender in bills.toml yet.")
|
||||
elif total is not None:
|
||||
note = ("\n\n(%d message(s) from other senders were found "
|
||||
"in the same window, so the mailbox is reachable "
|
||||
"and this sender genuinely has nothing.)" % total)
|
||||
problems.append((name, "%s\n\nSearched for `%s` between %s and %s in %s.%s"
|
||||
% (detail, sender, since, before,
|
||||
", ".join(mailboxes(imap_cfg)), note)))
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return amounts, problems, use_imap
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--month", help="target month as YYYY-MM (default: last month)")
|
||||
ap.add_argument("--set", action="append", default=[], metavar="NAME=AMOUNT",
|
||||
help="supply an amount by hand; repeatable")
|
||||
ap.add_argument("--discover", action="store_true",
|
||||
help="print recent senders and candidate amounts, then exit")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="print what would be pushed instead of pushing")
|
||||
args = ap.parse_args()
|
||||
|
||||
# Config load is outside the push-on-failure net: without it there is no
|
||||
# ntfy url or topic to push to. It surfaces on stderr, and systemd will
|
||||
# mark the unit failed.
|
||||
try:
|
||||
with open(CONFIG, "rb") as fh:
|
||||
cfg = tomllib.load(fh)
|
||||
except (OSError, tomllib.TOMLDecodeError) as exc:
|
||||
print("rent_utilities: cannot read config %s: %s" % (CONFIG, exc),
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.discover:
|
||||
try:
|
||||
return discover(cfg)
|
||||
except Fatal as exc:
|
||||
print("rent_utilities: %s" % exc, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
month = args.month or prev_month()
|
||||
if not re.fullmatch(r"\d{4}-\d{2}", month):
|
||||
print("rent_utilities: --month must be YYYY-MM, got %r" % month,
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
overrides = {}
|
||||
for item in args.set:
|
||||
if "=" not in item:
|
||||
print("rent_utilities: --set wants NAME=AMOUNT, got %r" % item,
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
k, v = item.split("=", 1)
|
||||
try:
|
||||
overrides[k.strip()] = float(v)
|
||||
except ValueError:
|
||||
print("rent_utilities: %r is not a number in %r" % (v, item),
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
known = {b["name"] for b in cfg["bills"]}
|
||||
unknown = set(overrides) - known
|
||||
if unknown:
|
||||
print("rent_utilities: --set names not in bills.toml: %s (known: %s)"
|
||||
% (", ".join(sorted(unknown)), ", ".join(sorted(known))),
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
table = load_csv()
|
||||
amounts, problems, used_fetch = collect(cfg, month, overrides)
|
||||
except Fatal as exc:
|
||||
body = "**%s could not run.**\n\n%s" % (month, exc)
|
||||
if args.dry_run:
|
||||
print(body)
|
||||
else:
|
||||
notify_failure(cfg, "Rent utilities: FAILED", body)
|
||||
return 1
|
||||
|
||||
if problems:
|
||||
note = None
|
||||
if not used_fetch:
|
||||
last = max((m for m in table if m < month), default=None)
|
||||
if last:
|
||||
prev = ", ".join(
|
||||
"%s $%s" % (u, money(a)) for u, a in sorted(table[last].items()))
|
||||
note = ("Template mode -- Proton Bridge is not wired up yet, so "
|
||||
"nothing was fetched.\n\nLast figures on file (%s): %s"
|
||||
% (last, prev))
|
||||
body = render_problems(month, problems, note)
|
||||
title = ("Rent utilities: %s needs numbers" % month if not used_fetch
|
||||
else "Rent utilities: %s incomplete" % month)
|
||||
if args.dry_run:
|
||||
print(body)
|
||||
else:
|
||||
notify_failure(cfg, title, body)
|
||||
return 1
|
||||
|
||||
msg = render_message(cfg, amounts)
|
||||
total = sum(amounts.values()) * cfg["split"]
|
||||
body = ("Their share for %s: **$%s**\n\nForward this:\n\n```\n%s\n```"
|
||||
% (month, money(total), msg))
|
||||
|
||||
# Before the write, not after: a dry run must not touch utilities.csv.
|
||||
if args.dry_run:
|
||||
print(body)
|
||||
return 0
|
||||
|
||||
table.setdefault(month, {}).update(amounts)
|
||||
try:
|
||||
write_csv(table)
|
||||
except OSError as exc:
|
||||
body = ("**Built the %s figures but could not save them.**\n\n"
|
||||
"Writing %s failed: %s\n\nThe message below is still correct, "
|
||||
"but it was NOT recorded -- next month will not have this "
|
||||
"month's history.\n\n```\n%s\n```"
|
||||
% (month, CSV_PATH, exc, render_message(cfg, amounts)))
|
||||
# No dry-run branch here: a dry run has already returned above, so
|
||||
# reaching this point means the write was real and really failed.
|
||||
notify_failure(cfg, "Rent utilities: %s not saved" % month, body)
|
||||
return 1
|
||||
|
||||
try:
|
||||
push(cfg, "Rent utilities: %s" % month, body, "house,moneybag")
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
print("rent_utilities: built the message but ntfy push failed: %s"
|
||||
% exc, file=sys.stderr)
|
||||
print(msg)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
[user]
|
||||
email = c@rcjohnstone.com
|
||||
name = Connor Johnstone
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Dump every database to a consistent on-disk file so restic snapshots a
|
||||
# restorable copy rather than a live file mid-write.
|
||||
#
|
||||
# Design notes:
|
||||
#
|
||||
# * SQLite dumps run from the HOST, not inside the containers. Vaultwarden
|
||||
# and SFTPGo images ship no sqlite3 binary and traggo is distroless with no
|
||||
# shell at all, so `podman exec` cannot work there. The DB files are on host
|
||||
# bind mounts and `.backup` is safe against a live writer.
|
||||
#
|
||||
# * The Immich dump goes to the NFS mount (-> the NAS), NOT /var/backups.
|
||||
# Immich's blobs live on the NAS and its database on mainframe; writing the
|
||||
# dump beside the blobs means one NAS restic snapshot captures both halves.
|
||||
# A database restored against missing photos is not a restore.
|
||||
#
|
||||
# * .env is PARSED, never sourced. OPENVPN_PASSWORD contains characters that
|
||||
# break `.` under bash, and quoting it changes what podman-compose passes to
|
||||
# the container -- so the file is left exactly as compose expects and read
|
||||
# with a plain parser instead.
|
||||
#
|
||||
# * Every file is written to .tmp and renamed, so a concurrent backup never
|
||||
# snapshots a truncated dump.
|
||||
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
DEST=/var/backups/db
|
||||
IMMICH_DEST=/home/connor/photo/immich/backups
|
||||
ENVFILE=/home/connor/.env
|
||||
|
||||
log() { printf '%s backup-db-dump: %s\n' "$(date -Is)" "$*"; }
|
||||
env_get() { sed -n "s/^$1=//p" "$ENVFILE" | head -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; }
|
||||
|
||||
mkdir -p "$DEST"
|
||||
IMMICH_DB_USERNAME=$(env_get IMMICH_DB_USERNAME)
|
||||
HEDGEDOC_DB_ROOT_PASSWORD=$(env_get HEDGEDOC_DB_ROOT_PASSWORD)
|
||||
[ -n "$IMMICH_DB_USERNAME" ] || { log "ERROR: could not read IMMICH_DB_USERNAME"; exit 1; }
|
||||
|
||||
# --- Postgres (Immich) -----------------------------------------------------
|
||||
if mountpoint -q /home/connor/photo && mkdir -p "$IMMICH_DEST" 2>/dev/null; then
|
||||
tgt="$IMMICH_DEST"
|
||||
else
|
||||
log "WARN: /home/connor/photo not mounted; writing immich dump locally instead"
|
||||
tgt="$DEST"
|
||||
fi
|
||||
log "immich postgres -> $tgt"
|
||||
podman exec connor_immich_db_1 sh -c "pg_dumpall -U '$IMMICH_DB_USERNAME'" \
|
||||
| zstd -q -o "$tgt/immich-pgdump.sql.zst.tmp"
|
||||
mv -f "$tgt/immich-pgdump.sql.zst.tmp" "$tgt/immich-pgdump.sql.zst"
|
||||
|
||||
# --- MariaDB (HedgeDoc) ----------------------------------------------------
|
||||
log "hedgedoc mariadb"
|
||||
podman exec connor_hedgedocdb_1 sh -c \
|
||||
"mariadb-dump --single-transaction -u root -p'$HEDGEDOC_DB_ROOT_PASSWORD' hedgedoc" \
|
||||
| zstd -q -o "$DEST/hedgedoc.sql.zst.tmp"
|
||||
mv -f "$DEST/hedgedoc.sql.zst.tmp" "$DEST/hedgedoc.sql.zst"
|
||||
|
||||
# --- SQLite ----------------------------------------------------------------
|
||||
for spec in \
|
||||
"vaultwarden:/home/connor/data/bitwarden/db.sqlite3" \
|
||||
"gitea:/home/connor/data/gitea/gitea/gitea.db" \
|
||||
"traggo:/home/connor/data/traggo/traggo.db" \
|
||||
"sftpgo:/home/connor/docs/sftpgo.db" \
|
||||
"shanty:/usr/local/shanty/shanty.db" \
|
||||
; do
|
||||
name=${spec%%:*}; path=${spec#*:}
|
||||
if [ ! -f "$path" ]; then log "WARN: $name db missing at $path, skipping"; continue; fi
|
||||
log "$name sqlite"
|
||||
rm -f "$DEST/$name.sqlite3.tmp"
|
||||
sqlite3 "$path" ".backup '$DEST/$name.sqlite3.tmp'"
|
||||
# Verify before promoting. A corrupt dump that looks like a file is worse
|
||||
# than a missing one: it restores without complaint.
|
||||
if [ "$(sqlite3 "$DEST/$name.sqlite3.tmp" 'pragma integrity_check;' 2>&1 | head -1)" != "ok" ]; then
|
||||
log "ERROR: $name failed integrity_check, keeping previous dump"
|
||||
rm -f "$DEST/$name.sqlite3.tmp"; continue
|
||||
fi
|
||||
mv -f "$DEST/$name.sqlite3.tmp" "$DEST/$name.sqlite3"
|
||||
done
|
||||
|
||||
log "done"
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Weekly image refresh for the compose stack in /home/connor.
|
||||
#
|
||||
# Why this exists: every service in compose.yml is pinned to `:latest` (bar the
|
||||
# two Immich images, which carry digests), podman-auto-update.timer is disabled
|
||||
# and nothing else pulled. The tag made the stack look current while the
|
||||
# running images were up to twelve months old -- jellyseerr, the personal-site
|
||||
# pair and searxng's valkey had not moved since 2025. `:latest` without a
|
||||
# puller is not a rolling tag, it is a snapshot with a misleading name.
|
||||
#
|
||||
# Recreation is deliberate rather than pull-only. Stale images are the larger
|
||||
# standing risk, `restart: unless-stopped` plus the nightly restic snapshot
|
||||
# make a bad pull recoverable, and podman keeps the previous image so a
|
||||
# rollback is `podman tag` away. Set APPLY=0 below to downgrade this to
|
||||
# pull-and-notify if that trade ever stops being worth it.
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
APPLY=1
|
||||
PROJECT_DIR=/home/connor
|
||||
NTFY_URL=https://ntfy.rcjohnstone.com/infra
|
||||
NTFY_ENV=/etc/ntfy/publish.env
|
||||
|
||||
notify() { # notify <priority> <tags> <title> <body>
|
||||
local u p
|
||||
[ -r "$NTFY_ENV" ] || return 0
|
||||
# PARSED, not sourced: the bot password contains ` and &, so sourcing it
|
||||
# dies with a syntax error. Same reason the restic scripts use sed.
|
||||
u=$(sed -n 's/^NTFY_USER=//p' "$NTFY_ENV" | head -1)
|
||||
p=$(sed -n 's/^NTFY_PASS=//p' "$NTFY_ENV" | head -1)
|
||||
[ -n "$u" ] && [ -n "$p" ] || return 0
|
||||
curl -fsS --max-time 20 -u "$u:$p" \
|
||||
-H "Title: $3" -H "Priority: $1" -H "Tags: $2" \
|
||||
-d "$4" "$NTFY_URL" >/dev/null || true
|
||||
}
|
||||
|
||||
# Trap installed before anything can fail, so a crash still reports. The
|
||||
# restic script learned this the hard way: its failure-log code sat above the
|
||||
# first thing that could die, and the one failure it was written to capture
|
||||
# happened before it was reached.
|
||||
LOG=$(mktemp /tmp/podman-stack-update.XXXXXX)
|
||||
FAILLOG=/var/log/podman-stack-update.failed.log
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
cp -f "$LOG" "$FAILLOG" 2>/dev/null || true
|
||||
notify urgent rotating_light "Stack update FAILED on $(uname -n)" \
|
||||
"exit $rc"$'\n\n'"$(tail -n 25 "$LOG")"
|
||||
fi
|
||||
rm -f "$LOG"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Image IDs before, so the report names what actually moved rather than
|
||||
# everything that was pulled.
|
||||
declare -A before
|
||||
while read -r ref id; do before["$ref"]=$id; done < <(
|
||||
podman images --format '{{.Repository}}:{{.Tag}} {{.ID}}' 2>/dev/null)
|
||||
|
||||
echo "=== pull ===" >>"$LOG"
|
||||
podman-compose pull >>"$LOG" 2>&1
|
||||
|
||||
changed=()
|
||||
while read -r ref id; do
|
||||
[ "$ref" = "<none>:<none>" ] && continue
|
||||
if [ "${before[$ref]:-none}" != "$id" ]; then changed+=("$ref"); fi
|
||||
done < <(podman images --format '{{.Repository}}:{{.Tag}} {{.ID}}' 2>/dev/null)
|
||||
|
||||
if [ "${#changed[@]}" -eq 0 ]; then
|
||||
echo "no image changes" >>"$LOG"
|
||||
notify low white_check_mark "Stack update: no changes" "All images already current."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$APPLY" -ne 1 ]; then
|
||||
notify default package "Stack update: ${#changed[@]} image(s) available" \
|
||||
"$(printf '%s\n' "${changed[@]}")"$'\n\n'"APPLY=0, not recreated."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "=== up -d ===" >>"$LOG"
|
||||
podman-compose up -d >>"$LOG" 2>&1
|
||||
|
||||
# A container that is not running 30s after recreation is the failure mode
|
||||
# worth shouting about -- a bad image starts, crashes, and without this the
|
||||
# run still looks like a success.
|
||||
sleep 30
|
||||
notrunning=$(podman ps -a --filter 'label=io.podman.compose.project=connor' \
|
||||
--format '{{.Names}} {{.Status}}' 2>/dev/null | grep -v '^\S* Up' || true)
|
||||
|
||||
if [ -n "$notrunning" ]; then
|
||||
notify urgent rotating_light "Stack update: containers DOWN on $(uname -n)" \
|
||||
"Updated:"$'\n'"$(printf '%s\n' "${changed[@]}")"$'\n\n'"Not running:"$'\n'"$notrunning"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
notify default package "Stack update OK on $(uname -n)" \
|
||||
"${#changed[@]} image(s) updated:"$'\n'"$(printf '%s\n' "${changed[@]}")"
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Nightly restic backup of mainframe's LOCAL data to the Hetzner Storage Box.
|
||||
#
|
||||
# Scope: this host only. The six directories under /home/connor that are NFS
|
||||
# mounts from the NAS (audio books docs downloads photo video) are excluded
|
||||
# automatically by --one-file-system and are backed up by the NAS's own copy
|
||||
# of this script, straight off the XFS array. Pulling 500G over NFS every
|
||||
# night to hand it back to the same machine would be absurd.
|
||||
#
|
||||
# Order matters: dump-databases-then-snapshot. backup-db-dump writes the
|
||||
# Immich pg dump onto the NAS mount so the NAS's 03:30 run picks it up an hour
|
||||
# later. If that run ever overtakes this one it snapshots yesterday's dump --
|
||||
# degraded, not broken, and Immich also writes its own daily dump to the same
|
||||
# directory.
|
||||
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
export RESTIC_CACHE_DIR=/var/cache/restic
|
||||
set -a; . /etc/restic/hetzner.env; set +a
|
||||
|
||||
# `hostname` is not installed on the NAS and is not on systemd's PATH in
|
||||
# general; uname -n always is.
|
||||
HOST=$(uname -n); HOST=${HOST%%.*}
|
||||
EXCLUDES=/etc/restic/excludes.txt
|
||||
NTFY_URL=https://ntfy.rcjohnstone.com/backup
|
||||
NTFY_ENV=/etc/ntfy/publish.env
|
||||
LOG=$(mktemp /tmp/restic-backup.XXXXXX)
|
||||
# Keep the log when the run FAILS. Without this the EXIT trap deleted the only
|
||||
# record of restic's actual error, leaving nothing to diagnose from but the 25
|
||||
# lines that made it into the ntfy body -- which is exactly what happened on
|
||||
# 2026-08-24 when forget/prune died and the cause could not be recovered.
|
||||
for d in /var/log "${HOME:-/nonexistent}/.local/state" /tmp; do
|
||||
[ -d "$d" ] && [ -w "$d" ] && { FAILLOG=$d/restic-backup.failed.log; break; }
|
||||
done
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
# Explicit if, not `[ $rc -ne 0 ] && cp ...`: a failing test as the last
|
||||
# statement of a trap is the kind of set -e landmine that has bitten this
|
||||
# codebase before.
|
||||
if [ "$rc" -ne 0 ] && [ -n "${FAILLOG:-}" ]; then
|
||||
cp -f "$LOG" "$FAILLOG" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$LOG"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
PATHS=(
|
||||
/etc
|
||||
/root
|
||||
# /usr/local rather than /usr/local/bin: shanty's live database is bind
|
||||
# mounted from /usr/local/shanty, not from anywhere under /home/connor.
|
||||
/usr/local
|
||||
/var/backups/db
|
||||
# Five named podman volumes live here. Four are tiny and one (model-cache)
|
||||
# is excluded. Compose bind-mounts almost everything from /home/connor, so
|
||||
# this is easy to forget -- and forgetting it silently drops syncthing's
|
||||
# and searxng's state from every snapshot.
|
||||
/var/lib/containers/storage/volumes
|
||||
/home/connor
|
||||
)
|
||||
|
||||
log() { printf '%s restic-backup: %s\n' "$(date -Is)" "$*" | tee -a "$LOG"; }
|
||||
|
||||
notify() { # notify <priority> <tags> <title> <body>
|
||||
local pri=$1 tags=$2 title=$3 body=$4 u p
|
||||
[ -r "$NTFY_ENV" ] || return 0
|
||||
# PARSED, not sourced. The bot password contains ` and &, so `. $NTFY_ENV`
|
||||
# dies with a syntax error -- and it cannot simply be quoted either,
|
||||
# because movie_recs_notify reads the same file with a literal split on
|
||||
# "=" and would then send the quotes as part of the password.
|
||||
u=$(sed -n 's/^NTFY_USER=//p' "$NTFY_ENV" | head -1)
|
||||
p=$(sed -n 's/^NTFY_PASS=//p' "$NTFY_ENV" | head -1)
|
||||
[ -n "$u" ] && [ -n "$p" ] || return 0
|
||||
curl -fsS --max-time 20 \
|
||||
-u "$u:$p" \
|
||||
-H "Title: $title" -H "Priority: $pri" -H "Tags: $tags" \
|
||||
-d "$body" "$NTFY_URL" >/dev/null || true
|
||||
}
|
||||
|
||||
fail() {
|
||||
log "FAILED: $1"
|
||||
notify urgent "rotating_light" "Backup FAILED on $HOST" \
|
||||
"$1"$'\n\n'"$(tail -n 25 "$LOG")"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- 1. consistent database dumps ------------------------------------------
|
||||
log "dumping databases"
|
||||
/usr/local/bin/backup-db-dump >>"$LOG" 2>&1 || fail "backup-db-dump failed"
|
||||
|
||||
# --- 2. snapshot -----------------------------------------------------------
|
||||
log "backing up: ${PATHS[*]}"
|
||||
rc=0
|
||||
nice -n 10 ionice -c2 -n7 restic backup \
|
||||
--one-file-system \
|
||||
--exclude-file="$EXCLUDES" \
|
||||
--exclude-caches \
|
||||
--tag "$HOST" \
|
||||
--verbose=1 \
|
||||
"${PATHS[@]}" >>"$LOG" 2>&1 || rc=$?
|
||||
# restic exits 3 when it could not read *some* files but the snapshot was
|
||||
# still written. That is worth a warning, not a failure -- a nightly job that
|
||||
# hard-fails on one transiently-locked file stops being a backup.
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
[ "$rc" -eq 3 ] || fail "restic backup exited $rc"
|
||||
log "WARN: restic exited 3 (some files unreadable); snapshot was written"
|
||||
fi
|
||||
|
||||
# --- 3. retention ----------------------------------------------------------
|
||||
log "forget + prune"
|
||||
# --group-by host, NOT the default host+paths. With the default, changing the
|
||||
# PATHS list above starts a fresh retention group and the snapshots taken under
|
||||
# the old path list are kept forever -- every group gets its own
|
||||
# daily/weekly/monthly/yearly allowance. One host per repo, so one group.
|
||||
restic forget --prune \
|
||||
--group-by host \
|
||||
--tag "$HOST" \
|
||||
--keep-daily 14 --keep-weekly 8 --keep-monthly 12 --keep-yearly 3 \
|
||||
>>"$LOG" 2>&1 || fail "restic forget/prune failed"
|
||||
|
||||
# --- 4. report -------------------------------------------------------------
|
||||
summary=$(grep -E '^(Added to the repository|processed|snapshot [0-9a-f]{8} saved)' "$LOG" | tail -3)
|
||||
stats=$(restic stats --mode raw-data latest 2>/dev/null | grep -E 'Total Size' || true)
|
||||
log "done"
|
||||
notify default "floppy_disk" "Backup OK on $HOST" "${summary:-(no summary)}"$'\n'"$stats"
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# OnFailure= handler for the restic units. Invoked by systemd with the failed
|
||||
# unit's name as $1.
|
||||
#
|
||||
# Why this exists: restic-backup notifies from inside the script, but
|
||||
# restic-check calls /usr/bin/restic directly and restic-seed had no notify at
|
||||
# all. Both could therefore fail completely silently -- and on 2026-08-21 the
|
||||
# seed did exactly that, dying on a dropped SFTP connection and leaving a stale
|
||||
# lock that then failed the Sunday check, with nothing said for three days.
|
||||
# "Silence is the alarm" only works if every unit can actually raise one.
|
||||
set -uo pipefail
|
||||
UNIT=${1:-unknown.service}
|
||||
NTFY_URL=https://ntfy.rcjohnstone.com/backup
|
||||
|
||||
for f in /etc/ntfy/publish.env /home/connor/.config/ntfy/publish.env; do
|
||||
[ -r "$f" ] && { NTFY_ENV=$f; break; }
|
||||
done
|
||||
[ -n "${NTFY_ENV:-}" ] || exit 0
|
||||
|
||||
# PARSED, not sourced -- the bot password contains ` and &, so sourcing dies
|
||||
# with a syntax error. Same reason restic-backup's notify() uses sed.
|
||||
u=$(sed -n 's/^NTFY_USER=//p' "$NTFY_ENV" | head -1)
|
||||
p=$(sed -n 's/^NTFY_PASS=//p' "$NTFY_ENV" | head -1)
|
||||
[ -n "$u" ] && [ -n "$p" ] || exit 0
|
||||
|
||||
HOST=$(uname -n); HOST=${HOST%%.*}
|
||||
RESULT=$(systemctl show -p Result --value "$UNIT" 2>/dev/null)
|
||||
BODY=$(printf '%s failed on %s (Result=%s)\n\n%s\n' \
|
||||
"$UNIT" "$HOST" "$RESULT" \
|
||||
"$(journalctl -u "$UNIT" -n 20 --no-pager -o cat 2>/dev/null | tail -c 1200)")
|
||||
|
||||
curl -fsS --max-time 20 -u "$u:$p" \
|
||||
-H "Title: $HOST: $UNIT FAILED" -H "Priority: urgent" -H "Tags: rotating_light" \
|
||||
-d "$BODY" "$NTFY_URL" >/dev/null || true
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"env": {
|
||||
"PATH": "$(PATH):$(HOME)/.local/bin"
|
||||
},
|
||||
"apps": [
|
||||
{
|
||||
"name": "Desktop",
|
||||
"image-path": "desktop.png"
|
||||
},
|
||||
{
|
||||
"name": "Low Res Desktop",
|
||||
"image-path": "desktop.png",
|
||||
"prep-cmd": [
|
||||
{
|
||||
"do": "xrandr --output HDMI-1 --mode 1920x1080",
|
||||
"undo": "xrandr --output HDMI-1 --mode 1920x1200"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Steam Big Picture",
|
||||
"detached": [
|
||||
"setsid steam steam://open/bigpicture"
|
||||
],
|
||||
"prep-cmd": [
|
||||
{
|
||||
"do": "",
|
||||
"undo": "setsid steam steam://close/bigpicture"
|
||||
}
|
||||
],
|
||||
"image-path": "steam.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
adapter_name = /dev/dri/renderD129
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Dynamic DNS record update
|
||||
# Singleton: updates live DNS records. Was a user crontab entry
|
||||
# (0 2 */2 * * ~/.local/bin/ddns_update) until the 2026-09 refit.
|
||||
ConditionHost=mainframe
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=%h/.dotfiles/hosts/mainframe/bin/ddns_update
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Dynamic DNS record update, every other day
|
||||
ConditionHost=mainframe
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-*/2 02:00:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
# Singleton: this automation must run on mainframe and nowhere else.
|
||||
# Structural guard is hosts/mainframe/ placement; this is the backstop.
|
||||
ConditionHost=mainframe
|
||||
Description=Archive stale unread mail out of the Proton inbox
|
||||
Documentation=file:///home/connor/docs/mail/deploy/README.md
|
||||
# Proton Bridge is a container, not a user unit, so this cannot Requires= it.
|
||||
# The script pushes a failure notification if Bridge is down or unauthenticated.
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
EnvironmentFile=-%h/.config/proton/bridge.env
|
||||
ExecStart=%h/.dotfiles/hosts/mainframe/bin/inbox_tidy
|
||||
# Classifying ~1200 messages means fetching that many header sets over
|
||||
# loopback IMAP. Slow, but nowhere near this ceiling.
|
||||
TimeoutStartSec=30min
|
||||
Nice=10
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Daily inbox tidy
|
||||
|
||||
[Timer]
|
||||
# Early, so the inbox is already tidy by the time it is first looked at, and
|
||||
# well clear of rent-utilities at 09:00 on the 5th.
|
||||
OnCalendar=*-*-* 07:30:00
|
||||
# If the machine was off, still run on the next boot rather than skipping a day
|
||||
# and letting two days of mail pile up.
|
||||
Persistent=true
|
||||
RandomizedDelaySec=10m
|
||||
AccuracySec=1min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
# Singleton: this automation must run on mainframe and nowhere else.
|
||||
# Structural guard is hosts/mainframe/ placement; this is the backstop.
|
||||
ConditionHost=mainframe
|
||||
Description=Generate movie recommendations from the Radarr library and push to ntfy
|
||||
# LiteLLM is a container, not a user unit, so this cannot Requires= it.
|
||||
# The script pushes a failure notification if it is unreachable.
|
||||
#
|
||||
# Timing note: this asks for gemma3-12b, which llama-swap loads on demand and
|
||||
# which EVICTS the resident gpt-oss-20b for the duration (one llama-server at
|
||||
# a time, one GPU). gemma3-12b then unloads on its own after 900 s idle. So an
|
||||
# 08:00 run costs the next OpenCode request a cold gpt-oss-20b reload. That is
|
||||
# the intended trade -- a once-daily swap is exactly the "keep swaps rare"
|
||||
# budget the llama-swap config is written around.
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=%h/.dotfiles/hosts/mainframe/bin/movie_recs_notify
|
||||
# Generation on a partially-offloaded model is slow; do not let systemd
|
||||
# kill a run that is still making progress.
|
||||
TimeoutStartSec=2700
|
||||
Nice=10
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Daily movie recommendations
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 08:00:00
|
||||
# Fire on next boot if the machine was off at 08:00.
|
||||
Persistent=true
|
||||
# Avoid colliding with anything else that starts on the hour.
|
||||
RandomizedDelaySec=10m
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
# Singleton: this automation must run on mainframe and nowhere else.
|
||||
# Structural guard is hosts/mainframe/ placement; this is the backstop.
|
||||
ConditionHost=mainframe
|
||||
Description=Assemble the rental utilities message and push it to ntfy
|
||||
Documentation=file:///home/connor/docs/leases/deploy/README.md
|
||||
# Proton Bridge is a container, not a user unit, so this cannot Requires= it.
|
||||
# The script pushes a failure notification if Bridge is down or unauthenticated,
|
||||
# which is the point -- a monthly job that fails silently is invisible until the
|
||||
# next run, by which time you have stopped expecting it.
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Holds PROTON_BRIDGE_PASSWORD. `-` so the unit still runs (and still pushes a
|
||||
# useful error) before Bridge has ever been set up.
|
||||
EnvironmentFile=-%h/.config/proton/bridge.env
|
||||
ExecStart=%h/.dotfiles/hosts/mainframe/bin/rent_utilities
|
||||
# Fetching two statements over loopback IMAP; a minute is already generous.
|
||||
TimeoutStartSec=300
|
||||
Nice=10
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Monthly rental utilities message
|
||||
|
||||
[Timer]
|
||||
# The 5th: late enough that both statements for the previous month have
|
||||
# normally landed, early enough to leave room to chase one that has not.
|
||||
OnCalendar=*-*-05 09:00:00
|
||||
# If the machine was down on the 5th, still run on the next boot rather than
|
||||
# skipping the month entirely.
|
||||
Persistent=true
|
||||
RandomizedDelaySec=5m
|
||||
AccuracySec=1min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,8 @@
|
||||
export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/docker.sock"
|
||||
export LIBVA_DRIVER_NAME="nvidia"
|
||||
|
||||
# Not actually where the music lives, but mpd's playlist paths are resolved
|
||||
# relative to this and the real mount is bind-mounted in.
|
||||
export MPD_MUSIC_DIR="/srv/audio/music"
|
||||
|
||||
export OUIJA_DIR="$HOME/docs/orion/ouija"
|
||||
@@ -0,0 +1,4 @@
|
||||
# Docker and podman both need sudo here: the rootless socket is not wired up,
|
||||
# and the homelab stack runs as root podman.
|
||||
alias dc="sudo docker compose"
|
||||
alias pc="sudo podman-compose"
|
||||
Reference in New Issue
Block a user