resources/lib/library_sync.py — the sync engine
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.
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
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 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.
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
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.
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.
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
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:
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.
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.
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
to EasyPlayTV's
parent Series/ path row in MyVideos.path. With verboselog=true, the TMDb
scraper dumps 20K+ log lines per /tv/
TMDb lookups and the disambiguation scorer
Thin wrappers over TMDb's search/movie, movie/,
search/tv, tv/,
tv/ 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.
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_similarityis 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_dateequal 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
The spine of the sync. Steps:
_fetch_all_series_shows(provider_filter)(line 803) — the provider modules' catalogue fetchers → one flat list of shows, tagged with_nav_mode(seriesvsplayable). Restricted to_nav_mode == "series"(and the provider filter if given) before processing.- Build
fresh_safe_titles— the stripped safe-filename set of the provider's current catalogue, handed to_sweep_absent_showsafter the loop for the per-sync absence sweep. - Query existing tvshow rows on
PLUGIN_SOURCE_PATHand buildexisting_safe_titles(anything) andexisting_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. - For each show (skipped if
safe_titlealready has episodes andforceis false): - 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}). - 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_idyet. - c.
_insert_tvshowonly once the episode list is non-empty, then insert episodes grouped by season via_ensure_season+_insert_episode. - d. STV episode_number=0 renumbering —
providers (notably STV) return episodes with
episode_number=0whenplayerSeriesis null; these are renumbered sequentially from 1 before insert, so no episode is silently dropped. - Track per-show
absent_showscounts and persist state toseries_sync_state.jsonevery 5 shows; sleep 1 s every 20 shows to be gentle on the TMDb API (no key quota, but the endpoints rate-limit). Also restoresabsent_showsfrom state so absence survives restarts. - After the loop:
_sweep_absent_shows(...)deletes shows absent forABSENT_SHOW_THRESHOLD(3) consecutive passes.
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.
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.
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
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).
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 fromshow_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.
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() JSON — required by Kodi's{"tmdb":"…","imdb":"…","tvdb":"…"} VideoInfoScannerbefore it'll call the scraper'sGetEpisodeList. Kodi ignores thetvshowrow's ownidShowfor episode enrichment; it reads theepisodeguidefrom 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, andart(poster + fanart).
Kodi's own VideoInfoScanner writes c10 in the wrapped
form ,
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.
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).
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.
Gets-or-creates a seasons row. Inserts poster art if TMDb
returned a season poster.
State files
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)
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.
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.
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
The function the scheduler's
run_sync(provider) calls. Branches on whether a single provider
is named:
- If
provideris 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
provideris empty:_sync_series()+_sync_movies()over the full catalogue. - The movie pass also triggers
_sweep_movies_fastand the series pass_sweep_absent_shows(per-sync stale removal); the widersweep_stalepass 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
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.