Repository: kolsys/YouTubeTV.bundle Branch: master Commit: 93c231df79e4 Files: 25 Total size: 103.0 KB Directory structure: gitextract_uz02c5iz/ ├── CNAME ├── Contents/ │ ├── Code/ │ │ ├── __init__.py │ │ └── updater.py │ ├── DefaultPrefs.json │ ├── Info.plist │ ├── Services/ │ │ ├── ServiceInfo.plist │ │ ├── Shared Code/ │ │ │ ├── jsinterp.pys │ │ │ └── video.pys │ │ └── URL/ │ │ └── YouTubeTV/ │ │ ├── ServiceCode.pys │ │ └── ServicePrefs.json │ └── Strings/ │ ├── da.json │ ├── de.json │ ├── en.json │ ├── es.json │ ├── fr-BE.json │ ├── fr.json │ ├── hu.json │ ├── it.json │ ├── nl.json │ ├── pl.json │ ├── ru.json │ └── sv.json ├── LICENSE ├── README.md └── _config.yml ================================================ FILE CONTENTS ================================================ ================================================ FILE: CNAME ================================================ kolsys.ml ================================================ FILE: Contents/Code/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2014, KOL # All rights reserved. # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # * Neither the name of the nor the # names of its contributors may be used to endorse or promote products # derived from this software without specific prior written permission. # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE # DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from urllib import urlencode from time import time from updater import Updater Video = SharedCodeService.video PREFIX = '/video/youtubetv' ART = 'art-default.jpg' ICON = 'icon-default.png' TITLE = u'%s' % L('Title') YT_CLIENT_ID = ( '383749313750-e0fj400djq4lukahnfjfqg6ckdbets63' '.apps.googleusercontent.com' ) YT_SECRET = 'rHHvL6tgl8Ej9KngduayT2ce' YT_SCOPE = 'https://www.googleapis.com/auth/youtube' YT_VERSION = 'v3' ICONS = { 'likes': R('heart-full.png'), 'favorites': R('star-2.png'), 'uploads': R('outbox-2.png'), 'watchHistory': R('revert.png'), 'watchLater': R('clock.png'), 'subscriptions': R('podcast-2.png'), 'browseChannels': R('grid-2.png'), 'playlists': R('list.png'), 'whatToWhatch': R('home.png'), 'account': R('user-2.png'), 'categories': R('store.png'), 'options': R('settings.png'), 'suggestions': R('tag.png'), 'remove': R('bin.png'), 'next': R('arrow-right.png'), 'offline': R('power.png'), 'search': R('search.png'), } YT_EDITABLE = { 'watchLater': L('watchLater'), 'likes': L('I like this'), 'favorites': L('Add to favorites'), } YT_FEEDS = { '_SB': {'u': 'feed/subscriptions', 'title': L('My Subscriptions')}, 'HL': {'u': 'feed/history', 'title': L('watchHistory')}, 'WL': {'u': 'playlist', 'title': L('watchLater')}, } ############################################################################### # Init ############################################################################### Plugin.AddViewGroup( 'details', viewMode='InfoList', type=ViewType.List, summary=SummaryTextType.Long ) def Start(): HTTP.CacheTime = CACHE_1HOUR ValidatePrefs() def ValidatePrefs(): loc = GetLanguage() if Core.storage.file_exists(Core.storage.abs_path( Core.storage.join_path( Core.bundle_path, 'Contents', 'Strings', '%s.json' % loc ) )): Locale.DefaultLocale = loc else: Locale.DefaultLocale = 'en-us' ############################################################################### # Video ############################################################################### @handler(PREFIX, TITLE, thumb=ICON) def MainMenu(complete=False, offline=False): oc = ObjectContainer(title2=TITLE, no_cache=True, replace_parent=False) if offline: ResetToken() if not CheckToken(): oc.add(DirectoryObject( key=Callback(Authorization), title=u'%s' % L('Authorize'), thumb=ICONS['options'], )) if complete: oc.header = L('Authorize') oc.message = L('You must enter code for continue') return oc Updater(PREFIX+'/update', oc) oc.add(DirectoryObject( key=Callback(Feed, oid='_SB'), title=u'%s' % L('My Subscriptions'), thumb=ICONS['subscriptions'], )) oc.add(DirectoryObject( key=Callback(Category, title=L('What to Watch')), title=u'%s' % L('What to Watch'), thumb=ICONS['whatToWhatch'], )) oc.add(DirectoryObject( key=Callback(Playlists, uid='me', title=L('Playlists')), title=u'%s' % L('Playlists'), thumb=ICONS['playlists'], )) oc.add(DirectoryObject( key=Callback(Categories, title=L('Categories'), c_type='video'), title=u'%s' % L('Categories'), thumb=ICONS['categories'], )) oc.add(DirectoryObject( key=Callback(Categories, title=L('Browse channels'), c_type='guide'), title=u'%s' % L('Browse channels'), thumb=ICONS['browseChannels'], )) oc.add(DirectoryObject( key=Callback(Channel, oid='me', title=L('My channel')), title=u'%s' % L('My channel'), thumb=ICONS['account'], )) FillChannelInfo(oc, 'me', ('watchLater', 'watchHistory', 'likes')) oc.add(InputDirectoryObject( key=Callback( Search, s_type='video', title=u'%s' % L('Search Video') ), title=u'%s' % L('Search'), prompt=u'%s' % L('Search Video'), thumb=ICONS['search'] )) return AddSubscriptions(oc, uid='me') @route(PREFIX + '/feed') def Feed(oid, offset=None): if not CheckToken(): return NoContents() params = { 'access_token': Dict['access_token'], 'ajax': 1, } if offset: path = 'feed' params['action_continuation'] = 1 params.update(JSON.ObjectFromString(offset)) else: path = YT_FEEDS[oid]['u'] if YT_FEEDS[oid]['u'] == 'playlist': params['list'] = oid path = YT_FEEDS[oid]['u'] try: res = JSON.ObjectFromString(HTTP.Request( 'https://m.youtube.com/%s?%s' % (path, urlencode(params)), headers={ 'User-Agent': Video.USER_AGENT } ).content[4:])['content'] except: return NoContents() if 'single_column_browse_results' in res: for item in res['single_column_browse_results']['tabs']: if 'selected' in item and item['selected'] is True: res = item['content'] break elif 'section_list' in res and len(res['section_list']['contents']): for item in res['section_list']['contents']: if item['item_type'] == 'playlist_video_list': res = item break elif 'contents' in item: for subitem in item['contents']: if subitem['item_type'] == 'playlist_video_list': res = subitem break else: continue break elif 'continuation_contents' in res: res = res['continuation_contents'] else: return NoContents() if not 'contents' in res or not len(res['contents']): return NoContents() ids = [] if 'continuations' in res and len(res['continuations']): continuations = res['continuations'] else: continuations = None for item in res['contents']: if 'continuations' in item and len(item['continuations']): continuations = item['continuations'] vid = Video.GetFeedVid(item) if vid is not None: ids.append(vid) continue for subitem in item['contents']: vid = Video.GetFeedVid(subitem) if vid is not None: ids.append(vid) if not len(ids): return NoContents() oc = ObjectContainer(title2=u'%s' % YT_FEEDS[oid]['title']) chunk_size = 50 extended = Prefs['my_subscriptions_extened'] if oid == '_SB' else Prefs['playlists_extened'] [AddVideos( oc, ApiGetVideos(ids=ids[i:i + chunk_size]), extended=extended ) for i in xrange(0, len(ids), chunk_size)] if continuations is None: return oc # Add offset for item in continuations: if item['item_type'] == 'next_continuation_data': oc.add(NextPageObject( key=Callback( Feed, oid=oid, offset=JSON.StringFromObject({ 'itct': item['click_tracking_params'], 'ctoken': item['continuation'], }), ), title=u'%s' % L('Next page'), thumb=ICONS['next'] )) break return oc @route(PREFIX + '/video/view') def VideoView(vid, **kwargs): return URLService.MetadataObjectForURL( url=Video.GetServiceURL(vid, Dict['access_token'], GetLanguage()), in_container=True ) @route(PREFIX + '/video/info') def VideoInfo(vid, pl_item_id=None): oc = ObjectContainer() res = ApiGetVideos(ids=[vid]) AddVideos(oc, res, title=L('Play video')) if not len(oc): return NoContents() item = res['items'][0] oc.title2 = u'%s' % item['snippet']['localized']['title'] oc.add(DirectoryObject( key=Callback( Channel, oid=item['snippet']['channelId'], title=item['snippet']['channelTitle'] ), title=u'%s' % item['snippet']['channelTitle'], thumb=ICONS['account'], )) oc.add(DirectoryObject( key=Callback( Search, title=L('Related videos'), query=None, relatedToVideoId=item['id'] ), title=u'%s' % L('Related videos'), thumb=ICONS['suggestions'], )) for key, title in YT_EDITABLE.items(): oc.add(DirectoryObject( key=Callback(PlaylistAdd, aid=item['id'], key=key), title=u'%s' % title, thumb=ICONS[key], )) if pl_item_id: oc.add(DirectoryObject( key=Callback(PlaylistRemove, pl_item_id=pl_item_id), title=u'%s' % L('Remove from playlist'), thumb=ICONS['remove'], )) return AddItemsFromDescription( oc, item['snippet']['localized']['description'] ) @route(PREFIX + '/channels') def Channels(oid, title, offset=None): res = ApiRequest('channels', ApiGetParams( categoryId=oid, hl=GetLanguage(), limit=Prefs['items_per_page'], offset=offset )) if not res or not len(res['items']): return NoContents() oc = ObjectContainer( title2=u'%s' % title, replace_parent=bool(offset) ) for item in res['items']: cid = item['id'] item = item['snippet'] oc.add(DirectoryObject( key=Callback( Channel, oid=cid, title=item['title'] ), title=u'%s' % item['title'], summary=u'%s' % item['description'], thumb=GetThumbFromSnippet(item), )) if 'nextPageToken' in res: oc.add(NextPageObject( key=Callback( Channels, oid=oid, title=title, offset=res['nextPageToken'], ), title=u'%s' % L('Next page'), thumb=ICONS['next'] )) return oc @route(PREFIX + '/channel') def Channel(oid, title): oc = ObjectContainer( title2=u'%s' % title ) # Add standart menu FillChannelInfo(oc, oid) if oid == 'me': oc.add(DirectoryObject( key=Callback(MainMenu, offline=True), title=u'%s' % L('Sign out'), thumb=ICONS['offline'], )) return oc oc.add(DirectoryObject( key=Callback( Subscriptions, title=u'%s - %s' % (title, L('Subscriptions')), uid=oid ), title=u'%s' % L('Subscriptions'), thumb=ICONS['subscriptions'], )) oc.add(InputDirectoryObject( key=Callback( Search, s_type='video', channelId=oid, title=u'%s' % L('Search Channel') ), title=u'%s' % L('Search'), prompt=u'%s' % L('Search Channel'), thumb=ICONS['search'] )) AddPlaylists(oc, uid=oid) return oc @route(PREFIX + '/user') def User(username): res = ApiRequest('channels', ApiGetParams( forUsername=username, hl=GetLanguage() )) if not res or not len(res['items']): return NoContents() item = res['items'][0] return Channel(item['id'], item['snippet']['localized']['title']) @route(PREFIX + '/categories') def Categories(title, c_type): res = ApiRequest('%sCategories' % c_type, ApiGetParams( regionCode=GetRegion(), hl=GetLanguage() )) if not res or not len(res['items']): return NoContents() oc = ObjectContainer( title2=u'%s' % title ) if c_type == 'guide': c_callback = Channels oc.add(InputDirectoryObject( key=Callback( Search, s_type='channel', title=u'%s' % L('Search channels') ), title=u'%s' % L('Search'), prompt=u'%s' % L('Search channels'), thumb=ICONS['search'] )) else: c_callback = Category for item in res['items']: oc.add(DirectoryObject( key=Callback( c_callback, title=item['snippet']['title'], oid=item['id'] ), title=u'%s' % item['snippet']['title'] )) return oc @route(PREFIX + '/category') def Category(title, oid=0, offset=None): oc = ObjectContainer( title2=u'%s' % title, replace_parent=bool(offset) ) res = ApiGetVideos( chart='mostPopular', limit=Prefs['items_per_page'], offset=offset, regionCode=GetRegion(), videoCategoryId=oid ) AddVideos(oc, res, extended=Prefs['category_extened']) if not len(oc): return NoContents() if 'nextPageToken' in res: oc.add(NextPageObject( key=Callback( Category, title=oc.title2, oid=oid, offset=res['nextPageToken'], ), title=u'%s' % L('Next page'), thumb=ICONS['next'] )) return oc @route(PREFIX + '/playlists') def Playlists(uid, title, offset=None): oc = ObjectContainer( title2=u'%s' % title, replace_parent=bool(offset) ) if not offset and uid == 'me': FillChannelInfo(oc, uid, ('watchLater', 'likes', 'favorites')) oc.add(InputDirectoryObject( key=Callback( Search, s_type='playlist', title=u'%s' % L('Search playlists') ), title=u'%s' % L('Search'), prompt=u'%s' % L('Search playlists'), thumb=ICONS['search'] )) return AddPlaylists(oc, uid=uid, offset=offset) @route(PREFIX + '/playlist') def Playlist(oid, title, can_edit=False, offset=None): if oid in YT_FEEDS: return Feed(oid) res = ApiRequest('playlistItems', ApiGetParams( part='contentDetails', playlistId=oid, offset=offset, limit=Prefs['items_per_page'] )) if not res or not len(res['items']): return NoContents() oc = ObjectContainer( title2=u'%s' % title, replace_parent=bool(offset) ) ids = [] pl_map = {} can_edit = can_edit and can_edit != 'False' for item in res['items']: ids.append(item['contentDetails']['videoId']) if can_edit: pl_map[item['contentDetails']['videoId']] = item['id'] AddVideos( oc, ApiGetVideos(ids=ids), extended=Prefs['playlists_extened'], pl_map=pl_map ) if 'nextPageToken' in res: oc.add(NextPageObject( key=Callback( Playlist, title=oc.title2, oid=oid, can_edit=can_edit, offset=res['nextPageToken'], ), title=u'%s' % L('Next page'), thumb=ICONS['next'] )) return oc @route(PREFIX + '/playlist/add') def PlaylistAdd(aid, key=None, oid=None, a_type='video'): if key is not None: items = ApiGetChannelInfo('me')['playlists'] if key in items: oid = items[key] if not oid: return ErrorMessage() res = ApiRequest('playlistItems', {'part': 'snippet'}, data={ 'snippet': { 'playlistId': oid, 'resourceId': { 'kind': 'youtube#'+a_type, a_type+'Id': aid, } } }) if not res: return ErrorMessage() return SuccessMessage() def PlaylistRemove(pl_item_id): if ApiRequest('playlistItems', {'id': pl_item_id}, rmethod='DELETE'): return SuccessMessage() return ErrorMessage() @route(PREFIX + '/subscriptions') def Subscriptions(uid, title, offset=None): oc = ObjectContainer( title2=u'%s' % L('Subscriptions'), replace_parent=bool(offset) ) return AddSubscriptions(oc, uid=uid, offset=offset) def AddVideos(oc, res, title=None, extended=False, pl_map={}): if not res or not len(res['items']): return oc for item in res['items']: snippet = item['snippet'] duration = Video.ParseDuration( item['contentDetails']['duration'] )*1000 summary = u'%s\n%s' % (snippet['channelTitle'], snippet['description']) if extended: pl_item_id = pl_map[item['id']] if item['id'] in pl_map else None oc.add(DirectoryObject( key=Callback(VideoInfo, vid=item['id'], pl_item_id=pl_item_id), title=u'%s' % snippet['title'], summary=summary, thumb=GetThumbFromSnippet(snippet), duration=duration, )) else: oc.add(VideoClipObject( key=Callback(VideoView, vid=item['id']), rating_key=Video.GetServiceURL(item['id']), title=u'%s' % snippet['title'] if title is None else title, summary=summary, thumb=GetThumbFromSnippet(snippet), duration=duration, originally_available_at=Datetime.ParseDate( snippet['publishedAt'] ).date(), items=URLService.MediaObjectsForURL( Video.GetServiceURL(item['id'], Dict['access_token']) ) )) return oc def FillChannelInfo(oc, uid, pl_types=None): info = ApiGetChannelInfo(uid) if info['banner'] is not None: oc.art = info['banner'] if not info['playlists']: return oc if pl_types is not None: items = dict(filter( lambda v: v[0] in pl_types, info['playlists'].items() )) else: items = info['playlists'] for key in sorted( items, key=lambda v: v != 'uploads' ): oc.add(DirectoryObject( key=Callback( Playlist, oid=items[key], title=L(key), can_edit=uid == 'me' and key in YT_EDITABLE ), title=u'%s' % L(key), thumb=ICONS[key] if key in ICONS else None, )) return oc def AddPlaylists(oc, uid, offset=None): res = ApiRequest('playlists', ApiGetParams( uid=uid, limit=GetLimitForOC(oc), offset=offset, hl=GetLanguage() )) if res: if 'items' in res: for item in res['items']: oid = item['id'] item = item['snippet'] oc.add(DirectoryObject( key=Callback( Playlist, oid=oid, title=item['localized']['title'], can_edit=uid == 'me' ), title=u'%s' % item['localized']['title'], summary=u'%s' % item['localized']['description'], thumb=GetThumbFromSnippet(item), )) if 'nextPageToken' in res: oc.add(NextPageObject( key=Callback( Playlists, uid=uid, title=oc.title2, offset=res['nextPageToken'], ), title=u'%s' % L('More playlists'), thumb=ICONS['next'] )) if not len(oc): return NoContents() return oc def AddSubscriptions(oc, uid, offset=None): res = ApiRequest('subscriptions', ApiGetParams( uid=uid, limit=GetLimitForOC(oc), offset=offset, order=str(Prefs['subscriptions_order']).lower() )) if res: if 'items' in res: for item in res['items']: item = item['snippet'] oc.add(DirectoryObject( key=Callback( Channel, oid=item['resourceId']['channelId'], title=item['title'] ), title=u'%s' % item['title'], summary=u'%s' % item['description'], thumb=GetThumbFromSnippet(item), )) if 'nextPageToken' in res: offset = res['nextPageToken'] oc.add(NextPageObject( key=Callback( Subscriptions, uid=uid, title=oc.title2, offset=offset, ), title=u'%s' % L('More subscriptions'), thumb=ICONS['next'] )) if not len(oc): return NoContents() return oc def AddItemsFromDescription(oc, description): links = Video.ParseLinksFromDescription(description) if not len(links): return oc; for (ext_title, url) in links: ext_title = ext_title.strip() if '/user/' in url: oc.add(DirectoryObject( key=Callback(User, username=Video.GetOID(url)), title=u'[*] %s' % ext_title, )) continue elif '/channel/' in url: oc.add(DirectoryObject( key=Callback(Channel, oid=Video.GetOID(url), title=ext_title), title=u'[*] %s' % ext_title, )) continue try: ext_vid = URLService.NormalizeURL(url) except: continue if ext_vid is None: continue if 'playlist?' in ext_vid: ext_vid = ext_vid[ext_vid.rfind('list=')+5:] oc.add(DirectoryObject( key=Callback(Playlist, oid=ext_vid, title=ext_title), title=u'[*] %s' % ext_title )) else: ext_vid = Video.GetOID(ext_vid) oc.add(DirectoryObject( key=Callback(VideoInfo, vid=ext_vid), title=u'[*] %s' % ext_title, thumb=Video.GetThumb(ext_vid) )) return oc def Search(query=None, title=L('Search'), s_type='video', offset=0, **kwargs): if not query and not kwargs: return NoContents() is_video = s_type == 'video' res = ApiRequest('search', ApiGetParams( part='id' if is_video else 'snippet', q=query, type=s_type, regionCode=GetRegion(), videoDefinition='high' if is_video and Prefs['search_hd'] else '', offset=offset, limit=Prefs['items_per_page'], **kwargs )) if not res or not len(res['items']): return NoContents() oc = ObjectContainer( title2=u'%s' % title, replace_parent=bool(offset) ) if is_video: ids = [] for item in res['items']: ids.append(item['id']['videoId']) AddVideos(oc, ApiGetVideos(ids=ids), extended=Prefs['search_extened']) else: s_callback = Channel if s_type == 'channel' else Playlist s_key = s_type+'Id' for item in res['items']: oid = item['id'][s_key] item = item['snippet'] oc.add(DirectoryObject( key=Callback( s_callback, title=item['title'], oid=oid ), title=u'%s' % item['title'], summary=u'%s' % item['description'], thumb=GetThumbFromSnippet(item), )) if 'nextPageToken' in res: oc.add(NextPageObject( key=Callback( Search, query=query, title=oc.title2, s_type=s_type, offset=res['nextPageToken'], **kwargs ), title=u'%s' % L('Next page'), thumb=ICONS['next'] )) return oc @route(PREFIX + '/authorization') def Authorization(): code = None if CheckAccessData('device_code'): code = Dict['user_code'] url = Dict['verification_url'] else: res = OAuthRequest({'scope': YT_SCOPE}, 'device/code') if res: code = res['user_code'] url = res['verification_url'] StoreAccessData(res) if code: oc = ObjectContainer( view_group='details', no_cache=True, objects=[ DirectoryObject( key=Callback(MainMenu, complete=True), title=u'%s' % F('codeIs', code), summary=u'%s' % F('enterCodeSite', code, url), tagline=url, ), DirectoryObject( key=Callback(MainMenu, complete=True), title=u'%s' % L('Authorize'), summary=u'%s' % L('Complete authorization'), ), ] ) return oc return ObjectContainer( header=u'%s' % L('Error'), message=u'%s' % L('Service temporarily unavailable') ) ############################################################################### # Common ############################################################################### def NoContents(): return ObjectContainer( header=u'%s' % L('Error'), message=u'%s' % L('No entries found') ) def SuccessMessage(): return ObjectContainer( header=u'%s' % L('Success'), message=u'%s' % L('Action complete') ) def ErrorMessage(): return ObjectContainer( header=u'%s' % L('Error'), message=u'%s' % L('An error has occurred') ) def NotImplemented(**kwargs): return ObjectContainer( header=u'%s' % L('Not Implemented'), message=u'%s' % L('This function not implemented yet') ) def GetRegion(): return Prefs['region'].split('/')[1] def GetLanguage(): return Prefs['language'].split('/')[1] def GetLimitForOC(oc): ret = int(Prefs['items_per_page'])-len(oc) return 8 if ret <= 0 else ret def GetThumbFromSnippet(snippet): try: return snippet['thumbnails']['high']['url'] except: return '' def ApiGetVideos(ids=[], title=None, extended=False, **kwargs): return ApiRequest('videos', ApiGetParams( part='snippet,contentDetails', hl=GetLanguage(), id=','.join(ids), **kwargs )) def ApiGetChannelInfo(uid): res = ApiRequest('channels', ApiGetParams( part='contentDetails,brandingSettings', hl=GetLanguage(), uid=uid, id=uid if uid != 'me' else None )) ret = { 'playlists': {}, 'banner': None } if res and res['items']: res = res['items'][0] ret['playlists'] = res['contentDetails']['relatedPlaylists'] try: ret['banner'] = res['brandingSettings']['image']['bannerTvHighImageUrl'] except: pass return ret def ApiRequest(method, params, data=None, rmethod=None): if not CheckToken(): return None params['access_token'] = Dict['access_token'] is_change = data or rmethod == 'DELETE' try: res = HTTP.Request( 'https://www.googleapis.com/youtube/%s/%s?%s' % ( YT_VERSION, method, urlencode(params) ), headers={'Content-Type': 'application/json; charset=UTF-8'}, data=None if not data else JSON.StringFromObject(data), method=rmethod, cacheTime=0 if is_change else CACHE_1HOUR ).content except Exception as e: Log.Debug(str(e)) return None if is_change: HTTP.ClearCache() return True try: res = JSON.ObjectFromString(res) except: return None if 'error' in res: return None return res def ApiGetParams(part='snippet', offset=None, limit=None, uid=None, **kwargs): params = { 'part': part, } if uid is not None: if uid == 'me': params['mine'] = 'true' else: params['channelId'] = uid if offset: params['pageToken'] = offset if limit: params['maxResults'] = limit params.update(filter(lambda v: v[1], kwargs.items())) return params def CheckToken(): if CheckAccessData('access_token'): return True if 'refresh_token' in Dict: res = OAuthRequest({ 'refresh_token': Dict['refresh_token'], 'grant_type': 'refresh_token', }) if res: StoreAccessData(res) return True if CheckAccessData('device_code'): res = OAuthRequest({ 'code': Dict['device_code'], 'grant_type': 'http://oauth.net/grant_type/device/1.0', }) if res: StoreAccessData(res) return True return False def ResetToken(): del Dict['access_token'] del Dict['refresh_token'] del Dict['device_code'] Dict.Reset() Dict.Save() def OAuthRequest(params, rtype='token'): params['client_id'] = YT_CLIENT_ID if rtype == 'token': params['client_secret'] = YT_SECRET try: res = JSON.ObjectFromURL( 'https://accounts.google.com/o/oauth2/' + rtype, values=params, cacheTime=0 ) if 'error' in res: res = False except: res = False return res def CheckAccessData(key): return (key in Dict and Dict['expires'] >= int(time())) def StoreAccessData(data): if 'expires_in' in data: data['expires'] = int(time()) + int(data['expires_in']) for key, val in data.items(): Dict[key] = val ================================================ FILE: Contents/Code/updater.py ================================================ # -*- coding: utf-8 -*- # # Plex Plugin Updater # $Id$ # # Universal plugin updater module for Plex Server Channels that # implement automatic plugins updates from remote config. # Support Github API by default # # https://github.com/kolsys/plex-channel-updater # # Copyright (c) 2014, KOL # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # * Neither the name of the nor the # names of its contributors may be used to endorse or promote products # derived from this software without specific prior written permission. # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE # DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. KEY_PLIST_VERSION = 'CFBundleVersion' KEY_PLIST_URL = 'PlexPluginVersionUrl' KEY_DATA_VERSION = 'tag_name' KEY_DATA_DESC = 'body' KEY_DATA_ZIPBALL = 'zipball_url' CHECK_INTERVAL = CACHE_1HOUR * 12 class Updater: info = None update = None def __init__(self, prefix, oc): if self.InitBundleInfo() and self.IsUpdateAvailable(): Route.Connect(prefix, self.DoUpdate) oc.add(DirectoryObject( key=Callback(self.DoUpdate), title=u'%s' % F( 'Update available: %s', self.update['version'] ), summary=u'%s\n%s' % (L( 'Install latest version of the channel.' ), self.update['info']), )) def NormalizeVersion(self, version): if version[:1] == 'v': version = version[1:] return version def ParseVersion(self, version): try: return tuple(map(int, (version.split('.')))) except: # String comparison by default return version def IsUpdateAvailable(self): try: info = JSON.ObjectFromURL( self.info['url'], cacheTime=CHECK_INTERVAL, timeout=5 ) version = self.NormalizeVersion(info[KEY_DATA_VERSION]) dist_url = info[KEY_DATA_ZIPBALL] except: return False if self.ParseVersion(version) > self.ParseVersion( self.info['version'] ): self.update = { 'version': version, 'url': dist_url, 'info': info[KEY_DATA_DESC] if KEY_DATA_DESC in info else '', } return bool(self.update) def InitBundleInfo(self): try: plist = Plist.ObjectFromString(Core.storage.load( Core.storage.abs_path( Core.storage.join_path( Core.bundle_path, 'Contents', 'Info.plist' ) ) )) self.info = { 'version': plist[KEY_PLIST_VERSION], 'url': plist[KEY_PLIST_URL], } except: pass return bool(self.info) def DoUpdate(self): try: zip_data = Archive.ZipFromURL(self.update['url']) bundle_path = Core.storage.abs_path(Core.bundle_path) for name in zip_data.Names(): data = zip_data[name] parts = name.split('/') shifted = Core.storage.join_path(*parts[1:]) full = Core.storage.join_path(bundle_path, shifted) if '/.' in name: continue if name.endswith('/'): Core.storage.ensure_dirs(full) else: Core.storage.save(full, data) del zip_data return ObjectContainer( header=u'%s' % L('Success'), message=u'%s' % F( 'Channel updated to version %s', self.update['version'] ) ) except Exception as e: return ObjectContainer( header=u'%s' % L('Error'), message=u'%s' % e ) ================================================ FILE: Contents/DefaultPrefs.json ================================================ [ { "id": "my_subscriptions_extened", "type": "bool", "label": "Extended video info in My subscriptions", "default": "false", }, { "id": "playlists_extened", "type": "bool", "label": "Extended video info in Playlists", "default": "false", }, { "id": "category_extened", "type": "bool", "label": "Extended video info in Categories", "default": "true", }, { "id": "search_extened", "type": "bool", "label": "Extended video info in Search", "default": "true", }, { "id": "search_hd", "type": "bool", "label": "Search HD only", "default": "true", }, { "id": "items_per_page", "type": "enum", "label": "Items per page", "values": ["16", "32", "48"], "default": "16", }, { "id": "subscriptions_order", "type": "enum", "label": "Subscriptions list sorting order", "values": ["Relevance", "Alphabetical"], "default": "Relevance", }, { "id": "region", "type": "enum", "label": "Preferred Region for Videos and channels", "default": "Worldwide (All)/US", "values": [ "Worldwide (All)/US", "Algeria/DZ", "Argentina/AR", "Australia/AU", "Austria/AT", "Bahrain/BH", "Belgium/BE", "Bosnia and Herzegovina/BA", "Brazil/BR", "Bulgaria/BG", "Canada/CA", "Chile/CL", "Colombia/CO", "Croatia/HR", "Czech Republic/CZ", "Denmark/DK", "Egypt/EG", "Estonia/EE", "Finland/FI", "France/FR", "Germany/DE", "Ghana/GH", "Greece/GR", "Hong Kong/HK", "Hungary/HU", "India/IN", "Indonesia/ID", "Ireland/IE", "Israel/IL", "Italy/IT", "Japan/JP", "Jordan/JO", "Kenya/KE", "Kuwait/KW", "Latvia/LV", "Lebanon/LB", "Libya/LY", "Lithuania/LT", "Macedonia/MK", "Malaysia/MY", "Mexico/MX", "Montenegro/ME", "Morocco/MA", "Netherlands/NL", "New Zealand/NZ", "Nigeria/NG", "Norway/NO", "Oman/OM", "Peru/PE", "Philippines/PH", "Poland/PL", "Portugal/PT", "Puerto Rico/PR", "Qatar/QA", "Romania/RO", "Russia/RU", "Saudi Arabia/SA", "Senegal/SN", "Serbia/RS", "Singapore/SG", "Slovakia/SK", "Slovenia/SI", "South Africa/ZA", "South Korea/KR", "Spain/ES", "Sweden/SE", "Switzerland/CH", "Taiwan/TW", "Thailand/TH", "Tunisia/TN", "Turkey/TR", "Uganda/UG", "Ukraine/UA", "United Arab Emirates/AE", "United Kingdom/GB", "Vietnam/VN", "Yemen/YE" ] }, { "id": "language", "type": "enum", "label": "Preferred language for Videos and channels", "default": "English/en", "values": [ "Afrikaans/af", "Albanian/sq", "Amharic/am", "Arabic/ar", "Armenian/hy", "Azerbaijani/az", "Basque/eu", "Bengali/bn", "Bulgarian/bg", "Catalan/ca", "Chinese (Hong Kong)/zh-HK", "Chinese (Taiwan)/zh-TW", "Chinese/zh-CN", "Croatian/hr", "Czech/cs", "Danish/da", "Dutch/nl", "English (United Kingdom)/en-GB", "English/en", "Estonian/et", "Filipino/fil", "Finnish/fi", "French (Canada)/fr-CA", "French/fr", "Galician/gl", "Georgian/ka", "German/de", "Greek/el", "Gujarati/gu", "Hebrew/iw", "Hindi/hi", "Hungarian/hu", "Icelandic/is", "Indonesian/id", "Italian/it", "Japanese/ja", "Kannada/kn", "Kazakh/kk", "Khmer/km", "Korean/ko", "Kyrgyz/ky", "Lao/lo", "Latvian/lv", "Lithuanian/lt", "Macedonian/mk", "Malay/ms", "Malayalam/ml", "Marathi/mr", "Mongolian/mn", "Myanmar (Burmese)/my", "Nepali/ne", "Norwegian/no", "Persian/fa", "Polish/pl", "Portuguese (Brazil)/pt", "Portuguese (Portugal)/pt-PT", "Punjabi/pa", "Romanian/ro", "Russian/ru", "Serbian/sr", "Sinhala/si", "Slovak/sk", "Slovenian/sl", "Spanish (Latin America)/es-419", "Spanish (Spain)/es", "Swahili/sw", "Swedish/sv", "Tamil/ta", "Telugu/te", "Thai/th", "Turkish/tr", "Ukrainian/uk", "Urdu/ur", "Uzbek/uz", "Vietnamese/vi", "Zulu/zu", ] } ] ================================================ FILE: Contents/Info.plist ================================================ CFBundleDevelopmentRegion English CFBundleExecutable CFBundleIdentifier com.plexapp.plugins.youtube.tv CFBundleInfoDictionaryVersion 6.0 CFBundlePackageType AAPL CFBundleSignature hook CFBundleVersion 4.13 PlexPluginVersionUrl https://go.kolsys.cf/1FuE3dz PlexPluginCodePolicy Elevated PlexClientPlatforms * PlexFrameworkVersion 2 PlexMediaContainer MP4 PlexVideoCodec H.264 PlexAudioCodec AAC PlexPluginConsoleLogging 1 PlexPluginDevMode 0 ================================================ FILE: Contents/Services/ServiceInfo.plist ================================================ URL YouTubeTV URLPatterns ^(https?:)?//tv\.youtube\.plugins\.plex\.com/.+ ^(https?:)?//(www\.|m\.)?youtu(be(\.googleapis)?\.com|\.be)/(?!account(_|\?)?|artist(/|\?)|blog\?|categories\?|channels\?|charts|embed/(__videoid__|videoseries\?)|playlist\?|p/|profile\?|results\?|subscribe_widget|\?tab=).+ ================================================ FILE: Contents/Services/Shared Code/jsinterp.pys ================================================ # -*- coding: utf-8 -*- # # Based on youtube-dl lib # https://github.com/rg3/youtube-dl/blob/master/youtube_dl/jsinterp.py # Copyright (c) 2015, KOL # All rights reserved. # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # * Neither the name of the nor the # names of its contributors may be used to endorse or promote products # derived from this software without specific prior written permission. # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTLICULAR PURPOSE ARE # DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from __future__ import unicode_literals import json import operator import re OPERATORS = [ ('|', operator.or_), ('^', operator.xor), ('&', operator.and_), ('>>', operator.rshift), ('<<', operator.lshift), ('-', operator.sub), ('+', operator.add), ('%', operator.mod), ('/', operator.truediv), ('*', operator.mul), ] ASSIGNOPERATORS = [(op + '=', opfunc) for op, opfunc in OPERATORS] ASSIGNOPERATORS.append(('=', lambda cur, right: right)) NAME_RE = r'[a-zA-Z_$][a-zA-Z_$0-9]*' class JSInterpreter(object): def __init__(self, code, objects=None): if objects is None: objects = {} self.code = code self.p_functions = {} self.p_objects = objects def interpret_statement(self, stmt, local_vars, allow_recursion=100): if allow_recursion < 0: raise Exception('Recursion limit reached') should_abort = False stmt = stmt.lstrip() stmt_m = re.match(r'var\s', stmt) if stmt_m: expr = stmt[len(stmt_m.group(0)):] else: return_m = re.match(r'return(?:\s+|$)', stmt) if return_m: expr = stmt[len(return_m.group(0)):] should_abort = True else: # Try interpreting it as an expression expr = stmt v = self.interpret_expression(expr, local_vars, allow_recursion) return v, should_abort def interpret_expression(self, expr, local_vars, allow_recursion): expr = expr.strip() if expr == '': # Empty expression return None if expr.startswith('('): parens_count = 0 for m in re.finditer(r'[()]', expr): if m.group(0) == '(': parens_count += 1 else: parens_count -= 1 if parens_count == 0: sub_expr = expr[1:m.start()] sub_result = self.interpret_expression( sub_expr, local_vars, allow_recursion) remaining_expr = expr[m.end():].strip() if not remaining_expr: return sub_result else: expr = json.dumps(sub_result) + remaining_expr break else: raise Exception('Premature end of parens in %r' % expr) for op, opfunc in ASSIGNOPERATORS: m = re.match(r'''(?x) (?P%s)(?:\[(?P[^\]]+?)\])? \s*%s (?P.*)$''' % (NAME_RE, re.escape(op)), expr) if not m: continue right_val = self.interpret_expression( m.group('expr'), local_vars, allow_recursion - 1) if m.groupdict().get('index'): lvar = local_vars[m.group('out')] idx = self.interpret_expression( m.group('index'), local_vars, allow_recursion) assert isinstance(idx, int) cur = lvar[idx] val = opfunc(cur, right_val) lvar[idx] = val return val else: cur = local_vars.get(m.group('out')) val = opfunc(cur, right_val) local_vars[m.group('out')] = val return val if expr.isdigit(): return int(expr) var_m = re.match( r'(?!if|return|true|false)(?P%s)$' % NAME_RE, expr) if var_m: return local_vars[var_m.group('name')] try: return json.loads(expr) except ValueError: pass m = re.match( # r'(?P%s)\.(?P[^(]+)(?:\(+(?P[^()]*)\))?$' % NAME_RE, r'(?P%s)(?:\.(?P[^(]+)|\[[\'\"](?P[^(]+)[\'\"]\])(?:\(+(?P[^()]*)\))?$' % NAME_RE, expr) if m: variable = m.group('var') member = m.group('member') if not member: member = m.group('member_str') arg_str = m.group('args') if variable in local_vars: obj = local_vars[variable] else: if variable not in self.p_objects: self.p_objects[variable] = self.extract_object(variable) obj = self.p_objects[variable] if arg_str is None: # Member access if member == 'length': return len(obj) return obj[member] assert expr.endswith(')') # Function call if arg_str == '': argvals = tuple() else: argvals = tuple([ self.interpret_expression(v, local_vars, allow_recursion) for v in arg_str.split(',')]) if member == 'split': assert argvals == ('',) return list(obj) if member == 'join': assert len(argvals) == 1 return argvals[0].join(obj) if member == 'reverse': assert len(argvals) == 0 obj.reverse() return obj if member == 'slice': assert len(argvals) == 1 return obj[argvals[0]:] if member == 'splice': assert isinstance(obj, list) index, howMany = argvals res = [] for i in range(index, min(index + howMany, len(obj))): res.append(obj.pop(index)) return res return obj[member](argvals) m = re.match( r'(?P%s)\[(?P.+)\]$' % NAME_RE, expr) if m: val = local_vars[m.group('in')] idx = self.interpret_expression( m.group('idx'), local_vars, allow_recursion - 1) return val[idx] for op, opfunc in OPERATORS: m = re.match(r'(?P.+?)%s(?P.+)' % re.escape(op), expr) if not m: continue x, abort = self.interpret_statement( m.group('x'), local_vars, allow_recursion - 1) if abort: raise Exception( 'Premature left-side return of %s in %r' % (op, expr)) y, abort = self.interpret_statement( m.group('y'), local_vars, allow_recursion - 1) if abort: raise Exception( 'Premature right-side return of %s in %r' % (op, expr)) return opfunc(x, y) m = re.match( r'^(?P%s)\((?P[a-zA-Z0-9_$,]+)\)$' % NAME_RE, expr) if m: fname = m.group('func') argvals = tuple([ int(v) if v.isdigit() else local_vars[v] for v in m.group('args').split(',')]) if fname not in self.p_functions: self.p_functions[fname] = self.extract_function(fname) return self.p_functions[fname](argvals) raise Exception('Unsupported JS expression %r' % expr) def extract_object(self, objname): obj = {} obj_m = re.search( (r'(?:var\s+)?%s\s*=\s*\{' % re.escape(objname)) + r'\s*(?P[\'\"]?([a-zA-Z$0-9]+[\'\"]?\s*:\s*function\(.*?\)\s*\{.*?\}\s*,*\s*)*)' + r'\}\s*;', self.code) fields = obj_m.group('fields') # Currently, it only supports function definitions fields_m = re.finditer( r'[\'\"]?(?P[a-zA-Z$0-9]+)[\'\"]?\s*:\s*function' r'\((?P[a-z,]+)\){(?P[^}]+)}', fields) for f in fields_m: argnames = f.group('args').split(',') obj[f.group('key')] = self.build_function(argnames, f.group('code')) return obj def extract_function(self, funcname): func_m = re.search( r'''(?x) (?:function\s+%s|[{;,\s]%s\s*=\s*function)\s* \((?P[^)]*)\)\s* \{(?P[^}]+)\}''' % ( re.escape(funcname), re.escape(funcname)), self.code) if func_m is None: raise Exception('Could not find JS function %r' % funcname) argnames = func_m.group('args').split(',') return self.build_function(argnames, func_m.group('code')) def call_function(self, funcname, *args): f = self.extract_function(funcname) return f(args) def build_function(self, argnames, code): def resf(args): local_vars = dict(zip(argnames, args)) for stmt in code.split(';'): res, abort = self.interpret_statement(stmt, local_vars) if abort: break return res return resf ================================================ FILE: Contents/Services/Shared Code/video.pys ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2014, KOL # All rights reserved. # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # * Neither the name of the nor the # names of its contributors may be used to endorse or promote products # derived from this software without specific prior written permission. # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTLICULAR PURPOSE ARE # DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from datetime import timedelta from jsinterp import JSInterpreter DEFINITIONS = { 'sd': (36, 18), 'hd': (22, 18, 36), } RESOLUTIONS = { 22: 720, 18: 360, 36: 240, 160: 144, 133: 240, 134: 360, 135: 480, 136: 720, 298: 720.60, 137: 1080, 299: 1080.60, } AUDIO = { 141: 256, 140: 128, 139: 48, } MAX_RESOLUTION = 22 USER_AGENT = ( 'Mozilla/5.0 (iPad; CPU OS 7_0_4 like Mac OS X) ' 'AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 ' 'Mobile/11B554a Safari/9537.54' ) RE_DATA = Regex('ytplayer.config\s*=\s*({(?:.+)});\s*(?:ytplayer.web_player_context_config|ytplayer.load)\s*=') def GetServiceURL(vid, token='', hl=''): return 'http://tv.youtube.plugins.plex.com/%s%s%s%s%s' % ( vid, '&access_token=' if token else '', token, '&hl=' if hl else '', hl ) def GetMetaUrlByServiceURL(url): return 'https://www.youtube.com/watch?v=%s' % GetOID(url) def GetThumb(vid): return 'https://i.ytimg.com/vi/%s/hqdefault.jpg' % vid def GetVideoData(url): try: res = HTML.ElementFromURL(GetMetaUrlByServiceURL(url)) except: raise Ex.MediaNotAvailable ret = {} try: ret = JSON.ObjectFromString(RE_DATA.search( res.xpath( '//script[contains(., "ytplayer.config")]' )[0].text_content() ).group(1)) except Exception as e: Log.Error('Cannot get video data: %s' % e) raise Ex.MediaNotAvailable try: cont = res.xpath('//div[@id="watch7-container"]')[0] ret['date_published'] = cont.xpath( '//meta[@itemprop="datePublished"]' )[0].get('content') cont = cont.xpath( '//div[@id="watch-description-text"]' )[0] for br in cont.xpath('*//br'): br.tail = '\n' + br.tail if br.tail else '\n' ret['description'] = cont.text_content() except: pass return ret def GetOID(url): return url[url.rfind('/')+1:] def MetaFromInfo(item): try: return item['args'] except: return None def GetFeedVid(item): if 'video_id' in item: return item['video_id'] if 'encrypted_id' in item: return item['encrypted_id'] if item['item_type'] == 'shelf': return item['content']['items'][0]['encrypted_id'] return None def GetVideoUrls(url): info = GetVideoData(url) meta = MetaFromInfo(info) try: player_url = info['assets']['js'] except: player_url = None ret = {} # Live stream if 'hlsvp' in meta and meta['hlsvp']: Log.Debug('Parse playlist') try: res = HTTP.Request(meta['hlsvp']).content.splitlines() except: res = None if res: fmt_re = Regex('^#EXT-X-STREAM-INF.+,RESOLUTION=[0-9]+x([0-9]+),') index = range(len(res)) res_map = dict(map(reversed, RESOLUTIONS.items())) for i in index: match = fmt_re.search(res[i]) if match: resolution = int(match.group(1)) i = i+1 if resolution in res_map: ret[res_map[resolution]] = res[i] # Next line does need to processing index.remove(i) # Normal video elif 'url_encoded_fmt_stream_map' in meta: # High definition key = 'url_encoded_fmt_stream_map' if 'adaptive_fmts' and IsAdaptiveSupport(): key = 'adaptive_fmts' for item in meta[key].split(','): item = dict(map( lambda x: (x[0], x[1][0]), String.ParseQueryString(item).items() )) itag = int(item['itag']) if itag in RESOLUTIONS or itag in AUDIO: ret[itag] = GetUrlFromStream(item, player_url) elif 'player_response' in meta: player_response = JSON.ObjectFromString(meta['player_response']) if 'streamingData' in player_response: streaming_data = player_response['streamingData'] if 'formats' in streaming_data: for item in streaming_data['formats']: itag = item['itag'] if itag in RESOLUTIONS or itag in AUDIO: ret[itag] = GetUrlFromStream(item, player_url) if not len(ret): raise Ex.MediaNotAvailable return ret def ParseLinksFromDescription(text): re = Regex( ( r'^(.+)[\s\n]*(https?://(?:www\.|m\.)?youtu(?:be\.com|\.be)/' '(?!account(?:_|\?)?|artist(?:/|\?)|blog\?|categories\?|' 'channels\?|charts|embed/(?:__videoid__|videoseries\?)|p/|' 'profile\?|results\?|subscribe_widget|\?tab=).+)' ), Regex.MULTILINE ) return re.findall(text) def GetUrlFromStream(item, player_url): ret = item['url'] if player_url and 's' in item: ret = '%s&signature=%s' % (ret, DecryptSignature(item['s'], player_url)) return ret def IsAdaptiveSupport(): return False def DecryptSignature(s, player_url): """Turn the encrypted s field into a working signature""" try: # hasattr workaround len(DecryptSignature.cache) except: DecryptSignature.cache = {} def signature_cache_id(example_sig): return '.'.join(str(len(part)) for part in example_sig.split('.')) def parse_sig_js(jscode): # These regexes are pulled from the youtube-dl source. Credit to rg3 sig_regexes = (r'(["\'])signature\1\s*,\s*(?P[a-zA-Z0-9$]+)\(', r'\.sig\|\|(?P[a-zA-Z0-9$]+)\(', r'yt\.akamaized\.net/\)\s*\|\|\s*.*?\s*c\s*&&\s*d\.set\([^,]+\s*,\s*(?P[a-zA-Z0-9$]+)\(', r'\bc\s*&&\s*d\.set\([^,]+\s*,\s*(?P[a-zA-Z0-9$]+)\(', r'\bc\s*&&\s*d\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P[a-zA-Z0-9$]+)\(') searches = [Regex(rx).search(jscode) for rx in sig_regexes] for search in searches: if not search is None: funcname = search.group('sig') break jsi = JSInterpreter(jscode) initial_function = jsi.extract_function(funcname) return lambda s: initial_function([s]) if player_url.startswith('//'): player_url = 'https:' + player_url elif player_url.startswith('/'): player_url = 'https://www.youtube.com' + player_url try: player_id = (player_url, signature_cache_id(s)) if player_id not in DecryptSignature.cache: code = HTTP.Request(player_url).content DecryptSignature.cache[player_id] = parse_sig_js(code) return DecryptSignature.cache[player_id](s) except Exception as e: Log.Error('Cannot decrypt signature: %s' % e) return '' def ParseDuration(durationstr): ''' Original on https://bitbucket.org/nielsenb/aniso8601 ''' def parse_duration_element(durationstr, elementstr): #Extracts the specified portion of a duration, for instance, given: #durationstr = 'T4H5M6.1234S' #elementstr = 'H' # #returns 4 # #Note that the string must start with a character, so its assumed the #full duration string would be split at the 'T' durationstartindex = 0 durationendindex = durationstr.find(elementstr) for characterindex in xrange(durationendindex - 1, 0, -1): if durationstr[characterindex].isalpha() == True: durationstartindex = characterindex break durationstartindex += 1 if ',' in durationstr: #Replace the comma with a 'full-stop' durationstr = durationstr.replace(',', '.') return float(durationstr[durationstartindex:durationendindex]) #durationstr can be of the form PnYnMnDTnHnMnS or PnW #Make sure only the lowest order element has decimal precision if durationstr.count('.') > 1: raise ValueError('String is not a valid ISO8601 duration.') elif durationstr.count('.') == 1: #There should only ever be 1 letter after a decimal if there is more #then one, the string is invalid lettercount = 0; for character in durationstr.split('.')[1]: if character.isalpha() == True: lettercount += 1 if lettercount > 1: raise ValueError('String is not a valid ISO8601 duration.') #Parse the elements of the duration if durationstr.find('T') == -1: if durationstr.find('Y') != -1: years = parse_duration_element(durationstr, 'Y') else: years = 0 if durationstr.find('M') != -1: months = parse_duration_element(durationstr, 'M') else: months = 0 if durationstr.find('W') != -1: weeks = parse_duration_element(durationstr, 'W') else: weeks = 0 if durationstr.find('D') != -1: days = parse_duration_element(durationstr, 'D') else: days = 0 #No hours, minutes or seconds hours = 0 minutes = 0 seconds = 0 else: firsthalf = durationstr[:durationstr.find('T')] secondhalf = durationstr[durationstr.find('T'):] if firsthalf.find('Y') != -1: years = parse_duration_element(firsthalf, 'Y') else: years = 0 if firsthalf.find('M') != -1: months = parse_duration_element(firsthalf, 'M') else: months = 0 if durationstr.find('W') != -1: weeks = parse_duration_element(durationstr, 'W') else: weeks = 0 if firsthalf.find('D') != -1: days = parse_duration_element(firsthalf, 'D') else: days = 0 if secondhalf.find('H') != -1: hours = parse_duration_element(secondhalf, 'H') else: hours = 0 if secondhalf.find('M') != -1: minutes = parse_duration_element(secondhalf, 'M') else: minutes = 0 if secondhalf.find('S') != -1: seconds = parse_duration_element(secondhalf, 'S') else: seconds = 0 #Note that weeks can be handled without conversion to days totaldays = years * 365 + months * 30 + days return int(timedelta( weeks=weeks, days=totaldays, hours=hours, minutes=minutes, seconds=seconds ).total_seconds()) ================================================ FILE: Contents/Services/URL/YouTubeTV/ServiceCode.pys ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2014, KOL # All rights reserved. # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # * Neither the name of the nor the # names of its contributors may be used to endorse or promote products # derived from this software without specific prior written permission. # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE # DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from urlparse import urlparse import video as Video RE_PATH_1 = Regex('^/(watch|details)(_popup)?') RE_PATH_2 = Regex('^/[^/]+$') RE_VID_ID = Regex('/(v|e(mbed)?)/(v/)?(?P.{11})') RE_VID_PID = Regex('pid=(.{11})') RE_VIDEO_ID = Regex('"video_id":\s"([^"]+)') ############################################################################ def NormalizeURL(url): ''' This modified version from Services.bundle ''' if 'tv.youtube.plugins.plex.com' in url: return url video_id = None if isinstance(url, basestring): url = urlparse(url) domain = url.netloc # http://www.youtube.com/?v=lgTssWW2Qw4&feature=player_embedded # http://www.youtube.com/watch?v=lgTssWW2Qw4&feature=player_embedded # http://www.youtube.com/details?v=pjyfMCTAqKU if url.path == '/' or RE_PATH_1.match(url.path): qs = String.ParseQueryString(url.query) if 'v' in qs: video_id = qs['v'][0][0:11] # http://youtu.be/vXhmyyXFd5I elif domain == 'youtu.be': video_id = url.path[1:12] # http://www.youtube.com/user/andyverg#p/a/u/0/HTGOoQNYGL4 elif (url.path[0:6] == '/user/' or RE_PATH_2.match(url.path)) and url.fragment != '': video_id = url.fragment.split('/')[-1] # http://www.youtube.com/user/fujitvlive?feature=lb&v=eYpAUMZLXqo elif url.path[0:6] == '/user/' and "v=" in url.query: qs = String.ParseQueryString(url.query) if 'v' in qs: video_id = qs['v'][0][0:11] # http://www.youtube.com/v/taaSlWjKdDY # http://www.youtube.com/e/NO00y65njH0 # http://www.youtube.com/embed/nd5WGLWNllA elif url.path[0:3] == '/v/' or url.path[0:3] == '/e/' or url.path[0:7] == '/embed/': video_id = RE_VID_ID.search(url.path).group('id') # http://www.youtube.com/my_subscriptions?pid=nCgQDjiotG0&show_edit=1&feature=uploadeemail_edit elif url.path[0:17] == '/my_subscriptions' and url.query != '': video_id = RE_VID_PID.findall(url.query)[0] # http://www.youtube.com/movie/the-last-man-on-earth elif url.path[0:7] == '/movie/': url = HTML.ElementFromURL(url.geturl()).xpath( '//a[contains(@href, "watch-now-button")]' ) if url: url = urlparse(url[0].get('href')) qs = String.ParseQueryString(url.query) if 'v' in qs: video_id = qs['v'][0][0:11] if video_id is None: page = HTTP.Request(url.geturl()).content id = HTML.ElementFromString(page).xpath( '//div[@data-video-id]/@data-video-id' ) if id: video_id = id[0] else: id = RE_VIDEO_ID.search(page) if id: video_id = id.group(1) else: return None return Video.GetServiceURL(video_id) def MetadataObjectForURL(url): item = Video.GetVideoData(url) meta = Video.MetaFromInfo(item) if not meta: raise Ex.MediaNotAvailable return VideoClipObject( title=u'%s' % meta['title'] if 'title' in meta else '', rating_key=url.split('&')[0], summary=u'%s\n%s' % ( meta['author'] if 'author' in meta else '', item['description'] if 'description' in item else '', ), thumb=Video.GetThumb(meta['video_id'] if 'video_id' in meta else ''), rating=(float(meta['avg_rating'])*2 if 'avg_rating' in meta else 0.0), tags=(item['keywords'] if 'keywords' in item else '').split(','), duration=int(meta['length_seconds'])*1000 if 'length_seconds' in meta else None, originally_available_at=Datetime.ParseDate( item['date_published'] ) if 'date_published' in item else None, ) def MediaObjectsForURL(url, definition='hd'): definitions = Video.DEFINITIONS[definition] if Prefs['play_highest']: definitions = (Video.MAX_RESOLUTION,) return [ MediaObject( parts=[ PartObject(key=Callback(PlayVideo, url=url, fmt=key)) ], video_resolution=Video.RESOLUTIONS[key], container=Container.MP4, video_codec=VideoCodec.H264, audio_codec=AudioCodec.AAC, optimized_for_streaming=True ) for key in definitions ] @indirect def PlayVideo(url, fmt): info = Video.GetVideoUrls(url) fmt = int(fmt) if fmt not in info: Log.Debug('Get default format') for fmt, v in sorted( Video.RESOLUTIONS.items(), lambda x,y: cmp(x[1], y[1]), reverse=True ): if fmt in info: break Log.Debug('Play itag: %d' % fmt) # Live stream if info[fmt][-5:] == '.m3u8': info[fmt] = HTTPLiveStreamURL(info[fmt]) return IndirectResponse(VideoClipObject, key=info[fmt]) ================================================ FILE: Contents/Services/URL/YouTubeTV/ServicePrefs.json ================================================ [ { "id": "play_highest", "type": "bool", "label": "Always play highest quality", "default": "false", }, ] ================================================ FILE: Contents/Strings/da.json ================================================ { "Error": "Fejl", "Next page": "Næste side", "No entries found": "Intet fundet", "Search": "Søg", "Search Video": "Søg Video", "Success": "Success", "Title": "YouTubeTV", "Search HD only": "Søg kun efter HD", "Items per page": "Elementer pr. side", "likes": "Liked videoer", "favorites": "Fortrukne", "uploads": "Videos", "watchHistory": "Historie", "watchLater": "Se senere", "Authorize": "Godkend", "You must enter code for continue": "Du skal indtaste en kode for at fortsætte", "My Subscriptions": "Mine abbonementer", "What to Watch": "Hvad skal der ses", "Playlists": "Afspille liste", "My channel": "Min kanal", "Channels": "Kanaler", "Categories": "Kategorier", "Subscriptions": "Abbonementer", "More playlists": "Flere afspille lister", "More subscriptions": "Flere abbonementer", "Complete authorization": "Færdigør godkendelse", "Service temporarily unavailable": "Service er midlertidigt ude af drift", "Preferred Region for Videos and channels": "Foretrukken region for Videoer og kanaler", "Preferred language for Videos and channels": "Fortrukken sprog for videoer og kanaler", "Search playlists": "Søg afspille liste", "Search channels": "Søg kanaler", "Browse channels": "Gennemse kanaler", "enterCodeSite": "Venligst, indtast kode \"%s\" i %s", "codeIs": "Kode: %s", "Play video": "Afspil video", "Related videos": "Relaterede videoer", "I like this": "Synes godt om", "Add to favorites": "Tilføj til fortrukne", "Extended video info in My subscriptions": "Udvidet video info i mine abbonementer", "Extended video info in Playlists": "Udvidet video info i afspille lister", "Extended video info in Categories": "Udvidet video information for kategorier", "Extended video info in Search": "Udvidet video info i søgninger", "Update available: %s": "Opdatering tilgængelig: %s", "Install latest version of the channel.": "Installer seneste version af denne kanal.", "Channel updated to version %s": "Kanalen er opdateret til version %s", "Remove from playlist": "Fjern fra afspille kø", "Action complete": "Opgave fuldført", "An error has occurred": "Der er opstået en fejl", "Sign out": "Log ud", "Subscriptions list sorting order": "Abonnements liste sorterings order " } ================================================ FILE: Contents/Strings/de.json ================================================ { "Error": "Fehler", "Next page": "Nächste Seite", "No entries found": "Keine Einträge gefunden", "Search": "Suche", "Search Video": "Suche Video", "Success": "Erfolg", "Title": "YouTubeTV", "Search HD only": "Nur HD suchen", "Items per page": "Einträge pro Seite", "likes": "Positiv bewertete Videos", "favorites": "Favoriten", "uploads": "Videos", "watchHistory": "Verlauf", "watchLater": "Später ansehen", "Authorize": "Genehmigen", "You must enter code for continue": "Zum fortfahren Code eingeben", "My Subscriptions": "Meine Abos", "What to Watch": "Empfohlene Videos ", "Playlists": "Playlists", "My channel": "Mein Kanal", "Channels": "Kanäle", "Categories": "Kategorien", "Subscriptions": "Abos", "More playlists": "Mehr Playlists", "More subscriptions": "Mehr Abonnements", "Complete authorization": "Genehmigung abschließen ", "Service temporarily unavailable": "Service temporär nicht verfügbar", "Preferred Region for Videos and channels": "Bevorzugte Region für Videos und Kanäle", "Preferred language for Videos and channels": "Bevorzugte Sprache für Videos und Kanäle", "Search playlists": "Suche Playlists", "Search channels": "Suche Kanäle", "Browse channels": "Kanäle durchsuchen", "enterCodeSite": "Bitte Code \"%s\" in %s eingeben", "codeIs": "Code: %s", "Play video": "Video abspielen", "Related videos": "Videovorschläge", "I like this": "Mag Ich", "Add to favorites": "Zu Favoriten hinzufügen", "Extended video info in My subscriptions": "Erweiterte Video Info in \"Meine Abos\"", "Extended video info in Playlists": "Erweiterte Video Info in \"Playlists\"", "Extended video info in Categories": "Erweiterte Video Info in \"Kategorien\"", "Extended video info in Search": "Erweiterte Video Info in \"Suche\"", "Update available: %s": "Update verfügbar: %s", "Install latest version of the channel.": "Installiere die aktuelle Version des Kanals.", "Channel updated to version %s": "Kanal Aktualisierung auf Version %s", "Remove from playlist": "Von Playlist entfernen", "Action complete": "Aktion vollständig", "An error has occurred": "Ein Fehler ist aufgetreten", "Sign out": "Abmelden", "Subscriptions list sorting order": "Sortierreihenfolge meiner Abos" } ================================================ FILE: Contents/Strings/en.json ================================================ { "Error": "Error", "Next page": "Next page", "No entries found": "No entries found", "Search": "Search", "Search Video": "Search Video", "Success": "Success", "Title": "YouTubeTV", "Search HD only": "Search HD only", "Items per page": "Items per page", "likes": "Liked videos", "favorites": "Favorites", "uploads": "Videos", "watchHistory": "History", "watchLater": "Watch Later", "Authorize": "Authorize", "You must enter code for continue": "You must enter code for continue", "My Subscriptions": "My Subscriptions", "What to Watch": "What to Watch", "Playlists": "Playlists", "My channel": "My channel", "Channels": "Channels", "Categories": "Categories", "Subscriptions": "Subscriptions", "More playlists": "More playlists", "More subscriptions": "More subscriptions", "Complete authorization": "Complete authorization", "Service temporarily unavailable": "Service temporarily unavailable", "Preferred Region for Videos and channels": "Preferred Region for Videos and channels", "Preferred language for Videos and channels": "Preferred language for Videos and channels", "Search playlists": "Search playlists", "Search channels": "Search channels", "Browse channels": "Browse channels", "enterCodeSite": "Please, enter code \"%s\" in %s", "codeIs": "Code: %s", "Play video": "Play video", "Related videos": "Related videos", "I like this": "I like this", "Add to favorites": "Add to favorites", "Extended video info in My subscriptions": "Extended video info in My subscriptions", "Extended video info in Playlists": "Extended video info in Playlists", "Extended video info in Categories": "Extended video info in Categories", "Extended video info in Search": "Extended video info in Search", "Update available: %s": "Update available: %s", "Install latest version of the channel.": "Install latest version of the channel.", "Channel updated to version %s": "Channel updated to version %s", "Remove from playlist": "Remove from playlist", "Action complete": "Action complete", "An error has occurred": "An error has occurred", "Sign out": "Sign out", "Subscriptions list sorting order": "Subscriptions list sorting order", "Always play highest quality": "Always play highest quality", "Search Channel": "Search Channel" } ================================================ FILE: Contents/Strings/es.json ================================================ { "Error": "Error", "Next page": "Página siguiente", "No entries found": "No se han encontrado entradas", "Search": "Búsqueda", "Search Video": "Buscar vídeo", "Success": "Éxito", "Title": "YouTubeTV", "Search HD only": "Buscar solo en HD", "Items per page": "Objetos por página", "likes": "Vídeos que te gustan", "favorites": "Favoritos", "uploads": "Vídeos", "watchHistory": "Historia", "watchLater": "Ver más tarde", "Authorize": "Autorizar", "You must enter code for continue": "Tienes que introducir un código para continuar", "My Subscriptions": "Mis subscriptores", "What to Watch": "Qué ver", "Playlists": "Lista de reproducción", "My channel": "Mi canal", "Channels": "Canales", "Categories": "Categorías", "Subscriptions": "Subscripciones", "More playlists": "Más listas de reproducción", "More subscriptions": "Más suscripciones", "Complete authorization": "Completar autorización", "Service temporarily unavailable": "Servicio no disponible temporalmente", "Preferred Region for Videos and channels": "Región preferida para vídeos y canales", "Preferred language for Videos and channels": "Idioma preferido para Vídeos y canales", "Search playlists": "Buscar listas de reproducción", "Search channels": "Buscar canales", "Browse channels": "Explorar canales", "enterCodeSite": "Por favor, introduce el código \"%s\" en %s", "codeIs": "Código: %s", "Play video": "Reproducir vídeo", "Related videos": "Vídeos relacionados", "I like this": "Me gusta esto", "Add to favorites": "Añadir a favoritos", "Extended video info in My subscriptions": "Información ampliada del vídeo en Mis suscripciones", "Extended video info in Playlists": "Información ampliada del vídeo en Listas de reproducción", "Extended video info in Categories": "Información ampliada del vídeo en Categorías", "Extended video info in Search": "Información ampliada del vídeo en Buscar", "Update available: %s": "Actualización disponible: %s", "Install latest version of the channel.": "Instalar la última versión del canal.", "Channel updated to version %s": "Canal actualizado a la versión %s", "Remove from playlist": "Eliminar de la lista de reproducción", "Action complete": "Completar acción", "An error has occurred": "Ha ocurrido un error", "Sign out": "Cerrar sesión", "Subscriptions list sorting order": "Orden de clasificación de la lista de suscripciones" } ================================================ FILE: Contents/Strings/fr-BE.json ================================================ { "Error": "Erreur", "Next page": "Page suivante", "No entries found": "Aucune entrée n'a été trouvée", "Search": "Rechercher", "Search Video": "Rechercher une vidéo", "Success": "Succès", "Title": "YouTubeTV", "Search HD only": "Rechercher uniquement en haute définition", "Items per page": "Nombre d'éléments par page", "likes": "Vidéos que vous aimez", "favorites": "Favoris", "uploads": "Vidéos", "watchHistory": "Historique", "watchLater": "Á regarder plus tard", "Authorize": "Autoriser", "You must enter code for continue": "Vous devez introduire un code pour pouvoir continuer", "My Subscriptions": "Mes abonnements", "What to Watch": "Que regarder ?", "Playlists": "Listes de lecture", "My channel": "Ma chaîne", "Channels": "Chaînes", "Categories": "Catégories", "Subscriptions": "Abonnements", "More playlists": "Plus de listes de lecture", "More subscriptions": "Plus d'abonnements", "Complete authorization": "Finaliser l'autorisation", "Service temporarily unavailable": "Service temporairement indisponible", "Preferred Region for Videos and channels": "Région de prédilection pour les vidéos et les chaînes", "Preferred language for Videos and channels": "Langue de prédilection pour les vidéos et les chaînes", "Search playlists": "Rechercher dans les listes de lecture", "Search channels": "Rechercher dans les chaînes", "Browse channels": "Parcourir les chaînes", "enterCodeSite": "Veuillez introduire le code \"%s\" sur %s", "codeIs": "Code : %s", "Play video": "Lire la vidéo", "Related videos": "Vidéos suggérées", "I like this": "J'aime ce contenu", "Add to favorites": "Ajouter aux favoris", "Extended video info in My subscriptions": "Informations étendues pour les vidéos de mes abonnements", "Extended video info in Playlists": "Informations étendues pour les vidéos des listes de lecture", "Extended video info in Categories": "Informations étendues pour les vidéos des catégories", "Extended video info in Search": "Informations étendues pour les vidéos de mes recherches", "Update available: %s": "Mise à jour disponible : %s", "Install latest version of the channel.": "Installer la dernière version de la chaîne.", "Channel updated to version %s": "Chaîne mise à jour vers la version %s", "Remove from playlist": "Supprimer de la liste de lecture", "Action complete": "Action terminée", "An error has occurred": "Une erreur est survenue", "Sign out": "Déconnexion", "Subscriptions list sorting order": "Ordre de tri de la liste des abonnements", "Always play highest quality": "Toujours utiliser la meilleurs qualité", "Search Channel": "Rechercher dans les chaînes" } ================================================ FILE: Contents/Strings/fr.json ================================================ { "Error": "Erreur", "Next page": "Page suivante", "No entries found": "Aucune entrée n'a été trouvée", "Search": "Rechercher", "Search Video": "Rechercher une vidéo", "Success": "Succès", "Title": "YouTubeTV", "Search HD only": "Rechercher uniquement en haute définition", "Items per page": "Nombre d'éléments par page", "likes": "Vidéos que vous aimez", "favorites": "Favoris", "uploads": "Vidéos", "watchHistory": "Historique", "watchLater": "Á regarder plus tard", "Authorize": "Autoriser", "You must enter code for continue": "Vous devez introduire un code pour pouvoir continuer", "My Subscriptions": "Mes abonnements", "What to Watch": "Que regarder ?", "Playlists": "Listes de lecture", "My channel": "Ma chaîne", "Channels": "Chaînes", "Categories": "Catégories", "Subscriptions": "Abonnements", "More playlists": "Plus de listes de lecture", "More subscriptions": "Plus d'abonnements", "Complete authorization": "Finaliser l'autorisation", "Service temporarily unavailable": "Service temporairement indisponible", "Preferred Region for Videos and channels": "Région de prédilection pour les vidéos et les chaînes", "Preferred language for Videos and channels": "Langue de préférence pour les vidéos et les chaînes", "Search playlists": "Rechercher dans les listes de lecture", "Search channels": "Rechercher dans les chaînes", "Browse channels": "Parcourir les chaînes", "enterCodeSite": "Veuillez introduire le code \"%s\" sur %s", "codeIs": "Code : %s", "Play video": "Lire la vidéo", "Related videos": "Vidéos suggérées", "I like this": "J'aime ce contenu", "Add to favorites": "Ajouter aux favoris", "Extended video info in My subscriptions": "Informations étendues pour les vidéos de mes abonnements", "Extended video info in Playlists": "Informations étendues pour les vidéos des listes de lecture", "Extended video info in Categories": "Informations étendues pour les vidéos des catégories", "Extended video info in Search": "Informations étendues pour les vidéos de mes recherches", "Update available: %s": "Mise à jour disponible : %s", "Install latest version of the channel.": "Installer la dernière version de la chaîne.", "Channel updated to version %s": "Chaîne mise à jour vers la version %s", "Remove from playlist": "Supprimer de la liste de lecture", "Action complete": "Action terminée", "An error has occurred": "Une erreur est survenue", "Sign out": "Déconnexion", "Subscriptions list sorting order": "Ordre de tri de la liste des abonnements", "Always play highest quality": "Toujours utiliser la meilleurs qualité", "Search Channel": "Rechercher dans les chaînes" } ================================================ FILE: Contents/Strings/hu.json ================================================ { "Error": "Hiba", "Next page": "Következő oldal", "No entries found": "Nem találhatók bejegyzések", "Search": "Keresés", "Search Video": "Videó keresés", "Success": "Sikeres", "Title": "YouTubeTV", "Search HD only": "Csak HD keresés", "Items per page": "Elem laponként", "likes": "Hivatkozott videók", "favorites": "Kedvencek", "uploads": "Videók", "watchHistory": "Előzmények", "watchLater": "Megnézés később", "Authorize": "Engedélyez", "You must enter code for continue": "Írja be a kódot a folytatáshoz", "My Subscriptions": "Saját előfizetések", "What to Watch": "Mit néz", "Playlists": "Lejátszási lista", "My channel": "Saját csatorna", "Channels": "Csatornák", "Categories": "Kategóriák", "Subscriptions": "Előfizetések", "More playlists": "További lejátszási listák", "More subscriptions": "További előfizetések", "Complete authorization": "Teljes engedélyezés", "Service temporarily unavailable": "A szolgáltatás átmenetileg nem elérhető", "Preferred Region for Videos and channels": "Preferált régiók a videók és a csatornák között", "Preferred language for Videos and channels": "Preferált nyelvek videóknál és csatornáknál", "Search playlists": "Lejátszási listák keresése", "Search channels": "Csatornák keresése", "Browse channels": "Böngészés a csatornák közt", "enterCodeSite": "Kérjük írja be a kódot \"%s\" itt %s", "codeIs": "Kód: %s", "Play video": "Videó lejátszása", "Related videos": "Hasonló videók", "I like this": "Ez tetszik", "Add to favorites": "Hozzáadás a Kedvencekhez", "Extended video info in My subscriptions": "Kibővített videó információ a Saját előfizetésekben", "Extended video info in Playlists": "Kibővített videó információ a Lejátszási listákban", "Extended video info in Categories": "Kibővített videó információ a Kategóriákban", "Extended video info in Search": "Kibővített videó információ a Keresésekben", "Update available: %s": "Frissítés elérhető: %s", "Install latest version of the channel.": "A csatorna legújabb verziójának telepítése", "Channel updated to version %s": "A csatorna frissítve %s verzióra", "Remove from playlist": "Eltávolítás a lejátszási listáról", "Action complete": "Művelet befejezve", "An error has occurred": "Hiba történt", "Sign out": "Kijelentkezés", "Subscriptions list sorting order": "Előfizetői lista rendezés" } ================================================ FILE: Contents/Strings/it.json ================================================ { "Error": "Errore", "Next page": "Pagina successiva", "No entries found": "Nessuna voce trovata", "Search": "Cerca", "Search Video": "Cerca video", "Success": "Successo", "Title": "YouTubeTV", "Search HD only": "Cerca solo in HD", "Items per page": "Voci per pagina", "likes": "Video correlati", "favorites": "Favoriti", "uploads": "Video", "watchHistory": "Cronologia", "watchLater": "Guarda più tardi", "Authorize": "Autorizza", "You must enter code for continue": "Devi inserire il codice per proseguire", "My Subscriptions": "Le mie iscrizioni", "What to Watch": "Cosa guardare", "Playlists": "Playlists", "My channel": "Il mio canale", "Channels": "Canali", "Categories": "Categorie", "Subscriptions": "Iscrizioni", "More playlists": "Altre playlists", "More subscriptions": "Altre iscrizioni", "Complete authorization": "Completa autorizzazione", "Service temporarily unavailable": "Servizio temporaneamente non disponibile", "Preferred Region for Videos and channels": "Regione preferita per video e canali", "Preferred language for Videos and channels": "Lingua preferita per i Video e i canali", "Search playlists": "Cerca playlists", "Search channels": "Cerca canali", "Browse channels": "Sfoglia i canali", "enterCodeSite": "Per favore, inserire il codice \"%s\" in %s", "codeIs": "Codice: %s", "Play video": "Play video", "Related videos": "Video correlati", "I like this": "Mi piace", "Add to favorites": "Aggiungi ai preferiti", "Extended video info in My subscriptions": "Info estese dei video in Le mie iscrizioni", "Extended video info in Playlists": "Info estese dei video in Playlists", "Extended video info in Categories": "Info estese dei video in Categorie", "Extended video info in Search": "Info estese in Cerca", "Update available: %s": "Aggiornamento disponibile: %s", "Install latest version of the channel.": "Installa l'ultima versione del canale.", "Channel updated to version %s": "Canale aggiornato alla versione %s", "Remove from playlist": "Rimuovi dalla playlist", "Action complete": "Azione completata", "An error has occurred": "Si è verificato un errore", "Sign out": "Disconnetti", "Subscriptions list sorting order": "Ordinamento della lista delle iscrizioni" } ================================================ FILE: Contents/Strings/nl.json ================================================ { "Error": "Fout", "Next page": "Volgende pagina", "No entries found": "Geen items gevonden", "Search": "Zoeken", "Search Video": "Video zoeken", "Success": "Gelukt", "Title": "YouTubeTV", "Search HD only": "Zoek alleen HD", "Items per page": "Items per pagina", "likes": "Video's die ik leuk vind", "favorites": "Favorieten", "uploads": "Video's", "watchHistory": "Geschiedenis", "watchLater": "Later bekijken", "Authorize": "Machtigen", "You must enter code for continue": "Voer de code in om door te gaan", "My Subscriptions": "Mijn abonnementen", "What to Watch": "Wat je kunt bekijken", "Playlists": "Afspeellijsten", "My channel": "Mijn kanaal", "Channels": "Kanalen", "Categories": "Categorieën", "Subscriptions": "Abonnementen", "More playlists": "Meer afspeellijsten", "More subscriptions": "Meer abonnementen", "Complete authorization": "Machtiging voltooien", "Service temporarily unavailable": "Service tijdelijk niet beschikbaar", "Preferred Region for Videos and channels": "Voorkeursregio voor video's en kanalen", "Preferred language for Videos and channels": "Voorkeurstaal voor video's en kanalen", "Search playlists": "Zoek afspeellijsten", "Search channels": "Zoek kanalen", "Browse channels": "Browse door kanalen", "enterCodeSite": "Voer code \"%s\" in %s", "codeIs": "Code: %s", "Play video": "Speel video af", "Related videos": "Gerelateerde video's", "I like this": "Ik vind dit leuk", "Add to favorites": "Toevoegen aan favorieten", "Extended video info in My subscriptions": "Uitgebreide video info in Mijn abonnementen", "Extended video info in Playlists": "Uitgebreide video info in Afspeellijsten", "Extended video info in Categories": "Uitgebreide video info in Zoekresultaten", "Extended video info in Search": "Uitgebreide video info in Zoekresultaten", "Update available: %s": "Update beschikbaar: %s", "Install latest version of the channel.": "Installeer meest recente versie van dit kanaal", "Channel updated to version %s": "Kanaal is geüpdatet naar versie %s", "Remove from playlist": " Verwijder uit afspeellijst", "Action complete": "Actie voltooid", "An error has occurred": "Er is een fout opgetreden", "Sign out": "Uitloggen", "Subscriptions list sorting order": "Subscriptions list sorting order" } ================================================ FILE: Contents/Strings/pl.json ================================================ { "Error": "Błąd", "Next page": "Następna strona", "No entries found": "Nie znaleziono wpisów", "Search": "Szukaj", "Search Video": "Szukaj filmów", "Success": "Sukces", "Title": "YouTubeTV", "Search HD only": "Szukaj tylko w HD", "Items per page": "Ilość na stronie", "likes": "Polubione Filmy", "favorites": "Ulubione", "uploads": "Filmy", "watchHistory": "Historia", "watchLater": "Obejrzyj później", "Authorize": "Autoryzuj", "You must enter code for continue": "Musisz wprowadzić kod żeby kontynuować", "My Subscriptions": "Moje subscrybcje", "What to Watch": "Co oglądać", "Playlists": "Lista odtwarzania", "My channel": "Mój kanał", "Channels": "Kanały", "Categories": "Categorie", "Subscriptions": "Subskrybcja", "More playlists": "Więcej list odtwarzania", "More subscriptions": "Więcej Subskrypcji ", "Complete authorization": "Pełna autoryzacja ", "Service temporarily unavailable": "Serwis chwilowo niedostępny", "Preferred Region for Videos and channels": "Preferowany Region dla wideo i kanałów ", "Preferred language for Videos and channels": "Preferowany język dla Filmów i Kanałów ", "Search playlists": "Szukaj list odtwarzania", "Search channels": "Szukaj kanałow", "Browse channels": "Przeglądaj kanały", "enterCodeSite": "Proszę wprowadzić kod \"%s\" w %s", "codeIs": "Kod: %s", "Play video": "Odtwórz", "Related videos": "Powiązane filmy", "I like this": "Lubie to", "Add to favorites": "Dodaj do ulubionych", "Extended video info in My subscriptions": "Rozszerzona informacja o wideo w Moich Subscrybcjach", "Extended video info in Playlists": "Rozszerzona informacja o wideo w Liscie Odtwarzania", "Extended video info in Categories": "Rozrzeszona informacja o wideo w Kategoriach", "Extended video info in Search": "Rozszerzona informacja o wyszukiwanych filmach", "Update available: %s": "Aktualizacja dostępna %s", "Install latest version of the channel.": "Zainstaluj najnowsza wersje kanału.", "Channel updated to version %s": "Kanał uaktualniony do wersji %s", "Remove from playlist": "Usuń z listy odtwarzania", "Action complete": "Zakończono", "An error has occurred": "Wystąpił błąd", "Sign out": "Wyloguj się", "Subscriptions list sorting order": "Posortowana lista subscrybcji" } ================================================ FILE: Contents/Strings/ru.json ================================================ { "Error": "Ошибка", "Next page": "Далее", "No entries found": "Ничего не нашлось", "Search": "Поиск", "Search Video": "Поиск видеозаписей", "Success": "Успех", "Title": "YouTubeTV", "Search HD only": "Искать только в HD", "Items per page": "Элементов на страницу", "likes": "Понравившиеся", "favorites": "Избранное", "uploads": "Видео", "watchHistory": "Просмотренные", "watchLater": "Посмотреть позже", "Authorize": "Авторизация", "You must enter code for continue": "Вы должны ввести код для продолжения", "My Subscriptions": "Мои подписки", "What to Watch": "Рекомендации", "Playlists": "Плейлисты", "My channel": "Мой канал", "Channels": "Каналы", "Categories": "Каталог", "Subscriptions": "Подписки", "More playlists": "Еще плейлисты", "More subscriptions": "Еще подписки", "Complete authorization": "Завершить авторизацию", "Service temporarily unavailable": "Сервис временно недоступен", "Preferred Region for Videos and channels": "Ваш регион", "Preferred language for Videos and channels": "Ваш язык", "Search playlists": "Искать плейлисты", "Search channels": "Поиск каналов", "Browse channels": "Каталог каналов", "enterCodeSite": "Пожалуйста, введите код \"%s\" на сайте %s", "codeIs": "Код: %s", "Play video": "Просмотр", "Related videos": "Похожие видео", "I like this": "Мне понравилось", "Add to favorites": "Добавить в избранное", "Extended video info in My subscriptions": "Расширенная информация по видео в Моих подписках", "Extended video info in Playlists": "Расширенная информация по видео в Плейлистах", "Extended video info in Categories": "Расширенная информация по видео в Каталоге", "Extended video info in Search": "Расширенная информация по видео в Поиске", "Update available: %s": "Доступно обновление: %s", "Install latest version of the channel.": "Установить последнюю версию плагина.", "Channel updated to version %s": "Плагин обновлен до версии %s", "Remove from playlist": "Удалить из плейлиста", "Action complete": "Действие выполнено", "An error has occurred": "Произошла ошибка", "Sign out": "Выйти", "Subscriptions list sorting order": "Порядок сортировки Подписок", "Always play highest quality": "Всегда проигрывать максимальное качество", "Search Channel": "Поиск на канале" } ================================================ FILE: Contents/Strings/sv.json ================================================ { "Error": "Fel", "Next page": "Nästa sida", "No entries found": "Inga objekt hittades", "Search": "Sök", "Search Video": "Sök video", "Success": "Det lyckades", "Title": "YouTubeTV", "Search HD only": "Sök HD enbart", "Items per page": "Objekt per sida", "likes": "Gillade videos", "favorites": "Favoriter", "uploads": "Videos", "watchHistory": "Historik", "watchLater": "Titta senare", "Authorize": "Godkänn", "You must enter code for continue": "Du måste skriva in koden för att fortsätta", "My Subscriptions": "Mina prenumerationer", "What to Watch": "Intressanta videoklipp", "Playlists": "Spellistor", "My channel": "Min kanal", "Channels": "Kanaler", "Categories": "Kategorier", "Subscriptions": "Prenumerationer", "More playlists": "Mer spellistor", "More subscriptions": "Mer prenumerationer", "Complete authorization": "Slutför godkännande", "Service temporarily unavailable": "Tjänsten tillfälligt otillgänglig", "Preferred Region for Videos and channels": "Föredragen region för videos och kanaler", "Preferred language for Videos and channels": "Föredraget språk för videos och kanaler", "Search playlists": "Sök spellistor", "Search channels": "Sök kanaler", "Browse channels": "Bläddra kanaler", "enterCodeSite": "Vänligen skriv in kod \"%s\" i %s", "codeIs": "Kod: %s", "Play video": "Spela upp video", "Related videos": "Liknande videos", "I like this": "Gilla detta", "Add to favorites": "Lägg till favoriter", "Extended video info in My subscriptions": "Mer videoinformation i Mina prenumerationer", "Extended video info in Playlists": "Mer videoinformation i Spellistor", "Extended video info in Categories": "Mer videoinformation i Kategorier", "Extended video info in Search": "Mer videoinformation i Sök", "Update available: %s": "Uppdatering tillgänglig: %s", "Install latest version of the channel.": "Installera senaste versionen av kanalen.", "Channel updated to version %s": "Kanalen uppdaterad till version %s", "Remove from playlist": "Ta bort från spellista", "Action complete": "Åtgärd genomförd", "An error has occurred": "Ett fel har inträffat", "Sign out": "Logga ut", "Subscriptions list sorting order": "Sorteringsordning för prenumerationer" } ================================================ FILE: LICENSE ================================================ Copyright (c) 2014, KOL All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the {organization} nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: README.md ================================================ YouTubeTV Plex Plugin ===== This is fast and easy alternative [YouTube](http://youtube.com/) plugin for [Plex Media Server](http://plex.tv/). Current status ------- * Support categories * Support subscriptions * Support playlists * Support channels catalogue and video categories browsing * Search playlists, channels and videos * Support live streams Features ------- * Much faster than original Plex YouTube plugin * Full YouTube API v3 support * Does not require Google login and password * Navigation like YouTube Apps * Pagination and regional settings * Russian, Swedish, Danish, Dutch, French, French (Belgium), Hungarian, Italian, Spanish, German, Polish localization * YouTube content localization by region preferences * "Like" and "Watch later" video functions including edit function * Videos and playlists from videos description * Navigation by videos, playlists and channels from video description * Update function Installation ------- You can install it using the [instruction](https://support.plex.tv/hc/en-us/articles/201187656-How-do-I-manually-install-a-channel-) ------- [![Donate](http://storage5.static.itmages.com/i/14/1206/h_1417885701_6519792_bb39979c38.png)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=27YXJGA4W5VP4&lc=RU&item_name=KOL%27s%20Plex%20YouTubeTV%20Plugin&item_number=YouTubeTV%2ebundle¤cy_code=USD&bn=PP%2dDonationsBF%3abtn_donateCC_LG%2egif%3aNonHosted "Donate for project support") [![Поддержать](http://storage6.static.itmages.com/i/14/1206/h_1417885181_1364213_1508537bad.png)](https://money.yandex.ru/embed/shop.xml?account=410012666604862&quickpay=shop&payment-type-choice=on&mobile-payment-type-choice=on&writer=seller&targets=%D0%9F%D0%BE%D0%B4%D0%B4%D0%B5%D1%80%D0%B6%D0%BA%D0%B0+Plex+YouTubeTV+Plugin&targets-hint=&default-sum=300&button-text=03&comment=on&hint=%D0%92%D0%B0%D1%88%D0%B8+%D0%BF%D0%BE%D0%B6%D0%B5%D0%BB%D0%B0%D0%BD%D0%B8%D1%8F&successURL=https%3A%2F%2Fgithub.com%2Fkolsys%2FYouTubeTV.bundle "Поддержкать проект") Help with localization ------- [YouTubeTV on Transifex](https://www.transifex.com/kolsys/youtubetv/) ================================================ FILE: _config.yml ================================================ theme: jekyll-theme-slate