Skip to content

Latest commit

 

History

History
220 lines (189 loc) · 11.9 KB

File metadata and controls

220 lines (189 loc) · 11.9 KB

Audio Offset Manager: Evolved

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.

What it is

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.

Design principles

  • 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.

Entry points

Two processes share one file, the offset store.

  • service.pyaome.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.pyaome.script_router, the RunScript half. Routes: manage_offsets, export_offsets / import_offsets, export_log. Anything else opens settings, the hub every view returns to.

How the code is organized

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.

The offset store

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 .bak sibling, which therefore trails by one write; an unparseable file is quarantined to .bad and 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.py is 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 — see policies.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/clear leave 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_ms is 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 mutation channel

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

settings.xml holds behavior toggles only.

  • The settings object is a live shared proxy, not a snapshot, and only while its parent xbmcaddon.Addon stays alive. Keep the Addon on self; never read from a throwaway xbmcaddon.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 the store_*_if_changed helpers.
  • Learn and apply are orthogonal, so apply-off with learn-on is the legal re-teach state.

Development workflow

  • Tests gate everything. python -m pytest tests -q takes 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, then gh run watch.

Packaging

  • Dev tooling is export-ignore'd (this file, docs/, .github/, tests/, tools/, .claude/). Check what ships with git archive --format=zip -o /tmp/pkg.zip HEAD.
  • Targets xbmc.python 3.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

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.

Conventions

  • 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 — .gitignore lists 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.py gates 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.

  • 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 with AOMe_-prefixed messages (the e separates 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 -q on what comes back. The contract tests are the only part of this that holds without anyone having read it.