mirror of
https://github.com/SickGear/SickGear.git
synced 2024-11-14 17:05:05 +00:00
Merge branch 'feature/AddTvdbV4' into dev
Some checks are pending
Python Unit Tests / windows (windows-latest, 3.10) (push) Waiting to run
Python Unit Tests / windows (windows-latest, 3.11) (push) Waiting to run
Python Unit Tests / windows (windows-latest, 3.12) (push) Waiting to run
Python Unit Tests / windows (windows-latest, 3.8) (push) Waiting to run
Python Unit Tests / windows (windows-latest, 3.9) (push) Waiting to run
Python Unit Tests / linux (ubuntu-latest, 3.10) (push) Waiting to run
Python Unit Tests / linux (ubuntu-latest, 3.11) (push) Waiting to run
Python Unit Tests / linux (ubuntu-latest, 3.12) (push) Waiting to run
Python Unit Tests / linux (ubuntu-latest, 3.8) (push) Waiting to run
Python Unit Tests / linux (ubuntu-latest, 3.9) (push) Waiting to run
Python Unit Tests / macos (macos-latest, 3.10) (push) Waiting to run
Python Unit Tests / macos (macos-latest, 3.11) (push) Waiting to run
Python Unit Tests / macos (macos-latest, 3.12) (push) Waiting to run
Python Unit Tests / macos (macos-latest, 3.8) (push) Waiting to run
Python Unit Tests / macos (macos-latest, 3.9) (push) Waiting to run
Some checks are pending
Python Unit Tests / windows (windows-latest, 3.10) (push) Waiting to run
Python Unit Tests / windows (windows-latest, 3.11) (push) Waiting to run
Python Unit Tests / windows (windows-latest, 3.12) (push) Waiting to run
Python Unit Tests / windows (windows-latest, 3.8) (push) Waiting to run
Python Unit Tests / windows (windows-latest, 3.9) (push) Waiting to run
Python Unit Tests / linux (ubuntu-latest, 3.10) (push) Waiting to run
Python Unit Tests / linux (ubuntu-latest, 3.11) (push) Waiting to run
Python Unit Tests / linux (ubuntu-latest, 3.12) (push) Waiting to run
Python Unit Tests / linux (ubuntu-latest, 3.8) (push) Waiting to run
Python Unit Tests / linux (ubuntu-latest, 3.9) (push) Waiting to run
Python Unit Tests / macos (macos-latest, 3.10) (push) Waiting to run
Python Unit Tests / macos (macos-latest, 3.11) (push) Waiting to run
Python Unit Tests / macos (macos-latest, 3.12) (push) Waiting to run
Python Unit Tests / macos (macos-latest, 3.8) (push) Waiting to run
Python Unit Tests / macos (macos-latest, 3.9) (push) Waiting to run
This commit is contained in:
commit
29047e44d3
264 changed files with 1518 additions and 82 deletions
|
@ -3,6 +3,10 @@
|
|||
* Update filelock 3.14.0 (8556141) to 3.15.4 (9a979df)
|
||||
* Update package resource API 68.2.2 (8ad627d) to 70.1.1 (222ebf9)
|
||||
* Update urllib3 2.2.1 (54d6edf) to 2.2.2 (27e2a5c)
|
||||
* Change add TheTVDb v4 support
|
||||
* Add menu Shows/"TVDb Cards"
|
||||
* Add a persons available socials (Youtube, LinkedIn, Reddit, Fansite, TikTok, Wikidata)
|
||||
* Change increase viewable history menu items from 13 to 15
|
||||
|
||||
|
||||
### 3.32.8 (2024-10-07 00:30:00 UTC)
|
||||
|
@ -343,7 +347,6 @@
|
|||
* Add config to change media process log message if there is no media to process
|
||||
* Change view-show text "invalid timeformat" to "time unknown"
|
||||
* Add menu Shows/"TMDB Cards"
|
||||
* Add a persons available socials (Youtube, LinkedIn, Reddit, Fansite, TikTok, Wikidata)
|
||||
* Change use TVDb genres on view-show if config/General/Interface/"Enable IMDb info" is disabled
|
||||
* Fix TVDb api episode issues
|
||||
* Change remove Python 3.7 from CI
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
#import re
|
||||
#import sickgear
|
||||
#from sickgear import TVInfoAPI
|
||||
#from sickgear.indexers.indexer_config import TVINFO_TMDB, TVINFO_TRAKT, TVINFO_TVMAZE
|
||||
#from sickgear.indexers.indexer_config import TVINFO_TMDB, TVINFO_TVDB, TVINFO_TRAKT, TVINFO_TVMAZE
|
||||
#from sickgear.helpers import anon_url
|
||||
#from sickgear.tv import PersonGenders
|
||||
#from sg_helpers import spoken_height
|
||||
|
@ -186,7 +186,7 @@ def param(visible=True, rid=None, cache_person=None, cache_char=None, person=Non
|
|||
#end if
|
||||
|
||||
#set $section_links = False
|
||||
#set $all_sources = $TVInfoAPI().all_sources
|
||||
#set $all_sources = $TVInfoAPI().all_non_fallback_sources
|
||||
#for $cur_src, $cur_sid in sorted(iteritems($person.ids))
|
||||
#if $cur_src not in $all_sources:
|
||||
#continue
|
||||
|
@ -212,7 +212,7 @@ def param(visible=True, rid=None, cache_person=None, cache_char=None, person=Non
|
|||
</span>
|
||||
</div>
|
||||
#end if
|
||||
#set $src = (($TVINFO_TVMAZE, 'tvm'), ($TVINFO_TMDB, 'tmdb'), ($TVINFO_TRAKT, 'trakt'))
|
||||
#set $src = (($TVINFO_TVDB, 'tvdb'), ($TVINFO_TVMAZE, 'tvm'), ($TVINFO_TMDB, 'tmdb'), ($TVINFO_TRAKT, 'trakt'))
|
||||
#if any([$person.ids.get($cur_src) for ($cur_src, _) in $src])
|
||||
<div>
|
||||
<span class="details-title">Other shows</span>
|
||||
|
|
|
@ -60,7 +60,7 @@
|
|||
|
||||
// Set initial text
|
||||
overviewEl.html('Fetching overview...');
|
||||
$.getJSON($.SickGear.Root + '/add-shows/tvm-get-showinfo', {
|
||||
$.getJSON($.SickGear.Root + '/add-shows/tvi-get-showinfo', {
|
||||
tvid_prodid: showcardEl.attr('data-id'),
|
||||
oldest_dt: $('#oldest').attr('data-oldest-dt'),
|
||||
newest_dt: $('#newest').attr('data-newest-dt'),
|
||||
|
@ -301,7 +301,7 @@ $(function() {
|
|||
var filterValue = this.value;
|
||||
if (-1 == filterValue.indexOf('trakt') && -1 == filterValue.indexOf('imdb') && -1 == filterValue.indexOf('mc_')
|
||||
&& -1 == filterValue.indexOf('tmdb_') && -1 == filterValue.indexOf('tvc_')
|
||||
&& -1 == filterValue.indexOf('tvm_')
|
||||
&& -1 == filterValue.indexOf('tvdb_') && -1 == filterValue.indexOf('tvm_')
|
||||
&& -1 == filterValue.indexOf('ne_') && -1 == filterValue.indexOf('_ne')
|
||||
&& -1 == filterValue.indexOf('default')) {
|
||||
var el$ = $('#container')
|
||||
|
@ -507,6 +507,14 @@ $(function() {
|
|||
<option value="tmdb_trending_today"#echo ('', selected)['trending_today' == $mode]#>Trending today</option>
|
||||
<option value="tmdb_trending_week"#echo ('', selected)['trending_week' == $mode]#>Trending this week</option>
|
||||
</optgroup>
|
||||
#elif 'TVDb' == $browse_type
|
||||
<optgroup label="TVDb">
|
||||
<option value="tvdb_upcoming"#echo ('', selected)['upcoming' == $mode]#>Upcoming</option>
|
||||
<option value="tvdb_toprated"#echo ('', selected)['toprated' == $mode and not $kwargs.get('year')]#>Top rated all time</option>
|
||||
#for $cur_y in $kwargs.get('rate_years') or []
|
||||
<option value="$cur_y[1]"#echo ('', selected)[$cur_y[0] == $kwargs.get('year')]#>$cur_y[2]</option>
|
||||
#end for
|
||||
</optgroup>
|
||||
#elif 'TVCalendar' == $browse_type
|
||||
<optgroup label="TVCalendar">
|
||||
#for $page in $kwargs.get('pages') or []
|
||||
|
@ -623,7 +631,7 @@ $(function() {
|
|||
#end if
|
||||
<div class="clearfix">
|
||||
#if $use_ratings or $use_votes
|
||||
<p>#if $use_ratings#<span class="rating">$this_show['rating']#if $re.search(r'^\d+(\.\d+)?$', (str($this_show['rating'])))#%</span>#end if##end if##if $use_votes#<i class="heart icon-glyph"></i><i>$this_show['votes'] $term_vote.lower()</i>#end if#</p>#slurp#
|
||||
<p>#if $this_show.get('rank')##$this_show.get('rank') #end if##if $use_ratings#<span class="rating">$this_show['rating']#if $re.search(r'^\d+(\.\d+)?$', (str($this_show['rating'])))#%</span>#end if##end if##if $use_votes#<i class="heart icon-glyph"></i><i>$this_show['votes'] $term_vote.lower()</i>#end if#</p>#slurp#
|
||||
#else
|
||||
<p> </p>
|
||||
#end if
|
||||
|
|
|
@ -177,6 +177,10 @@
|
|||
<li><a id="add-show-name" data-href="$sbRoot/add-shows/find/" tabindex="$tab#set $tab += 1#"><i class="sgicon-addshow"></i>
|
||||
<input class="form-control form-control-inline input-sm" type="text" placeholder="Search" tabindex="$tab#set $tab += 1#">
|
||||
<div class="menu-item-desc opacity60">find show at TV info source</div></a></li>
|
||||
#set $tvdb_modes = dict(tvdb_upcoming='upcoming', tvdb_toprated='top rated')
|
||||
#set $tvdb_mode = $tvdb_modes.get($sg_var('TVDB_MRU'), 'upcoming')
|
||||
<li><a href="$sbRoot/add-shows/tvdb-default/" tabindex="$tab#set $tab += 1#"><i class="sgicon-addshow"></i>TVDb Cards
|
||||
<div class="menu-item-desc opacity60">$tvdb_mode...</div></a></li>
|
||||
#set $tvm_modes = dict(tvm_premieres='new shows', tvm_returning='returning')
|
||||
#set $tvm_mode = $tvm_modes.get($sg_var('TVM_MRU'), 'new shows')
|
||||
<li><a href="$sbRoot/add-shows/tvm-default/" tabindex="$tab#set $tab += 1#"><i class="sgicon-tvmaze"></i>TVmaze Cards
|
||||
|
|
|
@ -27,7 +27,6 @@ import warnings
|
|||
from bs4_parser import BS4Parser
|
||||
from collections import OrderedDict
|
||||
from sg_helpers import clean_data, get_url, try_int
|
||||
from sickgear import ENV
|
||||
|
||||
from lib.cachecontrol import CacheControl, caches
|
||||
from lib.dateutil.parser import parse
|
||||
|
@ -46,6 +45,8 @@ if False:
|
|||
from typing import Any, AnyStr, Dict, List, Optional, Union
|
||||
|
||||
|
||||
ENV = os.environ
|
||||
|
||||
THETVDB_V2_API_TOKEN = {'token': None, 'datetime': datetime.datetime.fromordinal(1)}
|
||||
log = logging.getLogger('tvdb.api')
|
||||
log.addHandler(logging.NullHandler())
|
||||
|
|
1218
lib/api_tvdb/tvdb_api_v4.py
Normal file
1218
lib/api_tvdb/tvdb_api_v4.py
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1809,7 +1809,7 @@ def is_virtualenv():
|
|||
def enforce_type(value, allowed_types, default):
|
||||
# type: (Any, Union[Type, Tuple[Type]], Any) -> Any
|
||||
"""
|
||||
enforces that value is given type(s)
|
||||
enforce value to supplied type(s)
|
||||
:param value: value to check
|
||||
:param allowed_types: type or tuple of types allowed
|
||||
:param default: value to return if other type
|
||||
|
@ -1817,3 +1817,12 @@ def enforce_type(value, allowed_types, default):
|
|||
if not isinstance(value, allowed_types):
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def clean_str(value):
|
||||
# type: (Any) -> AnyStr
|
||||
"""
|
||||
clean and enforced a value to a string type
|
||||
:param value: to process
|
||||
"""
|
||||
return enforce_type(clean_data(value), str, '')
|
||||
|
|
|
@ -247,13 +247,8 @@ class TVInfoSocialIDs(object):
|
|||
return self.__getitem__(key)
|
||||
|
||||
def keys(self):
|
||||
for k, v in iter(((TVINFO_TWITTER, self.twitter), (TVINFO_INSTAGRAM, self.instagram),
|
||||
(TVINFO_FACEBOOK, self.facebook), (TVINFO_TIKTOK, self.tiktok),
|
||||
(TVINFO_WIKIPEDIA, self.wikipedia), (TVINFO_WIKIDATA, self.wikidata),
|
||||
(TVINFO_REDDIT, self.reddit), (TVINFO_YOUTUBE, self.youtube),
|
||||
(TVINFO_LINKEDIN, self.linkedin), (TVINFO_FANSITE, self.fansite))):
|
||||
if None is not v:
|
||||
yield k
|
||||
for k, v in self.__iter__():
|
||||
yield k
|
||||
|
||||
def __iter__(self):
|
||||
for s, v in iter(((TVINFO_TWITTER, self.twitter), (TVINFO_INSTAGRAM, self.instagram),
|
||||
|
@ -329,7 +324,7 @@ class TVInfoImageSize(object):
|
|||
|
||||
class TVInfoImage(object):
|
||||
def __init__(self, image_type, sizes, img_id=None, main_image=False, type_str='', rating=None, votes=None,
|
||||
lang=None, height=None, width=None, aspect_ratio=None, updated_at=None):
|
||||
lang=None, height=None, width=None, aspect_ratio=None, updated_at=None, has_text=None):
|
||||
self.img_id = img_id # type: Optional[integer_types]
|
||||
self.image_type = image_type # type: integer_types
|
||||
self.sizes = sizes # type: Union[TVInfoImageSize, Dict]
|
||||
|
@ -341,6 +336,7 @@ class TVInfoImage(object):
|
|||
self.height = height # type: Optional[integer_types]
|
||||
self.width = width # type: Optional[integer_types]
|
||||
self.aspect_ratio = aspect_ratio # type: Optional[Union[float, integer_types]]
|
||||
self.has_text = has_text # type: Optional[bool]
|
||||
self.updated_at = updated_at # type: Optional[integer_types]
|
||||
|
||||
def __eq__(self, other):
|
||||
|
@ -951,9 +947,21 @@ class TVInfoPerson(PersonBase):
|
|||
|
||||
|
||||
class TVInfoCharacter(PersonBase):
|
||||
def __init__(self, person=None, voice=None, plays_self=None, regular=None, ti_show=None, start_year=None,
|
||||
end_year=None, ids=None, name=None, episode_count=None, guest_episodes_numbers=None, **kwargs):
|
||||
# type: (List[TVInfoPerson], bool, bool, bool, TVInfoShow, int, int, TVInfoIDs, AnyStr, int, Dict[int, List[int]], ...) -> None
|
||||
def __init__(self,
|
||||
person=None, # type: List[TVInfoPerson]
|
||||
voice=None, # type: bool
|
||||
plays_self=None, # type: bool
|
||||
regular=None, # type: bool
|
||||
ti_show=None, # type: TVInfoShow
|
||||
start_year=None, # type: int
|
||||
end_year=None, # type: int
|
||||
ids=None, # type: TVInfoIDs
|
||||
name=None, # type: AnyStr
|
||||
episode_count=None, # type: int
|
||||
guest_episodes_numbers=None, # type: Dict[int, List[int]]
|
||||
**kwargs):
|
||||
# type: (...) -> None
|
||||
|
||||
super(TVInfoCharacter, self).__init__(ids=ids, **kwargs)
|
||||
self.person = person # type: List[TVInfoPerson]
|
||||
self.voice = voice # type: Optional[bool]
|
||||
|
@ -1187,14 +1195,15 @@ class TVInfoBase(object):
|
|||
except (BaseException, Exception) as e:
|
||||
log.error('Error setting %s to cache: %s' % (key, ex(e)))
|
||||
|
||||
def get_person(self, p_id, get_show_credits=False, get_images=False, **kwargs):
|
||||
# type: (integer_types, bool, bool, Any) -> Optional[TVInfoPerson]
|
||||
def get_person(self, p_id, get_show_credits=False, get_images=False, include_guests=False, **kwargs):
|
||||
# type: (integer_types, bool, bool, bool, Any) -> Optional[TVInfoPerson]
|
||||
"""
|
||||
get person's data for id or list of matching persons for name
|
||||
|
||||
:param p_id: persons id
|
||||
:param get_show_credits: get show credits
|
||||
:param get_images: get images for person
|
||||
:param get_images: get person images
|
||||
:param include_guests: include guest roles
|
||||
:return: person object
|
||||
"""
|
||||
pass
|
||||
|
@ -1202,7 +1211,7 @@ class TVInfoBase(object):
|
|||
def _search_person(self, name=None, ids=None):
|
||||
# type: (AnyStr, Dict[integer_types, integer_types]) -> List[TVInfoPerson]
|
||||
"""
|
||||
search for person by name
|
||||
search by name for person
|
||||
:param name: name to search for
|
||||
:param ids: dict of ids to search
|
||||
:return: list of found person's
|
||||
|
@ -1212,7 +1221,7 @@ class TVInfoBase(object):
|
|||
def search_person(self, name=None, ids=None):
|
||||
# type: (AnyStr, Dict[integer_types, integer_types]) -> List[TVInfoPerson]
|
||||
"""
|
||||
search for person by name
|
||||
search by name for person
|
||||
:param name: name to search for
|
||||
:param ids: dict of ids to search
|
||||
:return: list of found person's
|
||||
|
@ -1234,8 +1243,8 @@ class TVInfoBase(object):
|
|||
seasonwides=False, fanart=False, actors=False, **kwargs):
|
||||
# type: (integer_types, AnyStr, bool, bool, bool, bool, bool, bool, bool, Optional[Any]) -> bool
|
||||
"""
|
||||
internal function that should be overwritten in class to get data for given show id
|
||||
:param sid: show id
|
||||
internal function that should be overwritten in subclass to get data
|
||||
:param sid: show id to get data for
|
||||
:param language: language
|
||||
:param get_ep_info: get episodes
|
||||
:param banners: load banners
|
||||
|
@ -1264,6 +1273,7 @@ class TVInfoBase(object):
|
|||
# type: (...) -> Optional[TVInfoShow]
|
||||
"""
|
||||
get data for show id
|
||||
|
||||
:param show_id: id of show
|
||||
:param load_episodes: load episodes
|
||||
:param banners: load banners
|
||||
|
@ -1330,8 +1340,12 @@ class TVInfoBase(object):
|
|||
self._old_config = None
|
||||
|
||||
# noinspection PyMethodMayBeStatic
|
||||
def _search_show(self, name=None, ids=None, lang=None, **kwargs):
|
||||
# type: (Union[AnyStr, List[AnyStr]], Dict[integer_types, integer_types], Optional[string_types], Optional[Any]) -> List[Dict]
|
||||
def _search_show(self,
|
||||
name=None, # type: Union[AnyStr, List[AnyStr]]
|
||||
ids=None, # type: Dict[integer_types, integer_types]
|
||||
lang=None, # type: Optional[string_types]
|
||||
**kwargs):
|
||||
# type: (...) -> List[Dict]
|
||||
"""
|
||||
internal search function to find shows, should be overwritten in class
|
||||
:param name: name to search for
|
||||
|
@ -1370,7 +1384,7 @@ class TVInfoBase(object):
|
|||
if None is lang:
|
||||
if self.config.get('language'):
|
||||
lang = self.config['language']
|
||||
lang = self.map_languages.get(lang, lang)
|
||||
lang = self.map_languages.get(lang, lang)
|
||||
if not name and not ids:
|
||||
log.debug('Nothing to search')
|
||||
raise BaseTVinfoShownotfound('Nothing to search')
|
||||
|
@ -1398,8 +1412,8 @@ class TVInfoBase(object):
|
|||
Since the nice-to-use tvinfo[1][24]['name] interface
|
||||
makes it impossible to do tvinfo[1][24]['name] = "name"
|
||||
and still be capable of checking if an episode exists
|
||||
so we can raise tvinfo_shownotfound, we have a slightly
|
||||
less pretty method of setting items.. but since the API
|
||||
so that we can raise tvinfo_shownotfound, we have a slightly
|
||||
less pretty method of setting items... but since the API
|
||||
is supposed to be read-only, this is the best way to
|
||||
do it!
|
||||
The problem is that calling tvinfo[1][24]['episodename'] = "name"
|
||||
|
@ -1454,6 +1468,7 @@ class TVInfoBase(object):
|
|||
# type: (integer_types, int, Any) -> List[TVInfoShow]
|
||||
"""
|
||||
return list of similar shows to given id
|
||||
|
||||
:param tvid: id to give similar shows for
|
||||
:param result_count: count of results requested
|
||||
"""
|
||||
|
@ -1463,6 +1478,7 @@ class TVInfoBase(object):
|
|||
# type: (integer_types, int, Any) -> List[TVInfoShow]
|
||||
"""
|
||||
list of recommended shows to the provided tv id
|
||||
|
||||
:param tvid: id to find recommended shows for
|
||||
:param result_count: result count to returned
|
||||
"""
|
||||
|
@ -1486,7 +1502,7 @@ class TVInfoBase(object):
|
|||
def get_top_rated(self, result_count=100, **kwargs):
|
||||
# type: (...) -> List[TVInfoShow]
|
||||
"""
|
||||
get top rated shows
|
||||
get top-rated shows
|
||||
"""
|
||||
return []
|
||||
|
||||
|
@ -1526,7 +1542,7 @@ class TVInfoBase(object):
|
|||
# type: (...) -> List[TVInfoShow]
|
||||
"""
|
||||
get most played shows
|
||||
:param result_count: how many results are suppose to be returned
|
||||
:param result_count: how many results are supposed to be returned
|
||||
"""
|
||||
return []
|
||||
|
||||
|
@ -1534,7 +1550,7 @@ class TVInfoBase(object):
|
|||
# type: (...) -> List[TVInfoShow]
|
||||
"""
|
||||
get most watched shows
|
||||
:param result_count: how many results are suppose to be returned
|
||||
:param result_count: how many results are supposed to be returned
|
||||
"""
|
||||
return []
|
||||
|
||||
|
@ -1542,7 +1558,7 @@ class TVInfoBase(object):
|
|||
# type: (...) -> List[TVInfoShow]
|
||||
"""
|
||||
get most collected shows
|
||||
:param result_count: how many results are suppose to be returned
|
||||
:param result_count: how many results are supposed to be returned
|
||||
"""
|
||||
return []
|
||||
|
||||
|
@ -1550,7 +1566,7 @@ class TVInfoBase(object):
|
|||
# type: (...) -> List[TVInfoShow]
|
||||
"""
|
||||
get most recommended shows
|
||||
:param result_count: how many results are suppose to be returned
|
||||
:param result_count: how many results are supposed to be returned
|
||||
"""
|
||||
return []
|
||||
|
||||
|
@ -1558,8 +1574,9 @@ class TVInfoBase(object):
|
|||
# type: (...) -> List[TVInfoShow]
|
||||
"""
|
||||
get recommended shows for account
|
||||
|
||||
:param account: account to get recommendations for
|
||||
:param result_count: how many results are suppose to be returned
|
||||
:param result_count: how many results are supposed to be returned
|
||||
"""
|
||||
return []
|
||||
|
||||
|
@ -1567,6 +1584,7 @@ class TVInfoBase(object):
|
|||
# type: (integer_types, List[integer_types], Any) -> List[integer_types]
|
||||
"""
|
||||
hide recommended show for account
|
||||
|
||||
:param account: account to get recommendations for
|
||||
:param show_ids: list of show_ids to no longer recommend for account
|
||||
:return: list of added ids
|
||||
|
@ -1577,6 +1595,7 @@ class TVInfoBase(object):
|
|||
# type: (integer_types, List[integer_types], Any) -> List[integer_types]
|
||||
"""
|
||||
unhide recommended show for account
|
||||
|
||||
:param account: account to get recommendations for
|
||||
:param show_ids: list of show_ids to be included in possible recommend for account
|
||||
:return: list of removed ids
|
||||
|
@ -1587,6 +1606,7 @@ class TVInfoBase(object):
|
|||
# type: (integer_types, Any) -> List[TVInfoShow]
|
||||
"""
|
||||
list hidden recommended show for account
|
||||
|
||||
:param account: account to get recommendations for
|
||||
:return: list of hidden shows
|
||||
"""
|
||||
|
@ -1596,8 +1616,9 @@ class TVInfoBase(object):
|
|||
# type: (...) -> List[TVInfoShow]
|
||||
"""
|
||||
get most watchlisted shows for account
|
||||
|
||||
:param account: account to get recommendations for
|
||||
:param result_count: how many results are suppose to be returned
|
||||
:param result_count: how many results are supposed to be returned
|
||||
"""
|
||||
return []
|
||||
|
||||
|
@ -1605,7 +1626,7 @@ class TVInfoBase(object):
|
|||
# type: (...) -> List[TVInfoShow]
|
||||
"""
|
||||
get anticipated shows
|
||||
:param result_count: how many results are suppose to be returned
|
||||
:param result_count: how many results are supposed to be returned
|
||||
"""
|
||||
return []
|
||||
|
||||
|
@ -1625,7 +1646,7 @@ class TVInfoBase(object):
|
|||
# Item is integer, treat as show id
|
||||
return self.get_show(item, (True, arg)[None is not arg], old_call=True)
|
||||
|
||||
# maybe adding this to make callee use showname so that i can bring in the new endpoint
|
||||
# maybe adding this to make callee use showname so that I can bring in the new endpoint
|
||||
if isinstance(arg, string_types) and 'Tvdb' == self.__class__.__name__:
|
||||
return self.search_show(item)
|
||||
|
||||
|
|
|
@ -625,6 +625,7 @@ MC_MRU = ''
|
|||
NE_MRU = ''
|
||||
TMDB_MRU = ''
|
||||
TVC_MRU = ''
|
||||
TVDB_MRU = ''
|
||||
TVM_MRU = ''
|
||||
|
||||
COOKIE_SECRET = b64encodestring(uuid.uuid4().bytes + uuid.uuid4().bytes)
|
||||
|
@ -773,7 +774,7 @@ def init_stage_1(console_logging):
|
|||
global USE_TRAKT, TRAKT_CONNECTED_ACCOUNT, TRAKT_ACCOUNTS, TRAKT_MRU, TRAKT_VERIFY, \
|
||||
TRAKT_USE_WATCHLIST, TRAKT_REMOVE_WATCHLIST, TRAKT_TIMEOUT, TRAKT_METHOD_ADD, TRAKT_START_PAUSED, \
|
||||
TRAKT_SYNC, TRAKT_DEFAULT_INDEXER, TRAKT_REMOVE_SERIESLIST, TRAKT_UPDATE_COLLECTION, \
|
||||
MC_MRU, NE_MRU, TMDB_MRU, TVC_MRU, TVM_MRU, \
|
||||
MC_MRU, NE_MRU, TMDB_MRU, TVC_MRU, TVDB_MRU, TVM_MRU, \
|
||||
USE_SLACK, SLACK_NOTIFY_ONSNATCH, SLACK_NOTIFY_ONDOWNLOAD, SLACK_NOTIFY_ONSUBTITLEDOWNLOAD, \
|
||||
SLACK_CHANNEL, SLACK_AS_AUTHED, SLACK_BOT_NAME, SLACK_ICON_URL, SLACK_ACCESS_TOKEN, \
|
||||
USE_DISCORD, DISCORD_NOTIFY_ONSNATCH, DISCORD_NOTIFY_ONDOWNLOAD, \
|
||||
|
@ -1214,6 +1215,7 @@ def init_stage_1(console_logging):
|
|||
NE_MRU = check_setting_str(CFG, 'NextEpisode', 'ne_mru', '')
|
||||
TMDB_MRU = check_setting_str(CFG, 'TMDB', 'tmdb_mru', '')
|
||||
TVC_MRU = check_setting_str(CFG, 'TVCalendar', 'tvc_mru', '')
|
||||
TVDB_MRU = check_setting_str(CFG, 'TVDb', 'tvdb_mru', '')
|
||||
TVM_MRU = check_setting_str(CFG, 'TVmaze', 'tvm_mru', '')
|
||||
|
||||
USE_PYTIVO = bool(check_setting_int(CFG, 'pyTivo', 'use_pytivo', 0))
|
||||
|
@ -1727,7 +1729,7 @@ def init_stage_2():
|
|||
background_mapping_task = threading.Thread(name='MAPPINGUPDATES', target=indexermapper.load_mapped_ids,
|
||||
kwargs={'load_all': True})
|
||||
|
||||
MEMCACHE['history_tab_limit'] = 13
|
||||
MEMCACHE['history_tab_limit'] = 15
|
||||
MEMCACHE['history_tab'] = History.menu_tab(MEMCACHE['history_tab_limit'])
|
||||
|
||||
try:
|
||||
|
@ -2298,6 +2300,9 @@ def _save_config(force=False, **kwargs):
|
|||
('TVCalendar', [
|
||||
('mru', TVC_MRU)
|
||||
]),
|
||||
('TVDb', [
|
||||
('mru', TVDB_MRU)
|
||||
]),
|
||||
('TVmaze', [
|
||||
('mru', TVM_MRU)
|
||||
]),
|
||||
|
|
|
@ -78,27 +78,40 @@ class TVInfoAPI(object):
|
|||
if sickgear.CACHE_DIR:
|
||||
return self.api_params['cache']
|
||||
|
||||
@staticmethod
|
||||
def _filter(condition):
|
||||
return dict([(int(x['id']), x['name']) for x in list(tvinfo_config.values()) if condition(x)])
|
||||
|
||||
@property
|
||||
def sources(self):
|
||||
# type: () -> Dict[int, AnyStr]
|
||||
return dict([(int(x['id']), x['name']) for x in list(tvinfo_config.values()) if not x['mapped_only'] and
|
||||
True is not x.get('fallback') and True is not x.get('people_only')])
|
||||
return self._filter(lambda x:
|
||||
not x['mapped_only'] and
|
||||
True is not x.get('fallback') and True is not x.get('people_only'))
|
||||
|
||||
@property
|
||||
def search_sources(self):
|
||||
# type: () -> Dict[int, AnyStr]
|
||||
return dict([(int(x['id']), x['name']) for x in list(tvinfo_config.values()) if not x['mapped_only'] and
|
||||
x.get('active') and not x.get('defunct') and True is not x.get('fallback')
|
||||
and True is not x.get('people_only')])
|
||||
return self._filter(lambda x:
|
||||
not x['mapped_only'] and x.get('active') and not x.get('defunct') and
|
||||
True is not x.get('fallback') and True is not x.get('people_only'))
|
||||
|
||||
@property
|
||||
def all_sources(self):
|
||||
# type: () -> Dict[int, AnyStr]
|
||||
"""
|
||||
:return: return all indexers including mapped only indexers excluding fallback indexers
|
||||
:return: return all indexers for show data including mapped only indexers excluding fallback indexers
|
||||
"""
|
||||
return dict([(int(x['id']), x['name']) for x in list(tvinfo_config.values()) if True is not x.get('fallback')
|
||||
and True is not x.get('people_only')])
|
||||
return self._filter(lambda x:
|
||||
True is not x.get('fallback') and True is not x.get('people_only'))
|
||||
|
||||
@property
|
||||
def all_non_fallback_sources(self):
|
||||
# type: (...) -> Dict[int, AnyStr]
|
||||
"""
|
||||
return all sources with the exclusion of fallback indexer
|
||||
"""
|
||||
return self._filter(lambda x: True is not x.get('fallback'))
|
||||
|
||||
@property
|
||||
def fallback_sources(self):
|
||||
|
@ -106,9 +119,9 @@ class TVInfoAPI(object):
|
|||
"""
|
||||
:return: return all fallback indexers
|
||||
"""
|
||||
return dict([(int(x['id']), x['name']) for x in list(tvinfo_config.values()) if True is x.get('fallback')])
|
||||
return self._filter(lambda x: True is x.get('fallback'))
|
||||
|
||||
@property
|
||||
def xem_supported_sources(self):
|
||||
# type: () -> Dict[int, AnyStr]
|
||||
return dict([(int(x['id']), x['name']) for x in list(tvinfo_config.values()) if x.get('xem_origin')])
|
||||
return self._filter(lambda x: x.get('xem_origin'))
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
from lib.api_tvdb.tvdb_api import Tvdb
|
||||
from lib.api_tvdb.tvdb_api_v4 import TvdbAPIv4
|
||||
import lib.api_tvdb.tvdb_api_v4
|
||||
from lib.api_trakt.indexerapiinterface import TraktIndexer
|
||||
from lib.api_tvmaze.tvmaze_api import TvMaze
|
||||
from lib.api_tmdb.tmdb_api import TmdbIndexer
|
||||
|
@ -23,8 +25,9 @@ tvinfo_config = {
|
|||
api_url='https://api.thetvdb.com/',
|
||||
id=TVINFO_TVDB,
|
||||
name='TheTVDB', slug='tvdb', kodi_slug='tvdb',
|
||||
module=Tvdb,
|
||||
api_params=dict(apikey='6cfd6399fd2bee018a8793da976f6522', language='en'),
|
||||
module=TvdbAPIv4,
|
||||
api_params=dict(apikey='6cfd6399fd2bee018a8793da976f6522',
|
||||
apikey_v4=b'm5uaxWhrm56TlWTGm5Jkk5uYZW-ea5uOnmqcmWmXZmVtxp2a', language='en'),
|
||||
active=True,
|
||||
dupekey='',
|
||||
mapped_only=False,
|
||||
|
@ -260,3 +263,5 @@ tvinfo_config[src].update(dict(
|
|||
show_url='%stv/%%d' % tvinfo_config[src]['main_url'],
|
||||
finder='%ssearch/tv?query=%s' % (tvinfo_config[src]['main_url'], '%s'),
|
||||
))
|
||||
|
||||
lib.api_tvdb.tvdb_api_v4.TVDB_API_CONFIG = tvinfo_config[TVINFO_TVDB]
|
||||
|
|
|
@ -73,7 +73,7 @@ class SBRotatingLogHandler(object):
|
|||
self.console_logging = False # type: bool
|
||||
self.log_lock = threading.Lock()
|
||||
self.log_types = ['sickgear', 'tornado.application', 'tornado.general', 'subliminal', 'adba', 'encodingKludge',
|
||||
'tvdb.api', 'TVInfo']
|
||||
'tvdb.api', 'TVInfo', 'tvdb_v4.api']
|
||||
self.external_loggers = ['sg.helper', 'api_trakt', 'api_trakt.api']
|
||||
self.log_types_null = ['tornado.access']
|
||||
|
||||
|
|
|
@ -96,6 +96,7 @@ from lib.api_trakt import TraktAPI
|
|||
from lib.api_trakt.exceptions import TraktException, TraktAuthException
|
||||
from lib.tvinfo_base import TVInfoEpisode, RoleTypes
|
||||
from lib.tvinfo_base.base import tv_src_names
|
||||
from lib.tvinfo_base.exceptions import *
|
||||
|
||||
import lib.rarfile.rarfile as rarfile
|
||||
|
||||
|
@ -112,6 +113,7 @@ if False:
|
|||
# from api_imdb.imdb_api import IMDbIndexer
|
||||
from api_tmdb.tmdb_api import TmdbIndexer
|
||||
from api_trakt.indexerapiinterface import TraktIndexer
|
||||
from api_tvdb.tvdb_api_v4 import TvdbAPIv4 as TvdbIndexer
|
||||
from api_tvmaze.tvmaze_api import TvMaze as TvmazeIndexer
|
||||
|
||||
|
||||
|
@ -6036,6 +6038,155 @@ class AddShows(Home):
|
|||
|
||||
return self.new_show('|'.join(['', '', '', show_name]), use_show_name=True)
|
||||
|
||||
def tvdb_default(self):
|
||||
method = getattr(self, sickgear.TVDB_MRU, None)
|
||||
if not callable(method) or not self.allow_browse_mru(sickgear.TVDB_MRU):
|
||||
return self.tvdb_upcoming()
|
||||
return method()
|
||||
|
||||
def tvdb_upcoming(self, **kwargs):
|
||||
return self.browse_tvdb(
|
||||
'Upcoming at TVDb', mode='upcoming', **kwargs)
|
||||
|
||||
def tvdb_toprated(self, **kwargs):
|
||||
return self.browse_tvdb(
|
||||
'Top rated at TVDb', mode='toprated', **kwargs)
|
||||
|
||||
def tvdb_person(self, person_tvdb_id=None, **kwargs):
|
||||
return self.browse_tvdb(
|
||||
'Person at TVDb', mode='person', p_id=person_tvdb_id, **kwargs)
|
||||
|
||||
def browse_tvdb(self, browse_title, **kwargs):
|
||||
|
||||
browse_type = 'TVDb'
|
||||
mode = kwargs.get('mode', '')
|
||||
|
||||
footnote = None
|
||||
filtered = []
|
||||
p_ref = None
|
||||
overview_ajax = 'person' == mode
|
||||
|
||||
tvid = TVINFO_TVDB
|
||||
tvinfo_config = sickgear.TVInfoAPI(tvid).api_params.copy()
|
||||
t = sickgear.TVInfoAPI(tvid).setup(**tvinfo_config) # type: Union[TvdbIndexer, TVInfoBase]
|
||||
|
||||
top_year = helpers.try_int(kwargs.get('year'), None)
|
||||
try:
|
||||
if 'upcoming' == mode:
|
||||
items = t.discover()
|
||||
elif 'person' == mode:
|
||||
items = []
|
||||
p_item = t.get_person(get_show_credits=True, include_guests=True, **kwargs) # type: TVInfoPerson
|
||||
if p_item:
|
||||
p_ref = f'{TVINFO_TVDB}:{p_item.id}'
|
||||
dup = {} # type: Dict[int, TVInfoShow]
|
||||
for c in p_item.characters: # type: TVInfoCharacter
|
||||
c.ti_show.cast[(RoleTypes.ActorGuest, RoleTypes.ActorMain)[True is c.regular]].append(c)
|
||||
if c.ti_show.id not in dup:
|
||||
dup[c.ti_show.id] = c.ti_show
|
||||
items.append(c.ti_show)
|
||||
else:
|
||||
dup[c.ti_show.id].cast[RoleTypes.ActorMain].extend(c.ti_show.cast[RoleTypes.ActorMain])
|
||||
dup[c.ti_show.id].cast[RoleTypes.ActorGuest].extend(c.ti_show.cast[RoleTypes.ActorGuest])
|
||||
del dup
|
||||
else:
|
||||
p_item = None
|
||||
else:
|
||||
items = t.get_top_rated(year=top_year, in_last_year=1 == dt_date.today().month and 7 > dt_date.today().day)
|
||||
except (BaseTVinfoError, BaseException, Exception) as e:
|
||||
return self.browse_shows(browse_type, browse_title, filtered, **kwargs)
|
||||
|
||||
ranking = dict((val, idx+1) for idx, val in
|
||||
enumerate(sorted([cur_show_info.rating or 0 for cur_show_info in items], reverse=True)))
|
||||
oldest, newest, oldest_dt, newest_dt, dedupe = None, None, 9999999, 0, []
|
||||
use_networks = False
|
||||
parseinfo = dateutil.parser.parserinfo(dayfirst=False, yearfirst=True)
|
||||
base_url = sickgear.TVInfoAPI(TVINFO_TVDB).config['show_url']
|
||||
for cur_show_info in items:
|
||||
if cur_show_info.id in dedupe or not cur_show_info.seriesname:
|
||||
continue
|
||||
dedupe += [cur_show_info.id]
|
||||
|
||||
try:
|
||||
airtime = cur_show_info.airs_time
|
||||
if not airtime or (0, 0) == (airtime.hour, airtime.minute):
|
||||
airtime = dateutil.parser.parse('23:59').time()
|
||||
dt = datetime.combine(
|
||||
dateutil.parser.parse(cur_show_info.firstaired, parseinfo).date(), airtime)
|
||||
ord_premiered, str_premiered, started_past, oldest_dt, newest_dt, oldest, newest, _, _, _, _ \
|
||||
= self.sanitise_dates(dt, oldest_dt, newest_dt, oldest, newest)
|
||||
|
||||
image = self._make_cache_image_url(tvid, cur_show_info)
|
||||
images = {} if not image else dict(poster=dict(thumb=image))
|
||||
|
||||
ids = dict(tvdb=cur_show_info.id)
|
||||
if cur_show_info.ids.imdb:
|
||||
ids['imdb'] = cur_show_info.ids.imdb
|
||||
|
||||
network_name = cur_show_info.network
|
||||
cc = 'US'
|
||||
if network_name:
|
||||
use_networks = True
|
||||
cc = cur_show_info.network_country_code or cc
|
||||
|
||||
language = ((cur_show_info.language and 'jap' in cur_show_info.language.lower())
|
||||
and 'jp' or 'en')
|
||||
|
||||
filtered.append(dict(
|
||||
ord_premiered=ord_premiered,
|
||||
str_premiered=str_premiered,
|
||||
started_past=started_past,
|
||||
episode_overview=self.clean_overview(cur_show_info),
|
||||
episode_season=cur_show_info.season,
|
||||
genres=(', '.join(cur_show_info.genre_list)
|
||||
or (cur_show_info.genre and (cur_show_info.genre.strip('|').replace('|', ', ')) or '')
|
||||
).lower(),
|
||||
ids=ids,
|
||||
images=images,
|
||||
overview=self.clean_overview(cur_show_info),
|
||||
overview_ajax=(0, 1)[overview_ajax],
|
||||
title=cur_show_info.seriesname,
|
||||
language=language,
|
||||
language_img=sickgear.MEMCACHE_FLAG_IMAGES.get(language, False),
|
||||
country=cc,
|
||||
country_img=sickgear.MEMCACHE_FLAG_IMAGES.get(cc.lower(), False),
|
||||
network=network_name,
|
||||
rating=False,
|
||||
url_src_db=base_url % cur_show_info.id,
|
||||
votes=cur_show_info.rating or 0,
|
||||
rank=cur_show_info.rating and ranking.get(cur_show_info.rating) or 0,
|
||||
))
|
||||
if p_ref:
|
||||
filtered[-1].update(dict(
|
||||
p_name=p_item.name or None,
|
||||
p_ref=p_ref,
|
||||
p_chars=self._make_char_person_list(cur_show_info)
|
||||
))
|
||||
except (BaseException, Exception):
|
||||
pass
|
||||
kwargs.update(dict(oldest=oldest, newest=newest, oldest_dt=oldest_dt, newest_dt=newest_dt, use_ratings=False, term_vote='Score'))
|
||||
|
||||
this_year = dt_date.today().year
|
||||
years = [
|
||||
(this_year - cur_y,
|
||||
'tvdb_toprated?year=%s' % (this_year - cur_y),
|
||||
'Top %s releases' % (this_year - cur_y))
|
||||
for cur_y in range(0, 10)]
|
||||
kwargs.update(dict(footnote=footnote, use_networks=use_networks, year=top_year or '', rate_years=years))
|
||||
|
||||
if mode and self.allow_browse_mru(mode):
|
||||
func = 'tvdb_%s' % mode
|
||||
if callable(getattr(self, func, None)):
|
||||
sickgear.TVDB_MRU = func
|
||||
sickgear.save_config()
|
||||
return self.browse_shows(browse_type, browse_title, filtered, **kwargs)
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
def info_tvdb(self, ids, show_name):
|
||||
|
||||
if not list(filter(lambda tvid_prodid: helpers.find_show_by_id(tvid_prodid), ids.split(' '))):
|
||||
return self.new_show('|'.join(['', '', '', ' '.join([ids, show_name])]), use_show_name=True)
|
||||
|
||||
def tvm_default(self):
|
||||
method = getattr(self, sickgear.TVM_MRU, None)
|
||||
if not callable(method) or not self.allow_browse_mru(sickgear.TMDB_MRU):
|
||||
|
@ -6065,13 +6216,16 @@ class AddShows(Home):
|
|||
return result.replace('.....', '...')
|
||||
return 'No overview yet'
|
||||
|
||||
def tvm_get_showinfo(self, tvid_prodid=None, oldest_dt=9999999, newest_dt=0):
|
||||
def tvi_get_showinfo(self, tvid_prodid=None, oldest_dt=9999999, newest_dt=0):
|
||||
result = {}
|
||||
if 'tvmaze' in tvid_prodid:
|
||||
if isinstance(tvid_prodid, str) and (tvid_prodid.startswith('tvmaze') or tvid_prodid.startswith('tvdb')):
|
||||
tvid = TVINFO_TVMAZE
|
||||
if tvid_prodid.startswith('tvdb'):
|
||||
tvid = TVINFO_TVDB
|
||||
|
||||
tvinfo_config = sickgear.TVInfoAPI(tvid).api_params.copy()
|
||||
t = sickgear.TVInfoAPI(tvid).setup(**tvinfo_config) # type: Union[TvmazeIndexer, TVInfoBase]
|
||||
show_info = t.get_show(int(tvid_prodid.replace('tvmaze:','')), load_episodes=False)
|
||||
show_info = t.get_show(int(re.sub('^[a-z]+?:', '', tvid_prodid)), load_episodes=False)
|
||||
|
||||
oldest_dt, newest_dt = int(oldest_dt), int(newest_dt)
|
||||
ord_premiered, str_premiered, started_past, old_dt, new_dt, oldest, newest, \
|
||||
|
@ -6234,7 +6388,7 @@ class AddShows(Home):
|
|||
|
||||
@staticmethod
|
||||
def sanitise_dates(date, oldest_dt, newest_dt, oldest, newest, episode_info=None, combine_ep_airtime=False):
|
||||
# in case of person search (tvmaze) guest starring entires have only show name/id, no dates
|
||||
# in case of person search (tvmaze) guest starring entries have only show name/id, no dates
|
||||
if None is date:
|
||||
return 9, '', True, oldest_dt, newest_dt, oldest, newest, True, 9, 'TBC', False
|
||||
parseinfo = dateutil.parser.parserinfo(dayfirst=False, yearfirst=True)
|
||||
|
@ -6283,7 +6437,7 @@ class AddShows(Home):
|
|||
def browse_mru(browse_type, **kwargs):
|
||||
save_config = False
|
||||
if browse_type in ('AniDB', 'IMDb', 'Metacritic', 'Trakt', 'TVCalendar',
|
||||
'TMDB', 'TVmaze', 'Nextepisode'):
|
||||
'TMDB', 'TVDb', 'TVmaze', 'Nextepisode'):
|
||||
save_config = True
|
||||
if browse_type in ('TVmaze',) and kwargs.get('showfilter') and kwargs.get('showsort'):
|
||||
sickgear.BROWSELIST_MRU.setdefault(browse_type, dict()) \
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue