diff --git a/Contents/Code/__init__.py b/Contents/Code/__init__.py index 6f31d0d..8d57301 100644 --- a/Contents/Code/__init__.py +++ b/Contents/Code/__init__.py @@ -1,7 +1,12 @@ # LiveTVH - Live TV streaming for Plex via Tvheadend # https://github.com/taligentx/LiveTVH +import base64 +import time +import re +# Preferences +# # Display the EPG zap2it ID for a show in the channel summary if the ID on theTVDB.com does not match # due to a missing ID, outdated ID, or incorrect show match. If the show matches correctly, consider # contributing by updating the entry on thetvdb.com to improve search results. @@ -9,22 +14,18 @@ improveTheTVDB = True # Cache times -channelDataCacheTime = CACHE_1DAY +channelDataCacheTime = 60 +epgCacheTime = 4200 imageCacheTime = CACHE_1MONTH tvdbRetryInterval = CACHE_1MONTH -if int(Prefs['prefEPGCount']) == 0: - epgCacheTime = CACHE_1HOUR -else: - epgCacheTime = int(Prefs['prefEPGCount']) * CACHE_1HOUR +# /Preferences -liveTVHVersion = '1.1' +liveTVHVersion = '1.2' TITLE = 'LiveTVH' PREFIX = '/video/livetvh' THUMB = 'LiveTVH-thumb.jpg' ART = 'LiveTVH-art.jpg' - -import base64, time, re tvhHeaders = None tvhAddress = None tvhReachable = False @@ -32,9 +33,8 @@ tmdbBaseURL = None tmdbGenreData = None - def Start(): - Log.Info("LiveTVH version: " + liveTVHVersion) + Log.Info('LiveTVH version: ' + liveTVHVersion) setPrefs() @@ -62,26 +62,26 @@ def setPrefs(): try: tvhInfoData = JSON.ObjectFromURL(url=tvhServerInfoURL, headers=tvhHeaders, values=None, cacheTime=1) - Log.Info("Tvheadend version: " + tvhInfoData['sw_version']) + Log.Info('Tvheadend version: ' + tvhInfoData['sw_version']) if tvhInfoData['api_version'] >= 15: tvhReachable = True else: - Log.Critical("Tvheadend version " + tvhInfoData['sw_version'] + " is unsupported.") + Log.Critical('Tvheadend version ' + tvhInfoData['sw_version'] + ' is unsupported.') return except Exception as e: - Log.Critical("Error accessing Tvheadend: " + str(e)) + Log.Critical('Error accessing Tvheadend: ' + str(e)) tvhReachable = False return # Renew theTVDB authorization token if necessary - if Prefs['prefMetadata'] == True and tvdbToken != None: + if Prefs['prefMetadata'] and tvdbToken: tvdbToken = None tvdbAuth() # Retrieve themovieDB base URL for images and genre list - if Prefs['prefMetadata'] == True: + if Prefs['prefMetadata']: tmdbConfigURL = 'https://api.themoviedb.org/3/configuration?api_key=0fd2136e80c47d0e371ee1af87eaedde' tmdbGenreURL = 'https://api.themoviedb.org/3/genre/movie/list?api_key=0fd2136e80c47d0e371ee1af87eaedde' @@ -91,145 +91,266 @@ def setPrefs(): tmdbBaseURL = tmdbConfigData['images']['base_url'] except Exception as e: - Log.Warn("Error accessing themovieDB: " + str(e)) + Log.Warn('Error accessing themovieDB: ' + str(e)) # Build the main menu @handler(PREFIX, TITLE, art=ART, thumb=THUMB) def MainMenu(): - mainMenuContainer = ObjectContainer(title1=TITLE, no_cache=True) # Request channel data from Tvheadend tvhChannelsData = None - tvhChannelURL = '%s/api/channel/grid?start=0&limit=1000' % tvhAddress + tvhChannelsURL = '%s/api/channel/grid?start=0&limit=100000' % tvhAddress - if tvhReachable == True: + if tvhReachable: try: - tvhChannelsData = JSON.ObjectFromURL(url=tvhChannelURL, headers=tvhHeaders, values=None, cacheTime=channelDataCacheTime) - + tvhChannelsData = JSON.ObjectFromURL(url=tvhChannelsURL, headers=tvhHeaders, values=None, cacheTime=channelDataCacheTime) except Exception as e: - Log.Critical("Error retrieving Tvheadend channel data: " + str(e)) + Log.Critical('Error retrieving Tvheadend channel data: ' + str(e)) # Display an error message to clients if Tvheadend is malfunctional - if tvhChannelsData == None: - mainMenuContainer.add(DirectoryObject(title=L("channelsUnavailable"))) + if tvhChannelsData is None: + errorContainer = ObjectContainer(title1=TITLE, no_cache=True) + errorContainer.add(DirectoryObject(title=L('channelsUnavailable'))) + return errorContainer + + # Request channel tags from Tvheadend + # Tags are used as a manual method to identify codecs for each channel: H264-AAC, MPEG2-AC3, MPEG2 + tvhTagsData = None + tvhTagUUID_H264AAC = None + tvhTagUUID_MPEG2AC3 = None + tvhTagUUID_MPEG2 = None + tvhTagsURL = '%s/api/channeltag/grid?start=0&limit=100000' % tvhAddress - # Build the channel list - else: - # Set the number of EPG items to request from Tvheadend (up to 10000) - tvhEPGData = None + try: + tvhTagsData = JSON.ObjectFromURL(url=tvhTagsURL, headers=tvhHeaders, values=None, cacheTime=channelDataCacheTime) + except Exception as e: + Log.Warn('Error retrieving Tvheadend channel tags data: ' + str(e)) + + try: + if tvhTagsData: + for tvhTagEntry in tvhTagsData['entries']: + if tvhTagEntry['name'].lower() == 'H264-AAC'.lower(): + tvhTagUUID_H264AAC = tvhTagEntry['uuid'] + elif tvhTagEntry['name'].lower() == 'MPEG2-AC3'.lower(): + tvhTagUUID_MPEG2AC3 = tvhTagEntry['uuid'] + elif tvhTagEntry['name'].lower() == 'MPEG2'.lower(): + tvhTagUUID_MPEG2 = tvhTagEntry['uuid'] + + except Exception as e: + Log.Warn('Error parsing Tvheadend channel tags data: ' + str(e)) + + # Request recordings from Tvheadend + tvhRecordingsData = None + tvhRecordingsURL = '%s/api/dvr/entry/grid_finished' % tvhAddress + + try: + tvhRecordingsData = JSON.ObjectFromURL(url=tvhRecordingsURL, headers=tvhHeaders, values=None, cacheTime=channelDataCacheTime) + if int(tvhRecordingsData['total']) == 0: + tvhRecordingsData = None + + except Exception as e: + Log.Warn('Error retrieving Tvheadend recordings data: ' + str(e)) + + # Set the number of EPG items to retrieve + tvhEPGData = None + try: + if int(Prefs['prefEPGCount']) == 0: + epgLimit = int(tvhChannelsData['total']) * 4 + else: + epgLimit = int(tvhChannelsData['total']) * int(Prefs['prefEPGCount']) * 4 + + if epgLimit > 20000: epgLimit = 20000 + + except Exception as e: + Log.Warn('Error calculating the EPG limit: ' + str(e)) + epgLimit = 20000 + + # Request EPG data as UTF-8 with fallback to ISO-8859-1 + epgLoopLimit = epgLimit + epgUTF8Encoding = True + + while True: try: - if int(Prefs['prefEPGCount']) == 0: - epgLimit = int(tvhChannelsData['total']) * 6 + tvhEPGURL = '%s/api/epg/events/grid?start=0&limit=%s' % (tvhAddress,epgLoopLimit) + + if epgUTF8Encoding: + epgEncoding = 'utf-8' else: - epgLimit = int(tvhChannelsData['total']) * int(Prefs['prefEPGCount']) * 6 + epgEncoding = 'latin-1' - if epgLimit > 10000: epgLimit = 10000 + rawEPGData = HTTP.Request(url=tvhEPGURL, headers=tvhHeaders, cacheTime=epgCacheTime, encoding=epgEncoding, values=None).content + rawEPGData = re.sub(r'[\x00-\x1f]', '', rawEPGData) # Strip control characters from EPG data (yep, this has actually happened) + tvhEPGData = JSON.ObjectFromString(rawEPGData, encoding='utf-8', max_size=20971520) + if tvhEPGData: break except Exception as e: - Log.Warn("Error calculating the EPG limit: " + str(e)) - epgLimit = 10000 - - # Request EPG data from Tvheadend as UTF-8 with fallback to ISO-8859-1 - epgLoopLimit = epgLimit - epgUTF8Encoding = True - while True: - try: - tvhEPGURL = '%s/api/epg/events/grid?start=0&limit=%s' % (tvhAddress,epgLoopLimit) - - if epgUTF8Encoding == True: - rawEPGData = HTTP.Request(url=tvhEPGURL, headers=tvhHeaders, cacheTime=epgCacheTime, encoding='utf-8', values=None).content + if 'Data of size' in str(e): + epgLoopLimit = epgLoopLimit - 1000 + if epgLoopLimit > 0: + Log.Warn('Tvheadend EPG data exceeded the data size limit, reducing the request: ' + str(e)) else: - rawEPGData = HTTP.Request(url=tvhEPGURL, headers=tvhHeaders, cacheTime=epgCacheTime, encoding='latin-1', values=None).content + Log.Warn('Unable to retrieve Tvheadend EPG data within the data size limit.') + break - rawEPGData = re.sub(r'[\x00-\x1f]', '', rawEPGData) # Strip control characters from EPG data (yep, this has actually happened) - tvhEPGData = JSON.ObjectFromString(rawEPGData, encoding='utf-8', max_size=10485760) - if tvhEPGData != None: break + else: + if epgUTF8Encoding: + Log.Warn('Unable to retrieve Tvheadend EPG data as UTF-8, falling back to ISO-8859-1: ' + str(e)) + epgUTF8Encoding = False + else: + Log.Warn('Error retrieving Tvheadend EPG data: ' + str(e)) + break - except Exception as e: - if 'Data of size' in str(e): - epgLoopLimit = epgLoopLimit - 500 - if epgLoopLimit > 0: - Log.Warn("Tvheadend EPG data exceeded the data size limit, reducing the request: " + str(e)) - else: - Log.Warn('Unable to retrieve Tvheadend EPG data within the data size limit.') - break + # Build the channel list + startCount = 0 - else: - if epgUTF8Encoding == True: - Log.Warn("Unable to retrieve Tvheadend EPG data as UTF-8, falling back to ISO-8859-1: " + str(e)) - epgUTF8Encoding = False - else: - Log.Warn("Error retrieving Tvheadend EPG data: " + str(e)) - break + @route(PREFIX + '/channels', startCount=int) + def channels(startCount=0, art=ART): + pageContainer = ObjectContainer(title1=TITLE, no_cache=True) + nextStartCount = startCount + int(Prefs['prefPageCount']) - # Set metadata for each channel and add to the main menu - for tvhChannel in sorted(tvhChannelsData['entries'], key=lambda t: float(t['number'])): + if tvhChannelsData: - # Set channel metadata using Tvheadend channel info - try: - title = tvhChannel['name'] - except: - title = None + # Set metadata for each channel and add to the main menu + for tvhChannel in sorted(tvhChannelsData['entries'], key=lambda t: float(t['number']))[startCount:nextStartCount]: - if (Prefs['prefChannelNumbers'] == True): - if title: - title = str(tvhChannel['number']) + " " + title - else: - title = str(tvhChannel['number']) - - uuid = tvhChannel['uuid'] - thumb = None - fallbackThumb = None - art = R(ART) - summary = None - tagline = None - source_title = None - year = None - rating = 0.0 - content_rating = None - genres = ' ' - - # Set channel metadata using Tvheadend EPG info - if tvhEPGData != None: - for tvhEPGEntry in tvhEPGData['entries']: - if tvhEPGEntry['channelUuid'] == uuid and time.time() >= int(tvhEPGEntry['start']) and time.time() < int(tvhEPGEntry['stop']): - if tvhEPGEntry.get('title'): + # Set channel metadata using Tvheadend channel info + try: + title = tvhChannel['name'] + except: + title = None + + if Prefs['prefChannelNumbers']: + if title: + title = str(tvhChannel['number']) + ' ' + title + else: + title = str(tvhChannel['number']) + + uuid = tvhChannel['uuid'] + streamURL = '/stream/channel/%s' % uuid + streamCodec = None + thumb = None + fallbackThumb = None + epgThumb = None + art = R(ART) + summary = None + tagline = None + source_title = None + year = None + rating = None + content_rating = None + genres = ' ' + + # Set channel codec using Tvheadend channel tags + if tvhTagUUID_H264AAC or tvhTagUUID_MPEG2AC3 or tvhTagUUID_MPEG2: + try: + for tvhChannelTagEntry in tvhChannel['tags']: + if tvhChannelTagEntry == tvhTagUUID_H264AAC: + streamCodec = 'H264AAC' + elif tvhChannelTagEntry == tvhTagUUID_MPEG2AC3: + streamCodec = 'MPEG2AC3' + elif tvhChannelTagEntry == tvhTagUUID_MPEG2: + streamCodec = 'MPEG2' + except: pass + + # Set channel metadata using Tvheadend EPG info + if tvhEPGData: + for tvhEPGEntry in tvhEPGData['entries']: + if ( + time.time() < int(tvhEPGEntry['stop']) + and tvhEPGEntry['channelUuid'] == uuid + and time.time() >= int(tvhEPGEntry['start']) + and tvhEPGEntry.get('title')): + + epgStart = None + epgStop = None + epgSubtitle = None + epgSummary = None + epgDescription = None + epgDupedSubtitleSummary = False + + if tvhEPGEntry.get('start'): epgStart = int(tvhEPGEntry['start']) + if tvhEPGEntry.get('stop'): epgStop = int(tvhEPGEntry['stop']) + if tvhEPGEntry.get('subtitle'): epgSubtitle = tvhEPGEntry['subtitle'] + if tvhEPGEntry.get('summary'): epgSummary = tvhEPGEntry['summary'] + if tvhEPGEntry.get('description'): epgDescription = tvhEPGEntry['description'] + if epgSubtitle and epgSummary and epgSubtitle == epgSummary: epgDupedSubtitleSummary = True # Some EPG providers duplicate info in these fields # Set the show title - title = title + ": " + tvhEPGEntry['title'] + title = title + ': ' + tvhEPGEntry['title'] # Set times - if (Prefs['pref24Time'] == True): - startTime = time.strftime("%H:%M", time.localtime(int(tvhEPGEntry['start']))) - stopTime = time.strftime("%H:%M", time.localtime(int(tvhEPGEntry['stop']))) + if Prefs['pref24Time']: + startTime = time.strftime('%H:%M', time.localtime(epgStart)) + stopTime = time.strftime('%H:%M', time.localtime(epgStop)) else: - startTime = time.strftime("%I:%M%p", time.localtime(int(tvhEPGEntry['start']))).lstrip('0').lower() - stopTime = time.strftime("%I:%M%p", time.localtime(int(tvhEPGEntry['stop']))).lstrip('0').lower() + startTime = time.strftime('%I:%M%p', time.localtime(epgStart)).lstrip('0').lower() + stopTime = time.strftime('%I:%M%p', time.localtime(epgStop)).lstrip('0').lower() - # Set the episode title and summary + # Set the titles and summary per client if Client.Product == 'Plex Web': - title = title + " " # Force Plex Web to use the Details view by padding the title + title = title + ' ' # Force Plex Web to use the Details view by padding the title tagline = startTime + '-' + stopTime - if tvhEPGEntry.get('subtitle'): tagline = tagline + ': ' + tvhEPGEntry['subtitle'] - if tvhEPGEntry.get('description'): summary = tvhEPGEntry['description'] + '\n' + + if epgDupedSubtitleSummary: + if epgDescription: + tagline = tagline + ': ' + epgSubtitle + summary = epgDescription + '\n' + else: + summary = epgSummary + '\n' + else: + if epgSubtitle: tagline = tagline + ': ' + epgSubtitle + if epgSummary: summary = epgSummary + '\n' + if epgDescription: summary = epgDescription + '\n' elif Client.Product == 'Plex for Roku': source_title = startTime + '-' + stopTime - if tvhEPGEntry.get('subtitle'): source_title = source_title + ': ' + tvhEPGEntry['subtitle'] - if tvhEPGEntry.get('description'): summary = tvhEPGEntry['description'] + '\n' + + if epgDupedSubtitleSummary: + if epgDescription: + source_title = source_title + ': ' + epgSubtitle + summary = epgDescription + '\n' + else: + summary = epgSummary + '\n' + else: + if epgSubtitle: source_title = source_title + ': ' + epgSubtitle + if epgSummary: summary = epgSummary + '\n' + if epgDescription: summary = epgDescription + '\n' elif Client.Product == 'Plex for Android': source_title = startTime + '-' + stopTime summary = startTime + '-' + stopTime - if tvhEPGEntry.get('description'): summary = summary + ': ' + tvhEPGEntry['description'] + '\n' - else: summary = summary + '\n' + + if epgDupedSubtitleSummary: + if epgDescription: + title = title + ' (' + epgSubtitle + ')' + summary = summary + ': ' + epgDescription + '\n' + else: + summary = summary + ': ' + epgSummary + '\n' + else: + if epgSubtitle: title = title + ' (' + epgSubtitle + ')' + if epgSummary or epgDescription: + if epgSummary: summary = summary + ': ' + epgSummary + '\n' + if epgDescription: summary = summary + ': ' + epgDescription + '\n' + else: + summary = summary + '\n' else: summary = startTime + '-' + stopTime - if tvhEPGEntry.get('subtitle'): title = title + " (" + tvhEPGEntry['subtitle'] + ")" - if tvhEPGEntry.get('description'): summary = summary + ': ' + tvhEPGEntry['description'] + '\n' - else: summary = summary + '\n' + + if epgDupedSubtitleSummary: + if epgDescription: + title = title + ' (' + epgSubtitle + ')' + summary = summary + ': ' + epgDescription + '\n' + else: + summary = summary + ': ' + epgSummary + '\n' + else: + if epgSubtitle: title = title + ' (' + epgSubtitle + ')' + if epgSummary or epgDescription: + if epgSummary: summary = summary + ': ' + epgSummary + '\n' + if epgDescription: summary = summary + ': ' + epgDescription + '\n' + else: + summary = summary + '\n' # List upcoming titles on this channel in the summary by searching for shows # in the next number of hours or number of entries, whichever is greater @@ -239,18 +360,20 @@ def MainMenu(): timeLimit = int(time.time()) + (int(Prefs['prefEPGCount'])*3600) nextEPGCount = 1 nextEPGLoop = True - while nextEPGLoop == True: + while nextEPGLoop: for nextEntry in tvhEPGData['entries']: nextEntryStart = int(nextEntry['start']) try: if nextEntry['eventId'] == nextEventID and (nextEntryStart <= timeLimit or nextEPGCount <= epgCount): - if (Prefs['pref24Time'] == True): - nextStart = time.strftime("%H:%M", time.localtime(nextEntryStart)) + if Prefs['pref24Time']: + nextStartTime = time.strftime('%H:%M', time.localtime(nextEntryStart)) else: - nextStart = time.strftime("%I:%M%p", time.localtime(nextEntryStart)).lstrip('0').lower() + nextStartTime = time.strftime('%I:%M%p', time.localtime(nextEntryStart)).lstrip('0').lower() - if summary: summary = summary + nextStart + ": " + nextEntry['title'] + '\n' - else: summary = nextStart + ": " + nextEntry['title'] + '\n' + if summary: + summary = summary + nextStartTime + ': ' + nextEntry['title'] + '\n' + else: + summary = nextStartTime + ': ' + nextEntry['title'] + '\n' nextEventID = nextEntry['nextEventId'] nextEPGCount += 1 @@ -266,52 +389,120 @@ def MainMenu(): zap2itID = None try: if tvhEPGEntry.get('episodeUri'): - epgID=tvhEPGEntry['episodeUri'].split("/")[3].split(".")[0] + epgID=tvhEPGEntry['episodeUri'].split('/')[3].split('.')[0] if epgID.startswith('MV') or epgID.startswith('EP') or epgID.startswith('SH'): zap2itID = epgID except: pass # Find metadata for this title - if Prefs['prefMetadata'] == True: + if Prefs['prefMetadata']: metadataResults = metadata(title=tvhEPGEntry['title'], zap2itID=zap2itID) - if metadataResults['thumb'] != None: thumb = metadataResults['thumb'] - if metadataResults['art'] != None: art = metadataResults['art'] - if metadataResults['year'] != None: year = metadataResults['year'] - if metadataResults['rating'] != None: rating = metadataResults['rating'] - if metadataResults['content_rating'] != None: content_rating = metadataResults['content_rating'] - if metadataResults['genres'] != None: genres = metadataResults['genres'] - if metadataResults['zap2itMissingID'] != None and improveTheTVDB == True: - summary = metadataResults['zap2itMissingID'] + " | " + summary - - # Use channel icons from Tvheadend as a fallback to remote artwork - try: - if thumb == None and tvhChannel['icon_public_url'].startswith('imagecache'): - thumb = '%s/%s' % (tvhAddress, tvhChannel['icon_public_url']) - - if tvhChannel['icon_public_url'].startswith('imagecache'): - fallbackThumb ='%s/%s' % (tvhAddress, tvhChannel['icon_public_url']) - - except: pass - - # Set the channel object type - if Client.Product == 'Plex for Roku': - channelType = 'VideoClipObject' # Plex for Roku only displays source_title for VideoClipObjects - else: - channelType = 'MovieObject' - - # Build and add the channel to the main menu - mainMenuContainer.add(channel(channelType=channelType, title=title, uuid=uuid, thumb=thumb, fallbackThumb=fallbackThumb, art=art, summary=summary, tagline=tagline, source_title=source_title, year=year, rating=rating, content_rating=content_rating, genres=genres)) - - # Add the built-in Preferences object to the main menu - visible on PHT, Android - mainMenuContainer.add(PrefsObject(title=L('preferences'))) - return mainMenuContainer + if metadataResults['thumb']: thumb = metadataResults['thumb'] + if metadataResults['art']: art = metadataResults['art'] + if metadataResults['year']: year = int(metadataResults['year']) + if metadataResults['rating']: rating = float(metadataResults['rating']) + if metadataResults['content_rating']: content_rating = metadataResults['content_rating'] + if metadataResults['genres']: genres = metadataResults['genres'] + if metadataResults['zap2itMissingID'] and improveTheTVDB: + summary = metadataResults['zap2itMissingID'] + ' | ' + summary + + # Check the EPG entry for a thumbnail + if tvhEPGEntry.get('image') and tvhEPGEntry['image'].startswith('http') and thumb is None: + epgThumb = tvhEPGEntry['image'] + + # Use EPG thumbnails from Tvheadend if a thumbnail is not available from the metadata providers + if thumb is None and epgThumb: + thumb = epgThumb + + if fallbackThumb is None and epgThumb: + fallbackThumb = epgThumb + + # Use channel icons from Tvheadend if no other thumbnail is available + try: + if thumb is None and tvhChannel['icon_public_url'].startswith('imagecache'): + thumb = '%s/%s' % (tvhAddress, tvhChannel['icon_public_url']) + + if tvhChannel['icon_public_url'].startswith('imagecache'): + fallbackThumb ='%s/%s' % (tvhAddress, tvhChannel['icon_public_url']) + + except: pass + + # Set the channel object type - this determines if thumbnails are displayed as posters or video clips + # Plex for Roku only displays source_title for VideoClipObjects + if Client.Product == 'Plex Home Theater': + channelType = 'MovieObject' + elif Client.Product == 'Plex for Roku' or not Prefs['prefMetadata']: + channelType = 'VideoClipObject' + else: + channelType = 'MovieObject' + + # Build and add the channel to the main menu + pageContainer.add( + channel( + channelType=channelType, + title=title, + uuid=uuid, + streamURL=streamURL, + streamCodec=streamCodec, + thumb=thumb, + fallbackThumb=fallbackThumb, + art=art, + summary=summary, + tagline=tagline, + source_title=source_title, + year=year, + rating=rating, + content_rating=content_rating, + genres=genres)) + + # Add recordings and preferences to the end of the channel list because several clients have display + # issues when these types of objects are at the beginning of the container + if len(tvhChannelsData['entries']) < int(Prefs['prefPageCount']): + pageContainer.add( + DirectoryObject( + key=Callback( + recordings, + tvhTagUUID_H264AAC=tvhTagUUID_H264AAC, + tvhTagUUID_MPEG2AC3=tvhTagUUID_MPEG2AC3, + tvhTagUUID_MPEG2=tvhTagUUID_MPEG2), + title=L('recordings'), + thumb=R('LiveTVH-recording.png'))) + + pageContainer.add(PrefsObject(title=L('preferences'))) + + # Paginate the channel list + if len(tvhChannelsData['entries']) > nextStartCount: + pageContainer.add(NextPageObject(key=Callback(channels, startCount=nextStartCount), title=L('next'), thumb=R('LiveTVH-next.png'))) + + # Add recordings and preferences to the end of the first page of the channel list when paginated + if tvhRecordingsData and startCount == 0: + pageContainer.add( + DirectoryObject( + key=Callback( + recordings, + tvhTagUUID_H264AAC=tvhTagUUID_H264AAC, + tvhTagUUID_MPEG2AC3=tvhTagUUID_MPEG2AC3, + tvhTagUUID_MPEG2=tvhTagUUID_MPEG2), + title=L('recordings'), + thumb=R('LiveTVH-recording.png'))) + + pageContainer.add(PrefsObject(title=L('preferences'))) + + return pageContainer + return channels() # Build the channel as a MovieObject -@route(PREFIX + '/channel') -def channel(channelType, title, uuid, thumb, fallbackThumb, art, summary, tagline, source_title, year, rating, content_rating, genres, container=False, checkFiles=0, **kwargs): - if year: year = int(year) - channelData = dict(key = Callback(channel, channelType=channelType, title=title, uuid=uuid, thumb=thumb, fallbackThumb=fallbackThumb, art=art, summary=summary, tagline=tagline, source_title=source_title, year=year, rating=rating, content_rating=content_rating, genres=genres, container=True, checkFiles=0, **kwargs), +@route(PREFIX + '/channel', year=int, rating=float, container=bool, checkFiles=int) +def channel( + channelType, title, uuid, streamURL, streamCodec, thumb, fallbackThumb, art, summary, tagline, source_title, year, + rating, content_rating, genres, container=False, checkFiles=0, **kwargs): + + channelMetadata = dict( + key=Callback( + channel, channelType=channelType, title=title, uuid=uuid, streamURL=streamURL, streamCodec=streamCodec, thumb=thumb, fallbackThumb=fallbackThumb, + art=art, summary=summary, tagline=tagline, source_title=source_title, year=year, rating=rating, + content_rating=content_rating, genres=genres, container=True, checkFiles=0, **kwargs), rating_key = uuid, title = title, thumb = Callback(image, url=thumb, fallback=fallbackThumb), @@ -320,25 +511,65 @@ def channel(channelType, title, uuid, thumb, fallbackThumb, art, summary, taglin source_title = source_title, tagline = tagline, year = year, - rating = float(rating), + rating = rating, content_rating = content_rating, - duration = 86400000, - genres = [genres], + genres = [genres]) + + channelMediaData = dict( + items = [ + MediaObject( + parts = [PartObject(key=Callback(stream, streamURL=streamURL))], + video_resolution = '1080', + optimized_for_streaming = True)]) + + channelMediaDataH264AAC = dict( items = [ MediaObject( - parts = [ - PartObject( - key=Callback(stream, uuid=uuid), - streams=[ - VideoStreamObject(), - AudioStreamObject() - ] - ) - ], - optimized_for_streaming = True - ) - ]) + parts = [PartObject(key=Callback(stream, streamURL=streamURL))], + video_resolution = '1080', + container = 'mpegts', + bitrate = 10000, + width = 1920, + height = 1080, + video_codec = 'h264', + audio_codec = 'aac', + audio_channels = 2, + optimized_for_streaming = True)]) + + channelMediaDataMPEG2AC3 = dict( + items = [ + MediaObject( + parts = [PartObject(key=Callback(stream, streamURL=streamURL))], + video_resolution = '1080', + container = 'mpegts', + video_codec = 'mpeg2video', + audio_codec = 'ac3', + optimized_for_streaming = True)]) + + channelMediaDataMPEG2 = dict( + items = [ + MediaObject( + parts = [PartObject(key=Callback(stream, streamURL=streamURL))], + video_resolution = '1080', + container = 'mpegts', + video_codec = 'mpeg2video', + audio_codec = 'mp2', + audio_channels = 2, + optimized_for_streaming = True)]) + + # Build channel data with the codec specified in the Tvheadend channel tag if available + channelData = channelMetadata.copy() + + if streamCodec == 'H264AAC': + channelData.update(channelMediaDataH264AAC) + elif streamCodec == 'MPEG2AC3': + channelData.update(channelMediaDataMPEG2AC3) + elif streamCodec == 'MPEG2': + channelData.update(channelMediaDataMPEG2) + else: + channelData.update(channelMediaData) + # Set the framework object type if channelType == 'MovieObject': channelObject = MovieObject(**channelData) @@ -356,7 +587,7 @@ def channel(channelType, title, uuid, thumb, fallbackThumb, art, summary, taglin # channel list load time can be reduced by running the search asynchronously @route(PREFIX + '/image') def image(url=None, fallback=None): - if url == None and fallback == None: + if url is None and fallback is None: return None if 'api.thetvdb.com' in url: @@ -371,7 +602,7 @@ def image(url=None, fallback=None): if fallback == R(ART): return Redirect(R(ART)) - elif fallback != None: + elif fallback: if tvhAddress in fallback: imageContent = HTTP.Request(url=fallback, headers=tvhHeaders, cacheTime=imageCacheTime, values=None).content else: @@ -381,56 +612,240 @@ def image(url=None, fallback=None): else: return None - if tvdbImageData != None: + if tvdbImageData: for tvdbImageResult in tvdbImageData['data']: url = 'http://thetvdb.com/banners/' + tvdbImageResult['fileName'] - imageContent = HTTP.Request(url, cacheTime=imageCacheTime, values=None).content - return DataObject(imageContent, 'image/jpeg') + try: + imageContent = HTTP.Request(url, cacheTime=imageCacheTime, values=None).content + return DataObject(imageContent, 'image/jpeg') + except Exception as e: + Log.Warn('Error retrieving image: ' + str(e)) + return None elif tvhAddress in url: try: imageContent = HTTP.Request(url=url, headers=tvhHeaders, cacheTime=imageCacheTime, values=None).content return DataObject(imageContent, 'image/jpeg') - - except: + except Exception as e: + Log.Warn('Error retrieving image: ' + str(e)) return None elif url == R(ART): return Redirect(R(ART)) else: - imageContent = HTTP.Request(url, cacheTime=imageCacheTime, values=None).content - return DataObject(imageContent, 'image/jpeg') + try: + imageContent = HTTP.Request(url, cacheTime=imageCacheTime, values=None).content + return DataObject(imageContent, 'image/jpeg') + except Exception as e: + Log.Warn('Error retrieving image: ' + str(e)) + return None # Build the Tvheadend stream URL and verify availability @route(PREFIX + '/stream') @indirect -def stream(uuid): +def stream(streamURL): - # Add basic authentication info to the channel URL - Plex ignores the headers parameter in PartObject + # Add basic authentication info to the stream URL - Plex ignores the headers parameter in PartObject tvhBasicAuth = '//%s:%s@' % (Prefs['tvhUser'], Prefs['tvhPass']) - tvhAuthAddress = tvhAddress.replace('//',tvhBasicAuth) - streamURL = '%s/stream/channel/%s' % (tvhAuthAddress, uuid) + tvhAuthAddress = tvhAddress.replace('//', tvhBasicAuth) + playbackURL = '%s%s' % (tvhAuthAddress, streamURL) - if Prefs['tvhProfile'] != None: - streamURL = streamURL + '?profile=' + Prefs['tvhProfile'] + if Prefs['tvhProfile']: + playbackURL = playbackURL + '?profile=' + Prefs['tvhProfile'] # Verify the channel is available before returning it to PartObject - testURL = '%s/stream/channel/%s' % (tvhAddress, uuid) + testURL = '%s%s' % (tvhAddress, streamURL) try: - responseCode = HTTP.Request(testURL, headers=tvhHeaders, values=None, cacheTime=None, timeout=1).headers - return IndirectResponse(MovieObject, key=streamURL) + responseCode = HTTP.Request(testURL, headers=tvhHeaders, values=None, cacheTime=None, timeout=2).headers + return IndirectResponse(MovieObject, key=playbackURL) except Exception as e: - Log.Warn("Tvheadend is not responding to this channel request - verify that there are available tuners: " + repr(e)) + Log.Warn('Tvheadend is not responding to this channel request - verify that there are available tuners: ' + repr(e)) raise Ex.MediaNotAvailable +# Build the Tvheadend recordings list +@route(PREFIX + '/recordings', startCount=int) +def recordings(tvhTagUUID_H264AAC, tvhTagUUID_MPEG2AC3, tvhTagUUID_MPEG2, startCount=0): + nextStartCount = startCount + int(Prefs['prefPageCount']) + recordingsContainer = ObjectContainer(title1=L('recordings'), no_cache=True) + + # Request recordings from Tvheadend + tvhRecordingsData = None + tvhRecordingsURL = '%s/api/dvr/entry/grid_finished' % tvhAddress + + try: + tvhRecordingsData = JSON.ObjectFromURL(url=tvhRecordingsURL, headers=tvhHeaders, values=None, cacheTime=channelDataCacheTime) + except Exception as e: + Log.Warn('Error retrieving Tvheadend recordings data: ' + str(e)) + + # Request channel data from Tvheadend + tvhChannelsData = None + tvhChannelsURL = '%s/api/channel/grid?start=0&limit=100000' % tvhAddress + + try: + tvhChannelsData = JSON.ObjectFromURL(url=tvhChannelsURL, headers=tvhHeaders, values=None, cacheTime=channelDataCacheTime) + except Exception as e: + Log.Critical('Error retrieving Tvheadend channel data: ' + str(e)) + + for tvhRecording in sorted(tvhRecordingsData['entries'], key=lambda r: r['start'], reverse=True)[startCount:nextStartCount]: + + title = tvhRecording['disp_title'] + streamURL = '/' + tvhRecording['url'] + + uuid = streamURL + streamCodec = None + thumb = None + fallbackThumb = None + art = R(ART) + summary = None + tagline = None + source_title = None + year = None + rating = None + content_rating = None + genres = ' ' + + # Set recording time for recordings today + if time.strftime('%Y%m%d', time.localtime()) == time.strftime('%Y%m%d', time.localtime(tvhRecording['start'])): + if Prefs['pref24Time']: + startTime = 'Today, ' + time.strftime('%H:%M', time.localtime(tvhRecording['start'])) + stopTime = time.strftime('%H:%M', time.localtime(tvhRecording['stop'])) + recordingTime = startTime + '-' + stopTime + else: + startTime = 'Today, ' + time.strftime('%I:%M%p', time.localtime(tvhRecording['start'])).lstrip('0').lower() + stopTime = time.strftime('%I:%M%p', time.localtime(tvhRecording['stop'])).lstrip('0').lower() + recordingTime = startTime + '-' + stopTime + + # Set recording time for recordings within the past 6 days + elif (time.time() - tvhRecording['start']) < 518400: + if Prefs['pref24Time']: + startTime = time.strftime('%A, %H:%M', time.localtime(tvhRecording['start'])) + stopTime = time.strftime('%H:%M', time.localtime(tvhRecording['stop'])) + recordingTime = startTime + '-' + stopTime + else: + startTime = time.strftime('%A, ', time.localtime(tvhRecording['start'])).lstrip('0') + startTime = startTime + time.strftime('%I:%M%p', time.localtime(tvhRecording['start'])).lstrip('0').lower() + stopTime = time.strftime('%I:%M%p', time.localtime(tvhRecording['stop'])).lstrip('0').lower() + recordingTime = startTime + '-' + stopTime + + # Set recording time for recordings this year + elif time.strftime('%Y', time.localtime()) == time.strftime('%Y', time.localtime(tvhRecording['start'])): + recordingTime = time.strftime('%B %d', time.localtime(tvhRecording['start'])) + + else: + recordingTime = time.strftime('%B %d, %Y', time.localtime(tvhRecording['start'])) + + # Set the recording codec based on the Tvheadend channel tags + for tvhChannel in tvhChannelsData['entries']: + if tvhChannel['uuid'] == tvhRecording['channel']: + for tvhChannelTagEntry in tvhChannel['tags']: + if tvhChannelTagEntry == tvhTagUUID_H264AAC: + streamCodec = 'H264AAC' + elif tvhChannelTagEntry == tvhTagUUID_MPEG2AC3: + streamCodec = 'MPEG2AC3' + elif tvhChannelTagEntry == tvhTagUUID_MPEG2: + streamCodec = 'MPEG2' + break + + # Set the channel object type - this determines if thumbnails are displayed as posters or video clips + # Plex for Roku only displays source_title for VideoClipObjects + if Client.Product == 'Plex Home Theater': + channelType = 'MovieObject' + elif Client.Product == 'Plex for Roku' or not Prefs['prefMetadata']: + channelType = 'VideoClipObject' + else: + channelType = 'MovieObject' + + # Set the titles and summary per client + if Client.Product == 'Plex Web': + title = title + ' ' # Force Plex Web to use the Details view by padding the title + tagline = recordingTime + if tvhRecording['disp_subtitle']: + tagline = tagline + ': ' + tvhRecording['disp_subtitle'] + summary = tvhRecording['disp_description'] + + elif Client.Product == 'Plex for Roku': + source_title = recordingTime + if tvhRecording['disp_subtitle']: + source_title = source_title + ': ' + tvhRecording['disp_subtitle'] + summary = tvhRecording['disp_description'] + + elif Client.Product == 'Plex for Android': + source_title = recordingTime + if tvhRecording['disp_subtitle']: + title = title + ' (' + tvhRecording['disp_subtitle'] + ')' + summary = recordingTime + if tvhRecording['disp_description']: + summary = summary + ': ' + tvhRecording['disp_description'] + + else: + if tvhRecording['disp_subtitle']: + title = title + ' (' + tvhRecording['disp_subtitle'] + ')' + summary = recordingTime + if tvhRecording['disp_description']: + summary = summary + ': ' + tvhRecording['disp_description'] + + # Find metadata for this title + if Prefs['prefMetadata']: + metadataResults = metadata(title=tvhRecording['disp_title']) + if metadataResults['thumb']: thumb = metadataResults['thumb'] + if metadataResults['art']: art = metadataResults['art'] + if metadataResults['year']: year = int(metadataResults['year']) + if metadataResults['rating']: rating = float(metadataResults['rating']) + if metadataResults['content_rating']: content_rating = metadataResults['content_rating'] + if metadataResults['genres']: genres = metadataResults['genres'] + + # Use channel icons from Tvheadend as a fallback + try: + if thumb is None and tvhRecording['channel_icon'].startswith('imagecache'): + thumb = '%s/%s' % (tvhAddress, tvhRecording['channel_icon']) + + if tvhRecording['channel_icon'].startswith('imagecache'): + fallbackThumb ='%s/%s' % (tvhAddress, tvhRecording['channel_icon']) + + except: pass + + # Build and add the recording to the recordings menu + recordingsContainer.add( + channel( + channelType=channelType, + title=title, + uuid=uuid, + streamURL=streamURL, + streamCodec=streamCodec, + thumb=thumb, + fallbackThumb=fallbackThumb, + art=art, + summary=summary, + tagline=tagline, + source_title=source_title, + year=year, + rating=rating, + content_rating=content_rating, + genres=genres)) + + # Paginate the channel list + if len(tvhRecordingsData['entries']) > nextStartCount: + recordingsContainer.add(NextPageObject( + key=Callback( + recordings, + tvhTagUUID_H264AAC=tvhTagUUID_H264AAC, + tvhTagUUID_MPEG2AC3=tvhTagUUID_MPEG2AC3, + tvhTagUUID_MPEG2=tvhTagUUID_MPEG2, + startCount=nextStartCount), + title=L('next'), + thumb=R('LiveTVH-next.png'))) + + return recordingsContainer + + # Search for metadata @route(PREFIX + '/metadata') -def metadata(title, zap2itID): +def metadata(title, zap2itID=None): thumb = None art = None year = None @@ -440,33 +855,40 @@ def metadata(title, zap2itID): zap2itMissingID = None # Skip searching theTVDB if EPG data states the title is a movie - if str(zap2itID).startswith('MV') == True: + if str(zap2itID).startswith('MV'): epgMovie = True else: epgMovie = False # Search theTVDB - if (thumb == None or art == None) and epgMovie == False: + if (thumb is None or art is None) and not epgMovie: tvdbResults = tvdb(title, zap2itID) - if tvdbResults != None: - if thumb == None: thumb = tvdbResults['poster'] - if art == None: art = tvdbResults['fanart'] - if rating == None: rating = tvdbResults['siteRating'] - if content_rating == None: content_rating = tvdbResults['rating'] - if genres == None: genres = tvdbResults['genres'] + if tvdbResults: + if thumb is None: thumb = tvdbResults['poster'] + if art is None: art = tvdbResults['fanart'] + if rating is None: rating = tvdbResults['siteRating'] + if content_rating is None: content_rating = tvdbResults['rating'] + if genres is None: genres = tvdbResults['genres'] zap2itMissingID = tvdbResults['zap2itMissingID'] # Search themovieDB - if thumb == None or art == None: - tmdbResults = tmdb(title, epgMovie) - if tmdbResults != None: - if thumb == None: thumb = tmdbResults['poster'] - if art == None: art = tmdbResults['backdrop'] - if rating == None: rating = tmdbResults['vote_average'] - if year == None: year = tmdbResults['year'] - if genres == None: genres = tmdbResults['genres'] - - return { 'thumb': thumb, 'art': art, 'year': year, 'rating': rating, 'content_rating': content_rating, 'genres': genres, 'zap2itMissingID': zap2itMissingID } + if thumb is None or art is None: + tmdbResults = tmdb(title) + if tmdbResults: + if thumb is None: thumb = tmdbResults['poster'] + if art is None: art = tmdbResults['backdrop'] + if rating is None: rating = tmdbResults['vote_average'] + if year is None: year = tmdbResults['year'] + if genres is None: genres = tmdbResults['genres'] + + return { + 'thumb': thumb, + 'art': art, + 'year': year, + 'rating': rating, + 'content_rating': content_rating, + 'genres': genres, + 'zap2itMissingID': zap2itMissingID } # Retrieve an authorization token from theTVDB @@ -476,7 +898,7 @@ def tvdbAuth(): tvdbLoginURL = 'https://api.thetvdb.com/login' tvdbApiKeyJSON = '{"apikey" : "C7DE76F57D6BE6CE"}' - tvdbHeaders = {'content-type' : 'application/json'} + tvdbHeaders = {'content-type': 'application/json'} try: tvdbResponse = HTTP.Request(url=tvdbLoginURL, headers=tvdbHeaders, data=tvdbApiKeyJSON, cacheTime=1).content @@ -484,7 +906,7 @@ def tvdbAuth(): tvdbToken = tvdbTokenData['token'] except Ex.HTTPError as e: - Log.Warn("Failed to retrieve theTVDB authorization token: " + str(e)) + Log.Warn('Failed to retrieve theTVDB authorization token: ' + str(e)) tvdbToken = False @@ -499,7 +921,7 @@ def tvdb(title, zap2itID, zap2itMissingID=None): tvdbID = None # Skip searching for this title if the theTVDB had no results within tvdbRetryInterval. - # This uses the framework Dict as a cache because Plex does not cache the HTTP 404 response from theTVDB API. + # This uses the framework Dict as a cache because Plex does not cache the HTTP 404 response from theTVDB API. if title in Dict: if time.time() >= Dict[title]: pass else: @@ -508,35 +930,35 @@ def tvdb(title, zap2itID, zap2itMissingID=None): d, h = divmod(h, 24) if d != 0: if d == 1: - Log.Info("theTVDB previously had no results for " + title + ", will try again after 1 day, %sh." % h) + Log.Info('theTVDB previously had no results for ' + title + ', will try again after 1 day, %sh.' % h) else: - Log.Info("theTVDB previously had no results for " + title + ", will try again after %s days." % d) + Log.Info('theTVDB previously had no results for ' + title + ', will try again after %s days.' % d) elif h != 0: if h == 1: - Log.Info("theTVDB previously had no results for " + title + ", will try again after 1 hour, %sm." % m) + Log.Info('theTVDB previously had no results for ' + title + ', will try again after 1 hour, %sm.' % m) else: - Log.Info("theTVDB previously had no results for " + title + ", will try again after %s hours." % h) + Log.Info('theTVDB previously had no results for ' + title + ', will try again after %s hours.' % h) else: if m == 1: - Log.Info("theTVDB previously had no results for " + title + ", will try again after 1m, %ss." % s) + Log.Info('theTVDB previously had no results for ' + title + ', will try again after 1m, %ss.' % s) else: - Log.Info("theTVDB previously had no results for " + title + ", will try again after %s minutes." % m) + Log.Info('theTVDB previously had no results for ' + title + ', will try again after %s minutes.' % m) return None # Request an authorization token if it doesn't exist - if tvdbToken == None: - Log.Info("Requesting an authorization token for theTVDB") + if tvdbToken is None: + Log.Info('Requesting an authorization token for theTVDB') tvdbAuth() return tvdb(title, zap2itID) - if tvdbToken == False: - Log.Info("theTVDB authorization failed.") + elif not tvdbToken: + Log.Info('theTVDB authorization failed.') return {'poster': tvdbPosterSearchURL, 'fanart': tvdbFanartSearchURL} # Search using zap2it ID if available, otherwise search by name tvdbHeaders = {'Authorization' : 'Bearer %s' % tvdbToken} - if zap2itID != None: + if zap2itID: tvdbSearchURL = 'https://api.thetvdb.com/search/series?zap2itId=%s' % String.Quote(zap2itID) else: tvdbSearchURL = 'https://api.thetvdb.com/search/series?name=%s' % String.Quote(title) @@ -545,26 +967,28 @@ def tvdb(title, zap2itID, zap2itMissingID=None): tvdbData = JSON.ObjectFromURL(url=tvdbSearchURL, headers=tvdbHeaders, values=None, cacheTime=imageCacheTime) for tvdbResult in tvdbData['data']: - if zap2itID != None: + if zap2itID: tvdbID = tvdbResult['id'] break elif String.LevenshteinDistance(tvdbResult['seriesName'], title) == 0: tvdbID = tvdbResult['id'] - if zap2itMissingID != None: - Log.Info("Found " + title + " at http://thetvdb.com/?tab=series&id=" + str(tvdbID) + " by name but not by zap2it ID " + zap2itMissingID + " - if this match is correct, consider adding the zap2it ID to theTVDB.com to improve search results.") + if zap2itMissingID: + Log.Info('Found ' + title + ' at http://thetvdb.com/?tab=series&id=' + str(tvdbID) + + ' by name but not by zap2it ID ' + zap2itMissingID + + ' - if this match is correct, consider adding the zap2it ID to theTVDB.com to improve search results.') break except Ex.HTTPError as e: if e.code == 401: - Log.Info("theTVDB authorization token is invalid, requesting a new one") + Log.Info('theTVDB authorization token is invalid, requesting a new one.') tvdbAuth() return tvdb(title, zap2itID) if e.code == 404: # Search again by name if there are no results by zap2it ID, and save the ID to report # a possible missing/mismatched ID on thetvdb.com if there is a match by name - if zap2itID != None: + if zap2itID: zap2itMissingID = zap2itID zap2itID = None return tvdb(title, zap2itID, zap2itMissingID) @@ -572,14 +996,14 @@ def tvdb(title, zap2itID, zap2itMissingID=None): Dict[title] = time.time() + tvdbRetryInterval h, m = divmod(int(tvdbRetryInterval), 3600) d, h = divmod(h, 24) - Log.Info("No results from theTVDB for " + title + ", skipping lookup for %s days." % d) + Log.Info('No results from theTVDB for ' + title + ', skipping lookup for %s days.' % d) return None else: - Log.Warn("Error while searching theTVDB: " + str(e)) + Log.Warn('Error while searching theTVDB: ' + str(e)) return None - if tvdbID != None: + if tvdbID: tvdbPosterSearchURL = 'https://api.thetvdb.com/series/%s/images/query?keyType=poster' % tvdbID tvdbFanartSearchURL = 'https://api.thetvdb.com/series/%s/images/query?keyType=fanart' % tvdbID @@ -591,9 +1015,9 @@ def tvdb(title, zap2itID, zap2itMissingID=None): tvdbMetadata = JSON.ObjectFromURL(url=tvdbMetadataSearchURL, headers=tvdbHeaders, values=None, cacheTime=imageCacheTime) except Ex.HTTPError as e: if e.code == 404: - Log.Info("No metadata from theTVDB for " + title) + Log.Info('No metadata from theTVDB for ' + title) - if tvdbMetadata != None: + if tvdbMetadata: if tvdbMetadata['data']['rating'] != '': tvdbRating = tvdbMetadata['data']['rating'] if tvdbMetadata['data']['siteRating'] != '': @@ -601,25 +1025,31 @@ def tvdb(title, zap2itID, zap2itMissingID=None): if tvdbMetadata['data']['genre'] != []: # Convert genres to a string - Plex will not accept a list directly for genre in the channel object - tvdbGenres = str(tvdbMetadata['data']['genre']).lstrip('[').rstrip(']').replace("'", "") + tvdbGenres = str(tvdbMetadata['data']['genre']).lstrip('[').rstrip(']').replace("'", '') else: Dict[title] = time.time() + tvdbRetryInterval h, m = divmod(int(tvdbRetryInterval), 3600) d, h = divmod(h, 24) if d == 1: - Log.Info("No results from theTVDB for " + title + ", skipping lookup for 1 day, %sh." % h) + Log.Info('No results from theTVDB for ' + title + ', skipping lookup for 1 day, %sh.' % h) else: - Log.Info("No results from theTVDB for " + title + ", skipping lookup for %s days." % d) + Log.Info('No results from theTVDB for ' + title + ', skipping lookup for %s days.' % d) return None - return {'poster': tvdbPosterSearchURL, 'fanart': tvdbFanartSearchURL, 'rating': tvdbRating, 'siteRating': tvdbSiteRating, 'genres': tvdbGenres, 'zap2itMissingID': zap2itMissingID} + return { + 'poster': tvdbPosterSearchURL, + 'fanart': tvdbFanartSearchURL, + 'rating': tvdbRating, + 'siteRating': tvdbSiteRating, + 'genres': tvdbGenres, + 'zap2itMissingID': zap2itMissingID} # Search The Movie Database for metadata @route(PREFIX + '/tmdb') -def tmdb(title, epgMovie): +def tmdb(title): tmdbData = None tmdbPoster = None tmdbBackdrop = None @@ -631,43 +1061,53 @@ def tmdb(title, epgMovie): # Search try: tmdbData = JSON.ObjectFromURL(url=tmdbSearchURL, cacheTime=imageCacheTime, values=None) + except Exception as e: - Log.Warn("Error retrieving TMDb data: " + str(e)) + Log.Warn('Error retrieving TMDb data: ' + str(e)) - if tmdbData == None: - Log.Info("No results from TMDb for " + title) + if tmdbData is None: + Log.Info('No results from TMDb for ' + title) # Check for a matching movie or TV show elif int(tmdbData['total_results']) > 0 : for tmdbResult in tmdbData['results']: - tmdbTV = False - tmdbMovie = False + tmdbMatchedTV = False + tmdbMatchedMovie = False if tmdbResult.get('name') and String.LevenshteinDistance(tmdbResult['name'], title) == 0: - tmdbTV = True + tmdbMatchedTV = True elif tmdbResult.get('title') and String.LevenshteinDistance(tmdbResult['title'], title) == 0: - tmdbMovie = True + tmdbMatchedMovie = True + + if ( + (tmdbMatchedTV or tmdbMatchedMovie) + and tmdbResult['media_type'] in ['movie', 'tv'] + and (tmdbResult['poster_path'] or tmdbResult['backdrop_path'])): - if (tmdbTV == True or tmdbMovie == True) and (tmdbResult['poster_path'] != None or tmdbResult['backdrop_path'] != None): - if tmdbResult['poster_path'] != None: + if tmdbResult['poster_path']: tmdbPoster = tmdbBaseURL + 'w342' + tmdbResult['poster_path'] - if tmdbResult['backdrop_path'] != None: + if tmdbResult['backdrop_path']: tmdbBackdrop = tmdbBaseURL + 'original' + tmdbResult['backdrop_path'] if tmdbResult.get('vote_average'): tmdbVoteAverage = float(tmdbResult['vote_average']) - if tmdbMovie == True and tmdbResult.get('release_date'): - tmdbYear = int(tmdbResult['release_date'].split("-")[0]) + if tmdbMatchedMovie and tmdbResult.get('release_date'): + tmdbYear = int(tmdbResult['release_date'].split('-')[0]) if tmdbResult.get('genre_ids'): for genreResultID in tmdbResult['genre_ids']: for genreList in tmdbGenreData['genres']: if genreResultID == genreList['id']: - if tmdbGenres == None: + if tmdbGenres is None: tmdbGenres = genreList['name'] else: - tmdbGenres = tmdbGenres + ", " + genreList['name'] + tmdbGenres = tmdbGenres + ', ' + genreList['name'] break - return { 'poster': tmdbPoster, 'backdrop': tmdbBackdrop, 'year': tmdbYear, 'vote_average': tmdbVoteAverage, 'genres': tmdbGenres} \ No newline at end of file + return { + 'poster': tmdbPoster, + 'backdrop': tmdbBackdrop, + 'year': tmdbYear, + 'vote_average': tmdbVoteAverage, + 'genres': tmdbGenres} diff --git a/Contents/DefaultPrefs.json b/Contents/DefaultPrefs.json index 2820772..8e8e285 100644 --- a/Contents/DefaultPrefs.json +++ b/Contents/DefaultPrefs.json @@ -3,7 +3,7 @@ "id": "tvhAddress", "label": "tvhAddress", "type": "text", - "default": "http://localhost:9981" + "default": "http://address:9981" }, { "id": "tvhUser", @@ -36,6 +36,12 @@ "type": "bool", "default": "true" }, + { + "id": "prefPageCount", + "label": "prefPageCount", + "type": "text", + "default": "30" + }, { "id": "prefEPGCount", "label": "prefEPGCount", diff --git a/Contents/Strings/en.json b/Contents/Strings/en.json index dc5d9f5..9095a1d 100644 --- a/Contents/Strings/en.json +++ b/Contents/Strings/en.json @@ -7,6 +7,9 @@ "tvhProfile": "Streaming profile:", "prefChannelNumbers": "Display channel numbers", "pref24Time": "Display 24-hour time", + "prefPageCount": "Number of channels to display per page:", "prefEPGCount": "Number of hours/entries of upcoming shows to display:", - "prefMetadata": "Display artwork and metadata from theTVDB and The Movie DB" + "prefMetadata": "Display artwork and metadata from theTVDB and The Movie DB", + "next": "Next...", + "recordings": "Recordings" } \ No newline at end of file diff --git a/README.md b/README.md index c6d6aee..c058675 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,22 @@ LiveTVH provides live TV streaming for [Plex](https://plex.tv) via [Tvheadend](https://tvheadend.org), including metadata from Tvheadend's EPG, [theTVDB](https://thetvdb.com), and [The Movie DB](https://www.themoviedb.org). ## Release notes +* 2017.05.22 - [LiveTVH 1.2](https://github.com/taligentx/LiveTVH.bundle/releases/tag/v1.2) + * New: Paginated channel lists with configurable # of items per page - this helps with longer channel lists (a necessity for IPTV providers with thousands of channels). + * New: Tvheadend recordings for playback - located at the end of the first page of the channel list (a display bug with several Plex clients prevents placing it at the beginning of the list). + * New: Codec identification using Tvheadend channel tags (experimental). This can enable direct streaming for H264-AAC streams on some clients (see setup notes below). + * Updated: EPG parser to improve support for IPTV sources, including using images for a show if specified in the EPG (if other metadata providers are not available or are missing artwork). + * Updated: EPG item limit to 20k items/20MB (again, for IPTV sources). + * Updated: Plex clients will now display channel thumbnails as video clip objects (widescreen thumbnails) if metadata providers are disabled. + * Updated: Code housekeeping (partially PEP8-conformant) + * Bugfix: transcoding quality options not visible during playback + * Bugfix: episode names from EPG were not set on Plex for Android + * 2017.05.14 - [LiveTVH 1.1](https://github.com/taligentx/LiveTVH.bundle/releases/tag/v1.1) * EPG is no longer hard set - the number of EPG items requested is now based on the number of channels and hours of EPG data necessary (up to a maximum of 10,000 items or 10MB of data). - * Thumbnails fallback to a channel logo when a show matches theTVDB but does not have a poster. - * 12-hour time displays correctly on non-linux platforms. - * Year displays for movies (when available from TMDb). + * Bugfix: Thumbnails fallback to a channel logo when a show matches theTVDB but does not have a poster. + * Bugfix: 12-hour time displays correctly on non-linux platforms. + * Bugfix: Year displays for movies (when available from TMDb). * 2017.05.10 - Initial release 1.0 ## Features @@ -15,37 +26,46 @@ LiveTVH provides live TV streaming for [Plex](https://plex.tv) via [Tvheadend](h If available through Tvheadend's EPG, searching theTVDB utilizes zap2it ID information for more exact matches and will fall back to searching by name if not available. - If show artwork isn't available, LiveTVH will fallback to using Tvheadend's channel icons. + If show artwork isn't available, LiveTVH will fallback to using images in the EPG data if available or Tvheadend's channel icons. * Customized for different clients to display metadata more efficiently - Plex clients vary quite a bit in which fields they choose to display! * Search results, metadata, and artwork caching - again, to minimize channel list load times. +* Tvheadend recordings accessible for playback, including rich metadata lookup. * Tvheadend authentication info stored in HTTP headers where possible instead of being sent in the URL - this prevents the Tvheadend username and password from showing up in the Plex log files, for example. * Tvheadend stream URL checking for availability prior to sending the stream to the client - this prevents long timeouts on the client if Tvheadend does not have an available tuner. This also sends the stream URL as an indirect object to Plex, which prevents the Tvheadend username and password from showing up in the Plex XML file. However, if the stream is direct played instead of running through the Plex Transcoder, the client will receive the username and password as part of the stream URL and show up in the clear in the client logs as Plex does not seem to support sending headers as part of the stream object. ## Screenshots +![Plex Web Posters Screenshot](https://cloud.githubusercontent.com/assets/12835671/26337954/21753de4-3f42-11e7-895d-005c4da6b0a5.jpg) ![Plex Web Screenshot](https://cloud.githubusercontent.com/assets/12835671/25927053/c6212fda-35b8-11e7-98ca-ad636e62076e.jpg) +![Plex Web Recordings Screenshot](https://cloud.githubusercontent.com/assets/12835671/26337967/3b2e345c-3f42-11e7-9d58-1671841e06ab.jpg) ![Plex Home Theater Screenshot](https://cloud.githubusercontent.com/assets/12835671/25927057/d018e2ee-35b8-11e7-9f41-27554d4fca97.jpg) ![Plex Media Player Screenshot](https://cloud.githubusercontent.com/assets/12835671/25927122/2137e76a-35b9-11e7-85a0-949371255083.jpg) ![Plex iOS Screenshot](https://cloud.githubusercontent.com/assets/12835671/25927072/dbecdd3c-35b8-11e7-80d9-056e59088501.jpg) ## Setup -1. Download and unzip the current release of LiveTVH.bundle to the [Plex Media Server/Plug-ins](https://support.plex.tv/hc/en-us/articles/201106098-How-do-I-find-the-Plug-Ins-folder-) directory: - https://github.com/taligentx/LiveTVH.bundle/releases - -2. Set the LiveTVH preferences with the Tvheadend local or remotely accessible IP address/hostname, username, and password. +1. [Download LiveTVH.bundle](https://github.com/taligentx/LiveTVH.bundle/releases/) and unzip to the [Plex Media Server/Plug-ins](https://support.plex.tv/hc/en-us/articles/201106098-How-do-I-find-the-Plug-Ins-folder-) directory, and rename (if necessary) to `LiveTVH.bundle`. +2. Set the LiveTVH preferences with the Tvheadend LAN IP address/hostname (or WAN for remote access), username, and password. - ![Prefs Screenshot](https://cloud.githubusercontent.com/assets/12835671/25927076/df92f73c-35b8-11e7-99d2-5250e964cc04.jpg) + ![Prefs Screenshot](https://cloud.githubusercontent.com/assets/12835671/26337942/0a4d9724-3f42-11e7-9654-7c8e82e4877a.jpg) 3. Watch! ## Notes -* LiveTVH implements channels only at this point (recordings can be handled by [Plex DVR](https://www.plex.tv/features/dvr) and [tvhProxy](https://github.com/jkaberg/tvhProxy)). +* Codec identification - LiveTVH now uses Tvheadend channel tags to identify a channel's codecs and help Plex clients direct stream (tested with Plex Web and iOS). Create and set one of the following channel tags in Tvheadend as appropriate for each channel (Tvheadend supports editing multiple selections to make this a quick update): + * `H264-AAC` - many DVB and IPTV sources, may permit direct streaming on Plex Web and iOS. + * `MPEG2-AC3` - ATSC and some DVB sources + * `MPEG2` - some IPTV sources + * Setting the channel tag is not required - if a tag is not set, Plex will typically transcode as necessary. + + ![Tvheadend Channel Tags Screenshot](https://cloud.githubusercontent.com/assets/12835671/26338051/e0cb75dc-3f42-11e7-85a0-7af80e425a21.png) + +* LiveTVH implements channels and playback of Tvheadend recordings. New recordings can be managed within Tvheadend or switching recordings to [Plex DVR](https://www.plex.tv/features/dvr) and [tvhProxy](https://github.com/jkaberg/tvhProxy)). -* Channels will take a bit of time to load initially while metadata is fetched and speed up over time as images and metadata requests are stored in the cache. +* Channels will take a bit of time to load initially while metadata is fetched and speed up over time as images and metadata requests are stored in the cache (up to 30 days cache) - 20-30 channels per page works reasonably well. * Watching remotely may require Tvheadend to have a public-facing address, as some clients will attempt to directly play the Tvheadend stream instead of running through the Plex transcoder. - In this case, putting Tvheadend behind a reverse proxy with SSL is highly recommended, as the Tvheadend username and password is sent using HTTP Basic Authentication and is not secure over plain HTTP. + In this case, putting Tvheadend behind a [reverse proxy with SSL](https://www.nginx.com/resources/admin-guide/reverse-proxy/) is highly recommended, as the Tvheadend username and password is sent using HTTP Basic Authentication and is not secure over plain HTTP. * LiveTVH preferentially searches for metadata on theTVDB using a show's zap2it ID if provided through Tvheadend's EPG. @@ -55,6 +75,8 @@ LiveTVH provides live TV streaming for [Plex](https://plex.tv) via [Tvheadend](h ![zap2it Screenshot](https://cloud.githubusercontent.com/assets/12835671/25927080/e3b33ec6-35b8-11e7-8eb2-d0f0a3cfabc1.jpg) + + ## Known Issues * Plex Web currently does not display a detailed pre-play page - this is a bug/side effect of setting up the channels as movies instead of video clips to display posters correctly - channels can be played directly from the channel list. * Plex for Xbox One fails to play channels - this may be due to a [known Plex issue](https://forums.plex.tv/discussion/173008/known-issues-in-1-8-0#latest).