__init__.py 15.2 KB
Newer Older
1
import collections
2
import errno
echel0n's avatar
echel0n committed
3
import time
4
import traceback
5
from urllib.parse import urljoin
6

7
import oauthlib.oauth2
8
import requests
9
import requests.exceptions
10
from jose import ExpiredSignatureError
11
from keycloak.exceptions import KeycloakClientError
echel0n's avatar
echel0n committed
12
13
from requests_oauthlib import OAuth2Session

14
import sickrage
15
from sickrage.core.api.exceptions import APIError
16
17
18


class API(object):
echel0n's avatar
echel0n committed
19
    def __init__(self):
20
        self.name = 'SR-API'
21
        self.api_base = 'https://www.sickrage.ca/api/'
echel0n's avatar
echel0n committed
22
        self.api_version = 'v6'
23
        self._token = {}
24

25
26
27
    @property
    def imdb(self):
        return self.IMDbAPI(self)
28

29
    @property
echel0n's avatar
echel0n committed
30
31
    def server(self):
        return self.ServerAPI(self)
32
33

    @property
34
35
36
37
38
39
    def search_provider(self):
        return self.SearchProviderAPI(self)

    @property
    def series_provider(self):
        return self.SeriesProviderAPI(self)
40
41
42
43
44
45
46
47
48
49

    @property
    def announcement(self):
        return self.AnnouncementsAPI(self)

    @property
    def google(self):
        return self.GoogleDriveAPI(self)

    @property
echel0n's avatar
echel0n committed
50
51
    def torrent(self):
        return self.TorrentAPI(self)
52

53
54
55
56
    @property
    def scene_exceptions(self):
        return self.SceneExceptions(self)

57
58
59
60
    @property
    def alexa(self):
        return self.AlexaAPI(self)

61
62
    @property
    def session(self):
63
64
        if not self.token_url:
            return
65

66
67
68
69
70
71
        return OAuth2Session(
            token=self.token,
            auto_refresh_kwargs={'client_id': sickrage.app.auth_server.client_id},
            auto_refresh_url=self.token_url,
            token_updater=self.token_updater
        )
72
73

    @property
74
    def token(self):
75
76
77
78
        if not self._token:
            self.login()
        elif self.token_time_remaining < (int(self._token.get('expires_in')) / 2):
            self.refresh_token()
79

80
        return self._token
81

82
83
84
    @property
    def token_expiration(self):
        try:
85
86
87
            if not self._token:
                return time.time()

88
            certs = sickrage.app.auth_server.certs()
89
90
91
            if not certs:
                return time.time()

92
            decoded_token = sickrage.app.auth_server.decode_token(self._token.get('access_token'), certs)
93
94
95
96
97
98
99
100
101
102
103
104
            return decoded_token.get('exp', time.time())
        except ExpiredSignatureError:
            return time.time()

    @property
    def token_time_remaining(self):
        return max(self.token_expiration - time.time(), 0)

    @property
    def token_is_expired(self):
        return self.token_expiration <= time.time()

105
106
    @property
    def token_url(self):
107
        return sickrage.app.auth_server.get_url('token_endpoint')
108

109
110
    @property
    def health(self):
111
112
        for i in range(3):
            try:
echel0n's avatar
echel0n committed
113
                health = requests.get(urljoin(self.api_base, "health"), verify=False, timeout=30).ok
114
115
116
117
118
            except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout):
                pass
            else:
                break
        else:
119
120
121
            health = False

        if not health:
122
            sickrage.app.log.debug("SiCKRAGE API is currently unreachable")
123
124
125
            return False

        return True
126

127
128
    @property
    def userinfo(self):
129
        return self.request('GET', 'userinfo')
130

131
    def token_updater(self, value):
132
        self._token = value
133

134
135
136
    def login(self):
        if not self.health:
            return False
137

138
139
140
141
142
143
        if not self.token_url:
            return False

        session = requests.session()

        data = {
144
            'client_id': sickrage.app.auth_server.client_id,
145
146
            'grant_type': 'password',
            'apikey': sickrage.app.config.general.sso_api_key
147
148
        }

149
150
151
152
153
154
155
156
157
158
159
        try:
            resp = session.post(self.token_url, data)
            resp.raise_for_status()
            self._token = resp.json()
        except requests.exceptions.RequestException:
            return False

        return True

    def logout(self):
        if self._token:
160
            try:
161
162
163
164
165
                sickrage.app.auth_server.logout(self._token.get('refresh_token'))
            except KeycloakClientError:
                pass

    def refresh_token(self):
166
167
168
169
170
171
        retries = 3

        for i in range(retries):
            try:
                if not self._token:
                    return self.login()
172

173
174
175
176
177
178
179
180
                self._token = sickrage.app.auth_server.refresh_token(self._token.get('refresh_token'))
            except KeycloakClientError:
                return self.login()
            except (requests.exceptions.ReadTimeout, requests.exceptions.ConnectionError):
                if i > retries:
                    return False
                time.sleep(0.2)
                continue
181

182
            return True
183

184
    def allowed_usernames(self):
185
        return self.request('GET', 'allowed-usernames')
echel0n's avatar
echel0n committed
186

187
    def download_privatekey(self):
echel0n's avatar
echel0n committed
188
        return self.request('GET', 'server/config/private-key')
189
190

    def upload_privatekey(self, privatekey):
echel0n's avatar
echel0n committed
191
192
193
194
        return self.request('POST', 'server/config/private-key', data=dict({'privatekey': privatekey}))

    def network_timezones(self):
        return self.request('GET', 'network-timezones')
195

echel0n's avatar
echel0n committed
196
    def request(self, method, url, timeout=120, **kwargs):
197
        if not self.session:
198
199
            return

200
201
202
203
204
        url = urljoin(self.api_base, "/".join([self.api_version, url]))

        for i in range(5):
            resp = None

205
            try:
206
                if not self.health:
207
208
                    if i > 3:
                        return None
209
210
                    continue

211
                resp = self.session.request(method, url, timeout=timeout, verify=False, hooks={'response': self.throttle_hook}, **kwargs)
212

213
214
                resp.raise_for_status()
                if resp.status_code == 204:
215
                    return resp.ok
216

217
                try:
218
                    return resp.json()
219
220
                except ValueError:
                    return resp.content
221
222
223
224
            except (oauthlib.oauth2.TokenExpiredError, oauthlib.oauth2.InvalidGrantError):
                self.refresh_token()
                time.sleep(1)
            except (oauthlib.oauth2.InvalidClientIdError, oauthlib.oauth2.MissingTokenError) as e:
225
                self.refresh_token()
226
227
228
                time.sleep(1)
            except requests.exceptions.ReadTimeout as e:
                if i > 3:
echel0n's avatar
echel0n committed
229
                    sickrage.app.log.debug(f'Error connecting to url {url} Error: {e}')
230
231
                    return resp or e.response

232
                timeout += timeout
233
                time.sleep(1)
234
            except requests.exceptions.HTTPError as e:
235
236
                status_code = e.response.status_code
                error_message = e.response.text
237

238
239
                if status_code == 403 and "login-pf-page" in error_message:
                    self.refresh_token()
240
                    time.sleep(1)
241
                    continue
242

243
                if 'application/json' in e.response.headers.get('content-type', ''):
echel0n's avatar
echel0n committed
244
245
246
                    status_code = e.response.json().get('error', status_code)
                    error_message = e.response.json().get('message', error_message)
                    sickrage.app.log.debug(f'SiCKRAGE API response returned for url {url} Response: {error_message}, Code: {status_code}')
247
                else:
echel0n's avatar
echel0n committed
248
                    sickrage.app.log.debug(f'The response returned a non-200 response while requesting url {url} Error: {e!r}')
249

250
251
252
                return resp or e.response
            except requests.exceptions.ConnectionError as e:
                if i > 3:
echel0n's avatar
echel0n committed
253
                    sickrage.app.log.debug(f'Error connecting to url {url} Error: {e}')
254
                    return resp or e.response
255

256
257
                time.sleep(1)
            except requests.exceptions.RequestException as e:
echel0n's avatar
echel0n committed
258
                sickrage.app.log.debug(f'Error requesting url {url} Error: {e}')
259
260
261
                return resp or e.response
            except Exception as e:
                if (isinstance(e, collections.Iterable) and 'ECONNRESET' in e) or (getattr(e, 'errno', None) == errno.ECONNRESET):
echel0n's avatar
echel0n committed
262
                    sickrage.app.log.warning(f'Connection reset by peer accessing url {url} Error: {e}')
263
                else:
echel0n's avatar
echel0n committed
264
                    sickrage.app.log.info(f'Unknown exception in url {url} Error: {e}')
265
266
267
                    sickrage.app.log.debug(traceback.format_exc())

                return None
268

echel0n's avatar
echel0n committed
269
270
    @staticmethod
    def throttle_hook(response, **kwargs):
echel0n's avatar
echel0n committed
271
        if "X-RateLimit-Remaining" in response.headers:
echel0n's avatar
echel0n committed
272
273
274
275
            remaining = int(response.headers["X-RateLimit-Remaining"])
            if remaining == 1:
                sickrage.app.log.debug("Throttling SiCKRAGE API Calls... Sleeping for 60 secs...\n")
                time.sleep(60)
276

echel0n's avatar
echel0n committed
277
    class ServerAPI:
278
279
280
        def __init__(self, api):
            self.api = api

echel0n's avatar
echel0n committed
281
        def register_server(self, ip_addresses, web_protocol, web_port, web_root, server_version):
echel0n's avatar
echel0n committed
282
            data = {
echel0n's avatar
echel0n committed
283
284
285
286
287
                'ip-addresses': ip_addresses,
                'web-protocol': web_protocol,
                'web-port': web_port,
                'web-root': web_root,
                'server-version': server_version
echel0n's avatar
echel0n committed
288
            }
289

echel0n's avatar
echel0n committed
290
            return self.api.request('POST', 'server', data=data)
echel0n's avatar
echel0n committed
291
292
293
294
295
296

        def unregister_server(self, server_id):
            data = {
                'server-id': server_id
            }

echel0n's avatar
echel0n committed
297
            return self.api.request('DELETE', 'server', data=data)
298

echel0n's avatar
echel0n committed
299
        def update_server(self, server_id, ip_addresses, web_protocol, web_port, web_root, server_version):
300
            data = {
echel0n's avatar
echel0n committed
301
                'server-id': server_id,
echel0n's avatar
echel0n committed
302
303
304
305
306
                'ip-addresses': ip_addresses,
                'web-protocol': web_protocol,
                'web-port': web_port,
                'web-root': web_root,
                'server-version': server_version
307
308
            }

echel0n's avatar
echel0n committed
309
310
311
312
313
314
315
            return self.api.request('PUT', 'server', data=data)

        def get_status(self, server_id):
            return self.api.request('GET', f'server/{server_id}/status')

        def get_server_certificate(self, server_id):
            return self.api.request('GET', f'server/{server_id}/certificate')
316

317
318
319
        def declare_amqp_queue(self, server_id):
            return self.api.request('GET', f'server/{server_id}/declare-amqp-queue')

echel0n's avatar
echel0n committed
320
        def upload_config(self, server_id, pkey_sig, config):
321
            data = {
322
                'server-id': server_id,
323
324
325
                'pkey-sig': pkey_sig,
                'config': config
            }
echel0n's avatar
echel0n committed
326
            return self.api.request('POST', f'server/{server_id}/config', data=data)
327

echel0n's avatar
echel0n committed
328
        def download_config(self, server_id, pkey_sig):
329
330
331
332
            data = {
                'pkey-sig': pkey_sig
            }

echel0n's avatar
echel0n committed
333
            return self.api.request('GET', f'server/{server_id}/config', json=data)['config']
334
335
336
337
338
339
340
341

    class AnnouncementsAPI:
        def __init__(self, api):
            self.api = api

        def get_announcements(self):
            return self.api.request('GET', 'announcements')

342
    class SearchProviderAPI:
343
344
345
346
        def __init__(self, api):
            self.api = api

        def get_urls(self, provider):
347
348
            endpoint = f'provider/{provider}/urls'
            return self.api.request('GET', endpoint)
349
350

        def get_status(self, provider):
351
352
            endpoint = f'provider/{provider}/status'
            return self.api.request('GET', endpoint)
353

echel0n's avatar
echel0n committed
354
        def get_search_result(self, provider, series_id, season, episode):
355
356
            endpoint = f'provider/{provider}/series-id/{series_id}/season/{season}/episode/{episode}'
            return self.api.request('GET', endpoint)
357

echel0n's avatar
echel0n committed
358
359
        def add_search_result(self, provider, data):
            return self.api.request('POST', f'provider/{provider}', json=data)
360

echel0n's avatar
echel0n committed
361
    class TorrentAPI:
362
363
364
        def __init__(self, api):
            self.api = api

echel0n's avatar
echel0n committed
365
        def get_trackers(self):
366
367
            endpoint = f'torrent/trackers'
            return self.api.request('GET', endpoint)
echel0n's avatar
echel0n committed
368
369

        def get_torrent(self, hash):
370
371
            endpoint = f'torrent/{hash}'
            return self.api.request('GET', endpoint)
372

echel0n's avatar
echel0n committed
373
374
        def add_torrent(self, url):
            return self.api.request('POST', 'torrent', json={'url': url})
375
376
377
378
379
380

    class IMDbAPI:
        def __init__(self, api):
            self.api = api

        def search_by_imdb_title(self, title):
381
382
            endpoint = f'imdb/search-by-title/{title}'
            return self.api.request('GET', endpoint)
383

384
        def search_by_imdb_id(self, imdb_id):
385
386
            endpoint = f'imdb/search-by-id/{imdb_id}'
            return self.api.request('GET', endpoint)
387
388
389
390
391
392

    class GoogleDriveAPI:
        def __init__(self, api):
            self.api = api

        def is_connected(self):
393
394
            endpoint = 'google-drive/is-connected'
            return self.api.request('GET', endpoint)
395
396

        def upload(self, file, folder):
397
398
            endpoint = 'google-drive/upload'
            return self.api.request('POST', endpoint, files={'file': open(file, 'rb')}, params={'folder': folder})
399
400

        def download(self, id):
401
402
            endpoint = f'google-drive/download/{id}'
            return self.api.request('GET', endpoint)
403
404

        def delete(self, id):
405
406
            endpoint = f'google-drive/delete/{id}'
            return self.api.request('GET', endpoint)
407
408

        def search_files(self, id, term):
409
410
            endpoint = f'google-drive/search-files/{id}/{term}'
            return self.api.request('GET', endpoint)
411
412

        def list_files(self, id):
413
414
            endpoint = f'google-drive/list-files/{id}'
            return self.api.request('GET', endpoint)
415
416

        def clear_folder(self, id):
417
418
            endpoint = f'google-drive/clear-folder/{id}'
            return self.api.request('GET', endpoint)
419
420
421
422
423

    class SceneExceptions:
        def __init__(self, api):
            self.api = api

424
        def get(self, *args, **kwargs):
425
426
            endpoint = 'scene-exceptions'
            return self.api.request('GET', endpoint)
427

428
        def search_by_id(self, series_id):
429
430
            endpoint = f'scene-exceptions/search-by-id/{series_id}'
            return self.api.request('GET', endpoint)
431
432
433
434
435
436
437

    class AlexaAPI:
        def __init__(self, api):
            self.api = api

        def send_notification(self, message):
            return self.api.request('POST', 'alexa/notification', json={'message': message})
438
439
440
441
442
443
444
445
446

    class SeriesProviderAPI:
        def __init__(self, api):
            self.api = api

        def search(self, provider, query, language='eng'):
            endpoint = f'series-provider/{provider}/search/{query}/{language}'
            return self.api.request('GET', endpoint)

447
448
449
450
        def search_by_id(self, provider, remote_id, language='eng'):
            endpoint = f'series-provider/{provider}/search-by-id/{remote_id}/{language}'
            return self.api.request('GET', endpoint)

451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
        def get_series_info(self, provider, series_id, language='eng'):
            endpoint = f'series-provider/{provider}/series/{series_id}/{language}'
            return self.api.request('GET', endpoint)

        def get_episodes_info(self, provider, series_id, season_type='default', language='eng'):
            endpoint = f'series-provider/{provider}/series/{series_id}/episodes/{season_type}/{language}'
            return self.api.request('GET', endpoint)

        def languages(self, provider):
            endpoint = f'series-provider/{provider}/languages'
            return self.api.request('GET', endpoint)

        def updates(self, provider, since):
            endpoint = f'series-provider/{provider}/updates/{since}'
            return self.api.request('GET', endpoint)