Skip to content

Commit 4b5d8a9

Browse files
authored
Merge pull request #578 from romanvm/metadata.tvmaze@omega
[metadata.tvmaze@omega] 1.4.0
2 parents c4e1cdd + d15c718 commit 4b5d8a9

16 files changed

Lines changed: 2333 additions & 0 deletions

File tree

metadata.tvmaze/LICENSE.txt

Lines changed: 621 additions & 0 deletions
Large diffs are not rendered by default.

metadata.tvmaze/addon.xml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2+
<addon id="metadata.tvmaze"
3+
name="TVmaze"
4+
version="1.4.0"
5+
provider-name="Roman V.M.">
6+
<requires>
7+
<import addon="xbmc.python" version="3.0.1"/>
8+
<import addon="xbmc.metadata" version="2.1.0"/>
9+
<import addon="script.module.simple-requests"/>
10+
</requires>
11+
<extension point="xbmc.metadata.scraper.tvshows" library="main.py" cachepersistence="24:00"/>
12+
<extension point="xbmc.addon.metadata">
13+
<summary lang="en_GB">Fetch TV Show metadata from TVmaze.com</summary>
14+
<description lang="en_GB">TVmaze is a free user driven TV database curated by TV lovers all over the world. You can track your favorite shows from anywhere.
15+
We provide an API that can be used by anyone or service like Kodi to retrieve TV Metadata, show/episode/cast images, and much more.</description>
16+
<platform>all</platform>
17+
<license>GPL-3.0-or-later</license>
18+
<assets>
19+
<icon>resources/icon.png</icon>
20+
<fanart>resources/background.jpg</fanart>
21+
</assets>
22+
<website>https://www.tvmaze.com</website>
23+
<source>https://github.com/romanvm/kodi.tvmaze</source>
24+
<news>1.4.0:
25+
- Added support for show original language and season plot in Kodi v.22+
26+
- Internal changes.
27+
</news>
28+
<reuselanguageinvoker>true</reuselanguageinvoker>
29+
</extension>
30+
</addon>

metadata.tvmaze/libs/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# coding: utf-8
2+
# Author: Roman Miroshnychenko aka Roman V.M.
3+
# E-mail: roman1972@gmail.com

metadata.tvmaze/libs/actions.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
# Copyright (C) 2019, Roman Miroshnychenko aka Roman V.M.
2+
#
3+
# This program is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License, or
6+
# (at your option) any later version.
7+
#
8+
# This program is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU General Public License for more details.
12+
#
13+
# You should have received a copy of the GNU General Public License
14+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
15+
16+
"""Plugin route actions"""
17+
18+
import json
19+
import logging
20+
import sys
21+
from typing import Optional
22+
from urllib import parse as urllib_parse
23+
24+
import xbmcgui
25+
import xbmcplugin
26+
27+
from . import tvmaze_api, data_service
28+
from .kodi_utils import Settings
29+
30+
HANDLE = int(sys.argv[1])
31+
32+
33+
def find_show(title: str, year: Optional[str] = None) -> None:
34+
"""Find a show by title"""
35+
search_results = data_service.search_show(title, year)
36+
for search_result in search_results:
37+
show_name = search_result['name']
38+
if premiered := search_result.get('premiered'):
39+
show_name += f' ({premiered[:4]})'
40+
list_item = xbmcgui.ListItem(show_name, offscreen=True)
41+
data_service.add_basic_show_info(list_item, search_result)
42+
# Below "url" is some unique ID string (may be an actual URL to a show page)
43+
# that is used to get information about a specific TV show.
44+
xbmcplugin.addDirectoryItem(
45+
HANDLE,
46+
url=str(search_result['id']),
47+
listitem=list_item,
48+
isFolder=True
49+
)
50+
51+
52+
def parse_nfo_file(nfo: str):
53+
"""
54+
Analyze NFO file contents
55+
56+
This function is called either instead of find_show
57+
if a tvshow.nfo file is found in the TV show folder or for each episode
58+
if episode NFOs are present along with episode files.
59+
60+
:param nfo: the contents of an NFO file
61+
"""
62+
is_tvshow_nfo = True
63+
logging.debug('Trying to parse NFO file:\n%s', nfo)
64+
tvmaze_id = None
65+
full_nfo = Settings().get_value_bool('full_nfo')
66+
if '<episodedetails>' in nfo:
67+
if full_nfo:
68+
return
69+
is_tvshow_nfo = False
70+
tvmaze_id = data_service.get_tvmaze_episode_id_from_xml_nfo(nfo)
71+
if tvmaze_id is None:
72+
# We cannot resolve an episode by alternative IDs or by title/year from TVmaze API
73+
return
74+
if tvmaze_id is None and '<tvshow>' in nfo:
75+
if full_nfo:
76+
return
77+
tvmaze_id = data_service.get_tvmaze_show_id_from_xml_nfo(nfo)
78+
if tvmaze_id is None:
79+
tvmaze_id = data_service.get_tvmaze_show_id_from_url_nfo(nfo)
80+
if tvmaze_id is None:
81+
return
82+
list_item = xbmcgui.ListItem(offscreen=True)
83+
id_string = str(tvmaze_id)
84+
uniqueids = {'tvmaze': id_string}
85+
info_tag = list_item.getVideoInfoTag()
86+
info_tag.setUniqueIDs(uniqueids, 'tvmaze')
87+
if is_tvshow_nfo:
88+
episodeguide = json.dumps(uniqueids)
89+
info_tag.setEpisodeGuide(episodeguide)
90+
# "url" is some string that uniquely identifies a show.
91+
# It may be an actual URL of a TV show page.
92+
xbmcplugin.addDirectoryItem(
93+
HANDLE,
94+
url=id_string,
95+
listitem=list_item,
96+
isFolder=True
97+
)
98+
99+
100+
def get_details(show_id: Optional[str], unique_ids: Optional[str] = None) -> None:
101+
"""Get details about a specific show"""
102+
logging.debug('Getting details for show id %s', show_id)
103+
if not show_id and unique_ids is not None:
104+
show_id = data_service.get_tvmaze_show_id_from_json_episodeguide(unique_ids)
105+
if not show_id:
106+
logging.error('Unable to determine TVmaze show ID. show_id: %s, unique_ids: %s',
107+
show_id, unique_ids)
108+
xbmcplugin.setResolvedUrl(HANDLE, False, xbmcgui.ListItem(offscreen=True))
109+
return
110+
show_info = tvmaze_api.load_show_info(show_id)
111+
if show_info is None:
112+
xbmcplugin.setResolvedUrl(HANDLE, False, xbmcgui.ListItem(offscreen=True))
113+
return
114+
list_item = xbmcgui.ListItem(show_info['name'], offscreen=True)
115+
data_service.add_full_show_info(list_item, show_info)
116+
xbmcplugin.setResolvedUrl(HANDLE, True, list_item)
117+
118+
119+
def get_episode_list(episodeguide: str, episode_order: str) -> None: # pylint: disable=missing-docstring
120+
logging.debug('Getting episode list for episodeguide %s, order: %s',
121+
episodeguide, episode_order)
122+
show_id = None
123+
if episodeguide.startswith('{'):
124+
show_id = data_service.get_tvmaze_show_id_from_json_episodeguide(episodeguide)
125+
if show_id is None:
126+
logging.error('Unable to determine TVmaze show ID from episodeguide: %s', episodeguide)
127+
return
128+
if show_id is None and not episodeguide.isdigit():
129+
logging.warning('Invalid episodeguide format: %s (probably URL).', episodeguide)
130+
show_id = data_service.get_tvmaze_show_id_from_url_episodeguide(episodeguide)
131+
if show_id is None and episodeguide.isdigit():
132+
logging.warning('Invalid episodeguide format: %s (a numeric string). '
133+
'Please consider re-scanning the show to update episodeguide record.',
134+
episodeguide)
135+
show_id = episodeguide
136+
if show_id is None:
137+
return
138+
episodes_map = data_service.get_episodes_map(show_id, episode_order)
139+
for episode in episodes_map.values():
140+
list_item = xbmcgui.ListItem(episode['name'], offscreen=True)
141+
data_service.add_basic_episode_info(list_item, episode)
142+
encoded_ids = urllib_parse.urlencode({
143+
'show_id': show_id,
144+
'episode_id': str(episode['id']),
145+
'season': str(episode['season']),
146+
'episode': str(episode['number']),
147+
})
148+
# Below "url" is some unique ID string (it may be an actual URL to an episode page)
149+
# that allows to retrieve information about a specific episode.
150+
url = urllib_parse.quote(encoded_ids)
151+
xbmcplugin.addDirectoryItem(
152+
HANDLE,
153+
url=url,
154+
listitem=list_item,
155+
isFolder=True
156+
)
157+
158+
159+
def get_episode_details(encoded_ids: str, episode_order: str) -> None: # pylint: disable=missing-docstring
160+
encoded_ids = urllib_parse.unquote(encoded_ids)
161+
decoded_ids = dict(urllib_parse.parse_qsl(encoded_ids))
162+
logging.debug('Getting episode details for %s', decoded_ids)
163+
episode_info = data_service.get_episode_info(decoded_ids['show_id'],
164+
decoded_ids['episode_id'],
165+
decoded_ids['season'],
166+
decoded_ids['episode'],
167+
episode_order)
168+
if not episode_info:
169+
xbmcplugin.setResolvedUrl(HANDLE, False, xbmcgui.ListItem(offscreen=True))
170+
return
171+
list_item = xbmcgui.ListItem(episode_info['name'], offscreen=True)
172+
data_service.add_full_episode_info(list_item, episode_info)
173+
xbmcplugin.setResolvedUrl(HANDLE, True, list_item)
174+
175+
176+
def get_artwork(show_id: str) -> None:
177+
"""
178+
Get available artwork for a show
179+
180+
:param show_id: default unique ID set by setUniqueIDs() method
181+
182+
.. note:: This action does not seem to be used in Kodi v. 21 and above.
183+
You should set all shows artwork in the get_details action.
184+
"""
185+
logging.debug('Getting artwork for show ID %s', show_id)
186+
if show_id:
187+
show_info = tvmaze_api.load_show_info(show_id)
188+
if show_info is not None:
189+
list_item = xbmcgui.ListItem(show_info['name'], offscreen=True)
190+
data_service.set_show_artwork(show_info, list_item)
191+
xbmcplugin.setResolvedUrl(HANDLE, True, list_item)
192+
return
193+
xbmcplugin.setResolvedUrl(HANDLE, False, xbmcgui.ListItem(offscreen=True))
194+
195+
196+
def router(paramstring: str) -> None:
197+
"""
198+
Route addon calls
199+
200+
:param paramstring: url-encoded query string
201+
:raises RuntimeError: on unknown call action
202+
"""
203+
params = dict(urllib_parse.parse_qsl(paramstring))
204+
logging.debug('Called addon with params: %s', str(sys.argv))
205+
path_settings = json.loads(params.get('pathSettings') or '{}')
206+
logging.debug('Path settings: %s', path_settings)
207+
Settings().initialize(path_settings)
208+
episode_order = data_service.get_episode_order()
209+
if params['action'] == 'find':
210+
find_show(params['title'], params.get('year'))
211+
elif params['action'].lower() == 'nfourl':
212+
parse_nfo_file(params['nfo'])
213+
elif params['action'] == 'getdetails':
214+
url = params.get('url')
215+
unique_ids = params.get('uniqueIDs')
216+
get_details(url, unique_ids)
217+
elif params['action'] == 'getepisodelist':
218+
get_episode_list(params['url'], episode_order)
219+
elif params['action'] == 'getepisodedetails':
220+
get_episode_details(params['url'], episode_order)
221+
elif params['action'] == 'getartwork':
222+
get_artwork(params.get('id'))
223+
else:
224+
raise RuntimeError(f'Invalid addon call: {sys.argv}')
225+
xbmcplugin.endOfDirectory(HANDLE)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Copyright (C) 2019, Roman Miroshnychenko aka Roman V.M.
2+
#
3+
# This program is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License, or
6+
# (at your option) any later version.
7+
#
8+
# This program is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU General Public License for more details.
12+
#
13+
# You should have received a copy of the GNU General Public License
14+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
15+
16+
"""Cache-related functionality"""
17+
18+
import json
19+
import logging
20+
import time
21+
from pathlib import Path
22+
from typing import Optional, Text, Dict, Any, Union
23+
24+
import xbmcgui
25+
import xbmcvfs
26+
27+
from .kodi_utils import ADDON_ID
28+
29+
EPISODES_CACHE_TTL_SECONDS = 60 * 10 # 10 minutes
30+
31+
32+
class MemoryCache:
33+
_instance = None
34+
CACHE_KEY = f'__{ADDON_ID}_cache__'
35+
36+
def __new__(cls):
37+
if cls._instance is None:
38+
cls._instance = super().__new__(cls)
39+
return cls._instance
40+
41+
def __init__(self):
42+
self._window = xbmcgui.Window(10000)
43+
44+
def set(self, obj_id: Union[int, str], obj: Any) -> None:
45+
cache = {
46+
'id': obj_id,
47+
'timestamp': time.monotonic(),
48+
'object': obj,
49+
}
50+
cache_json = json.dumps(cache)
51+
self._window.setProperty(self.CACHE_KEY, cache_json)
52+
53+
def get(self, obj_id: Union[int, str]) -> Optional[Any]:
54+
cache_json = self._window.getProperty(self.CACHE_KEY)
55+
if not cache_json:
56+
logging.debug('Memory cache empty')
57+
return None
58+
try:
59+
cache = json.loads(cache_json)
60+
except ValueError as exc:
61+
logging.debug('Memory cache error: %s', exc)
62+
return None
63+
if (cache['id'] != obj_id
64+
or time.monotonic() - cache['timestamp'] > EPISODES_CACHE_TTL_SECONDS):
65+
logging.debug('Memory cache miss')
66+
return None
67+
logging.debug('Memory cache hit')
68+
return cache['object']
69+
70+
71+
def cache_episodes_map(show_id: Union[int, str], episodes_map: Dict[Text, Any]) -> None:
72+
MemoryCache().set(int(show_id), episodes_map)
73+
74+
75+
def load_episodes_map_from_cache(show_id: Union[int, str]) -> Optional[Dict[str, Any]]:
76+
episodes_map = MemoryCache().get(int(show_id))
77+
return episodes_map
78+
79+
80+
def _get_cache_directory() -> Path: # pylint: disable=missing-docstring
81+
temp_dir = Path(xbmcvfs.translatePath('special://temp'))
82+
cache_dir = temp_dir / 'scrapers' / ADDON_ID
83+
if not xbmcvfs.exists(str(cache_dir)):
84+
xbmcvfs.mkdir(str(cache_dir))
85+
return cache_dir
86+
87+
88+
def cache_show_info(show_info: Dict[str, Any]) -> None:
89+
"""
90+
Save show_info dict to cache
91+
"""
92+
cache_dir = _get_cache_directory()
93+
cache_file = cache_dir / f'{show_info["id"]}.json'
94+
with cache_file.open('w', encoding='utf-8') as fo:
95+
json.dump(show_info, fo)
96+
97+
98+
def load_show_info_from_cache(show_id: Union[int, str]) -> Optional[Dict[str, Any]]:
99+
"""
100+
Load show info from a local cache
101+
102+
:param show_id: show ID on TVmaze
103+
:return: show_info dict or None
104+
"""
105+
cache_dir = _get_cache_directory()
106+
cache_file = cache_dir / f'{show_id}.json'
107+
try:
108+
with cache_file.open('r', encoding='utf-8') as fo:
109+
show_info = json.load(fo)
110+
logging.debug('Show info cache hit')
111+
return show_info
112+
except (IOError, EOFError, ValueError) as exc:
113+
logging.debug('Cache error: %s %s', type(exc), exc)
114+
return None

0 commit comments

Comments
 (0)