Disclaimer: This documentation is provided for convenience and may contain errors. Always verify information against the official Kodi and provider documentation. Report issues.
Fastmail
Home / Architecture

Architecture

EasyPlayTV ships as three packages around a shared runtime:

  1. Video addonplugin.video.easyplaytv, entry point default.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 Kodi ListItem with the right setup_dash_item() props set. Playback and sign-in only — no service code, no database access.
  2. Service addonservice.easyplaytv, a long-lived Kodi xbmc.service (server role). Vendors the platform-agnostic easyplaytv_service runtime and drives it via lib/kodi_service.py: generates the PVR files, configures pvr.iptvsimple, and serves the PVR files over HTTP for client boxes.
  3. Standalone serviceeasyplaytv-service. The same runtime, run outside Kodi as a systemd/Docker process configured by config.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)

Kodi's own library view doesn't call the addon at all

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_tvlist_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:

FileWriterReaderPurpose
provider_map.jsonruntime (vod_sync)runtime, default.pyMap 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.jsonruntime (vod_sync)runtime (vod_sync)Movie sync state (synced_titles + last_sync timestamps).
series_sync_state.jsonruntime (vod_sync)runtime (vod_sync)Series sync state — skips shows already synced in the last 6h window (avoid hammering TMDb).
stale_titles.jsoncatalog_check.pydefault.pyTitles 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_sessionitvx.pyitvx.pyOAuth access + refresh tokens for ITVX (required for VOD playback of any ITV channel content).
my5_sessionmy5.pymy5.pyJSON 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.pklruntime (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.xmlruntime (pvr_manager)pvr.iptvsimpleGenerated 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.xmlKodi settings UIall 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)

pymysql is NOT thread-safe on a shared connection

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.

No manual sync trigger

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.

Why we write directly to the DB and use exclude=1

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.

ProviderCatalogueStreamAuthDRM
BBC iPlayerHTML scrape (__IPLAYER_REDUX_STATE__)MediaSelector 6 JSONNone (UK-IP geofenced)None (clear DASH)
ITVXHTML scrape (Next.js __NEXT_DATA__)magni playlist POSTOAuth (JWT in itv_session)Widevine L3
My5corona.channel5.com JSON APIcassie.channel5.com JSON APIHMAC + AES, both keys XOR-extracted from live player JS; optional AWS Cognito Bearer attached when user signed in (see my5.cognito)Widevine
Channel 4HTML scrape (__PARAMS__ inline JSON)/vod/stream/ JSONAES-CBC encrypted token (keys hardcoded per client)Widevine via Redbeemedia
STVplayer.api.stv.tv JSONSamestv-drm: true headerWidevine
Blazewatch.blaze.tv HTML scrapeSame HTML scrape (per-episode data-key)NoneNone
Sky Newsno catalogue (single live stream)Brightcove playback APINone (UK-IP)Widevine optional