youtube.Videos: like/dislikeCount may be missing, return "-" in such cases
[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._urlRequest = None
60                 #self.getUrl()
61
62         def isValid(self):
63                 #two checks for videos that have e.g. gone private
64                 return self._entry.has_key("contentDetails") and self._entry["snippet"].has_key("thumbnails")
65
66         def isPlaylistEntry(self):
67                 return False
68
69         def getId(self):
70                 return str(self._entry["id"])
71         id = property(getId)
72
73         def getTitle(self):
74                 return str(self._entry["snippet"]["title"])
75         title = property(getTitle)
76
77         def getDescription(self):
78                 return str(self._entry["snippet"]["description"])
79         description = property(getDescription)
80
81         def getThumbnailUrl(self, best=False):
82                 prios = ["maxres", "standard", "high", "medium", "default"]
83                 if not best:
84                         prios.reverse()
85                 for prio in prios:
86                         if self._entry["snippet"]["thumbnails"].has_key(prio):
87                                 return str(self._entry["snippet"]["thumbnails"][prio]["url"])
88                         else:
89                                 Log.w(self.id)
90                 return None
91         thumbnailUrl = property(getThumbnailUrl)
92
93         def getPublishedDate(self):
94                 return str(self._entry["snippet"]["publishedAt"])
95         publishedDate = property(getPublishedDate)
96
97         def getViews(self):
98                 return str(self._entry["statistics"]["viewCount"])
99         views = property(getViews)
100
101         def _parse_duration(self, duration):
102                 # isodate replacement
103                 if 'P' in duration:
104                         dt, duration = duration.split('P')
105
106                 duration_regex = re.compile(
107                         r'^((?P<years>\d+)Y)?'
108                         r'((?P<months>\d+)M)?'
109                         r'((?P<weeks>\d+)W)?'
110                         r'((?P<days>\d+)D)?'
111                         r'(T'
112                         r'((?P<hours>\d+)H)?'
113                         r'((?P<minutes>\d+)M)?'
114                         r'((?P<seconds>\d+)S)?'
115                         r')?$'
116                 )
117
118                 data = duration_regex.match(duration)
119                 if not data or duration[-1] == 'T':
120                         raise ValueError("'P%s' does not match ISO8601 format" % duration)
121                 data = {k:int(v) for k, v in data.groupdict().items() if v}
122                 if 'years' in data or 'months' in data:
123                         raise ValueError('Year and month values are not supported in python timedelta')
124
125                 return datetime.timedelta(**data)
126
127         def getDuration(self):
128                 try:
129                         return self._parse_duration(str(self._entry["contentDetails"]["duration"])).total_seconds()
130                 except KeyError, e:
131                         Log.w(e)
132                         return 0
133                 except ValueError, e:
134                         Log.w(e)
135                         return 0
136         duration = property(getDuration)
137
138         def getLikes(self):
139                 if "likeCount" in self._entry["statistics"]:
140                         return str(self._entry["statistics"]["likeCount"])
141                 return "-"
142         likes = property(getLikes)
143
144         def getDislikes(self):
145                 if "dislikeCount" in self._entry["statistics"]:
146                         return str(self._entry["statistics"]["dislikeCount"])
147                 return "-"
148         dislikes = property(getDislikes)
149
150         def getChannelTitle(self):
151                 return str(self._entry["snippet"]["channelTitle"])
152         channelTitle = property(getChannelTitle)
153
154         def getChannelId(self):
155                 return str(self._entry["snippet"]["channelId"])
156         channelId = property(getChannelId)
157
158         def getChannelTitle(self):
159                 return str(self._entry["snippet"]["channelTitle"])
160         channelTitle = property(getChannelTitle)
161
162         def _onUrlReady(self, url):
163                 Log.d(url)
164                 if url:
165                         self._url = url
166                 else:
167                         self._url = "broken..."
168
169         def getUrl(self, callback=None):
170                 if not self._url:
171                         watch_url = 'http://www.youtube.com/watch?v=%s' % self.id
172                         callbacks = [self._onUrlReady]
173                         if callback:
174                                 callbacks.append(callback)
175                         isAsync = callback != None
176                         self._urlRequest = VideoUrlRequest(watch_url, callbacks, async=isAsync)
177                         return self._url
178                 else:
179                         if callback:
180                                 callback(self._url)
181                         return self._url
182         url = property(getUrl)
183
184 class VideoUrlRequest(object):
185         VIDEO_FMT_PRIORITY_MAP = {
186                 1 : '38', #MP4 Original (HD)
187                 2 : '37', #MP4 1080p (HD)
188                 3 : '22', #MP4 720p (HD)
189                 4 : '18', #MP4 360p
190                 5 : '35', #FLV 480p
191                 6 : '34', #FLV 360p
192         }
193         KEY_FORMAT_ID = u"format_id"
194         KEY_URL = u"url"
195         KEY_ENTRIES = u"entries"
196         KEY_FORMATS = u"formats"
197
198         def __init__(self, baseurl, callbacks=[], async=True):
199                 self._canceled = False
200                 self._callbacks = callbacks
201                 self._baseurl = baseurl
202                 self._async = async
203                 if self._async:
204                         threads.deferToThread(self._request)
205                 else:
206                         self._request()
207
208         def _request(self):
209                 try:
210                         format_prio = "/".join(self.VIDEO_FMT_PRIORITY_MAP.itervalues())
211                         ytdl = YoutubeDL(params={"youtube_include_dash_manifest": False, "format" : format_prio})
212                         result = ytdl.extract_info(self._baseurl, download=False)
213                         if self.KEY_ENTRIES in result: # Can be a playlist or a list of videos
214                                 entry = result[self.KEY_ENTRIES][0] #TODO handle properly
215                         else:# Just a video
216                                 entry = result
217                         self._onResult(True, str(entry.get(self.KEY_URL)))
218                 except Exception as e:
219                         Log.w(e)
220                         self._onResult(False, None)
221
222         def _onResult(self, success, data):
223                 if self._canceled:
224                         return
225                 for callback in self._callbacks:
226                         if self._async:
227                                 reactor.callFromThread(callback, data)
228                         else:
229                                 callback(data)
230
231         def cancel(self):
232                 self._canceled = True