Architecture
EasyPlayTV ships as three packages around a shared runtime:
- Video addon —
plugin.video.easyplaytv, entry pointdefault.py. Invoked by Kodi on every listitem click and every directory navigation. Short-lived, stateless, fast: its job is to take a routed URL like?action=resolve_vod&provider=my5&...and turn it into a KodiListItemwith the rightsetup_dash_item()props set. Playback and sign-in only — no service code, no database access. - Service addon —
service.easyplaytv, a long-lived Kodixbmc.service(server role). Vendors the platform-agnosticeasyplaytv_serviceruntime and drives it vialib/kodi_service.py: generates the PVR files, configurespvr.iptvsimple, and serves the PVR files over HTTP for client boxes. - Standalone service —
easyplaytv-service. The same runtime, run outside Kodi as a systemd/Docker process configured byconfig.yaml. Writes directly to the Kodi video database while Kodi is running.
The Service addon and the standalone share the same easyplaytv_service runtime — the standalone is canonical, and the Service addon vendors a byte-copy at build time. Inside Kodi, the plugin and the runtime run in the same Kodi Python interpreter (separate LanguageInvoker threads); the video addon's resources/lib/ holds "things the plugin needs" (fast path, UI listing, DASH setup), while the runtime holds "things the service needs" (slow path, network fetches, TMDb lookups, DB inserts).
Request flow
1. User clicks an episode in the Kodi library
Kodi starts a fresh Python interpreter for the plugin, invokes
default.py with argv like
?action=resolve_vod&provider=my5&episode_id=a-family-christmas&...
and the script's if __name__ == '__main__' block dispatches on
action:
args = dict(urllib.parse.parse_qsl(sys.argv[2][1:]))
action = args.get('action')
...
elif action == 'resolve_vod':
vod_kwargs = {k: args[k] for k in args if k not in ("action", "provider", "episode_id")}
_resolve_vod(args.get('provider'), args.get('episode_id'), **vod_kwargs)
_resolve_vod() populates a ListItem with display
labels (skin-friendly "1x01." prefix comes from setInfo season/episode) and calls
the per-provider resolver (e.g. my5.resolve_vod()).
The resolver returns after calling setup_dash_item(), which sets
the inputstream.adaptive properties on the listitem for MPEG-DASH + Widevine.
Kodi then takes that listitem, hands the manifest URL to
inputstream.adaptive, opens the Widevine session with the
license URL we set, and starts playback.
2. User opens Movies → Genre → Family (Kodi's own library view)
Once library_sync has
populated the Kodi MariaDB video database, Kodi's built-in
Movies / TV Shows home-screen items read those rows
directly via the standard Kodi library windows — no plugin invocation, no
default.py call, no live HTTP. This is the whole point of
writing into the DB rather than surfacing content only via plugin
directory listings: the user can browse exactly as if the shows were
local files. The addon doesn't participate.
The addon does not expose its own browse UI for Movies and Series —
Kodi's own library is the only browse entry point. All the catalog
metadata (genre, year, plot, cast, posters) was written into the DB by
library_sync at
sync time, so Kodi's library windows show it natively.
The addon's plugin:// browse UI only contains a single tile:
Live TV. Opening the addon from Kodi's Add-ons menu
calls default.py with no params → list_main()
→ one directory item linking to ?mode=live_tv →
list_live(). The live-TV
channels then resolve through the per-provider resolver modules
(bbc.py, sky.py, itvx.py,
c4.py, my5.py, stv.py,
blaze.py).
3. Service runs a sequential sync batch
The runtime's scheduler.py runs a batch pass on its interval:
providers sync one-after-another (e.g. C4 when its turn comes), each calling
library_sync.sync_provider("c4")
→ sync_series_direct(provider_filter="c4"). After all providers the
pass refreshes PVR (channels + EPG), then repeats, capped by the sync interval.
Each provider sync fetches every series listing, calls TMDb to enrich the
metadata, then issues raw SQL INSERT INTO tvshow / episode / files /
seasons / uniqueid to populate the Kodi DB. A successful sync concludes with
prefix_existing_tvshows()
and a targeted UpdateLibrary(video, ...) so Kodi's window refreshes.
Module dependency graph
plugin.video.easyplaytv (video addon) service.easyplaytv / easyplaytv-service
default.py lib/kodi_service.py (or main.py)
│ │
┌───────┬──┴──┴───────┐ easyplaytv_service.runtime
│ │ │ ┌───────────┬───────────┬────────────┐
livestream resolve_vod login actions │ │ │ │
│ │ │ │ ▼ ▼ │
│ │ │ pvr_manager scheduler http_server
│ │ │ │ │
│ │ │ ▼ ▼
└───┬───┴──────┬──────┴──► resources.lib.__init__ vod_sync.library_sync
│ │ (setup_dash_item, load_provider_map, get_ua)
▼ ▼ │
per-provider live modules resolve-only VOD helpers ▼
bbc.py / itvx.py / c4.py / (video addon plays them at vod_sync/providers/
stv.py / blaze.py / sky.py / resolve time; the full bbc_vod / itvx_vod / c4_vod /
pluto.py / greatplayer.py catalogue scrapers live my5_vod / stv_vod / blaze_vod /
in the service runtime) uktv_vod / greatplayer
State files in addon_data/
The service runtime keeps its working state in the video addon's profile
(addon_data/plugin.video.easyplaytv/), and PVR output in the
Service addon's profile (addon_data/service.easyplaytv/pvr/).
Shared files:
| File | Writer | Reader | Purpose |
|---|---|---|---|
provider_map.json | runtime (vod_sync) | runtime, default.py | Map safe-title → provider label + f_name/pid/slug for cross-reference. Used to prefix rows during sync when their source catalogue omits the provider tag. |
library_sync_state.json | runtime (vod_sync) | runtime (vod_sync) | Movie sync state (synced_titles + last_sync timestamps). |
series_sync_state.json | runtime (vod_sync) | runtime (vod_sync) | Series sync state — skips shows already synced in the last 6h window (avoid hammering TMDb). |
stale_titles.json | catalog_check.py | default.py | Titles marked stale on a playback 404 (per-title marker so the user knows which episodes have gone away) — written by default.py:_resolve_vod's 404 handler, read on each resolve to suppress retries. |
itv_session | itvx.py | itvx.py | OAuth access + refresh tokens for ITVX (required for VOD playback of any ITV channel content). |
my5_session | my5.py | my5.py | JSON dict holding the AWS Cognito {access_token, id_token, refresh_token, expires_at, email} for optional Channel5 user sign-in. Attached as Authorization: Bearer … on Cassie requests when present. Free-to-air content plays anonymously when absent or expired. |
itvx_cookies.pkl | runtime (itvx_vod) | runtime (itvx_vod) | Pickled http.cookiejar for the itvx.com catalog fetches (compiled by the scraper behind the scenes). |
stv_cache/ | runtime (stv_vod) | runtime (stv_vod) | Per-show JSON responses cached to avoid re-downloading 535 catalog pages every sync. |
pvr/channels.m3u, pvr/epg.xml | runtime (pvr_manager) | pvr.iptvsimple | Generated channel list + EPG. Written to the Service addon's profile when running inside Kodi (addon_data/service.easyplaytv/pvr/), or to paths.pvr_dir for the standalone. |
settings.xml | Kodi settings UI | all via getSetting* | Video addon: max_resolution, itvx/my5 email+password, has_tv_license, pvr_enabled, pvr_fetch_url (client PVR → points pvr.iptvsimple at a server). Service addon: pvr_enabled, pvr_refresh_hours, serve_http, pvr_region, vod_sync_hours, stale_sweep_hours (server role only — no device-role / fetch settings). |
Concurrency notes (sharp edges)
Concurrent queries on the same socket corrupt each other's packet stream
("Packet sequence number wrong - got 2 expected 1" /
"'NoneType' object has no attribute 'read'").
_get_db() uses threading.local() to give each
worker thread its own pymysql.connect(). Cache the local
object per-thread; never pass a connection into a worker thread.
Older releases polled marker files (force_sync_<provider>)
in the video addon's addon_data/ to trigger targeted syncs, and
exposed an HTTP POST /sync/vod/<provider> endpoint plus a
--sync CLI flag. All of these were removed. VOD sync now runs
only on the scheduled sequential batch (providers one-after-another, then PVR
refresh), capped by the sync interval. Stale VOD is removed on two cadences:
every sync drops whole movies and TV shows missing from the current catalogue
(a show only after several consecutive absences, tracked in
series_sync_state.json); a slower pass
(library_sync.sweep_stale, cadence stale_sweep_hours)
fetches each provider's full current catalogue and deletes episodes/seasons
whose upstream id is no longer present. Both are failsafe-skipped if a
provider's catalogue fetch comes back empty.
Kodi ships a TMDb scanner that auto-creates tvshow /
movie rows from any on-disk source path it can read. Without
exclude=1 on the path row, the scanner would re-create
un-prefixed duplicate rows from the bare plugin://plugin.video.easyplaytv/?action=resolve_vod&...
filenames (or from any disk folder the addon has touched), repeatedly
on every library refresh — colliding with the addon's
[ITVX] Ackley Bridge prefix convention.
The runtime writes rows directly into the Kodi tables via SQL
(vod_sync/library_sync._insert_tvshow et al.) instead of relying on
Kodi's scanner. To prevent the Kodi scanner from creating duplicates
on top of those rows, the runtime sets exclude=1 on two
path rows in ensure_series_path_config:
the plugin-source path row (idPath=485,
plugin://plugin.video.easyplaytv/) and the on-disk Series
folder row (idPath=3736, /home/osmc/.../Series/).
With both exclude flags set, the only thing creating rows in the video
DB is the addon's sync engine; the scanner is bypassed entirely.
Per-provider network endpoints (one-line summary)
Full comparison lives on the Provider matrix page.
| Provider | Catalogue | Stream | Auth | DRM |
|---|---|---|---|---|
| BBC iPlayer | HTML scrape (__IPLAYER_REDUX_STATE__) | MediaSelector 6 JSON | None (UK-IP geofenced) | None (clear DASH) |
| ITVX | HTML scrape (Next.js __NEXT_DATA__) | magni playlist POST | OAuth (JWT in itv_session) | Widevine L3 |
| My5 | corona.channel5.com JSON API | cassie.channel5.com JSON API | HMAC + AES, both keys XOR-extracted from live player JS; optional AWS Cognito Bearer attached when user signed in (see my5.cognito) | Widevine |
| Channel 4 | HTML scrape (__PARAMS__ inline JSON) | /vod/stream/ JSON | AES-CBC encrypted token (keys hardcoded per client) | Widevine via Redbeemedia |
| STV | player.api.stv.tv JSON | Same | stv-drm: true header | Widevine |
| Blaze | watch.blaze.tv HTML scrape | Same HTML scrape (per-episode data-key) | None | None |
| Sky News | no catalogue (single live stream) | Brightcove playback API | None (UK-IP) | Widevine optional |