Guidance for Claude Code (claude.ai/code) when working in this repository.
This file is a summary and an arrival brief: the commands, the shape of the app,
and the rules that apply tree-wide. Subsystem detail lives in docs/, because it is
worth reading when you are working on that subsystem and not otherwise.
You MUST read these before starting work — this CLAUDE.md is a summary only, and each of these docs carries footguns that have no line to sit on: the danger is in the line you are about to add, so an inline comment cannot warn you.
| Before doing... | Read this first |
|---|---|
Editing player.py or its mixins (player_audio.py, player_reporting.py, player_window.py, mpv_options.py, mpv_events.py) |
docs/mpv-backends.md |
Any browser shell work — routes, pages, loading, navigation (mpvtk_browser/) |
docs/browser-shell.md |
| Drawing anything new with the toolkit (widgets, layout, overlays) | jellyfin_mpv_shim/mpvtk/GUIDE.md |
Adding or changing a server query or Fields list |
docs/jellyfin-api-notes.md |
| Anything about startup, shutdown, the tray, single-instance or the gamepad | docs/architecture.md |
| Adding a config key, or moving one between settings tabs | docs/settings-curation.md |
Touching watched/played state, or the download catalog (sync/) |
docs/offline-sync.md |
Trickplay, the seek-preview bubble, or anything handing mpv a file to overlay-add (trickplay.py, bifdecode.py, thumbfast.lua) |
docs/artwork-pipeline.md §11 |
| Writing a test, or adding/extending a fake | docs/testing.md |
docs/mpv-backends.md is essential before any player work. Three examples of
what is in it and cannot be inlined: mpv is not re-created between queue items, so
any global option written for one item is still set for the next (this has leaked an
auth token to a third-party host, clobbered a user's own mpv.conf, and made every
film play stretched); a bound method cannot be a property_observer on libmpv, so
it fails on exactly one backend; and an input section without allow-hide-cursor
means the mouse cursor never hides again for the rest of the session.
- Run from source (no install):
./run.py - Build:
./gen_pkg.sh— downloadsdefault-shader-pack, compiles.po→.mo, checks the version matches acrossconstants.py, the Inno Setup script and the appdata XML, then runspython3 -m build.--skip-builddoes prep only (translations + shader pack). Use on Windows, or when you only want.mofiles.--installrunspip3 install .[all](withsudoif available;--localskips sudo).--get-pyinstaller/--gen-fingerprintare CI helpers for the Windows cache.
- Regenerate the translation template after changing user-facing strings:
./regen_pot.sh. It updatesjellyfin_mpv_shim/messages/base.potand nothing else — committing the.potis the whole i18n obligation of a feature branch. - Windows build (after
gen_pkg.sh --skip-build):build-win.bat(-32,-arm64,-dbgvariants). It needsfribidi-0.dllbeside it —python tools/build_win_fribidi.pybuilds it in about a second. Pillow's wheels ship no FriBiDi, and without the DLLImageFont.truetypesilently returns a Basic-layout font: RTL text draws reversed, and — the one with reach — nothing gets kerned, sompvtk.metricsfeeds unkerned measurements to every ellipsize and wrap decision. Seedocs/packaging.md. - Flatpak bundle of the working tree:
flatpak-builder build flatpak/com.github.iwalton3.jellyfin-mpv-shim.json --force-clean --repo=repo --user, then./artifacts.sh flatpak amd64. Cloneflathub/shared-modulesintoflatpak/shared-modulesfirst.tools/check_flatpak_pins.py [manifest]verifies every pinned sha256 in seconds — pass it the Flathub manifest, which is usually the one that has drifted. Seedocs/packaging.md. - Tests:
xvfb-run -a python3 -m unittest discover tests(stdlib unittest, no extra deps) — ~7 minutes. Integration matrix:xvfb-run -a python3 tests/integration/run_integration.py— ~6.5 minutes. - Faster unit runs:
xvfb-run -a python3 tools/run_tests_parallel.py— the same 4,900 tests one process per module, ~64s. Same import semantics asdiscover; a per-module timeout means a hang is a reported failure, and each worker gets its own process group so a kill cannot orphan an mpv. Putxvfb-runin front of it, not the workers. Do not parallelize the integration matrix — its legs are serial on purpose (docs/testing.md §9). - Tee a full run to a file rather than piping it through
tail/grep. At these runtimes, one look at a failure you did not keep costs another run, andrun_integration.py's summary prints only a return code per leg. - Profile a server's artwork handling:
tools/bench_image_loading.py. Read-only; uses the saved credentials. Seedocs/artwork-pipeline.md. - There is no linter config.
Two test rules, because both cost a session when missed:
- Select with
-k, never a module name and never-p. Importing almost anything underjellyfin_mpv_shimreachesargs.get_args()at import time, which parses the realsys.argv.discover testsis safe because unittest replaces argv first and consumes-k/-vitself; a module name stays in argv as a positional and dies with the app's own usage line, which reads as a broken test module and is not. (tests/e2e/is exempt and is named directly.) - Always
xvfb-run, including for the unit suite.player.pybuilds itsplayerManagersingleton at module scope and__init__ends with_init_mpv(), so importing it opens a real mpv window. Eight unit modules import it.
The Python build is PEP 517 / pyproject.toml with setuptools. The full path needs
pip install build; pip install .[all] and pip install -e . work without it.
gen_pkg.sh fetches jellyfin_mpv_shim/default_shader_pack/ from a GitHub release;
it is not in git.
jellyfin_mpv_shim/constants.py:CLIENT_VERSION is the single source of truth for the
Python package — pyproject.toml reads it via tool.setuptools.dynamic. The other
two are not derived and gen_pkg.sh warns loudly if they drift:
jellyfin_mpv_shim/constants.py→CLIENT_VERSIONJellyfin MPV Shim.iss→#define MyAppVersionjellyfin_mpv_shim/integration/com.github.iwalton3.jellyfin-mpv-shim.appdata.xml→ first<release version="...">
A version containing pre skips the appdata (it is Flathub's changelog and
pre-releases are not published there) and normalizes to rc in the wheel. Both rules
and their traps: docs/packaging.md §4.
Entry point is jellyfin_mpv_shim/mpv_shim.py:main. It wires a set of
module-level singletons that talk to each other via direct references and
threading.Event triggers — no DI container, no event bus, just imports. Each is a
module-level instance, not a class to instantiate:
clientManager(clients.py) — oneJellyfinClientper logged-in server, persists creds tocred.json, runs a health-check thread, forwards websocket events viaclientManager.callback.eventHandler(event_handler.py) — receives those and dispatches toplayerManager. Add a remote-control event by decorating a method with@bind("EventName").playerManager(player.py) — wraps mpv, owns the current playlist (aMediafrommedia.py), exposes the operations the rest of the app calls. The largest and most central module.timelineManager(timeline.py) — posts playback progress to Jellyfin and fires theidle_cmd/idle_ended_cmdhooks.actionThread(action_thread.py) — pumpsplayerManager.update()so mpv property changes can trigger Python work without re-entering mpv's callback context.user_interface—mpvtk_browser.uiifenable_gui, Pillow imports, and this mpv has lua; otherwisecli_mgr. The third condition is not a nicety: everything the shim draws is lua, and an mpv built-Dlua=disabledhas no--oscoption and refuses to start when told to set one.
menu.py draws the in-player OSD config menu; mouse.lua forwards mouse hits back.
syncplay.py implements the SyncPlay timing loop. bulk_subtitle.py and
video_profile.py are menu-driven features.
Four smaller pure modules, each with its own tests: keysweep.py (reads mpv's
resolved input-bindings so a claim re-issues the user's own binding),
input_conf.py (the one-time migration of seek_* into the user's input.conf),
items_api.py (the modern GET /Items; build_query raises on an unknown
keyword, since a silently dropped parameter is a filter that stopped applying), and
shader_overrides.py (which shader profile an item gets, by scope).
mpvtk_browser/ renders inside the player's mpv window, in the main process,
attached to playerManager's mpv. No second window, no subprocess. Nothing in the
package imports tkinter, and tests/test_no_tkinter.py enforces that.
Shape, routing, thread contract, epoch and lock policy: docs/browser-shell.md.
The standing footgun is state that changes between draws. A screen is rebuilt from scratch on every repaint, so a widget tree is a snapshot and nothing reconciles it afterwards. Two failures, both of which have shipped:
- A handler that captures.
lambda: play(item, aid=aid)whereaidwas resolved inrender()fires with whatever was selected when the page last drew. Read mutable state inside the handler, not in the builder. - A handler that writes without asking for a repaint. The renderer flips a
Dropdown's selection and a TextBox's text optimistically, so those look
self-updating; a
Checkboxdoes not — it is Box-plus-tick coloured fromchecked, and only a redraw can move it.
A scene assertion is not a repaint assertion. build_scene renders when asked, so
it draws a correct tree whether or not the app would ever have redrawn — a handler that
changes state owes a test that invalidate was called, and a stray build_scene(b)
between a click and its assertion silently refreshes every closure on screen. Both
bugs above passed their scene-based tests, one of them because the test rebuilt in
between.
tools/audit_stale_captures.py (run by tests/test_no_stale_captures.py) catches the
capturing half from the source. A finding is not automatically a bug — read the state
inside the handler, or add it to that file's ACCEPTED with a reason. It says nothing
about the writing half.
Both measured (tests/e2e/test_filter_matrix.py):
- Jellyfin drops what it does not recognise. An unknown parameter name and an unparseable enum value both answer exactly as sending nothing does, so a typo turns a filter off and the screen looks normal. The evidence that a value parses is the contrast with a deliberately bogus one.
Filters=IsUnplayed,IsPlayedis HTTP 400 on 12.0 and an empty result on 10.11, which is whydialogs.MUTUALLY_EXCLUSIVEexists.
More server behaviour — DisplayPreferences, CustomPrefs, UserData, the obsolete
Users/{id}/Items route: docs/jellyfin-api-notes.md.
player.py is composed: PlayerManager(AudioMixin, ReportingMixin, WindowMixin),
with those in player_audio.py, player_reporting.py and player_window.py, and the
option dict built by mpv_options.py. Mixins rather than owned objects on purpose:
self._player is read ~200 times, 36 methods share one RLock via
@synchronous("_lock"), and re-entrancy across them is load-bearing. Each mixin
declares what it borrows under if TYPE_CHECKING:; the length of that list is the
coupling metric, so grow it deliberately.
player.py picks a backend at import time:
- default:
import mpv(thepython-mpvlibmpv binding); - if
settings.mpv_extis set, or libmpv can't load (OSError):python_mpv_jsonipc, withis_using_ext_mpv = True. macOS forces this — libmpv isn't reliable there.
Both are aliased as mpv. _mpv_errors is the tuple to catch on shutdown
(BrokenPipeError always, plus mpv.ShutdownError only on libmpv). wait_property
has separate paths for the two. Everything else that diverges — observer registration,
construction failures, input sections, options that outlive a queue item, version
behaviour — is in docs/mpv-backends.md.
Extracted modules must import the backend globals (is_using_ext_mpv,
_mpv_errors, discord_presence, win_utils) per call, inside the method, never at
module scope. Module-scope binding captures the backend, and the integration harness
swaps a fake mpv in and out by evicting modules from sys.modules — a second module
holding a bound copy has to be evicted in lockstep, and it fails only on the
whole-suite leg. Each module has a subprocess guard test pinning this.
mpv_options.py imports neither player nor a backend, so option behaviour is
testable without opening a window (tests/test_mpv_options.py pins that). The dict is
an OrderedDict and insertion order is deliberate.
- "Is the library on screen" is
_library_showing(), never_video is None. Audio keeps_videoset and keeps the browser up — that is what the now-playing bar is for — so the obvious test answers "no" while the user is looking straight at the library._nav_backasked it the wrong way and refused BACK for the whole of music and audiobook playback, which killed the mouse thumb buttons the moment anything played. That predicate's docstring warns about this. keepaspectis the window's property, owned byset_browse_window(off, so the library window resizes freely) andbrowse_yield(on). The comic reader only borrows it, soreset_picture_viewmust not put it back while something is playing. That method runs from_release_page_grabson every browse → video handoff throughrun_action, which defers whenever the player lock is busy — and it is busy for the whole of a playback start. When the reset landed second, every film played stretched, with no comic anywhere in the session.tests/test_window_geometry.py:PictureViewHandoffTestasserts both interleavings.
conf.py:Settings declares every config key as a typed class attribute; defaults live
there. settings_base.py:SettingsBase is a homegrown pydantic-lite — it reads
__annotations__ and coerces via the object_types table. A new config key's type
annotation must be one of the entries in object_types (bool, int, str,
float, list, or the Optional[...] forms of the first four) — anything else
KeyErrors at load time. The single global instance is conf.settings.
docs/configuration.md is the user-facing reference and
tests/test_docs_coverage.py fails a setting that has no entry there.
Config and credentials live in a per-platform path from conffile.py
(~/.config/jellyfin-mpv-shim/, %appdata%\jellyfin-mpv-shim\,
~/Library/Application Support/jellyfin-mpv-shim/).
Themes are not config keys — they are JSON files, resolved like shader packs:
built-ins in jellyfin_mpv_shim/themes/*.json, shadowed by a same-named file under
<config>/themes/. mpvtk_browser/themes.py holds the loader and DEFAULT, which is
simultaneously the fallback, the schema (a theme may only set keys that appear in it,
coerced to those types) and the merge base. mpvtk_browser/theme.py then serves
theme.ACCENT-style reads from a dict via a module __getattr__.
Do not reintroduce writing the palette into globals(): it let a theme define
arbitrary module attributes, including over the functions there, and turned a mistyped
colour name into a new global while the real one silently kept its old value.
Project policy (CONTRIBUTING.md): everything beyond the four required deps must
degrade gracefully when its package is missing or broken. mpv_shim.py:main and
player.py both demonstrate the pattern — try: import optional_thing inside a guard,
then a feature flag or a fallback.
Required: python-mpv, python-mpv-jsonipc, jellyfin-apiclient-python, requests.
Everything else (GUI, mirror, Discord, Windows niceties) is an extras_require group.
New features touching outside dependencies follow the same try/except ImportError +
fallback pattern; don't add a hard import.
Three rules that have each caught real bugs here. The suites, the case histories and
the audit tooling are in docs/testing.md.
- Assert the property over several steps, not the mechanics of one. The recurring bug shape is state feeding back into the input that produced it, and one-step tests cannot see it. Anything a scheduler, poller, health check or websocket can re-run gets a loop of ≥3 and an assertion that the observable did not walk.
- A stand-in that omits a field is how a property goes untested. It does not leave
a path uncovered — it makes the path unreachable while reporting a pass, because
the thing the test is named after has nowhere to live. The review question for a new
fake is which field of the real object did I not model, and is that the field the
test is named after?
tools/audit_fake_contracts.py(viatests/test_no_fake_gaps.py) checks the half that is checkable. - Ordering claims go through
_harness.Journal, one stream every fake writes into, and assertions are subsequences, never equality — a log compared as a whole fails the day somebody adds an event.
User-facing strings use gettext via i18n.py's _(). After adding or changing them:
./regen_pot.sh— updatesbase.potand nothing else. Commit it../gen_pkg.sh --skip-buildcompiles.po→.mo..mofiles are gitignored.
Never run ./regen_pot.sh --merge, and never touch the per-locale .po files,
unless explicitly asked. The .pot is the template Weblate reads; filling the 86
.po files in is Weblate's job. Merging locally rewrites every locale (msgmerge
rewrites references and re-wraps what it touches) — ~10k lines across 86 files even
when no translation changed — which then collides with master in files nobody on the
branch edited, unreadably, because the diff is almost entirely reference comments.
It is a maintainer operation that has to be merged within the hour to be worth
anything.
Translations are managed via Weblate; "Translated using Weblate (...)" commits come
from there — don't hand-edit .po files for in-flight translations. A weblate remote
points at its git export, and MASTER_REF=weblate/master merges against the freshest
volunteer work.
One word, two meanings: use _p(context, string). gettext keys on the English, so
a string reused in two senses collapses to one entry and no language can tell them
apart. A context is part of the key, so adding one to a string that did not need it
discards every existing translation of it. Known cases: Record, Channels,
Download, None. Extraction rationale and seeding: docs/i18n.md.
| Doc File | When to Read |
|---|---|
| docs/architecture.md | the process model: primary election, what a launch claims, the tray child process, making the exit finish; the gamepad; users; singleton wiring |
| docs/browser-shell.md | the browser shell: mixins/Pages, thread contract, epoch, lock policy, refresh-in-place, scroll parking, pollers, live-applied settings |
| docs/mpv-backends.md | libmpv vs jsonipc, construction failures, mpv versions, SDL/signals, input sections, options that outlive a queue item, wait_property, video-setting defaults |
| docs/artwork-pipeline.md | strips and the cache, transparent-artwork plating, tile shapes, Cover Size, badges, header baking, grid centring; trickplay's windowed frame file (§11) |
| docs/jellyfin-api-notes.md | how the server actually behaves: DisplayPreferences/CustomPrefs, UserData, dropped parameters, Live TV query shapes |
| docs/readers.md | books, audiobooks, the epub reader and the comic reader |
| docs/live-tv.md | the Live TV screens, guide, timers and self-refresh |
| docs/offline-sync.md | who may write watched state and in which direction; the sweep schedule; what UserDataChanged does and does not announce |
| docs/testing.md | the SyncPlay suites, the fakes discipline and its case histories, the journal |
| docs/packaging.md | Windows/FriBiDi, the ARM64 job, tools/msgfmt.py, Flatpak, version spelling |
| docs/i18n.md | xgettext, location granularity, --merge, seeding from jellyfin-web |
| docs/settings-curation.md | adding a config key, which tab and why, and whether a change takes effect before a restart |
| docs/configuration.md | the user-facing settings reference (enforced by a test) |
| docs/PERMISSION_GAPS.md | what breaks without each Jellyfin user permission |
| jellyfin_mpv_shim/mpvtk/GUIDE.md | the mpvtk toolkit itself: widgets, layout, the ASS/bitmap z-order constraint |