Contributor guide. Developer-only (export-ignore), so it never ships in the
addon package.
Keep this file short. It is loaded whole into every session, so anything here
is paid for by every task, including the ones that never touch the subsystem
it describes. Rules and the map belong here; derivations belong in docs/,
and behaviour belongs in the code that implements it.
README.md— what the addon does, for users.docs/kodi-platform-notes.md— where the addon fights Kodi rather than uses it: the device axis, core-setting reads, dispatcher ordering, toast fade-out, infolabel quirks. Read it before touching those.
A Kodi service addon built around one loop: the user fixes lipsync once by adjusting Kodi's audio offset during playback, the addon remembers that value per stream profile, and re-applies it automatically on every matching playback. Seek-back replays, notifications and a management view are trim around that loop.
main is the only branch. An earlier addon, script.audiooffsetmanager
("classic"), is a separate end-of-life project; nothing is shared between
them.
- Zero-config install. No onboarding, test video, capability probe or stored platform flags. An empty store does nothing until the user teaches it, and the save/apply toasts are the tutorial.
- The remembered adjustment is the product. The "editor" is whatever changes Kodi's audio offset during playback: the native slider, a keymap, JSON-RPC. The watcher reads the resulting value and does not care how it was set.
- Capability gating is emergent, not probed. The management view lists only the profiles the platform actually produced.
- Settings are behavior; data is data. Learned offsets live in a JSON
store, never in
settings.xml. Keeping runtime state out of settings avoids a class of dialog save-on-close bugs. - Offsets are authored during playback, where they can be judged. No surface outside playback types a millisecond value.
- The risk to guard against is invisibility. The addon works silently, so both notification defaults are on and carry the teaching load.
Two processes share one file, the offset store.
service.py→aome.runtime.ServiceRuntime, the long-running service. It builds the whole graph with required constructor injection and blocks on Kodi's abort monitor. Subscription order is load-bearing; the runtime docstring says why.script.py→aome.script_router, theRunScripthalf. Routes:manage_offsets,export_offsets/import_offsets,export_log. Anything else opens settings, the hub every view returns to.
All runtime code is under resources/lib/aome/. Each module's docstring owns
its own contract — read those rather than expecting this file to repeat them.
tests/contract/test_architecture.py enforces the layering.
| Package | Role |
|---|---|
domain/ |
Pure decisions. No Kodi, no I/O. Profiles, the stream-state machine, gating policies, format and device-spelling rules. |
store/ |
The sparse offset database. Pure; the file path is injected. Persistence, the key codec, lookup/write semantics, display labels. |
app/ |
Orchestration on the dispatcher thread. Pure. Detection, apply, the learn loop, device polling, seek scheduling, toasts, the mutation channel's service side. |
kodi/ |
The only package allowed to import xbmc*. Single-shot adapters over JSON-RPC, settings, GUI, logging, and the player/monitor bridges. |
view/ |
Script-process surfaces over the store: manage, transfer, log export. |
Learned offsets live in a sparse JSON file
(special://profile/addon_data/<addon id>/offsets.json), never in
settings.xml.
- Single writer. Only the service's dispatcher thread writes the file.
Persistence is atomic; every persist but the restore's rebuild first rotates
the live file onto a
.baksibling, which therefore trails by one write; an unparseable file is quarantined to.badand restored from.bak, or starts empty if that fails too; a newer schema version makes the store read-only rather than risk overwriting it. Schema version 3. Older files load writable and their keys expand through boundary canonicalization — that expansion is the migration. The read-only guard cuts both ways: once a store is v3, 1.1.0 and earlier can read but not write it, so a user who rolls back stops learning. That belongs in release text. - Keys are
hdr|fps|audio|ch|dev, accepted verbatim. The reported string, case-folded and trimmed, is the segment. No whitelist and no substring matching, so a format this code has never seen still works. Add a cross-build alias only for a spelling split actually observed, never speculatively. - Four granularity toggles, one per axis after
hdr, all default off. Each opts its axis into finer keys. Lookup is strict: exactly one candidate key per resolve, no fallback between levels. Flipping a toggle is non-destructive; entries the current mode does not consult go dormant and the manage view tags them.store/resolve.pyis the reference. - Two absences are not interchangeable on the device axis. An unreadable
device keeps the profile incomplete, so the session holds the device it is
keyed on; a deliberately unread one (toggle off) is complete. Only a real
reading that names no device degrades to
all. This is the subtlest rule in the store — seepolicies.is_complete. - Device keys are machine-specific and do not survive export/import to another box. Every other axis is portable.
- A miss does nothing until the addon has acted on the session, then it
zero-resets stale residue.
delete/clearleave reset markers that force 0 at the next resolve, because the deletion is the authorization. - The write key has no history dependence, but a write can be refused. It
is derived at store time from the current profile and toggles, never from
what a lookup hit. A quiesced value is discarded if the stream it was
dialled under moved before it settled
(
AdjustmentWatcher._dialled_stream_unmoved). - Edits take effect immediately. A settings save or store mutation is a
resolve moment. The detector deliberately does not subscribe to
SettingsChanged: a save changes no stream fact, and re-gathering at an arbitrary instant adopts a transiently blank infolabel as a real profile change. Do not add that subscription. delay_msis a verbatim signed integer at 1 ms resolution, bounded only by Kodi's ±10 s. Nothing quantizes or clamps it, so custom-build sliders work as they are.
The script-process views never write the store file. They read through a
read-only reader and ask the service to mutate over a JSONRPC.NotifyAll
channel whitelisted to delete, clear, import and copy_device. There is
no set op and no value field, so the channel structurally cannot carry a
value write. Acks match by request id; no ack means "service not running",
reported to the user with no direct-write fallback. import is replace-all
from a staged backup file, the one sibling the script process may write; no
path and no value travel on the wire. copy_device names a source device
only; the service resolves the destination endpoints itself.
settings.xml holds behavior toggles only.
- The settings object is a live shared proxy, not a snapshot, and only while
its parent
xbmcaddon.Addonstays alive. Keep theAddononself; never read from a throwawayxbmcaddon.Addon(...).getSettings(). - Never write a setting from Python while the settings dialog is open — its
save-on-close clobbers the write. Action buttons that navigate elsewhere use
<close>true</close>; service-side writes go through thestore_*_if_changedhelpers. - Learn and apply are orthogonal, so apply-off with learn-on is the legal re-teach state.
- Tests gate everything.
python -m pytest tests -qtakes a couple of seconds; keep it green before every commit. - Deploying to a test box: script-process files (
script_router,view/*, the store read path) copy over with no Kodi restart; service-side changes need one. - CI (
ci.yml) is manual-dispatch only, since the local suite already gates commits:gh workflow run ci.yml --ref main, thengh run watch.
- Dev tooling is
export-ignore'd (this file,docs/,.github/,tests/,tools/,.claude/). Check what ships withgit archive --format=zip -o /tmp/pkg.zip HEAD. - Targets
xbmc.python3.0.1 (the Kodi Nexus floor), so Python 3.8 syntax. addon.xml<news>is schema-capped at 1500 characters; keep the last couple of versions and let git history hold the rest.
Releases are cut by tagging, and the tag gets a GitHub Release. Betas publish
as pre-releases and fire nothing; publishing a stable Release fires
submit.yml, which submits to Kodi's repo-scripts. The version keeps its ~
suffix until the deliberate final bump. submit.yml refuses any ~ version
as a backstop, so bumping addon.xml to a release version is a prerequisite
of submission, not a side effect of tagging.
-
Docstrings and comments follow PEP 257: a one-line summary, then a short paragraph or a few bullets covering the contract and the load-bearing invariants a maintainer must know. Inline comments explain a surprising choice and stay terse. Four things never go in — each reads as justified when written and only looks like clutter in aggregate, which is why the earlier, softer version of this rule did not hold:
- History. What the code used to do, what was tried and refused, what a
field test measured. Git carries that. State the rule in force; where a
refuted alternative is a live temptation, name it and why not in one
sentence, and put the derivation in
docs/. - Repetition. A rule belongs in exactly one place, at the level that
owns it. If the module docstring states it, the method does not; if the
code plainly shows it, nothing does. This file does not restate module
docstrings, and module docstrings do not restate
docs/. - Pointers the reader cannot follow. Never cite an untracked file —
.gitignorelists the local-only planning docs, and a citation of one resolves on exactly one machine. Citing a tracked doc is fine and is how background stays out of the code. - Consequences that follow from a stated rule. Say the rule and trust the reader.
tests/contract/test_comment_style.pygates the mechanical half and caps docstring length. Those caps bound growth rather than measure quality, so raise one in its own commit with a reason, never in passing. - History. What the code used to do, what was tried and refused, what a
field test measured. Git carries that. State the rule in force; where a
refuted alternative is a live temptation, name it and why not in one
sentence, and put the derivation in
-
Structure: constructor dependency injection with required args, no globals, no singletons.
-
Kodi I/O goes through the
kodi/adapters only. Log through the injected sinks withAOMe_-prefixed messages (theeseparates this addon's lines from classic AOM's in a shared kodi.log). -
strings.po: never reuse a retired string id; give new strings a translator context comment. Contract tests pin id parity and fallbacks. -
User-facing text is plain and succinct: describe the behavior and its off-state, with no em dashes and no marketing framing.
-
Commits are small and imperative, with a
Co-authored-by: Claude <noreply@anthropic.com>trailer. -
Delegated work does not inherit this file. A subagent starts from its own context, so put the conventions the task will touch into the prompt itself. For anything writing or editing code that means the docstring and comment rules above, which regress fastest: a fresh reader cannot tell a load-bearing invariant from an essay and will write both. Then run
python -m pytest tests -qon what comes back. The contract tests are the only part of this that holds without anyone having read it.