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 / Files / library_sync.py

resources/lib/library_sync.py — the sync engine

Moved: now lives in the standalone service runtime

Since the 3-package split the sync engine ships in the shared runtime at easyplaytv-service/src/easyplaytv_service/vod_sync/library_sync.py (and the vendored copy inside service.easyplaytv). The video addon is playback-only and no longer contains a library_sync.py.

Path easyplaytv-service/src/easyplaytv_service/vod_sync/library_sync.py Lines 1,608 Type sync engine (shared runtime — standalone service or Service addon) ← called by the scheduler's Scheduler.run_sync(provider) (sequential per-provider batch) → calls pymysql (via Database), TMDb HTTP, all *_vod.py provider modules

Role

The heart of the library sync. Connects to Kodi's MariaDB video database (host/user/password/name from DatabaseConfig — default MyVideos131), fetches catalogue listings from each provider, enriches them with TMDb metadata via the disambiguation scorer, and writes tvshow / seasons / episode / files / movie / uniqueid / art rows directly — bypassing Kodi's own scanner, which is why the series path is exclude=1. This file is platform-agnostic (no xbmc* imports): it runs identically inside Kodi via the Service addon and as a systemd/Docker process in the standalone service.

Module-level constants

PLUGIN_SOURCE_PATH = "plugin://plugin.video.easyplaytv/" · line 31

The single path row every episode / movie file gets attached to in Kodi's MyVideos DB. Movie paths (…/movies/) and TV paths (…/Series/) live under it as sub-paths in the Kodi source for the same library source. exclude=1 on the series path row prevents Kodi's TMDB scanner from creating duplicate un-prefixed tvshow rows (see the path-row section below).

TMDB_API_KEY · TMDB_BASE · TMDB_IMG_BASE · lines 27–29

TMDb HTTP glue. The API key is bundled; there is no user-facing resolution knob anymore (resolution follows what each provider's stream API returns). TMDB_IMG_BASE is used to assemble season/episode art URLs.

DatabaseConfig (config.py) — host/port/user/password/name

DB connection values are no longer hardcoded in this module. Runtime reads them from config.yaml via DatabaseConfig (config.py), injected as db into every sync function. MyVideos131 is the default database.name for Kodi 21's schema version.

DB connection model — the gotcha

Database class (database.py) · _ensure_path_id(db, …)

All DB work goes through the runtime.Database manager — a single lazily-created pymysql.Connection (per DatabaseConfig, reconnecting via ping(reconnect=True) on idle). _ensure_path_id (line 277) is the addon's entry into the path-row configuration below.

pymysql is NOT safe to fan out across threads on one shared connection

Concurrent queries on the same socket corrupt each other's MySQL packet stream. Because the original addon pooled connections per worker thread and fell back to a shared module-level handle when a worker's died mid-query, two failure modes were seen in production:

  • "Packet sequence number wrong - got 2 expected 1" — packets from two threads interleave on the wire.
  • "'NoneType' object has no attribute 'read'" — one thread's ping() closed the other's socket reader mid-query.

The runtime avoids this by running the batch sync on a single thread — the scheduler's sequential provider passes each use their own Database instance, and no sync code creates sibling connections per provider.

DatabaseConfig (config.py) · database.name default MyVideos131

The MySQL target is read from the runtime config.yaml (database.host/port/user/password/name) — DatabaseConfig in config.py. A Kodi upgrade that bumps the schema means the name must be updated in config (e.g. MyVideos131 → a newer version) — there is no auto-discovery of the latest MyVideos% database.

Path-row configuration

_ensure_path(db, str_path, content_type, scraper) · line 503 — for tvshows

Idempotent insert-or-fetch of the path row used as the series library root. If the row is missing, copies the strSettings XML from any other library source with the same content type + scraper (sanitised via _sanitize_scraper_settings_xml) so the addon doesn't ship a hard-coded scraper config. Called with content_type="tvshows", scraper=metadata.tvshows.themoviedb.org.python. The exclude flag on this row is the important part:

Why the plugin URL is exclude=1

Registering the plugin URL as a strContent='tvshows' path with exclude=0 causes Kodi's library scanner to scrape the addon's directory listings and create tvshow rows on its own. Because the addon's Series-mode listing returns provider-flavoured show titles like "Ackley Bridge" (without the DB-prefixed [ITVX] Ackley Bridge convention), Kodi's TMDb.tv scraper created hundreds of un-prefixed 0-episode duplicate tvshow rows that conflict with the prefixed rows directly inserted by sync. Keeping the path row (so tvshowlinkpath can attach to it) but setting exclude=1 stops Kodi's scanner from recursing into the plugin URL. The addon fills the library via direct INSERTs; Kodi's scan is unwanted noise.

_ensure_path_id(db, str_path, content_type, scraper, settings_xml="") · line 277 — for movies

A bare variant for the movies root (PLUGIN_SOURCE_PATH with content_type="movies", the TMDb movie scraper): insert-or-fetch without the settings-copy, used by the movie sync pass. Same exclude=0 on the row — movie rows carry no provider prefix to mis-scrape.

_sanitize_scraper_settings_xml(xml) -> str · line 490

Quotes unquoted attributes in TMDb scraper strSettings XML and force-pins verboselog=false. Why this matters: "a prior EasyPlayTV run propagated a malformed unquoted XML true to EasyPlayTV's parent Series/ path row in MyVideos.path. With verboselog=true, the TMDb scraper dumps 20K+ log lines per /tv//season/N API call (pformat of the entire JSON response), costing ~22s per season on RPi SD-card I/O — e.g. ~13 min for WWE Raw alone (35 seasons)."

TMDb lookups and the disambiguation scorer

_tmdb_search(title, year=None, description="") · _tmdb_movie(id) · _tmdb_tv_search(title, year=None, description="") · _tmdb_tv(id) · _tmdb_tv_season(tmdb_id, season_num) · lines 63–134

Thin wrappers over TMDb's search/movie, movie/, search/tv, tv/, tv//season/ endpoints. All return None on any failure (the sync caller falls back to inserting with title-only metadata). _tmdb_tv_season is called once per season per show, so a single provider sync can fire hundreds of these — that's why the sync serialises with a 1 s sleep every 20 shows and a 5-show state save cadence.

_title_similarity(query, result_title) · _score_result(r, title, year, description, date_key, name_key) · _pick_tmdb_movie_result(results, title, year=None, description="") · _pick_tmdb_tv_result(results, title, year=None, description="") · lines 195–269

The search results are ranked, not just trusted in TMDb's order. The scorer keeps the exact-title top result unless a same-title edition matches the provider's year, and otherwise scores every candidate:

  • Title similarity dominates. _title_similarity is token Jaccard over accent-folded, lower-cased word tokens (parenthesised years stripped): apollo 13 vs apollo 13 → 1.0, vs apollo 13: to the edge and back → 0.29. Weighted ×100 so a making-of doc, spinoff or regional variant that merely shares tokens with the query can never dethrone an exact-name title.
  • Year is a tie-break only (+10 when the first four characters of the TMDb release_date / first_air_date equal the provider's year) — never enough to override title similarity.
  • Overview token overlap (+3, capped) nudges very close calls toward the edition whose plot mentions the same people/places.

This is what fixes titles TMDb's bare first-search-result gets wrong — ITVX's War (2007) resolving to Avengers: Infinity War, or a documentary shadowing its titular film. Directory-name lookup (prefixed_dirname) still guards later syncs against matching a re-ranking TMDb result to the wrong existing row (see the callout below).

The big show: _sync_series

_sync_series(db, provider_filter=None, force=False, data_dir="") -> int · line 1000

The spine of the sync. Steps:

  1. _fetch_all_series_shows(provider_filter) (line 803) — the provider modules' catalogue fetchers → one flat list of shows, tagged with _nav_mode (series vs playable). Restricted to _nav_mode == "series" (and the provider filter if given) before processing.
  2. Build fresh_safe_titles — the stripped safe-filename set of the provider's current catalogue, handed to _sweep_absent_shows after the loop for the per-sync absence sweep.
  3. Query existing tvshow rows on PLUGIN_SOURCE_PATH and build existing_safe_titles (anything) and existing_safe_titles_with_eps (any show with ≥1 episode). Pre-strip the [PROVIDER] prefix so prefixed and un-prefixed rows collapse to the same key — the de-duplication safety net.
  4. For each show (skipped if safe_title already has episodes and force is false):
  5. a. TMDb tv-search via the disambiguation scorer (_pick_tmdb_tv_result), then _tmdb_tv() for canonical metadata. If search fails the show syncs with title-only metadata ({"name": title, "id": 0}).
  6. b. Fetch episodes BEFORE inserting the tvshow row. This avoids leaking empty tvshow rows into the DB when a show's episodes come back empty (films mis-badged as series, withdrawn brands, API fetch errors). Fetchers key off the show's own fields (f_name/pid/slug), so they don't need show_id yet.
  7. c. _insert_tvshow only once the episode list is non-empty, then insert episodes grouped by season via _ensure_season + _insert_episode.
  8. d. STV episode_number=0 renumbering — providers (notably STV) return episodes with episode_number=0 when playerSeries is null; these are renumbered sequentially from 1 before insert, so no episode is silently dropped.
  9. Track per-show absent_shows counts and persist state to series_sync_state.json every 5 shows; sleep 1 s every 20 shows to be gentle on the TMDb API (no key quota, but the endpoints rate-limit). Also restores absent_shows from state so absence survives restarts.
  10. After the loop: _sweep_absent_shows(...) deletes shows absent for ABSENT_SHOW_THRESHOLD (3) consecutive passes.
provider_map — cross-ref only, never authoritative

provider_map (data_dir/provider_map.json) records which provider first synced each safe_title, plus the provider-native navigation fields needed to replay a stream in the video addon (bbc_pid, itvx_prog_id/itvx_slug, c4_f_name, my5_f_name, stv_guid, uktv_slug, great_show_id). It is a keyed by safe_title (not by DB id), so it ties the sync's nav data to the library rows the video addon resolves. The sync always uses the show dict's own provider for the current pass — provider_map is only UI / re-resolution cross-ref.

Ordering rule — insert the tvshow row only after episodes fetch

Fetching episodes before the tvshow INSERT avoids leaking empty tvshow rows into the DB when a provider's episode fetch comes back empty (films mis-badged as series, withdrawn brands, API errors). _sync_series calls _fetch_show_episodes first and continues on an empty list — _insert_tvshow runs only for shows that actually have episodes.

_sync_movies(db, provider_filter=None, force=False, data_dir="") -> int · line 1198

The movie equivalent of _sync_series. Simpler — no seasons to track, no provider-vs-provider episode-fetcher dispatch, no episode renumbering. Calls provider_mod.fetch_movies(provider_filter=…), iterates, TMDb-searches by title via the disambiguation scorer, _insert_movie. State file is library_sync_state.json (separate from the series one).

Insert helpers

_insert_movie(db, movie_data, strm_url, provider="") · line 324

Inserts a single movie. Idempotency check at line 338: looks for an existing files.idFile joined with movie.idFile for this path+filename and bails if found. Title construction [%s] %s % (PROVIDER_LABELS[provider], base_title) gives the familiar [ITVX] Foobar shape. Inserts into files, movie (with all the c00-c23 fields Kodi expects), videoversion (Kodi 21 requirement), the genres via genre_link, and art (poster/fanart).

_insert_tvshow(db, show_dir, tv_data, provider="") · line 521 · returns (show_id, is_new)

The single most subtle function in the addon. Two prefix candidates:

  • prefixed_name — built from TMDb's canonical name (tv_data["name"]).
  • prefixed_dirname — built from show_dir (the local safe_filename(show_title)).

Lookup preference: prefixed_dirname first, then prefixed_name. This is critical because TMDb's Search-by-title can re-rank similar-named siblings — e.g. C4's "Buried" → TMDb returns "Buried Hearts" with a different tmdb_id. If we keyed purely on TMDb's canonical name, every subsequent sync would resolve the title to "Buried Hearts", insert a fresh tvshow row, and orphan the original "[C4] Buried" row — its idShow ends up empty forever because every episode rows' idShow points at the new row.

TV show lookup prefers prefixed_dirname before the TMDb canonical name

New rows are matched by the score-based picker above. On subsequent syncs the lookup still must not key on the TMDb canonical name alone (prefixed_name) — TMDb's search relevance re-ranks similar-named siblings over time, so the same catalogue title could otherwise resolve to a different canonical name and fragment the show across two tvshow rows. Keying the existing-row lookup on the dir-name (prefixed_dirname) first keeps the local identity stable; the canonical name is only a fallback.

Besides the lookup order, this function also:

  • Assembles the all-important c10 ({"tmdb":"…","imdb":"…","tvdb":"…"}) JSON — required by Kodi's VideoInfoScanner before it'll call the scraper's GetEpisodeList. Kodi ignores the tvshow row's own idShow for episode enrichment; it reads the episodeguide from c10. If empty, episodes won't be fetched by Kodi, even though our own sync inserts episodes directly (this field still matters for the rare cases where Kodi tries to scan).
  • Inserts into tvshow, tvshowlinkpath, uniqueid (tmdb + imdb + tvdb), genre_link, and art (poster + fanart).
c10 wrapper tags — why we emit

Kodi's own VideoInfoScanner writes c10 in the wrapped form {"tmdb":"…"}, and Kodi's scraper code accepts both bare JSON and the wrapped form. We emit the wrapped form so addon-inserted rows have the same shape as Kodi-scraped rows when both coexist in the library — e.g. an Optima disk-source row next to a BBC plugin-source row in the same Kodi view.

Pre-existing bare-JSON rows written by older addon builds (about 6,000 on the production box) are not rewritten on sync — Kodi still reads them fine, so they're left untouched. Only freshly-inserted shows pick up the wrapped form.

_insert_episode(db, show_id, season_id, season_num, ep_num, ep_data, strm_url, strm_filename="", show_name="") · line 625

Inserts a single episode. Idempotency check at line 626 by (idShow, c12=season, c13=episode) triple. Builds ep_c00 as "Show Name - Episode Title" when different, just like Kodi's own scraper does. Inserts into files (with the full plugin URL as strFilename — see below), episode, uniqueid (tmdb episode id), art (still thumbnail).

strFilename holds the full plugin URL — same string as episode.c18

For every row the addon writes, files.strFilename is set to the full plugin://plugin.video.easyplaytv/?action=resolve_vod&provider=…&episode_id=… URL — the same string that goes into episode.c18. There's no ?-only truncation. The _insert_episode function picks plugin_filename = strm_url when strm_url starts with plugin:// (the normal path — library_sync's own _build_episode_strm_url (referenced in strm.py, now moved inline) always emits full plugin://plugin.video.easyplaytv/… URLs), and only falls back to a placeholder plugin://plugin.video.easyplaytv/?action=resolve_vod&provider=unknown&episode_id=0 when a non-plugin:// value somehow reaches it (xbmc.LOGWARNING emitted in that case). The same defensive shape exists in _insert_movie for symmetry. This differs from how Kodi's own scanner populates disk-based rows, where strFilename is the bare basename (e.g. S01E01 - 12AM-1AM.mkv) and c18 is the full http://…/Season 1/S01E01 - 12AM-1AM.mkv.

Practical consequence: when Kodi's library window renders an EasyPlayTV episode row, the filename column shows the raw plugin://...?action=resolve_vod&... URL rather than a pretty basename. The skin's ListItem.Label falls back to episode.c00 (the display title) so the user sees "Doctor Who - Into the Dalek" in the title column, not the URL — but if a skin falls back to the filename for an empty-title row, the URL will leak through.

_ensure_season(db, show_id, season_num, poster_path=None) · line 613

Gets-or-creates a seasons row. Inserts poster art if TMDb returned a season poster.

State files

library_sync_state.json (movies) · series_sync_state.json (series)

Two JSON state files in data_dir (the addon profile dir for the Service addon, configured data_dir for standalone). Shape:

{
  "synced_titles": { "": {"tmdb_id": 123, "time": 1700000000.0} },
  "last_sync": 1700000000.0
}

Read on every sync start, written every 10 movies / 5 shows during a sync. synced_titles/synced_shows avoid re-fetching TMDb for shows the current pass already processed; the series file also carries absent_shows (the per-show consecutive-miss counter used by the absence sweep).

Stale-title cleanup (two cadences)

_sweep_movies_fast(db, provider_filter, films) · line 1463 · _sweep_absent_shows(db, provider_filter, fresh_safe_titles, absent) · line 1420

Runs on every sync pass. Movies and whole TV shows missing from the provider's current catalogue are removed, reusing the episode lists already fetched that pass (so the fast path costs nothing extra). A show is only deleted after ABSENT_SHOW_THRESHOLD (3) consecutive passes where it's missing — absence counts persist in series_sync_state.json and a reappearance resets them, guarding against transient upstream index gaps.

sweep_stale(db, provider, data_dir) · line 1506 (cadence stale_sweep_hours)

Slower pass that fetches each provider's complete current episode/movie id set upstream and deletes any library files / episode / movie row whose id is no longer present. Failsafe: if a provider's upstream set comes back empty, nothing is deleted that pass.

_sweep_episode(db, file_id) · line 1375 · _sweep_movie(db, movie_id, file_id) · line 1390 · _delete_tvshow_chain(db, show_id) · line 1399

Deleting a row must cascade through every child table manually — Kodi doesn't FK these in modern schemas (MyVideos131 has no foreign keys). Each helper drops art / uniqueid / genre_link / countrylink / streamdetails rows for the media entity, then the row itself (files too for episodes/movies). _delete_tvshow_chain loops the show's episodes (via idFile), deletes each episode's file + child rows, then seasons, tvshow art/uniqueids/genres, tvshowlinkpath and finally tvshow.

Caveat: the helpers hardcode countrylink, but modern Kodi schemas rename that table to country_link (MyVideos131). Where the table is absent that single DELETE raises 1146 — the per-sync sweeps wrap the chain in try/except so a sync completes (and the failure is logged) instead of aborting, at the cost of the affected show/episode rows surviving the pass. _sweep_movie is unaffected (it uses genre_link).

The entry point

sync_provider(db, provider, force=False, data_dir="") -> int · line 428

The function the scheduler's run_sync(provider) calls. Branches on whether a single provider is named:

  • If provider is given (the normal scheduled case): run _sync_series(provider_filter=provider) then _sync_movies(provider_filter=provider) — never both at once; the scheduler runs providers one-after-another in a single thread.
  • If provider is empty: _sync_series() + _sync_movies() over the full catalogue.
  • The movie pass also triggers _sweep_movies_fast and the series pass _sweep_absent_shows (per-sync stale removal); the wider sweep_stale pass runs on its own slower cadence under the scheduler.
  • Returns the combined count (movies + series synced) for the scheduler's log line.

Startup path reconciliation

_ensure_path · _ensure_path_id · _sanitize_scraper_settings_xml · lines 503, 277, 490

Path rows are reconciled lazily inside each sync pass — every call to _sync_series / _sync_movies starts by _ensure_path-ing its library root, so the plugin:// invariant self-heals on the first run after a fresh install without any boot-time migration. The stale-art/maint helpers (backfill_series_art, prefix_existing_tvshows, fix_sort_titles) were dropped in the standalone-service split — the runtime inserts with the [PROVIDER] prefix directly.