Skip to content

Commit 4884fc1

Browse files
committed
setup vault-sync
1 parent 98ab75b commit 4884fc1

7 files changed

Lines changed: 612 additions & 0 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
/dist/
22
/result
3+
__pycache__/
4+
*.pyc

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,23 @@ nodes; use an ephemeral key so retired ones auto-clean (see step 3).
138138
each position straight into the assistant's Obsidian vault as a note; the
139139
service retries every 10s until the file exists.
140140
141+
7. **(Optional)** Place a Discogs personal access token so `assistant-vault-sync`
142+
can sync your record collection + wantlist into Album notes. Letterboxd is
143+
public and needs nothing; without this file only the Discogs half is skipped.
144+
Get the token at <https://www.discogs.com/settings/developer>. Same runtime
145+
env-file pattern, backed up under the assistant home:
146+
147+
```
148+
sudo install -d -o 2001 -g 2001 -m 700 /var/lib/assistant/.config/vault-sync
149+
sudo sh -c 'umask 077; printf "DISCOGS_TOKEN=%s\n" "<token>" > /var/lib/assistant/.config/vault-sync/env'
150+
sudo chown 2001:2001 /var/lib/assistant/.config/vault-sync/env
151+
```
152+
153+
Optionally add `LETTERBOXD_USERNAME=…` / `DISCOGS_USERNAME=…` lines (both
154+
default to `ngalaiko`). The scripts are additive: a new movie or record
155+
becomes a note, a repeat watch appends a `watched:` date, and a wishlist
156+
record you buy gets its blank `lp:` filled — hand edits are never clobbered.
157+
141158
## Configuration
142159
143160
### Backups

hosts/exedev/users/assistant.nix

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ let
1919
vault="''${OBSIDIAN_VAULT_DIR:-/var/lib/assistant/Vault}"
2020
exec ${obsidian-headless}/bin/ob sync --path "$vault" "$@"
2121
'';
22+
# vault-sync: stdlib-only Python that writes Letterboxd watches into Movie
23+
# notes and the Discogs collection+wantlist into Album notes. Additive only —
24+
# it dedups on each note's letterboxd:/discogs: URL, so it appends a new
25+
# watched date or fills a blank lp:, never rewriting a hand-curated note.
26+
vault-sync = import ../../../packages/vault-sync { inherit pkgs; };
27+
# Letterboxd is public (hourly); Discogs is rate-limited and rarely changes
28+
# (every 6h). Discogs self-skips without a token, so a missing token never
29+
# blocks the Letterboxd half.
30+
vaultSyncCrontab = pkgs.writeText "vault-sync-crontab" ''
31+
17 * * * * ${vault-sync}/bin/vault-sync-letterboxd
32+
47 */6 * * * ${vault-sync}/bin/vault-sync-discogs
33+
'';
2234
in
2335
{
2436
users.users.assistant = {
@@ -48,6 +60,7 @@ in
4860
chromium
4961
obsidian-headless
5062
obsidian-sync
63+
vault-sync # `vault-sync-letterboxd` / `vault-sync-discogs`, for manual runs
5164
];
5265
};
5366
users.groups.assistant.gid = 2001;
@@ -164,6 +177,49 @@ in
164177
'';
165178
};
166179

180+
# vault-sync: supercronic runs the Letterboxd (Movies) and Discogs (Albums)
181+
# polls on a schedule, as the assistant so the notes are written into the
182+
# vault it owns; obsidian-sync then propagates them. Guards on the vault being
183+
# set up, like the services above.
184+
s6.services.assistant-vault-sync = {
185+
dependencies = [
186+
"base"
187+
"backup-restore"
188+
];
189+
run = ''
190+
vault=/var/lib/assistant/Vault
191+
if [ ! -d "$vault/.obsidian" ]; then
192+
echo "assistant-vault-sync: $vault not configured yet (run ob sync-setup). Retrying." >&2
193+
sleep 30
194+
exit 1
195+
fi
196+
# Optional runtime env: DISCOGS_TOKEN (+ optional LETTERBOXD_USERNAME /
197+
# DISCOGS_USERNAME). Absent -> the Discogs poll self-skips and only
198+
# Letterboxd (public) runs. Exported (set -a) so `env` (no -i) and
199+
# s6-setuidgid pass it to supercronic's jobs through the environment,
200+
# never argv.
201+
envfile=/var/lib/assistant/.config/vault-sync/env
202+
if [ -f "$envfile" ]; then
203+
set -a
204+
. "$envfile"
205+
set +a
206+
else
207+
echo "assistant-vault-sync: $envfile missing; Discogs sync skipped until DISCOGS_TOKEN is placed (see README)." >&2
208+
fi
209+
exec /command/s6-setuidgid assistant \
210+
env \
211+
HOME=/var/lib/assistant \
212+
USER=assistant \
213+
SHELL=/bin/sh \
214+
PATH=/etc/profiles/per-user/assistant/bin:/nix/var/nix/profiles/default/bin:/bin:/sbin:/usr/bin \
215+
SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt \
216+
NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt \
217+
NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-bundle.crt \
218+
OBSIDIAN_VAULT_DIR="$vault" \
219+
${pkgs.supercronic}/bin/supercronic ${vaultSyncCrontab}
220+
'';
221+
};
222+
167223
# Telegram bridge for pi, via pilegram (the flake input above). Long-polling
168224
# needs only outbound HTTPS, so nothing is exposed on the tailnet or the image.
169225
# pilegram reads pi's provider keys from ~/.pi and keeps its own state under

packages/vault-sync/default.nix

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{ pkgs }:
2+
# Two tiny stdlib-only Python scripts that write vault notes from Letterboxd
3+
# (Movies) and Discogs (Albums). No third-party deps, so they build against the
4+
# pinned python3 directly; a shared vaultlib.py sits on PYTHONPATH. Exposes
5+
# `vault-sync-letterboxd` and `vault-sync-discogs`.
6+
let
7+
inherit (pkgs) lib python3 makeWrapper;
8+
in
9+
pkgs.stdenv.mkDerivation {
10+
pname = "vault-sync";
11+
version = "0.1.0";
12+
src = ./.;
13+
nativeBuildInputs = [ makeWrapper ];
14+
15+
# scripts are stdlib-only; just syntax-check them at build time.
16+
doCheck = true;
17+
checkPhase = ''
18+
${python3}/bin/python3 -m py_compile vaultlib.py letterboxd.py discogs.py
19+
'';
20+
21+
installPhase = ''
22+
mkdir -p $out/libexec $out/bin
23+
cp vaultlib.py letterboxd.py discogs.py $out/libexec/
24+
for s in letterboxd discogs; do
25+
makeWrapper ${python3}/bin/python3 $out/bin/vault-sync-$s \
26+
--add-flags $out/libexec/$s.py \
27+
--set PYTHONPATH $out/libexec
28+
done
29+
'';
30+
31+
meta = {
32+
description = "Sync Letterboxd + Discogs into the Obsidian vault as notes";
33+
mainProgram = "vault-sync-letterboxd";
34+
license = lib.licenses.mit;
35+
};
36+
}

packages/vault-sync/discogs.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
#!/usr/bin/env python3
2+
"""Sync a Discogs collection + wantlist into the vault as Album notes.
3+
4+
The vault's convention (see the Albums memory / README): one note per album,
5+
``categories: [[Albums]]``, an ``lp:`` date meaning "owned on vinyl" — empty for
6+
a wishlist record. So:
7+
* every release in the Discogs collection -> Album note, lp = date added;
8+
* every release in the wantlist not owned -> Album note, lp empty (wishlist).
9+
10+
Dedup is by the ``discogs:`` release URL, which every existing album carries.
11+
A wishlist note whose record you later buy gets its blank ``lp:`` filled in
12+
(the only mutation); nothing else about a hand-curated note is touched.
13+
"""
14+
15+
import argparse
16+
import json
17+
import os
18+
import re
19+
import sys
20+
import time
21+
import urllib.request
22+
from urllib.parse import urljoin
23+
24+
import vaultlib
25+
26+
BASE_URL = "https://api.discogs.com"
27+
UA = "vault-sync robot"
28+
29+
30+
def api_get(url, token):
31+
headers = {"User-Agent": UA, "Accept": "application/json"}
32+
if token:
33+
headers["Authorization"] = f"Discogs token={token}"
34+
while True:
35+
req = urllib.request.Request(method="GET", url=url, headers=headers)
36+
try:
37+
resp = urllib.request.urlopen(req)
38+
except urllib.error.HTTPError as e:
39+
if e.code == 429: # rate limited
40+
time.sleep(int(e.headers.get("Retry-After", 2)))
41+
continue
42+
raise
43+
return json.loads(resp.read())
44+
45+
46+
def paginate(first_url, token, key):
47+
url = first_url
48+
while url:
49+
page = api_get(url, token)
50+
for item in page.get(key, []):
51+
yield item
52+
url = page.get("pagination", {}).get("urls", {}).get("next")
53+
time.sleep(1) # stay under Discogs' rate limit
54+
55+
56+
_ARTIST_SUFFIX = re.compile(r"\s*\(\d+\)$") # Discogs disambiguator, e.g. "Nirvana (2)"
57+
58+
59+
def clean_artist(name):
60+
return _ARTIST_SUFFIX.sub("", name).strip()
61+
62+
63+
def release_url(info):
64+
return f"https://www.discogs.com/release/{info['id']}"
65+
66+
67+
def album_note(title, year, artists, cover_file, url, lp_date):
68+
artist_block = "artist:\n" + "".join(f' - "[[{a}]]"\n' for a in artists)
69+
cover_line = f'cover: "[[{cover_file}]]"\n' if cover_file else "cover:\n"
70+
lp_line = f'lp: "[[{lp_date}]]"\n' if lp_date else "lp:\n"
71+
return (
72+
"---\n"
73+
"categories:\n"
74+
' - "[[Albums]]"\n'
75+
f"{artist_block}"
76+
f"{cover_line}"
77+
f"discogs: {url}\n"
78+
f"{lp_line}"
79+
f"year: {year}\n"
80+
"---\n"
81+
)
82+
83+
84+
def sync_release(item, references, attachments, token, lp_date, index):
85+
"""Create or update a single album note. ``lp_date`` is the owned-on date
86+
(collection) or None (wantlist). ``index`` is the {discogs-url: path} dedup
87+
map, updated in place when a note is created. Returns 'new', 'updated', or
88+
None."""
89+
info = item["basic_information"]
90+
url = release_url(info)
91+
title = info["title"]
92+
year = info.get("year") or ""
93+
artists = [clean_artist(a["name"]) for a in info.get("artists", []) if a.get("name")]
94+
95+
existing = index.get(url.rstrip("/"))
96+
if existing:
97+
if lp_date and vaultlib.set_scalar_if_empty(existing, "lp", f'"[[{lp_date}]]"'):
98+
print(f" + owned {lp_date}: {os.path.basename(existing)}")
99+
return "updated"
100+
return None
101+
102+
path, base = vaultlib.unique_note_path(references, title, year)
103+
104+
cover_file = None
105+
cover = info.get("cover_image")
106+
if cover and "spacer" not in os.path.basename(cover):
107+
ext = os.path.splitext(cover.split("?")[0])[1] or ".jpeg"
108+
cover_file = f"{base}{ext}"
109+
try:
110+
vaultlib.download(cover, os.path.join(attachments, cover_file),
111+
{"User-Agent": UA, "Authorization": f"Discogs token={token}"})
112+
except Exception as e:
113+
print(f" war: cover download failed for {title}: {e}", file=sys.stderr)
114+
cover_file = None
115+
116+
for a in artists:
117+
vaultlib.ensure_person_note(references, a, "Artists")
118+
119+
vaultlib.write_note(path, album_note(title, year, artists, cover_file, url, lp_date))
120+
index[url.rstrip("/")] = path
121+
kind = "owned" if lp_date else "wishlist"
122+
print(f" new album ({kind}): {os.path.basename(path)} ({', '.join(artists)})")
123+
return "new"
124+
125+
126+
def main(username, token, vault, include_wantlist):
127+
references = os.path.join(vault, "References")
128+
attachments = os.path.join(vault, "Attachments")
129+
os.makedirs(references, exist_ok=True)
130+
os.makedirs(attachments, exist_ok=True)
131+
132+
index = vaultlib.index_by_field(references, "discogs")
133+
created = updated = 0
134+
135+
collection = urljoin(BASE_URL, f"/users/{username}/collection/folders/0/releases?sort=artist")
136+
for item in paginate(collection, token, "releases"):
137+
date_added = (item.get("date_added") or "")[:10] # ISO ts -> YYYY-MM-DD
138+
result = sync_release(item, references, attachments, token, date_added or None, index)
139+
created += result == "new"
140+
updated += result == "updated"
141+
142+
if include_wantlist:
143+
wantlist = urljoin(BASE_URL, f"/users/{username}/wants")
144+
for item in paginate(wantlist, token, "wants"):
145+
result = sync_release(item, references, attachments, token, None, index)
146+
created += result == "new"
147+
148+
print(f"discogs: {created} new, {updated} updated")
149+
150+
151+
if __name__ == "__main__":
152+
p = argparse.ArgumentParser(description="Sync Discogs collection + wantlist into vault Album notes.")
153+
p.add_argument("-u", "--username", default=os.environ.get("DISCOGS_USERNAME", "ngalaiko"))
154+
p.add_argument("-t", "--token", default=os.environ.get("DISCOGS_TOKEN"))
155+
p.add_argument("--vault", default=os.environ.get("OBSIDIAN_VAULT_DIR", "/var/lib/assistant/Vault"))
156+
p.add_argument("--no-wantlist", dest="wantlist", action="store_false", help="skip the wantlist (owned records only)")
157+
args = p.parse_args()
158+
if not args.token:
159+
sys.exit("discogs: no token (set DISCOGS_TOKEN or pass --token)")
160+
main(args.username, args.token, args.vault, args.wantlist)

0 commit comments

Comments
 (0)