Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions addon.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="metadata.tvshows.themoviedb.org.python"
name="TMDb TV Shows"
version="2.0.5"
version="2.0.6"
provider-name="Team Kodi">
<requires>
<import addon="xbmc.python" version="3.0.1"/>
Expand All @@ -10,10 +10,10 @@
<extension point="xbmc.metadata.scraper.tvshows" library="main.py"/>
<extension point="xbmc.addon.metadata">
<reuselanguageinvoker>true</reuselanguageinvoker>
<news>2.0.5
- Show the full series cast in themoviedb.org order, limited to 200
- Request compressed responses from TMDb for faster scraping
- Restore Trakt ratings after their API access change
<news>2.0.6
- Show the season regular cast instead of everyone who ever appeared
- Fix episodes listing the whole series cast
- Fewer API calls when scraping a show
</news>
<platform>all</platform>
<license>GPL-3.0-or-later</license>
Expand Down
5 changes: 5 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
2.0.6
Show the season regular cast instead of everyone who ever appeared
Fix episodes listing the whole series cast
Fewer API calls when scraping a show

2.0.5
Show the full series cast in themoviedb.org order, limited to 200
Request compressed responses from TMDb for faster scraping
Expand Down
113 changes: 68 additions & 45 deletions lib/api/tmdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@
from lib.config import API_HEADERS, CACHE_LIMIT, TMDB_API_KEY

_BASE = 'https://api.themoviedb.org/3'
# aggregate credits push show responses past 2MB uncompressed
# season appends repeat the whole show payload
_HEADERS = dict(API_HEADERS, **{'Accept-Encoding': 'gzip'})
# TMDB rejects more than 20 appends
_MAX_APPENDS = 20
# aggregate credits can reach thousands on long-running shows
# soaps credit hundreds, aggregate fallback thousands
_MAX_CAST = 200
# {show_id: {'show': dict, 'episodes': {(s,e): dict}, 'season_cast': {s: list}}}
# {show_id: {'show': dict, 'episodes': {(s,e): dict}}}
_cache = OrderedDict()
_img_base = ''

Expand Down Expand Up @@ -57,18 +58,26 @@ def _top_role(member):
return (roles[0].get('character') or '') if roles else ''


def _set_series_cast(show):
"""Whole-series cast, trimmed, in the order themoviedb.org lists it."""
cast = (show.pop('aggregate_credits', None) or {}).get('cast') or []
if not cast:
return
show.setdefault('credits', {})['cast'] = [{
def _season_regulars(show):
"""Regulars across every season, deduped, in TMDB's billing order."""
found = OrderedDict()
for season in show.get('seasons', []):
for member in season.get('credits', {}).get('cast', []):
name = member.get('name', '')
if not name:
continue
billing = member.get('order')
billing = _MAX_CAST if billing is None else billing
# billing is show-wide, so keep the season they ranked highest in
if name not in found or billing < found[name][0]:
found[name] = [billing, member]
ranked = sorted(found.values(), key=lambda e: e[0])
return [{
'name': member.get('name', ''),
'character': _top_role(member),
'character': member.get('character', ''),
'order': i,
'profile_path': member.get('profile_path'),
} for i, member in enumerate(cast[:_MAX_CAST])]
log.debug('series cast: {}'.format(len(show['credits']['cast'])))
} for i, (_, member) in enumerate(ranked[:_MAX_CAST])]


class TmdbApi:
Expand Down Expand Up @@ -100,7 +109,7 @@ def find_by_external_id(self, external_id, source):
return str(results[0]['id']) if results else None

def get_show_details(self, show_id):
"""Show metadata + per-season images, batched and cached."""
"""Show metadata, per-season images and cast, batched and cached."""
show_id = str(show_id)
cached = _cache.get(show_id, {}).get('show')
if cached:
Expand All @@ -115,39 +124,65 @@ def get_show_details(self, show_id):
show = self._get('/tv/{}'.format(show_id), {
'language': self._lang,
'append_to_response': ','.join([
'credits', 'aggregate_credits', 'content_ratings',
'external_ids', 'images', 'videos', 'keywords',
'credits', 'content_ratings', 'external_ids',
'images', 'videos', 'keywords',
]),
'include_image_language': self._img_lang,
'include_video_language': self._img_lang,
})
if not show:
return None

_set_series_cast(show)

if not self._is_english and not show.get('overview'):
en = self._get('/tv/{}'.format(show_id), {'language': 'en-US'})
if en and en.get('overview'):
show['overview'] = en['overview']

self._attach_season_images(show)
self._attach_season_data(show)
self._set_series_cast(show_id, show)
_cache.setdefault(show_id, {})['show'] = show
return show

def _attach_season_images(self, show):
"""Batch-fetch and attach per-season images."""
def _set_series_cast(self, show_id, show):
"""Show cast from the season regulars, or the whole run if none exist."""
cast = _season_regulars(show)
if not cast:
cast = self._aggregate_cast(show_id)
if cast:
show.setdefault('credits', {})['cast'] = cast
log.debug('series cast: {}'.format(len(cast)))

def _aggregate_cast(self, show_id):
"""Fallback for shows credited per episode with no season regulars."""
data = self._get('/tv/{}/aggregate_credits'.format(show_id), {
'language': self._lang,
})
if not data:
return []
return [{
'name': member.get('name', ''),
'character': _top_role(member),
'order': i,
'profile_path': member.get('profile_path'),
} for i, member in enumerate(data.get('cast', [])[:_MAX_CAST])]

def _attach_season_data(self, show):
"""Batch-fetch and attach per-season images and regular cast."""
seasons = show.get('seasons', [])
season_map = {s.get('season_number', 0): s for s in seasons}
show_id = show['id']

season_keys = list(season_map.keys())
for i in range(0, len(season_keys), _MAX_APPENDS):
batch = season_keys[i:i + _MAX_APPENDS]
per_call = _MAX_APPENDS // 2
for i in range(0, len(season_keys), per_call):
batch = season_keys[i:i + per_call]
appends = []
for n in batch:
appends.append('season/{}/images'.format(n))
appends.append('season/{}/credits'.format(n))
data = self._get('/tv/{}'.format(show_id), {
'append_to_response': ','.join(
'season/{}/images'.format(n) for n in batch
),
'language': self._lang,
'append_to_response': ','.join(appends),
'include_image_language': self._img_lang,
})
if not data:
Expand All @@ -156,6 +191,9 @@ def _attach_season_images(self, show):
images = data.get('season/{}/images'.format(snum))
if images:
season_map[snum]['images'] = images
credits = data.get('season/{}/credits'.format(snum))
if credits:
season_map[snum]['credits'] = credits

def prefetch_episodes(self, show_id):
"""Pre-fetch all episode data for the entire show."""
Expand All @@ -175,15 +213,14 @@ def prefetch_episodes(self, show_id):
]

all_seasons = self._fetch_all_seasons(show_id, season_nums)
episodes, season_cast = self._fetch_episode_extras(
episodes = self._fetch_episode_extras(
show_id, season_nums, all_seasons
)

if not self._is_english:
self._episode_lang_fallback(show_id, episodes)

entry['episodes'] = episodes
entry['season_cast'] = season_cast

def _fetch_all_seasons(self, show_id, season_nums):
"""Phase 1: Fetch full season data via show endpoint appends."""
Expand All @@ -205,9 +242,9 @@ def _fetch_all_seasons(self, show_id, season_nums):
return result

def _fetch_episode_extras(self, show_id, season_nums, all_seasons):
"""Phase 2: Episode images + external_ids + season credits."""
"""Phase 2: Episode images and external_ids, two appends per episode."""
episodes = {}
season_cast = {}
per_call = _MAX_APPENDS // 2

for snum in season_nums:
sd = all_seasons.get(snum)
Expand All @@ -220,16 +257,12 @@ def _fetch_episode_extras(self, show_id, season_nums, all_seasons):
eps = [e for e in eps if 'episode_number' in e]
ep_nums = [e['episode_number'] for e in eps]
ep_by_num = {e['episode_number']: e for e in eps}
need_credits = True
i = 0

while i < len(ep_nums):
limit = 9 if need_credits else 10
batch = ep_nums[i:i + limit]
batch = ep_nums[i:i + per_call]

appends = []
if need_credits:
appends.append('credits')
for en in batch:
appends.extend([
'episode/{}/images'.format(en),
Expand All @@ -244,10 +277,6 @@ def _fetch_episode_extras(self, show_id, season_nums, all_seasons):
}
)

if data and need_credits and 'credits' in data:
season_cast[snum] = data['credits'].get('cast', [])
need_credits = False

if not data:
i += len(batch)
continue
Expand All @@ -267,7 +296,7 @@ def _fetch_episode_extras(self, show_id, season_nums, all_seasons):

i += len(batch)

return episodes, season_cast
return episodes

def _episode_lang_fallback(self, show_id, episodes):
"""Fill missing episode names/overviews from English."""
Expand Down Expand Up @@ -320,12 +349,6 @@ def get_episode(self, show_id, season_num, episode_num):
entry.setdefault('episodes', {})[(season_num, episode_num)] = data
return data

def get_season_cast(self, show_id, season_num):
"""Season regular cast from cache."""
return _cache.get(str(show_id), {}).get('season_cast', {}).get(
season_num, []
)

def get_cached_episodes(self, show_id):
"""All cached episodes for iteration. Empty dict if not prefetched."""
return _cache.get(str(show_id), {}).get('episodes', {})
Expand Down
22 changes: 4 additions & 18 deletions lib/scraper.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,8 +289,6 @@ def _getepisodedetails(handle, api, params, settings):
_fail(handle)
return

season_cast = api.get_season_cast(show_id, season_num)

# Show's IMDB ID needed for Trakt episode ratings
show = api.get_show_details(show_id)
show_imdb_id = ''
Expand All @@ -299,8 +297,7 @@ def _getepisodedetails(handle, api, params, settings):

li = xbmcgui.ListItem(ep.get('name', ''), offscreen=True)
_populate_episode(
li, ep, season_num, episode_num,
season_cast, settings, show_imdb_id
li, ep, season_num, episode_num, settings, show_imdb_id
)
xbmcplugin.setResolvedUrl(handle, True, li)

Expand Down Expand Up @@ -787,7 +784,7 @@ def _populate_show(li, show, settings, ep_grouping='', named_seasons=None,


def _populate_episode(li, ep, season_num, episode_num,
season_cast=None, settings=None, show_imdb_id=''):
settings=None, show_imdb_id=''):
vtag = li.getVideoInfoTag()

title = ep.get('name') or 'Episode {}'.format(episode_num)
Expand Down Expand Up @@ -875,19 +872,8 @@ def _populate_episode(li, ep, season_num, episode_num,
if runtime:
vtag.setDuration(runtime * 60)

# Cast: season regulars + guest stars (deduplicated)
cast = []
seen = set()
for member in (season_cast or []):
name = member.get('name', '')
if name and name not in seen:
seen.add(name)
cast.append(_make_actor(member))
for g in ep.get('guest_stars', []):
name = g.get('name', '')
if name and name not in seen:
seen.add(name)
cast.append(_make_actor(g))
# Kodi merges show cast into every episode, regulars already there
cast = [_make_actor(g) for g in ep.get('guest_stars', []) if g.get('name')]
if cast:
vtag.setCast(cast)

Expand Down