Skip to content

Instantly share code, notes, and snippets.

@CarstenG2
Last active March 8, 2026 12:35
Show Gist options
  • Select an option

  • Save CarstenG2/667da3900404e05ddeb79b5eec5e0c03 to your computer and use it in GitHub Desktop.

Select an option

Save CarstenG2/667da3900404e05ddeb79b5eec5e0c03 to your computer and use it in GitHub Desktop.
xStream trailer: per-language priority search (Version: 2026-03-08) — code + design docs
# -*- coding: utf-8 -*-
# Python 3
import sys
import xbmc
import xbmcgui
import xbmcplugin
from resources.lib import utils
from resources.lib.config import cConfig
from resources.lib.gui.contextElement import cContextElement
from resources.lib.gui.guiElement import cGuiElement
from resources.lib.handler.ParameterHandler import ParameterHandler
from urllib.parse import quote_plus, urlencode
class cGui:
# This class "abstracts" a list of xbmc listitems.
def __init__(self):
try:
self.pluginHandle = int(sys.argv[1])
except:
self.pluginHandle = 0
try:
self.pluginPath = sys.argv[0]
except:
self.pluginPath = ''
self.isMetaOn = cConfig().getSetting('TMDBMETA') == 'true'
if cConfig().getSetting('metaOverwrite') == 'true':
self.metaMode = 'replace'
else:
self.metaMode = 'add'
# for globalSearch or alterSearch
self.globalSearch = False
self._collectMode = False
self._isViewSet = False
self.searchResults = []
def addFolder(self, oGuiElement, params='', bIsFolder=True, iTotal=0, isHoster=False):
# add GuiElement to Gui, adds listitem to a list
# abort xbmc list creation if user requests abort
if xbmc.Monitor().abortRequested():
self.setEndOfDirectory(False)
raise RuntimeError('UserAborted')
# store result in list if we searched global for other sources
if self._collectMode:
import copy
self.searchResults.append({'guiElement': oGuiElement, 'params': copy.deepcopy(params), 'isFolder': bIsFolder})
return
if not oGuiElement._isMetaSet and self.isMetaOn and oGuiElement._mediaType and iTotal < 100:
tmdbID = params.getValue('tmdbID')
if tmdbID:
oGuiElement.getMeta(oGuiElement._mediaType, tmdbID, mode=self.metaMode)
else:
oGuiElement.getMeta(oGuiElement._mediaType, mode=self.metaMode)
sUrl = self.__createItemUrl(oGuiElement, bIsFolder, params)
#kasi
try:
if params.exist('trumb'): oGuiElement.setIcon(params.getValue('trumb'))
except:
pass
listitem = self.createListItem(oGuiElement)
if not bIsFolder and cConfig().getSetting('hosterSelect') == 'List':
bIsFolder = True
if isHoster:
bIsFolder = False
listitem = self.__createContextMenu(oGuiElement, listitem, bIsFolder, sUrl)
if not bIsFolder:
listitem.setProperty('IsPlayable', 'true')
xbmcplugin.addDirectoryItem(self.pluginHandle, sUrl, listitem, bIsFolder, iTotal)
def addNextPage(self, site, function, params=''):
guiElement = cGuiElement(cConfig().getLocalizedString(30279), site, function)
self.addFolder(guiElement, params)
def searchNextPage(self, sTitle, site, function, params=''):
guiElement = cGuiElement(sTitle, site, function)
self.addFolder(guiElement, params)
def createListItem(self, oGuiElement):
itemValues = oGuiElement.getItemValues()
itemTitle = oGuiElement.getTitle()
infoString = ''
if self.globalSearch: # Reihenfolge der zu anzeigenden GUI Elemente
infoString += ' %s' % oGuiElement.getSiteName()
if oGuiElement._sLanguage != '':
infoString += ' (%s)' % oGuiElement._sLanguage
if oGuiElement._sSubLanguage != '':
infoString += ' *Sub: %s*' % oGuiElement._sSubLanguage
if oGuiElement._sQuality != '':
infoString += ' [%s]' % oGuiElement._sQuality
if oGuiElement._sInfo != '':
infoString += ' [%s]' % oGuiElement._sInfo
# if self.globalSearch:
# infoString += ' %s' % oGuiElement.getSiteName()
if infoString:
infoString = '[I]%s[/I]' % infoString
itemValues['title'] = itemTitle + infoString
try:
if not 'plot' in str(itemValues) or itemValues['plot'] == '':
itemValues['plot'] = ' ' #kasi Alt 255
except:
pass
#listitem = xbmcgui.ListItem(itemTitle + infoString, oGuiElement.getIcon(), oGuiElement.getThumbnail())
listitem = xbmcgui.ListItem(itemTitle + infoString)
# Function: setInfo(type, infoLabels)
# listitem.setInfo('video', { 'genre': 'Comedy' })
listitem.setInfo(oGuiElement.getType(), itemValues)
#Wenn Kodi 19, dann ignoriere setinfotagvideo
kodi_version = xbmc.getInfoLabel('System.BuildVersion')
if kodi_version[:2] > '19':
self.setInfoTagVideo(oGuiElement, listitem)
listitem.setProperty('fanart_image', oGuiElement.getFanart())
listitem.setArt({'icon': oGuiElement.getIcon(), 'thumb': oGuiElement.getThumbnail(), 'poster': oGuiElement.getThumbnail(), 'fanart': oGuiElement.getFanart()})
aProperties = oGuiElement.getItemProperties()
if len(aProperties) > 0:
for sPropertyKey in aProperties.keys():
listitem.setProperty(sPropertyKey, aProperties[sPropertyKey])
return listitem
### ÄNDERUNG ANFANG ###
def setInfoTagVideo(self, oGuiElement, listitem):
itemValues = oGuiElement.getItemValues()
vtag = listitem.getVideoInfoTag()
vtag.setMediaType(oGuiElement.getType())
# Titel ist bereits gesetzt und der Infostring geht hier verloren, wenn man den Titel erneut setzt
#if 'title' in itemValues:
# try:
# vtag.setTitle(itemValues['title'])
# except: pass
if 'plot' in itemValues:
try:
vtag.setPlot(itemValues['plot'])
except: pass
if 'year' in itemValues:
try:
vtag.setYear(int(itemValues['year']))
except: pass
if 'season' in itemValues:
try:
vtag.setSeason(int(itemValues['season']))
except: pass
if 'episode' in itemValues:
try:
vtag.setEpisode(int(itemValues['episode']))
except: pass
if 'TVShowTitle' in itemValues:
try:
vtag.setTvShowTitle(itemValues['TVShowTitle'])
except: pass
if 'cast' in itemValues:
try:
vtag.setCast([xbmc.Actor(cast[0], cast[1], thumbnail=cast[2]) for cast in itemValues['cast']])
except: pass
if 'countries' in itemValues:
try:
vtag.setCountries(itemValues['countries'])
except: pass
if 'country' in itemValues:
try:
vtag.setCountries([itemValues['country']])
except: pass
if 'dateadded' in itemValues:
try:
vtag.setDateAdded(itemValues['dateadded'])
except: pass
if 'directors' in itemValues:
try:
vtag.setDirectors(itemValues['directors'])
except: pass
if 'duration' in itemValues:
# minuten in sekunden umrechnen
try:
vtag.setDuration(int(itemValues['duration']) * 60)
except: pass
if 'rating' in itemValues:
try:
vtag.setRating(float(itemValues['rating']))
except: pass
if 'genre' in itemValues:
try:
vtag.setGenres(itemValues['genres'].split(' / '))
except: pass
if 'imdb_id' in itemValues:
try:
vtag.setUniqueID(str(itemValues['imdb_id']), 'imdb')
except: pass
if 'tmdb_id' in itemValues:
try:
vtag.setUniqueID(str(itemValues['tmdb_id']), 'tmdb')
except: pass
if 'originaltitle' in itemValues:
try:
vtag.setOriginalTitle(itemValues['originaltitle'])
except: pass
if 'trailer' in itemValues:
try:
vtag.setTrailer(itemValues['trailer'])
except: pass
if 'tagline' in itemValues:
try:
vtag.setTagLine(itemValues['tagline'])
except: pass
# Kodi versucht Bilder er Studios zu finden, was zu Fehlern im Logfile führt
#if 'studio' in itemValues:
# try:
# vtag.setStudios(itemValues['studio'].split(' / '))
# except: pass
if 'premiered' in itemValues:
try:
vtag.setPremiered(itemValues['premiered'])
except: pass
### ÄNDERUNG ENDE ###
def __createContextMenu(self, oGuiElement, listitem, bIsFolder, sUrl):
contextmenus = []
if len(oGuiElement.getContextItems()) > 0:
for contextitem in oGuiElement.getContextItems():
params = contextitem.getOutputParameterHandler()
sParams = params.getParameterAsUri()
sTest = "%s?site=%s&function=%s&%s" % (self.pluginPath, contextitem.getFile(), contextitem.getFunction(), sParams)
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s)" % (sTest,),)]
itemValues = oGuiElement.getItemValues()
contextitem = cContextElement()
if oGuiElement._mediaType == 'movie' or oGuiElement._mediaType == 'tvshow':
if cConfig().getSetting('xstream.trailer') == 'true':
contextitem.setTitle(cConfig().getLocalizedString(30027)) # Trailer Funktion
trailerParams = {
'function': 'playTrailer',
'title': oGuiElement.getTitle(),
'year': oGuiElement._sYear,
'mediatype': oGuiElement._mediaType,
'poster': oGuiElement.getThumbnail(),
}
if 'tmdb_id' in itemValues and itemValues['tmdb_id']:
trailerParams['tmdb_id'] = str(itemValues['tmdb_id'])
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s?%s)" % (self.pluginPath, urlencode(trailerParams)))]
if oGuiElement._mediaType == 'movie' or oGuiElement._mediaType == 'tvshow':
contextitem.setTitle(cConfig().getLocalizedString(30239)) # Erweiterte Info
searchParams = {'searchTitle': oGuiElement.getTitle(), 'sMeta': oGuiElement._mediaType, 'sYear': oGuiElement._sYear}
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s?function=viewInfo&%s)" % (self.pluginPath, urlencode(searchParams),),)]
if oGuiElement._mediaType == 'season' or oGuiElement._mediaType == 'episode':
contextitem.setTitle(cConfig().getLocalizedString(30241)) # Info
contextmenus += [(contextitem.getTitle(), cConfig().getLocalizedString(30242),)] # Action(Info)
# search for alternative source
contextitem.setTitle(cConfig().getLocalizedString(30243)) # Weitere Quellen
searchParams = {'searchTitle': oGuiElement.getTitle()}
if 'imdb_id' in itemValues:
searchParams['searchImdbID'] = itemValues['imdb_id']
contextmenus += [(contextitem.getTitle(), "Container.Update(%s?function=searchAlter&%s)" % (self.pluginPath, urlencode(searchParams),),)]
if 'imdb_id' in itemValues and 'title' in itemValues:
metaParams = {}
if itemValues['title']:
metaParams['title'] = oGuiElement.getTitle()
if 'mediaType' in itemValues and itemValues['mediaType']:
metaParams['mediaType'] = itemValues['mediaType']
elif 'TVShowTitle' in itemValues and itemValues['TVShowTitle']:
metaParams['mediaType'] = 'tvshow'
else:
metaParams['mediaType'] = 'movie'
if 'season' in itemValues and itemValues['season'] and int(itemValues['season']) > 0:
metaParams['season'] = itemValues['season']
metaParams['mediaType'] = 'season'
if 'episode' in itemValues and itemValues['episode'] and int(itemValues['episode']) > 0 and 'season' in itemValues and itemValues['season'] and int(itemValues['season']):
metaParams['episode'] = itemValues['episode']
metaParams['mediaType'] = 'episode'
# context options for movies or episodes
if not bIsFolder:
contextitem.setTitle(cConfig().getLocalizedString(30244)) # Playlist hinzufügen
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s&playMode=enqueue)" % (sUrl,),)]
contextitem.setTitle(cConfig().getLocalizedString(30245)) # Download
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s&playMode=download)" % (sUrl,),)]
if cConfig().getSetting('jd_enabled') == 'true':
contextitem.setTitle(cConfig().getLocalizedString(30246)) # send JD
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s&playMode=jd)" % (sUrl,),)]
if cConfig().getSetting('jd2_enabled') == 'true':
contextitem.setTitle(cConfig().getLocalizedString(30247)) # Send JD2
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s&playMode=jd2)" % (sUrl,),)]
if cConfig().getSetting('myjd_enabled') == 'true':
contextitem.setTitle(cConfig().getLocalizedString(30248)) # Send myjd
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s&playMode=myjd)" % (sUrl,),)]
if cConfig().getSetting('pyload_enabled') == 'true':
contextitem.setTitle(cConfig().getLocalizedString(30249)) # Send Pyload
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s&playMode=pyload)" % (sUrl,),)]
if cConfig().getSetting('hosterSelect') == 'Auto':
contextitem.setTitle(cConfig().getLocalizedString(30149)) # select Hoster
contextmenus += [(contextitem.getTitle(), "RunPlugin(%s&playMode=play&manual=1)" % (sUrl,),)]
listitem.addContextMenuItems(contextmenus)
# listitem.addContextMenuItems(contextmenus, True)
return listitem
def setEndOfDirectory(self, success=True):
# mark the listing as completed, this is mandatory
if not self._isViewSet:
self.setView('files')
xbmcplugin.setPluginCategory(self.pluginHandle, "")
# add some sort methods, these will be available in all views
xbmcplugin.addSortMethod(self.pluginHandle, xbmcplugin.SORT_METHOD_UNSORTED)
xbmcplugin.addSortMethod(self.pluginHandle, xbmcplugin.SORT_METHOD_VIDEO_RATING)
xbmcplugin.addSortMethod(self.pluginHandle, xbmcplugin.SORT_METHOD_LABEL)
xbmcplugin.addSortMethod(self.pluginHandle, xbmcplugin.SORT_METHOD_DATE)
xbmcplugin.addSortMethod(self.pluginHandle, xbmcplugin.SORT_METHOD_PROGRAM_COUNT)
xbmcplugin.addSortMethod(self.pluginHandle, xbmcplugin.SORT_METHOD_VIDEO_RUNTIME)
xbmcplugin.addSortMethod(self.pluginHandle, xbmcplugin.SORT_METHOD_GENRE)
xbmcplugin.endOfDirectory(self.pluginHandle, success)
def setView(self, content='movies'):
# set the listing to a certain content, makes special views available
# sets view to the viewID which is selected in xStream settings
# see http://mirrors.xbmc.org/docs/python-docs/stable/xbmcplugin.html#-setContent
# (seasons is also supported but not listed)
content = content.lower()
supportedViews = ['files', 'songs', 'artists', 'albums', 'movies', 'tvshows', 'seasons', 'episodes', 'musicvideos']
if content in supportedViews:
self._isViewSet = True
xbmcplugin.setContent(self.pluginHandle, content)
if cConfig().getSetting('auto-view') == 'true' and content:
viewId = cConfig().getSetting(content + '-view')
if viewId:
xbmc.executebuiltin("Container.SetViewMode(%s)" % viewId)
def updateDirectory(self):
# update the current listing
xbmc.executebuiltin("Container.Refresh")
def __createItemUrl(self, oGuiElement, bIsFolder, params=''):
if params == '':
params = ParameterHandler()
itemValues = oGuiElement.getItemValues()
if 'tmdb_id' in itemValues and itemValues['tmdb_id']:
params.setParam('tmdbID', itemValues['tmdb_id'])
if 'TVShowTitle' in itemValues and itemValues['TVShowTitle']:
params.setParam('TVShowTitle', itemValues['TVShowTitle'])
if 'season' in itemValues and itemValues['season'] and int(itemValues['season']) > 0:
params.setParam('season', itemValues['season'])
if 'episode' in itemValues and itemValues['episode'] and float(itemValues['episode']) > 0:
params.setParam('episode', itemValues['episode'])
# TODO change this, it can cause bugs it influencec the params for the following listitems
if not bIsFolder:
params.setParam('MovieTitle', oGuiElement.getTitle())
thumbnail = oGuiElement.getThumbnail()
if thumbnail:
params.setParam('thumb', thumbnail)
if oGuiElement._mediaType:
params.setParam('mediaType', oGuiElement._mediaType)
elif 'TVShowTitle' in itemValues and itemValues['TVShowTitle']:
params.setParam('mediaType', 'tvshow')
if 'season' in itemValues and itemValues['season'] and int(itemValues['season']) > 0:
params.setParam('mediaType', 'season')
if 'episode' in itemValues and itemValues['episode'] and float(itemValues['episode']) > 0:
params.setParam('mediaType', 'episode')
sParams = params.getParameterAsUri()
try:
if params.getValue('sUrl').startswith("plugin://"):
return params.getValue('sUrl')
except: pass
if len(oGuiElement.getFunction()) == 0:
sUrl = "%s?site=%s&title=%s&%s" % (self.pluginPath, oGuiElement.getSiteName(), quote_plus(oGuiElement.getTitle()), sParams)
else:
#kasi
sUrl = "%s?site=%s&function=%s&title=%s&trumb=%s&%s" % (self.pluginPath, oGuiElement.getSiteName(), oGuiElement.getFunction(), quote_plus(oGuiElement.getTitle()), oGuiElement.getThumbnail(), sParams)
if not bIsFolder:
sUrl += '&playMode=play'
return sUrl
@staticmethod
def showKeyBoard(sDefaultText="", sHeading=""):
# Create the keyboard object and display it modal
oKeyboard = xbmc.Keyboard(sDefaultText, sHeading)
oKeyboard.doModal()
# If key board is confirmed and there was text entered return the text
if oKeyboard.isConfirmed():
sSearchText = oKeyboard.getText()
if len(sSearchText) > 0:
return sSearchText
return False
@staticmethod
def showNumpad(defaultNum="", numPadTitle=cConfig().getLocalizedString(30251)):
defaultNum = str(defaultNum)
dialog = xbmcgui.Dialog()
num = dialog.numeric(0, numPadTitle, defaultNum)
return num
@staticmethod
def openSettings():
cConfig().showSettingsWindow()
@staticmethod
def showNofication(sTitle, iSeconds=0):
if iSeconds == 0:
iSeconds = 1000
else:
iSeconds = iSeconds * 1000
xbmc.executebuiltin("Notification(%s,%s,%s,%s)" % (cConfig().getLocalizedString(30308), (cConfig().getLocalizedString(30309) % str(sTitle)), iSeconds, cConfig().getAddonInfo('icon')))
@staticmethod
def showError(sTitle, sDescription, iSeconds=0):
if iSeconds == 0:
iSeconds = 1000
else:
iSeconds = iSeconds * 1000
xbmc.executebuiltin("Notification(%s,%s,%s,%s)" % (str(sTitle), (str(sDescription)), iSeconds, cConfig().getAddonInfo('icon')))
@staticmethod
def showInfo(sTitle='xStream', sDescription=cConfig().getLocalizedString(30253), iSeconds=0):
if iSeconds == 0:
iSeconds = 1000
else:
iSeconds = iSeconds * 1000
xbmc.executebuiltin("Notification(%s,%s,%s,%s)" % (str(sTitle), (str(sDescription)), iSeconds, cConfig().getAddonInfo('icon')))
@staticmethod
def showLanguage(sTitle='xStream', sDescription=cConfig().getLocalizedString(30403), iSeconds=0):
if iSeconds == 0:
iSeconds = 1000
else:
iSeconds = iSeconds * 1000
xbmc.executebuiltin("Notification(%s,%s,%s,%s)" % (str(sTitle), (str(sDescription)), iSeconds, cConfig().getAddonInfo('icon')))
# -*- coding: utf-8 -*-
# Python 3
import xbmc
import time
import xbmcgui
from resources.lib.config import cConfig
from resources.lib.tmdb import cTMDB
from datetime import date, datetime
from urllib.parse import urlencode
def WindowsBoxes(sTitle, sFileName, metaType, year=''):
try:
meta = cTMDB().get_meta(metaType, sFileName, tmdb_id=xbmc.getInfoLabel('ListItem.Property(TmdbId)'), year=year, advanced='true')
try:
meta['plot'] = str(meta['plot'].encode('latin-1'), 'utf-8')
except Exception:
pass
except Exception:
print("TMDB - error")
pass
if 'tmdb_id' not in meta:
xbmc.executebuiltin("Notification(TMDB, Kein Eintrag gefunden, 1000, '')")
return
if 'premiered' in meta and meta['premiered']:
releaseDate = datetime(*(time.strptime(meta['premiered'], '%Y-%m-%d')[0:6]))
meta['releaseDate'] = releaseDate.strftime('%d/%m/%Y')
else:
meta['releaseDate'] = '-'
if 'duration' in meta and meta['duration']:
duration = meta['duration']
durationH = duration // 60
meta['durationH'] = durationH
meta['durationM'] = '{:02d}'.format(int(duration - 60 * durationH))
else:
meta['durationH'] = 0
meta['durationM'] = 0
class XMLDialog(xbmcgui.WindowXMLDialog):
def __init__(self, *args, **kwargs):
xbmcgui.WindowXMLDialog.__init__(self)
pass
def onInit(self):
self.setProperty('color', cConfig().getSetting('Color'))
self.poster = 'https://image.tmdb.org/t/p/%s' % cConfig().getSetting('poster_tmdb')
self.none_poster = 'https://eu.ui-avatars.com/api/?background=000&size=512&name=%s&color=FFF&font-size=0.33'
self.setFocusId(9000)
if 'credits' in meta and meta['credits']:
cast = []
crew = []
try:
data = eval(str(meta['credits'].encode('latin-1'), 'utf-8'))
except Exception:
data = eval(str(meta['credits']))
listitems = []
if 'cast' in data and data['cast']:
for i in data['cast']:
slabel = i['name']
slabel2 = i['character']
if i['profile_path']:
sicon = self.poster + str(i['profile_path'])
else:
sicon = self.none_poster % slabel
sid = i['id']
listitem_ = xbmcgui.ListItem(label=slabel, label2=slabel2)
listitem_.setProperty('id', str(sid))
listitem_.setArt({'icon': sicon})
listitems.append(listitem_)
cast.append(slabel)
self.getControl(50).addItems(listitems)
listitems2 = []
if 'crew' in data and data['crew']:
for i in data['crew']:
slabel = i['name']
slabel2 = i['job']
if i['profile_path']:
sicon = self.poster + str(i['profile_path'])
else:
sicon = self.none_poster % slabel
sid = i['id']
listitem_ = xbmcgui.ListItem(label=slabel, label2=slabel2)
listitem_.setProperty('id', str(sid))
listitem_.setArt({'icon': sicon})
listitems2.append(listitem_)
crew.append(slabel)
self.getControl(5200).addItems(listitems2)
meta['title'] = sTitle
if 'rating' not in meta or meta['rating'] == 0:
meta['rating'] = '-'
if 'votes' not in meta or meta['votes'] == '0':
meta['votes'] = '-'
for prop in meta:
try:
if isinstance(meta[prop], str):
self.setProperty(prop, meta[prop])
else:
self.setProperty(prop, str(meta[prop]))
except Exception:
if isinstance(meta[prop], str):
self.setProperty(prop, meta[prop])
else:
self.setProperty(prop, str(meta[prop]))
# Check trailer in background thread so onInit() returns immediately
# and Kodi can render the dialog content (blocking here = empty dialog)
import threading
_tmdb_id = str(meta.get('tmdb_id', ''))
_imdb_id = str(meta.get('imdb_id', ''))
_dialog = self
_meta = meta
def _bgTrailerCheck():
try:
from resources.lib.trailer import hasTrailer
if _tmdb_id and hasTrailer(_tmdb_id, _imdb_id, metaType):
_dialog.setProperty('isTrailer', 'true')
except Exception:
if 'trailer' in _meta:
_dialog.setProperty('isTrailer', 'true')
t = threading.Thread(target=_bgTrailerCheck)
t.daemon = True
t.start()
def credit(self, meta='', control=''):
listitems = []
if not meta:
meta = {}
for i in meta:
if 'title' in i and i['title']:
sTitle = i['title']
elif 'name' in i and i['name']:
sTitle = i['name']
if i['poster_path']:
sThumbnail = self.poster + str(i['poster_path'])
else:
sThumbnail = self.none_poster % sTitle
listitem_ = xbmcgui.ListItem(label=sTitle)
listitem_.setArt({'icon': sThumbnail})
listitems.append(listitem_)
self.getControl(control).addItems(listitems)
def onClick(self, controlId):
if controlId == 11:
_tmdb_id = str(self.getProperty('tmdb_id'))
_poster = self.getProperty('cover_url') or ''
self.close()
try:
from resources.lib.trailer import playTrailer
from resources.lib.config import cConfig
_tmdb_lang = cConfig().getSetting('tmdb_lang') or 'de'
playTrailer(
tmdb_id=_tmdb_id,
mediatype=metaType,
title=sTitle,
year=year,
poster=_poster,
pref_lang=_tmdb_lang,
)
except Exception:
import traceback
xbmc.log('[xstream.trailer] onClick error: %s' % traceback.format_exc(), xbmc.LOGERROR)
xbmc.executebuiltin("Notification(Trailer, Trailer-Suche fehlgeschlagen, 3000, '')")
return
elif controlId == 30:
self.close()
return
elif controlId == 50 or controlId == 5200:
item = self.getControl(controlId).getSelectedItem()
sid = item.getProperty('id')
sUrl = 'person/' + str(sid)
try:
meta = cTMDB().getUrl(sUrl, '', "append_to_response=movie_credits,tv_credits")
meta_credits = meta['movie_credits']['cast']
self.credit(meta_credits, 5215)
personName = meta['name']
if not meta['deathday']:
today = date.today()
try:
birthday = datetime(*(time.strptime(meta['birthday'], '%Y-%m-%d')[0:6]))
age = today.year - birthday.year - ((today.month, today.day) < (birthday.month, birthday.day))
age = '%s Jahre' % age
except Exception:
age = ''
else:
age = meta['deathday']
self.setProperty('Person_name', personName)
self.setProperty('Person_birthday', meta['birthday'])
self.setProperty('Person_place_of_birth', meta['place_of_birth'])
self.setProperty('Person_deathday', str(age))
self.setProperty('Person_biography', meta['biography'])
self.setFocusId(9000)
except Exception:
return
self.setProperty('xstream_menu', 'Person')
elif controlId == 9:
sid = self.getProperty('tmdb_id')
if metaType == 'movie':
sUrl_simil = 'movie/%s/similar' % str(sid)
sUrl_recom = 'movie/%s/recommendations' % str(sid)
else:
sUrl_simil = 'tv/%s/similar' % str(sid)
sUrl_recom = 'tv/%s/recommendations' % str(sid)
try:
meta = cTMDB().getUrl(sUrl_simil)
meta = meta['results']
self.credit(meta, 5205)
except Exception:
pass
try:
meta = cTMDB().getUrl(sUrl_recom)
meta = meta['results']
self.credit(meta, 5210)
except Exception:
return
elif controlId == 5215 or controlId == 5205 or controlId == 5210:
item = self.getControl(controlId).getSelectedItem()
self.close()
xbmc.executebuiltin("Container.Update(%s?function=searchTMDB&%s)" % ('plugin://plugin.video.xstream/', urlencode({'searchTitle': item.getLabel()})))
return
def onFocus(self, controlId):
self.controlId = controlId
def _close_dialog(self):
self.close()
def onAction(self, action):
if action.getId() in (104, 105, 1, 2):
return
if action.getId() in (9, 10, 11, 30, 92, 216, 247, 257, 275, 61467, 61448):
self.close()
# kasi
path = 'special://home/addons/%s' % cConfig().getAddonInfo('id')
wd = XMLDialog('info.xml', path, 'default', '720p')
wd.doModal()
del wd

Trailer — Global Concept

Version: 2026-03-08

Common architecture shared by xShip and xStream. Addon-specific details: see trailer-xship.md and trailer-xstream.md.

Architecture Overview

Phase 0: Metadata Resolution (xStream only)

xStream scrapers provide title/year but no TMDB ID. A resolution step finds the TMDB ID first. See trailer-xstream.md.

Phase 1: Per-Language Trailer Search

Try trailer sources in priority order, organized as language blocks. Each block runs the same pattern. First hit wins.

Phase 2: Play

Once a trailer is found, play it via the appropriate player.

Per-Language Priority Sequence

Two-level priority:

  • Per-language: within each language block, sources are tried in priority order
  • Overall: language blocks follow a priority order, then YouTube search, then give up
Per-language priority (same pattern in EVERY block):
  KinoCheck API -> KinoCheck YT (if DE) -> TMDB -> IMDB (if EN)

Block list = caller's languages + EN (if missing) + ANY

After all blocks: YouTube search (per caller language) -> Give up

Block Construction

blocks = list(languages)       # e.g. ['ja', 'de', 'en']
if 'en' not in blocks:
    blocks.append('en')        # auto-add EN before ANY
blocks.append(None)            # None = ANY block

Per-Block Pattern

FOR EACH language IN blocks:
  +-- KinoCheck API (lang)                  <- gated: has_yt_player
  +-- KinoCheck YT (if DE or ANY+DE-unused) <- gated: has_yt_player + has_own_key
  +-- TMDB videos (language)                <- gated: has_yt_player
  +-- IMDB (if lang=EN)                     <- always available (no player needed)

EN Always a Block

EN is auto-added before ANY if not in the caller's list. This guarantees IMDB (English content) always has its own block.

ANY Block

Same pattern, same gating. Specifics:

  • KC API: KC-API(de) only if DE was NOT an explicit block
  • KC YT: fires if DE was not already in a previous block
  • TMDB: no lang filter, exclude all named languages
  • IMDB: skipped (EN already had its own block)

KC API Language Support

KC API only supports de and en. Calls with unsupported languages (fr, ja) return empty (fast, free, harmless). In the ANY block: KC-API(de) fires only if DE wasn't an explicit block. Since EN is always a block, the only uncovered KC language in ANY is DE.

Example Walkthroughs

languages=['de', 'en'] (default)

[DE] KC-API(de) -> KC-YT -> TMDB-DE
[EN] KC-API(en) -> TMDB-EN -> IMDB
[ANY] TMDB-ANY                          <- KC skipped (de+en already covered)
YouTube: YT-DE -> YT-EN

languages=['en'] (English only)

[EN] KC-API(en) -> TMDB-EN -> IMDB
[ANY] KC-API(de) -> KC-YT -> TMDB-ANY   <- DE not explicit -> KC-API(de) + KC-YT fire!
YouTube: YT-EN

languages=['fr', 'de'] (EN auto-added)

[FR] KC-API(fr) -> TMDB-FR
[DE] KC-API(de) -> KC-YT -> TMDB-DE
[EN] KC-API(en) -> TMDB-EN -> IMDB      <- auto-added!
[ANY] TMDB-ANY                          <- KC skipped (de covered in [DE])
YouTube: YT-FR -> YT-DE

languages=['fr', 'ja'] (EN auto-added, DE not explicit)

[FR] KC-API(fr) -> TMDB-FR
[JA] KC-API(ja) -> TMDB-JA
[EN] KC-API(en) -> TMDB-EN -> IMDB      <- auto-added!
[ANY] KC-API(de) -> KC-YT -> TMDB-ANY   <- DE not explicit -> KC-API(de) + KC-YT fire!
YouTube: YT-FR -> YT-JA

No YT player, languages=['de', 'en']

[DE] (skip KC, skip TMDB)
[EN] (skip KC, skip TMDB) -> IMDB
[ANY] (skip KC, skip TMDB)
-> Only IMDB works.

Architecture: Single Unified File

One trailer.py — identical file deployed to both xShip and xStream. Addon detection via _ADDON_NAME (auto-detected from xbmcaddon.Addon().getAddonInfo('id')) gates the few addon-specific branches.

trailer.py (same file in both addons)
+-- playTrailer()           <- ENTRY POINT (addon-specific branches inside)
|   _ADDON_NAME == 'xstream': Phase 0 resolution, multi-source language list
|   _ADDON_NAME == 'xship':   simple pref_lang, no Phase 0
|   Both: same capability detection, TMDB pre-fetch, _runTrailerSearch() call
|
+-- _runTrailerSearch()     <- SHARED CORE (no addon-specific code)
|   Takes languages list + pre-fetched data as parameters.
|
+-- hasTrailer()            <- SHARED (async existence check for TMDB info dialog)
|
+-- Helpers                 <- SHARED (no addon imports)
    _searchKinoCheckAPI(), _searchKinoCheck(), _searchYouTube(),
    _searchIMDB(), _play(), _playDirect(), _notify(), etc.

Addon-Specific Branches in playTrailer()

Branch xStream (_ADDON_NAME == 'xstream') xShip (default)
Language list Multi-source: pref_lang + tmdb_lang + prefLanguage + Kodi GUI Simple: [pref_lang]
Phase 0 TMDB ID resolution via search_movie_name()/search_tvshow_name() Skipped (TMDB ID from listing)
Settings import from resources.lib.config import cConfig Not needed

Common TMDB import

Both addons expose class cTMDB in resources/lib/tmdb.py with compatible getUrl() and __init__(lang=). The unified file uses from resources.lib.tmdb import cTMDB — works for both.

_runTrailerSearch() Signature

def _runTrailerSearch(
    tmdb_id, mediatype, title, en_title, year, poster,
    imdb_id, languages, has_yt_player, has_own_key, skip_api,
    tmdb_videos,
):
    """
    languages:   list of 1-3 ISO codes, e.g. ['de'] or ['ja', 'de', 'en']
    tmdb_videos: single pre-fetched TMDB /videos response (all languages)
    """

Available Trailer Sources

KinoCheck API

  • Endpoint: https://api.kinocheck.de/movies?tmdb_id=ID&language=de (also /shows for TV)
  • Requirements: TMDB ID
  • Cost: free, no API key, 1000 req/day
  • Returns: YouTube video IDs with category/language/views/date
  • Coverage: newer movies (~2010+) — old films return trailer: null, videos: []
  • Languages: only de and en supported. No wildcard/all mode. Returns different YouTube video IDs per language.
  • Verification: oEmbed (SmartTube) or videos.list (YT addon)

KinoCheck YouTube Channel Search

  • Endpoint: YouTube search.list scoped to KinoCheck's channel
  • Requirements: TMDB ID (for title), YouTube player, own API key (search.list = 100 units)
  • Cost: 100 YouTube API units
  • Purpose: fallback when KinoCheck API is down
  • Verification: videos.list + duration filter

TMDB Videos

  • Endpoint: TMDB /movie/{id}/videos or /tv/{id}/videos
  • Requirements: TMDB ID, YouTube player (for YouTube video IDs)
  • Cost: 0 YouTube API units (TMDB is free)
  • Returns: YouTube video IDs with type (Trailer/Teaser), language, site
  • Sort: Trailer > Teaser, then newest first
  • Verification: oEmbed (SmartTube) or videos.list (YT addon)
  • Pre-fetched once: TMDB /videos returns ALL videos regardless of language param — each has its own iso_639_1 tag. Client-side filtering per block.

IMDB Direct MP4

  • Endpoint: POST https://caching.graphql.imdb.com/ (GraphQL API, ~3 KB response)
  • Requirements: IMDB ID (from TMDB metadata)
  • Cost: free, no API key, no YouTube player needed
  • Returns: direct CloudFront-signed MP4 URLs (1080p/720p/480p/SD) + HLS
  • Player: Kodi native (xbmc.Player().play()) — no addon dependency
  • Quality: 1080p > 720p > 480p > SD > HLS fallback
  • Cache: 1h TTL (CloudFront signed URLs expire ~24h)
  • Dead flag: _imdb_dead set on HTTP 403/429, skips for rest of Kodi session
  • Key advantage: ID-based — no title confusion (e.g. Flatland 2017 vs 2019)

YouTube Search

  • Endpoint: YouTube search.list + videos.list
  • Requirements: title + year, YouTube player, own API key (search.list = 100 units)
  • Cost: 100-201 YouTube API units per search (search + videos.list for filtering)
  • Query: "title" year trailer + &relevanceLanguage=
  • maxResults: 25
  • Cache: _yt_search_cache keyed by (title_lower, year, lang) — cross-language reuse if same title
  • Verification: videos.list for full filtering

Optimization: Pre-fetch Once, Filter Per Block

TMDB: 3 calls -> 1 call

Old: separate TMDB calls for primary, secondary, and details. New: single call with append_to_response=videos,external_ids. TMDB /videos returns ALL videos regardless of language param — each has its own iso_639_1 tag. Client-side filtering per block.

KinoCheck API: stays per-language

Tested: KC API returns different YouTube video IDs per language. No "all languages" mode (language=all returns null). Must call per block.

Call Summary

playTrailer():
  TMDB details+videos   <- 1 call (append_to_response=videos,external_ids)

_runTrailerSearch() per block:
  KC API(lang)           <- 1 call per block (per-language, cannot batch)
  KC YT(if DE)           <- 1 call (YouTube search, per-language)
  TMDB videos -> filter  <- from pre-fetched (0 calls)
  IMDB(if EN)            <- 1 call

Total: 1 TMDB + 2-4 KC API + 1 IMDB = 4-6 calls (before YouTube search)

Players

  • SmartTube (Android/Fire TV) — preferred, no API key needed, no ISA needed. Package: org.smarttube.stable
  • YouTube addon (plugin.video.youtube) — fallback player (Windows, or Android without SmartTube). Requires ISA enabled (kodion.video.quality.isa = true). Must use PlayMedia() not RunPlugin().
  • Kodi native (_TrailerPlayer wrapper around xbmc.Player) — for direct MP4 URLs (IMDB). No addon dependency.
    • _TrailerPlayer: wraps xbmc.Player with onPlayBackStopped/onPlayBackEnded/onPlayBackError callbacks
    • Monitors fullscreen: waits for Window.IsVisible(fullscreenvideo) to appear, then polls every 0.3s
    • Back = stop: when user leaves fullscreen, calls player.stop() — prevents background playback
    • No hardcoded timeouts — loops exit on player callbacks (tp.done) or Kodi abort (tp.aborted)

Before playback:

  • 3-second notification popup (source + language label), poster URL as icon
  • SmartTube only: 2-second sleep after notification before launch (SmartTube covers Kodi UI immediately)

YouTube Search — Title Filtering

_titleOkGlobal() — general YouTube search results

  • Movie title must appear in video title (normalized comparison)
  • Must contain "trailer", "teaser", or "official" (case-insensitive)
  • No #short or #shorts
  • _JUNK_WORDS blocklist: reaction, review, parody, explained, ranking, behind the scenes, etc.
  • Year conflict check via _yearConflict()
  • HTML entity decoding (_htmlDecode()) before all title comparisons

_titleOkChannel() — KinoCheck channel results

  • Looser filtering (trusted channel)
  • Still applies year conflict check

YouTube videos.list Quality Checks

Used when YouTube addon is the player (SmartTube skips via skip_api=True). API call: part=contentDetails,status,snippet,statistics — 1 unit per batch (up to 50 IDs).

Check Field Reject if
Age restriction contentRating.ytRating == ytAgeRestricted
Unlisted/private status.privacyStatus != public
Cam-rip snippet.categoryId == '22' (People & Blogs)
Deleted/restricted video ID missing from response always
Duration contentDetails.duration < 60s or > 360s

View-count ranking: re-order only if best has >=10K views AND >=10x more than first pick.

Return values: _fetchVideoDetails() returns None on API error (-> fallback to unfiltered) vs {} on success-but-empty (-> skip all).

oEmbed — Free YouTube Verification

Endpoint

https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=VIDEO_ID&format=json

Properties

  • Cost: completely free — no API key, no YouTube Data API quota
  • Returns: HTTP 200 + JSON if video exists, 404 if deleted/private/unavailable
  • Cannot detect: age-restricted videos (return 200, same as normal)
  • Response fields: title (full, not truncated), author_name, author_url, thumbnail_url

Existence Check — _videoExists() / _filterExistence()

  • SmartTube path skips videos.list (skip_api=True), but MUST verify videos exist — TMDB/KinoCheck can return stale IDs for deleted/private videos
  • oEmbed catches these (0 quota)

Sanity Check — _oembedSanityCheck()

  • Single oEmbed call on #1 YouTube search pick, just before playing
  • Checks: video exists, full title has no year conflict, channel not in _BAD_CHANNELS blocklist

Why NOT oEmbed for bulk filtering

oEmbed is 1 HTTP roundtrip per video (sequential, ~200-500ms each). videos.list batches 50 videos in 1 call (~300ms) for 1 unit — faster AND more data.

Year Conflict Filters (two layers)

Solves same-title-different-year problem (e.g. "Good Boys" 2005 vs 2019).

Layer 1: _yearConflict() — year in video title

  • Regex: (?<!\d)((?:19|20)\d{2})(?!\d) — finds bare years 1920-2039
  • No year found in video title -> allow; our year found -> allow; only different year(s) -> reject

Layer 2: _uploadYearOk() — upload date proximity

  • Uses snippet.publishedAt from YouTube search results (no extra API call)
  • Rejects if upload_year - movie_year > 5 (max_gap=5)
  • Only checks positive gap — pre-release trailers always OK

HTTP Layer

  • cRequestHandler.__cleanupUrl() double-encodes %22 -> %2522 and + -> %2B — breaks YouTube quoted phrase search
  • Fix: all YouTube/KinoCheck/IMDB API calls use _fetchJSON() / direct urllib.request, NOT cRequestHandler
  • TMDB calls still use addon's TMDB client via cRequestHandler — fine (no percent-encoded query params)

_yt_api_dead Flag

  • Set by _fetchJSON() on HTTP 403 from googleapis.com URLs only
  • Differentiates: quotaExceeded vs forbidden (invalid/revoked key)
  • KinoCheck API (kinocheck.de) NOT affected — different domain
  • Persists for Kodi session, resets on restart
  • Gates: _searchKinoCheck() (YT channel), _searchYouTube(), _fetchVideoDetails()
  • Does NOT gate: _searchKinoCheckAPI() (uses kinocheck.de)

What Differs Between Addons

Single trailer.py file — addon-specific branches gated by _ADDON_NAME.

Aspect xStream xShip
_ADDON_NAME 'xstream' 'xship'
Phase 0 TMDB ID resolution (scrapers lack TMDB ID) Skipped (TMDB ID from listing)
Language list Multi-source: pref_lang + tmdb_lang + prefLanguage + Kodi GUI Simple: [pref_lang] (default 'de')
Settings import from resources.lib.config import cConfig (inside Phase 0 + language) Not used
TMDB client from resources.lib.tmdb import cTMDB Same import, same class name
Log prefix [xstream.trailer] [xship.trailer]
Window properties xstream.trailer.* xship.trailer.*

Trailer — xShip Implementation

Version: 2026-03-08

Issue #58. See trailer-concept.md for shared architecture.

Unified File

resources/lib/trailer.py is the same file as in xStream — see trailer-concept.md for architecture. Auto-detects _ADDON_NAME = 'xship' and takes the simpler code path (no Phase 0, simple language list).

Entry Point + Context Menu

  • default.py: playTrailer action handler — tmdb_id already known from listing, passes pref_lang='de'
  • resources/lib/trailer.py: full trailer search implementation (shared with xStream)
  • movies.py + tvshows.py + person.py: context menu entry "Trailer ansehen"
  • Passes tmdb_id, mediatype, title, year, poster directly — no TMDB ID resolution needed
  • control.hasTrailerPlayer() -> always True (IMDB works without any player)

Language

Current: hardcoded DE+EN

xShip's playTrailer() takes NO language parameter:

def playTrailer(tmdb_id, mediatype='movie', title='', year='', poster=''):

Language is hardcoded inside: tmdb_de = cTMDB() (German) + tmdb_en = cTMDB(lang='en').

Migration: add pref_lang parameter

  1. Add parameter: def playTrailer(..., pref_lang='de'):
  2. Inside playTrailer(): languages = [pref_lang]
  3. Call site in default.py: hardcode pref_lang='de' (no setting yet) or map from a future setting
  4. No new setting needed initially — default pref_lang='de' preserves current behavior (DE first, then EN via auto-added block). A setting can be added later.

Result with default pref_lang='de'

languages = ['de'] -> blocks: [DE, EN, ANY] -> YouTube: YT-DE

This matches the current hardcoded DE+EN behavior.

Capability Detection (at entry of playTrailer())

  1. SmartTube installed? -> _getSmartTubePackage() (cached for session)
  2. YouTube addon installed? -> System.HasAddon(plugin.video.youtube)
  3. has_yt_player = bool(smarttube or has_yt_addon) — NO early exit (IMDB works without player)
  4. has_own_key = user has API key in YT addon AND key != our built-in key
  5. skip_api = bool(smarttube) — SmartTube handles age-gates, no videos.list needed

EN title + IMDB ID fetched upfront (before search) via TMDB en_data:

  • Movies: imdb_id at top level of TMDB response
  • TV shows: append_to_response=external_ids -> external_ids.imdb_id

API Key Strategy

Core principle: Our key for 1-unit verification, user's key for 100-unit search.

Key Source Used for Cost
_api_checksum _API_CHECKSUM_B64 in trailer.py videos.list for KinoCheck/TMDB results + user key validation 1 unit/trailer
user's key YT addon's api_keys.json YouTube search.list + videos.list of search results 100-201 units/trailer

Two purposes of _api_checksum

  1. User key validation: detect if user copied our key -> has_own_key = False -> show Popup 2
  2. Cheap verification: videos.list (1 unit) for age/duration/cam-rip filtering on curated results when YT addon is the player

Code obfuscation

  • Variable named _api_checksum / _API_CHECKSUM_B64 — misleading name
  • Key stored as base64, decoded at runtime — never plaintext in source
  • No comments in code explaining what the key is

Key routing

  • KinoCheck/TMDB results: SmartTube -> oEmbed (0 units); YT addon -> videos.list with _api_checksum (1 unit)
  • YouTube search results: search.list + videos.list with user's own key

Search Order (default DE)

With the per-language priority sequence (see trailer-concept.md), the search is organized as language blocks. For the default languages=['de']:

[DE] KC-API(de) -> KC-YT -> TMDB-DE
[EN] KC-API(en) -> TMDB-EN -> IMDB
[ANY] TMDB-ANY
YouTube: YT-DE

Extensible to other languages via the pref_lang parameter.

Gating Summary

  • KC API, TMDB: has_yt_player (need a YouTube player for these YouTube video IDs)
  • KC YT: has_yt_player + has_own_key (search.list is expensive)
  • IMDB: always (no player, no key needed)
  • YouTube search: has_yt_player + has_own_key

Scenario Matrix

# SmartTube YT addon Own key Steps available Popup
A yes yes yes ALL none
B yes yes no KC/TMDB + IMDB Popup 2 (if zero hits)
C yes no -- KC/TMDB + IMDB Popup 2 (if zero hits)
D no yes yes ALL none
E no yes no KC/TMDB + IMDB Popup 2 (if zero hits)
F no no -- IMDB only Popup 1 (if IMDB hit + lang != EN)

User Popups (once per Kodi session)

Goal: guide users toward better trailer experience based on what they're missing. Storage: Window(10000) properties (RAM only, clears on Kodi restart). Timing: 2s delay after trailer finishes playing (or at give-up), then Dialog().ok(). Language: xShip always German (hardcoded via _ADDON_NAME == 'xship'). xStream follows Kodi GUI language (German if de, English otherwise). No Kodi API exists for addon-level language detection.

Popup 1 — "Install a player" (scenario F)

  • Trigger: No player AND IMDB played AND primary_lang != 'en' (EN users already got what they need from IMDB)
  • Purpose: user wanted a trailer in their language but only got English from IMDB — a player unlocks KinoCheck + TMDB for free
  • Android: suggest SmartTube or YouTube add-on
  • Non-Android: suggest YouTube add-on
  • Mentions KinoCheck only if primary_lang is DE or EN (KC only supports those)
  • Emphasizes: no API key needed for these sources

Popup 2 — "Try YouTube search" (scenarios B, C, E)

  • Trigger: Has player but not has_own_key AND zero hits across all sources (not just "no German" — truly nothing found)
  • Purpose: all free sources (KC/TMDB/IMDB) failed — YouTube search might find something
  • Tentative wording: "You could try to install the YouTube add-on with your own API key to find additional trailer sources on YouTube"
  • Accounts for user possibly not having YT addon installed at all

Give-up notification

  • Trigger: no trailer found AND no popup was shown
  • Brief toast in upper-right corner (Dialog().notification()), auto-dismisses after 3s
  • "Kein Trailer gefunden" / "No trailer found"

Red Band Trailer Handling (KinoCheck)

  • KinoCheck API may return Red Band trailers (potentially age-restricted on YouTube)
  • SmartTube: play any result, SmartTube handles age-gates
  • YT addon: prefer non-Red-Band; only Red Band -> _filterAgeRestricted() first

SmartTube Specifics

  • Does NOT bypass YouTube age restrictions — requires Google account sign-in
  • finish_on_ended intent extra: stops auto-play after trailer ends
  • Detection: subprocess.run(['sh', '-c', 'pm path %s' % pkg]) — must go through shell

Files Modified (vs upstream michaz)

  • trailer.py — new file, shared with xStream (identical copy)
  • control.py — added hasTrailerPlayer(), trailerLabel(), window property helpers
  • default.py — added playTrailer action handler (passes pref_lang='de')
  • movies.py, tvshows.py, person.py — added "Trailer ansehen" context menu entry

Trailer — xStream Implementation

Version: 2026-03-08

Issue #77. See trailer-concept.md for shared architecture.

Unified File

resources/lib/trailer.py is the same file as in xShip — see trailer-concept.md for architecture. Auto-detects _ADDON_NAME = 'xstream' and activates Phase 0 (TMDB ID resolution) and the multi-source language list.

Key Difference from xShip

xStream has NO TMDB metadata upfront. Scrapers provide title, year, and sometimes an IMDB ID — but no TMDB ID. This means:

  • Cannot use TMDB videos endpoint directly (needs TMDB ID)
  • Cannot use KinoCheck API directly (needs TMDB ID)
  • Cannot use IMDB GraphQL directly (needs IMDB ID — sometimes available, sometimes not)
  • Must first resolve to a TMDB ID via title/IMDB search -> then use the common search

Entry Point + Context Menu

  • default.py: thin routing — safe try: from resources.lib.trailer import playTrailer (addon works even if trailer.py missing)
  • resources/lib/trailer.py: shared trailer logic (identical copy from xShip) — TMDB resolution, search, playback
  • gui.py: context menu passes searchTitle, sMeta, sYear, tmdbID, searchImdbID, sDuration, sThumbnail
  • Respects existing xstream.trailer settings toggle

Language Sources — Three Settings (narrowest context first)

# Source Setting Example
1 TMDB metadata language tmdb_lang 'de', 'en'
2 xStream preferred language prefLanguage mapped 'de', 'en', 'ja'
3 Kodi GUI language xbmc.getLanguage(ISO_639_1) 'de', 'en'

prefLanguage Mapping

_pref_map = {'0': _kodi_lang, '1': 'de', '2': 'en', '3': 'ja'}

Setting value '0' means "follow Kodi GUI language".

Entry Points — Each Picks Its Own #1

Context menu (xstream.py) — primary = prefLanguage (content context):

#1: prefLanguage    -> content language preference
#2: tmdb_lang       -> metadata language (if different)
#3: Kodi GUI lang   -> system language (if different)

TMDB info dialog (tmdbinfo.py) — primary = tmdb_lang (metadata context):

#1: tmdb_lang       -> metadata context language
#2: prefLanguage    -> content preference (if different)
#3: Kodi GUI lang   -> system language (if different)

Inside playTrailer() — Builds Deduplicated Languages List

Signature: playTrailer(tmdb_id, ..., pref_lang='de'). Caller passes the entry-point primary as pref_lang. Inside, playTrailer() reads the other two sources and appends them.

# Read all 3 sources
_tmdb_lang = cConfig().getSetting('tmdb_lang') or 'de'
_pref_raw = cConfig().getSetting('prefLanguage') or '0'
_kodi_lang = xbmc.getLanguage(xbmc.ISO_639_1) or 'de'
_pref_map = {'0': _kodi_lang, '1': 'de', '2': 'en', '3': 'ja'}
_xstream_pref = _pref_map.get(_pref_raw, _kodi_lang)

# Build deduplicated list: caller's #1 first, then tmdb, xstream pref, kodi
languages = []
for lang in [pref_lang, _tmdb_lang, _xstream_pref, _kodi_lang]:
    if lang and lang not in languages:
        languages.append(lang)

The fixed order [pref_lang, tmdb_lang, xstream_pref, kodi_lang] works for both entry points via dedup:

  • Context menu passes pref_lang=xstream_pref -> [xstream_pref, tmdb_lang, kodi_lang]
  • TMDB dialog passes pref_lang=tmdb_lang -> [tmdb_lang, xstream_pref, kodi_lang]

Scenarios

All settings = DE (typical German user)

languages = ['de'] -> blocks: [DE, EN, ANY] -> YouTube: YT-DE

tmdb_lang=EN, prefLang=DE, Kodi=DE

  • Context menu: ['de', 'en'] -> blocks: [DE, EN, ANY] -> YT: DE, EN
  • TMDB dialog: ['en', 'de'] -> blocks: [EN, DE, ANY] -> YT: EN, DE

tmdb_lang=DE, prefLang=JA, Kodi=EN

  • Context menu: ['ja', 'de', 'en'] -> blocks: [JA, DE, EN, ANY] -> YT: JA, DE, EN
  • TMDB dialog: ['de', 'ja', 'en'] -> blocks: [DE, JA, EN, ANY] -> YT: DE, JA, EN

No YT player, any language

Only IMDB fires (in the EN block). All KC/TMDB/YouTube skipped.

TMDB Resolution Flow (Phase 0 — before search)

Since xStream doesn't have TMDB IDs, a resolution step is needed first:

  1. Direct tmdbID -> use directly (if scraper provides it — rare)
  2. IMDB ID -> TMDB /find/{imdb_id} endpoint -> get TMDB ID
  3. Title search -> TMDB /search/movie or /search/tv -> top 5 results -> filtering:
    • Title filter: remove unrelated results (exact or starts-with match on normalized title)
    • Shortcut A: single result -> auto-select
    • Shortcut B: single exact title match -> auto-select
    • Shortcut C: exact title + year match narrows to one -> auto-select
    • Poster match: if multiple candidates, compare scraper thumbnail against TMDB posters
    • Dialog: year-sorted list with poster thumbnails, pre-selection from ranking/poster match

Poster Matching (xStream only)

Used during TMDB resolution when multiple title matches exist.

  • Compares scraper thumbnail against TMDB poster candidates by average RGB color
  • Euclidean distance: sqrt((R1-R2)^2 + (G1-G2)^2 + (B1-B2)^2)
  • Thresholds: auto-select if bestDist < 10 AND gap to second > 15
  • Platform decoders (zero external deps):
    • Android/Fire TV: libjpeg.so via ctypes (/system/lib/)
    • Windows: gdiplus.dll via ctypes (built-in)
    • Other/failure: skip poster match, fall through to dialog
  • TMDB image size: w92 (smallest, ~3KB, baseline JPEG)
  • IMPORTANT: must use urllib.request directly for raw byte downloads — cRequestHandler does .decode('utf-8', 'replace') which mangles binary JPEG data

Square Notification Icon (removed)

Was used for trailer playback notification popup:

  • Kodi Estuary notification icon: 110x110 with aspectratio=stretch
  • _makeSquareIcon(): downloads poster, decodes JPEG, pads to 138x138 BMP with 23px black bars
  • Falls back to poster URL (stretched) if no JPEG decoder available
  • Uses same platform decoders as poster matching

Video Selection

After TMDB resolution provides a TMDB ID, the per-language search proceeds (see trailer-concept.md):

  • Type priority: Trailer > Teaser > Featurette
  • Language wins over type (German Teaser > English Trailer)
  • Auto-play first hit (no video selection dialog)

Localized UI Strings

  • Based on tmdb.lang (German if de, English otherwise)
  • 4 strings: no TMDB results, no trailer found, select movie, select show

TMDB Info Dialog — hasTrailer() Button

The TMDB info dialog (tmdbinfo.py) shows a trailer button only if a trailer actually exists. The hasTrailer() function checks this asynchronously.

Gating

Check Gate Reason
KinoCheck API has_yt_player Returns YouTube video IDs -> need player
TMDB videos has_yt_player Returns YouTube video IDs -> need player
IMDB GraphQL imdb_id provided Direct MP4, no player needed

Async First-Positive Pattern

All checks run in parallel via ThreadPoolExecutor. as_completed() yields futures in completion order — the function returns True as soon as any check succeeds, without waiting for the others. If IMDB answers in 200ms while KinoCheck is still loading, the button appears immediately.

with ThreadPoolExecutor(max_workers=len(tasks)) as pool:
    futures = {pool.submit(fn): name for name, fn in tasks}
    for future in as_completed(futures):
        if future.result():
            return True   # first positive -> done, others abandoned

Call Site (tmdbinfo.py)

Called in onInit() after dialog content is rendered. Sets isTrailer property to show the button:

if _tmdb_id and hasTrailer(_tmdb_id, _imdb_id, metaType):
    self.setProperty('isTrailer', 'true')

Fallback: if trailer.py can't be imported, falls back to checking meta['trailer'] field.

Differences from xShip (same trailer.py, different code paths)

Both addons use the same trailer.py. Differences are gated by _ADDON_NAME:

Aspect xShip xStream
trailer.py identical file identical file
Phase 0 skipped (TMDB ID from listing) TMDB ID resolution via search_movie_name()/search_tvshow_name()
Language list simple: [pref_lang] multi-source: pref_lang + tmdb_lang + prefLanguage + Kodi GUI
Entry points 1 (context menu) 2 (context menu + TMDB info dialog)
TMDB client from resources.lib.tmdb import cTMDB same import, same class name

GitHub

  • Issue #77
# -*- coding: utf-8 -*-
# Python 3
# Version: 2026-03-08
#
# Trailer lookup — shared by xStream and xShip.
# xStream needs Phase 0 (TMDB ID resolution); xShip has TMDB ID from listings.
#
# Search: per-language priority blocks (_runTrailerSearch):
# Block list = caller languages + EN (if missing) + ANY
# FOR EACH block:
# KinoCheck API (lang) — gated: has_yt_player
# KinoCheck YT (if DE block) — gated: has_yt_player + has_own_key
# TMDB videos (lang filter) — gated: has_yt_player
# IMDB (if EN block) — always (direct MP4, no player needed)
# YouTube search (per caller language) — gated: has_yt_player + has_own_key
# Give up
#
# Play phase:
# SmartTube: StartAndroidActivity — no API key needed, handles age-gates
# YouTube addon: PlayMedia — ISA recommended
# IMDB: xbmc.Player().play(mp4_url) — Kodi native player
#
# Before playing: 3s notification popup (upper-right) showing source + language.
# Poster URL passed as notification icon (Kodi stretches to square).
import re
KINOCHECK_CHANNEL = 'UCOL10n-as9dXO2qtjjFUQbQ' # KinoCheck's YouTube channel ID
# Words that disqualify a global YouTube search result title (reactions, reviews, etc.)
_JUNK_WORDS = [
'#short', 'react', ' review', 'explained', 'breakdown',
'tribute', 'fan edit', 'fan made', 'fan film',
'deleted scene', 'interview', 'commentary', 'behind the scenes',
'music video', 'lyric', 'live performance',
'blooper', 'gag reel', 'backstage', 'making of',
'recap', 'full movie', 'soundtrack', 'parody', 'gameplay',
'scene', 'comments',
]
# At least one of these must appear in a global YouTube search result title
_TRAILER_WORDS = ['trailer', 'teaser', 'official']
# Built-in API key (base64) — used for cheap 1-unit videos.list verification + user key detection
_API_CHECKSUM_B64 = b'QUl6YVN5RG5sSjBlX0NabExvWm03Q01Obk80MXhJblpnVkZ5T2Jv'
import base64 as _b64
_api_checksum = _b64.b64decode(_API_CHECKSUM_B64).decode() if _API_CHECKSUM_B64 else ''
# ── Module-level cached state (persists for Kodi session, resets on restart) ───
_smarttube_pkg = None # SmartTube detection: None=unchecked, str=package, False=absent
_yt_api_key = None # YouTube API key: None=unchecked, str=key, ''=no key found
_yt_api_dead = False # Set on YT API HTTP 403 — skips all remaining YT API calls
_yt_search_cache = {} # Avoids duplicate YT searches: (title, year, lang) -> raw items
_yt_video_cache = {} # Avoids duplicate videos.list calls: video_id -> quality info dict
_imdb_dead = False # Set on IMDB HTTP 403/429 — skips IMDB for rest of session
_imdb_cache = {} # IMDB GraphQL results: imdb_id -> (mp4_url, quality, expiry)
_IMDB_CACHE_TTL = 3600 # 1h cache (CloudFront signed URLs expire ~24h)
# ── Addon detection — auto-detect xStream vs xShip for branch gating ──────────
# Determines: log prefix, window property prefix, and playTrailer() code path.
# 'xstream' -> Phase 0 (TMDB resolution) + multi-source language list
# 'xship' (or anything else) -> simple language list, no Phase 0
try:
import xbmcaddon as _xa
_ADDON_ID = _xa.Addon().getAddonInfo('id') # e.g. 'plugin.video.xstream'
except Exception:
_ADDON_ID = ''
_ADDON_NAME = _ADDON_ID.split('.')[-1] if _ADDON_ID else 'trailer' # 'xstream' or 'xship'
_LOG_TAG = '[%s.trailer]' % _ADDON_NAME # log prefix: [xstream.trailer] or [xship.trailer]
_PROP_PREFIX = '%s.trailer' % _ADDON_NAME # window property prefix for hint popups
# ── Module-level logger ──────────────────────────────────────────────────────
def _log(msg):
try:
import xbmc
xbmc.log(_LOG_TAG + ' ' + msg, xbmc.LOGINFO)
except Exception:
pass
# ── SmartTube detection (Android only) ─────────────────────────────────────────
def _getSmartTubePackage():
"""Return SmartTube package name if installed on Android, else None.
Result is cached for the session."""
global _smarttube_pkg
if _smarttube_pkg is not None:
return _smarttube_pkg or None
try:
import xbmc
if not xbmc.getCondVisibility('System.Platform.Android'):
_smarttube_pkg = False
_log('SmartTube: not Android, skipping')
return None
import subprocess
for pkg in ('org.smarttube.stable', 'org.smarttube.beta'):
try:
ret = subprocess.run(['sh', '-c', 'pm path %s' % pkg],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
timeout=5)
if ret.returncode == 0 and b'package:' in ret.stdout:
_smarttube_pkg = pkg
_log('SmartTube found: %s' % pkg)
return pkg
except subprocess.TimeoutExpired:
_log('SmartTube: pm timeout for %s' % pkg)
continue
_smarttube_pkg = False
_log('SmartTube not found')
return None
except Exception as e:
_log('SmartTube check failed: %s' % e)
_smarttube_pkg = False
return None
# ── HTTP helper (bypass cRequestHandler — its __cleanupUrl double-encodes %22) ─
def _fetchJSON(url, timeout=10):
"""GET a JSON API URL and return parsed dict. Returns {} on any error.
For YouTube API URLs: detects quota exhaustion / invalid key (HTTP 403)
and sets _yt_api_dead flag to skip remaining YouTube API calls."""
global _yt_api_dead
import json
from urllib.request import Request, urlopen
from urllib.error import HTTPError
try:
req = Request(url)
req.add_header('User-Agent', 'Mozilla/5.0')
resp = urlopen(req, timeout=timeout)
return json.loads(resp.read().decode('utf-8'))
except HTTPError as e:
if e.code == 403 and 'googleapis.com' in url:
try:
body = json.loads(e.read().decode('utf-8'))
reason = body.get('error', {}).get('errors', [{}])[0].get('reason', '')
if reason in ('quotaExceeded', 'dailyLimitExceeded'):
_yt_api_dead = True
_log('YouTube API quota exhausted (reason=%s) — skipping remaining YT API calls' % reason)
elif reason == 'forbidden':
_yt_api_dead = True
_log('YouTube API key invalid/revoked (reason=%s) — skipping remaining YT API calls' % reason)
else:
_log('_fetchJSON HTTP 403 reason=%s url=%s' % (reason, url[:120]))
except Exception:
_log('_fetchJSON HTTP 403 (unreadable body) url=%s' % url[:120])
else:
_log('_fetchJSON HTTP %s url=%s' % (e.code, url[:120]))
return {}
except Exception as e:
_log('_fetchJSON error: %s url=%s' % (e, url[:120]))
return {}
def _fetchHTML(url, timeout=10):
"""GET a URL and return raw HTML string. Returns '' on any error.
Sets _imdb_dead flag on HTTP 403/429 from imdb.com."""
global _imdb_dead
from urllib.request import Request, urlopen
from urllib.error import HTTPError
try:
req = Request(url)
req.add_header('User-Agent',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/120.0.0.0 Safari/537.36')
req.add_header('Accept-Language', 'en-US,en;q=0.9')
resp = urlopen(req, timeout=timeout)
return resp.read().decode('utf-8', errors='replace')
except HTTPError as e:
if e.code in (403, 429) and 'imdb.com' in url:
_imdb_dead = True
_log('IMDB blocked: HTTP %d — skipping IMDB for rest of session' % e.code)
else:
_log('_fetchHTML HTTP %s url=%s' % (e.code, url[:120]))
return ''
except Exception as e:
_log('_fetchHTML error: %s url=%s' % (e, url[:120]))
return ''
# ── YouTube helpers ───────────────────────────────────────────────────────────
def _getYouTubeApiKey():
"""Return YouTube Data API key. Cached at module level (reset on Kodi restart)."""
global _yt_api_key
if _yt_api_key is not None:
return _yt_api_key
# Try YouTube addon's api_keys.json first, then fall back to built-in key
key = ''
try:
import xbmcvfs, json
f = xbmcvfs.File('special://profile/addon_data/plugin.video.youtube/api_keys.json')
data = json.loads(f.read())
f.close()
key = data.get('keys', {}).get('user', {}).get('api_key', '')
except Exception:
pass
if key:
_log('YT-apikey: addon key (%s...)' % key[:8])
_yt_api_key = key
return key
# 2. Fallback
if _API_CHECKSUM_B64:
try:
import base64
key = base64.b64decode(_API_CHECKSUM_B64).decode()
if key:
_log('YT-apikey: fallback (%s...)' % key[:8])
_yt_api_key = key
return key
except Exception:
pass
_log('YT-apikey: MISSING')
_yt_api_key = ''
return ''
def _getUserKey():
"""Return user's own API key, or '' if they copied our built-in key."""
key = _getYouTubeApiKey()
if not key or _b64.b64encode(key.encode()) == _API_CHECKSUM_B64:
return '' # no key or same as built-in -> not a user key
return key
def _fetchVideoDetails(keys, api_key=None):
"""Call YouTube Data API v3 to get duration, age-restriction, privacy and category for video IDs.
Uses _yt_video_cache to avoid redundant API calls across search steps.
Returns dict {video_id: {...}} on success (may be empty if videos are unavailable).
Returns None on API failure (no key, dead API, network error)."""
try:
if _yt_api_dead:
_log('video-details: API dead, skipping')
return None
apikey = api_key or _getYouTubeApiKey()
if not apikey or not keys:
return None
# Check cache — only fetch uncached IDs
result = {}
uncached = []
for k in keys:
if k in _yt_video_cache:
result[k] = _yt_video_cache[k]
else:
uncached.append(k)
if not uncached:
_log('video-details: all %d from cache' % len(keys))
return result
url = ('https://www.googleapis.com/youtube/v3/videos'
'?part=contentDetails,status,snippet,statistics&id=%s&key=%s'
% (','.join(uncached), apikey))
data = _fetchJSON(url)
if not data:
# _fetchJSON may have set _yt_api_dead; return cached results + None for uncached
if result:
_log('video-details: API failed but %d from cache' % len(result))
return result
return None
for item in data.get('items', []):
cd = item.get('contentDetails', {})
st = item.get('status', {})
sn = item.get('snippet', {})
stats = item.get('statistics', {})
dur = cd.get('duration', '')
m = re.match(r'PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?', dur)
secs = (int(m.group(1) or 0) * 3600
+ int(m.group(2) or 0) * 60
+ int(m.group(3) or 0)) if m else 0
age_restricted = cd.get('contentRating', {}).get('ytRating') == 'ytAgeRestricted'
unlisted = st.get('privacyStatus') != 'public'
cam_rip = sn.get('categoryId') == '22'
views = int(stats.get('viewCount', 0))
info = {'secs': secs, 'age_restricted': age_restricted,
'unlisted': unlisted, 'cam_rip': cam_rip, 'views': views}
_yt_video_cache[item['id']] = info
result[item['id']] = info
_log('video-details: fetched=%d cached=%d total=%d' % (
len(uncached), len(keys) - len(uncached), len(result)))
return result
except Exception as e:
_log('video-details exception: %s' % e)
return None
def _oembedFetch(video_id):
"""Fetch oEmbed data for a YouTube video (free, no API key, no quota).
Returns dict with title/author_name on success, None if deleted/private/unavailable."""
try:
import json
from urllib.request import Request, urlopen
from urllib.error import HTTPError
url = 'https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=%s&format=json' % video_id
req = Request(url)
req.add_header('User-Agent', 'Mozilla/5.0')
resp = urlopen(req, timeout=5)
return json.loads(resp.read().decode('utf-8'))
except HTTPError as e:
if e.code in (404, 401, 403):
_log('oEmbed %s: HTTP %d (unavailable)' % (video_id, e.code))
return None
return {} # other HTTP errors — assume available but no data
except Exception:
return {} # network error — assume available but no data
def _videoExists(video_id):
"""Check if a YouTube video exists using the free oEmbed endpoint (no API key, no quota).
Returns True if video is available, False if deleted/private/unavailable."""
return _oembedFetch(video_id) is not None
def _filterExistence(hits):
"""Remove deleted/private videos using free oEmbed check (0 YT quota).
Used for SmartTube path where we don't need age/duration filtering."""
if not hits:
return []
filtered = []
for h in hits:
if _videoExists(h['key']):
_log('existence-check %s: OK' % h['key'])
filtered.append(h)
else:
_log('existence-check %s: REJECT (unavailable)' % h['key'])
return filtered
def _filterByDuration(hits, minS=60, maxS=360, skip_api=False, api_key=None):
"""Filter YouTube hits by duration and remove age-restricted/unlisted/cam-rip videos.
When skip_api=True (SmartTube): uses free oEmbed existence check (0 quota).
Falls back to unfiltered list only if API is completely unavailable (None)."""
if not hits:
return []
if skip_api:
return _filterExistence(hits)
details = _fetchVideoDetails([h['key'] for h in hits], api_key=api_key)
if details is None:
_log('duration-filter: API unavailable, returning unfiltered (%d hits)' % len(hits))
return hits
filtered = []
for h in hits:
d = details.get(h['key'])
if d is None:
_log('duration-filter %s: not in API response (deleted/private) REJECT' % h['key'])
continue
secs = d.get('secs', 0)
aged = d.get('age_restricted', False)
priv = d.get('unlisted', False)
cam = d.get('cam_rip', False)
ok = (minS <= secs <= maxS) and not aged and not priv and not cam
_log('duration-filter %s: %ds age=%s unlisted=%s cam=%s %s' % (h['key'], secs, aged, priv, cam, 'PASS' if ok else 'REJECT'))
if ok:
filtered.append(h)
# Re-rank: promote a video with overwhelming views (>=10K AND >=10x first pick)
if len(filtered) >= 2:
views = [(details.get(h['key'], {}).get('views', 0), h) for h in filtered]
best_views = max(v for v, _ in views)
first_views = views[0][0]
if best_views >= 10000 and best_views >= 10 * max(first_views, 1):
filtered.sort(key=lambda h: details.get(h['key'], {}).get('views', 0), reverse=True)
_log('view-rank: promoted %s (%d views) over %s (%d views)' % (
filtered[0]['key'], best_views, views[0][1]['key'], first_views))
return filtered # empty = all rejected -> waterfall continues to next source
def _filterAgeRestricted(hits, skip_api=False, api_key=None):
"""Remove unavailable videos (always) and age-restricted/unlisted/cam-rip (YT addon only).
When skip_api=True (SmartTube): uses free oEmbed existence check (0 quota).
Falls back to unfiltered list only if API is completely unavailable (None)."""
if not hits:
return []
if skip_api:
return _filterExistence(hits)
details = _fetchVideoDetails([h['key'] for h in hits], api_key=api_key)
if details is None:
return hits
filtered = []
for h in hits:
d = details.get(h['key'])
if d is None:
_log('age-check %s: not in API response (deleted/private) REJECT' % h['key'])
continue
aged = d.get('age_restricted', False)
priv = d.get('unlisted', False)
cam = d.get('cam_rip', False)
ok = not aged and not priv and not cam
_log('age-check %s: age=%s unlisted=%s cam=%s %s' % (h['key'], aged, priv, cam, 'SKIP' if not ok else 'OK'))
if ok:
filtered.append(h)
return filtered
def _htmlDecode(s):
"""Decode HTML entities in YouTube API snippet titles (&#39; -> ', &quot; -> ", etc.)."""
from html import unescape
return unescape(s)
def _yearConflict(vtitle, year):
"""Check if a video title contains a 4-digit year that differs from the expected year.
Looks for years both in parentheses (2019) and bare 2019.
Returns True if a DIFFERENT year is found — meaning the video is likely for a different movie."""
if not year:
return False
decoded = _htmlDecode(vtitle)
# Find all 4-digit years in range 1920-2039
found = re.findall(r'(?<!\d)((?:19|20)\d{2})(?!\d)', decoded)
if not found:
return False # no year in title — can't tell, allow it
# If any found year matches the expected year, it's OK
if year in found:
return False
# All found years differ from expected — wrong movie
return True
def _titleOkChannel(vtitle, title, year=''):
"""Title check for curated channel results (KinoCheck): title match, no Shorts, year conflict."""
vl = _htmlDecode(vtitle).lower()
if title.lower() not in vl:
return False
if '#short' in vl:
return False
if _yearConflict(vtitle, year):
return False
return True
def _titleOkGlobal(vtitle, title, year=''):
"""Strict title check for global YouTube search results."""
vl = _htmlDecode(vtitle).lower()
if title.lower() not in vl:
return False
if any(w in vl for w in _JUNK_WORDS):
return False
if not any(w in vl for w in _TRAILER_WORDS):
return False
if _yearConflict(vtitle, year):
return False
return True
def _uploadYearOk(snippet, year, max_gap=5):
"""Check if a YouTube video's upload date is within max_gap years of the movie year.
Uses snippet.publishedAt (available in search results, no extra API call).
Returns True if OK or if we can't determine (missing data). False if gap too large."""
if not year:
return True
pub = snippet.get('publishedAt', '') # e.g. "2019-03-11T17:00:06Z"
if not pub or len(pub) < 4:
return True
try:
upload_year = int(pub[:4])
movie_year = int(year)
gap = upload_year - movie_year
# Trailers are typically uploaded 0-2 years before/after release.
# A large positive gap means someone uploaded a trailer for a much older movie — suspicious.
if gap > max_gap:
return False
except (ValueError, TypeError):
return True
return True
# Blocklisted channel keywords — reject YT search results from music/gaming channels
_BAD_CHANNELS = [
'music', 'vevo', 'records', 'gaming', 'gameplay', 'react',
'podcast', 'radio', 'live performance',
]
def _oembedSanityCheck(video_id, title, year=''):
"""Last safety check before playing a YouTube search result (steps 4/5).
Single oEmbed call (free, 0 quota) on the #1 pick. Checks:
1. Video still exists (not deleted/private)
2. Full title (not truncated) has no year conflict
3. Channel name is not obviously wrong (music/gaming/etc.)
Returns True if OK to play, False if should skip this step."""
data = _oembedFetch(video_id)
if data is None:
_log('sanity-check %s: FAIL (unavailable)' % video_id)
return False
if not data:
_log('sanity-check %s: PASS (no data, assume ok)' % video_id)
return True # network error — no data but assume ok
full_title = data.get('title', '')
author = data.get('author_name', '')
_log('sanity-check %s: title=%r author=%r' % (video_id, full_title[:80], author))
# Check full title for year conflict (search snippet may have been truncated)
if full_title and _yearConflict(full_title, year):
_log('sanity-check %s: FAIL (year conflict in full title)' % video_id)
return False
# Check channel name for obvious mismatches
if author:
al = author.lower()
if any(w in al for w in _BAD_CHANNELS):
_log('sanity-check %s: FAIL (bad channel: %r)' % (video_id, author))
return False
_log('sanity-check %s: PASS' % video_id)
return True
# ── TMDB video helper ─────────────────────────────────────────────────────────
def _tmdbVideos(data, lang=None):
"""Extract YouTube Trailer/Teaser from a TMDB /videos response, newest first.
If lang is given, only include videos with matching iso_639_1 (e.g. 'de', 'en')."""
if not data:
return []
all_results = data.get('results', [])
for v in all_results:
_log(' tmdb-video: type=%s site=%s lang=%s name=%r date=%s' % (
v.get('type'), v.get('site'), v.get('iso_639_1'),
v.get('name', '')[:60], v.get('published_at', '')[:10]))
videos = [v for v in all_results
if v.get('site') == 'YouTube'
and v.get('type') in ('Trailer', 'Teaser')
and (lang is None or v.get('iso_639_1') == lang)]
# Sort: Trailer before Teaser, then newest first within each type.
videos.sort(key=lambda v: v.get('published_at', ''), reverse=True)
videos.sort(key=lambda v: 0 if v.get('type') == 'Trailer' else 1)
return videos
# ── Source-specific search functions ─────────────────────────────────────────
def _searchKinoCheckAPI(tmdb_id, mediatype='movie', language='de'):
"""Exact TMDB ID lookup via KinoCheck API. Free, no key required, no YT quota.
NOT gated by _yt_api_dead — this uses kinocheck.de, not YouTube API.
Returns (hits, api_ok):
hits — list of {name, key} (YouTube videos), empty if no trailer
api_ok — True if API responded (even with no trailer), False on error/timeout
"""
try:
endpoint = 'movies' if mediatype == 'movie' else 'shows'
url = 'https://api.kinocheck.de/%s?tmdb_id=%s&language=%s' % (endpoint, tmdb_id, language)
_log('KinoCheck-API: %s' % url)
data = _fetchJSON(url)
if not data:
_log('KinoCheck-API: empty response (down/rate-limited?)')
return [], False
# API responded — check for videos
trailer = data.get('trailer')
videos = data.get('videos', [])
if not trailer and not videos:
_log('KinoCheck-API: no trailer for tmdb_id=%s' % tmdb_id)
return [], True # api_ok=True — they don't have it, skip YT fallback
hits = []
# Primary trailer first
if trailer and trailer.get('youtube_video_id'):
hits.append({'name': trailer.get('title', ''), 'key': trailer['youtube_video_id'], 'language': language})
_log('KinoCheck-API trailer: %s %r lang=%s' % (trailer['youtube_video_id'], trailer.get('title', '')[:60], language))
# Additional videos
for v in videos:
vid = v.get('youtube_video_id', '')
if vid and vid not in [h['key'] for h in hits]:
cat = v.get('categories', '')
if cat in ('Trailer', 'Teaser'):
hits.append({'name': v.get('title', ''), 'key': vid, 'language': v.get('language', language)})
_log('KinoCheck-API video: %s %r cat=%s lang=%s' % (vid, v.get('title', '')[:60], cat, v.get('language', language)))
return hits, True
except Exception as e:
_log('KinoCheck-API exception: %s' % e)
return [], False
def _searchKinoCheck(title, year):
"""Search KinoCheck YouTube channel for a German trailer.
Requires working YouTube API key. Gated by _yt_api_dead flag.
Year-matched results bubble to the top. Returns list of {name, key}."""
try:
if _yt_api_dead:
_log('KinoCheck-YT: API dead, skipping')
return []
from urllib.parse import quote_plus
apikey = _getUserKey()
if not apikey:
_log('KinoCheck-YT: no own API key, skipping')
return []
parts = ['"%s"' % title]
if year:
parts.append(str(year))
parts.append('Trailer')
query = ' '.join(parts)
url = ('https://www.googleapis.com/youtube/v3/search?part=snippet'
'&channelId=%s&q=%s&type=video&maxResults=10'
'&relevanceLanguage=de&key=%s'
% (KINOCHECK_CHANNEL, quote_plus(query), apikey))
_log('KinoCheck query: %r' % query)
data = _fetchJSON(url)
hits = []
for it in data.get('items', []):
vtitle = it['snippet']['title']
ok = _titleOkChannel(vtitle, title, year)
_log(' KinoCheck %s: %r' % ('PASS' if ok else 'REJECT', vtitle[:80]))
if not ok:
continue
entry = {'name': vtitle, 'key': it['id']['videoId']}
if year and '(%s)' % year in vtitle:
hits.insert(0, entry) # year match -> front
else:
hits.append(entry)
return hits
except Exception as e:
_log('KinoCheck exception: %s' % e)
return []
def _searchYouTube(title, year, lang=''):
"""Global YouTube search with strict title filter.
Single query: "title" year trailer (maxResults=25).
Results cached in _yt_search_cache. Cross-language cache hit for same-title movies.
Gated by _yt_api_dead flag. Returns list of {name, key}."""
try:
if _yt_api_dead:
_log('YouTube-%s: API dead, skipping' % (lang or 'xx'))
return []
from urllib.parse import quote_plus
apikey = _getUserKey()
if not apikey:
_log('YouTube-%s: no own API key, skipping' % (lang or 'xx'))
return []
# Check cache — avoid burning 100 units if we already searched this title
cache_key = (title.lower(), str(year), lang)
cached_items = _yt_search_cache.get(cache_key)
# Cross-language reuse: same title+year already searched in a different lang
if cached_items is None:
for (t, y, l), items in _yt_search_cache.items():
if t == title.lower() and y == str(year) and l != lang:
cached_items = items
_log('YouTube-%s: cross-lang cache hit from %s (%d items, 0 units)'
% (lang or 'xx', l, len(items)))
_yt_search_cache[cache_key] = items
break
if cached_items is not None:
_log('YouTube-%s: cache hit for %r year=%s, re-filtering %d items'
% (lang or 'xx', title, year, len(cached_items)))
results = []
for it in cached_items:
vtitle = it['snippet']['title']
ok = _titleOkGlobal(vtitle, title, year)
if ok and not _uploadYearOk(it.get('snippet', {}), year):
ok = False
_log(' YouTube-%s REJECT (upload year gap): %r pub=%s' % (
lang or 'xx', vtitle[:80], it.get('snippet', {}).get('publishedAt', '')[:10]))
else:
_log(' YouTube-%s %s: %r' % (lang or 'xx', 'PASS' if ok else 'REJECT', vtitle[:80]))
if ok:
results.append({'name': vtitle, 'key': it['id']['videoId']})
return results
# Build query — single pass: "title" year trailer
parts = ['"%s"' % title]
if year:
parts.append(str(year))
parts.append('trailer')
query = ' '.join(parts)
url = ('https://www.googleapis.com/youtube/v3/search?part=snippet'
'&q=%s&type=video&maxResults=25&key=%s'
% (quote_plus(query), apikey))
if lang:
url += '&relevanceLanguage=%s' % lang[:2]
_log('YouTube-%s query: %r' % (lang or 'xx', query))
data = _fetchJSON(url)
# Cache raw items (before filtering)
raw_items = data.get('items', [])
_yt_search_cache[cache_key] = raw_items
# Filter
results = []
for it in raw_items:
vtitle = it['snippet']['title']
ok = _titleOkGlobal(vtitle, title, year)
if ok and not _uploadYearOk(it.get('snippet', {}), year):
ok = False
_log(' YouTube-%s REJECT (upload year gap): %r pub=%s' % (
lang or 'xx', vtitle[:80], it.get('snippet', {}).get('publishedAt', '')[:10]))
else:
_log(' YouTube-%s %s: %r' % (lang or 'xx', 'PASS' if ok else 'REJECT', vtitle[:80]))
if ok:
results.append({'name': vtitle, 'key': it['id']['videoId']})
return results
except Exception as e:
_log('YouTube-%s exception: %s' % (lang or 'xx', e))
return []
# ── IMDB direct MP4 lookup ───────────────────────────────────────────────────
# IMDB quality preference (MP4 > HLS, highest resolution first)
_IMDB_QUALITY_ORDER = ['DEF_1080p', 'DEF_720p', 'DEF_480p', 'DEF_SD']
_IMDB_GRAPHQL_URL = 'https://caching.graphql.imdb.com/'
# Minimal GraphQL query: fetches primary video + CloudFront-signed playback URLs (~3 KB response)
_IMDB_GRAPHQL_QUERY = '{"query":"query($id:ID!){title(id:$id){primaryVideos(first:1){edges{node{id name{value}playbackURLs{mimeType url videoDefinition}}}}}}","variables":{"id":"%s"}}'
def _searchIMDB(imdb_id):
"""IMDB trailer lookup via GraphQL API (~3 KB response vs 1.5 MB title page).
Returns (mp4_url, quality) on success, ('', '') on failure.
Result cached with 1h TTL (CloudFront signed URLs expire in ~24h)."""
import time, json
global _imdb_dead
if not imdb_id:
return ('', '')
if _imdb_dead:
_log('IMDB: dead flag set, skipping')
return ('', '')
# Check cache
cached = _imdb_cache.get(imdb_id)
if cached:
url, quality, expiry = cached
if time.time() < expiry:
_log('IMDB cache hit: %s -> %s (%s)' % (imdb_id, url[:80] if url else '', quality))
return (url, quality)
else:
del _imdb_cache[imdb_id]
# GraphQL query for primary video + playback URLs
_log('IMDB GraphQL: %s' % imdb_id)
from urllib.request import Request, urlopen
from urllib.error import HTTPError
try:
body = (_IMDB_GRAPHQL_QUERY % imdb_id).encode('utf-8')
req = Request(_IMDB_GRAPHQL_URL, data=body, method='POST')
req.add_header('Content-Type', 'application/json')
req.add_header('Accept', 'application/json')
req.add_header('User-Agent',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/120.0.0.0 Safari/537.36')
resp = urlopen(req, timeout=5)
data = json.loads(resp.read().decode('utf-8'))
except HTTPError as e:
if e.code in (403, 429):
_imdb_dead = True
_log('IMDB blocked: HTTP %d — skipping IMDB for rest of session' % e.code)
else:
_log('IMDB GraphQL HTTP %s' % e.code)
return ('', '')
except Exception as e:
_log('IMDB GraphQL error: %s' % e)
return ('', '')
# Parse response: data.title.primaryVideos.edges[0].node.playbackURLs
try:
edges = data['data']['title']['primaryVideos']['edges']
except (KeyError, TypeError):
_log('IMDB: unexpected GraphQL structure for %s' % imdb_id)
_imdb_cache[imdb_id] = ('', '', time.time() + _IMDB_CACHE_TTL)
return ('', '')
if not edges:
_log('IMDB: no trailer for %s' % imdb_id)
_imdb_cache[imdb_id] = ('', '', time.time() + _IMDB_CACHE_TTL)
return ('', '')
node = edges[0].get('node', {})
video_name = (node.get('name') or {}).get('value', '')
urls = node.get('playbackURLs', [])
_log('IMDB: video=%s name=%r urls=%d' % (node.get('id', ''), video_name, len(urls)))
if not urls:
_imdb_cache[imdb_id] = ('', '', time.time() + _IMDB_CACHE_TTL)
return ('', '')
# Pick best quality MP4
best_url = ''
best_quality = ''
for pref in _IMDB_QUALITY_ORDER:
for entry in urls:
if entry.get('videoDefinition') == pref and entry.get('mimeType') == 'video/mp4':
best_url = entry['url']
best_quality = pref.replace('DEF_', '')
break
if best_url:
break
# Fallback to HLS (M3U8)
if not best_url:
for entry in urls:
if 'mpegurl' in (entry.get('mimeType') or '').lower():
best_url = entry['url']
best_quality = 'HLS'
break
# Fallback to any MP4
if not best_url:
for entry in urls:
if entry.get('mimeType') == 'video/mp4':
best_url = entry['url']
best_quality = (entry.get('videoDefinition') or '').replace('DEF_', '') or '?'
break
_log('IMDB result: quality=%s url=%s' % (best_quality, best_url[:80] if best_url else ''))
_imdb_cache[imdb_id] = (best_url, best_quality, time.time() + _IMDB_CACHE_TTL)
return (best_url, best_quality)
# ── Notification + playback ───────────────────────────────────────────────────
def _notify(search_title, step, source, vtype, lang, poster):
"""3-second notification popup (upper-right).
Heading: search title used (DE or EN).
Message: source - type [lang] e.g. 'TMDB - Trailer [DE]'
If lang is empty (e.g. IMDB): 'IMDB - Trailer'
"""
try:
import xbmcgui
icon = poster if poster else xbmcgui.NOTIFICATION_INFO
msg = '%s - %s [%s]' % (source, vtype, lang) if lang else '%s - %s' % (source, vtype)
xbmcgui.Dialog().notification(
search_title,
msg,
icon,
3000,
False,
)
except Exception:
pass
def _play(video_id, step, source, vtype, lang, poster, search_title):
"""Show source/language popup then play via SmartTube (if installed) or YouTube addon."""
import xbmc
_log('PLAY video_id=%s step=%d source=%s vtype=%s lang=%s title=%r'
% (video_id, step, source, vtype, lang, search_title))
_notify(search_title, step, source, vtype, lang, poster)
pkg = _getSmartTubePackage()
if pkg:
xbmc.sleep(2000) # let notification show before SmartTube covers Kodi UI
_log('PLAY via SmartTube (%s)' % pkg)
xbmc.executebuiltin(
'StartAndroidActivity(%s,android.intent.action.VIEW,,'
'https://www.youtube.com/watch?v=%s,,'
'"[{\\"type\\":\\"string\\",\\"key\\":\\"finish_on_ended\\"'
',\\"value\\":\\"true\\"}]")'
% (pkg, video_id)
)
else:
_log('PLAY via YouTube addon')
xbmc.executebuiltin(
'PlayMedia(plugin://plugin.video.youtube/play/?video_id=%s)' % video_id
)
class _TrailerPlayer(object):
"""Kodi player wrapper for direct MP4/HLS (IMDB). Monitors fullscreen, stops on back."""
def __init__(self):
import xbmc as _xbmc
class _P(_xbmc.Player):
def __init__(s): super().__init__(); s.done = False
def onPlayBackStopped(s): s.done = True
def onPlayBackEnded(s): s.done = True
def onPlayBackError(s): s.done = True
self._p = _P()
self._mon = _xbmc.Monitor()
self._xbmc = _xbmc
def play(self, url): self._p.play(url)
def stop(self): self._p.stop()
@property
def done(self): return self._p.done
def wait(self, secs): return self._mon.waitForAbort(secs)
@property
def aborted(self): return self._mon.abortRequested()
def fullscreen(self):
return self._xbmc.getCondVisibility('Window.IsVisible(fullscreenvideo)')
def _playDirect(url, step, source, vtype, lang, poster, search_title):
"""Show source popup then play a direct MP4/M3U8 URL via Kodi's native player.
Monitors fullscreen — stops playback when user presses back."""
_log('PLAY-DIRECT url=%s step=%d source=%s vtype=%s title=%r'
% (url[:80], step, source, vtype, search_title))
_notify(search_title, step, source, vtype, lang, poster)
tp = _TrailerPlayer()
tp.play(url)
# Wait for fullscreen to appear — exit early if playback fails
fs_seen = False
while not tp.aborted and not tp.done:
if tp.fullscreen():
fs_seen = True
break
tp.wait(0.1)
if not fs_seen:
_log('PLAY-DIRECT: playback ended before fullscreen')
return
# Monitor: stop when user leaves fullscreen (back = stop for trailers)
while not tp.aborted and not tp.done:
if not tp.fullscreen():
tp.stop()
_log('PLAY-DIRECT stopped (user left fullscreen)')
break
tp.wait(0.3)
# ── Shared search core (addon-agnostic) ──────────────────────────────────────
def _runTrailerSearch(tmdb_id, mediatype, title, en_title, year, poster,
imdb_id, languages, has_yt_player, has_own_key, skip_api,
tmdb_videos):
"""Per-language priority block search — shared core for xStream/xShip.
languages: list of 1-3 ISO codes, e.g. ['de'] or ['ja', 'de', 'en']
tmdb_videos: single pre-fetched TMDB /videos response (all languages)
Block list = languages + EN (if missing) + ANY.
Per block: KC API -> KC YT (if DE) -> TMDB -> IMDB (if EN).
After all blocks: YouTube search per caller language, then give up.
Returns dict on success: {'found_lang': 'DE', 'source': 'IMDB'|'KinoCheck'|...}
Returns None on give-up (no trailer found).
"""
import xbmcgui
_vf = _api_checksum # built-in key for cheap 1-unit verification (age/duration filter)
# Build block list: caller languages + EN (ensures IMDB) + ANY (catches remaining)
blocks = list(languages)
if 'en' not in blocks:
blocks.append('en') # EN auto-added so IMDB always gets its own block
blocks.append(None) # None = ANY block (TMDB videos in unlisted languages)
all_explicit = [b for b in blocks if b] # named languages to exclude from ANY block
_log('SEARCH languages=%s blocks=%s' % (languages, [b or 'ANY' for b in blocks]))
step = 0
# Walk each block in priority order — first trailer found wins
for lang in blocks:
is_any = (lang is None)
lang_label = lang.upper() if lang else 'ANY'
lang_title = en_title if (lang == 'en' or is_any) else title # EN title for EN/ANY
# ── Sources that return YouTube video IDs (need a player) ─────
if has_yt_player:
# KinoCheck API: free, no key, ID-based (only supports de/en)
# ANY block: try KC-API(de) only if DE wasn't already an explicit block
do_kc = not is_any or (is_any and 'de' not in languages)
if do_kc:
kc_lang = 'de' if is_any else lang
step += 1
_log('--- [%s] KinoCheck API (lang=%s) ---' % (lang_label, kc_lang))
kc_hits, kc_ok = _searchKinoCheckAPI(tmdb_id, mediatype, language=kc_lang)
_log('[%s] KC-API: hits=%d ok=%s' % (lang_label, len(kc_hits), kc_ok))
if kc_hits:
if not skip_api:
non_rb = [h for h in kc_hits if 'red band' not in h.get('name', '').lower()]
if non_rb:
kc_hits = non_rb
else:
_log('[%s] KC-API: only Red Band, running age-check' % lang_label)
kc_hits = _filterAgeRestricted(kc_hits, skip_api=False, api_key=_vf)
else:
kc_hits = _filterExistence(kc_hits)
if kc_hits:
_play(kc_hits[0]['key'], step, 'KinoCheck', 'Trailer',
kc_lang.upper(), poster, lang_title)
return {'found_lang': kc_lang.upper(), 'source': 'KinoCheck'}
_log('[%s] KC-API: all results unavailable' % lang_label)
# KinoCheck YT channel search: DE only, needs user's own key (100 units)
if (lang == 'de' or (is_any and 'de' not in languages)) and has_own_key:
step += 1
_log('--- [%s] KinoCheck YT channel ---' % lang_label)
kc_raw = _searchKinoCheck(lang_title, year)
kc_hit = _filterByDuration(kc_raw, skip_api=skip_api, api_key=_vf)
_log('[%s] KC-YT: raw=%d filtered=%d' % (lang_label, len(kc_raw), len(kc_hit)))
if kc_hit:
_play(kc_hit[0]['key'], step, 'KinoCheck', 'Trailer',
'DE', poster, lang_title)
return {'found_lang': 'DE', 'source': 'KinoCheck'}
# TMDB videos: filter pre-fetched results by language (0 API calls)
step += 1
_log('--- [%s] TMDB videos ---' % lang_label)
if is_any:
videos = _tmdbVideos(tmdb_videos)
videos = [v for v in videos if v.get('iso_639_1') not in all_explicit] # exclude already-tried langs
else:
videos = _tmdbVideos(tmdb_videos, lang=lang)
videos = _filterAgeRestricted(videos, skip_api=skip_api, api_key=_vf)
_log('[%s] TMDB: filtered=%d' % (lang_label, len(videos)))
if videos:
vlang = (videos[0].get('iso_639_1') or lang or '??').upper()
_play(videos[0]['key'], step, 'TMDB', videos[0].get('type', 'Trailer'),
vlang, poster, lang_title)
return {'found_lang': vlang, 'source': 'TMDB'}
# IMDB direct MP4: EN block only, no player/key needed, ID-based
if lang == 'en' and imdb_id and not _imdb_dead:
step += 1
_log('--- [EN] IMDB ---')
imdb_url, imdb_quality = _searchIMDB(imdb_id)
_log('[EN] IMDB: url=%s quality=%s' % (imdb_url[:80] if imdb_url else '', imdb_quality))
if imdb_url:
_playDirect(imdb_url, step, 'IMDB', 'Trailer', '', poster, en_title or title)
return {'found_lang': 'EN', 'source': 'IMDB'}
# YouTube global search (last resort, expensive: 100-201 units per language)
if has_yt_player and has_own_key:
user_key = _getUserKey() # search uses user's own key (not built-in)
for yt_lang in languages:
step += 1
yt_title = en_title if yt_lang == 'en' else title
yt_upper = yt_lang.upper()
_log('--- YouTube-%s search ---' % yt_upper)
yt_raw = _searchYouTube(yt_title, year, lang=yt_lang)
yt_hit = _filterByDuration(yt_raw, skip_api=skip_api, api_key=user_key)
_log('YouTube-%s: raw=%d filtered=%d' % (yt_upper, len(yt_raw), len(yt_hit)))
if yt_hit and _oembedSanityCheck(yt_hit[0]['key'], yt_title, year):
_play(yt_hit[0]['key'], step, 'YouTube', 'Trailer',
yt_upper, poster, yt_title)
return {'found_lang': yt_upper, 'source': 'YouTube'}
# ── Give up ───────────────────────────────────────────────────
_log('Give up — languages=%s has_yt_player=%s has_own_key=%s' % (languages, has_yt_player, has_own_key))
return None
# ── User guidance popups (once per Kodi session) ─────────────────────────────
def _showHintIfNeeded(has_yt_player, has_own_key, found_any, played_imdb, primary_lang='de'):
"""Show guidance popup after trailer plays (or at give-up). Once per Kodi session.
Popup 1: no player, IMDB played, primary_lang != 'en' -> suggest player install.
Popup 2: has player, no own key, zero hits -> suggest YT addon with own API key.
Messages in German if Kodi GUI is German, English otherwise.
Returns True if a popup was shown."""
try:
import xbmc, xbmcgui
win = xbmcgui.Window(10000)
kodi_lang = xbmc.getLanguage(xbmc.ISO_639_1) or 'de'
is_de_gui = (_ADDON_NAME == 'xship') or (kodi_lang == 'de')
if not has_yt_player and played_imdb and primary_lang != 'en':
# Popup 1: IMDB played but user wanted non-EN -> suggest player
if not win.getProperty(_PROP_PREFIX + '.hint.player'):
xbmc.sleep(2000)
is_android = xbmc.getCondVisibility('System.Platform.Android')
has_kc = primary_lang in ('de', 'en')
if is_de_gui:
sources = 'KinoCheck und TMDB' if has_kc else 'TMDB'
player = 'SmartTube oder das YouTube Add-on' if is_android else 'das YouTube Add-on'
msg = ('Dieser Trailer war auf Englisch (IMDB).\n'
'F\u00fcr weitere Trailer in deiner Sprache von %s '
'%s installieren (kein API-Key n\u00f6tig).' % (sources, player))
else:
sources = 'KinoCheck and TMDB' if has_kc else 'TMDB'
player = 'SmartTube or the YouTube add-on' if is_android else 'the YouTube add-on'
msg = ('This trailer was in English (IMDB).\n'
'For additional trailers in your language from %s '
'install %s (no API key needed).' % (sources, player))
xbmcgui.Dialog().ok('Trailer', msg)
win.setProperty(_PROP_PREFIX + '.hint.player', '1')
_log('hint: showed player popup')
return True
elif has_yt_player and not has_own_key and not found_any:
# Popup 2: zero hits, no own key -> suggest YT addon with API key
if not win.getProperty(_PROP_PREFIX + '.hint.apikey'):
xbmc.sleep(2000)
if is_de_gui:
msg = ('Kein Trailer gefunden.\n'
'Du kannst versuchen, das YouTube Add-on mit eigenem API-Key zu installieren, '
'um zus\u00e4tzliche Trailer-Quellen auf YouTube zu finden.')
else:
msg = ('No trailer found.\n'
'You could try to install the YouTube add-on with your own API key '
'to find additional trailer sources on YouTube.')
xbmcgui.Dialog().ok('Trailer', msg)
win.setProperty(_PROP_PREFIX + '.hint.apikey', '1')
_log('hint: showed apikey popup')
return True
except Exception as e:
_log('hint popup error: %s' % e)
return False
# ── Entry point (shared by xStream and xShip) ────────────────────────────────
def playTrailer(tmdb_id, mediatype='movie', title='', year='', poster='', pref_lang='de'):
"""Trailer wrapper — detects capabilities, pre-fetches TMDB data,
then calls _runTrailerSearch().
Args:
tmdb_id: TMDB numeric ID (string), or empty for Phase 0 resolution (xStream)
mediatype: 'movie' or 'tv' (xStream may pass 'tvshow' — mapped to 'tv')
title: display title (for YouTube fallback searches)
year: release year string
poster: poster image URL (shown as notification icon)
pref_lang: preferred trailer language code ('de', 'en', 'fr', ...)
xStream: context menu passes prefLanguage, TMDB dialog passes tmdb_lang.
xShip: default 'de'.
"""
import xbmc, xbmcgui
from resources.lib.tmdb import cTMDB
if mediatype == 'tvshow':
mediatype = 'tv'
url_type = 'movie' if mediatype == 'movie' else 'tv'
title_key = 'title' if mediatype == 'movie' else 'name'
# ── Build language list (addon-specific) ────────────────────────────
if _ADDON_NAME == 'xstream':
# xStream: 3 settings sources merged, deduplicated, narrowest first
try:
from resources.lib.config import cConfig
_tmdb_lang = cConfig().getSetting('tmdb_lang') or 'de'
_pref_raw = cConfig().getSetting('prefLanguage') or '0'
_kodi_lang = xbmc.getLanguage(xbmc.ISO_639_1) or 'de'
_pref_map = {'0': _kodi_lang, '1': 'de', '2': 'en', '3': 'ja'}
_xstream_pref = _pref_map.get(_pref_raw, _kodi_lang)
languages = []
for lang in [pref_lang, _tmdb_lang, _xstream_pref, _kodi_lang]:
if lang and lang not in languages:
languages.append(lang)
_log('Languages: pref=%s tmdb=%s xstream=%s kodi=%s -> %s' % (
pref_lang, _tmdb_lang, _xstream_pref, _kodi_lang, languages))
except Exception:
languages = [pref_lang or 'de']
else:
# xShip (default): single preferred language, passed by caller
languages = [pref_lang or 'de']
# ── Phase 0 (xStream only): resolve TMDB ID from title search ─────
if _ADDON_NAME == 'xstream' and not tmdb_id:
_log('Phase 0: resolving TMDB ID for title=%r year=%s mediatype=%s' % (title, year, mediatype))
search_title = re.sub(r'\s*\(\d{4}\)\s*$', '', title).strip() if title else ''
if search_title:
try:
tmdb_search = cTMDB()
if mediatype == 'movie':
result = tmdb_search.search_movie_name(search_title, year)
else:
result = tmdb_search.search_tvshow_name(search_title, year)
if result and 'id' in result:
tmdb_id = str(result['id'])
_log('Phase 0: resolved tmdb_id=%s' % tmdb_id)
except Exception as e:
_log('Phase 0: search failed: %s' % e)
if not tmdb_id:
_log('Phase 0: could not resolve TMDB ID, aborting')
xbmcgui.Dialog().notification(
'Trailer', 'TMDB-ID nicht gefunden',
xbmcgui.NOTIFICATION_WARNING, 3000,
)
return
_log('START tmdb_id=%s title=%r year=%s mediatype=%s languages=%s' % (tmdb_id, title, year, mediatype, languages))
# ── Capability detection (same for both addons) ────────────────
smarttube = _getSmartTubePackage() # Android only, cached for session
has_yt_addon = xbmc.getCondVisibility('System.HasAddon(plugin.video.youtube)')
if has_yt_addon:
try:
import xbmcaddon
xbmcaddon.Addon('plugin.video.youtube')
except Exception:
has_yt_addon = False
_log('YouTube addon found but not loadable — disabled/broken')
has_yt_player = bool(smarttube or has_yt_addon) # can play YouTube video IDs
has_own_key = bool(_getUserKey()) # user has own key for expensive searches
skip_api = bool(smarttube) # SmartTube handles age-gates, skip videos.list
_log('Player: %s | YT addon: %s | has_yt_player: %s | has_own_key: %s | skip_api: %s' % (
smarttube if smarttube else 'none', has_yt_addon, has_yt_player, has_own_key, skip_api))
# ── ISA pre-flight: warn if YouTube addon's InputStream Adaptive is off ──
if not smarttube and has_yt_addon:
_ISA_WARNED = _PROP_PREFIX + '.isa_warned'
try:
import xbmcaddon
_win = xbmcgui.Window(10000)
yt = xbmcaddon.Addon('plugin.video.youtube')
if yt.getSetting('kodion.video.quality.isa') != 'true':
if not _win.getProperty(_ISA_WARNED):
_win.setProperty(_ISA_WARNED, '1')
if xbmcgui.Dialog().yesno(
'Trailer',
'"InputStream Adaptive" im YouTube Add-on ist aus.\n'
'Trailer-Wiedergabe kann fehlschlagen. Aktivieren?'):
yt.setSetting('kodion.video.quality.isa', 'true')
_log('ISA enabled via pre-flight check')
except Exception:
pass
# ── Single TMDB call: EN details + all videos + IMDB ID (1 API call) ──
tmdb_en = cTMDB(lang='en') # EN for English title + IMDB ID
en_data = None
try:
term = 'append_to_response=videos'
if url_type == 'tv':
term += ',external_ids'
en_data = tmdb_en.getUrl('%s/%s' % (url_type, tmdb_id), term=term)
en_title = (en_data or {}).get(title_key, '') or title
except Exception:
en_title = title
imdb_id = (en_data or {}).get('imdb_id', '') # movies have imdb_id at top level
if not imdb_id and url_type == 'tv':
imdb_id = (en_data or {}).get('external_ids', {}).get('imdb_id', '') or '' # TV shows need external_ids
tmdb_videos = (en_data or {}).get('videos', {}) # all videos regardless of language
_log('EN title: %r imdb_id: %s tmdb_videos: %d results' % (
en_title, imdb_id, len((tmdb_videos or {}).get('results', []))))
# ── Run per-language block search ────────────────────────────────
result = _runTrailerSearch(
tmdb_id=tmdb_id, mediatype=mediatype,
title=title, en_title=en_title, year=year, poster=poster,
imdb_id=imdb_id, languages=languages,
has_yt_player=has_yt_player, has_own_key=has_own_key, skip_api=skip_api,
tmdb_videos=tmdb_videos,
)
# ── Post-search handling ─────────────────────────────────────────
primary_lang = languages[0] if languages else 'de'
if result:
played_imdb = result['source'] == 'IMDB'
_showHintIfNeeded(has_yt_player, has_own_key, True, played_imdb, primary_lang)
else:
# Give up — show hint popup or generic notification
hint_shown = _showHintIfNeeded(has_yt_player, has_own_key, False, False, primary_lang)
if not hint_shown:
is_de = (_ADDON_NAME == 'xship') or (xbmc.getLanguage(xbmc.ISO_639_1) or 'de') == 'de'
no_hit = 'Kein Trailer gefunden' if is_de else 'No trailer found'
xbmcgui.Dialog().notification(
'Trailer', no_hit,
xbmcgui.NOTIFICATION_WARNING, 3000,
)
# ── Quick trailer existence check (for TMDB info dialog button) ──────────
def hasTrailer(tmdb_id, imdb_id='', mediatype='movie'):
"""Quick async check if a trailer exists via KinoCheck, TMDB, or IMDB.
Runs available checks in parallel, returns True on first hit.
Respects player gating: KinoCheck/TMDB need a YT player, IMDB always works.
Used by tmdbinfo.py to decide whether to show the trailer button."""
import xbmc
from concurrent.futures import ThreadPoolExecutor, as_completed
if mediatype == 'tvshow':
mediatype = 'tv'
url_type = 'movie' if mediatype == 'movie' else 'tv'
_log('hasTrailer: tmdb_id=%s imdb_id=%s mediatype=%s' % (tmdb_id, imdb_id, mediatype))
# Detect YT player capability (same logic as playTrailer)
smarttube_pkg = _getSmartTubePackage()
has_yt_addon = xbmc.getCondVisibility('System.HasAddon(plugin.video.youtube)')
has_yt_player = bool(smarttube_pkg) or has_yt_addon
def _ck():
try:
hits, _ = _searchKinoCheckAPI(tmdb_id, mediatype)
return bool(hits)
except Exception:
return False
def _tmdb():
try:
from resources.lib.tmdb import cTMDB
data = cTMDB().getUrl('%s/%s/videos' % (url_type, tmdb_id))
return bool(data and data.get('results'))
except Exception:
return False
def _imdb():
try:
url, _ = _searchIMDB(imdb_id)
return bool(url)
except Exception:
return False
# Build task list respecting gating
tasks = []
if has_yt_player:
tasks.append(('KinoCheck', _ck))
tasks.append(('TMDB', _tmdb))
# IMDB always available (direct MP4, no player needed)
if imdb_id and not _imdb_dead:
tasks.append(('IMDB', _imdb))
if not tasks:
_log('hasTrailer: no checks to run (no YT player, no IMDB ID)')
return False
with ThreadPoolExecutor(max_workers=len(tasks)) as pool:
futures = {pool.submit(fn): name for name, fn in tasks}
for future in as_completed(futures):
try:
if future.result():
_log('hasTrailer: %s has trailer' % futures[future])
return True
except Exception:
pass
_log('hasTrailer: no trailer found')
return False
# -*- coding: utf-8 -*-
# Python 3
import sys
import xbmc
import xbmcgui
import os
import time
import concurrent.futures
from resources.lib.handler.ParameterHandler import ParameterHandler
from resources.lib.handler.requestHandler import cRequestHandler
from resources.lib.handler.pluginHandler import cPluginHandler
from xbmc import LOGINFO as LOGNOTICE, LOGERROR, log
from resources.lib.gui.guiElement import cGuiElement
from resources.lib.gui.gui import cGui
from resources.lib.config import cConfig
from resources.lib.tools import logger, cParser, cCache
try:
import resolveurl as resolver
except ImportError:
# Resolver Fehlermeldung (bei defekten oder nicht installierten Resolver)
xbmcgui.Dialog().ok(cConfig().getLocalizedString(30119), cConfig().getLocalizedString(30120))
def viewInfo(params):
from resources.lib.tmdbinfo import WindowsBoxes
parms = ParameterHandler()
sCleanTitle = params.getValue('searchTitle')
sMeta = parms.getValue('sMeta')
sYear = parms.getValue('sYear')
WindowsBoxes(sCleanTitle, sCleanTitle, sMeta, sYear)
def parseUrl():
if xbmc.getInfoLabel('Container.PluginName') == 'plugin.video.osmosis':
sys.exit()
params = ParameterHandler()
logger.info(params.getAllParameters())
# If no function is set, we set it to the default "load" function
if params.exist('function'):
sFunction = params.getValue('function')
if sFunction == 'spacer':
return True
elif sFunction == 'clearCache':
cRequestHandler('dummy').clearCache()
return
elif sFunction == 'viewInfo':
viewInfo(params)
return
elif sFunction == 'playTrailer':
from resources.lib.trailer import playTrailer
try:
# Map prefLanguage setting to language code
_pref = cConfig().getSetting('prefLanguage') or '0'
_kodi_lang = xbmc.getLanguage(xbmc.ISO_639_1) or 'de'
_lang_map = {'0': _kodi_lang, '1': 'de', '2': 'en', '3': 'ja'}
_pref_lang = _lang_map.get(_pref, _kodi_lang)
playTrailer(
tmdb_id=params.getValue('tmdb_id') or '',
mediatype=params.getValue('mediatype') or 'movie',
title=params.getValue('title') or '',
year=params.getValue('year') or '',
poster=params.getValue('poster') or '',
pref_lang=_pref_lang,
)
except Exception:
import traceback
log(cConfig().getLocalizedString(30166) + ' -> [xstream]: Trailer error: %s' % traceback.format_exc(), LOGERROR)
cGui.showError('Trailer', 'Trailer-Suche fehlgeschlagen')
return
elif sFunction == 'searchAlter':
searchAlter(params)
return
elif sFunction == 'searchTMDB':
searchTMDB(params)
return
elif sFunction == 'devUpdates':
from resources.lib import updateManager
updateManager.devUpdates()
return
elif sFunction == 'pluginInfo':
cPluginHandler().pluginInfo()
return
elif sFunction == 'changelog':
from resources.lib import tools
cConfig().setSetting('changelog_version', '')
tools.changelog()
return
elif sFunction == 'devWarning':
from resources.lib import tools
tools.devWarning()
return
elif params.exist('remoteplayurl'):
try:
remotePlayUrl = params.getValue('remoteplayurl')
sLink = resolver.resolve(remotePlayUrl)
if sLink:
xbmc.executebuiltin('PlayMedia(' + sLink + ')')
else:
log(cConfig().getLocalizedString(30166) + ' -> [xstream]: Could not play remote url %s ' % sLink, LOGNOTICE)
except resolver.resolver.ResolverError as e:
log(cConfig().getLocalizedString(30166) + ' -> [xstream]: ResolverError: %s' % e, LOGERROR)
return
else:
sFunction = 'load'
# Test if we should run a function on a special site
if not params.exist('site'):
# As a default if no site was specified, we run the default starting gui with all plugins
showMainMenu(sFunction)
return
sSiteName = params.getValue('site')
if params.exist('playMode'):
from resources.lib.gui.hoster import cHosterGui
url = False
playMode = params.getValue('playMode')
isHoster = params.getValue('isHoster')
url = params.getValue('url')
manual = params.exist('manual')
if cConfig().getSetting('hosterSelect') == 'Auto' and playMode != 'jd' and playMode != 'jd2' and playMode != 'pyload' and not manual:
cHosterGui().streamAuto(playMode, sSiteName, sFunction)
else:
cHosterGui().stream(playMode, sSiteName, sFunction, url)
return
log(cConfig().getLocalizedString(30166) + " -> [xstream]: Call function '%s' from '%s'" % (sFunction, sSiteName), LOGNOTICE)
# If the hoster gui is called, run the function on it and return
if sSiteName == 'cHosterGui':
showHosterGui(sFunction)
# If global search is called
elif sSiteName == 'globalSearch':
searchterm = False
if params.exist('searchterm'):
searchterm = params.getValue('searchterm')
searchGlobal(searchterm)
elif sSiteName == 'xStream':
oGui = cGui()
oGui.openSettings()
# resolves strange errors in the logfile
#oGui.updateDirectory()
oGui.setEndOfDirectory()
xbmc.executebuiltin('Action(ParentDir)')
# Resolver Einstellungen im Hauptmenü
elif sSiteName == 'resolver':
oGui = cGui()
resolver.display_settings()
# resolves strange errors in the logfile
oGui.setEndOfDirectory()
xbmc.executebuiltin('Action(ParentDir)')
# Manuelles Update im Hauptmenü
elif sSiteName == 'devUpdates':
from resources.lib import updateManager
updateManager.devUpdates()
# Plugin Infos
elif sSiteName == 'pluginInfo':
cPluginHandler().pluginInfo()
# Changelog anzeigen
elif sSiteName == 'changelog':
from resources.lib import tools
tools.changelog()
# Dev Warnung anzeigen
elif sSiteName == 'devWarning':
from resources.lib import tools
tools.devWarning()
# Unterordner der Einstellungen
elif sSiteName == 'settings':
oGui = cGui()
for folder in settingsGuiElements():
oGui.addFolder(folder)
oGui.setEndOfDirectory()
else:
# Else load any other site as plugin and run the function
plugin = __import__(sSiteName, globals(), locals())
function = getattr(plugin, sFunction)
function()
def showMainMenu(sFunction):
ART = os.path.join(cConfig().getAddonInfo('path'), 'resources', 'art')
addon_id = cConfig().getAddonInfo('id')
start_time = time.time()
# timeout for the startup status check = 60s
while (startupStatus := cCache().get(addon_id + '_main', -1)) != 'finished' and time.time() - start_time <= 60:
time.sleep(0.5)
oGui = cGui()
# Setzte die globale Suche an erste Stelle
if cConfig().getSetting('GlobalSearchPosition') == 'true':
oGui.addFolder(globalSearchGuiElement())
oPluginHandler = cPluginHandler()
aPlugins = oPluginHandler.getAvailablePlugins()
if not aPlugins:
log(cConfig().getLocalizedString(30166) + ' -> [xstream]: No activated Plugins found', LOGNOTICE)
# Open the settings dialog to choose a plugin that could be enabled
oGui.openSettings()
oGui.updateDirectory()
else:
# Create a gui element for every plugin found
for aPlugin in sorted(aPlugins, key=lambda k: k['id']):
if 'vod_' in aPlugin['id']:
continue
oGuiElement = cGuiElement()
oGuiElement.setTitle(aPlugin['name'])
oGuiElement.setSiteName(aPlugin['id'])
oGuiElement.setFunction(sFunction)
if 'icon' in aPlugin and aPlugin['icon']:
oGuiElement.setThumbnail(aPlugin['icon'])
oGui.addFolder(oGuiElement)
if cConfig().getSetting('GlobalSearchPosition') == 'false':
oGui.addFolder(globalSearchGuiElement())
if cConfig().getSetting('SettingsFolder') == 'true':
# Einstellung im Menü mit Untereinstellungen
oGuiElement = cGuiElement()
oGuiElement.setTitle(cConfig().getLocalizedString(30041))
oGuiElement.setSiteName('settings')
oGuiElement.setFunction('showSettingsFolder')
oGuiElement.setThumbnail(os.path.join(ART, 'settings.png'))
oGui.addFolder(oGuiElement)
else:
for folder in settingsGuiElements():
oGui.addFolder(folder)
oGui.setEndOfDirectory()
def settingsGuiElements():
ART = os.path.join(cConfig().getAddonInfo('path'), 'resources', 'art')
# GUI Plugin Informationen
oGuiElement = cGuiElement()
oGuiElement.setTitle(cConfig().getLocalizedString(30267))
oGuiElement.setSiteName('pluginInfo')
oGuiElement.setFunction('pluginInfo')
oGuiElement.setThumbnail(os.path.join(ART, 'plugin_info.png'))
PluginInfo = oGuiElement
# GUI xStream Einstellungen
oGuiElement = cGuiElement()
oGuiElement.setTitle(cConfig().getLocalizedString(30042))
oGuiElement.setSiteName('xStream')
oGuiElement.setFunction('display_settings')
oGuiElement.setThumbnail(os.path.join(ART, 'xstream_settings.png'))
xStreamSettings = oGuiElement
# GUI Resolver Einstellungen
oGuiElement = cGuiElement()
oGuiElement.setTitle(cConfig().getLocalizedString(30043))
oGuiElement.setSiteName('resolver')
oGuiElement.setFunction('display_settings')
oGuiElement.setThumbnail(os.path.join(ART, 'resolveurl_settings.png'))
resolveurlSettings = oGuiElement
# GUI Nightly Updatemanager
oGuiElement = cGuiElement()
oGuiElement.setTitle(cConfig().getLocalizedString(30121))
oGuiElement.setSiteName('devUpdates')
oGuiElement.setFunction('devUpdates')
oGuiElement.setThumbnail(os.path.join(ART, 'manuel_update.png'))
DevUpdateMan = oGuiElement
return PluginInfo, xStreamSettings, resolveurlSettings, DevUpdateMan
def globalSearchGuiElement():
ART = os.path.join(cConfig().getAddonInfo('path'), 'resources', 'art')
# Create a gui element for global search
oGuiElement = cGuiElement()
oGuiElement.setTitle(cConfig().getLocalizedString(30040))
oGuiElement.setSiteName('globalSearch')
oGuiElement.setFunction('globalSearch')
oGuiElement.setThumbnail(os.path.join(ART, 'search.png'))
return oGuiElement
def showHosterGui(sFunction):
from resources.lib.gui.hoster import cHosterGui
oHosterGui = cHosterGui()
function = getattr(oHosterGui, sFunction)
function()
return True
def searchGlobal(sSearchText=False):
oGui = cGui()
oGui.globalSearch = True
oGui._collectMode = True
if not sSearchText:
sSearchText = oGui.showKeyBoard(sHeading=cConfig().getLocalizedString(30280)) # Bitte Suchbegriff eingeben
if not sSearchText:
oGui.setEndOfDirectory()
return True
aPlugins = cPluginHandler().getAvailablePlugins()
dialog = xbmcgui.DialogProgress()
dialog.create(cConfig().getLocalizedString(30122), cConfig().getLocalizedString(30123))
numPlugins = len(aPlugins)
searchablePlugins = [pluginEntry for pluginEntry in aPlugins if pluginEntry['globalsearch'] not in ['false', '']]
def worker(pluginEntry):
log(cConfig().getLocalizedString(30166) + ' -> [xstream]: Searching for %s at %s' % (sSearchText, pluginEntry['id']),LOGNOTICE)
_pluginSearch(pluginEntry, sSearchText, oGui)
return pluginEntry['name']
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
future_to_plugin = {executor.submit(worker, pluginEntry): pluginEntry for pluginEntry in searchablePlugins}
for count, future in enumerate(concurrent.futures.as_completed(future_to_plugin)):
pluginEntry = future_to_plugin[future]
if dialog.iscanceled():
oGui.setEndOfDirectory()
return
try: pluginName = future.result()
except Exception as e:
pluginName = pluginEntry['name']
log(f"Fehler bei Plugin {pluginName}: {str(e)}", LOGERROR)
progress = (count + 1) * 50 // len(searchablePlugins)
dialog.update(progress, pluginName + cConfig().getLocalizedString(30125))
dialog.close()
# Ergebnisse anzeigen
oGui._collectMode = False
total = len(oGui.searchResults)
dialog = xbmcgui.DialogProgress()
dialog.create(cConfig().getLocalizedString(30126), cConfig().getLocalizedString(30127))
for count, result in enumerate(sorted(oGui.searchResults, key=lambda k: k['guiElement'].getSiteName()), 1):
if dialog.iscanceled():
oGui.setEndOfDirectory()
return
oGui.addFolder(result['guiElement'], result['params'], bIsFolder=result['isFolder'], iTotal=total)
dialog.update(count * 100 // total, str(count) + cConfig().getLocalizedString(30128) + str(total) + ': ' + result['guiElement'].getTitle())
dialog.close()
oGui.setView()
oGui.setEndOfDirectory()
return True
def searchAlter(params):
searchTitle = params.getValue('searchTitle')
searchImdbId = params.getValue('searchImdbID')
searchYear = params.getValue('searchYear')
# Jahr aus dem Titel extrahieren
if ' (19' in searchTitle or ' (20' in searchTitle:
isMatch, aYear = cParser.parse(searchTitle, r'(.*?) \((\d{4})\)')
if isMatch:
searchTitle = aYear[0][0]
if not searchYear:
searchYear = str(aYear[0][1])
# Staffel oder Episodenkennung abschneiden
for token in [' S0', ' E0', ' - Staffel', ' Staffel']:
if token in searchTitle:
searchTitle = searchTitle.split(token)[0].strip()
break
oGui = cGui()
oGui.globalSearch = True
oGui._collectMode = True
aPlugins = cPluginHandler().getAvailablePlugins()
dialog = xbmcgui.DialogProgress()
dialog.create(cConfig().getLocalizedString(30122), cConfig().getLocalizedString(30123))
searchablePlugins = [
pluginEntry for pluginEntry in aPlugins
if pluginEntry['globalsearch'] not in ['false', '']
]
def worker(pluginEntry):
log(cConfig().getLocalizedString(30166) + ' -> [xstream]: Searching for ' + searchTitle + pluginEntry['id'], LOGNOTICE)
_pluginSearch(pluginEntry, searchTitle, oGui)
return pluginEntry['name']
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
future_to_plugin = {executor.submit(worker, plugin): plugin for plugin in searchablePlugins}
for count, future in enumerate(concurrent.futures.as_completed(future_to_plugin)):
plugin = future_to_plugin[future]
if dialog.iscanceled():
oGui.setEndOfDirectory()
return
try:
name = future.result()
except Exception as e:
name = plugin['name']
log(f"Fehler bei Plugin {name}: {str(e)}", LOGERROR)
dialog.update((count + 1) * 50 // len(searchablePlugins) + 50, name + cConfig().getLocalizedString(30125))
dialog.close()
# Ergebnisse filtern
filteredResults = []
for result in oGui.searchResults:
guiElement = result['guiElement']
log(cConfig().getLocalizedString(30166) + ' -> [xstream]: Site: %s Titel: %s' % (guiElement.getSiteName(), guiElement.getTitle()), LOGNOTICE)
if searchTitle not in guiElement.getTitle():
continue
if guiElement._sYear and searchYear and guiElement._sYear != searchYear:
continue
if searchImdbId and guiElement.getItemProperties().get('imdbID') != searchImdbId:
continue
filteredResults.append(result)
oGui._collectMode = False
total = len(filteredResults)
for result in sorted(filteredResults, key=lambda k: k['guiElement'].getSiteName()):
oGui.addFolder(result['guiElement'], result['params'], bIsFolder=result['isFolder'], iTotal=total)
oGui.setView()
oGui.setEndOfDirectory()
xbmc.executebuiltin('Container.Update')
return True
def searchTMDB(params):
sSearchText = params.getValue('searchTitle')
oGui = cGui()
oGui.globalSearch = True
oGui._collectMode = True
if not sSearchText:
oGui.setEndOfDirectory()
return True
aPlugins = cPluginHandler().getAvailablePlugins()
dialog = xbmcgui.DialogProgress()
dialog.create(cConfig().getLocalizedString(30122), cConfig().getLocalizedString(30123))
searchablePlugins = [
pluginEntry for pluginEntry in aPlugins
if pluginEntry['globalsearch'] != 'false'
]
def worker(pluginEntry):
log(cConfig().getLocalizedString(30166) + ' -> [xstream]: Searching for %s at %s' % (sSearchText, pluginEntry['id']), LOGNOTICE)
_pluginSearch(pluginEntry, sSearchText, oGui)
return pluginEntry['name']
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
future_to_plugin = {executor.submit(worker, plugin): plugin for plugin in searchablePlugins}
for count, future in enumerate(concurrent.futures.as_completed(future_to_plugin)):
plugin = future_to_plugin[future]
if dialog.iscanceled():
oGui.setEndOfDirectory()
return
try:
name = future.result()
except Exception as e:
name = plugin['name']
log(f"Fehler bei Plugin {name}: {str(e)}", LOGERROR)
dialog.update((count + 1) * 50 // len(searchablePlugins) + 50, name + cConfig().getLocalizedString(30125))
dialog.close()
oGui._collectMode = False
total = len(oGui.searchResults)
dialog = xbmcgui.DialogProgress()
dialog.create(cConfig().getLocalizedString(30126), cConfig().getLocalizedString(30127))
for count, result in enumerate(sorted(oGui.searchResults, key=lambda k: k['guiElement'].getSiteName()), 1):
if dialog.iscanceled():
oGui.setEndOfDirectory()
return
oGui.addFolder(result['guiElement'], result['params'], bIsFolder=result['isFolder'], iTotal=total)
dialog.update(count * 100 // total, str(count) + cConfig().getLocalizedString(30128) + str(total) + ': ' + result['guiElement'].getTitle())
dialog.close()
oGui.setView()
oGui.setEndOfDirectory()
return True
def _pluginSearch(pluginEntry, sSearchText, oGui):
try:
plugin = __import__(pluginEntry['id'], globals(), locals())
function = getattr(plugin, '_search')
function(oGui, sSearchText)
except Exception:
log(cConfig().getLocalizedString(30166) + ' -> [xstream]: ' + pluginEntry['name'] + ': search failed', LOGERROR)
import traceback
log(traceback.format_exc())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment