Compare commits

...
38 Commits
Author SHA1 Message Date
Outis a7a79806dd Merge pull request #457 from outis1one/claude/steam-non-steam-apps-visibility-p1uqc2
wolf: grant /dev/uinput to the Steam container for Steam Input's virt…
2026-09-10 14:51:50 -04:00
Claude 9524c79557 wolf: grant /dev/uinput to the Steam container for Steam Input's virtual controller
Confirmed live: Steam's own controller detection and test screen worked
fine (device listed, buttons fired correctly, Gamepad template applied)
but every actual game — a real Steam title or a non-Steam emulator
shortcut alike — saw no controller at all.

Root cause: Steam Input never hands a game the raw controller device.
It exclusively grabs the raw device for itself and creates its own
synthetic virtual controller via /dev/uinput, then hands that synthetic
device to the game. Reading/testing the raw device (what Steam's own
controller page does) doesn't need uinput; creating the virtual output
device it hands to games does. The 'steam' CATALOG entry only granted
/dev/input/*, /dev/dri/*, /dev/nvidia* via GOW_REQUIRED_DEVICES and had
no devices= list at all — so Steam Input could read the controller but
could never create the virtual device games actually see.

This is the exact same requirement the 'esde' entry already has for
AntiMicroX (also a uinput-based synthetic input tool), including the
same two-part fix noted in its own comment: GOW_REQUIRED_DEVICES alone
only gets the base image's entrypoint script to bind-mount the node —
the container also needs Wolf's own create-time devices= grant to
actually open it. Added /dev/uinput to GOW_REQUIRED_DEVICES and a
devices=['/dev/uinput:/dev/uinput'] entry to both copies of the
'steam' CATALOG dict, matching esde's existing pattern exactly.

update_field() already refreshes an existing app's 'devices' field on
rerun (added specifically for this same esde/AntiMicroX case per its
own comment), so reselecting Steam via 'sudo ./setup.sh wolf' or
'./manage.sh apps' picks this up without a fresh reinstall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BWYKEERLA1a7gv86Z4W23
2026-09-10 18:49:59 +00:00
Outis 89e8a01d1f Merge pull request #456 from outis1one/claude/pensive-hopper-4c9e7i
anki-deck-periodic.py: fix element-photo fetching hitting Wikipedia's…
2026-09-10 12:59:30 -04:00
Outis f81ce0e0a9 Merge pull request #455 from outis1one/claude/steam-non-steam-apps-visibility-p1uqc2
wolf: fix GE-Proton download URL resolution (malformed URL)
2026-09-10 12:55:57 -04:00
Claude 2070c31cfe anki-deck-periodic.py: fix element-photo fetching hitting Wikipedia's rate limit
Confirmed live by the user: the one-request-per-element loop (per-element
API metadata lookup + per-element image download, no delay between any of
them) got 429'd by Wikimedia partway through a real run of --deck hs.

Fixes:
- Batch pageimage metadata lookups up to 50 titles per MediaWiki query
  instead of one request per element (ensure_element_photos ->
  resolve_pageimage_urls), cutting ~99 requests down to ~2 for a full hs
  run.
- Retry with backoff on 429 (http_get_with_retry), honoring Wikipedia's
  own Retry-After header when present.
- Request thumbnails (piprop=thumbnail) instead of full-resolution
  originals, per Wikimedia's own guidance in the 429 response body.
- Small delay between individual image downloads, which still can't be
  batched (one HTTP request per element's actual image bytes).

Also fixes a real bug caught while unit-testing the new batched
redirect/normalization resolution against a simulated response: the
final-title -> original-input-title lookup had the mapping backwards
(looked up by final title in a dict keyed by input title), which would
have silently dropped every element whose title needed resolving through
a redirect (e.g. Cesium -> Caesium) even after the rate-limit fix.

Still couldn't test against the real Wikipedia API (no network route to
en.wikipedia.org from this sandbox) — verified instead with a local HTTP
server simulating 429-then-200 and a fabricated MediaWiki response with a
redirect chain. Needs a real run to fully confirm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-10 16:55:57 +00:00
Claude 52c7609050 wolf: fix GE-Proton download URL resolution (malformed URL)
./manage.sh ge-proton failed live with:
  curl: (3) URL rejected: Malformed input to a URL function

Root cause: the GitHub release JSON is parsed with a plain
grep browser_download_url | grep '\.tar\.gz' | cut -d'"' -f4 pipeline.
Once a release publishes more than one asset whose name contains
".tar.gz" (a second architecture build, a checksum-adjacent file,
etc.), grep returns more than one line and $(...) glues them together
with an embedded newline instead of yielding a single URL — curl then
rejects the whole multi-line string outright. Reproduced synthetically
with a release carrying two .tar.gz assets: the old pipeline emits two
lines where exactly one is expected.

Fixed all three independent copies of this same extraction (manage.sh's
_cache_ge_proton() helper and its ge-proton command, plus install_wolf()'s
own pre-download step) to parse the release JSON for real via python3
instead of pattern-matching the raw text — filtering by an actual
".tar.gz" suffix (not substring) and preferring an x86_64-tagged asset
if more than one still matches, since every container this repo runs
is x86_64. Verified against a synthetic multi-asset release JSON that
the new logic picks exactly one clean URL where the old one produced
two concatenated lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BWYKEERLA1a7gv86Z4W23
2026-09-10 16:51:11 +00:00
Outis 38d1fe8f3a Merge pull request #454 from outis1one/claude/pensive-hopper-4c9e7i
anki decks: add multiple-choice shapes variant; add real element phot…
2026-09-10 10:26:38 -04:00
Outis d94c4320a4 Merge pull request #453 from outis1one/claude/steam-non-steam-apps-visibility-p1uqc2
Claude/steam non steam apps visibility p1uqc2
2026-09-10 10:26:07 -04:00
Claude 67282521ac anki decks: add multiple-choice shapes variant; add real element photos to periodic table
shapes_mc (tools/anki-deck-visual.py) is the same shape images as
"shapes" but multiple choice instead of type-the-name, matching the
plain-text A/B/C/D pattern anki-deck-periodic.py's "category" deck
already uses (no clickable UI, since that needs a desktop-only Anki
add-on and breaks on AnkiDroid/AnkiMobile).

periodic prehs/hs (tools/anki-deck-periodic.py) now show each element's
real sample photo alongside every card, fetched once from Wikipedia's
own MediaWiki pageimages API and cached under tools/periodic_images/ —
the one part of this tooling that needs internet access at generation
time; --no-images restores the old text-only cards. Elements 100
(Fermium) through 118 (Oganesson) are excluded, since none has ever
existed in a photographable quantity — every fetch is otherwise
per-element and non-fatal, with progress printed so failures are visible.

This fetch logic could not be exercised against the real Wikipedia API
from this sandbox (no route to en.wikipedia.org here) — --dry-run-tts
now also substitutes a placeholder image so the pipeline is at least
structurally tested end to end. Real-network behavior needs verifying
on an actual run.

Added tools/*.apkg, tools/media_*/, tools/periodic_images/, and
tools/__pycache__/ to .gitignore — all generated/cached locally, never
meant to be committed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-10 14:20:24 +00:00
Outis 5cada48172 Merge pull request #452 from outis1one/claude/pensive-hopper-4c9e7i
anki decks: add --skip-ones multiplication/division variant; switch s…
2026-09-10 10:01:26 -04:00
Claude e4e32f20e7 anki decks: add --skip-ones multiplication/division variant; switch shapes/clocks/currency images from SVG to PNG
--skip-ones drops every fact involving 1 (trivial, not worth drilling),
121 cards instead of 144 for both multiplication and division.

SVG images never displayed on AnkiDroid (mobile) despite rendering fine
on desktop Anki — a long-documented, still-open AnkiDroid limitation
(multiple open ankidroid/Anki-Android GitHub issues), not a bug in the
generated markup. Rewrote shape/clock/coin image generation to render
PNG via Pillow instead, preserving the same geometry math (regular
polygons, clock-hand angles including the fractional hour-hand
movement, coin layouts) — card counts and content are unchanged, only
the image format.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-10 12:28:28 +00:00
Claude 05942fb44a wolf: add fullscreen wrapper for Steam-launched emulators, document controller/crash troubleshooting
Addresses reported symptoms after adding Cemu/Azahar/PCSX2/Dolphin/ES-DE
as non-Steam games in Wolf's Steam app: a launched emulator opens at
roughly half the screen, and the controller doesn't respond.

Half-screen: a non-Steam game launched from Steam is a second top-level
window inside Wolf's single-app Steam Sway session (RUN_SWAY=true) —
Sway's own kiosk config only auto-fullscreens the ONE window it expects
(Steam's own), so anything launched from inside Steam opens at whatever
default size it requests instead. steam-add-nonsteam-game and
steam-setup-frontends now route the launch through a small generated
wrapper (emulators/steam-fullscreen-wrap) that launches the real binary
and repeatedly asks Sway to fullscreen whatever currently has focus for
a few seconds after launch. NOT yet confirmed live against a real Wolf
Steam session — if swaymsg isn't reachable from inside that container,
the wrapper's loop just fails silently (2>/dev/null) and the launch is
unaffected, so this is safe to try either way.

Since every wrapped shortcut now shares the same Exe (the wrapper) with
the real per-emulator target carried in LaunchOptions instead, the
shortcuts.vdf dedupe/appid logic had to move from keying on Exe alone
to the (Exe, LaunchOptions) pair — otherwise adding a second emulator
would have silently overwritten the first one's entry. Also cleans up
a legacy pre-wrapper entry (Exe pointing directly at the same real
binary, no LaunchOptions) when re-adding something added before this
existed, so re-running doesn't leave a stale duplicate tile behind.
Verified with a synthetic shortcuts.vdf: two different emulators keep
distinct entries, re-adding one updates in place, and a legacy
direct-launch entry gets replaced rather than duplicated.

Controller not responding turned out to most likely be Steam Input
applying its own (unconfigured) controller mapping to the new shortcut
rather than passing raw input through — documented the actual fix
(Big Picture -> shortcut -> Controller Options -> Gamepad template or
disable Steam Input) since this is a Steam-side per-shortcut setting
this installer has no way to preconfigure via shortcuts.vdf.

Also documented the third reported symptom (Cemu/Azahar/PCSX2 tiles
appear but crash on launch, while Dolphin/ES-DE work) as a separate,
not-yet-diagnosed issue, with the exact docker exec command to capture
the real error directly (no wrapper, no Steam) since guessing at a fix
without that output isn't reliable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BWYKEERLA1a7gv86Z4W23
2026-09-10 03:40:42 +00:00
Claude d41da6356d wolf: fix Steam catalog entry missing roms/bios/retro-home mounts
install_wolf()'s own embedded copy of the app CATALOG dict (used the
first time Wolf apps are injected into config.toml) had drifted from
the copy in the generated manage.sh's own 'apps' command: the 'steam'
entry only mounted steam-cache/ and emulators/, missing roms/, saves/,
media/, bios/, retro-home/, retro-home-data/, retroarch/,
esde-custom-systems/, and esde-settings/ — all present in the
manage.sh copy already, per this repo's own existing comment
explaining exactly why they're needed.

Confirmed live: this is why ES-DE (or Dolphin) added to Steam as a
non-Steam game saw none of the ROMs/BIOS files the esde app finds, and
had no persisted controller/input config either, since that also lives
under the now-unmounted retro-home/esde-settings paths.

Diffing the two CATALOG copies end-to-end confirmed this was the only
drift — every other app entry (esde, lutris, retroarch, prismlauncher,
kodi, firefox, desktop) already matched.

update_field() already refreshes an existing app's 'mounts' field on
every rerun, so re-selecting Steam via 'sudo ./setup.sh wolf' or
'./manage.sh apps' picks up the fix without a fresh reinstall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BWYKEERLA1a7gv86Z4W23
2026-09-10 03:30:12 +00:00
Outis 8f83c96ca7 Merge pull request #451 from outis1one/claude/steam-non-steam-apps-visibility-p1uqc2
wolf: fix ES-DE GitLab AppImage download resolving no URL
2026-09-09 23:17:10 -04:00
Claude 750c55a4d4 wolf: fix ES-DE GitLab AppImage download resolving no URL
Two compounding bugs in _wolf_download_emulator_appimage_gitlab() made
the ES-DE AppImage download always fail with "Could not resolve
download URL", confirmed live against the real GitLab API:

1. The asset filter checked url.endswith(".AppImage"), but GitLab's own
   release-asset URL is an opaque .../package_files/<id>/download link
   with no filename in it at all — only the asset's own "name" field
   (e.g. "ES-DE_x64.AppImage") carries the real filename. Filtering on
   the URL suffix matched nothing, even though the latest ES-DE release
   genuinely ships x64/aarch64/SteamDeck AppImage assets.

2. Even with the URL resolved, the download target was built as
   $dir/$(basename "$_url"), which for that same opaque URL evaluates
   to just "download" instead of the real filename — breaking every
   downstream step that looks for a *.AppImage file (the ES-DE.AppImage
   symlink creation, and the "already downloaded" idempotency check on
   a later rerun).

Fixed by filtering on the asset's own "name" field and threading that
name through (tab-separated from the URL) to use as the actual saved
filename. Verified end-to-end against the live GitLab API: resolves to
ES-DE_x64.AppImage and downloads a real, correctly-arched ELF binary.

(Also had to drop an f-string in the same python snippet — pre-3.12
Python disallows a backslash inside an f-string's {} expression, and
separately this whole snippet is wrapped in a bash single-quoted
string, so it can't contain single quotes at all either. Plain string
concatenation avoids both constraints.)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BWYKEERLA1a7gv86Z4W23
2026-09-10 03:16:27 +00:00
Outis a03f976eb1 Merge pull request #450 from outis1one/claude/steam-non-steam-apps-visibility-p1uqc2
wolf: fix auto steam-setup-frontends invocation ($0 resolution)
2026-09-09 23:16:17 -04:00
Outis 2820471654 Merge pull request #448 from outis1one/claude/pensive-hopper-4c9e7i
Add anki-progress service + anki-deck-*.py generation tools
2026-09-09 23:15:40 -04:00
Claude 92e9ee19a7 wolf: fix auto steam-setup-frontends invocation ($0 resolution)
install_wolf()'s new auto-wire-up called it as 'bash manage.sh
steam-setup-frontends', which sets $0 inside manage.sh to the bare
string 'manage.sh' (no path). steam-setup-frontends re-invokes itself
per emulator via "$0" steam-add-nonsteam-game ..., and a bare
'manage.sh' with no '/' triggers a $PATH lookup instead of running the
local file — confirmed live: every emulator already downloaded (Azahar,
PCSX2, Cemu, Dolphin) failed with 'manage.sh: command not found' and
the run ended with a false "No emulator AppImages found" even though
they were sitting right there in emulators/.

Invoking it as './manage.sh' instead keeps $0 as './manage.sh', which
resolves correctly for the nested re-invocation, matching how every
other caller in this file already runs it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BWYKEERLA1a7gv86Z4W23
2026-09-10 03:06:00 +00:00
Outis 15f594e4eb Merge pull request #449 from outis1one/claude/steam-non-steam-apps-visibility-p1uqc2
wolf: auto-add downloaded emulators to Steam's library on rerun
2026-09-09 23:01:18 -04:00
Claude cf879f0090 wolf: auto-add downloaded emulators to Steam's library on rerun
Downloading an emulator AppImage (Cemu, Azahar, PCSX2, Dolphin, ES-DE,
RetroArch) only ever dropped the file into emulators/ — getting it into
Steam's own shortcuts.vdf as a non-Steam game still needed a separate,
manually-typed manage.sh command per emulator, and steam-setup-frontends
only ever covered ES-DE/RetroArch, leaving Cemu out entirely.

- steam-setup-frontends now scans emulators/ and adds every AppImage it
  finds (deduping a fixed-name symlink like ES-DE.AppImage against the
  real versioned file it points at), instead of only handling ES-DE and
  RetroArch by name.
- install_wolf() now calls it automatically at the end of a run whenever
  Steam already has a signed-in profile, so the flow is just: run
  setup.sh wolf, sign into Steam via Moonlight, run it again — no
  separate manage.sh command to remember.
- Docs (manage.sh help text, printed install summary, generated
  README.md) updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BWYKEERLA1a7gv86Z4W23
2026-09-10 02:47:18 +00:00
Claude 102b0405b4 Drop family framing from anki-progress; add anki-deck-*.py tools
anki-progress.sh and its embedded app.py assumed a family/kids use case
(dashboard title, ntfy topic default, Authelia warning text, comments)
that was never actually stated — nothing in this repo should assume who
the accounts belong to. Retitled to plain "Anki Progress" throughout,
default ntfy topic changed from family-anki to anki-progress, and every
"family member" reference reworded to "account".

Also adds tools/anki-deck-math.py, tools/anki-deck-periodic.py, and
tools/anki-deck-visual.py — the Anki deck-generation scripts developed
earlier in this session, now committed as standalone, self-documented
tools (same tools/*.{sh,py} convention as tools/dedupe-finder.py) rather
than living only in chat. Each script's own header docstring carries the
full one-time setup (venv, genanki + piper-tts, downloading a voice) and
usage — anki-deck-periodic.py and anki-deck-visual.py point back to
anki-deck-math.py's copy rather than repeating it three times. Content is
generic (multiplication/division/addition/subtraction/fractions/decimals,
the periodic table, shapes/clocks/coin-counting) — nothing here assumes
who's using it or why.

Re-verified after the rename: the embedded app.py still passes its full
logic test suite once written out by the installer, and all three
tools/anki-deck-*.py scripts still build correct decks under
--dry-run-tts after their docstrings were rewritten.

Adds a README.md section pointing at the three scripts, and updates the
anki-progress Services table entry to drop "family" from its wording.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-09 19:34:48 +00:00
Claude 9c7a054d97 Add anki-progress service — family Anki study-progress dashboard
Read-only dashboard + ntfy notifications for an anki-sync-server
instance, following this repo's standard Docker-service template
(multi-instance, port scanning, DRY_RUN, update/fresh/cancel).

Dashboard shows reviews today/this week, accuracy, and current streak
per account. A background loop detects when a study session starts
(first review after a configurable inactivity gap, default 30 min) and
sends one ntfy notification a configurable delay later (default 10 min,
per request) if the session is still going — not on every review, and
not twice for the same session.

Reads every account's collection.anki2 with SQLite's read-only mode
(file:...?mode=ro) — never opens for write, so it can't corrupt or lock
out the live sync server or a syncing client. Verified this concurrently
against a real writer with no lock conflict, plus the streak/session/
notify-state logic against synthetic review timelines covering gapped
streaks, multi-session boundaries, and the no-duplicate-notification
requirement, before ever writing the installer around it.

Requires an anki-sync-server instance (hard dependency, checked at
install time, chains only that one direction per this repo's
"Chaining into another service" convention) and auto-detects a local
ntfy install to reach it directly over caddy_net instead of requiring
a public URL. Follows security-dashboard.sh's Authelia pattern: local
Authelia used automatically, remote Authelia offered otherwise, since
this exposes every family member's personal study activity.

Updates the Services table in README.md per the three-step rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-09 19:18:48 +00:00
Outis c63237a3db Merge pull request #447 from outis1one/claude/pensive-hopper-4c9e7i
Fix anki-sync-server container failing to start: data/ ownership
2026-09-09 12:01:00 -04:00
Claude 575c4ac185 Fix anki-sync-server container failing to start: data/ ownership
afrima/anki-sync-server is built on gcr.io/distroless/static-debian12:
nonroot — the process always runs as that image's fixed nonroot UID/GID
(65532), never as ACTUAL_USER, and distroless ships no shell so nothing
inside the container can chown its own data dir at startup.

The installer's final chown gave the whole instance directory to
ACTUAL_USER, including ./data, which the container then can't write to
— it fails outright the moment it tries to create anything under /data
(e.g. a new user's collection), not just at sync time. Confirmed live.

Fix: re-chown ./data to 65532:65532 specifically, applied *after* the
existing ACTUAL_USER chown (not before — that call recurses over the
whole instance dir and would just clobber it). docker-compose.yml/.env/
README.md stay owned by ACTUAL_USER as before. The same fix is applied
in the "update" path so an already-broken existing install self-heals
on the next non-destructive update, without touching its port, Caddy
config, or accounts.

Verified live: fresh install now leaves data/ owned by 65532:65532
while the rest of the instance dir stays ACTUAL_USER; simulating a
pre-fix broken install (data/ owned by root) and running "update"
correctly repairs it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-09 15:50:10 +00:00
Outis a339d5fbb0 Merge pull request #446 from outis1one/claude/pensive-hopper-4c9e7i
Add anki-sync-server service — self-hosted Anki flashcard sync
2026-09-09 11:02:35 -04:00
Claude c2cf5bfe69 Add sync-account management menu to anki-sync-server
Re-running the installer against an existing install now offers "Manage
sync accounts" (add / remove / rotate a password) as a first-class menu
option, instead of requiring a hand-edit of .env kept in lockstep with
docker-compose.yml.

_anki_rewrite_account_block() regenerates the SYNC_USERn lines in both
files from the current account list, always renumbered contiguously from
1, and is shared by the initial install and every management mutation so
they can't drift apart. Only SYNC_USER/ANKI_SYNC_* lines are touched —
port, Caddy wiring, and everything else in either file is left alone.

Verified against a stubbed docker/ss sandbox: add, remove (mid-list, with
renumbering), and password rotation all produce the expected .env/
docker-compose.yml diffs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-09 14:58:32 +00:00
Claude 92f8503d48 Add anki-sync-server service — self-hosted Anki flashcard sync
Docker-based sync backend for the Anki app (afrima/anki-sync-server, the
official Rust sync server). Supports multiple independent accounts per
instance, the repo's multi-instance pattern, port collision avoidance,
Caddy wiring, and update/fresh/cancel reinstall detection.

No Authelia gate — this is a raw sync API the Anki client talks to, not
a browser session, so a forward_auth portal in front of it would just
break every sync request; SYNC_USER1/SYNC_USER2/... is its own auth
boundary.

Companion services/anki-sync-server.md covers client setup (Desktop,
AnkiDroid, AnkiMobile) and the Quizlet import/export walkthrough.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-09 14:07:31 +00:00
Outis d95bd7fd3c Merge pull request #445 from outis1one/claude/gitea-webhook-base-url-9ofg1n
Fix inactivity sync being skipped when remember_me is unchanged
2026-09-09 09:45:38 -04:00
Claude 3cd9a1ece3 Fix _authelia_set_remember_me skipping inactivity sync on a no-op remember_me
The "already equal" early-exit compared only remember_me against the typed
value, so re-entering an unchanged remember_me (the exact case for anyone
who'd set it before the earlier fix existed) skipped the inactivity write
entirely, leaving inactivity stuck at its old mismatched value. Now only
skips when both keys already match the typed duration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148pWopbt3tEKZWHYuHTb3c
2026-09-09 13:20:32 +00:00
Outis 5c13054cdd Merge pull request #444 from outis1one/claude/gitea-webhook-base-url-9ofg1n
Fix Authelia remember_me not actually keeping sessions alive
2026-09-09 09:16:51 -04:00
Outis 7d5674aad8 Merge pull request #443 from outis1one/claude/wolf-controller-setup-vl05t5
wolf: fix stale command list in the post-install summary, surface the…
2026-09-09 09:16:19 -04:00
Claude 2d82b2b278 Fix Authelia remember_me not actually keeping sessions alive
inactivity (idle timeout) was independent of remember_me and stayed at a
much shorter default (2h), so a long remember_me got silently overridden
by ordinary daily gaps between visits. install_authelia()'s template now
defaults inactivity to match remember_me, and the "Change remember me
duration" menu option now writes both keys together instead of just one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148pWopbt3tEKZWHYuHTb3c
2026-09-08 22:43:20 +00:00
Claude 08a2617b06 wolf: fix stale command list in the post-install summary, surface the Steam Input workflow
The final echo summary still advertised a removed `apps` command and left out
everything added since (cores, backup, controllers, steam-add-nonsteam-game,
steam-setup-frontends, cemu-clone-controller, cemu-sync-controllers,
install-completion, etc.). Also add a short pointer to the Steam-as-4-controller-hub
workflow (documented in depth further down in README.md) right in the install
summary, since it's currently only discoverable by reading the generated README.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V99t5754SyXdnMTpVe2ba5
2026-09-07 22:29:55 +00:00
Outis 39387b5e0f Merge pull request #442 from outis1one/claude/wolf-cemu-four-controllers-373xn8
Claude/wolf cemu four controllers 373xn8
2026-09-05 15:00:15 -04:00
Claude e67ac50c61 wolf: document the ES-DE/RetroArch-in-Steam workflow in the generated README
The steam-setup-frontends command and the ES-DE/RetroArch AppImage
download step had no matching section in the ~/docker/wolf/README.md
content this file generates, unlike every other manage.sh command. Adds
one, alongside the existing Cemu-in-Steam section: why you'd want it,
how the mounts/cores are already shared, and the two honest caveats
(returning to Steam from ES-DE only works via this path, and whether
controller mappings sync across separately-paired Wolf clients is
expected but unconfirmed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-05 18:58:04 +00:00
Claude 3ebbeba672 wolf: add steam-setup-frontends to wait for Steam sign-in then auto-wire ES-DE/RetroArch
Steam Guard's QR-code sign-in can't be scripted (needs a phone approving
a prompt), so this polls for it instead: starts Wolf if needed, waits
for Steam's userdata/ to appear (or proceeds immediately if already
signed in), then re-invokes the existing steam-add-nonsteam-game command
for whichever of ES-DE.AppImage/RetroArch.AppImage was downloaded during
install. Points to ./manage.sh cores all for the shared cores/shaders/
overlays directory rather than duplicating that download logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-04 21:30:24 +00:00
Claude 07394769ca wolf: add optional ES-DE/RetroArch AppImage downloads for Steam Input
Both projects ship official standalone Linux AppImages separate from
the esde/retroarch Wolf catalog containers this repo already runs.
Adding either one to Steam as a non-Steam game (steam-add-nonsteam-game,
now reaching the same roms/saves/bios/retro-home/retroarch mounts as
the esde/retroarch containers) lets Steam Input assign a 4th controller
its own identity by device path, past Wolf's 3-concrete-pad-type ceiling.

ES-DE is hosted on GitLab rather than GitHub, so this adds a GitLab
Releases API counterpart to the existing GitHub-based download helper.
RetroArch's own buildbot doesn't publish through either API, so this
uses hizzlekizzle/RetroArch-AppImage, the community nightly-build
project the AppImage catalogs themselves point to (flagged as
third-party, same treatment this file already gives the Dolphin
community build). Both downloads are opt-in (default no) and symlink
to a fixed filename so steam-add-nonsteam-game's case-sensitive
substring match finds them regardless of the vendor's own asset name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-04 21:04:00 +00:00
Claude ae939c4085 wolf: mount ROMs/saves/BIOS/retro-home/retroarch into the Steam container
Lets a manually-downloaded ES-DE or RetroArch AppImage, added via
./manage.sh steam-add-nonsteam-game, see the same library, cores, and
ES-DE settings/custom systems (TI-99, Wii U) the esde/retroarch
containers already have, instead of starting from an empty config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-04 14:15:33 +00:00
11 changed files with 3817 additions and 37 deletions
+8
View File
@@ -1,2 +1,10 @@
# Vanilla Tweaks ZIP files placed for automatic install — never commit these
extras/datapacks/*.zip
# tools/anki-deck-*.py output and caches — generated locally, regenerable,
# never meant to be committed (periodic_images/ especially: fetched
# element photos, not source)
tools/*.apkg
tools/media_*/
tools/periodic_images/
tools/__pycache__/
+20 -1
View File
@@ -623,6 +623,25 @@ in `services/authelia.sh`) — prompts for a new duration (`12h`, `7d`,
Sessions persist through reboots regardless of duration (Redis stores
session state in a volume).
**`inactivity` must track `remember_me`, or a long remember_me is a lie.**
`inactivity` is a separate session field — how long a session can sit idle
before Authelia ends it — and it is NOT extended or bypassed by the
"Remember me" checkbox; the two are independent. Confirmed live: a user
set `remember_me: 1y` expecting "won't be asked to log in again for a
year," but the install default left `inactivity` at a much shorter value
(2h at the time), so ordinary daily gaps between visits (overnight, a
workday) ended the session on inactivity grounds well before remember_me
ever came into play — the 1y setting was doing nothing. Fixed at both ends
so this can't recur silently: `install_authelia()`'s own template now sets
`inactivity: 7d`, matching its `remember_me: 7d` default instead of a
shorter one, and `_authelia_set_remember_me()` now writes the SAME new
duration into both keys on every change, not just `remember_me` alone. If
you ever hand-edit `session:` instead of using the menu option, keep
`inactivity` and `remember_me` equal — a mismatch here is exactly the bug
above, not a valid intentional configuration. `expiration` (the cap for a
session that never checked "Remember me") is a legitimately different,
shorter-by-design setting and is untouched by any of this.
**The config key is `remember_me`, not `remember_me_duration`.** Authelia
renamed it in 4.38; this repo pins `4.39.20`. A stale `remember_me_duration`
key doesn't error, Authelia just silently ignores it — confirmed against
@@ -635,7 +654,7 @@ touch this by hand instead of the menu option, the current schema is:
session:
secret: 'your-existing-secret'
expiration: 1h
inactivity: 5m
inactivity: 1y
remember_me: 1y
cookies:
- domain: 'example.com'
+38 -1
View File
@@ -186,7 +186,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`.
|-------|---------|
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network |
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` (own dedicated coturn for TURN/STUN — see `mattermost` below for the other coturn-owning service), `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) |
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `pressbooks` (self-hosted book platform — WordPress Multisite, drag-and-drop chapter editing, PDF export via PrinceXML/DocRaptor, Authelia-gated), `rustdesk`, `samba` (SMB/CIFS file sharing — shares, dedicated Samba users/passwords, LAN-scoped firewall by default; also offered as an optional nudge from `base`), `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `anki-progress` (read-only study-progress dashboard for an `anki-sync-server` instance — reviews/accuracy/streak per account, plus an ntfy notification once a study session has been going for a configurable number of minutes; reads collection files with SQLite's read-only mode so it can't interfere with the live sync server), `anki-sync-server` (self-hosted sync backend for the Anki flashcard app — spaced-repetition scheduling stays in the Anki client, this just syncs collections across devices without AnkiWeb; supports multiple independent accounts per instance), `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `pressbooks` (self-hosted book platform — WordPress Multisite, drag-and-drop chapter editing, PDF export via PrinceXML/DocRaptor, Authelia-gated), `rustdesk`, `samba` (SMB/CIFS file sharing — shares, dedicated Samba users/passwords, LAN-scoped firewall by default; also offered as an optional nudge from `base`), `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
| `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` |
@@ -293,6 +293,43 @@ backup
</details>
## Generating Anki decks (tools/anki-deck-*.py)
`anki-sync-server` gives you a self-hosted sync backend, but a fresh
account has no content — `tools/anki-deck-math.py`,
`tools/anki-deck-periodic.py`, and `tools/anki-deck-visual.py` generate
ready-to-import `.apkg` decks (multiplication/division/addition/
subtraction/fractions/decimals, the periodic table, and shapes/clocks/
coin-counting) with offline neural TTS audio (Piper) on every card. Most
cards use Anki's built-in type-the-answer input; a few (periodic table
categories, `--deck shapes_mc`) are multiple choice instead, shown as
plain-text A/B/C/D options — type the letter rather than tapping, since a
clickable UI needs a desktop-only Anki add-on and would break on
AnkiDroid/AnkiMobile. All three are standalone Python
scripts, unrelated to the `services/*.sh` installer framework — run them
on any machine with Python, not necessarily the server itself. Full setup
(a venv, `genanki` + `piper-tts`, downloading a voice) and every deck's
exact usage is documented in `tools/anki-deck-math.py`'s own header
docstring; the other two scripts point back to it rather than repeating
the same instructions three times.
Shapes, clocks, and coin images are drawn programmatically (PNG, via
Pillow) rather than AI-generated — image generation is a poor fit for
content that has to be exactly correct (an exact clock time, an exact
side count), not just plausible-looking. PNG rather than SVG specifically
because AnkiDroid has long-standing, still-open bugs rendering SVG
`<img>` tags on mobile; see `tools/anki-deck-visual.py`'s own docstring
for more on both tradeoffs.
The periodic table `prehs`/`hs` decks also show each element's real
sample photo (fetched once from Wikipedia's own MediaWiki API and cached
under `tools/periodic_images/`) alongside every card — the one part of
this tooling that needs internet access at generation time; pass
`--no-images` for the old text-only cards. Elements 100 (Fermium) through
118 (Oganesson) are skipped, since none has ever existed in a
photographable quantity. See `tools/anki-deck-periodic.py`'s own
docstring for the details and its caveats.
## Layout
```
+741
View File
@@ -0,0 +1,741 @@
#!/bin/bash
# services/anki-progress.sh — Anki study-progress dashboard + ntfy
# "started studying" notifications. Reads an anki-sync-server instance's
# data directly (read-only) — see services/anki-sync-server.sh, which this
# service requires.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash anki-progress.sh
# (Docker must already be installed, and an anki-sync-server instance must
# already exist on the same box, when run standalone)
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
require_docker() {
command -v docker &>/dev/null || {
log_error "Docker not found. Install it first:"
log_error " curl -fsSL https://get.docker.com | sudo sh"
return 1
}
docker compose version &>/dev/null || {
log_error "Docker Compose plugin missing:"
log_error " sudo apt-get install -y docker-compose-plugin"
return 1
}
}
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_reinstall_mode() {
local _var="$1" _r
if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi
echo " Already installed."
read -r -p " (u)pdate / (f)resh reinstall / (c)ancel [c]: " _r
case "${_r,,}" in
u|update) eval "$_var='update'" ;;
f|fresh) eval "$_var='fresh'" ;;
*) eval "$_var='cancel'" ;;
esac
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile"
local _display_port="${_upstream##*:}"
local _mode="none"
[[ -d "$_caddy_dir" ]] && _mode="local"
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
[[ "$_mode" == "none" ]] && {
log_info "Access $_name directly on port $_display_port."
return 0
}
echo ""
local _do_caddy=""
if [[ "$_mode" == "remote" ]]; then
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
fi
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
[[ "${_do_caddy,,}" == "y" ]] || {
log_info "Skipping — access at: http://localhost:$_display_port"
return 0
}
local _default_domain=""
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
_default_domain="${_subdomain}.${SITE_DOMAIN}"
log_info "Default: $_default_domain"
fi
local _domain=""
read -r -p " Domain [${_default_domain:-required}]: " _domain
_domain="${_domain:-$_default_domain}"
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
local _block_upstream="$_upstream"
if [[ "$_mode" == "remote" ]]; then
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
fi
local _site_block
_site_block="$(cat << CBLOCK
# $_name
${_domain} {
reverse_proxy ${_block_upstream}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/${_domain}.log
format json
}
${_extra}
}
CBLOCK
)"
if [[ "$_mode" == "local" ]]; then
if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk"
log_info "Backed up Caddyfile to $(basename "$_bk")"
else
touch "$_caddyfile"
fi
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile"
local _ow=""
read -r -p " Overwrite? [y/N]: " _ow
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
fi
printf '%s\n' "$_site_block" >> "$_caddyfile"
log_success "Added $_domain to Caddyfile"
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
log_success "$_name accessible at: https://$_domain"
else
log_warning "Reload failed — check: docker logs caddy"
fi
else
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
mkdir -p "$_snippet_dir"
printf '%s\n' "$_site_block" > "$_snippet_file"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
log_success "Snippet saved: $_snippet_file"
fi
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
}
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service anki-progress utilities "Anki study-progress dashboard + ntfy 'started studying' notifications (reads an anki-sync-server instance's data read-only)" 8099
install_anki-progress() {
require_docker || return 1
log_info "Installing Anki Progress Dashboard..."
# ── Dependency: needs an anki-sync-server instance already installed ────
# Meaningless on its own — see CLAUDE.md's "Chaining into another
# service" section. Only chains one direction: anki-progress requires
# anki-sync-server, never the reverse.
local _sync_dirs=()
local _d
for _d in "$DOCKER_DIR"/anki-sync-server*; do
[ -d "$_d" ] && _sync_dirs+=("$(basename "$_d")")
done
if [ "${#_sync_dirs[@]}" -eq 0 ]; then
log_error "No anki-sync-server install found — this dashboard reads its data directly."
log_error "Install it first: sudo ./setup.sh anki-sync-server"
return 1
fi
local SYNC_INSTANCE="${_sync_dirs[0]}"
if [ "${#_sync_dirs[@]}" -gt 1 ] && [ "$UNATTENDED" != true ]; then
echo ""
echo " Multiple anki-sync-server instances found:"
local i
for i in "${!_sync_dirs[@]}"; do
echo " $((i + 1))) ${_sync_dirs[$i]}"
done
local _choice=""
prompt_text " Which one should this dashboard monitor? [1]:" "1" _choice
if [[ "$_choice" =~ ^[0-9]+$ ]] && [ "$_choice" -ge 1 ] && [ "$_choice" -le "${#_sync_dirs[@]}" ]; then
SYNC_INSTANCE="${_sync_dirs[$((_choice - 1))]}"
fi
fi
local SYNC_DATA_DIR="$DOCKER_DIR/$SYNC_INSTANCE/data"
# ── Instance selection (of this dashboard itself) ───────────────────────
# A second instance is a real use case (e.g. a second household with its
# own anki-sync-server and its own dashboard) — same multi-instance
# pattern as every other service here (see CLAUDE.md).
local AP_DIR="$DOCKER_DIR/anki-progress"
local INSTANCE_SUFFIX="" CONTAINER="anki-progress"
local WEB_PORT="8099"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would verify an anki-sync-server instance exists ($SYNC_INSTANCE found)"
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
echo "[DRY-RUN] Would create $AP_DIR(-<name>) with app.py, Dockerfile, docker-compose.yml"
echo "[DRY-RUN] Would prompt for ntfy URL/topic and notification timing"
echo "[DRY-RUN] Would auto-scan for a free host port"
return 0
fi
if [ -d "$AP_DIR" ]; then
echo ""
echo " Anki Progress Dashboard is already installed at $AP_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate dashboard instance alongside it"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/anki-progress-$_suffix" ]; then
log_warning "anki-progress-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
AP_DIR="$DOCKER_DIR/anki-progress-$_suffix"
CONTAINER="anki-progress-$_suffix"
log_info "New instance: $AP_DIR"
else
if [[ -f "$AP_DIR/docker-compose.yml" ]]; then
local MODE=""
prompt_reinstall_mode MODE
case "$MODE" in
update)
log_info "Refreshing app code + rebuilding the image — ntfy config and Caddy setup are left as-is."
( cd "$AP_DIR" && docker compose up -d --build ) \
&& log_success "Anki Progress Dashboard refreshed" \
|| log_warning "Refresh failed — check: docker compose -f $AP_DIR/docker-compose.yml logs"
return 0
;;
cancel)
log_info "Leaving the existing install as-is."
return 0
;;
fresh) ;;
esac
fi
fi
fi
find_free_port WEB_PORT "$WEB_PORT"
# ── ntfy ──────────────────────────────────────────────────────────────
# If ntfy is installed locally, reach it directly over caddy_net by
# container name — avoids a round trip through the public internet for
# a purely internal notification. Otherwise ask for a full URL (a
# remote/self-hosted instance elsewhere, or public ntfy.sh).
local NTFY_URL="" NTFY_TOPIC=""
if [ -d "$DOCKER_DIR/ntfy" ]; then
log_info "Local ntfy install detected — reaching it directly over caddy_net."
NTFY_URL="http://ntfy:80"
else
prompt_text " ntfy server URL (e.g. https://ntfy.yourdomain.com, or https://ntfy.sh):" "https://ntfy.sh" NTFY_URL
fi
prompt_text " ntfy topic to publish 'started studying' notifications to:" "anki-progress" NTFY_TOPIC
local SESSION_GAP_MINUTES="" NOTIFY_DELAY_MINUTES=""
prompt_text " Minutes of inactivity that counts as a new study session starting:" "30" SESSION_GAP_MINUTES
prompt_text " Minutes after a session starts to send the notification:" "10" NOTIFY_DELAY_MINUTES
mkdir -p "$AP_DIR/state"
ensure_docker_dir_ownership "$AP_DIR"
cd "$AP_DIR" || return 1
# Mirrors configure_caddy_for_service's own mode resolution — only
# "local" joins caddy_net.
local _CADDY_MODE="${CADDY_MODE:-none}"
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
local _CADDY_NET_BLOCK=""
local _CADDY_NET_SECTION=""
if [ "$_CADDY_MODE" = "local" ]; then
_CADDY_NET_BLOCK=" networks:
- caddy_net
"
_CADDY_NET_SECTION="
networks:
caddy_net:
external: true
name: ${SITE_CADDY_NET:-caddy_net}
"
fi
backup_if_exists app.py
cat > app.py << 'PYEOF'
#!/usr/bin/env python3
"""Anki study-progress dashboard + ntfy "started studying" notifications.
Reads every account's collection.anki2 directly (READ-ONLY — never opens for
write, so it can't corrupt live data the sync server or a client is using)
from the anki-sync-server's data directory, and:
1. Serves a small web dashboard (reviews today/week, accuracy, streak,
last active) per account.
2. Runs a background loop that detects when a new study session starts
(first review after a gap of SESSION_GAP_MINUTES with no reviews) and
sends one ntfy notification NOTIFY_DELAY_MINUTES after that session
started, if the session is still going (i.e. more reviews happened
after the initial one) — not on every single review.
All configuration (NTFY_URL, NTFY_TOPIC, SESSION_GAP_MINUTES,
NOTIFY_DELAY_MINUTES, ANKI_DATA_DIR, STATE_FILE) comes from environment
variables, set in docker-compose.yml / .env by the installer — nothing to
hand-edit in this file.
"""
import glob
import json
import os
import sqlite3
import threading
import time
from datetime import datetime, timezone
import requests
from flask import Flask, render_template_string
NTFY_URL = os.environ.get("NTFY_URL", "https://ntfy.example.com")
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "anki-progress")
SESSION_GAP_MINUTES = int(os.environ.get("SESSION_GAP_MINUTES", 30))
NOTIFY_DELAY_MINUTES = int(os.environ.get("NOTIFY_DELAY_MINUTES", 10))
POLL_INTERVAL_SECONDS = 60
ANKI_DATA_DIR = os.environ.get("ANKI_DATA_DIR", "/anki-data")
STATE_FILE = os.environ.get("STATE_FILE", "/app/state/notify_state.json")
app = Flask(__name__)
def find_collections():
"""{username: path-to-collection-file} for every account directory found.
Globs for *.anki2 rather than assuming the exact filename, since that's
an implementation detail of the sync server we shouldn't hardcode."""
result = {}
if not os.path.isdir(ANKI_DATA_DIR):
return result
for entry in sorted(os.listdir(ANKI_DATA_DIR)):
user_dir = os.path.join(ANKI_DATA_DIR, entry)
if not os.path.isdir(user_dir):
continue
matches = glob.glob(os.path.join(user_dir, "*.anki2"))
if matches:
result[entry] = matches[0]
return result
def read_revlog_ids_eases(path):
"""Returns a list of (epoch_ms, ease) tuples sorted by time, read-only.
Opening with mode=ro is what makes this safe to run alongside a live
sync server — it never takes a write lock, so it can't corrupt or
block the account that's actually in use."""
uri = f"file:{path}?mode=ro"
con = sqlite3.connect(uri, uri=True)
try:
rows = con.execute("SELECT id, ease FROM revlog ORDER BY id ASC").fetchall()
except sqlite3.OperationalError:
rows = []
finally:
con.close()
return rows
def compute_stats(revlog_rows, now_ms):
"""Pure function over a list of (epoch_ms, ease) — kept separate from
any file/DB access so it can be unit-tested with synthetic data."""
if not revlog_rows:
return {
"total_reviews": 0, "reviews_today": 0, "reviews_week": 0,
"accuracy_pct": None, "streak_days": 0, "last_active": None,
}
day_ms = 24 * 60 * 60 * 1000
today_day = now_ms // day_ms
today_start = today_day * day_ms
week_start = today_start - 6 * day_ms
reviews_today = sum(1 for ts, _ in revlog_rows if ts >= today_start)
reviews_week = sum(1 for ts, _ in revlog_rows if ts >= week_start)
total = len(revlog_rows)
correct = sum(1 for _, ease in revlog_rows if ease != 1) # ease 1 = "Again" = a miss
accuracy_pct = round(100 * correct / total, 1) if total else None
# Streak: consecutive calendar days with >=1 review, walking backward
# from today. Still "alive" through yesterday if today has no reviews
# yet (so it doesn't reset to 0 first thing each morning) — but not if
# the most recent review is 2+ days old. review_days is unique/sorted
# descending, so any day that isn't exactly "expected" means a gap.
review_days = sorted({ts // day_ms for ts, _ in revlog_rows}, reverse=True)
streak = 0
if review_days and review_days[0] in (today_day, today_day - 1):
expected = review_days[0]
for d in review_days:
if d == expected:
streak += 1
expected -= 1
else:
break
last_active = max(ts for ts, _ in revlog_rows)
return {
"total_reviews": total,
"reviews_today": reviews_today,
"reviews_week": reviews_week,
"accuracy_pct": accuracy_pct,
"streak_days": streak,
"last_active": last_active,
}
def detect_current_session_start(revlog_rows, now_ms):
"""Walk backwards from the most recent review; the session start is the
earliest review such that every gap between consecutive reviews from
there to now is < SESSION_GAP_MINUTES. Returns None if the most recent
review itself is older than the gap threshold (no session "in progress")."""
if not revlog_rows:
return None
gap_ms = SESSION_GAP_MINUTES * 60 * 1000
last_ts = revlog_rows[-1][0]
if now_ms - last_ts > gap_ms:
return None # most recent review is old news, not an active session
session_start = last_ts
for ts, _ in reversed(revlog_rows[:-1]):
if session_start - ts > gap_ms:
break
session_start = ts
return session_start
DASHBOARD_TEMPLATE = """
<!doctype html>
<title>Anki Progress</title>
<meta http-equiv="refresh" content="60">
<style>
body { font-family: Arial, sans-serif; background: #f4f6f8; margin: 0; padding: 24px; }
h1 { color: #333; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.card { background: white; border-radius: 10px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
.card h2 { margin: 0 0 10px 0; font-size: 20px; }
.stat { display: flex; justify-content: space-between; margin: 4px 0; font-size: 15px; }
.stat b { color: #1c4587; }
.empty { color: #888; font-style: italic; }
</style>
<h1>Anki Progress</h1>
<div class="grid">
{% for user, s in stats.items() %}
<div class="card">
<h2>{{ user }}</h2>
{% if s.total_reviews == 0 %}
<div class="empty">No reviews yet</div>
{% else %}
<div class="stat"><span>Reviews today</span><b>{{ s.reviews_today }}</b></div>
<div class="stat"><span>Reviews this week</span><b>{{ s.reviews_week }}</b></div>
<div class="stat"><span>Accuracy</span><b>{{ s.accuracy_pct }}%</b></div>
<div class="stat"><span>Streak</span><b>{{ s.streak_days }} day{{ 's' if s.streak_days != 1 else '' }}</b></div>
<div class="stat"><span>Last active</span><b>{{ s.last_active_str }}</b></div>
{% endif %}
</div>
{% endfor %}
</div>
"""
@app.route("/")
def dashboard():
now_ms = int(time.time() * 1000)
stats = {}
for user, path in find_collections().items():
rows = read_revlog_ids_eases(path)
s = compute_stats(rows, now_ms)
if s["last_active"]:
s["last_active_str"] = datetime.fromtimestamp(
s["last_active"] / 1000, tz=timezone.utc
).astimezone().strftime("%b %-d, %-I:%M %p")
else:
s["last_active_str"] = "—"
stats[user] = s
return render_template_string(DASHBOARD_TEMPLATE, stats=stats)
def load_notify_state():
if os.path.isfile(STATE_FILE):
with open(STATE_FILE) as f:
return json.load(f)
return {}
def save_notify_state(state):
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
with open(STATE_FILE, "w") as f:
json.dump(state, f)
def send_ntfy(message):
try:
requests.post(f"{NTFY_URL.rstrip('/')}/{NTFY_TOPIC}",
data=message.encode("utf-8"), timeout=10)
except requests.RequestException as e:
print(f"[ntfy] failed to send: {e}")
def notifier_loop():
state = load_notify_state()
while True:
now_ms = int(time.time() * 1000)
for user, path in find_collections().items():
rows = read_revlog_ids_eases(path)
session_start = detect_current_session_start(rows, now_ms)
entry = state.get(user, {})
if session_start is None:
# No active session right now — clear tracking so the next
# real session starts fresh.
if entry:
state[user] = {}
continue
if entry.get("session_start") != session_start:
# A new session started (different from whatever we were
# tracking) — start the countdown over.
state[user] = {"session_start": session_start, "notified": False}
entry = state[user]
elapsed_minutes = (now_ms - session_start) / 60000
if not entry.get("notified") and elapsed_minutes >= NOTIFY_DELAY_MINUTES:
send_ntfy(f"{user} started studying {NOTIFY_DELAY_MINUTES} minutes ago and is still going.")
entry["notified"] = True
save_notify_state(state)
time.sleep(POLL_INTERVAL_SECONDS)
if __name__ == "__main__":
threading.Thread(target=notifier_loop, daemon=True).start()
app.run(host="0.0.0.0", port=5000)
PYEOF
backup_if_exists Dockerfile
cat > Dockerfile << 'DOCKEREOF'
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir flask requests
COPY app.py .
CMD ["python3", "app.py"]
DOCKEREOF
backup_if_exists docker-compose.yml
cat > docker-compose.yml << COMPOSEEOF
name: $CONTAINER
services:
$CONTAINER:
build: .
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
env_file: .env
environment:
- ANKI_DATA_DIR=/anki-data
- STATE_FILE=/app/state/notify_state.json
volumes:
# Read-only — this container only ever reads collection files (see
# app.py's read_revlog_ids_eases, which opens SQLite in mode=ro),
# never writes, so it can't corrupt live data the sync server or a
# client is using.
- $SYNC_DATA_DIR:/anki-data:ro
- ./state:/app/state
ports:
- "${WEB_PORT}:5000"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
COMPOSEEOF
backup_if_exists .env
cat > .env << ENVEOF
NTFY_URL=$NTFY_URL
NTFY_TOPIC=$NTFY_TOPIC
SESSION_GAP_MINUTES=$SESSION_GAP_MINUTES
NOTIFY_DELAY_MINUTES=$NOTIFY_DELAY_MINUTES
ENVEOF
chmod 600 .env
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$AP_DIR"
echo ""
log_success "Anki Progress Dashboard${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $AP_DIR (port $WEB_PORT)"
log_info "Monitoring: $SYNC_INSTANCE"
local START=""
prompt_yn "Start Anki Progress Dashboard${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
docker compose up -d --build \
&& log_success "Anki Progress Dashboard started" \
|| log_warning "Start failed — check: docker compose logs"
fi
# ── Caddy + Authelia-aware protection ────────────────────────────────────
# This dashboard shows every account's personal study activity — same
# "sensitive, protect by default" reasoning as
# services/security-dashboard.sh: auto-use local Authelia if present, no
# prompt needed; otherwise warn clearly and offer a remote instance,
# since leaving it open is a real privacy tradeoff, not a neutral default.
local EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
EXTRA_BLOCK=" import authelia"
log_info "Local Authelia detected — protecting with it."
else
log_warning "No local Authelia found. This dashboard shows every account's"
log_warning "personal study activity — recommend protecting it before"
log_warning "exposing it publicly."
local _use_remote=""
prompt_yn " Protect with a remote Authelia instance (e.g. on a homelab)? (y/n):" "y" _use_remote
if [[ "$_use_remote" =~ ^[Yy]$ ]]; then
local _remote_authelia=""
prompt_text " Remote Authelia address (bare host:port on a private network, or a full https:// URL on its own public domain+TLS):" "" _remote_authelia
if [ -n "$_remote_authelia" ]; then
EXTRA_BLOCK=" forward_auth ${_remote_authelia} {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
header_up X-Forwarded-Method {method}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Uri {uri}
}"
fi
fi
fi
configure_caddy_for_service "Anki Progress Dashboard${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:5000" "anki-progress${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" "$EXTRA_BLOCK"
declare -F _authelia_scope_access >/dev/null 2>&1 && [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ] \
&& _authelia_scope_access "anki-progress" "$CADDY_SERVICE_DOMAIN"
write_readme "$AP_DIR" << MD
# Anki Progress Dashboard${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Read-only study-progress dashboard for the accounts on **$SYNC_INSTANCE**
(reviews today/this week, accuracy, streak, last active), plus an ntfy
notification sent ${NOTIFY_DELAY_MINUTES} minutes after a study session
starts — defined as the first review after ${SESSION_GAP_MINUTES}+ minutes
of inactivity, and only sent if the session is still going at that point
(not on every single review, and not for a session that's already over).
Reads collection files directly with SQLite's read-only mode — never opens
them for write, so it can't corrupt or interfere with the live sync server
or any client actively syncing.
## Access
- URL: $( [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ] && echo "https://${CADDY_SERVICE_DOMAIN}/" || echo "http://localhost:${WEB_PORT}/" )
## Config
- \`$AP_DIR/.env\` — ntfy URL/topic, session-gap and notify-delay minutes
- Edit and \`docker compose up -d\` to apply changes (no rebuild needed —
these are read at container start from environment variables)
## Manage
\`\`\`bash
cd $AP_DIR
docker compose up -d --build
docker compose down
docker compose logs -f
\`\`\`
MD
}
# ── Standalone execution ───────────────────────────────────────────────────
if [[ "${_RUN_STANDALONE:-0}" == "1" ]]; then
install_anki-progress
fi
+82
View File
@@ -0,0 +1,82 @@
## Client setup — pointing Anki at this server instead of AnkiWeb
Every client below needs the **Sync URL** and one of the **accounts** shown
higher up in this README. Do this on every device you want synced — a client
still pointed at AnkiWeb won't see collections synced here, and vice versa.
### Anki Desktop (2.1.66 and newer)
1. **Preferences → Network**
2. Tick **"Self-hosted sync server"**
3. Paste the Sync URL into the field that appears
4. **Sync → log in** with one of the accounts above
### Anki Desktop (older than 2.1.66)
There's no GUI field yet — set an environment variable before launching Anki
instead, then sync normally:
```bash
# Linux/macOS
export SYNC_ENDPOINT="https://your-sync-url/"
anki
# Windows (Command Prompt)
set SYNC_ENDPOINT=https://your-sync-url/
anki.exe
```
Upgrading Anki to 2.1.66+ is the easier long-term fix — do that if you're
setting this up for anyone who isn't comfortable with environment variables.
### AnkiDroid
**Settings → Advanced → Custom sync server**, then enter the Sync URL and
log in with one of the accounts above (AnkiDroid 2.16+; update the app if
this option isn't there).
### AnkiMobile (iOS)
**Settings → Advanced → Custom Sync Server**, same as AnkiDroid — enter the
Sync URL and log in.
### First sync on each device
The very first sync from a device that already has a local collection will
ask whether to upload local data or download from the server — pick upload
from whichever device has your real collection, and download on every other
device, or you'll end up with two different collections that never merge.
## Importing your existing Quizlet sets
This server only handles syncing already-existing Anki collections — it
doesn't import anything itself. Quizlet import happens once, locally, in the
Anki desktop app, before your first sync:
1. **In Quizlet:** open the set → **Export** → choose the plain-text /
tab-separated format (Quizlet's export dialog lets you pick the delimiter
between term and definition, and between rows — tab and newline are the
Anki-friendly defaults) → copy the exported text or download it as a
`.txt`/`.csv` file.
2. **In Anki Desktop:** **File → Import**, pick the file (or paste the text
into a `.txt` file first if you copied it to the clipboard).
3. Map the two columns to **Front** and **Back** in the import dialog, pick
or create the deck and note type, and import.
4. For **math facts or other simple front/back cards**, the Basic note type
is enough. For **more complex cards** (extra example fields, images,
audio, cloze deletions), switch the note type in the import dialog to a
template with more fields, or convert cards afterward — Anki's own
built-in note types (Basic, Basic (and reversed card), Cloze) cover most
of what Quizlet's own card types can do.
5. Sync from this device once the import looks right, so the imported deck
becomes the copy every other device downloads.
### Exporting back out (Anki → Quizlet or anywhere else)
**File → Export**, choose "Notes in Plain Text" and pick the deck — this
produces the same tab-separated format Quizlet's own import expects, so the
round trip works in both directions.
## Why spaced repetition here actually reschedules failed cards
Anki's scheduler (FSRS, the default since recent Anki versions) tracks a
per-card memory-strength estimate and schedules the next review right before
you'd be expected to forget it. Answering "Again" on a card doesn't just
requeue it for later the same session — it lowers that card's estimated
strength, which shortens every subsequent interval for it until you've
proven you know it again, so a card you keep failing gets shown far more
often than one you consistently get right. This is scheduling logic inside
the Anki client itself; this sync server only stores and syncs the resulting
review history, it doesn't change how reviews are scheduled.
+669
View File
@@ -0,0 +1,669 @@
#!/bin/bash
# services/anki-sync-server.sh — Self-hosted Anki flashcard sync server.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash anki-sync-server.sh
# (Docker must already be installed when run standalone)
# ── Standalone bootstrap ──────────────────────────────────────────────────────
# Detected when the script is executed directly rather than sourced by setup.sh.
# Sets up helpers and globals, then defers execution until after the function
# definition at the bottom of this file.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# Full repo present — use the real helpers (picks up ~/docker/.config too)
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
# One-off copy — inline minimal stubs so the script works without the repo
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
require_docker() {
command -v docker &>/dev/null || {
log_error "Docker not found. Install it first:"
log_error " curl -fsSL https://get.docker.com | sudo sh"
return 1
}
docker compose version &>/dev/null || {
log_error "Docker Compose plugin missing:"
log_error " sudo apt-get install -y docker-compose-plugin"
return 1
}
}
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_reinstall_mode() {
local _var="$1" _r
if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi
echo " Already installed."
read -r -p " (u)pdate / (f)resh reinstall / (c)ancel [c]: " _r
case "${_r,,}" in
u|update) eval "$_var='update'" ;;
f|fresh) eval "$_var='fresh'" ;;
*) eval "$_var='cancel'" ;;
esac
}
generate_password() {
local length="${1:-32}"
openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length"
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile"
local _display_port="${_upstream##*:}"
# Determine mode: local Caddy, remote Caddy, or none
local _mode="none"
[[ -d "$_caddy_dir" ]] && _mode="local"
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
[[ "$_mode" == "none" ]] && {
log_info "Access $_name directly on port $_display_port."
return 0
}
echo ""
local _do_caddy=""
if [[ "$_mode" == "remote" ]]; then
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
fi
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
[[ "${_do_caddy,,}" == "y" ]] || {
log_info "Skipping — access at: http://localhost:$_display_port"
return 0
}
# Domain prompt — pre-fill from SITE_DOMAIN when available
local _default_domain=""
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
_default_domain="${_subdomain}.${SITE_DOMAIN}"
log_info "Default: $_default_domain"
fi
local _domain=""
read -r -p " Domain [${_default_domain:-required}]: " _domain
_domain="${_domain:-$_default_domain}"
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
# Build upstream — remote Caddy uses host IP:port, not container name
local _block_upstream="$_upstream"
if [[ "$_mode" == "remote" ]]; then
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
fi
local _site_block
_site_block="$(cat << CBLOCK
# $_name
${_domain} {
reverse_proxy ${_block_upstream}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/${_domain}.log
format json
}
${_extra}
}
CBLOCK
)"
if [[ "$_mode" == "local" ]]; then
if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk"
log_info "Backed up Caddyfile to $(basename "$_bk")"
else
touch "$_caddyfile"
fi
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile"
local _ow=""
read -r -p " Overwrite? [y/N]: " _ow
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
fi
printf '%s\n' "$_site_block" >> "$_caddyfile"
log_success "Added $_domain to Caddyfile"
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
log_success "$_name accessible at: https://$_domain"
else
log_warning "Reload failed — check: docker logs caddy"
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
fi
else
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
mkdir -p "$_snippet_dir"
printf '%s\n' "$_site_block" > "$_snippet_file"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
log_success "Snippet saved: $_snippet_file"
log_info "Copy to Caddy machine:"
log_info " scp $_snippet_file caddy-host:~/caddy-snippets/"
log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)"
fi
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
}
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
# ($HOME under sudo is /root, not the real user's home)
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
register_service() { :; } # no-op — no wizard to register into
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service anki-sync-server utilities "Self-hosted Anki flashcard sync server (spaced repetition, syncs across devices without AnkiWeb)" 8080
# Reads the current ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn pairs out of an
# instance's .env into the caller's ANKI_USERS/ANKI_PASSWORDS arrays (bash's
# dynamic scoping means a `local` array declared in the caller is visible
# here without being passed explicitly — same assumption every other helper
# below makes). Numbering is always kept contiguous from 1 by
# _anki_rewrite_account_block, so stopping at the first missing index is
# safe — there's never a gap to skip over.
_anki_load_accounts() {
local _dir="$1" _n=1 _u _p
ANKI_USERS=() ANKI_PASSWORDS=()
while true; do
_u="$(grep "^ANKI_SYNC_USER${_n}=" "$_dir/.env" 2>/dev/null | cut -d= -f2-)"
[ -z "$_u" ] && break
_p="$(grep "^ANKI_SYNC_PASSWORD${_n}=" "$_dir/.env" 2>/dev/null | cut -d= -f2-)"
ANKI_USERS+=("$_u")
ANKI_PASSWORDS+=("$_p")
_n=$((_n + 1))
done
}
# Regenerates the SYNC_USERn=... lines in docker-compose.yml and the
# matching ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn pairs in .env from the
# caller's current ANKI_USERS/ANKI_PASSWORDS arrays (always renumbered
# contiguously from 1 — see _anki_load_accounts). Used by both the initial
# install and every account-management mutation (add/remove/rotate) so the
# two never drift apart, same reasoning as CLAUDE.md's shared-helper
# guidance for update vs. fresh-install codepaths. Leaves the port, Caddy
# block, and every other line in either file untouched — only lines
# matching the SYNC_USER/ANKI_SYNC_* patterns are touched.
_anki_rewrite_account_block() {
local _dir="$1"
local _compose="$_dir/docker-compose.yml"
local _env="$_dir/.env"
sed -i '/^ - SYNC_USER[0-9]\+=/d' "$_compose"
sed -i '/^ANKI_SYNC_USER[0-9]\+=/d; /^ANKI_SYNC_PASSWORD[0-9]\+=/d' "$_env"
local _compose_lines="" _env_lines="" i idx
for i in "${!ANKI_USERS[@]}"; do
idx=$((i + 1))
_compose_lines+=" - SYNC_USER${idx}=\${ANKI_SYNC_USER${idx}}:\${ANKI_SYNC_PASSWORD${idx}}
"
_env_lines+="ANKI_SYNC_USER${idx}=${ANKI_USERS[$i]}
ANKI_SYNC_PASSWORD${idx}=${ANKI_PASSWORDS[$i]}
"
done
# Insert right after the fixed SYNC_BASE anchor line — always present,
# written by every version of this script's install flow — instead of
# appending at the end, so the block stays grouped with SYNC_HOST/
# SYNC_PORT/SYNC_BASE rather than drifting after `volumes:`.
local _tmp
_tmp="$(mktemp)"
printf '%s' "$_compose_lines" > "$_tmp"
sed -i "\|^ - SYNC_BASE=/data\$|r $_tmp" "$_compose"
rm -f "$_tmp"
printf '%s' "$_env_lines" >> "$_env"
}
# Interactive add/remove/rotate menu for an existing instance's sync
# accounts, offered from install_anki-sync-server's "already installed"
# menu. Every mutation restarts the container (`docker compose up -d`
# re-reads .env for the new/removed/rotated credentials) but never touches
# the port, Caddy config, or the image — the things CLAUDE.md's "update vs.
# fresh reinstall" convention says a non-destructive path must leave alone.
_anki_manage_accounts() {
local _dir="$1"
local ANKI_USERS=() ANKI_PASSWORDS=()
while true; do
_anki_load_accounts "$_dir"
echo ""
echo " Current sync accounts:"
local i
for i in "${!ANKI_USERS[@]}"; do
echo " $((i + 1))) ${ANKI_USERS[$i]}"
done
[ "${#ANKI_USERS[@]}" -eq 0 ] && echo " (none)"
echo ""
echo " a) Add an account"
echo " r) Remove an account"
echo " p) Rotate (reset) an account's password"
echo " 0) Done"
echo ""
local ACTION=""
prompt_text " Choice [a/r/p/0]:" "0" ACTION
case "$ACTION" in
a|A)
if [ "${#ANKI_USERS[@]}" -ge 8 ]; then
log_warning "That's plenty — stopping at 8 accounts."
continue
fi
local _u=""
prompt_text " New username:" "" _u
if [ -z "$_u" ]; then
log_warning "Name can't be empty."; continue
fi
ANKI_USERS+=("$_u")
ANKI_PASSWORDS+=("$(generate_password 24)")
_anki_rewrite_account_block "$_dir"
( cd "$_dir" && docker compose up -d ) \
&& log_success "Account '$_u' added — password: ${ANKI_PASSWORDS[-1]} (also saved in $_dir/.env)" \
|| log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs"
;;
r|R)
if [ "${#ANKI_USERS[@]}" -eq 0 ]; then
log_warning "No accounts to remove."; continue
fi
local _n=""
prompt_text " Remove which number?" "" _n
if ! [[ "$_n" =~ ^[0-9]+$ ]] || [ "$_n" -lt 1 ] || [ "$_n" -gt "${#ANKI_USERS[@]}" ]; then
log_warning "Invalid choice."; continue
fi
local _removed="${ANKI_USERS[$((_n - 1))]}"
unset 'ANKI_USERS[_n - 1]' 'ANKI_PASSWORDS[_n - 1]'
ANKI_USERS=("${ANKI_USERS[@]}")
ANKI_PASSWORDS=("${ANKI_PASSWORDS[@]}")
_anki_rewrite_account_block "$_dir"
( cd "$_dir" && docker compose up -d ) \
&& log_success "Account '$_removed' removed" \
|| log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs"
;;
p|P)
if [ "${#ANKI_USERS[@]}" -eq 0 ]; then
log_warning "No accounts yet."; continue
fi
local _n=""
prompt_text " Rotate password for which number?" "" _n
if ! [[ "$_n" =~ ^[0-9]+$ ]] || [ "$_n" -lt 1 ] || [ "$_n" -gt "${#ANKI_USERS[@]}" ]; then
log_warning "Invalid choice."; continue
fi
ANKI_PASSWORDS[$((_n - 1))]="$(generate_password 24)"
_anki_rewrite_account_block "$_dir"
( cd "$_dir" && docker compose up -d ) \
&& log_success "New password for '${ANKI_USERS[$((_n - 1))]}': ${ANKI_PASSWORDS[$((_n - 1))]} (also saved in $_dir/.env)" \
|| log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs"
;;
0)
break
;;
*)
log_warning "Unrecognized choice."
;;
esac
done
}
install_anki-sync-server() {
require_docker || return 1
log_info "Installing Anki Sync Server..."
# ── Instance selection ───────────────────────────────────────────────────
# First instance keeps the plain "anki-sync-server" name/paths/port exactly
# as before (zero behavior change for anyone with a single instance). Only
# asking to add a second one introduces suffixed naming — same pattern as
# services/ntfy.sh and services/homebox.sh. A second instance is a real
# use case here (e.g. a second household wanting fully separate data on
# the same box) even though one instance already supports multiple
# independent accounts via SYNC_USER1/SYNC_USER2/... — see CLAUDE.md's
# "Multi-instance services" section.
local ANKI_DIR="$DOCKER_DIR/anki-sync-server"
local INSTANCE_SUFFIX="" CONTAINER="anki-sync-server"
local WEB_PORT="8080"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
echo "[DRY-RUN] Would create $ANKI_DIR(-<name>)"
echo "[DRY-RUN] Would prompt for one or more sync accounts and generate passwords"
echo "[DRY-RUN] Would write docker-compose.yml and .env"
echo "[DRY-RUN] Would auto-scan for a free host port"
return 0
fi
if [ -d "$ANKI_DIR" ]; then
echo ""
echo " Anki Sync Server is already installed at $ANKI_DIR."
echo " 1) Manage sync accounts (add / remove / rotate a password — doesn't"
echo " touch the port, Caddy, or the image)"
echo " 2) Manage that install (update image / full reinstall / cancel)"
echo " 3) Add a NEW, separate Anki Sync Server instance alongside it (its"
echo " own data and port — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2/3]:" "2" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "1" ]; then
_anki_manage_accounts "$ANKI_DIR"
return 0
elif [ "$_TOP_CHOICE" = "3" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'family'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/anki-sync-server-$_suffix" ]; then
log_warning "anki-sync-server-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
ANKI_DIR="$DOCKER_DIR/anki-sync-server-$_suffix"
CONTAINER="anki-sync-server-$_suffix"
log_info "New instance: $ANKI_DIR"
else
# "Manage that install" on THIS instance — the banner above promises
# update/fresh/cancel, so actually offer it instead of falling straight
# through into the same unconditional-overwrite flow as a new install.
if [[ -f "$ANKI_DIR/docker-compose.yml" ]]; then
local MODE=""
prompt_reinstall_mode MODE
case "$MODE" in
update)
log_info "Refreshing the Anki Sync Server image only — existing accounts, port, and Caddy setup are left as-is."
# The image is a Google distroless "nonroot" build (fixed UID/GID
# 65532, no shell — it can't chown anything itself at startup), so
# ./data has to already be writable by that exact UID or the
# container fails to start. Versions of this installer before this
# fix chowned it to ACTUAL_USER instead, which the container can't
# write to — re-asserting the correct ownership here repairs any
# install made under that bug, non-destructively (it's the
# installer's own bug being corrected, not a config choice, so it
# belongs in the non-destructive update path).
chown -R 65532:65532 "$ANKI_DIR/data" 2>/dev/null
( cd "$ANKI_DIR" && docker compose pull && docker compose up -d ) \
&& log_success "Anki Sync Server image refreshed" \
|| log_warning "Refresh failed — check: docker compose -f $ANKI_DIR/docker-compose.yml logs"
return 0
;;
cancel)
log_info "Leaving the existing install as-is."
return 0
;;
fresh) ;; # fall through to the full install flow below
esac
fi
fi
fi
# Scan for a free port unconditionally — not just when adding an explicit
# additional instance. A plain first install can just as easily collide
# with an unrelated service that already claimed this default port — see
# CLAUDE.md's "Port collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
# ── Sync accounts ─────────────────────────────────────────────────────────
# The official sync server has no signup flow of its own — accounts are
# fixed credentials baked in as SYNC_USER1, SYNC_USER2, ... at container
# start, one per line in .env. Ask for at least one now (each Anki client
# — desktop, AnkiDroid, AnkiMobile — logs in with one of these) and offer
# to add more for other people sharing this box, since a single instance
# already keeps each account's collection completely separate.
local ANKI_USERS=() ANKI_PASSWORDS=()
local _u=""
prompt_text " Username for your Anki sync account:" "$ACTUAL_USER" _u
ANKI_USERS+=("$_u")
ANKI_PASSWORDS+=("$(generate_password 24)")
while true; do
local _more=""
prompt_yn " Add another Anki sync account (e.g. for a family member)? (y/n):" "n" _more
[[ "$_more" =~ ^[Yy]$ ]] || break
prompt_text " Username for the additional account:" "" _u
if [ -z "$_u" ]; then
log_warning "Name can't be empty."; continue
fi
ANKI_USERS+=("$_u")
ANKI_PASSWORDS+=("$(generate_password 24)")
if [ "${#ANKI_USERS[@]}" -ge 8 ]; then
log_warning "That's plenty — stopping at 8 accounts."
break
fi
done
mkdir -p "$ANKI_DIR/data"
ensure_docker_dir_ownership "$ANKI_DIR"
cd "$ANKI_DIR" || return 1
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh):
# explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy,
# then the legacy CADDY_REMOTE_HOST var. Only "local" joins caddy_net — a
# remote Caddy box can't resolve container names on this host's bridge
# network anyway; it reaches this service via the host's published port.
local _CADDY_MODE="${CADDY_MODE:-none}"
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
local _CADDY_NET_BLOCK=""
local _CADDY_NET_SECTION=""
if [ "$_CADDY_MODE" = "local" ]; then
_CADDY_NET_BLOCK=" networks:
- caddy_net
"
_CADDY_NET_SECTION="
networks:
caddy_net:
external: true
name: ${SITE_CADDY_NET:-caddy_net}
"
fi
# Build the SYNC_USERn=... lines for docker-compose.yml (compose-time
# interpolation of ${ANKI_SYNC_USERn}/${ANKI_SYNC_PASSWORDn} from .env —
# same \${VAR} pattern services/homebox.sh uses for its own .env values)
# and the matching ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn lines for .env.
local _COMPOSE_USER_LINES="" _ENV_USER_LINES="" i idx
for i in "${!ANKI_USERS[@]}"; do
idx=$((i + 1))
_COMPOSE_USER_LINES+=" - SYNC_USER${idx}=\${ANKI_SYNC_USER${idx}}:\${ANKI_SYNC_PASSWORD${idx}}
"
_ENV_USER_LINES+="ANKI_SYNC_USER${idx}=${ANKI_USERS[$i]}
ANKI_SYNC_PASSWORD${idx}=${ANKI_PASSWORDS[$i]}
"
done
backup_if_exists docker-compose.yml
cat > docker-compose.yml << ANKI_COMPOSE
name: $CONTAINER
services:
anki-sync-server:
image: afrima/anki-sync-server:latest
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
environment:
- SYNC_HOST=0.0.0.0
- SYNC_PORT=8080
- SYNC_BASE=/data
${_COMPOSE_USER_LINES} volumes:
- ./data:/data
ports:
- "${WEB_PORT}:8080"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
ANKI_COMPOSE
backup_if_exists .env
cat > .env << ANKI_ENV
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
CADDY_NET=$SITE_CADDY_NET
# One username/password pair per Anki sync account (SYNC_USER1, SYNC_USER2,
# ... in docker-compose.yml). Enter these exact values as the account on
# each Anki client (Preferences/Settings → self-hosted sync server). To add,
# remove, or reset one of these later, re-run this installer against the
# existing install and pick "Manage sync accounts" — don't hand-edit these
# lines, the matching docker-compose.yml lines have to change in lockstep.
${_ENV_USER_LINES}
ANKI_ENV
chmod 600 .env
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ANKI_DIR"
# afrima/anki-sync-server is built on gcr.io/distroless/static-debian12:nonroot
# — the process always runs as that image's fixed "nonroot" UID/GID (65532),
# never as ACTUAL_USER, and distroless has no shell so nothing inside the
# container can chown its own data dir at startup. Applied AFTER the
# ACTUAL_USER chown above (not before — that call would just clobber it,
# since it recurses over the whole $ANKI_DIR including data/) so ./data ends
# up owned by 65532 specifically while docker-compose.yml/.env/README.md
# (which the sysadmin edits, not the container) stay owned by ACTUAL_USER.
# Confirmed live: getting this wrong is exactly what makes the container
# fail to come up with a permissions error the moment it tries to create
# anything under /data (e.g. a new user's collection).
chown -R 65532:65532 "$ANKI_DIR/data"
echo ""
log_success "Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $ANKI_DIR (port $WEB_PORT)"
echo ""
echo " Sync accounts (also saved in $ANKI_DIR/.env):"
for i in "${!ANKI_USERS[@]}"; do
echo " ${ANKI_USERS[$i]} / ${ANKI_PASSWORDS[$i]}"
done
echo ""
# No Authelia gate here, unlike most other web-facing services in this
# repo: this is a raw HTTP sync API that the Anki client itself talks to
# (not a browser session), so a forward_auth login portal in front of it
# would just break every sync request instead of protecting anything.
# SYNC_USER1/SYNC_USER2/... above is this service's own auth boundary —
# same reasoning as the has-built-in-auth services in CLAUDE.md, just
# with no web UI to additionally gate.
configure_caddy_for_service "Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8080" "anki${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
local START=""
prompt_yn "Start Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
docker compose up -d \
&& log_success "Anki Sync Server started" \
|| log_warning "Start failed — check: docker compose logs"
fi
write_readme "$ANKI_DIR" << MD
# Anki Sync Server${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Self-hosted sync server for the [Anki](https://apps.ankiweb.net/) flashcard
app — syncs your collection across devices without going through AnkiWeb.
Anki's own spaced-repetition scheduler (FSRS) gives failed cards more
repetition and correctly-recalled cards longer gaps automatically; nothing
here changes that, it's purely the sync backend.
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own data directory, own
accounts, own port) — not shared collections with another Anki Sync Server
instance.")
## Access
- Sync URL: $( [ -n "${CADDY_SERVICE_CONFIGURED:-}" ] && [ "$CADDY_SERVICE_CONFIGURED" = "true" ] && echo "https://${CADDY_SERVICE_DOMAIN}/" || echo "http://localhost:${WEB_PORT}/" )
- Accounts (username / password):
$(for i in "${!ANKI_USERS[@]}"; do echo " - ${ANKI_USERS[$i]} / ${ANKI_PASSWORDS[$i]}"; done)
Enter the Sync URL and one of the above accounts on each Anki client — see
the client setup section below for exactly where.
## Data
- Collections: \`$ANKI_DIR/data\`
- Credentials: \`$ANKI_DIR/.env\` (readable by $ACTUAL_USER only)
## Manage
\`\`\`bash
cd $ANKI_DIR
docker compose up -d
docker compose down
docker compose logs -f
docker compose pull && docker compose up -d
\`\`\`
To add, remove, or reset the password of a sync account later, re-run the
installer against this install and pick **"Manage sync accounts"** —
don't hand-edit \`.env\`, the matching lines in \`docker-compose.yml\` have
to change alongside it:
\`\`\`bash
sudo ./setup.sh anki-sync-server
\`\`\`
MD
log_info "Full client setup + Quizlet import walkthrough written to $ANKI_DIR/README.md"
}
# ── Standalone execution ───────────────────────────────────────────────────
if [[ "${_RUN_STANDALONE:-0}" == "1" ]]; then
install_anki-sync-server
fi
+51 -10
View File
@@ -242,7 +242,8 @@ install_authelia() {
echo " 7) Reconfigure from scratch (regenerates secrets/users — breaks"
echo " existing sessions for every domain already on this instance)"
echo " 8) Show who has universal vs. service-scoped access"
echo " 9) Change \"Remember me\" session duration (stay logged in longer)"
echo " 9) Change \"Remember me\" session duration (stay logged in longer — also"
echo " raises the inactivity timeout to match, so it can't cut it short)"
echo " 10) Protect an existing site with this instance (pick a local Caddy site,"
echo " or type one on a different box — gates it with a login, same as any"
echo " other service already protected this way)"
@@ -501,7 +502,12 @@ access_control:
session:
name: authelia_session
expiration: 12h
inactivity: 2h
# Matches remember_me below, not a shorter default — an idle timeout
# shorter than remember_me silently cuts a "remembered" session short
# regardless of its own duration. See _authelia_set_remember_me()'s
# comment for the live case this caused. Change both together (that
# function does exactly this) rather than one at a time.
inactivity: 7d
remember_me: 7d
cookies:
- domain: ${AUTHELIA_DOMAIN}
@@ -2383,6 +2389,19 @@ _authelia_report_access_scope() {
# earlier version of this very file's own README section) uses the old
# name, which Authelia would just silently ignore rather than error on.
#
# Also writes the SAME value into `inactivity` — a separate session field
# (default 2h, set alongside remember_me in install_authelia()'s own
# template) that ends a session after that much idle time regardless of
# remember_me, since it isn't disabled or extended by the "Remember me"
# checkbox. Confirmed live: a user who'd set remember_me to 1y still got
# logged out after ordinary daily gaps (overnight, a workday) because
# inactivity was still sitting at its 2h default — remember_me alone does
# NOT deliver "won't be asked to log in again for the duration I set"
# without this. Tying the two together is what actually delivers that.
# `expiration` (the session cap when "Remember me" is NOT checked) is left
# alone — a shorter default there for an un-remembered session is correct,
# separate behavior, not the same gap.
#
# This only controls AUTHELIA's own session — it does not touch how long
# a native-OIDC app's (Gitea/Mealie/ActualBudget) own session/token lasts
# after logging in via Authelia. A long remember_me makes re-authenticating
@@ -2393,27 +2412,48 @@ _authelia_set_remember_me() {
local config_file="$DOCKER_DIR/authelia/config/configuration.yml"
[ -f "$config_file" ] || { log_warning "No configuration.yml found — install Authelia first."; return 1; }
local current
local current current_inactivity
current="$(grep -E '^ remember_me:' "$config_file" | awk '{print $2}' | tr -d "'\"")"
current_inactivity="$(grep -E '^ inactivity:' "$config_file" | awk '{print $2}' | tr -d "'\"")"
echo ""
echo " Current \"remember me\" duration: ${current:-not set}"
echo " Current \"remember me\" duration: ${current:-not set} (inactivity timeout: ${current_inactivity:-not set})"
echo " How long a session lasts when someone checks \"Remember me\" at login —"
echo " applies to every domain this Authelia instance protects."
echo " applies to every domain this Authelia instance protects. Also sets"
echo " \"inactivity\" (idle timeout) to the same value, so a gap between visits"
echo " shorter than this can't log you out early — otherwise inactivity's own"
echo " separate, much shorter default cuts a long remember_me short."
echo " Examples: 12h, 7d, 1M (month), 1y. Set to -1 to disable Remember Me entirely."
local new_duration=""
prompt_text " New duration [${current:-7d}]:" "${current:-7d}" new_duration
if [ -z "$new_duration" ] || [ "$new_duration" = "$current" ]; then
if [ -z "$new_duration" ]; then
log_info "No change made."
return 0
fi
# Only truly a no-op if BOTH keys already match — remember_me alone
# matching isn't enough to skip, or an install still carrying the old
# mismatched inactivity default (from before this function synced the
# two) could never actually get inactivity fixed by re-entering the
# same remember_me value. Confirmed live: this is exactly what
# happened on a box that had already set remember_me: 1y before this
# sync existed — re-running with "1y" again hit this early return and
# left inactivity untouched.
if [ "$new_duration" = "$current" ] && [ "$new_duration" = "$current_inactivity" ]; then
log_info "No change made — remember_me and inactivity already both ${new_duration}."
return 0
fi
if grep -qE '^ remember_me:' "$config_file"; then
sed -i "s/^ remember_me:.*/ remember_me: '${new_duration}'/" "$config_file"
else
sed -i "/^session:\$/a\\ remember_me: '${new_duration}'" "$config_file"
fi
if grep -qE '^ inactivity:' "$config_file"; then
sed -i "s/^ inactivity:.*/ inactivity: '${new_duration}'/" "$config_file"
else
sed -i "/^ remember_me:/a\\ inactivity: '${new_duration}'" "$config_file"
fi
chown 1000:1000 "$config_file" 2>/dev/null || true
log_success "\"Remember me\" duration set to ${new_duration}."
log_success "\"Remember me\" duration and inactivity timeout both set to ${new_duration}."
local restart_auth=""
prompt_yn " Restart Authelia to apply? (y/n):" "y" restart_auth
@@ -2425,9 +2465,10 @@ _authelia_set_remember_me() {
echo ""
log_info "Takes effect for NEW logins where \"Remember me\" is checked at Authelia's"
log_info "login page — existing sessions keep whatever expiration they already had."
log_info "The checkbox itself is already on the login form by default; this only"
log_info "changes how long checking it actually keeps you signed in."
log_info "login page — existing sessions keep whatever expiration/inactivity they"
log_info "already had. The checkbox itself is already on the login form by default;"
log_info "this only changes how long checking it actually keeps you signed in, and"
log_info "stops the separate inactivity timeout from cutting that short."
}
# Export/import accounts (+ optionally 2FA/session state) — for migrating to
+666 -25
View File
@@ -285,6 +285,106 @@ print(pick[0]["browser_download_url"] if pick else "")
esac
}
# Same job as _wolf_download_emulator_appimage above, but against GitLab's
# Releases API instead of GitHub's — needed for any project (ES-DE included)
# that's hosted on GitLab rather than GitHub, since GitHub's API obviously
# can't answer for a repo it doesn't host. Mirrors the same arch-matching /
# post-download ELF-header verification logic so both call sites behave
# identically from the caller's point of view.
_wolf_download_emulator_appimage_gitlab() {
local _display_name="$1" _project_path="$2" _existing_glob="$3" _dir="$4"
if ls "$_dir"/$_existing_glob 2>/dev/null | grep -q .; then
log_info "$_display_name already present in $_dir/"
return 0
fi
local _get=""
echo ""
log_info "$_display_name can be auto-downloaded."
prompt_yn "Download $_display_name AppImage now? (y/n):" "y" _get
[[ "$_get" =~ ^[Yy]$ ]] || return 0
log_info "Fetching latest $_display_name release from GitLab..."
local _encoded_path
_encoded_path=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$_project_path")
local _url
_url=$(curl -fsSL "https://gitlab.com/api/v4/projects/${_encoded_path}/releases" \
| HOST_ARCH="$(uname -m)" python3 -c '
import sys, json, os
host = os.environ.get("HOST_ARCH", "")
arch_tags = {
"x86_64": ["x86_64", "amd64", "x64"],
"aarch64": ["aarch64", "arm64"],
"arm64": ["aarch64", "arm64"],
}.get(host, [host] if host else [])
all_arch_tags = ["x86_64", "amd64", "x64", "aarch64", "arm64", "armv7", "armhf", "i386", "i686"]
def has(name, tags):
n = name.lower()
return any(t in n for t in tags)
releases = json.load(sys.stdin)
# GitLab does not guarantee list order — sort explicitly instead of
# assuming index 0 is the newest (the mistake that would silently pick a
# stale/older release on some future API response ordering change).
releases = sorted(releases, key=lambda r: r.get("released_at") or "", reverse=True)
assets = []
for r in releases:
for link in r.get("assets", {}).get("links", []):
# The GitLab asset URL itself is an opaque .../package_files/<id>/download
# link with no filename/extension in it at all — confirmed live: only
# the link own "name" field carries the real filename
# (e.g. "ES-DE_x64.AppImage"), so filtering on the URL suffix (as
# this used to) matches nothing and silently fails with no
# resolvable download, even though the release genuinely has an
# AppImage asset sitting right there.
name = link.get("name", "")
url = link.get("direct_asset_url") or link.get("url") or ""
if name.endswith(".AppImage"):
assets.append({"name": name, "browser_download_url": url})
if assets:
break
matching = [a for a in assets if arch_tags and has(a["name"], arch_tags)]
untagged = [a for a in assets if not has(a["name"], all_arch_tags)]
pick = matching or untagged or assets
# No f-string here on purpose: this whole script is wrapped in a bash
# single-quoted string (see the "python3 -c" call above it), so a single
# quote anywhere in this code — the way an f-string would normally quote
# a dict key — would terminate that bash string early. Plain
# concatenation with double-quoted literals sidesteps that entirely.
print(pick[0]["browser_download_url"] + "\t" + pick[0]["name"] if pick else "")
' 2>/dev/null)
if [[ -z "$_url" ]]; then
log_warning "Could not resolve download URL — get it manually from https://gitlab.com/${_project_path}/-/releases"
return 1
fi
# The URL and the real filename are two different things here — GitLab's
# own asset URL is an opaque .../package_files/<id>/download link with no
# filename in it at all, so basename($_url) would save the file as
# literally "download" instead of e.g. "ES-DE_x64.AppImage". The release
# asset's own "name" field (tab-separated from the URL above) is the only
# place the real filename actually lives.
local _asset_name
_asset_name="${_url#*$'\t'}"
_url="${_url%%$'\t'*}"
local _file="$_dir/$_asset_name"
curl -fL --progress-bar -o "$_file" "$_url" \
&& chmod +x "$_file" \
&& chown "$ACTUAL_USER:$ACTUAL_USER" "$_file" \
&& log_success "$_display_name downloaded: $_file" \
|| { log_warning "Download failed — get it manually from https://gitlab.com/${_project_path}/-/releases"; return 1; }
local _got_arch
_got_arch=$(file -b "$_file" 2>/dev/null)
case "$(uname -m)" in
x86_64)
echo "$_got_arch" | grep -qi 'x86-64\|x86_64' || \
log_warning "$_file doesn't look like an x86_64 build ($_got_arch) — it will fail with 'exec format error'. Grab the x86_64 asset by hand from https://gitlab.com/${_project_path}/-/releases"
;;
aarch64|arm64)
echo "$_got_arch" | grep -qi 'aarch64\|arm64' || \
log_warning "$_file doesn't look like an aarch64 build ($_got_arch) — it may fail to run. Grab the aarch64 asset by hand from https://gitlab.com/${_project_path}/-/releases"
;;
esac
}
install_wolf() {
require_docker || return 1
@@ -939,6 +1039,85 @@ UDEV
log_info "not something this installer can supply. Cemu's own First-Time Setup Wizard covers where"
log_info "to put it once you have one."
# ── Optional: ES-DE and RetroArch as standalone AppImages (for Steam) ────
# These are ADDITIONAL to the esde/retroarch Wolf catalog containers
# above, not a replacement — nothing here removes or changes those. The
# only reason to want this: once added as a Steam non-Steam game
# (./manage.sh steam-add-nonsteam-game, which the wolf mount fix above
# already extended to reach the same roms/saves/bios/retro-home/
# retroarch paths the esde/retroarch containers use), Steam Input can
# give each of up to 4 identical-model controllers its own distinct
# identity by device path — the one thing Wolf's own 3-concrete-pad-type
# ceiling can't do for a 4th controller (see manage.sh's own "4
# controllers (Cemu / Wii U games)" help text). Skip both prompts below
# if you're happy running Wii U/retro systems through the esde app
# directly and don't need Steam's per-device controller assignment.
#
# Both download to a FIXED, predictable filename (ES-DE.AppImage /
# RetroArch.AppImage) regardless of the real upstream release asset's
# own name — steam-add-nonsteam-game matches by substring against the
# actual filename on disk, and bash glob matching is case-sensitive, so
# a fixed name (same symlink trick already used for Dolphin above) is
# what makes './manage.sh steam-add-nonsteam-game es-de' reliably find
# it regardless of how the vendor's own release happens to be named.
if [ ! -f "$_EMU_DIR/ES-DE.AppImage" ]; then
echo ""
# ES-DE is hosted on GitLab, not GitHub (confirmed against its own
# project page) — a different Releases API than every other
# standalone emulator above, hence the separate _gitlab helper.
log_info "ES-DE also ships an official standalone Linux AppImage — separate from the esde Wolf"
log_info "app above. Only useful for adding to Steam (see this repo's wolf README); skip this if"
log_info "you'll only ever use the esde app directly."
local _GET_ESDE_APPIMAGE=""
prompt_yn "Download the ES-DE AppImage for use via Steam? (y/n):" "n" _GET_ESDE_APPIMAGE
if [[ "$_GET_ESDE_APPIMAGE" =~ ^[Yy]$ ]]; then
_wolf_download_emulator_appimage_gitlab \
"ES-DE" "es-de/emulationstation-de" "ES-DE.AppImage" "$_EMU_DIR"
local _ESDE_REAL
_ESDE_REAL=$(ls "$_EMU_DIR"/*.AppImage 2>/dev/null \
| grep -iE '/(es-?de|emulationstation)[^/]*\.AppImage$' \
| grep -v '/ES-DE\.AppImage$' | head -1)
if [[ -n "$_ESDE_REAL" ]]; then
ln -sf "$(basename "$_ESDE_REAL")" "$_EMU_DIR/ES-DE.AppImage"
chown -h "$ACTUAL_USER:$ACTUAL_USER" "$_EMU_DIR/ES-DE.AppImage" 2>/dev/null || true
log_success "Linked $_EMU_DIR/ES-DE.AppImage -> $(basename "$_ESDE_REAL")"
log_info "Once Wolf is running, add it to Steam with: cd $WOLF_DIR && ./manage.sh steam-setup-frontends"
log_info "(waits for Steam sign-in — QR code via Moonlight — then wires this in automatically)"
fi
fi
fi
if [ ! -f "$_EMU_DIR/RetroArch.AppImage" ]; then
echo ""
# Libretro's own buildbot doesn't publish through a GitHub/GitLab
# Releases API this installer can automate against — hizzlekizzle/
# RetroArch-AppImage is the well-regarded THIRD-PARTY nightly-build
# project the AppImage community catalogs (appimage.github.io etc.)
# themselves point to, same "flagged, not silently offered as
# official" treatment as the Dolphin community build above.
log_info "RetroArch also has a standalone Linux AppImage, separate from the retroarch Wolf app"
log_info "above — same rationale as ES-DE just above (Steam Input's per-device controller"
log_info "assignment). This comes from hizzlekizzle/RetroArch-AppImage, a well-regarded but"
log_info "THIRD-PARTY nightly-build project, not an official libretro.org release — grab"
log_info "RetroArch's own build by hand instead if you'd rather not run that."
local _GET_RA_APPIMAGE=""
prompt_yn "Download the RetroArch AppImage for use via Steam? (y/n):" "n" _GET_RA_APPIMAGE
if [[ "$_GET_RA_APPIMAGE" =~ ^[Yy]$ ]]; then
_wolf_download_emulator_appimage \
"RetroArch" "hizzlekizzle/RetroArch-AppImage" "RetroArch.AppImage" "$_EMU_DIR"
local _RA_REAL
_RA_REAL=$(ls "$_EMU_DIR"/*[Rr]etro[Aa]rch*.AppImage 2>/dev/null \
| grep -v '/RetroArch\.AppImage$' | head -1)
if [[ -n "$_RA_REAL" ]]; then
ln -sf "$(basename "$_RA_REAL")" "$_EMU_DIR/RetroArch.AppImage"
chown -h "$ACTUAL_USER:$ACTUAL_USER" "$_EMU_DIR/RetroArch.AppImage" 2>/dev/null || true
log_success "Linked $_EMU_DIR/RetroArch.AppImage -> $(basename "$_RA_REAL")"
log_info "Once Wolf is running, add it to Steam with: cd $WOLF_DIR && ./manage.sh steam-setup-frontends"
log_info "(waits for Steam sign-in — QR code via Moonlight — then wires this in automatically)"
fi
fi
fi
# ── Optional: TI-99/4A as its own ES-DE system ────────────────────────────
# TI-99/4A has no libretro core and isn't one of ES-DE's built-in systems,
# so getting it real ES-DE treatment (artwork scraping, gameplay-time
@@ -1845,8 +2024,27 @@ _cache_ge_proton() {
url="https://github.com/GloriousEggroll/proton-ge-custom/releases/download/$version/$version.tar.gz"
else
echo "Fetching latest GE-Proton release info..."
# A plain grep+cut over the raw JSON used to break outright once a
# release started shipping more than one .tar.gz-suffixed asset (a
# second architecture build, a differently-named tarball, etc.) —
# grep then returns more than one line, and $(...) glues them
# together with an embedded newline instead of picking just one,
# which curl rejects outright ("URL rejected: Malformed input to a
# URL function") rather than a clean single URL. Parse the JSON for
# real instead of pattern-matching the raw text.
url=$(curl -sL https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases/latest \
| grep browser_download_url | grep '\.tar\.gz' | cut -d'"' -f4)
| python3 -c '
import sys, json
try:
release = json.load(sys.stdin)
except Exception:
print("")
raise SystemExit
assets = [a for a in release.get("assets", []) if a.get("name", "").endswith(".tar.gz")]
x64 = [a for a in assets if "x86_64" in a.get("name", "")]
pick = x64 or assets
print(pick[0]["browser_download_url"] if pick else "")
' 2>/dev/null)
if [ -z "$url" ]; then
echo "Could not determine GE-Proton download URL (GitHub rate-limited or offline?)."
return 1
@@ -2166,13 +2364,47 @@ CATALOG = {
# (see below) — without it, an emulator AppImage (Cemu, etc.)
# added as a non-Steam game (./manage.sh steam-add-nonsteam-game)
# has no file to actually point Exe at from inside this container.
f'{games}/emulators:/home/retro/Applications:rw'],
f'{games}/emulators:/home/retro/Applications:rw',
# Same roms/saves/bios/retro-home/retroarch mounts as esde/
# retroarch below — without these, a standalone ES-DE or
# RetroArch AppImage added here as a non-Steam game (same
# mechanism as Cemu above) would see none of the ROMs, cores,
# save states, BIOS files, or ES-DE's own settings/custom
# systems (TI-99, Wii U AntiMicroX command) that the esde/
# retroarch containers already have — it'd start from a
# completely empty config instead of reusing what's already
# set up. Every path here is the exact same host directory
# those two containers mount, just also visible from Steam.
f'{games}/roms:/ROMs:rw',
f'{games}/saves:/mnt/games/saves:rw',
f'{games}/media:/media:rw',
f'{games}/bios:/home/retro/bioses:rw',
f'{games}/retro-home:/home/retro/.config:rw',
f'{games}/retro-home-data:/home/retro/.local/share:rw',
f'{games}/retroarch:/home/retro/.config/retroarch:rw',
f'{games}/esde-custom-systems:/home/retro/ES-DE/custom_systems:rw',
f'{games}/esde-settings:/home/retro/ES-DE/settings:rw'],
# Steam Input needs /dev/uinput for the SAME reason the esde entry
# below needs it for AntiMicroX: it does not hand a game the raw
# controller device at all — it grabs the raw device exclusively for
# itself and creates its OWN synthetic virtual controller via uinput,
# then hands that synthetic device to the game. Confirmed live:
# without this, Steam's own controller test screen reads the raw
# device fine (it does not need uinput for that) and shows a correct
# Gamepad template, but every actual game — Steam title or non-Steam
# shortcut alike — sees no controller at all, since Steam Input can
# never create the virtual device it is supposed to hand off. Same
# devices= grant pattern as esde below: GOW_REQUIRED_DEVICES alone
# only gets the base image's entrypoint script to bind-mount the
# node; the container also needs Wolf's own create-time device grant
# to actually open it.
env=['PROTON_LOG=1', 'RUN_SWAY=true',
'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'],
'GOW_REQUIRED_DEVICES=/dev/uinput /dev/input/* /dev/dri/* /dev/nvidia*'],
cap_add=['SYS_ADMIN', 'SYS_NICE', 'SYS_PTRACE', 'NET_RAW', 'MKNOD', 'NET_ADMIN'],
security_opt=['seccomp=unconfined', 'apparmor=unconfined'],
ipc_mode='host', ulimits=[{'Name': 'nofile', 'Hard': 10240, 'Soft': 10240}],
privileged=False,
devices=['/dev/uinput:/dev/uinput'],
),
'esde': dict(
name='WolfES-DE', title='EmulationStation',
@@ -2945,8 +3177,23 @@ PYEOF
echo "Using pre-downloaded GE-Proton: $NAME"
else
echo "Fetching latest GE-Proton release info..."
# See _cache_ge_proton()'s own comment above for why this parses
# the JSON for real instead of grep+cut over the raw text —
# more than one matching .tar.gz asset used to glue into one
# multi-line, curl-rejected "Malformed input to a URL function".
URL=$(curl -sL https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases/latest \
| grep browser_download_url | grep '\.tar\.gz' | cut -d'"' -f4)
| python3 -c '
import sys, json
try:
release = json.load(sys.stdin)
except Exception:
print("")
raise SystemExit
assets = [a for a in release.get("assets", []) if a.get("name", "").endswith(".tar.gz")]
x64 = [a for a in assets if "x86_64" in a.get("name", "")]
pick = x64 or assets
print(pick[0]["browser_download_url"] if pick else "")
' 2>/dev/null)
if [ -z "$URL" ]; then
echo "Could not determine GE-Proton download URL (GitHub rate-limited or offline?)."
exit 1
@@ -3554,7 +3801,7 @@ _manage_wolf_complete() {
local commands="start stop restart logs status pin controllers update apps cores reorder
add-web ge-proton games setup-swbf2 fix-ea-game wait-ea-app
install-ea-app diagnose-ea fix-perms install-completion backup
steam-add-nonsteam-game cemu-clone-controller cemu-sync-controllers"
steam-add-nonsteam-game steam-setup-frontends cemu-clone-controller cemu-sync-controllers"
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
}
# Register for both 'manage.sh' and './manage.sh' invocation styles
@@ -3873,9 +4120,50 @@ except Exception:
ls "$_SANG_EMU_DIR" 2>/dev/null
exit 1
fi
_SANG_EXE="/home/retro/Applications/$(basename "$_SANG_HOST_FILE")"
_SANG_REAL_EXE="/home/retro/Applications/$(basename "$_SANG_HOST_FILE")"
[ -z "$_SANG_NAME" ] && _SANG_NAME=$(basename "$_SANG_HOST_FILE" | sed -E 's/\.(AppImage|sh|x86_64)$//I')
# Route the actual launch through a small fullscreen-forcing wrapper
# instead of pointing Steam's Exe directly at the AppImage. Why: a
# non-Steam game launched from Steam is a SECOND top-level window in
# Wolf's single-app Steam Sway session (RUN_SWAY=true on the 'steam'
# CATALOG entry) — Sway's own kiosk config only auto-fullscreens the
# ONE window it expects (Steam's own), so anything launched from
# inside Steam opens at whatever default size it requests instead,
# which reads as roughly half the screen against a full
# Moonlight-resolution display. Reported live against ES-DE/Dolphin
# added this way. NOT yet confirmed live that swaymsg actually
# reaches Sway from inside this exact container/session — if it
# doesn't, the wrapper's fullscreen loop just fails silently
# (2>/dev/null below) and the app launches exactly as it did before
# this existed, so this is safe to try without risking the launch
# itself. Regenerated on every call (cheap, stateless — nothing to
# lose by overwriting it) so a fix to the wrapper reaches every
# existing shortcut the next time it's (re-)added, not just new ones.
_SANG_WRAP="$_SANG_EMU_DIR/steam-fullscreen-wrap"
cat > "$_SANG_WRAP" << 'WRAPEOF'
#!/bin/bash
# steam-fullscreen-wrap <real-binary> [args...]
# Launches the real target, then repeatedly asks Sway to fullscreen whatever
# currently has input focus for a few seconds after launch — the newly
# launched window is expected to grab focus once it maps, same as any
# ordinary X11/Wayland client. 'fullscreen enable' (not 'toggle') is
# idempotent, so repeating it while the window is already fullscreen is a
# harmless no-op rather than flipping it back off.
REAL_BIN="$1"; shift
"$REAL_BIN" "$@" &
PID=$!
(
for _i in $(seq 1 20); do
sleep 0.5
swaymsg fullscreen enable 2>/dev/null
done
) &
wait "$PID"
WRAPEOF
chmod +x "$_SANG_WRAP"
_SANG_EXE="/home/retro/Applications/steam-fullscreen-wrap"
STEAM_HOME=$(_steam_home)
if [ -z "$STEAM_HOME" ]; then
echo "No Steam home found yet under ${WOLF_STATE_DIR:-/etc/wolf}."
@@ -3902,10 +4190,10 @@ except Exception:
sleep 3
fi
sudo python3 - "$_SANG_VDF" "$_SANG_EXE" "$_SANG_NAME" "/home/retro/Applications" << 'VDFPY'
sudo python3 - "$_SANG_VDF" "$_SANG_EXE" "$_SANG_NAME" "/home/retro/Applications" "$_SANG_REAL_EXE" << 'VDFPY'
import sys, struct, os, zlib
path, exe_path, app_name, start_dir = sys.argv[1:5]
path, exe_path, app_name, start_dir, real_exe = sys.argv[1:6]
TYPE_MAP, TYPE_STR, TYPE_INT, TYPE_END = 0x00, 0x01, 0x02, 0x08
@@ -3969,13 +4257,30 @@ if shortcuts_entry is None:
entries_list = shortcuts_entry[2]
# exe_path now points at the shared steam-fullscreen-wrap script (same Exe
# for every emulator added this way), with the real per-emulator target
# carried in LaunchOptions instead — so matching (for both idempotent
# re-adds and appid uniqueness) has to key on the (exe, LaunchOptions) PAIR,
# not exe alone, or adding a second emulator would silently overwrite the
# first one's shortcut entry. Also cleans up a legacy entry from before this
# wrapper existed, where Exe pointed directly at this same real binary with
# no LaunchOptions at all — re-adding an emulator added under the old
# scheme replaces that stale direct-launch entry instead of leaving a
# duplicate tile behind.
quoted_exe = f'"{exe_path}"'
entries_list[:] = [e for e in entries_list if get_field(e[2], 'exe') != quoted_exe]
quoted_launch = f'"{real_exe}"'
entries_list[:] = [
e for e in entries_list
if not (
(get_field(e[2], 'exe') == quoted_exe and get_field(e[2], 'LaunchOptions') == quoted_launch)
or get_field(e[2], 'exe') == quoted_launch
)
]
for idx, e in enumerate(entries_list):
e[0] = str(idx)
new_index = str(len(entries_list))
crc_input = (exe_path + app_name).encode('utf-8')
crc_input = (exe_path + real_exe + app_name).encode('utf-8')
appid = (zlib.crc32(crc_input) | 0x80000000) & 0xFFFFFFFF
appid_signed = appid - 0x100000000 if appid >= 0x80000000 else appid
@@ -3986,7 +4291,7 @@ new_entry_fields = [
['StartDir', TYPE_STR, f'"{start_dir}"'],
['icon', TYPE_STR, ''],
['ShortcutPath', TYPE_STR, ''],
['LaunchOptions', TYPE_STR, ''],
['LaunchOptions', TYPE_STR, quoted_launch],
['IsHidden', TYPE_INT, 0],
['AllowDesktopConfig', TYPE_INT, 1],
['AllowOverlay', TYPE_INT, 1],
@@ -4002,18 +4307,127 @@ entries_list.append([new_index, TYPE_MAP, new_entry_fields])
with open(path, 'wb') as f:
f.write(serialize_map(root))
print(f"Wrote shortcuts.vdf: {app_name} -> {exe_path} (appid {appid_signed})")
print(f"Wrote shortcuts.vdf: {app_name} -> {real_exe} (via {exe_path}, appid {appid_signed})")
VDFPY
sudo chown 1000:1000 "$_SANG_VDF"
echo ""
echo "Added '$_SANG_NAME' -> $_SANG_EXE to Steam's shortcuts.vdf."
echo "Added '$_SANG_NAME' -> $_SANG_REAL_EXE (via the fullscreen wrapper) to Steam's shortcuts.vdf."
echo "Open Steam in Moonlight (it will restart since it was stopped above) —"
echo "the new tile appears in your Library (may need the Library view, not just Home)."
echo "First launch: right-click it -> Properties -> Compatibility, and confirm"
echo "'Force the use of a specific Steam Play compatibility tool' is OFF — Cemu and"
echo "other native Linux AppImages don't run through Proton."
;;
steam-setup-frontends)
# One command covering what the emulator AppImage download step in
# setup.sh can't finish on its own: that step runs before Wolf/Steam
# containers even exist, so it can only download the AppImages and
# print instructions. This picks up from there — start Steam if
# needed, WAIT for it to be signed in (Steam Guard's QR-code sign-in
# itself can't be scripted: it needs a phone approving a prompt, so
# this only polls for the result, never performs the sign-in), then
# add EVERY AppImage already sitting in emulators/ (ES-DE, RetroArch,
# Cemu, Azahar, PCSX2, Dolphin — whatever setup.sh's download prompts
# were said yes to) as a Steam non-Steam game via the existing
# steam-add-nonsteam-game command above (re-invoked, not
# reimplemented, so the two never drift apart). Safe to re-run any
# time — already-added shortcuts are updated in place, never
# duplicated, so running this again after downloading one more
# emulator only adds the new one.
if ! docker ps --format '{{.Names}}' | grep -qi WolfSteam; then
echo "Starting Wolf (docker compose up -d)..."
docker compose up -d
sleep 5
fi
_SSF_SIGNED_IN() {
local _home
_home=$(_steam_home)
[ -n "$_home" ] && [ -n "$(sudo ls "$_home/.steam/steam/userdata/" 2>/dev/null)" ]
}
if _SSF_SIGNED_IN; then
echo "Steam is already signed in — proceeding."
else
echo ""
echo "Steam isn't signed in yet. In Moonlight:"
echo " 1. Connect to the Steam app."
echo " 2. On Steam's login screen, choose 'Sign in with QR code'."
echo " 3. Scan it with your phone's Steam app and approve the prompt."
echo ""
echo "Waiting for sign-in (up to 10 minutes, checking every 5s — Ctrl+C to give up"
echo "and finish this later by re-running './manage.sh steam-setup-frontends')..."
_SSF_WAITED=0
until _SSF_SIGNED_IN; do
sleep 5
_SSF_WAITED=$((_SSF_WAITED + 5))
if [ "$_SSF_WAITED" -ge 600 ]; then
echo "Still not signed in after 10 minutes — giving up for now."
exit 1
fi
done
echo "Signed in."
fi
GAME_DIR=$(grep '^GAME_STORAGE_DIR=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2-)
if [ -z "$GAME_DIR" ]; then read -r -p " Game storage path: " GAME_DIR; fi
_SSF_EMU_DIR="$GAME_DIR/emulators"
_SSF_ADDED_ANY=0
# realpaths already registered — so a fixed-name symlink
# (ES-DE.AppImage, RetroArch.AppImage, Dolphin_Emulator.AppImage) and the
# real versioned file it points at don't both end up as separate Steam
# tiles for the same emulator.
_SSF_SEEN=""
_ssf_add_appimage() {
# $1 = filename inside emulators/, $2 = display name (optional —
# falls back to steam-add-nonsteam-game's own filename-derived default).
local _file="$1" _name="${2:-}" _real
[ -f "$_SSF_EMU_DIR/$_file" ] || return 1
_real=$(readlink -f "$_SSF_EMU_DIR/$_file")
case " $_SSF_SEEN " in *" $_real "*) return 1 ;; esac
_SSF_SEEN="$_SSF_SEEN $_real"
if [ -n "$_name" ]; then
"$0" steam-add-nonsteam-game "$_file" "$_name"
else
"$0" steam-add-nonsteam-game "$_file"
fi
}
# Fixed-name symlinks first, so their friendly display names win over
# the versioned real filename each one points at (see the emulator
# download step in setup.sh for why these symlinks exist).
_ssf_add_appimage "ES-DE.AppImage" "EmulationStation (ES-DE)" && _SSF_ADDED_ANY=1
_ssf_add_appimage "RetroArch.AppImage" "RetroArch" && _SSF_ADDED_ANY=1
_ssf_add_appimage "Dolphin_Emulator.AppImage" "Dolphin (GameCube/Wii)" && _SSF_ADDED_ANY=1
# Everything else already downloaded into emulators/ — Cemu, Azahar,
# PCSX2, and any future emulator this list doesn't yet special-case by
# name. The realpath dedupe above skips the Dolphin/ES-DE/RetroArch
# symlinks' own real targets when this glob reaches them.
shopt -s nullglob
for _appimg in "$_SSF_EMU_DIR"/*.AppImage; do
_base=$(basename "$_appimg")
case "${_base,,}" in
cemu*) _ssf_add_appimage "$_base" "Cemu (Wii U)" && _SSF_ADDED_ANY=1 ;;
azahar*) _ssf_add_appimage "$_base" "Azahar (3DS)" && _SSF_ADDED_ANY=1 ;;
pcsx2*) _ssf_add_appimage "$_base" "PCSX2 (PS2)" && _SSF_ADDED_ANY=1 ;;
*) _ssf_add_appimage "$_base" && _SSF_ADDED_ANY=1 ;;
esac
done
shopt -u nullglob
if [ "$_SSF_ADDED_ANY" = 0 ]; then
echo "No emulator AppImages found in $_SSF_EMU_DIR yet — download them first: sudo ./setup.sh wolf"
fi
if [ "$_SSF_ADDED_ANY" = 1 ]; then
echo ""
echo "Cores/shaders/overlays live in the same retroarch/ directory the esde/retroarch apps"
echo "already use (shared mount — nothing new to configure there). If you haven't already:"
echo " ./manage.sh cores all (downloads every libretro core + shaders/overlays/database)"
fi
;;
cemu-clone-controller)
# Clones a WORKING Cemu controller mapping onto a new device slot,
# skipping Cemu's own Input Settings dialog entirely for that slot.
@@ -4327,6 +4741,9 @@ SYNCPY
echo " ./manage.sh install-completion - Enable tab-completion for this script"
echo " ./manage.sh steam-add-nonsteam-game [name] [display name]"
echo " - Add an emulator (emulators/) as a non-Steam game, no GUI needed"
echo " ./manage.sh steam-setup-frontends - Wait for Steam sign-in (QR code via Moonlight), then add"
echo " EVERY downloaded emulator AppImage (ES-DE, RetroArch,"
echo " Cemu, Azahar, PCSX2, Dolphin, ...) to Steam"
echo " ./manage.sh cemu-clone-controller [slot 0-3] [uuid] [display name]"
echo " - Clone a working Cemu controller mapping onto a new device slot"
echo " ./manage.sh cemu-sync-controllers - Auto-detect connected controllers via SDL and clone mappings onto all of them"
@@ -4391,14 +4808,48 @@ CATALOG = {
# (see below) — without it, an emulator AppImage (Cemu, etc.)
# added as a non-Steam game (./manage.sh steam-add-nonsteam-game)
# has no file to actually point Exe at from inside this container.
f'{games}/emulators:/home/retro/Applications:rw'],
f'{games}/emulators:/home/retro/Applications:rw',
# Same roms/saves/bios/retro-home/retroarch mounts as esde/
# retroarch below — without these, a standalone ES-DE or
# RetroArch AppImage added here as a non-Steam game (same
# mechanism as Cemu above) would see none of the ROMs, cores,
# save states, BIOS files, or ES-DE's own settings/custom
# systems (TI-99, Wii U AntiMicroX command) that the esde/
# retroarch containers already have — it'd start from a
# completely empty config instead of reusing what's already
# set up. Every path here is the exact same host directory
# those two containers mount, just also visible from Steam.
f'{games}/roms:/ROMs:rw',
f'{games}/saves:/mnt/games/saves:rw',
f'{games}/media:/media:rw',
f'{games}/bios:/home/retro/bioses:rw',
f'{games}/retro-home:/home/retro/.config:rw',
f'{games}/retro-home-data:/home/retro/.local/share:rw',
f'{games}/retroarch:/home/retro/.config/retroarch:rw',
f'{games}/esde-custom-systems:/home/retro/ES-DE/custom_systems:rw',
f'{games}/esde-settings:/home/retro/ES-DE/settings:rw'],
# Steam Input needs /dev/uinput for the SAME reason the esde entry
# below needs it for AntiMicroX: it does not hand a game the raw
# controller device at all — it grabs the raw device exclusively for
# itself and creates its OWN synthetic virtual controller via uinput,
# then hands that synthetic device to the game. Confirmed live:
# without this, Steam's own controller test screen reads the raw
# device fine (it does not need uinput for that) and shows a correct
# Gamepad template, but every actual game — Steam title or non-Steam
# shortcut alike — sees no controller at all, since Steam Input can
# never create the virtual device it is supposed to hand off. Same
# devices= grant pattern as esde below: GOW_REQUIRED_DEVICES alone
# only gets the base image's entrypoint script to bind-mount the
# node; the container also needs Wolf's own create-time device grant
# to actually open it.
env=['PROTON_LOG=1', 'RUN_SWAY=true',
'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'],
'GOW_REQUIRED_DEVICES=/dev/uinput /dev/input/* /dev/dri/* /dev/nvidia*'],
cap_add=['SYS_ADMIN', 'SYS_NICE', 'SYS_PTRACE', 'NET_RAW', 'MKNOD', 'NET_ADMIN'],
security_opt=['seccomp=unconfined', 'apparmor=unconfined'],
ipc_mode='host',
ulimits=[{'Name': 'nofile', 'Hard': 10240, 'Soft': 10240}],
privileged=False,
devices=['/dev/uinput:/dev/uinput'],
),
'esde': dict(
name='WolfES-DE', title='EmulationStation',
@@ -4715,6 +5166,35 @@ PYEOF
# Hand the folder back to the real user
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WOLF_DIR"
# ── Optional: auto-wire already-downloaded emulators into Steam ──────────
# Steam's own shortcuts.vdf (and the userdata/<id>/ dir it lives under)
# only exists once Steam has actually been signed into at least once —
# meaningless on a brand-new install, since Steam hasn't even been opened
# in Moonlight yet at this point in a first run. But on a RERUN of this
# module (sudo ./setup.sh wolf again, after you've since signed in),
# every AppImage already sitting in emulators/ can go straight into
# Steam's library with no extra manual step. No blocking here: this is
# a quick, one-shot check for a Steam profile that already exists — the
# actual up-to-10-minutes sign-in wait lives in
# 'manage.sh steam-setup-frontends' itself (re-invoked below, not
# duplicated), and only ever triggers there if this check somehow raced
# against a sign-in that hadn't finished landing on disk yet.
if echo "$_APP_KEYS" | grep -qw steam; then
local _WOLF_ANY_STEAM_UID=""
_WOLF_ANY_STEAM_UID=$(find "$WOLF_STATE_DIR" -maxdepth 2 -type d -name Steam 2>/dev/null \
| while IFS= read -r _sd; do ls "$_sd/.steam/steam/userdata/" 2>/dev/null | head -1; done | head -1)
if [ -n "$_WOLF_ANY_STEAM_UID" ]; then
log_info "Steam is already signed in — adding downloaded emulators to Steam's library..."
(cd "$WOLF_DIR" && ./manage.sh steam-setup-frontends) \
|| log_warning "Couldn't auto-add emulators to Steam — run manually: cd $WOLF_DIR && ./manage.sh steam-setup-frontends"
else
log_info "Once you've signed into Steam via Moonlight, run this to add every downloaded"
log_info "emulator (Cemu, ES-DE, RetroArch, etc.) to Steam's library — or just re-run"
log_info "'sudo ./setup.sh wolf' after signing in and it'll be done automatically:"
log_info " cd $WOLF_DIR && ./manage.sh steam-setup-frontends"
fi
fi
local ALL_IPS
ALL_IPS=$(ip -4 addr show | grep -oP '(?<=inet )\d+\.\d+\.\d+\.\d+(?=/)' | grep -v '^127\.')
@@ -4819,7 +5299,16 @@ PYEOF
echo " Online together → each player launches their own session,"
echo " all connect to the same game server"
echo ""
echo "Manage: cd $WOLF_DIR && ./manage.sh {start|stop|restart|logs|status|pin|update|apps|reorder|add-web|ge-proton|fix-ea-game}"
echo " 4 controllers, one game (e.g. Cemu/Wii U)? Steam Input tells identical"
echo " controllers apart by device path, not just SDL GUID — add the emulator"
echo " to Steam as a non-Steam game instead of launching it from ES-DE:"
echo " ./manage.sh steam-setup-frontends # adds every downloaded emulator AppImage to Steam"
echo " ./manage.sh cemu-clone-controller / cemu-sync-controllers"
echo " For the ES-DE/RetroArch Wolf apps directly, force distinct virtual pad"
echo " types per slot instead: ./manage.sh controllers (see README.md)"
echo ""
echo "Manage: cd $WOLF_DIR && ./manage.sh {start|stop|restart|logs|status|update|cores|reorder|add-web|backup|ge-proton|fix-ea-game|controllers|steam-add-nonsteam-game|steam-setup-frontends|pin}"
echo " ./manage.sh install-completion # tab-complete every command above"
echo ""
echo "── EA GAMES (Battlefront II, etc.) ───────────────────"
echo ""
@@ -4898,10 +5387,33 @@ cd $WOLF_DIR
Run \`./manage.sh install-completion\` once, then re-open your shell (or
\`source ~/.bashrc\`). After that, \`./manage.sh <TAB><TAB>\` lists all commands.
## Adding an emulator to Steam as a non-Steam game
Steam's own "Add a Non-Steam Game" is a Big Picture file-browser flow —
usable through Moonlight, but slow to click through for something you're
scripting or repeating. This does the same thing directly:
## Getting downloaded emulators into Steam — the easy way
Downloading an emulator AppImage (\`sudo ./setup.sh wolf\`'s Cemu/Azahar/
PCSX2/Dolphin/ES-DE/RetroArch prompts) only puts the file in \`emulators/\`
— it doesn't touch Steam's own library on its own, since that step runs
before Steam has even been signed into. Once you've signed into Steam via
Moonlight, one command adds everything already downloaded:
\`\`\`bash
cd $WOLF_DIR && ./manage.sh steam-setup-frontends
\`\`\`
This scans \`emulators/\` and adds every AppImage found (Cemu, Azahar,
PCSX2, Dolphin, ES-DE, RetroArch — whichever prompts you said yes to) as
a Steam non-Steam game in one pass; see the section below for exactly how
it waits for sign-in. **You don't even need to run this yourself on a
rerun** — \`sudo ./setup.sh wolf\` run again after you've signed into Steam
detects the signed-in profile automatically and adds everything for you
as the last step, no extra command needed. It's still worth knowing the
command directly for downloading one more emulator later without
re-running the whole installer.
## Adding a single emulator to Steam as a non-Steam game
\`steam-add-nonsteam-game\` is the one-off building block
\`steam-setup-frontends\` above calls for each emulator it finds — reach
for it directly when you only want to (re-)add one specific file, e.g.
after manually dropping in an AppImage the setup script doesn't know
about. Steam's own "Add a Non-Steam Game" is a Big Picture file-browser
flow — usable through Moonlight, but slow to click through for something
you're scripting or repeating. This does the same thing directly:
\`\`\`bash
cd $WOLF_DIR && ./manage.sh steam-add-nonsteam-game cemu
\`\`\`
@@ -4924,10 +5436,124 @@ controllers per player slot even when the physical controllers are
identical models (the same case \`./manage.sh controllers\` exists to work
around for ES-DE). Add Cemu here, then in Steam's own Big Picture
Controller Settings you can see and assign each detected controller
individually. This hasn't been confirmed live yet against Wolf's
container-created virtual controllers specifically (real hardware behaves
this way; whether Steam Input sees Wolf's virtual joypads the same way is
still to be tested) — worth trying before assuming it works.
individually.
**Controller doesn't respond at all once added to Steam?** Confirmed live:
this is Steam Input, not the emulator — by default Steam applies its own
controller configuration to *every* shortcut, Steam or non-Steam alike,
and a freshly-added non-Steam game with no configuration picked yet can
end up with no usable mapping at all rather than passing raw input
through. Fix from Steam's own Big Picture UI: select the shortcut → the
controller icon / **Manage Game** → **Controller Options**, and either
pick a **Gamepad** template (closest to "pass it through as a normal
joystick", needed for ES-DE/RetroArch's own native SDL input handling to
see it at all) or turn **Steam Input** off for that one shortcut entirely
if you don't need the per-device assignment this section is about in the
first place. This is a one-time, per-shortcut setting Steam remembers —
not something this installer can preconfigure from the command line, since
it's stored in Steam's own (separate, Steam Cloud-synced) controller
config rather than \`shortcuts.vdf\`.
**Opens at roughly half the screen instead of fullscreen?** Also confirmed
live, and now worked around automatically as of the version of this repo
that added the point below — \`steam-add-nonsteam-game\`/
\`steam-setup-frontends\` route the launch through a small wrapper
(\`emulators/steam-fullscreen-wrap\`) instead of pointing Steam's \`Exe\`
directly at the AppImage. The underlying cause: a non-Steam game launched
from Steam is a *second* top-level window inside Wolf's single-app Steam
Sway session, and Sway's own kiosk config only auto-fullscreens the ONE
window it expects (Steam's own) — anything launched from inside Steam
opens at whatever default size it requests instead. The wrapper launches
the real binary, then repeatedly asks Sway to fullscreen whatever currently
has focus for a few seconds after launch (idempotent — harmless if it's
already fullscreen). **This has not been confirmed live against a real
Wolf Steam session yet** — if \`swaymsg\` isn't reachable from inside that
exact container, the wrapper's fullscreen loop just fails silently and the
window opens at its old default size, same as before this existed; it's
safe to try either way since the underlying launch itself is unaffected.
Re-run \`steam-add-nonsteam-game\`/\`steam-setup-frontends\` for anything
already added before this existed — it replaces the old direct-launch
shortcut with the wrapped one automatically, no duplicate tile left behind.
**An added emulator's tile appears in Steam but crashes on launch instead
of opening at all** (as opposed to the half-screen case above, where it
does open): that's a different problem — something about the AppImage
itself failing to run inside the Steam container specifically, since the
same binary launching fine through the \`esde\`/\`desktop\` Wolf apps rules out
the AppImage being broken outright. Worth checking before assuming it's
unfixable:
\`\`\`bash
CONTAINER=\$(docker ps --format '{{.Names}}' | grep -i WolfSteam | head -1)
docker exec "\$CONTAINER" /home/retro/Applications/<the-appimage-file> ; echo "exit: \$?"
\`\`\`
Run directly like this (no wrapper, no Steam involved) the real error
usually prints straight to the terminal — a missing shared library, a
FUSE/AppImage mount failure (\`dlopen(): error loading libfuse.so.2\`),
or similar. That output is what actually narrows down the fix.
## Running ES-DE and/or RetroArch through Steam instead of their own Wolf apps
The same Steam Input reasoning above applies beyond just Cemu: ES-DE and
RetroArch both ship their own official standalone Linux AppImages
(separate from the \`esde\`/\`retroarch\` Wolf catalog apps this installer
already runs), and adding one of those to Steam gets you the same
per-device controller assignment for every system it covers, not just
Cemu. \`sudo ./setup.sh wolf\` offers to download both (opt-in, default
no) right after the Cemu step — ES-DE's from its GitLab releases (it
isn't on GitHub), RetroArch's from \`hizzlekizzle/RetroArch-AppImage\` (a
well-regarded third-party nightly build — libretro.org's own buildbot
doesn't publish through an API this installer can automate against).
Both land in \`emulators/\` under a fixed name (\`ES-DE.AppImage\` /
\`RetroArch.AppImage\`) regardless of the real release asset's own
filename, so Steam's non-Steam-game matching finds them reliably.
They reuse the exact same \`roms/\`, \`saves/\`, \`bios/\`, \`retro-home\`, and
\`retroarch\` (cores/shaders/overlays) directories the \`esde\`/\`retroarch\`
containers already use — the \`steam\` Wolf app mounts all of the same
paths, so nothing needs re-downloading or re-scraping just because it's
now also reachable from Steam. If you haven't already populated cores:
\`\`\`bash
cd $WOLF_DIR && ./manage.sh cores all
\`\`\`
Finishing the Steam side needs one thing that can't be scripted — Steam
Guard's QR-code sign-in requires a phone approving a prompt — so this
polls for it instead of trying to script past it:
\`\`\`bash
cd $WOLF_DIR && ./manage.sh steam-setup-frontends
\`\`\`
Starts Wolf if it isn't already up, checks whether Steam's already
signed in (proceeds immediately if so), otherwise prints the QR-code
steps and waits (up to 10 minutes) for sign-in to complete, then adds
every AppImage already sitting in \`emulators/\` — not just ES-DE and
RetroArch, but Cemu/Azahar/PCSX2/Dolphin too — as a Steam non-Steam game,
re-using \`steam-add-nonsteam-game\` above rather than duplicating its
shortcuts.vdf-writing logic. Safe to re-run any time (e.g. if it timed
out waiting, or you downloaded another emulator since) — already-added
shortcuts are updated in place, not duplicated.
**Returning to Steam from ES-DE, without a second instance:** if ES-DE
is running as a Steam non-Steam game (via the AppImage above, not the
separate \`esde\` Wolf app), Steam is the parent process the whole
time — quitting ES-DE drops you back into the same still-running Steam
Big Picture session rather than starting a new one. This only works for
the AppImage-in-Steam path; the standalone \`esde\` Wolf app is a
completely separate container, and Wolf has no supported way to hand off
from one running app to another mid-session (switching apps means
closing the Moonlight session and reconnecting to the other one).
**Multiple devices, same Steam account, same controller mappings?**
Worth knowing before relying on it: Wolf gives each *paired client* its
own separate Steam container/home directory (confirmed against this
repo's own \`_steam_home()\` — it searches across multiple
\`.../Steam\` directories, not just one), so a second device connecting
to Wolf doesn't reuse the first device's Steam install and has to sign
in separately the first time. Once it's signed into the *same* Steam
account, Valve's own account-level Steam Cloud config sync should
replicate your Steam Input controller bindings across those separate
local installs (the same mechanism that syncs bindings between a Steam
Deck and a gaming PC) — but that's a Steam-account feature, not
something Wolf or this installer controls, and hasn't been confirmed
live in this specific setup.
## Multiple controllers (same game/emulator can't tell them apart)
**Symptom:** two or more controllers connected through the same Moonlight
@@ -5613,8 +6239,23 @@ MD
(
CACHE_DIR="$WOLF_DIR/ge-proton-cache"
mkdir -p "$CACHE_DIR"
# See the matching comment on manage.sh's own ge-proton command for
# why this parses the JSON for real instead of grep+cut over the raw
# text — more than one matching .tar.gz asset used to glue into one
# multi-line, curl-rejected "Malformed input to a URL function".
URL=$(curl -sL https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases/latest \
| grep browser_download_url | grep '\.tar\.gz' | cut -d'"' -f4)
| python3 -c '
import sys, json
try:
release = json.load(sys.stdin)
except Exception:
print("")
raise SystemExit
assets = [a for a in release.get("assets", []) if a.get("name", "").endswith(".tar.gz")]
x64 = [a for a in assets if "x86_64" in a.get("name", "")]
pick = x64 or assets
print(pick[0]["browser_download_url"] if pick else "")
' 2>/dev/null)
if [ -z "$URL" ]; then
log_warning "Could not fetch GE-Proton URL — run './manage.sh ge-proton' later."
else
+436
View File
@@ -0,0 +1,436 @@
#!/usr/bin/env python3
"""tools/anki-deck-math.py — Generate math-fact Anki decks (.apkg) with a
vertical/stacked problem layout, Anki's built-in type-the-answer input, and
Piper (offline, local neural TTS) audio on both the question and answer
side of every card.
Standalone content-generation tool, unrelated to this repo's services/*.sh
installers — run it on any machine with Python (your desktop, laptop, or
the same box running services/anki-sync-server.sh), then import the
resulting .apkg into Anki (File -> Import) or push it into a sync-server
account with AnkiConnect's importPackage action. See services/anki-sync-server.sh
and services/anki-progress.sh for the actual self-hosted sync backend and
progress dashboard this content is meant to be studied through.
Decks:
multiplication 1-12, all 144 ordered pairs (a x b), shuffled
(not sequential — see the note near
random.shuffle(pairs) below for why). Add
--skip-ones to drop every fact involving 1
(trivial, 121 facts instead of 144).
division inverse of the multiplication deck (144 facts,
or 121 with --skip-ones)
addsub --lo L --hi H addition + subtraction fact family for [L, H]
(subtraction facts derived from the addition
facts, e.g. 7+3=10 also gives 10-7=3 and
10-3=7 — never negative results)
fractions reducing fractions to lowest terms (denominators 2-12)
decimals fraction -> decimal conversion (only denominators
whose decimal expansion terminates: 2,4,5,8,10,20,25)
Setup (one time):
python3 -m venv ~/anki-deck-venv
source ~/anki-deck-venv/bin/activate
pip install genanki piper-tts
# Download at least one voice (one time per voice you want to try —
# download_voices saves into the CURRENT directory by default, so cd
# somewhere sensible first, e.g. your home directory):
python3 -m piper.download_voices en_US-lessac-medium
# Other options: en_US-amy-medium (warm/friendly), en_US-ryan-high
# (best-quality US male), en_US-libritts_r-medium (multi-speaker),
# en_GB-alba-medium / en_GB-cori-high (British accent). Tiers are
# low < medium < high — higher sounds more natural but is bigger/slower.
# Sanity-check the voice before generating a full deck's worth of clips:
echo "three times seven" | python3 -m piper -m en_US-lessac-medium.onnx -f /tmp/test.wav
# play /tmp/test.wav and confirm it sounds right first.
Usage (run with the venv activated):
python3 anki-deck-math.py --deck multiplication
python3 anki-deck-math.py --deck multiplication --skip-ones
python3 anki-deck-math.py --deck division
python3 anki-deck-math.py --deck addsub --lo 3 --hi 7
python3 anki-deck-math.py --deck addsub --lo 3 --hi 13
python3 anki-deck-math.py --deck addsub --lo 2 --hi 21
python3 anki-deck-math.py --deck fractions
python3 anki-deck-math.py --deck decimals
(add --voice en_US-amy-medium etc. to any of the above to use a voice other
than the default en_US-lessac-medium; --model-path to point at a voice
file directly if it's not found in any of the usual places checked
automatically; --dry-run-tts to test the deck-building logic itself
without Piper or any voice model at all, using silent placeholder audio)
The addsub --hi 21 deck generates ~1600 audio clips and will take noticeably
longer than the others — consider `nohup python3 anki-deck-math.py --deck
addsub --lo 2 --hi 21 > addsub.log 2>&1 &` if you don't want to wait on it.
"""
import argparse
import hashlib
import genanki
import math
import os
import random
import subprocess
parser = argparse.ArgumentParser()
parser.add_argument("--deck", required=True,
choices=["multiplication", "division", "addsub", "fractions", "decimals"])
parser.add_argument("--lo", type=int, default=None, help="addsub only: low end of range")
parser.add_argument("--hi", type=int, default=None, help="addsub only: high end of range")
parser.add_argument("--skip-ones", action="store_true",
help="multiplication/division only: drop every fact where either"
" number is 1 (1x1..1x12, 2x1..12x1, and their division"
" inverses) — those are trivial and not worth drilling.")
parser.add_argument("--voice", default="en_US-lessac-medium")
parser.add_argument("--model-path", default=None)
parser.add_argument("--dry-run-tts", action="store_true",
help="Skip Piper entirely and write silent placeholder audio instead"
" (for testing the deck-building logic without a voice model).")
args = parser.parse_args()
if args.deck == "addsub":
if args.lo is None or args.hi is None:
raise SystemExit("--deck addsub requires --lo and --hi, e.g. --lo 3 --hi 7")
if args.lo >= args.hi:
raise SystemExit("--lo must be less than --hi")
SCRATCH = os.path.dirname(os.path.abspath(__file__))
# ─── Voice resolution (same search order as the multiplication script) ──────
_CANDIDATES = [
args.model_path,
f"{args.voice}.onnx",
os.path.join(SCRATCH, f"{args.voice}.onnx"),
os.path.expanduser(f"~/{args.voice}.onnx"),
os.path.expanduser(f"~/.local/share/piper/voices/{args.voice}.onnx"),
]
VOICE_MODEL = next((p for p in _CANDIDATES if p and os.path.isfile(p)), None)
if VOICE_MODEL is None and not args.dry_run_tts:
raise SystemExit(
f"Voice model for '{args.voice}' not found. Checked:\n"
+ "\n".join(f" {p}" for p in _CANDIDATES if p)
+ f"\n\nFind it with: find / -iname '{args.voice}.onnx' 2>/dev/null"
+ "\nThen pass its exact path with --model-path /the/real/path.onnx"
+ "\n(or pass --dry-run-tts to test deck-building without any voice at all)"
)
def piper_tts(text: str, out_path: str) -> None:
if args.dry_run_tts:
# 44-byte minimal valid WAV header, zero samples — enough for genanki
# to accept it as a real media file without needing Piper installed.
with open(out_path, "wb") as f:
f.write(
b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
b"\x22\x56\x00\x00\x44\xac\x00\x00\x02\x00\x10\x00data\x00\x00\x00\x00"
)
return
subprocess.run(
["python3", "-m", "piper", "-m", VOICE_MODEL, "-f", out_path],
input=text.encode("utf-8"),
check=True,
capture_output=True,
)
# ─── Number -> words ─────────────────────────────────────────────────────────
ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven",
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen"]
TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
"eighty", "ninety"]
def num2words(n):
if n < 0:
return "negative " + num2words(-n)
if n < 20:
return ONES[n]
if n < 100:
t, o = divmod(n, 10)
return TENS[t] + ("-" + ONES[o] if o else "")
h, rem = divmod(n, 100)
return ONES[h] + " hundred" + (" " + num2words(rem) if rem else "")
_NUM_CHECKS = {0: "zero", 9: "nine", 10: "ten", 13: "thirteen", 20: "twenty",
21: "twenty-one", 45: "forty-five", 99: "ninety-nine",
100: "one hundred", 110: "one hundred ten",
121: "one hundred twenty-one", 144: "one hundred forty-four",
441: "four hundred forty-one"}
for _n, _w in _NUM_CHECKS.items():
assert num2words(_n) == _w, f"num2words({_n}) = {num2words(_n)!r}, expected {_w!r}"
# Ordinal words, singular form, denominators 2-21 (covers every deck below).
# Irregular forms (half, third, fifth, eighth, ninth, twelfth) are real
# English irregularities, not a suffix rule, so this is a lookup table, not
# a formula — a formula would get exactly these wrong.
ORDINAL_SINGULAR = {
2: "half", 3: "third", 4: "fourth", 5: "fifth", 6: "sixth",
7: "seventh", 8: "eighth", 9: "ninth", 10: "tenth", 11: "eleventh",
12: "twelfth", 13: "thirteenth", 14: "fourteenth", 15: "fifteenth",
16: "sixteenth", 17: "seventeenth", 18: "eighteenth", 19: "nineteenth",
20: "twentieth", 21: "twenty-first", 25: "twenty-fifth", 50: "fiftieth",
100: "hundredth",
}
def ordinal_plural(n):
s = ORDINAL_SINGULAR[n]
return "halves" if s == "half" else s + "s"
def fraction_words(num, den):
"""'three fourths', 'one half', 'seven tenths'."""
ord_word = ORDINAL_SINGULAR[den] if num == 1 else ordinal_plural(den)
return f"{num2words(num)} {ord_word}"
_FRAC_CHECKS = {
(1, 2): "one half", (3, 4): "three fourths", (1, 4): "one fourth",
(7, 10): "seven tenths", (1, 3): "one third", (2, 3): "two thirds",
(5, 8): "five eighths", (1, 8): "one eighth",
}
for (_n, _d), _w in _FRAC_CHECKS.items():
assert fraction_words(_n, _d) == _w, f"fraction_words({_n},{_d}) = {fraction_words(_n, _d)!r}, expected {_w!r}"
def decimal_words(decimal_str):
"""'0.25' -> 'zero point two five' (each digit spoken individually,
avoids any ambiguity between e.g. 'point two five' vs 'twenty-five
hundredths')."""
whole, frac = decimal_str.split(".")
digit_words = " ".join(ONES[int(d)] for d in frac)
return f"{num2words(int(whole))} point {digit_words}"
assert decimal_words("0.25") == "zero point two five"
assert decimal_words("0.5") == "zero point five"
assert decimal_words("0.375") == "zero point three seven five"
# ─── Shared genanki model builder ────────────────────────────────────────────
# Every deck here renders as two stacked lines with a line under them (same
# visual language as the original multiplication deck): TOP over BOTTOM,
# with an optional prefix (operator) on the bottom line. Fractions/decimals
# reuse the exact same layout as numerator-over-denominator.
def build_model(deck_key):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
model_id = 1_600_000_000 + (voice_hash % 90_000_000)
return model_id, genanki.Model(
model_id,
f"Math Fact ({deck_key}, {args.voice})",
fields=[{"name": "Top"}, {"name": "Bottom"}, {"name": "Answer"},
{"name": "QSound"}, {"name": "ASound"}],
templates=[{
"name": "Card",
"qfmt": """
<div class="problem">
<div class="line1">{{Top}}</div>
<div class="line2">{{Bottom}}</div>
<div class="rule"></div>
</div>
{{QSound}}
{{type:Answer}}
""",
"afmt": """
<div class="problem">
<div class="line1">{{Top}}</div>
<div class="line2">{{Bottom}}</div>
<div class="rule"></div>
</div>
<hr id="answer">
{{type:Answer}}
{{ASound}}
""",
}],
css="""
.card { font-family: Arial, sans-serif; font-size: 28px; text-align: center; }
.problem { display: inline-block; text-align: right; margin: 20px auto; }
.line1, .line2 { font-size: 48px; padding: 2px 10px; }
.rule { border-top: 3px solid black; margin-top: 4px; width: 100%; }
""",
)
def build_deck(deck_key, deck_title):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
deck_id = 2_000_000_000 + (voice_hash % 90_000_000)
return genanki.Deck(deck_id, deck_title)
def add_note(deck, model, top, bottom, answer, qtext, atext, media_files, tag):
qfile = f"q_{tag}.wav"
afile = f"a_{tag}.wav"
qpath = os.path.join(MEDIA_DIR, qfile)
apath = os.path.join(MEDIA_DIR, afile)
piper_tts(qtext, qpath)
piper_tts(atext, apath)
media_files += [qpath, apath]
deck.add_note(genanki.Note(
model=model,
fields=[top, bottom, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
))
# ─── Per-deck generators ─────────────────────────────────────────────────────
def gen_multiplication():
deck_key = "multiplication" + ("_no_ones" if args.skip_ones else "")
model_id, model = build_model(deck_key)
title = "Multiplication Facts (1-12)" + (", no 1s" if args.skip_ones else "")
deck = build_deck(deck_key, title)
media_files = []
pairs = [(a, b) for a in range(1, 13) for b in range(1, 13)]
if args.skip_ones:
pairs = [(a, b) for a, b in pairs if a != 1 and b != 1]
random.seed(42)
random.shuffle(pairs)
for a, b in pairs:
ans = a * b
add_note(deck, model, str(a), f"&times; {b}", str(ans),
f"{num2words(a)} times {num2words(b)}", num2words(ans),
media_files, f"mul_{a}_{b}")
return deck, media_files, len(pairs)
def gen_division():
deck_key = "division" + ("_no_ones" if args.skip_ones else "")
model_id, model = build_model(deck_key)
title = "Division Facts (inverse of 1-12 times tables)" + (", no 1s" if args.skip_ones else "")
deck = build_deck(deck_key, title)
media_files = []
# Same (a, b) pairs as multiplication: product / a = b. This is the
# direct inverse of every multiplication card in that deck.
pairs = [(a, b) for a in range(1, 13) for b in range(1, 13)]
if args.skip_ones:
pairs = [(a, b) for a, b in pairs if a != 1 and b != 1]
random.seed(43)
random.shuffle(pairs)
for a, b in pairs:
product = a * b
add_note(deck, model, str(product), f"&divide; {a}", str(b),
f"{num2words(product)} divided by {num2words(a)}", num2words(b),
media_files, f"div_{a}_{b}")
return deck, media_files, len(pairs)
def gen_addsub(lo, hi):
deck_key = f"addsub_{lo}_{hi}"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, f"Addition & Subtraction Facts ({lo}-{hi})")
media_files = []
add_pairs = [(a, b) for a in range(lo, hi + 1) for b in range(lo, hi + 1)]
random.seed(hash((lo, hi)) & 0xFFFFFFFF)
random.shuffle(add_pairs)
sub_facts = [] # (minuend, subtrahend, answer)
seen = set()
for a, b in add_pairs:
c = a + b
for minuend, subtrahend, answer in ((c, a, b), (c, b, a)):
key = (minuend, subtrahend)
if key not in seen:
seen.add(key)
sub_facts.append((minuend, subtrahend, answer))
random.shuffle(sub_facts)
count = 0
for a, b in add_pairs:
ans = a + b
add_note(deck, model, str(a), f"+ {b}", str(ans),
f"{num2words(a)} plus {num2words(b)}", num2words(ans),
media_files, f"add_{lo}_{hi}_{a}_{b}")
count += 1
for minuend, subtrahend, answer in sub_facts:
add_note(deck, model, str(minuend), f"&minus; {subtrahend}", str(answer),
f"{num2words(minuend)} minus {num2words(subtrahend)}", num2words(answer),
media_files, f"sub_{lo}_{hi}_{minuend}_{subtrahend}")
count += 1
return deck, media_files, count
def gen_fractions():
deck_key = "fractions"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Reducing Fractions to Lowest Terms")
media_files = []
facts = []
for den in range(2, 13):
for num in range(1, den):
g = math.gcd(num, den)
if g > 1:
facts.append((num, den, num // g, den // g))
random.seed(44)
random.shuffle(facts)
for num, den, rnum, rden in facts:
answer = f"{rnum}/{rden}"
add_note(deck, model, str(num), f"&frasl; {den}", answer,
fraction_words(num, den), fraction_words(rnum, rden),
media_files, f"frac_{num}_{den}")
return deck, media_files, len(facts)
def gen_decimals():
deck_key = "decimals"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Fraction to Decimal Conversion")
media_files = []
# Only denominators whose only prime factors are 2 and 5 terminate in a
# finite decimal (1/3 = 0.333... never terminates) — restricting to
# these avoids ever needing to round/repeat.
facts = []
for den in (2, 4, 5, 8, 10, 20, 25):
for num in range(1, den):
if math.gcd(num, den) != 1:
continue # skip non-lowest-terms fractions (already covered by the fractions deck)
value = num / den
decimal_str = f"{value:.10f}".rstrip("0")
if decimal_str.endswith("."):
decimal_str += "0"
facts.append((num, den, decimal_str))
random.seed(45)
random.shuffle(facts)
for num, den, decimal_str in facts:
add_note(deck, model, str(num), f"&frasl; {den}", decimal_str,
fraction_words(num, den), decimal_words(decimal_str),
media_files, f"dec_{num}_{den}")
return deck, media_files, len(facts)
# ─── Dispatch ─────────────────────────────────────────────────────────────────
if args.deck == "addsub":
deck_key = f"addsub_{args.lo}_{args.hi}"
elif args.deck in ("multiplication", "division") and args.skip_ones:
deck_key = f"{args.deck}_no_ones"
else:
deck_key = args.deck
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
os.makedirs(MEDIA_DIR, exist_ok=True)
GENERATORS = {
"multiplication": lambda: gen_multiplication(),
"division": lambda: gen_division(),
"addsub": lambda: gen_addsub(args.lo, args.hi),
"fractions": lambda: gen_fractions(),
"decimals": lambda: gen_decimals(),
}
deck, media_files, count = GENERATORS[args.deck]()
if count == 0:
raise SystemExit(f"No cards generated for --deck {args.deck} — check the range/args.")
package = genanki.Package(deck)
package.media_files = media_files
out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
package.write_to_file(out_path)
size_mb = os.path.getsize(out_path) / (1024 * 1024)
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} audio clips)")
+603
View File
@@ -0,0 +1,603 @@
#!/usr/bin/env python3
"""tools/anki-deck-periodic.py — Generate periodic table Anki decks (.apkg)
with Anki's built-in type-the-answer input and Piper (offline, local
neural TTS) audio on both sides. See tools/anki-deck-math.py's docstring
for one-time setup (venv, genanki + piper-tts, downloading a voice) — same
steps apply here, this is a standalone, self-contained script otherwise.
Decks:
prehs symbol<->name, elements 1-36 (H through Kr)
hs symbol<->name plus number->symbol, all 118 elements
category element category as multiple choice (A/B/C/D shown as
plain text options — not a clickable UI, since that needs
a desktop-only Anki add-on and would break on
AnkiDroid/AnkiMobile), type the letter — only elements
with a confirmed category (excludes 8 very recent
superheavy elements whose category is still officially
unconfirmed)
Element photos on prehs/hs: every card (symbol->name, name->symbol,
number->symbol alike) also shows a real photo of the element, fetched
once from Wikipedia's own MediaWiki API (the same "pageimage" shown in
that element's infobox — the well-documented, standard
`action=query&prop=pageimages` endpoint on en.wikipedia.org, not a
guessed URL) and cached locally under tools/periodic_images/ so reruns
don't re-download. This is the one part of this repo's Anki tooling that
needs internet access at generation time — every other deck (math,
shapes/clocks/currency, TTS audio) is fully offline. Pass --no-images to
skip this and get the old text-only prehs/hs cards back.
Elements 100 (Fermium) through 118 (Oganesson) are hard-excluded from
this — every atom of these ever made has been produced (or claimed) one
at a time in a particle accelerator and never existed in macroscopic,
visible quantity, so no real sample photo exists to fetch; anything
Wikipedia's pageimage API returned for them would be a diagram or a
scientist's portrait, not the element. A couple of element names collide
with a more famous Wikipedia topic under the same plain title (Mercury
the planet, for one) — TITLE_OVERRIDES below is the fix-up list; if a
generated card shows an obviously wrong photo for some element, that's
almost certainly another one of these collisions — add it there.
Batched and rate-limited on purpose: metadata lookups (which element has
which photo) go out up to 50 titles per request, not one request per
element, and every HTTP call retries with backoff on a 429 (honoring
Wikipedia's own Retry-After header when it sends one). The first version
of this fetched one element at a time with no delay between requests and
got rate-limited by Wikimedia on a real run — this replaced it.
Caveat: this was written and tested without live access to Wikipedia
(sandboxed here with no route to en.wikipedia.org) — exercised
structurally (see --dry-run-tts), the redirect/normalization-chain
resolution logic and the 429-retry path both unit-tested against
simulated responses, but never against the real API's actual response
shape or real image content. Skips are per-element and non-fatal — one
bad/missing photo won't abort the rest of the deck — and every fetch
attempt prints what it did, so check that output the first time you
actually run this deck for real.
Element data: Bowserinator/Periodic-Table-JSON (a widely used, actively
maintained public dataset), fetched and spot-checked against known facts
before being embedded below — not typed from memory.
Usage (run with the venv from anki-deck-math.py's docstring activated;
also needs `pip install pillow` for resizing fetched photos):
python3 anki-deck-periodic.py --deck prehs
python3 anki-deck-periodic.py --deck hs
python3 anki-deck-periodic.py --deck hs --no-images
python3 anki-deck-periodic.py --deck category
(add --voice en_US-amy-medium etc.; --model-path if a voice isn't found
automatically; --dry-run-tts to test the deck-building logic without any
voice model OR network access at all, using silent placeholder audio and
a placeholder image)
"""
import argparse
import hashlib
import io
import genanki
import json
import os
import random
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
from PIL import Image
parser = argparse.ArgumentParser()
parser.add_argument("--deck", required=True, choices=["prehs", "hs", "category"])
parser.add_argument("--voice", default="en_US-lessac-medium")
parser.add_argument("--model-path", default=None)
parser.add_argument("--dry-run-tts", action="store_true")
parser.add_argument("--no-images", action="store_true",
help="prehs/hs only: skip fetching element photos, keep the old text-only cards")
args = parser.parse_args()
SCRATCH = os.path.dirname(os.path.abspath(__file__))
IMAGE_CACHE = os.path.join(SCRATCH, "periodic_images")
os.makedirs(IMAGE_CACHE, exist_ok=True)
# Elements with no macroscopic sample ever produced — see the docstring.
NO_PHOTO_NUMBERS = set(range(100, 119))
# Element names whose plain Wikipedia article title is a different, more
# famous topic — see the docstring. Add to this if a generated card shows
# an obviously wrong photo for some element.
TITLE_OVERRIDES = {"Hg": "Mercury (element)"}
USER_AGENT = "anki-deck-periodic.py/1.0 (personal Anki deck generator, run locally by its owner)"
def http_get_with_retry(url, max_retries=5):
"""GET with retry-on-429: honors a numeric Retry-After header if
Wikipedia sends one, otherwise backs off 5s * attempt. Returns the raw
response bytes, or None if every attempt failed — never raises, since
one element's fetch failing must not abort the whole deck build."""
headers = {"User-Agent": USER_AGENT}
for attempt in range(1, max_retries + 1):
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=20) as resp:
return resp.read()
except urllib.error.HTTPError as e:
if e.code == 429 and attempt < max_retries:
wait = 5 * attempt
retry_after = e.headers.get("Retry-After") if e.headers else None
if retry_after and retry_after.isdigit():
wait = int(retry_after)
print(f" (rate limited, waiting {wait}s before retry {attempt}/{max_retries})")
time.sleep(wait)
continue
print(f" (request failed: {e})")
return None
except (urllib.error.URLError, OSError, ValueError) as e:
print(f" (request failed: {e})")
return None
return None
def resolve_pageimage_urls(pairs):
"""pairs: list of (symbol, wikipedia_title). Batches lookups — up to
50 titles per MediaWiki query, the documented anonymous-access limit —
instead of one API call per element; a tight one-request-per-element
loop is exactly what triggered Wikipedia's rate limiting on a real
run. Requests thumbnails (piprop=thumbnail), not full-resolution
originals, per Wikimedia's own guidance on their 429 response. Returns
{symbol: thumbnail_url} for whichever elements actually have one."""
result = {}
CHUNK = 50
for i in range(0, len(pairs), CHUNK):
chunk = pairs[i:i + CHUNK]
symbol_by_title = {title: symbol for symbol, title in chunk}
titles_param = "|".join(title for _, title in chunk)
api_url = ("https://en.wikipedia.org/w/api.php?action=query&format=json"
"&prop=pageimages&piprop=thumbnail&pithumbsize=300&redirects=1&titles="
+ urllib.parse.quote(titles_param))
raw = http_get_with_retry(api_url)
if raw is None:
continue
data = json.loads(raw)
query = data.get("query", {})
# "pages" below is keyed by pageid with only the *final* resolved
# title on it, so build a title-at-this-point -> original-input-
# title map and walk it forward through each normalized/redirect
# step, re-keying as the title changes, to reach the same result.
input_of_title = {title: title for _, title in chunk}
for step in query.get("normalized", []) + query.get("redirects", []):
frm, to = step["from"], step["to"]
if frm in input_of_title:
input_of_title[to] = input_of_title.pop(frm)
for page in query.get("pages", {}).values():
final_title = page.get("title")
input_title = input_of_title.get(final_title, final_title)
symbol = symbol_by_title.get(input_title)
if symbol is None:
continue
thumb_url = page.get("thumbnail", {}).get("source")
if thumb_url:
result[symbol] = thumb_url
if i + CHUNK < len(pairs):
time.sleep(1) # be polite between batches
return result
def download_element_photos(url_map):
"""url_map: {symbol: thumbnail_url}. Downloads each into
periodic_images/, one request at a time with a short gap between —
the metadata lookups above are batched, but the actual image bytes
still need one HTTP request per element, and Wikimedia's upload
servers rate-limit that too if hit back-to-back with no gap."""
for symbol, url in url_map.items():
cache_path = os.path.join(IMAGE_CACHE, f"{symbol}.jpg")
if os.path.isfile(cache_path):
continue
raw = http_get_with_retry(url)
if raw is None:
print(f" (photo download failed for {symbol})")
continue
try:
img = Image.open(io.BytesIO(raw)).convert("RGB")
img.thumbnail((300, 300))
img.save(cache_path, "JPEG", quality=85)
print(f" fetched photo for {symbol}")
except OSError as e:
print(f" (photo decode failed for {symbol}: {e})")
time.sleep(0.5)
def ensure_element_photos(elements):
"""Call once per deck, before building any notes: makes sure every
eligible element (not --no-images, not NO_PHOTO_NUMBERS, not already
cached) has its photo downloaded into periodic_images/ up front —
batched and rate-limited, rather than the old one-request-per-card
approach that got 429'd on a real run. element_image_html() below then
only ever reads the cache; it makes no network calls itself."""
if args.no_images:
return
needed = [(symbol, name) for number, symbol, name, _cat in elements
if number not in NO_PHOTO_NUMBERS
and not os.path.isfile(os.path.join(IMAGE_CACHE, f"{symbol}.jpg"))]
if not needed:
return
if args.dry_run_tts:
# No real network call in dry-run mode — a flat placeholder lets the
# rest of the pipeline (HTML wiring, media_files list, .apkg
# packaging) still be exercised end-to-end without it.
for symbol, _name in needed:
cache_path = os.path.join(IMAGE_CACHE, f"{symbol}.jpg")
Image.new("RGB", (100, 100), (190, 190, 190)).save(cache_path, "JPEG")
return
pairs = [(symbol, TITLE_OVERRIDES.get(symbol, name)) for symbol, name in needed]
print(f"Fetching {len(pairs)} element photo(s) from Wikipedia (batched, rate-limited)...")
url_map = resolve_pageimage_urls(pairs)
for symbol, _title in pairs:
if symbol not in url_map:
print(f" (no photo found for {symbol})")
download_element_photos(url_map)
_CANDIDATES = [
args.model_path,
f"{args.voice}.onnx",
os.path.join(SCRATCH, f"{args.voice}.onnx"),
os.path.expanduser(f"~/{args.voice}.onnx"),
os.path.expanduser(f"~/.local/share/piper/voices/{args.voice}.onnx"),
]
VOICE_MODEL = next((p for p in _CANDIDATES if p and os.path.isfile(p)), None)
if VOICE_MODEL is None and not args.dry_run_tts:
raise SystemExit(
f"Voice model for '{args.voice}' not found. Checked:\n"
+ "\n".join(f" {p}" for p in _CANDIDATES if p)
+ f"\n\nFind it with: find / -iname '{args.voice}.onnx' 2>/dev/null"
+ "\nThen pass its exact path with --model-path /the/real/path.onnx"
+ "\n(or pass --dry-run-tts to test deck-building without any voice at all)"
)
def piper_tts(text: str, out_path: str) -> None:
if args.dry_run_tts:
with open(out_path, "wb") as f:
f.write(
b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
b"\x22\x56\x00\x00\x44\xac\x00\x00\x02\x00\x10\x00data\x00\x00\x00\x00"
)
return
subprocess.run(
["python3", "-m", "piper", "-m", VOICE_MODEL, "-f", out_path],
input=text.encode("utf-8"),
check=True,
capture_output=True,
)
ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven",
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen"]
TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
"eighty", "ninety"]
def num2words(n):
if n < 20:
return ONES[n]
if n < 100:
t, o = divmod(n, 10)
return TENS[t] + ("-" + ONES[o] if o else "")
h, rem = divmod(n, 100)
return ONES[h] + " hundred" + (" " + num2words(rem) if rem else "")
assert num2words(1) == "one"
assert num2words(26) == "twenty-six"
assert num2words(118) == "one hundred eighteen"
# ─── Element data: (atomic_number, symbol, name, category-or-None) ─────────
# category is None for the 8 most recently synthesized superheavy elements
# whose chemical category is still officially unconfirmed (excluded from
# the category deck below, still included in prehs/hs symbol/name/number).
ELEMENTS = [
(1, 'H', 'Hydrogen', 'diatomic nonmetal'),
(2, 'He', 'Helium', 'noble gas'),
(3, 'Li', 'Lithium', 'alkali metal'),
(4, 'Be', 'Beryllium', 'alkaline earth metal'),
(5, 'B', 'Boron', 'metalloid'),
(6, 'C', 'Carbon', 'polyatomic nonmetal'),
(7, 'N', 'Nitrogen', 'diatomic nonmetal'),
(8, 'O', 'Oxygen', 'diatomic nonmetal'),
(9, 'F', 'Fluorine', 'diatomic nonmetal'),
(10, 'Ne', 'Neon', 'noble gas'),
(11, 'Na', 'Sodium', 'alkali metal'),
(12, 'Mg', 'Magnesium', 'alkaline earth metal'),
(13, 'Al', 'Aluminium', 'post-transition metal'),
(14, 'Si', 'Silicon', 'metalloid'),
(15, 'P', 'Phosphorus', 'polyatomic nonmetal'),
(16, 'S', 'Sulfur', 'polyatomic nonmetal'),
(17, 'Cl', 'Chlorine', 'diatomic nonmetal'),
(18, 'Ar', 'Argon', 'noble gas'),
(19, 'K', 'Potassium', 'alkali metal'),
(20, 'Ca', 'Calcium', 'alkaline earth metal'),
(21, 'Sc', 'Scandium', 'transition metal'),
(22, 'Ti', 'Titanium', 'transition metal'),
(23, 'V', 'Vanadium', 'transition metal'),
(24, 'Cr', 'Chromium', 'transition metal'),
(25, 'Mn', 'Manganese', 'transition metal'),
(26, 'Fe', 'Iron', 'transition metal'),
(27, 'Co', 'Cobalt', 'transition metal'),
(28, 'Ni', 'Nickel', 'transition metal'),
(29, 'Cu', 'Copper', 'transition metal'),
(30, 'Zn', 'Zinc', 'transition metal'),
(31, 'Ga', 'Gallium', 'post-transition metal'),
(32, 'Ge', 'Germanium', 'metalloid'),
(33, 'As', 'Arsenic', 'metalloid'),
(34, 'Se', 'Selenium', 'polyatomic nonmetal'),
(35, 'Br', 'Bromine', 'diatomic nonmetal'),
(36, 'Kr', 'Krypton', 'noble gas'),
(37, 'Rb', 'Rubidium', 'alkali metal'),
(38, 'Sr', 'Strontium', 'alkaline earth metal'),
(39, 'Y', 'Yttrium', 'transition metal'),
(40, 'Zr', 'Zirconium', 'transition metal'),
(41, 'Nb', 'Niobium', 'transition metal'),
(42, 'Mo', 'Molybdenum', 'transition metal'),
(43, 'Tc', 'Technetium', 'transition metal'),
(44, 'Ru', 'Ruthenium', 'transition metal'),
(45, 'Rh', 'Rhodium', 'transition metal'),
(46, 'Pd', 'Palladium', 'transition metal'),
(47, 'Ag', 'Silver', 'transition metal'),
(48, 'Cd', 'Cadmium', 'transition metal'),
(49, 'In', 'Indium', 'post-transition metal'),
(50, 'Sn', 'Tin', 'post-transition metal'),
(51, 'Sb', 'Antimony', 'metalloid'),
(52, 'Te', 'Tellurium', 'metalloid'),
(53, 'I', 'Iodine', 'diatomic nonmetal'),
(54, 'Xe', 'Xenon', 'noble gas'),
(55, 'Cs', 'Cesium', 'alkali metal'),
(56, 'Ba', 'Barium', 'alkaline earth metal'),
(57, 'La', 'Lanthanum', 'lanthanide'),
(58, 'Ce', 'Cerium', 'lanthanide'),
(59, 'Pr', 'Praseodymium', 'lanthanide'),
(60, 'Nd', 'Neodymium', 'lanthanide'),
(61, 'Pm', 'Promethium', 'lanthanide'),
(62, 'Sm', 'Samarium', 'lanthanide'),
(63, 'Eu', 'Europium', 'lanthanide'),
(64, 'Gd', 'Gadolinium', 'lanthanide'),
(65, 'Tb', 'Terbium', 'lanthanide'),
(66, 'Dy', 'Dysprosium', 'lanthanide'),
(67, 'Ho', 'Holmium', 'lanthanide'),
(68, 'Er', 'Erbium', 'lanthanide'),
(69, 'Tm', 'Thulium', 'lanthanide'),
(70, 'Yb', 'Ytterbium', 'lanthanide'),
(71, 'Lu', 'Lutetium', 'lanthanide'),
(72, 'Hf', 'Hafnium', 'transition metal'),
(73, 'Ta', 'Tantalum', 'transition metal'),
(74, 'W', 'Tungsten', 'transition metal'),
(75, 'Re', 'Rhenium', 'transition metal'),
(76, 'Os', 'Osmium', 'transition metal'),
(77, 'Ir', 'Iridium', 'transition metal'),
(78, 'Pt', 'Platinum', 'transition metal'),
(79, 'Au', 'Gold', 'transition metal'),
(80, 'Hg', 'Mercury', 'transition metal'),
(81, 'Tl', 'Thallium', 'post-transition metal'),
(82, 'Pb', 'Lead', 'post-transition metal'),
(83, 'Bi', 'Bismuth', 'post-transition metal'),
(84, 'Po', 'Polonium', 'post-transition metal'),
(85, 'At', 'Astatine', 'diatomic nonmetal'),
(86, 'Rn', 'Radon', 'noble gas'),
(87, 'Fr', 'Francium', 'alkali metal'),
(88, 'Ra', 'Radium', 'alkaline earth metal'),
(89, 'Ac', 'Actinium', 'actinide'),
(90, 'Th', 'Thorium', 'actinide'),
(91, 'Pa', 'Protactinium', 'actinide'),
(92, 'U', 'Uranium', 'actinide'),
(93, 'Np', 'Neptunium', 'actinide'),
(94, 'Pu', 'Plutonium', 'actinide'),
(95, 'Am', 'Americium', 'actinide'),
(96, 'Cm', 'Curium', 'actinide'),
(97, 'Bk', 'Berkelium', 'actinide'),
(98, 'Cf', 'Californium', 'actinide'),
(99, 'Es', 'Einsteinium', 'actinide'),
(100, 'Fm', 'Fermium', 'actinide'),
(101, 'Md', 'Mendelevium', 'actinide'),
(102, 'No', 'Nobelium', 'actinide'),
(103, 'Lr', 'Lawrencium', 'actinide'),
(104, 'Rf', 'Rutherfordium', 'transition metal'),
(105, 'Db', 'Dubnium', 'transition metal'),
(106, 'Sg', 'Seaborgium', 'transition metal'),
(107, 'Bh', 'Bohrium', 'transition metal'),
(108, 'Hs', 'Hassium', 'transition metal'),
(109, 'Mt', 'Meitnerium', None),
(110, 'Ds', 'Darmstadtium', None),
(111, 'Rg', 'Roentgenium', None),
(112, 'Cn', 'Copernicium', None),
(113, 'Nh', 'Nihonium', 'post-transition metal'),
(114, 'Fl', 'Flerovium', 'post-transition metal'),
(115, 'Mc', 'Moscovium', None),
(116, 'Lv', 'Livermorium', None),
(117, 'Ts', 'Tennessine', None),
(118, 'Og', 'Oganesson', None),
]
assert len(ELEMENTS) == 118
assert [e[0] for e in ELEMENTS] == list(range(1, 119))
assert ELEMENTS[0] == (1, 'H', 'Hydrogen', 'diatomic nonmetal')
assert ELEMENTS[25] == (26, 'Fe', 'Iron', 'transition metal')
assert ELEMENTS[-1] == (118, 'Og', 'Oganesson', None)
ALL_CATEGORIES = sorted({e[3] for e in ELEMENTS if e[3] is not None})
def build_model(deck_key):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
model_id = 1_700_000_000 + (voice_hash % 90_000_000)
return model_id, genanki.Model(
model_id,
f"Periodic Table ({deck_key}, {args.voice})",
fields=[{"name": "Prompt"}, {"name": "Answer"}, {"name": "QSound"}, {"name": "ASound"}],
templates=[{
"name": "Card",
"qfmt": """
<div class="prompt">{{Prompt}}</div>
{{QSound}}
{{type:Answer}}
""",
"afmt": """
<div class="prompt">{{Prompt}}</div>
<hr id="answer">
{{type:Answer}}
{{ASound}}
""",
}],
css="""
.card { font-family: Arial, sans-serif; font-size: 26px; text-align: center; }
.prompt { font-size: 40px; margin: 20px auto; white-space: pre-line; }
.elem-img { max-width: 220px; max-height: 220px; display: block; margin: 0 auto 10px; }
""",
)
def build_deck(deck_key, deck_title):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
deck_id = 2_100_000_000 + (voice_hash % 90_000_000)
return genanki.Deck(deck_id, deck_title)
def element_image_html(number, symbol, media_files):
"""Returns an <img> tag for this element's cached photo, or "" if
--no-images was passed, the element is in NO_PHOTO_NUMBERS, or no
photo was found/downloaded for it. Reads the cache only — every
network call happens up front in ensure_element_photos(), once per
deck, not per card."""
if args.no_images or number in NO_PHOTO_NUMBERS:
return ""
cache_path = os.path.join(IMAGE_CACHE, f"{symbol}.jpg")
if not os.path.isfile(cache_path):
return ""
if cache_path not in media_files:
media_files.append(cache_path)
return f'<img class="elem-img" src="{os.path.basename(cache_path)}">'
def add_note(deck, model, prompt, answer, qtext, atext, media_files, tag):
qfile = f"q_{tag}.wav"
afile = f"a_{tag}.wav"
qpath = os.path.join(MEDIA_DIR, qfile)
apath = os.path.join(MEDIA_DIR, afile)
piper_tts(qtext, qpath)
piper_tts(atext, apath)
media_files += [qpath, apath]
deck.add_note(genanki.Note(
model=model,
fields=[prompt, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
))
def gen_prehs():
deck_key = "periodic_prehs"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Periodic Table: Symbols & Names (1-36)")
media_files = []
subset = [e for e in ELEMENTS if e[0] <= 36]
ensure_element_photos(subset)
cards = []
for number, symbol, name, category in subset:
cards.append(("symbol_to_name", number, symbol, name))
cards.append(("name_to_symbol", number, symbol, name))
random.seed(50)
random.shuffle(cards)
for kind, number, symbol, name in cards:
img = element_image_html(number, symbol, media_files)
if kind == "symbol_to_name":
add_note(deck, model, img + symbol, name,
f"What element has the symbol {symbol}?", name,
media_files, f"prehs_s2n_{number}")
else:
add_note(deck, model, img + name, symbol,
f"What is the symbol for {name}?", symbol,
media_files, f"prehs_n2s_{number}")
return deck, media_files, len(cards)
def gen_hs():
deck_key = "periodic_hs"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Periodic Table: Symbols, Names & Numbers (1-118)")
media_files = []
ensure_element_photos(ELEMENTS)
cards = []
for number, symbol, name, category in ELEMENTS:
cards.append(("symbol_to_name", number, symbol, name))
cards.append(("name_to_symbol", number, symbol, name))
cards.append(("number_to_symbol", number, symbol, name))
random.seed(51)
random.shuffle(cards)
for kind, number, symbol, name in cards:
img = element_image_html(number, symbol, media_files)
if kind == "symbol_to_name":
add_note(deck, model, img + symbol, name,
f"What element has the symbol {symbol}?", name,
media_files, f"hs_s2n_{number}")
elif kind == "name_to_symbol":
add_note(deck, model, img + name, symbol,
f"What is the symbol for {name}?", symbol,
media_files, f"hs_n2s_{number}")
else:
add_note(deck, model, img + f"Element #{number}", symbol,
f"What is the symbol for element number {num2words(number)}?", symbol,
media_files, f"hs_num2s_{number}")
return deck, media_files, len(cards)
def gen_category():
deck_key = "periodic_category"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Periodic Table: Element Categories (multiple choice)")
media_files = []
subset = [e for e in ELEMENTS if e[3] is not None]
random.seed(52)
shuffled = subset[:]
random.shuffle(shuffled)
letters = ["A", "B", "C", "D"]
for number, symbol, name, category in shuffled:
distractor_pool = [c for c in ALL_CATEGORIES if c != category]
distractors = random.sample(distractor_pool, 3)
choices = distractors + [category]
random.shuffle(choices)
correct_letter = letters[choices.index(category)]
prompt_lines = [f"{name} ({symbol})", ""]
for letter, choice in zip(letters, choices):
prompt_lines.append(f"{letter}) {choice}")
prompt = "\n".join(prompt_lines)
qtext = f"What category is {name}?"
atext = f"{category}"
add_note(deck, model, prompt, correct_letter, qtext, atext,
media_files, f"cat_{number}")
return deck, media_files, len(subset)
if args.deck == "prehs":
deck_key = "periodic_prehs"
elif args.deck == "hs":
deck_key = "periodic_hs"
else:
deck_key = "periodic_category"
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
os.makedirs(MEDIA_DIR, exist_ok=True)
GENERATORS = {"prehs": gen_prehs, "hs": gen_hs, "category": gen_category}
deck, media_files, count = GENERATORS[args.deck]()
package = genanki.Package(deck)
package.media_files = media_files
out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
package.write_to_file(out_path)
size_mb = os.path.getsize(out_path) / (1024 * 1024)
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} media files)")
+503
View File
@@ -0,0 +1,503 @@
#!/usr/bin/env python3
"""tools/anki-deck-visual.py — Generate image-based Anki decks (.apkg) for
shapes, clocks, and coin-counting, with Anki's built-in type-the-answer
input and Piper (offline, local neural TTS) audio. See
tools/anki-deck-math.py's docstring for one-time setup (venv, genanki +
piper-tts, downloading a voice) — same steps apply here, plus one more
package this script alone needs: `pip install pillow` (for drawing the
images — see the note below on PNG vs SVG for why).
All images are drawn programmatically (regular-polygon geometry,
clock-hand trigonometry, coin layouts) rather than AI-generated — image
generation (local or cloud) is a poor fit for content that has to be
exactly correct (an exact clock time, an exact side count, an exact coin
total), not just plausible-looking. See this script's own point/angle
generation functions for how each shape's geometry is computed directly
rather than approximated.
Rendered as PNG (via Pillow), not SVG — AnkiDroid has a long-documented
history of unreliable SVG rendering (multiple open ankidroid/Anki-Android
GitHub issues going back years: some SVGs render, some silently don't,
with no clear pattern tied to how the file itself is written). PNG has no
such history on any Anki client. Confirmed live: an earlier SVG-based
version of this script produced images that displayed fine on desktop
Anki but never appeared at all on a mobile client.
Decks:
shapes regular polygons (3-10 sides, image->name and name->sides)
plus 5 quadrilateral types (image->name: square, rectangle,
rhombus, trapezoid, parallelogram — each one's geometry is
genuinely distinct, not just differently labeled)
shapes_mc same shape images as "shapes", but as multiple choice
(A/B/C/D shown as plain text below the image — not a
clickable UI, since that needs a desktop-only Anki add-on
and would break on AnkiDroid/AnkiMobile — same approach as
tools/anki-deck-periodic.py's "category" deck), type the
letter
clocks analog clock faces, all 144 hour/5-min combinations,
type the time as H:MM (the hour hand moves fractionally
with the minutes, e.g. 6:30 sits halfway between 6 and 7 —
a static hour hand is the most common "looks right but
teaches wrong" bug in generated clock faces)
currency US coins (nickel/dime/quarter — no pennies, since they're
barely used day to day at this point), 1-4 coins per card,
type the total in cents
Usage (run with the venv from anki-deck-math.py's docstring activated):
python3 anki-deck-visual.py --deck shapes
python3 anki-deck-visual.py --deck shapes_mc
python3 anki-deck-visual.py --deck clocks
python3 anki-deck-visual.py --deck currency
(add --voice en_US-amy-medium etc.; --model-path if a voice isn't found
automatically; --dry-run-tts to test the deck-building logic without any
voice model at all, using silent placeholder audio)
"""
import argparse
import hashlib
import genanki
import math
import os
import random
from PIL import Image, ImageDraw, ImageFont
import subprocess
parser = argparse.ArgumentParser()
parser.add_argument("--deck", required=True,
choices=["shapes", "shapes_mc", "clocks", "currency"])
parser.add_argument("--voice", default="en_US-lessac-medium")
parser.add_argument("--model-path", default=None)
parser.add_argument("--dry-run-tts", action="store_true")
args = parser.parse_args()
SCRATCH = os.path.dirname(os.path.abspath(__file__))
_CANDIDATES = [
args.model_path,
f"{args.voice}.onnx",
os.path.join(SCRATCH, f"{args.voice}.onnx"),
os.path.expanduser(f"~/{args.voice}.onnx"),
os.path.expanduser(f"~/.local/share/piper/voices/{args.voice}.onnx"),
]
VOICE_MODEL = next((p for p in _CANDIDATES if p and os.path.isfile(p)), None)
if VOICE_MODEL is None and not args.dry_run_tts:
raise SystemExit(
f"Voice model for '{args.voice}' not found. Checked:\n"
+ "\n".join(f" {p}" for p in _CANDIDATES if p)
+ f"\n\nFind it with: find / -iname '{args.voice}.onnx' 2>/dev/null"
+ "\nThen pass its exact path with --model-path /the/real/path.onnx"
+ "\n(or pass --dry-run-tts to test deck-building without any voice at all)"
)
def piper_tts(text: str, out_path: str) -> None:
if args.dry_run_tts:
with open(out_path, "wb") as f:
f.write(
b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
b"\x22\x56\x00\x00\x44\xac\x00\x00\x02\x00\x10\x00data\x00\x00\x00\x00"
)
return
subprocess.run(
["python3", "-m", "piper", "-m", VOICE_MODEL, "-f", out_path],
input=text.encode("utf-8"),
check=True,
capture_output=True,
)
ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven",
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen"]
TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
"eighty", "ninety"]
def num2words(n):
if n < 20:
return ONES[n]
if n < 100:
t, o = divmod(n, 10)
return TENS[t] + ("-" + ONES[o] if o else "")
h, rem = divmod(n, 100)
return ONES[h] + " hundred" + (" " + num2words(rem) if rem else "")
assert num2words(15) == "fifteen"
assert num2words(40) == "forty"
def time_words(hour, minute):
"""3, 5 -> 'three oh five'; 3, 15 -> 'three fifteen'; 3, 0 -> 'three o'clock'."""
if minute == 0:
return f"{num2words(hour)} o'clock"
if minute < 10:
return f"{num2words(hour)} oh {num2words(minute)}"
return f"{num2words(hour)} {num2words(minute)}"
assert time_words(3, 0) == "three o'clock"
assert time_words(3, 5) == "three oh five"
assert time_words(3, 15) == "three fifteen"
assert time_words(12, 45) == "twelve forty-five"
def build_model(deck_key):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
model_id = 1_800_000_000 + (voice_hash % 90_000_000)
return model_id, genanki.Model(
model_id,
f"Visual Fact ({deck_key}, {args.voice})",
fields=[{"name": "Image"}, {"name": "Answer"}, {"name": "QSound"}, {"name": "ASound"}],
templates=[{
"name": "Card",
"qfmt": """
<div class="imgwrap">{{Image}}</div>
{{QSound}}
{{type:Answer}}
""",
"afmt": """
<div class="imgwrap">{{Image}}</div>
<hr id="answer">
{{type:Answer}}
{{ASound}}
""",
}],
css="""
.card { font-family: Arial, sans-serif; font-size: 24px; text-align: center; }
.imgwrap { margin: 10px auto; }
.imgwrap img { max-width: 260px; max-height: 260px; }
.mc-choices { display: inline-block; text-align: left; margin-top: 14px; font-size: 22px; }
.mc-choices div { margin: 4px 0; }
""",
)
def build_deck(deck_key, deck_title):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
deck_id = 2_200_000_000 + (voice_hash % 90_000_000)
return genanki.Deck(deck_id, deck_title)
def add_note(deck, model, image_html, answer, qtext, atext, media_files, tag):
qfile = f"q_{tag}.wav"
afile = f"a_{tag}.wav"
qpath = os.path.join(MEDIA_DIR, qfile)
apath = os.path.join(MEDIA_DIR, afile)
piper_tts(qtext, qpath)
piper_tts(atext, apath)
media_files += [qpath, apath]
deck.add_note(genanki.Note(
model=model,
fields=[image_html, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
))
def add_text_note(deck, model, text, answer, qtext, atext, media_files, tag):
"""For directions that don't need an image (e.g. name -> number of sides)."""
add_note(deck, model, f'<div style="font-size:36px;">{text}</div>', answer,
qtext, atext, media_files, tag)
# ─── PNG generation (Pillow) ─────────────────────────────────────────────────
_FONT_CACHE = {}
_FONT_PATH_CANDIDATES = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
]
def load_font(size):
"""Loads a real bold TTF font if one of the common Debian/Ubuntu paths
exists (DejaVu Sans Bold ships with fonts-dejavu-core, a very common
baseline package); falls back to Pillow's own scalable default font
otherwise, so this never crashes even if no system font is found."""
if size in _FONT_CACHE:
return _FONT_CACHE[size]
for path in _FONT_PATH_CANDIDATES:
if os.path.isfile(path):
font = ImageFont.truetype(path, size)
_FONT_CACHE[size] = font
return font
font = ImageFont.load_default(size=size)
_FONT_CACHE[size] = font
return font
def draw_text_centered(draw, xy, text, font, fill="black"):
bbox = draw.textbbox((0, 0), text, font=font)
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
x, y = xy
draw.text((x - w / 2 - bbox[0], y - h / 2 - bbox[1]), text, font=font, fill=fill)
def save_png(filename, draw_fn, size=(200, 200)):
img = Image.new("RGB", size, "white")
draw = ImageDraw.Draw(img)
draw_fn(draw)
path = os.path.join(MEDIA_DIR, filename)
img.save(path)
return path
def regular_polygon_points(n_sides, cx=100, cy=100, r=80):
points = []
# Start pointing up (-90deg) so shapes sit "upright" rather than vertex-right.
start_angle = -90
for i in range(n_sides):
angle_deg = start_angle + i * (360 / n_sides)
angle_rad = math.radians(angle_deg)
x = cx + r * math.cos(angle_rad)
y = cy + r * math.sin(angle_rad)
points.append((round(x, 1), round(y, 1)))
return points
def polygon_png(points, filename):
def draw_fn(draw):
draw.polygon(points, fill=(111, 168, 220), outline=(28, 69, 135), width=4)
return save_png(filename, draw_fn)
POLYGON_NAMES = {
3: "triangle", 4: "square", 5: "pentagon", 6: "hexagon", 7: "heptagon",
8: "octagon", 9: "nonagon", 10: "decagon",
}
QUADRILATERALS = {
"square": [(50, 50), (150, 50), (150, 150), (50, 150)],
"rectangle": [(30, 60), (170, 60), (170, 140), (30, 140)],
"rhombus": [(100, 20), (170, 100), (100, 180), (30, 100)],
"trapezoid": [(60, 60), (140, 60), (170, 140), (30, 140)],
"parallelogram": [(60, 60), (160, 60), (140, 140), (40, 140)],
}
def clock_png(hour, minute, filename):
cx, cy, r = 100, 100, 90
minute_angle = minute * 6 - 90
hour_angle = (hour % 12) * 30 + minute * 0.5 - 90
def draw_fn(draw):
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill="white", outline="black", width=3)
font = load_font(15)
for h in range(1, 13):
angle = math.radians(h * 30 - 90)
tx1, ty1 = cx + (r - 10) * math.cos(angle), cy + (r - 10) * math.sin(angle)
tx2, ty2 = cx + r * math.cos(angle), cy + r * math.sin(angle)
draw.line([(tx1, ty1), (tx2, ty2)], fill="black", width=2)
nx, ny = cx + (r - 22) * math.cos(angle), cy + (r - 22) * math.sin(angle)
draw_text_centered(draw, (nx, ny), str(h), font)
def hand(angle_deg, length, width):
rad = math.radians(angle_deg)
x2, y2 = cx + length * math.cos(rad), cy + length * math.sin(rad)
draw.line([(cx, cy), (x2, y2)], fill="black", width=width)
hand(hour_angle, 45, 6)
hand(minute_angle, 70, 4)
draw.ellipse([cx - 4, cy - 4, cx + 4, cy + 4], fill="black")
return save_png(filename, draw_fn)
COIN_INFO = {5: ("#c0c0c0", ""), 10: ("#d9d9d9", "10¢"), 25: ("#b8b8b8", "25¢")}
COIN_NAMES = {5: "nickel", 10: "dime", 25: "quarter"}
def coins_png(coin_values, filename):
n = len(coin_values)
spacing = 200 // (n + 1)
def draw_fn(draw):
font = load_font(14)
for i, v in enumerate(coin_values):
cx = spacing * (i + 1)
color, label = COIN_INFO[v]
radius = 30 if v == 25 else (26 if v == 10 else 28)
draw.ellipse([cx - radius, 100 - radius, cx + radius, 100 + radius],
fill=color, outline=(68, 68, 68), width=2)
draw_text_centered(draw, (cx, 100), label, font)
return save_png(filename, draw_fn)
def coin_list_words(coin_values):
names = [COIN_NAMES[v] for v in coin_values]
if len(names) == 1:
return f"a {names[0]}"
if len(names) == 2:
return f"a {names[0]} and a {names[1]}"
return ", ".join(f"a {n}" for n in names[:-1]) + f", and a {names[-1]}"
# ─── Per-deck generators ─────────────────────────────────────────────────────
def gen_shapes():
deck_key = "shapes"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Shapes: Polygons & Quadrilaterals")
media_files = []
jobs = []
for n in range(3, 11):
jobs.append(("polygon_image", n))
jobs.append(("polygon_sides", n))
for qname in QUADRILATERALS:
jobs.append(("quad_image", qname))
random.seed(60)
random.shuffle(jobs)
for kind, val in jobs:
if kind == "polygon_image":
n = val
name = POLYGON_NAMES[n]
png_path = polygon_png(regular_polygon_points(n), f"poly_{n}.png")
media_files.append(png_path)
add_note(deck, model, f'<img src="poly_{n}.png">', name,
"What shape is this?", name, media_files, f"shape_img_{n}")
elif kind == "polygon_sides":
n = val
name = POLYGON_NAMES[n]
add_text_note(deck, model, name.capitalize(), str(n),
f"How many sides does a {name} have?", num2words(n),
media_files, f"shape_sides_{n}")
else:
qname = val
png_path = polygon_png(QUADRILATERALS[qname], f"quad_{qname}.png")
media_files.append(png_path)
add_note(deck, model, f'<img src="quad_{qname}.png">', qname,
"What shape is this?", qname, media_files, f"shape_quad_{qname}")
return deck, media_files, len(jobs)
def gen_shapes_mc():
"""Same shape images as gen_shapes(), but multiple choice instead of
type-the-name — A/B/C/D distractors drawn from every other shape name
in the pool (polygons and quadrilaterals share one distractor pool, so
a polygon question can pull "square" as a wrong answer and vice versa)."""
deck_key = "shapes_mc"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Shapes: Polygons & Quadrilaterals (multiple choice)")
media_files = []
jobs = [("polygon", n, POLYGON_NAMES[n]) for n in range(3, 11)]
jobs += [("quad", qname, qname) for qname in QUADRILATERALS]
all_names = [name for _, _, name in jobs]
random.seed(63)
random.shuffle(jobs)
letters = ["A", "B", "C", "D"]
for kind, val, name in jobs:
if kind == "polygon":
png_path = polygon_png(regular_polygon_points(val), f"mc_poly_{val}.png")
tag = f"shape_mc_poly_{val}"
else:
png_path = polygon_png(QUADRILATERALS[val], f"mc_quad_{val}.png")
tag = f"shape_mc_quad_{val}"
media_files.append(png_path)
distractor_pool = [n for n in all_names if n != name]
distractors = random.sample(distractor_pool, 3)
choices = distractors + [name]
random.shuffle(choices)
correct_letter = letters[choices.index(name)]
choice_html = "".join(f"<div>{letter}) {choice}</div>"
for letter, choice in zip(letters, choices))
image_html = (f'<img src="{os.path.basename(png_path)}">'
f'<div class="mc-choices">{choice_html}</div>')
add_note(deck, model, image_html, correct_letter,
"What shape is this?", name, media_files, tag)
return deck, media_files, len(jobs)
def gen_clocks():
deck_key = "clocks"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Telling Time: Analog Clocks")
media_files = []
times = [(h, m) for h in range(1, 13) for m in range(0, 60, 5)]
random.seed(61)
random.shuffle(times)
# The question prompt ("What time is it?") is identical for every card —
# generate it once instead of 144 times.
shared_qfile = "q_clock_prompt.wav"
piper_tts("What time is it?", os.path.join(MEDIA_DIR, shared_qfile))
media_files.append(os.path.join(MEDIA_DIR, shared_qfile))
for hour, minute in times:
png_path = clock_png(hour, minute, f"clock_{hour}_{minute:02d}.png")
media_files.append(png_path)
answer = f"{hour}:{minute:02d}"
afile = f"a_clock_{hour}_{minute:02d}.wav"
apath = os.path.join(MEDIA_DIR, afile)
piper_tts(time_words(hour, minute), apath)
media_files.append(apath)
deck.add_note(genanki.Note(
model=model,
fields=[f'<img src="clock_{hour}_{minute:02d}.png">', answer,
f"[sound:{shared_qfile}]", f"[sound:{afile}]"],
))
return deck, media_files, len(times)
def gen_currency():
deck_key = "currency"
model_id, model = build_model(deck_key)
# Deliberately nickel/dime/quarter only, no pennies — pennies are barely
# used day to day at this point, and skipping them keeps every total a
# multiple of 5 cents, which is a cleaner first pass at coin counting.
deck = build_deck(deck_key, "Counting Coins (nickels, dimes, quarters)")
media_files = []
denoms = [5, 10, 25]
combos = set()
for count in range(1, 5):
def rec(remaining, current):
if remaining == 0:
combos.add(tuple(sorted(current)))
return
for d in denoms:
if not current or d >= current[-1]:
rec(remaining - 1, current + [d])
rec(count, [])
combos = sorted(combos)
random.seed(62)
random.shuffle(combos)
for coin_values in combos:
total = sum(coin_values)
tag = "_".join(map(str, coin_values))
png_path = coins_png(list(coin_values), f"coins_{tag}.png")
media_files.append(png_path)
qtext = f"How much money is {coin_list_words(list(coin_values))}?"
atext = f"{num2words(total)} cents"
add_note(deck, model, f'<img src="coins_{tag}.png">',
str(total), qtext, atext, media_files, f"coins_{tag}")
return deck, media_files, len(combos)
deck_key = args.deck
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
os.makedirs(MEDIA_DIR, exist_ok=True)
GENERATORS = {
"shapes": gen_shapes, "shapes_mc": gen_shapes_mc,
"clocks": gen_clocks, "currency": gen_currency,
}
deck, media_files, count = GENERATORS[args.deck]()
package = genanki.Package(deck)
package.media_files = media_files
out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
package.write_to_file(out_path)
size_mb = os.path.getsize(out_path) / (1024 * 1024)
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} media files)")