tubelib: fix youtube video sync url resolving and a potential crash
[enigma2-plugins.git] / tubelib / src / youtube / Videos.py
1 #enigma2
2 from Components.config import config
3 #twisted
4 from twisted.internet import reactor, threads
5 #youtube
6 from apiclient.discovery import build
7 from youtube_dl import YoutubeDL
8 #local
9 from ThreadedRequest import ThreadedRequest
10 from YoutubeQueryBase import YoutubeQueryBase
11
12 from Tools.Log import Log
13
14 import datetime, re
15
16 class Videos(YoutubeQueryBase):
17         MOST_POPULAR = "mostPopular"
18
19         def list(self, callback, maxResults=25, chart=None, videoCategoryId=None, ids=[]):
20                 if not chart:
21                         chart = self.MOST_POPULAR
22
23                 self._args = {
24                         "part" : "id,snippet,statistics,contentDetails",
25                         "maxResults" : maxResults,
26                         "hl" : config.plugins.mytube.search.lr.value
27                 }
28                 if videoCategoryId:
29                         self._args["videoCategoryId"] = videoCategoryId
30                 if chart:
31                         self._args["chart"] = chart
32                 if ids:
33                         self._args["id"] = ",".join(ids)
34                 Log.i(self._args)
35                 return self._doQuery(callback)
36
37         def _doQuery(self, callback):
38                 request = self._youtube.videos().list(**self._args)
39                 return self._query(callback, request)
40
41         def _onResult(self, success, data):
42                 videos = []
43                 if success:
44                         for item in data['items']:
45                                 v = Video(item)
46                                 if v.isValid():
47                                         videos.append(v)
48                                 else:
49                                         Log.w("Skipped video '%s (%s)'" % (v.title, v.id))
50                 if self._callback:
51                         self._callback(success, videos, data)
52
53         from twisted.internet import threads, reactor
54
55 class Video(object):
56         def __init__(self, entry):
57                 self._entry = entry
58                 self._url = None
59                 self._format = 0
60                 self._urlRequest = None
61                 #self.getUrl()
62
63         def isValid(self):
64                 #two checks for videos that have e.g. gone private
65                 return self._entry.has_key("contentDetails") and self._entry["snippet"].has_key("thumbnails")
66
67         def isPlaylistEntry(self):
68                 return False
69
70         def getId(self):
71                 return str(self._entry["id"])
72         id = property(getId)
73
74         def getTitle(self):
75                 return str(self._entry["snippet"]["title"])
76         title = property(getTitle)
77
78         def getDescription(self):
79                 return str(self._entry["snippet"]["description"])
80         description = property(getDescription)
81
82         def getThumbnailUrl(self, best=False):
83                 prios = ["maxres", "standard", "high", "medium", "default"]
84                 if not best:
85                         prios.reverse()
86                 for prio in prios:
87                         if self._entry["snippet"]["thumbnails"].has_key(prio):
88                                 return str(self._entry["snippet"]["thumbnails"][prio]["url"])
89                         else:
90                                 Log.w(self.id)
91                 return None
92         thumbnailUrl = property(getThumbnailUrl)
93
94         def getPublishedDate(self):
95                 return str(self._entry["snippet"]["publishedAt"])
96         publishedDate = property(getPublishedDate)
97
98         def getViews(self):
99                 return str(self._entry["statistics"]["viewCount"])
100         views = property(getViews)
101
102         def _parse_duration(self, duration):
103                 # isodate replacement
104                 if 'P' in duration:
105                         dt, duration = duration.split('P')
106
107                 duration_regex = re.compile(
108                         r'^((?P<years>\d+)Y)?'
109                         r'((?P<months>\d+)M)?'
110                         r'((?P<weeks>\d+)W)?'
111                         r'((?P<days>\d+)D)?'
112                         r'(T'
113                         r'((?P<hours>\d+)H)?'
114                         r'((?P<minutes>\d+)M)?'
115                         r'((?P<seconds>\d+)S)?'
116                         r')?$'
117                 )
118
119                 data = duration_regex.match(duration)
120                 if not data or duration[-1] == 'T':
121                         raise ValueError("'P%s' does not match ISO8601 format" % duration)
122                 data = {k:int(v) for k, v in data.groupdict().items() if v}
123                 if 'years' in data or 'months' in data:
124                         raise ValueError('Year and month values are not supported in python timedelta')
125
126                 return datetime.timedelta(**data)
127
128         def getDuration(self):
129                 try:
130                         return self._parse_duration(str(self._entry["contentDetails"]["duration"])).total_seconds()
131                 except KeyError, e:
132                         Log.w(e)
133                         return 0
134                 except ValueError, e:
135                         Log.w(e)
136                         return 0
137         duration = property(getDuration)
138
139         def getLikes(self):
140                 if "likeCount" in self._entry["statistics"]:
141                         return str(self._entry["statistics"]["likeCount"])
142                 return "-"
143         likes = property(getLikes)
144
145         def getDislikes(self):
146                 if "dislikeCount" in self._entry["statistics"]:
147                         return str(self._entry["statistics"]["dislikeCount"])
148                 return "-"
149         dislikes = property(getDislikes)
150
151         def getChannelTitle(self):
152                 return str(self._entry["snippet"]["channelTitle"])
153         channelTitle = property(getChannelTitle)
154
155         def getChannelId(self):
156                 return str(self._entry["snippet"]["channelId"])
157         channelId = property(getChannelId)
158
159         def getChannelTitle(self):
160                 return str(self._entry["snippet"]["channelTitle"])
161         channelTitle = property(getChannelTitle)
162
163         def _onUrlReady(self, url, format, *args):
164                 Log.d(url)
165                 if url:
166                         self._url = url
167                         self._format = format
168                 else:
169                         self._url = "broken..."
170
171         def getUrl(self, callback=None):
172                 if not self._url:
173                         watch_url = 'http://www.youtube.com/watch?v=%s' % self.id
174                         callbacks = [self._onUrlReady]
175                         if callback:
176                                 callbacks.append(callback)
177                         isAsync = callback != None
178                         self._urlRequest = VideoUrlRequest(watch_url, callbacks, async=isAsync)
179                         return self._url
180                 else:
181                         if callback:
182                                 callback(self._url)
183                         return self._url
184         url = property(getUrl)
185
186 class VideoUrlRequest(object):
187         VIDEO_FMT_PRIORITY_MAP = {
188                 1 : '96', #HLS 1080P
189                 2 : '95', #HLS 720p
190                 3 : '94', #HLS 480p
191                 4 : '93', #HLS 360p
192                 5 : '92', #HLS 240p
193                 6 : '91', #HLS 144p
194                 7 : '38', #MP4 Original (HD)
195                 8 : '37', #MP4 1080p (HD)
196                 9 : '22', #MP4 720p (HD)
197                 10 : '18', #MP4 360p
198                 11 : '35', #FLV 480p
199                 12 : '34', #FLV 360p
200         }
201         KEY_FORMAT_ID = u"format_id"
202         KEY_URL = u"url"
203         KEY_ENTRIES = u"entries"
204         KEY_FORMATS = u"formats"
205
206         @staticmethod
207         def isHls(format):
208                 return format >= 91 and format <= 96
209
210         def __init__(self, baseurl, callbacks=[], async=True):
211                 self._canceled = False
212                 self._callbacks = callbacks
213                 self._baseurl = baseurl
214                 self._async = async
215                 if self._async:
216                         threads.deferToThread(self._request)
217                 else:
218                         self._request()
219
220         def _request(self):
221                 try:
222                         format_prio = "/".join(self.VIDEO_FMT_PRIORITY_MAP.itervalues())
223                         ytdl = YoutubeDL(params={"youtube_include_dash_manifest": False, "format" : format_prio, "nocheckcertificate" : True})
224                         result = ytdl.extract_info(self._baseurl, download=False)
225                         if self.KEY_ENTRIES in result: # Can be a playlist or a list of videos
226                                 entry = result[self.KEY_ENTRIES][0] #TODO handle properly
227                         else:# Just a video
228                                 entry = result
229                                 url = str(entry.get(self.KEY_URL))
230                                 format = int(entry.get(self.KEY_FORMAT_ID))
231                         self._onResult(True, url, format)
232                 except Exception as e:
233                         Log.w(e)
234                         self._onResult(False, None, -1)
235
236         def _onResult(self, success, url, format):
237                 if self._canceled:
238                         return
239                 for callback in self._callbacks:
240                         if self._async:
241                                 reactor.callFromThread(callback, url, format)
242                         else:
243                                 callback(url, format)
244
245         def cancel(self):
246                 self._canceled = True