aboutsummaryrefslogtreecommitdiff
path: root/jellyfin_apiclient_python
diff options
context:
space:
mode:
authorSam Chudnick <sam@chudnick.com>2022-07-17 20:20:23 -0400
committerSam Chudnick <sam@chudnick.com>2022-07-17 20:20:23 -0400
commit6a93794737981247a1acb72704c172ef858153ad (patch)
tree0a8a879ace1eecf8511681c453edd0cbbc0cdd00 /jellyfin_apiclient_python
Initial commit
Diffstat (limited to 'jellyfin_apiclient_python')
-rw-r--r--jellyfin_apiclient_python/__init__.py133
-rw-r--r--jellyfin_apiclient_python/api.py653
-rw-r--r--jellyfin_apiclient_python/client.py87
-rw-r--r--jellyfin_apiclient_python/configuration.py53
-rw-r--r--jellyfin_apiclient_python/connection_manager.py379
-rw-r--r--jellyfin_apiclient_python/credentials.py128
-rw-r--r--jellyfin_apiclient_python/exceptions.py11
-rw-r--r--jellyfin_apiclient_python/http.py267
-rw-r--r--jellyfin_apiclient_python/keepalive.py20
-rw-r--r--jellyfin_apiclient_python/timesync_manager.py140
-rw-r--r--jellyfin_apiclient_python/ws_client.py140
11 files changed, 2011 insertions, 0 deletions
diff --git a/jellyfin_apiclient_python/__init__.py b/jellyfin_apiclient_python/__init__.py
new file mode 100644
index 0000000..dcc660f
--- /dev/null
+++ b/jellyfin_apiclient_python/__init__.py
@@ -0,0 +1,133 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3
4#################################################################################################
5
6import logging
7
8from .client import JellyfinClient
9
10#################################################################################################
11
12
13class NullHandler(logging.Handler):
14 def emit(self, record):
15 print(self.format(record))
16
17
18loghandler = NullHandler
19LOG = logging.getLogger('Jellyfin')
20
21#################################################################################################
22
23
24def config(level=logging.INFO):
25
26 logger = logging.getLogger('Jellyfin')
27 logger.addHandler(Jellyfin.loghandler())
28 logger.setLevel(level)
29
30def has_attribute(obj, name):
31 try:
32 object.__getattribute__(obj, name)
33 return True
34 except AttributeError:
35 return False
36
37def ensure_client():
38
39 def decorator(func):
40 def wrapper(self, *args, **kwargs):
41
42 if self.client.get(self.server_id) is None:
43 self.construct()
44
45 return func(self, *args, **kwargs)
46
47 return wrapper
48 return decorator
49
50
51class Jellyfin(object):
52
53 ''' This is your Jellyfinclient, you can create more than one. The server_id is only a temporary thing
54 to communicate with the JellyfinClient().
55
56 from jellyfin import Jellyfin
57
58 Jellyfin('123456').config.data['app']
59
60 # Permanent client reference
61 client = Jellyfin('123456').get_client()
62 client.config.data['app']
63 '''
64
65 # Borg - multiple instances, shared state
66 _shared_state = {}
67 client = {}
68 server_id = "default"
69 loghandler = loghandler
70
71 def __init__(self, server_id=None):
72 self.__dict__ = self._shared_state
73 self.server_id = server_id or "default"
74
75 def get_client(self):
76 return self.client[self.server_id]
77
78 @classmethod
79 def set_loghandler(cls, func=loghandler, level=logging.INFO):
80
81 for handler in logging.getLogger('Jellyfin').handlers:
82 if isinstance(handler, cls.loghandler):
83 logging.getLogger('Jellyfin').removeHandler(handler)
84
85 cls.loghandler = func
86 config(level)
87
88 def close(self):
89
90 if self.server_id not in self.client:
91 return
92
93 self.client[self.server_id].stop()
94 self.client.pop(self.server_id, None)
95
96 LOG.info("---[ STOPPED JELLYFINCLIENT: %s ]---", self.server_id)
97
98 @classmethod
99 def close_all(cls):
100
101 for client in cls.client:
102 cls.client[client].stop()
103
104 cls.client = {}
105 LOG.info("---[ STOPPED ALL JELLYFINCLIENTS ]---")
106
107 @classmethod
108 def get_active_clients(cls):
109 return cls.client
110
111 @ensure_client()
112 def __setattr__(self, name, value):
113
114 if has_attribute(self, name):
115 return super(Jellyfin, self).__setattr__(name, value)
116
117 setattr(self.client[self.server_id], name, value)
118
119 @ensure_client()
120 def __getattr__(self, name):
121 return getattr(self.client[self.server_id], name)
122
123 def construct(self):
124
125 self.client[self.server_id] = JellyfinClient()
126
127 if self.server_id == 'default':
128 LOG.info("---[ START JELLYFINCLIENT ]---")
129 else:
130 LOG.info("---[ START JELLYFINCLIENT: %s ]---", self.server_id)
131
132
133config()
diff --git a/jellyfin_apiclient_python/api.py b/jellyfin_apiclient_python/api.py
new file mode 100644
index 0000000..a6df708
--- /dev/null
+++ b/jellyfin_apiclient_python/api.py
@@ -0,0 +1,653 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3from datetime import datetime
4import requests
5import json
6import logging
7
8LOG = logging.getLogger('JELLYFIN.' + __name__)
9
10
11def jellyfin_url(client, handler):
12 return "%s/%s" % (client.config.data['auth.server'], handler)
13
14
15def basic_info():
16 return "Etag"
17
18
19def info():
20 return (
21 "Path,Genres,SortName,Studios,Writer,Taglines,LocalTrailerCount,"
22 "OfficialRating,CumulativeRunTimeTicks,ItemCounts,"
23 "Metascore,AirTime,DateCreated,People,Overview,"
24 "CriticRating,CriticRatingSummary,Etag,ShortOverview,ProductionLocations,"
25 "Tags,ProviderIds,ParentId,RemoteTrailers,SpecialEpisodeNumbers,"
26 "MediaSources,VoteCount,RecursiveItemCount,PrimaryImageAspectRatio"
27 )
28
29
30def music_info():
31 return (
32 "Etag,Genres,SortName,Studios,Writer,"
33 "OfficialRating,CumulativeRunTimeTicks,Metascore,"
34 "AirTime,DateCreated,MediaStreams,People,ProviderIds,Overview,ItemCounts"
35 )
36
37
38class API(object):
39
40 ''' All the api calls to the server.
41 '''
42 def __init__(self, client, *args, **kwargs):
43 self.client = client
44 self.config = client.config
45 self.default_timeout = 5
46
47 def _http(self, action, url, request={}):
48 request.update({'type': action, 'handler': url})
49
50 return self.client.request(request)
51
52 def _http_url(self, action, url, request={}):
53 request.update({"type": action, "handler": url})
54
55 return self.client.request_url(request)
56
57 def _http_stream(self, action, url, dest_file, request={}):
58 request.update({'type': action, 'handler': url})
59
60 self.client.request(request, dest_file=dest_file)
61
62 def _get(self, handler, params=None):
63 return self._http("GET", handler, {'params': params})
64
65 def _get_url(self, handler, params=None):
66 return self._http_url("GET", handler, {"params": params})
67
68 def _post(self, handler, json=None, params=None):
69 return self._http("POST", handler, {'params': params, 'json': json})
70
71 def _delete(self, handler, params=None):
72 return self._http("DELETE", handler, {'params': params})
73
74 def _get_stream(self, handler, dest_file, params=None):
75 self._http_stream("GET", handler, dest_file, {'params': params})
76
77 #################################################################################################
78
79 # Bigger section of the Jellyfin api
80
81 #################################################################################################
82
83 def try_server(self):
84 return self._get("System/Info/Public")
85
86 def sessions(self, handler="", action="GET", params=None, json=None):
87 if action == "POST":
88 return self._post("Sessions%s" % handler, json, params)
89 elif action == "DELETE":
90 return self._delete("Sessions%s" % handler, params)
91 else:
92 return self._get("Sessions%s" % handler, params)
93
94 def users(self, handler="", action="GET", params=None, json=None):
95 if action == "POST":
96 return self._post("Users/{UserId}%s" % handler, json, params)
97 elif action == "DELETE":
98 return self._delete("Users/{UserId}%s" % handler, params)
99 else:
100 return self._get("Users/{UserId}%s" % handler, params)
101
102 def items(self, handler="", action="GET", params=None, json=None):
103 if action == "POST":
104 return self._post("Items%s" % handler, json, params)
105 elif action == "DELETE":
106 return self._delete("Items%s" % handler, params)
107 else:
108 return self._get("Items%s" % handler, params)
109
110 def user_items(self, handler="", params=None):
111 return self.users("/Items%s" % handler, params=params)
112
113 def shows(self, handler, params):
114 return self._get("Shows%s" % handler, params)
115
116 def videos(self, handler):
117 return self._get("Videos%s" % handler)
118
119 def artwork(self, item_id, art, max_width, ext="jpg", index=None):
120 params = {"MaxWidth": max_width, "format": ext}
121 handler = ("Items/%s/Images/%s" % (item_id, art) if index is None
122 else "Items/%s/Images/%s/%s" % (item_id, art, index)
123 )
124
125 return self._get_url(handler, params)
126
127 def audio_url(self, item_id, container=None, audio_codec=None, max_streaming_bitrate=140000000):
128 params = {
129 "UserId": "{UserId}",
130 "DeviceId": "{DeviceId}",
131 "MaxStreamingBitrate": max_streaming_bitrate,
132 }
133
134 if container:
135 params["Container"] = container
136
137 if audio_codec:
138 params["AudioCodec"] = audio_codec
139
140 return self._get_url("Audio/%s/universal" % item_id, params)
141
142 def video_url(self, item_id, media_source_id=None):
143 params = {
144 "static": "true",
145 "DeviceId": "{DeviceId}"
146 }
147 if media_source_id is not None:
148 params["MediaSourceId"] = media_source_id
149
150 return self._get_url("Videos/%s/stream" % item_id, params)
151
152 def download_url(self, item_id):
153 params = {}
154 return self._get_url("Items/%s/Download" % item_id, params)
155
156 #################################################################################################
157
158 # More granular api
159
160 #################################################################################################
161
162 def get_users(self):
163 return self._get("Users")
164
165 def get_public_users(self):
166 return self._get("Users/Public")
167
168 def get_user(self, user_id=None):
169 return self.users() if user_id is None else self._get("Users/%s" % user_id)
170
171 def get_user_settings(self, client="emby"):
172 return self._get("DisplayPreferences/usersettings", params={
173 "userId": "{UserId}",
174 "client": client
175 })
176
177 def get_views(self):
178 return self.users("/Views")
179
180 def get_media_folders(self):
181 return self.users("/Items")
182
183 def get_item(self, item_id):
184 return self.users("/Items/%s" % item_id)
185
186 def get_items(self, item_ids):
187 return self.users("/Items", params={
188 'Ids': ','.join(str(x) for x in item_ids),
189 'Fields': info()
190 })
191
192 def get_sessions(self):
193 return self.sessions(params={'ControllableByUserId': "{UserId}"})
194
195 def get_device(self, device_id):
196 return self.sessions(params={'DeviceId': device_id})
197
198 def post_session(self, session_id, url, params=None, data=None):
199 return self.sessions("/%s/%s" % (session_id, url), "POST", params, data)
200
201 def get_images(self, item_id):
202 return self.items("/%s/Images" % item_id)
203
204 def get_suggestion(self, media="Movie,Episode", limit=1):
205 return self.users("/Suggestions", params={
206 'Type': media,
207 'Limit': limit
208 })
209
210 def get_recently_added(self, media=None, parent_id=None, limit=20):
211 return self.user_items("/Latest", {
212 'Limit': limit,
213 'UserId': "{UserId}",
214 'IncludeItemTypes': media,
215 'ParentId': parent_id,
216 'Fields': info()
217 })
218
219 def get_next(self, index=None, limit=1):
220 return self.shows("/NextUp", {
221 'Limit': limit,
222 'UserId': "{UserId}",
223 'StartIndex': None if index is None else int(index)
224 })
225
226 def get_adjacent_episodes(self, show_id, item_id):
227 return self.shows("/%s/Episodes" % show_id, {
228 'UserId': "{UserId}",
229 'AdjacentTo': item_id,
230 'Fields': "Overview"
231 })
232
233 def get_season(self, show_id, season_id):
234 return self.shows("/%s/Episodes" % show_id, {
235 'UserId': "{UserId}",
236 'SeasonId': season_id
237 })
238
239 def get_genres(self, parent_id=None):
240 return self._get("Genres", {
241 'ParentId': parent_id,
242 'UserId': "{UserId}",
243 'Fields': info()
244 })
245
246 def get_recommendation(self, parent_id=None, limit=20):
247 return self._get("Movies/Recommendations", {
248 'ParentId': parent_id,
249 'UserId': "{UserId}",
250 'Fields': info(),
251 'Limit': limit
252 })
253
254 # Modified
255 def get_items_by_letter(self, parent_id=None, media=None, letter=None, recurse=True):
256 return self.user_items(params={
257 'ParentId': parent_id,
258 'NameStartsWith': letter,
259 'Fields': info(),
260 'Recursive': recurse,
261 'IncludeItemTypes': media
262 })
263
264 def search_media_items(self, term=None, media=None, limit=20):
265 return self.user_items(params={
266 'searchTerm': term,
267 'Recursive': True,
268 'IncludeItemTypes': media,
269 'Limit': limit
270 })
271
272 def get_channels(self):
273 return self._get("LiveTv/Channels", {
274 'UserId': "{UserId}",
275 'EnableImages': True,
276 'EnableUserData': True
277 })
278
279 def get_intros(self, item_id):
280 return self.user_items("/%s/Intros" % item_id)
281
282 def get_additional_parts(self, item_id):
283 return self.videos("/%s/AdditionalParts" % item_id)
284
285 def delete_item(self, item_id):
286 return self.items("/%s" % item_id, "DELETE")
287
288 def get_local_trailers(self, item_id):
289 return self.user_items("/%s/LocalTrailers" % item_id)
290
291 def get_transcode_settings(self):
292 return self._get('System/Configuration/encoding')
293
294 def get_ancestors(self, item_id):
295 return self.items("/%s/Ancestors" % item_id, params={
296 'UserId': "{UserId}"
297 })
298
299 def get_items_theme_video(self, parent_id):
300 return self.users("/Items", params={
301 'HasThemeVideo': True,
302 'ParentId': parent_id
303 })
304
305 def get_themes(self, item_id):
306 return self.items("/%s/ThemeMedia" % item_id, params={
307 'UserId': "{UserId}",
308 'InheritFromParent': True
309 })
310
311 def get_items_theme_song(self, parent_id):
312 return self.users("/Items", params={
313 'HasThemeSong': True,
314 'ParentId': parent_id
315 })
316
317 def get_plugins(self):
318 return self._get("Plugins")
319
320 def check_companion_installed(self):
321 try:
322 self._get("/Jellyfin.Plugin.KodiSyncQueue/GetServerDateTime")
323 return True
324 except Exception:
325 return False
326
327 def get_seasons(self, show_id):
328 return self.shows("/%s/Seasons" % show_id, params={
329 'UserId': "{UserId}",
330 'EnableImages': True,
331 'Fields': info()
332 })
333
334 def get_date_modified(self, date, parent_id, media=None):
335 return self.users("/Items", params={
336 'ParentId': parent_id,
337 'Recursive': False,
338 'IsMissing': False,
339 'IsVirtualUnaired': False,
340 'IncludeItemTypes': media or None,
341 'MinDateLastSaved': date,
342 'Fields': info()
343 })
344
345 def get_userdata_date_modified(self, date, parent_id, media=None):
346 return self.users("/Items", params={
347 'ParentId': parent_id,
348 'Recursive': True,
349 'IsMissing': False,
350 'IsVirtualUnaired': False,
351 'IncludeItemTypes': media or None,
352 'MinDateLastSavedForUser': date,
353 'Fields': info()
354 })
355
356 def refresh_item(self, item_id):
357 return self.items("/%s/Refresh" % item_id, "POST", json={
358 'Recursive': True,
359 'ImageRefreshMode': "FullRefresh",
360 'MetadataRefreshMode': "FullRefresh",
361 'ReplaceAllImages': False,
362 'ReplaceAllMetadata': True
363 })
364
365 def favorite(self, item_id, option=True):
366 return self.users("/FavoriteItems/%s" % item_id, "POST" if option else "DELETE")
367
368 def get_system_info(self):
369 return self._get("System/Configuration")
370
371 def post_capabilities(self, data):
372 return self.sessions("/Capabilities/Full", "POST", json=data)
373
374 def session_add_user(self, session_id, user_id, option=True):
375 return self.sessions("/%s/Users/%s" % (session_id, user_id), "POST" if option else "DELETE")
376
377 def session_playing(self, data):
378 return self.sessions("/Playing", "POST", json=data)
379
380 def session_progress(self, data):
381 return self.sessions("/Playing/Progress", "POST", json=data)
382
383 def session_stop(self, data):
384 return self.sessions("/Playing/Stopped", "POST", json=data)
385
386 def item_played(self, item_id, watched):
387 return self.users("/PlayedItems/%s" % item_id, "POST" if watched else "DELETE")
388
389 def get_sync_queue(self, date, filters=None):
390 return self._get("Jellyfin.Plugin.KodiSyncQueue/{UserId}/GetItems", params={
391 'LastUpdateDT': date,
392 'filter': filters or None
393 })
394
395 def get_server_time(self):
396 return self._get("Jellyfin.Plugin.KodiSyncQueue/GetServerDateTime")
397
398 def get_play_info(self, item_id, profile, aid=None, sid=None, start_time_ticks=None, is_playback=True):
399 args = {
400 'UserId': "{UserId}",
401 'DeviceProfile': profile,
402 'AutoOpenLiveStream': is_playback,
403 'IsPlayback': is_playback
404 }
405 if sid:
406 args['SubtitleStreamIndex'] = sid
407 if aid:
408 args['AudioStreamIndex'] = aid
409 if start_time_ticks:
410 args['StartTimeTicks'] = start_time_ticks
411 return self.items("/%s/PlaybackInfo" % item_id, "POST", json=args)
412
413 def get_live_stream(self, item_id, play_id, token, profile):
414 return self._post("LiveStreams/Open", json={
415 'UserId': "{UserId}",
416 'DeviceProfile': profile,
417 'OpenToken': token,
418 'PlaySessionId': play_id,
419 'ItemId': item_id
420 })
421
422 def close_live_stream(self, live_id):
423 return self._post("LiveStreams/Close", json={
424 'LiveStreamId': live_id
425 })
426
427 def close_transcode(self, device_id):
428 return self._delete("Videos/ActiveEncodings", params={
429 'DeviceId': device_id
430 })
431
432 def get_audio_stream(self, dest_file, item_id, play_id, container, max_streaming_bitrate=140000000, audio_codec=None):
433 self._get_stream("Audio/%s/universal" % item_id, dest_file, params={
434 'UserId': "{UserId}",
435 'DeviceId': "{DeviceId}",
436 'PlaySessionId': play_id,
437 'Container': container,
438 'AudioCodec': audio_codec,
439 "MaxStreamingBitrate": max_streaming_bitrate,
440 })
441
442 def get_default_headers(self):
443 auth = "MediaBrowser "
444 auth += "Client=%s, " % self.config.data['app.name']
445 auth += "Device=%s, " % self.config.data['app.device_name']
446 auth += "DeviceId=%s, " % self.config.data['app.device_id']
447 auth += "Version=%s" % self.config.data['app.version']
448
449 return {
450 "Accept": "application/json",
451 "Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
452 "X-Application": "%s/%s" % (self.config.data['app.name'], self.config.data['app.version']),
453 "Accept-Charset": "UTF-8,*",
454 "Accept-encoding": "gzip",
455 "User-Agent": self.config.data['http.user_agent'] or "%s/%s" % (self.config.data['app.name'], self.config.data['app.version']),
456 "x-emby-authorization": auth
457 }
458
459 def send_request(self, url, path, method="get", timeout=None, headers=None, data=None, session=None):
460 request_method = getattr(session or requests, method.lower())
461 url = "%s/%s" % (url, path)
462 request_settings = {
463 "timeout": timeout or self.default_timeout,
464 "headers": headers or self.get_default_headers(),
465 "data": data
466 }
467
468 # Changed to use non-Kodi specific setting.
469 if self.config.data.get('auth.ssl') == False:
470 request_settings["verify"] = False
471
472 LOG.info("Sending %s request to %s" % (method, path))
473 LOG.debug(request_settings['timeout'])
474 LOG.debug(request_settings['headers'])
475
476 return request_method(url, **request_settings)
477
478 def login(self, server_url, username, password=""):
479 path = "Users/AuthenticateByName"
480 authData = {
481 "username": username,
482 "Pw": password
483 }
484
485 headers = self.get_default_headers()
486 headers.update({'Content-type': "application/json"})
487
488 try:
489 LOG.info("Trying to login to %s/%s as %s" % (server_url, path, username))
490 response = self.send_request(server_url, path, method="post", headers=headers,
491 data=json.dumps(authData), timeout=(5, 30))
492
493 if response.status_code == 200:
494 return response.json()
495 else:
496 LOG.error("Failed to login to server with status code: " + str(response.status_code))
497 LOG.error("Server Response:\n" + str(response.content))
498 LOG.debug(headers)
499
500 return {}
501 except Exception as e: # Find exceptions for likely cases i.e, server timeout, etc
502 LOG.error(e)
503
504 return {}
505
506 def validate_authentication_token(self, server):
507 authTokenHeader = {
508 'X-MediaBrowser-Token': server['AccessToken']
509 }
510 headers = self.get_default_headers()
511 headers.update(authTokenHeader)
512
513 response = self.send_request(server['address'], "system/info", headers=headers)
514 return response.json() if response.status_code == 200 else {}
515
516 def get_public_info(self, server_address):
517 response = self.send_request(server_address, "system/info/public")
518 return response.json() if response.status_code == 200 else {}
519
520 def check_redirect(self, server_address):
521 ''' Checks if the server is redirecting traffic to a new URL and
522 returns the URL the server prefers to use
523 '''
524 response = self.send_request(server_address, "system/info/public")
525 url = response.url.replace('/system/info/public', '')
526 return url
527
528
529
530 #################################################################################################
531
532 # Syncplay
533
534 #################################################################################################
535
536 def _parse_precise_time(self, time):
537 # We have to remove the Z and the least significant digit.
538 return datetime.strptime(time[:-2], "%Y-%m-%dT%H:%M:%S.%f")
539
540 def utc_time(self):
541 # Measure time as close to the call as is possible.
542 server_address = self.config.data.get("auth.server")
543 session = self.client.session
544
545 response = self.send_request(server_address, "GetUTCTime", session=session)
546 response_received = datetime.utcnow()
547 request_sent = response_received - response.elapsed
548
549 response_obj = response.json()
550 request_received = self._parse_precise_time(response_obj["RequestReceptionTime"])
551 response_sent = self._parse_precise_time(response_obj["ResponseTransmissionTime"])
552
553 return {
554 "request_sent": request_sent,
555 "request_received": request_received,
556 "response_sent": response_sent,
557 "response_received": response_received
558 }
559
560 def get_sync_play(self, item_id=None):
561 params = {}
562 if item_id is not None:
563 params["FilterItemId"] = item_id
564 return self._get("SyncPlay/List", params)
565
566 def join_sync_play(self, group_id):
567 return self._post("SyncPlay/Join", {
568 "GroupId": group_id
569 })
570
571 def leave_sync_play(self):
572 return self._post("SyncPlay/Leave")
573
574 def play_sync_play(self):
575 """deprecated (<= 10.7.0)"""
576 return self._post("SyncPlay/Play")
577
578 def pause_sync_play(self):
579 return self._post("SyncPlay/Pause")
580
581 def unpause_sync_play(self):
582 """10.7.0+ only"""
583 return self._post("SyncPlay/Unpause")
584
585 def seek_sync_play(self, position_ticks):
586 return self._post("SyncPlay/Seek", {
587 "PositionTicks": position_ticks
588 })
589
590 def buffering_sync_play(self, when, position_ticks, is_playing, item_id):
591 return self._post("SyncPlay/Buffering", {
592 "When": when.isoformat() + "Z",
593 "PositionTicks": position_ticks,
594 "IsPlaying": is_playing,
595 "PlaylistItemId": item_id
596 })
597
598 def ready_sync_play(self, when, position_ticks, is_playing, item_id):
599 """10.7.0+ only"""
600 return self._post("SyncPlay/Ready", {
601 "When": when.isoformat() + "Z",
602 "PositionTicks": position_ticks,
603 "IsPlaying": is_playing,
604 "PlaylistItemId": item_id
605 })
606
607 def reset_queue_sync_play(self, queue_item_ids, position=0, position_ticks=0):
608 """10.7.0+ only"""
609 return self._post("SyncPlay/SetNewQueue", {
610 "PlayingQueue": queue_item_ids,
611 "PlayingItemPosition": position,
612 "StartPositionTicks": position_ticks
613 })
614
615 def ignore_sync_play(self, should_ignore):
616 """10.7.0+ only"""
617 return self._post("SyncPlay/SetIgnoreWait", {
618 "IgnoreWait": should_ignore
619 })
620
621 def next_sync_play(self, item_id):
622 """10.7.0+ only"""
623 return self._post("SyncPlay/NextItem", {
624 "PlaylistItemId": item_id
625 })
626
627 def prev_sync_play(self, item_id):
628 """10.7.0+ only"""
629 return self._post("SyncPlay/PreviousItem", {
630 "PlaylistItemId": item_id
631 })
632
633 def set_item_sync_play(self, item_id):
634 """10.7.0+ only"""
635 return self._post("SyncPlay/SetPlaylistItem", {
636 "PlaylistItemId": item_id
637 })
638
639 def ping_sync_play(self, ping):
640 return self._post("SyncPlay/Ping", {
641 "Ping": ping
642 })
643
644 def new_sync_play(self):
645 """deprecated (< 10.7.0)"""
646 return self._post("SyncPlay/New")
647
648 def new_sync_play_v2(self, group_name):
649 """10.7.0+ only"""
650 return self._post("SyncPlay/New", {
651 "GroupName": group_name
652 })
653
diff --git a/jellyfin_apiclient_python/client.py b/jellyfin_apiclient_python/client.py
new file mode 100644
index 0000000..474c47c
--- /dev/null
+++ b/jellyfin_apiclient_python/client.py
@@ -0,0 +1,87 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3
4#################################################################################################
5
6import logging
7
8from . import api
9from .configuration import Config
10from .http import HTTP
11from .ws_client import WSClient
12from .connection_manager import ConnectionManager, CONNECTION_STATE
13from .timesync_manager import TimeSyncManager
14
15#################################################################################################
16
17LOG = logging.getLogger('JELLYFIN.' + __name__)
18
19#################################################################################################
20
21
22def callback(message, data):
23
24 ''' Callback function should received message, data
25 message: string
26 data: json dictionary
27 '''
28 pass
29
30
31class JellyfinClient(object):
32
33 logged_in = False
34
35 def __init__(self, allow_multiple_clients=False):
36 LOG.debug("JellyfinClient initializing...")
37
38 self.config = Config()
39 self.http = HTTP(self)
40 self.wsc = WSClient(self, allow_multiple_clients)
41 self.auth = ConnectionManager(self)
42 self.jellyfin = api.API(self.http)
43 self.callback_ws = callback
44 self.callback = callback
45 self.timesync = TimeSyncManager(self)
46
47 def set_credentials(self, credentials=None):
48 self.auth.credentials.set_credentials(credentials or {})
49
50 def get_credentials(self):
51 return self.auth.credentials.get_credentials()
52
53 def authenticate(self, credentials=None, options=None, discover=True):
54
55 self.set_credentials(credentials or {})
56 state = self.auth.connect(options or {}, discover)
57
58 if state['State'] == CONNECTION_STATE['SignedIn']:
59
60 LOG.info("User is authenticated.")
61 self.logged_in = True
62 self.callback("ServerOnline", {'Id': self.auth.server_id})
63
64 state['Credentials'] = self.get_credentials()
65
66 return state
67
68 def start(self, websocket=False, keep_alive=True):
69
70 if not self.logged_in:
71 raise ValueError("User is not authenticated.")
72
73 self.http.start_session()
74
75 if keep_alive:
76 self.http.keep_alive = True
77
78 if websocket:
79 self.start_wsc()
80
81 def start_wsc(self):
82 self.wsc.start()
83
84 def stop(self):
85 self.wsc.stop_client()
86 self.http.stop_session()
87 self.timesync.stop_ping()
diff --git a/jellyfin_apiclient_python/configuration.py b/jellyfin_apiclient_python/configuration.py
new file mode 100644
index 0000000..1d93ef9
--- /dev/null
+++ b/jellyfin_apiclient_python/configuration.py
@@ -0,0 +1,53 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3
4''' This will hold all configs from the client.
5 Configuration set here will be used for the HTTP client.
6'''
7
8#################################################################################################
9
10import logging
11
12#################################################################################################
13
14DEFAULT_HTTP_MAX_RETRIES = 3
15DEFAULT_HTTP_TIMEOUT = 30
16LOG = logging.getLogger('JELLYFIN.' + __name__)
17
18#################################################################################################
19
20
21class Config(object):
22
23 def __init__(self):
24
25 LOG.debug("Configuration initializing...")
26 self.data = {}
27 self.http()
28
29 def app(self, name, version, device_name, device_id, capabilities=None, device_pixel_ratio=None):
30
31 LOG.debug("Begin app constructor.")
32 self.data['app.name'] = name
33 self.data['app.version'] = version
34 self.data['app.device_name'] = device_name
35 self.data['app.device_id'] = device_id
36 self.data['app.capabilities'] = capabilities
37 self.data['app.device_pixel_ratio'] = device_pixel_ratio
38 self.data['app.default'] = False
39
40 def auth(self, server, user_id, token=None, ssl=None):
41
42 LOG.debug("Begin auth constructor.")
43 self.data['auth.server'] = server
44 self.data['auth.user_id'] = user_id
45 self.data['auth.token'] = token
46 self.data['auth.ssl'] = ssl
47
48 def http(self, user_agent=None, max_retries=DEFAULT_HTTP_MAX_RETRIES, timeout=DEFAULT_HTTP_TIMEOUT):
49
50 LOG.debug("Begin http constructor.")
51 self.data['http.max_retries'] = max_retries
52 self.data['http.timeout'] = timeout
53 self.data['http.user_agent'] = user_agent
diff --git a/jellyfin_apiclient_python/connection_manager.py b/jellyfin_apiclient_python/connection_manager.py
new file mode 100644
index 0000000..9c6a6af
--- /dev/null
+++ b/jellyfin_apiclient_python/connection_manager.py
@@ -0,0 +1,379 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3
4#################################################################################################
5
6import json
7import logging
8import socket
9from datetime import datetime
10from operator import itemgetter
11
12import urllib3
13
14from .credentials import Credentials
15from .api import API
16import traceback
17
18#################################################################################################
19
20LOG = logging.getLogger('JELLYFIN.' + __name__)
21CONNECTION_STATE = {
22 'Unavailable': 0,
23 'ServerSelection': 1,
24 'ServerSignIn': 2,
25 'SignedIn': 3
26}
27
28#################################################################################################
29
30class ConnectionManager(object):
31
32 user = {}
33 server_id = None
34
35 def __init__(self, client):
36
37 LOG.debug("ConnectionManager initializing...")
38
39 self.client = client
40 self.config = client.config
41 self.credentials = Credentials()
42
43 self.API = API(client)
44
45 def clear_data(self):
46
47 LOG.info("connection manager clearing data")
48
49 self.user = None
50 credentials = self.credentials.get_credentials()
51 credentials['Servers'] = list()
52 self.credentials.get_credentials(credentials)
53
54 self.config.auth(None, None)
55
56 def revoke_token(self):
57
58 LOG.info("revoking token")
59
60 self['server']['AccessToken'] = None
61 self.credentials.set_credentials(self.credentials.get())
62
63 self.config.data['auth.token'] = None
64
65 def get_available_servers(self, discover=True):
66
67 LOG.info("Begin getAvailableServers")
68
69 # Clone the credentials
70 credentials = self.credentials.get()
71 found_servers = []
72
73 if discover:
74 found_servers = self.process_found_servers(self._server_discovery())
75
76 if not found_servers and not credentials['Servers']: # back out right away, no point in continuing
77 LOG.info("Found no servers")
78 return list()
79
80 servers = list(credentials['Servers'])
81
82 # Merges servers we already knew with newly found ones
83 for found_server in found_servers:
84 try:
85 self.credentials.add_update_server(servers, found_server)
86 except KeyError:
87 continue
88
89 servers.sort(key=itemgetter('DateLastAccessed'), reverse=True)
90 credentials['Servers'] = servers
91 self.credentials.set(credentials)
92
93 return servers
94
95 def login(self, server_url, username, password=None, clear=None, options=None):
96
97 if not username:
98 raise AttributeError("username cannot be empty")
99
100 if not server_url:
101 raise AttributeError("server url cannot be empty")
102
103 if clear is not None:
104 LOG.warn("The clear option on login() has no effect.")
105
106 if options is not None:
107 LOG.warn("The options option on login() has no effect.")
108
109 data = self.API.login(server_url, username, password) # returns empty dict on failure
110
111 if not data:
112 LOG.info("Failed to login as `"+username+"`")
113 return {}
114
115 LOG.info("Succesfully logged in as %s" % (username))
116 # TODO Change when moving to database storage of server details
117 credentials = self.credentials.get()
118
119 self.config.data['auth.user_id'] = data['User']['Id']
120 self.config.data['auth.token'] = data['AccessToken']
121
122 for server in credentials['Servers']:
123 if server['Id'] == data['ServerId']:
124 found_server = server
125 break
126 else:
127 return {} # No server found
128
129 found_server['DateLastAccessed'] = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
130 found_server['UserId'] = data['User']['Id']
131 found_server['AccessToken'] = data['AccessToken']
132
133 self.credentials.add_update_server(credentials['Servers'], found_server)
134
135 info = {
136 'Id': data['User']['Id'],
137 'IsSignedInOffline': True
138 }
139 self.credentials.add_update_user(server, info)
140
141 self.credentials.set_credentials(credentials)
142
143 return data
144
145
146 def connect_to_address(self, address, options={}):
147
148 if not address:
149 return False
150
151 address = self._normalize_address(address)
152
153 try:
154 response_url = self.API.check_redirect(address)
155 if address != response_url:
156 address = response_url
157 LOG.info("connect_to_address %s succeeded", address)
158 server = {
159 'address': address,
160 }
161 server = self.connect_to_server(server, options)
162 if server is False:
163 LOG.error("connect_to_address %s failed", address)
164 return { 'State': CONNECTION_STATE['Unavailable'] }
165
166 return server
167 except Exception:
168 LOG.error("connect_to_address %s failed", address)
169 return { 'State': CONNECTION_STATE['Unavailable'] }
170
171
172 def connect_to_server(self, server, options={}):
173
174 LOG.info("begin connect_to_server")
175
176 try:
177 result = self.API.get_public_info(server.get('address'))
178
179 if not result:
180 LOG.error("Failed to connect to server: %s" % server.get('address'))
181 return { 'State': CONNECTION_STATE['Unavailable'] }
182
183 LOG.info("calling onSuccessfulConnection with server %s", server.get('Name'))
184
185 self._update_server_info(server, result)
186 credentials = self.credentials.get()
187 return self._after_connect_validated(server, credentials, result, True, options)
188
189 except Exception as e:
190 LOG.error(traceback.format_exc())
191 LOG.error("Failing server connection. ERROR msg: {}".format(e))
192 return { 'State': CONNECTION_STATE['Unavailable'] }
193
194 def connect(self, options={}, discover=True):
195
196 LOG.info("Begin connect")
197
198 servers = self.get_available_servers(discover)
199 LOG.info("connect has %s servers", len(servers))
200
201 if not (len(servers)): # No servers provided
202 return {
203 'State': ['ServerSelection']
204 }
205
206 result = self.connect_to_server(servers[0], options)
207 LOG.debug("resolving connect with result: %s", result)
208
209 return result
210
211 def jellyfin_user_id(self):
212 return self.get_server_info(self.server_id)['UserId']
213
214 def jellyfin_token(self):
215 return self.get_server_info(self.server_id)['AccessToken']
216
217 def get_server_info(self, server_id):
218
219 if server_id is None:
220 LOG.info("server_id is empty")
221 return {}
222
223 servers = self.credentials.get()['Servers']
224
225 for server in servers:
226 if server['Id'] == server_id:
227 return server
228
229 def get_public_users(self):
230 return self.client.jellyfin.get_public_users()
231
232 def get_jellyfin_url(self, base, handler):
233 return "%s/%s" % (base, handler)
234
235 def _server_discovery(self):
236 MULTI_GROUP = ("<broadcast>", 7359)
237 MESSAGE = b"who is JellyfinServer?"
238
239 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
240 sock.settimeout(1.0) # This controls the socket.timeout exception
241
242 sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 20)
243 sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
244 sock.setsockopt(socket.SOL_IP, socket.IP_MULTICAST_LOOP, 1)
245 sock.setsockopt(socket.IPPROTO_IP, socket.SO_REUSEADDR, 1)
246
247 LOG.debug("MultiGroup : %s", str(MULTI_GROUP))
248 LOG.debug("Sending UDP Data: %s", MESSAGE)
249
250 servers = []
251
252 try:
253 sock.sendto(MESSAGE, MULTI_GROUP)
254 except Exception as error:
255 LOG.exception(traceback.format_exc())
256 LOG.exception(error)
257 return servers
258
259 while True:
260 try:
261 data, addr = sock.recvfrom(1024) # buffer size
262 servers.append(json.loads(data))
263
264 except socket.timeout:
265 LOG.info("Found Servers: %s", servers)
266 return servers
267
268 except Exception as e:
269 LOG.error(traceback.format_exc())
270 LOG.exception("Error trying to find servers: %s", e)
271 return servers
272
273 def process_found_servers(self, found_servers):
274
275 servers = []
276
277 for found_server in found_servers:
278
279 server = self._convert_endpoint_address_to_manual_address(found_server)
280
281 info = {
282 'Id': found_server['Id'],
283 'address': server or found_server['Address'],
284 'Name': found_server['Name']
285 }
286
287 servers.append(info)
288 else:
289 return servers
290
291 # TODO: Make IPv6 compatable
292 def _convert_endpoint_address_to_manual_address(self, info):
293
294 if info.get('Address') and info.get('EndpointAddress'):
295 address = info['EndpointAddress'].split(':')[0]
296
297 # Determine the port, if any
298 parts = info['Address'].split(':')
299 if len(parts) > 1:
300 port_string = parts[len(parts) - 1]
301
302 try:
303 address += ":%s" % int(port_string)
304 return self._normalize_address(address)
305 except ValueError:
306 pass
307
308 return None
309
310 def _normalize_address(self, address):
311 # TODO: Try HTTPS first, then HTTP if that fails.
312 if '://' not in address:
313 address = 'http://' + address
314
315 # Attempt to correct bad input
316 url = urllib3.util.parse_url(address.strip())
317
318 if url.scheme is None:
319 url = url._replace(scheme='http')
320
321 if url.scheme == 'http' and url.port == 80:
322 url = url._replace(port=None)
323
324 if url.scheme == 'https' and url.port == 443:
325 url = url._replace(port=None)
326
327 return url.url
328
329 def _after_connect_validated(self, server, credentials, system_info, verify_authentication, options):
330 if options.get('enableAutoLogin') is False:
331
332 self.config.data['auth.user_id'] = server.pop('UserId', None)
333 self.config.data['auth.token'] = server.pop('AccessToken', None)
334
335 elif verify_authentication and server.get('AccessToken'):
336 system_info = self.API.validate_authentication_token(server)
337 if system_info:
338
339 self._update_server_info(server, system_info)
340 self.config.data['auth.user_id'] = server['UserId']
341 self.config.data['auth.token'] = server['AccessToken']
342
343 return self._after_connect_validated(server, credentials, system_info, False, options)
344
345 server['UserId'] = None
346 server['AccessToken'] = None
347 return { 'State': CONNECTION_STATE['Unavailable'] }
348
349 self._update_server_info(server, system_info)
350
351 server['DateLastAccessed'] = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
352 self.credentials.add_update_server(credentials['Servers'], server)
353 self.credentials.set(credentials)
354 self.server_id = server['Id']
355
356 # Update configs
357 self.config.data['auth.server'] = server['address']
358 self.config.data['auth.server-name'] = server['Name']
359 self.config.data['auth.server=id'] = server['Id']
360 self.config.data['auth.ssl'] = options.get('ssl', self.config.data['auth.ssl'])
361
362 result = {
363 'Servers': [server]
364 }
365
366 result['State'] = CONNECTION_STATE['SignedIn'] if server.get('AccessToken') else CONNECTION_STATE['ServerSignIn']
367 # Connected
368 return result
369
370 def _update_server_info(self, server, system_info):
371
372 if server is None or system_info is None:
373 return
374
375 server['Name'] = system_info['ServerName']
376 server['Id'] = system_info['Id']
377
378 if system_info.get('address'):
379 server['address'] = system_info['address']
diff --git a/jellyfin_apiclient_python/credentials.py b/jellyfin_apiclient_python/credentials.py
new file mode 100644
index 0000000..715abb5
--- /dev/null
+++ b/jellyfin_apiclient_python/credentials.py
@@ -0,0 +1,128 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3
4#################################################################################################
5
6import logging
7import time
8from datetime import datetime
9
10#################################################################################################
11
12LOG = logging.getLogger('JELLYFIN.' + __name__)
13
14#################################################################################################
15
16
17class Credentials(object):
18
19 credentials = None
20
21 def __init__(self):
22 LOG.debug("Credentials initializing...")
23 self.credentials = {}
24
25 def set_credentials(self, credentials):
26 self.credentials = credentials
27
28 def get_credentials(self):
29 return self.get()
30
31 def _ensure(self):
32
33 if not self.credentials:
34 try:
35 LOG.info(self.credentials)
36 if not isinstance(self.credentials, dict):
37 raise ValueError("invalid credentials format")
38
39 except Exception as e: # File is either empty or missing
40 LOG.warning(e)
41 self.credentials = {}
42
43 LOG.debug("credentials initialized with: %s", self.credentials)
44 self.credentials['Servers'] = self.credentials.setdefault('Servers', [])
45
46 def get(self):
47 self._ensure()
48
49 return self.credentials
50
51 def set(self, data):
52
53 if data:
54 self.credentials.update(data)
55 else:
56 self._clear()
57
58 LOG.debug("credentialsupdated")
59
60 def _clear(self):
61 self.credentials.clear()
62
63 def add_update_user(self, server, user):
64
65 for existing in server.setdefault('Users', []):
66 if existing['Id'] == user['Id']:
67 # Merge the data
68 existing['IsSignedInOffline'] = True
69 break
70 else:
71 server['Users'].append(user)
72
73 def add_update_server(self, servers, server):
74
75 if server.get('Id') is None:
76 raise KeyError("Server['Id'] cannot be null or empty")
77
78 # Add default DateLastAccessed if doesn't exist.
79 server.setdefault('DateLastAccessed', "2001-01-01T00:00:00Z")
80
81 for existing in servers:
82 if existing['Id'] == server['Id']:
83
84 # Merge the data
85 if server.get('DateLastAccessed'):
86 if self._date_object(server['DateLastAccessed']) > self._date_object(existing['DateLastAccessed']):
87 existing['DateLastAccessed'] = server['DateLastAccessed']
88
89 if server.get('UserLinkType'):
90 existing['UserLinkType'] = server['UserLinkType']
91
92 if server.get('AccessToken'):
93 existing['AccessToken'] = server['AccessToken']
94 existing['UserId'] = server['UserId']
95
96 if server.get('ExchangeToken'):
97 existing['ExchangeToken'] = server['ExchangeToken']
98
99 if server.get('ManualAddress'):
100 existing['ManualAddress'] = server['ManualAddress']
101
102 if server.get('LocalAddress'):
103 existing['LocalAddress'] = server['LocalAddress']
104
105 if server.get('Name'):
106 existing['Name'] = server['Name']
107
108 if server.get('LastConnectionMode') is not None:
109 existing['LastConnectionMode'] = server['LastConnectionMode']
110
111 if server.get('ConnectServerId'):
112 existing['ConnectServerId'] = server['ConnectServerId']
113
114 return existing
115 else:
116 servers.append(server)
117 return server
118
119 def _date_object(self, date):
120 # Convert string to date
121 try:
122 date_obj = time.strptime(date, "%Y-%m-%dT%H:%M:%SZ")
123 except (ImportError, TypeError):
124 # TypeError: attribute of type 'NoneType' is not callable
125 # Known Kodi/python error
126 date_obj = datetime(*(time.strptime(date, "%Y-%m-%dT%H:%M:%SZ")[0:6]))
127
128 return date_obj
diff --git a/jellyfin_apiclient_python/exceptions.py b/jellyfin_apiclient_python/exceptions.py
new file mode 100644
index 0000000..6cd5051
--- /dev/null
+++ b/jellyfin_apiclient_python/exceptions.py
@@ -0,0 +1,11 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3
4#################################################################################################
5
6
7class HTTPException(Exception):
8 # Jellyfin HTTP exception
9 def __init__(self, status, message):
10 self.status = status
11 self.message = message
diff --git a/jellyfin_apiclient_python/http.py b/jellyfin_apiclient_python/http.py
new file mode 100644
index 0000000..4cb7ae1
--- /dev/null
+++ b/jellyfin_apiclient_python/http.py
@@ -0,0 +1,267 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3
4#################################################################################################
5
6import json
7import logging
8import time
9import urllib
10
11import requests
12from six import string_types
13
14from .exceptions import HTTPException
15
16#################################################################################################
17
18LOG = logging.getLogger('Jellyfin.' + __name__)
19
20#################################################################################################
21
22
23class HTTP(object):
24
25 session = None
26 keep_alive = False
27
28 def __init__(self, client):
29
30 self.client = client
31 self.config = client.config
32
33 def start_session(self):
34
35 self.session = requests.Session()
36
37 max_retries = self.config.data['http.max_retries']
38 self.session.mount("http://", requests.adapters.HTTPAdapter(max_retries=max_retries))
39 self.session.mount("https://", requests.adapters.HTTPAdapter(max_retries=max_retries))
40
41 def stop_session(self):
42
43 if self.session is None:
44 return
45
46 try:
47 LOG.info("--<[ session/%s ]", id(self.session))
48 self.session.close()
49 except Exception as error:
50 LOG.warning("The requests session could not be terminated: %s", error)
51
52 def _replace_user_info(self, string):
53
54 if '{server}' in string:
55 if self.config.data.get('auth.server', None):
56 string = string.replace("{server}", self.config.data['auth.server'])
57 else:
58 LOG.debug("Server address not set")
59
60 if '{UserId}'in string:
61 if self.config.data.get('auth.user_id', None):
62 string = string.replace("{UserId}", self.config.data['auth.user_id'])
63 else:
64 LOG.debug("UserId is not set.")
65
66 if '{DeviceId}'in string:
67 if self.config.data.get('app.device_id', None):
68 string = string.replace("{DeviceId}", self.config.data['app.device_id'])
69 else:
70 LOG.debug("DeviceId is not set.")
71
72 return string
73
74 def request_url(self, data):
75 if not data:
76 raise AttributeError("Request cannot be empty")
77
78 data = self._request(data)
79
80 params = data["params"]
81 if "api_key" not in params:
82 params["api_key"] = self.config.data.get('auth.token')
83
84 encoded_params = urllib.parse.urlencode(data["params"])
85 return "%s?%s" % (data["url"], encoded_params)
86
87 def request(self, data, session=None, dest_file=None):
88
89 ''' Give a chance to retry the connection. Jellyfin sometimes can be slow to answer back
90 data dictionary can contain:
91 type: GET, POST, etc.
92 url: (optional)
93 handler: not considered when url is provided (optional)
94 params: request parameters (optional)
95 json: request body (optional)
96 headers: (optional),
97 verify: ssl certificate, True (verify using device built-in library) or False
98 '''
99 if not data:
100 raise AttributeError("Request cannot be empty")
101
102 data = self._request(data)
103 LOG.debug("--->[ http ] %s", json.dumps(data, indent=4))
104 retry = data.pop('retry', 5)
105 stream = dest_file is not None
106
107 while True:
108
109 try:
110 r = self._requests(session or self.session or requests, data.pop('type', "GET"), **data, stream=stream)
111 if stream:
112 for chunk in r.iter_content(chunk_size=8192):
113 if chunk: # filter out keep-alive new chunks
114 dest_file.write(chunk)
115 else:
116 r.content # release the connection
117
118 if not self.keep_alive and self.session is not None:
119 self.stop_session()
120
121 r.raise_for_status()
122
123 except requests.exceptions.ConnectionError as error:
124 if retry:
125
126 retry -= 1
127 time.sleep(1)
128
129 continue
130
131 LOG.error(error)
132 self.client.callback("ServerUnreachable", {'ServerId': self.config.data['auth.server-id']})
133
134 raise HTTPException("ServerUnreachable", error)
135
136 except requests.exceptions.ReadTimeout as error:
137 if retry:
138
139 retry -= 1
140 time.sleep(1)
141
142 continue
143
144 LOG.error(error)
145
146 raise HTTPException("ReadTimeout", error)
147
148 except requests.exceptions.HTTPError as error:
149 LOG.error(error)
150
151 if r.status_code == 401:
152
153 if 'X-Application-Error-Code' in r.headers:
154 self.client.callback("AccessRestricted", {'ServerId': self.config.data['auth.server-id']})
155
156 raise HTTPException("AccessRestricted", error)
157 else:
158 self.client.callback("Unauthorized", {'ServerId': self.config.data['auth.server-id']})
159 self.client.auth.revoke_token()
160
161 raise HTTPException("Unauthorized", error)
162
163 elif r.status_code == 500: # log and ignore.
164 LOG.error("--[ 500 response ] %s", error)
165
166 return
167
168 elif r.status_code == 502:
169 if retry:
170
171 retry -= 1
172 time.sleep(1)
173
174 continue
175
176 raise HTTPException(r.status_code, error)
177
178 except requests.exceptions.MissingSchema as error:
179 LOG.error("Request missing Schema. " + str(error))
180 raise HTTPException("MissingSchema", {'Id': self.config.data.get('auth.server', "None")})
181
182 except Exception as error:
183 raise
184
185 else:
186 try:
187 if stream:
188 return
189 self.config.data['server-time'] = r.headers['Date']
190 elapsed = int(r.elapsed.total_seconds() * 1000)
191 response = r.json()
192 LOG.debug("---<[ http ][%s ms]", elapsed)
193 LOG.debug(json.dumps(response, indent=4))
194
195 return response
196 except ValueError:
197 return
198
199 def _request(self, data):
200
201 if 'url' not in data:
202 data['url'] = "%s/%s" % (self.config.data.get("auth.server", ""), data.pop('handler', ""))
203
204 self._get_header(data)
205 data['timeout'] = data.get('timeout') or self.config.data['http.timeout']
206 data['verify'] = data.get('verify') or self.config.data.get('auth.ssl', False)
207 data['url'] = self._replace_user_info(data['url'])
208 self._process_params(data.get('params') or {})
209 self._process_params(data.get('json') or {})
210
211 return data
212
213 def _process_params(self, params):
214
215 for key in params:
216 value = params[key]
217
218 if isinstance(value, dict):
219 self._process_params(value)
220
221 if isinstance(value, string_types):
222 params[key] = self._replace_user_info(value)
223
224 def _get_header(self, data):
225
226 data['headers'] = data.setdefault('headers', {})
227
228 if not data['headers']:
229 data['headers'].update({
230 'Content-type': "application/json",
231 'Accept-Charset': "UTF-8,*",
232 'Accept-encoding': "gzip",
233 'User-Agent': self.config.data['http.user_agent'] or "%s/%s" % (self.config.data.get('app.name', 'Jellyfin for Kodi'), self.config.data.get('app.version', "0.0.0"))
234 })
235
236 if 'x-emby-authorization' not in data['headers']:
237 self._authorization(data)
238
239 return data
240
241 def _authorization(self, data):
242
243 auth = "MediaBrowser "
244 auth += "Client=%s, " % self.config.data.get('app.name', "Jellyfin for Kodi")
245 auth += "Device=%s, " % self.config.data.get('app.device_name', 'Unknown Device')
246 auth += "DeviceId=%s, " % self.config.data.get('app.device_id', 'Unknown Device id')
247 auth += "Version=%s" % self.config.data.get('app.version', '0.0.0')
248
249 data['headers'].update({'x-emby-authorization': auth})
250
251 if self.config.data.get('auth.token') and self.config.data.get('auth.user_id'):
252
253 auth += ', UserId=%s' % self.config.data.get('auth.user_id')
254 data['headers'].update({'x-emby-authorization': auth, 'X-MediaBrowser-Token': self.config.data.get('auth.token')})
255
256 return data
257
258 def _requests(self, session, action, **kwargs):
259
260 if action == "GET":
261 return session.get(**kwargs)
262 elif action == "POST":
263 return session.post(**kwargs)
264 elif action == "HEAD":
265 return session.head(**kwargs)
266 elif action == "DELETE":
267 return session.delete(**kwargs)
diff --git a/jellyfin_apiclient_python/keepalive.py b/jellyfin_apiclient_python/keepalive.py
new file mode 100644
index 0000000..7d9e40e
--- /dev/null
+++ b/jellyfin_apiclient_python/keepalive.py
@@ -0,0 +1,20 @@
1import threading
2
3class KeepAlive(threading.Thread):
4 def __init__(self, timeout, ws):
5 self.halt = threading.Event()
6 self.timeout = timeout
7 self.ws = ws
8
9 threading.Thread.__init__(self)
10
11 def stop(self):
12 self.halt.set()
13 self.join()
14
15 def run(self):
16 while not self.halt.is_set():
17 if self.halt.wait(self.timeout/2):
18 break
19 else:
20 self.ws.send("KeepAlive")
diff --git a/jellyfin_apiclient_python/timesync_manager.py b/jellyfin_apiclient_python/timesync_manager.py
new file mode 100644
index 0000000..5c4e98e
--- /dev/null
+++ b/jellyfin_apiclient_python/timesync_manager.py
@@ -0,0 +1,140 @@
1# This is based on https://github.com/jellyfin/jellyfin-web/blob/master/src/components/syncPlay/timeSyncManager.js
2import threading
3import logging
4import datetime
5
6LOG = logging.getLogger('Jellyfin.' + __name__)
7
8number_of_tracked_measurements = 8
9polling_interval_greedy = 1
10polling_interval_low_profile = 60
11greedy_ping_count = 3
12
13
14class Measurement:
15 def __init__(self, request_sent, request_received, response_sent, response_received):
16 self.request_sent = request_sent
17 self.request_received = request_received
18 self.response_sent = response_sent
19 self.response_received = response_received
20
21 def get_offset(self):
22 """Time offset from server."""
23 return ((self.request_received - self.request_sent) + (self.response_sent - self.response_received)) / 2.0
24
25 def get_delay(self):
26 """Get round-trip delay."""
27 return (self.response_received - self.request_sent) - (self.response_sent - self.request_received)
28
29 def get_ping(self):
30 """Get ping time."""
31 return self.get_delay() / 2.0
32
33
34class _TimeSyncThread(threading.Thread):
35 def __init__(self, manager):
36 self.manager = manager
37 self.halt = threading.Event()
38 threading.Thread.__init__(self)
39
40 def run(self):
41 while not self.halt.wait(self.manager.polling_interval):
42 try:
43 measurement = self.manager.client.jellyfin.utc_time()
44 measurement = Measurement(measurement["request_sent"], measurement["request_received"],
45 measurement["response_sent"], measurement["response_received"])
46
47 self.manager.update_time_offset(measurement)
48
49 if self.manager.pings > greedy_ping_count:
50 self.manager.polling_interval = polling_interval_low_profile
51 else:
52 self.manager.pings += 1
53
54 self.manager._notify_subscribers()
55 except Exception:
56 LOG.error("Timesync call failed.", exc_info=True)
57
58 def stop(self):
59 self.halt.set()
60 self.join()
61
62
63class TimeSyncManager:
64 def __init__(self, client):
65 self.ping_stop = True
66 self.polling_interval = polling_interval_greedy
67 self.poller = None
68 self.pings = 0 # number of pings
69 self.measurement = None # current time sync
70 self.measurements = []
71 self.client = client
72 self.timesync_thread = None
73 self.subscribers = set()
74
75 def is_ready(self):
76 """Gets status of time sync."""
77 return self.measurement is not None
78
79 def get_time_offset(self):
80 """Gets time offset with server."""
81 return self.measurement.get_offset() if self.measurement is not None else datetime.timedelta(0)
82
83 def get_ping(self):
84 """Gets ping time to server."""
85 return self.measurement.get_ping() if self.measurement is not None else datetime.timedelta(0)
86
87 def update_time_offset(self, measurement):
88 """Updates time offset between server and client."""
89 self.measurements.append(measurement)
90 if len(self.measurements) > number_of_tracked_measurements:
91 self.measurements.pop(0)
92
93 self.measurement = min(self.measurements, key=lambda x: x.get_delay())
94
95 def reset_measurements(self):
96 """Drops accumulated measurements."""
97 self.measurement = None
98 self.measurements = []
99
100 def start_ping(self):
101 """Starts the time poller."""
102 if not self.timesync_thread:
103 self.timesync_thread = _TimeSyncThread(self)
104 self.timesync_thread.start()
105
106 def stop_ping(self):
107 """Stops the time poller."""
108 if self.timesync_thread:
109 self.timesync_thread.stop()
110 self.timesync_thread = None
111
112 def force_update(self):
113 """Resets poller into greedy mode."""
114 self.stop_ping()
115 self.polling_interval = polling_interval_greedy
116 self.pings = 0
117 self.start_ping()
118
119 def server_date_to_local(self, server):
120 """Converts server time to local time."""
121 return server - self.get_time_offset()
122
123 def local_date_to_server(self, local):
124 """Converts local time to server time."""
125 return local + self.get_time_offset()
126
127 def subscribe_time_offset(self, subscriber_callable):
128 """Pass a callback function to get notified about time offset changes."""
129 self.subscribers.add(subscriber_callable)
130
131 def remove_subscriber(self, subscriber_callable):
132 """Remove a callback function from notifications."""
133 self.subscribers.remove(subscriber_callable)
134
135 def _notify_subscribers(self):
136 for subscriber in self.subscribers:
137 try:
138 subscriber(self.get_time_offset(), self.get_ping())
139 except Exception:
140 LOG.error("Exception in subscriber callback.")
diff --git a/jellyfin_apiclient_python/ws_client.py b/jellyfin_apiclient_python/ws_client.py
new file mode 100644
index 0000000..d36310b
--- /dev/null
+++ b/jellyfin_apiclient_python/ws_client.py
@@ -0,0 +1,140 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3
4#################################################################################################
5
6import json
7import logging
8import threading
9import ssl
10import certifi
11
12import websocket
13
14from .keepalive import KeepAlive
15
16##################################################################################################
17
18LOG = logging.getLogger('JELLYFIN.' + __name__)
19
20##################################################################################################
21
22
23class WSClient(threading.Thread):
24 multi_client = False
25 global_wsc = None
26 global_stop = False
27
28 def __init__(self, client, allow_multiple_clients=False):
29
30 LOG.debug("WSClient initializing...")
31
32 self.client = client
33 self.keepalive = None
34 self.wsc = None
35 self.stop = False
36 self.message_ids = set()
37
38 if self.multi_client or allow_multiple_clients:
39 self.multi_client = True
40
41 threading.Thread.__init__(self)
42
43 def send(self, message, data=""):
44 if self.wsc is None:
45 raise ValueError("The websocket client is not started.")
46
47 self.wsc.send(json.dumps({'MessageType': message, "Data": data}))
48
49 def run(self):
50
51 token = self.client.config.data['auth.token']
52 device_id = self.client.config.data['app.device_id']
53 server = self.client.config.data['auth.server']
54 server = server.replace('https', "wss") if server.startswith('https') else server.replace('http', "ws")
55 wsc_url = "%s/socket?api_key=%s&device_id=%s" % (server, token, device_id)
56 verify = self.client.config.data.get('auth.ssl', False)
57
58 LOG.info("Websocket url: %s", wsc_url)
59
60 self.wsc = websocket.WebSocketApp(wsc_url,
61 on_message=lambda ws, message: self.on_message(ws, message),
62 on_error=lambda ws, error: self.on_error(ws, error))
63 self.wsc.on_open = lambda ws: self.on_open(ws)
64
65 if not self.multi_client:
66 if self.global_wsc is not None:
67 self.global_wsc.close()
68 self.global_wsc = self.wsc
69
70 while not self.stop and not self.global_stop:
71 if not verify:
72 # https://stackoverflow.com/questions/48740053/
73 self.wsc.run_forever(
74 ping_interval=10, sslopt={"cert_reqs": ssl.CERT_NONE}
75 )
76 else:
77 self.wsc.run_forever(ping_interval=10, sslopt={"ca_certs": certifi.where()})
78
79 if not self.stop:
80 break
81
82 LOG.info("---<[ websocket ]")
83 self.client.callback('WebSocketDisconnect', None)
84
85 def on_error(self, ws, error):
86 LOG.error(error)
87 self.client.callback('WebSocketError', error)
88
89 def on_open(self, ws):
90 LOG.info("--->[ websocket ]")
91 self.client.callback('WebSocketConnect', None)
92
93 def on_message(self, ws, message):
94
95 message = json.loads(message)
96
97 # If a message is received multiple times, ignore repeats.
98 message_id = message.get("MessageId")
99 if message_id is not None:
100 if message_id in self.message_ids:
101 return
102 self.message_ids.add(message_id)
103
104 data = message.get('Data', {})
105
106 if message['MessageType'] == "ForceKeepAlive":
107 self.send("KeepAlive")
108 if self.keepalive is not None:
109 self.keepalive.stop()
110 self.keepalive = KeepAlive(data, self)
111 self.keepalive.start()
112 LOG.debug("ForceKeepAlive received from server.")
113 return
114 elif message['MessageType'] == "KeepAlive":
115 LOG.debug("KeepAlive received from server.")
116 return
117
118 if data is None:
119 data = {}
120 elif type(data) is not dict:
121 data = {"value": data}
122
123 if not self.client.config.data['app.default']:
124 data['ServerId'] = self.client.auth.server_id
125
126 self.client.callback(message['MessageType'], data)
127
128 def stop_client(self):
129
130 self.stop = True
131
132 if self.keepalive is not None:
133 self.keepalive.stop()
134
135 if self.wsc is not None:
136 self.wsc.close()
137
138 if not self.multi_client:
139 self.global_stop = True
140 self.global_wsc = None