cocorum 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cocorum/__init__.py ADDED
@@ -0,0 +1,543 @@
1
+ #!/usr/bin/env python3
2
+ """Cocorum: a Python wrapper for the Rumble.com API
3
+
4
+ S.D.G."""
5
+
6
+ import time
7
+ import calendar
8
+ import requests
9
+
10
+ from .localvars import *
11
+ from .utils import *
12
+
13
+ class RumbleAPISubObj():
14
+ """Abstract class for a Rumble API object"""
15
+ def __init__(self, json):
16
+ """Pass the JSON block for a single Rumble API subobject"""
17
+ self._json = json
18
+
19
+ def __getitem__(self, key):
20
+ """Get a key from the JSON"""
21
+ return self._json[key]
22
+
23
+ class RumbleUserAction(RumbleAPISubObj):
24
+ """Abstract class for Rumble user actions"""
25
+ def __init__(self, json):
26
+ """Pass the JSON block for a single Rumble user action"""
27
+ super().__init__(json)
28
+ self.__profile_pic = None
29
+
30
+ def __eq__(self, other):
31
+ """Is this follower equal to another"""
32
+ #Check if the compared string is our username
33
+ if isinstance(other, str):
34
+ return self.username == other
35
+
36
+ #Check if the compared object has a username and if it matches our own
37
+ if hasattr(other, "username"):
38
+ return self.username == other.username
39
+
40
+ def __str__(self):
41
+ """Follower as a string"""
42
+ return self.username
43
+
44
+ @property
45
+ def username(self):
46
+ """The follower's username"""
47
+ return self["username"]
48
+
49
+ @property
50
+ def profile_pic_url(self):
51
+ """The user's profile picture URL"""
52
+ return self["profile_pic_url"]
53
+
54
+ @property
55
+ def profile_pic(self):
56
+ """The user's profile picture as a bytes string"""
57
+ if not self.profile_pic_url: #The profile picture is blank
58
+ return b''
59
+
60
+ if not self.__profile_pic: #We never queried the profile pic before
61
+ response = requests.get(self.profile_pic_url)
62
+ if response.status_code != 200:
63
+ #TODO make this timeout assignable
64
+ raise Exception("Status code " + str(response.status_code), DEFAULT_TIMEOUT)
65
+
66
+ self.__profile_pic = response.content
67
+
68
+ return self.__profile_pic
69
+
70
+ class RumbleFollower(RumbleUserAction):
71
+ """Rumble follower"""
72
+ @property
73
+ def followed_on(self):
74
+ """When the follower followed, in seconds since Epoch UTC"""
75
+ return parse_timestamp(self["followed_on"])
76
+
77
+ class RumbleSubscriber(RumbleUserAction):
78
+ """Rumble subscriber"""
79
+ def __eq__(self, other):
80
+ """Is this subscriber equal to another"""
81
+ #Check if the compared string is our username
82
+ if isinstance(other, str):
83
+ return self.username == other
84
+
85
+ #check if the compared number is our amount in cents
86
+ if isinstance(other, (int, float)):
87
+ return self.amount_cents == other
88
+
89
+ #Check if the compared object's username matches our own, if it has one
90
+ if hasattr(other, "username"):
91
+ #Check if the compared object's cost amout matches our own, if it has one
92
+ if hasattr(other, "amount_cents"):
93
+ self.amount_cents == other.amount_cents
94
+
95
+ #Other object has no amount_cents attribute
96
+ return self.username == other.username
97
+
98
+ @property
99
+ def user(self):
100
+ """AFAIK this is being deprecated, use username instead"""
101
+ return self["user"]
102
+
103
+ @property
104
+ def amount_cents(self):
105
+ """The total subscription amount in cents"""
106
+ return self["amount_cents"]
107
+
108
+ @property
109
+ def amount_dollars(self):
110
+ """The subscription amount in dollars"""
111
+ return self["amount_dollars"]
112
+
113
+ @property
114
+ def subscribed_on(self):
115
+ """When the subscriber subscribed, in seconds since Epoch UTC"""
116
+ return parse_timestamp(self["subscribed_on"])
117
+
118
+ class RumbleStreamCategory(RumbleAPISubObj):
119
+ """Category of a Rumble stream"""
120
+
121
+ @property
122
+ def slug(self):
123
+ """Return the category's slug, AKA it's ID"""
124
+ return self["slug"]
125
+
126
+ @property
127
+ def title(self):
128
+ """Return the category's title"""
129
+ return self["title"]
130
+
131
+ def __eq__(self, other):
132
+ """Is this category equal to another"""
133
+ #Check if the compared string is our slug or title
134
+ if isinstance(other, str):
135
+ return other in (self.slug, self.title)
136
+
137
+ #Check if the compared object has the same slug, if it has one
138
+ if hasattr(other, "slug"):
139
+ return self.slug == other.slug
140
+
141
+ def __str__(self):
142
+ """The category in string form"""
143
+ return self.title
144
+
145
+ class RumbleLivestream():
146
+ """Rumble livestream"""
147
+ def __init__(self, json, api):
148
+ """Pass the JSON block of a single Rumble livestream"""
149
+ self._json = json
150
+ self.api = api
151
+ self.is_disappeared = False #The livestream is in the API listing
152
+ self.__chat = RumbleLiveChat(self)
153
+
154
+ def __eq__(self, other):
155
+ """Is this stream equal to another"""
156
+ #Check if the compared string is our stream ID
157
+ if isinstance(other, str):
158
+ return self.stream_id == other #or self.title == other
159
+
160
+ #check if the compared number is our chat ID (linked to stream ID)
161
+ if isinstance(other, (int, float)):
162
+ return self.chat_id == other
163
+
164
+ #Check if the compared object has the same stream ID
165
+ if hasattr(other, "stream_id"):
166
+ return self.stream_id == other.stream_id
167
+
168
+ #Check if the compared object has the same chat ID
169
+ if hasattr(other, "chat_id"):
170
+ return self.stream_id == other.chat_id
171
+
172
+ def __str__(self):
173
+ """The livestream in string form"""
174
+ return self.stream_id
175
+
176
+ def __getitem__(self, key):
177
+ """Return a key from the JSON, refreshing if necessary"""
178
+ #The livestream has not disappeared from the API listing,
179
+ #the key requested is not a value that doesn't change,
180
+ #and it has been api.refresh rate since the last time we refreshed
181
+ if (not self.is_disappeared) and (key not in STATIC_KEYS_STREAM) and (time.time() - self.api.last_refresh_time > self.api.refresh_rate):
182
+ self.api.refresh()
183
+
184
+ return self._json[key]
185
+
186
+ @property
187
+ def stream_id(self):
188
+ """The livestream ID"""
189
+ return self["id"]
190
+
191
+ @property
192
+ def chat_id(self):
193
+ """The livestream chat ID"""
194
+ return id_stream_to_chat(self.stream_id)
195
+
196
+ @property
197
+ def title(self):
198
+ """The title of the livestream"""
199
+ return self["title"]
200
+
201
+ @property
202
+ def created_on(self):
203
+ """When the livestream was created, in seconds since the Epock UTC"""
204
+ return parse_timestamp(self["created_on"])
205
+
206
+ @property
207
+ def is_live(self):
208
+ """Is the stream live?"""
209
+ return self["is_live"] and not self.is_disappeared
210
+
211
+ @property
212
+ def visibility(self):
213
+ """TODO"""
214
+ return self["visibility"]
215
+
216
+ @property
217
+ def categories(self):
218
+ """A list of our categories"""
219
+ data = self["categories"].copy().values()
220
+ return [RumbleStreamCategory(json_block) for json_block in data]
221
+
222
+ @property
223
+ def likes(self):
224
+ """Number of likes on the stream"""
225
+ return self["likes"]
226
+
227
+ @property
228
+ def dislikes(self):
229
+ """Number of dislikes on the stream"""
230
+
231
+ @property
232
+ def like_ratio(self):
233
+ """Ratio of people who liked the stream to people who reacted total"""
234
+ try:
235
+ return self.likes / (self.likes + self.dislikes)
236
+
237
+ except ZeroDivisionError:
238
+ return None
239
+
240
+ @property
241
+ def watching_now(self):
242
+ """The number of people watching now"""
243
+ return self["watching_now"]
244
+
245
+ @property
246
+ def chat(self):
247
+ """The livestream chat"""
248
+ return self.__chat
249
+
250
+ class RumbleChatMessage(RumbleUserAction):
251
+ """A single message in a Rumble livestream chat"""
252
+ def __eq__(self, other):
253
+ """Is this message equal to another"""
254
+ #Check if the compared string is our message
255
+ if isinstance(other, str):
256
+ return self.text == other
257
+
258
+ # #Check if the compared message has the same username and text (not needed)
259
+ # if type(other) == type(self):
260
+ # return (self.username, self.text) == (other.username, other.text)
261
+
262
+ #Check if the compared object has the same text
263
+ if hasattr(other, "text"):
264
+ #Check if the compared object has the same username, if it has one
265
+ if hasattr(other, "username"):
266
+ return (self.username, self.text) == (other.username, other.text)
267
+
268
+ return self.text == other.text #the other object had no username attribute
269
+
270
+ def __str__(self):
271
+ """Follower as a string"""
272
+ return self.text
273
+
274
+ @property
275
+ def text(self):
276
+ """The message text"""
277
+ return self["text"]
278
+
279
+ @property
280
+ def created_on(self):
281
+ """When the message was created, in seconds since Epoch UTC"""
282
+ return parse_timestamp(self["created_on"])
283
+
284
+ @property
285
+ def badges(self):
286
+ """The user's badges"""
287
+ return tuple(self["badges"].values())
288
+
289
+ class RumbleRant(RumbleChatMessage):
290
+ """A single rant in a Rumble livestream chat"""
291
+ def __eq__(self, other):
292
+ """Is this category equal to another"""
293
+ #Check if the compared string is our message
294
+ if isinstance(other, str):
295
+ return self.text == other
296
+
297
+ # #Check if the compared rant has the same username, amount, and text (unneccesary?)
298
+ # if type(other) == type(self):
299
+ # return (self.username, self.amount_cents, self.text) == (other.username, other.amount_cents, other.text)
300
+
301
+ #Check if the compared object has the same text
302
+ if hasattr(other, "text"):
303
+ #Check if the compared object has the same username, if it has one
304
+ if hasattr(other, "username"):
305
+ #Check if the compared object has the same cost amount, if it has one
306
+ if hasattr(other, "amount_cents"):
307
+ return (self.username, self.amount_cents, self.text) == (other.username, other.amount_cents, other.text)
308
+
309
+ #Other object has no amount_cents attribute
310
+ return (self.username, self.text) == (other.username, other.text)
311
+
312
+ #Other object had no username attribute
313
+ return self.text == other.text
314
+
315
+ @property
316
+ def expires_on(self):
317
+ """When the rant will expire, in seconds since the Epoch UTC"""
318
+ return self["expires_on"]
319
+
320
+ @property
321
+ def amount_cents(self):
322
+ """The total rant amount in cents"""
323
+ return self["amount_cents"]
324
+
325
+ @property
326
+ def amount_dollars(self):
327
+ """The rant amount in dollars"""
328
+ return self["amount_dollars"]
329
+
330
+ class RumbleLiveChat():
331
+ """Reference for chat of a Rumble livestream"""
332
+ def __init__(self, stream):
333
+ """Pass the JSON block of a single Rumble livestream"""
334
+ self.stream = stream
335
+ self.api = stream.api
336
+ self.last_newmessage_time = 0 #Last time we were checked for "new" messages
337
+ self.last_newrant_time = 0 #Last time we were checked for "new" rants
338
+
339
+ def __getitem__(self, key):
340
+ """Return a key from the stream's chat JSON"""
341
+ return self.stream._json["chat"][key]
342
+
343
+ @property
344
+ def latest_message(self):
345
+ """The latest chat message"""
346
+ if not self["latest_message"]:
347
+ return None #No-one has chatted on this stream yet
348
+ return RumbleChatMessage(self["latest_message"])
349
+
350
+ @property
351
+ def recent_messages(self):
352
+ """Recent chat messages"""
353
+ data = self["recent_messages"].copy()
354
+ return [RumbleChatMessage(json_block) for json_block in data]
355
+
356
+ @property
357
+ def new_messages(self):
358
+ """Chat messages that are newer than the last time this was referenced"""
359
+ rem = self.recent_messages.copy()
360
+ rem.sort(key = lambda x: x.created_on) #Sort the messages so the newest ones are last
361
+
362
+ if rem[-1].created_on < self.last_newmessage_time: #All messages are older than the last time we checked
363
+ return []
364
+
365
+ i = 0
366
+ for i, m in enumerate(rem):
367
+ if m.created_on > self.last_newmessage_time:
368
+ break #i is now the index of the oldest new message
369
+
370
+ self.last_newmessage_time = time.time()
371
+ return rem[i:]
372
+
373
+ @property
374
+ def latest_rant(self):
375
+ """The latest chat rant"""
376
+ if not self["latest_rant"]:
377
+ return None #No-one has ranted on this stream yet
378
+ return RumbleRant(self["latest_rant"])
379
+
380
+ @property
381
+ def recent_rants(self):
382
+ """Recent chat rants"""
383
+ data = self["recent_rants"].copy()
384
+ return [RumbleRant(json_block) for json_block in data]
385
+
386
+ @property
387
+ def new_rants(self):
388
+ """Chat rants that are newer than the last time this was referenced"""
389
+ rera = self.recent_rants.copy()
390
+ rera.sort(key = lambda x: x.created_on) #Sort the rants so the newest ones are last
391
+
392
+ if rera[-1].created_on < self.last_newrant_time: #All rants are older than the last time we checked
393
+ return []
394
+
395
+ i = 0
396
+ for i, r in enumerate(rera):
397
+ if r.created_on > self.last_newrant_time:
398
+ break #i is now the index of the oldest new rant
399
+
400
+ self.last_newrant_time = time.time()
401
+ return rera[i:]
402
+
403
+ class RumbleAPI():
404
+ """Rumble API wrapper"""
405
+ def __init__(self, api_url, refresh_rate = DEFAULT_REFRESH_RATE, request_timeout = DEFAULT_TIMEOUT):
406
+ """Pass the Rumble API URL, and how long to wait before refreshing on new queries"""
407
+ self.refresh_rate = refresh_rate
408
+ self.request_timeout = request_timeout
409
+ self.last_refresh_time = 0
410
+ self.__livestreams = {}
411
+ self._json = {}
412
+ self.api_url = api_url
413
+
414
+ @property
415
+ def api_url(self):
416
+ """Our API URL"""
417
+ return self.__api_url
418
+
419
+ @api_url.setter
420
+ def api_url(self, url):
421
+ """Set our API URL"""
422
+ self.__api_url = url
423
+ self.refresh()
424
+
425
+ def __getitem__(self, key):
426
+ """Return a key from the JSON, refreshing if necessary"""
427
+ if key not in STATIC_KEYS and time.time() - self.last_refresh_time > self.refresh_rate:
428
+ self.refresh()
429
+ return self._json[key]
430
+
431
+ def check_refresh(self):
432
+ """Refresh only if we are past the refresh rate"""
433
+ if time.time() - self.last_refresh_time > self.refresh_rate:
434
+ self.refresh()
435
+
436
+ def refresh(self):
437
+ """Reload data from the API"""
438
+ self.last_refresh_time = time.time()
439
+ response = requests.get(self.api_url, headers = HEADERS, timeout = self.request_timeout)
440
+ if response.status_code != 200:
441
+ raise Exception("Status code " + str(response.status_code))
442
+
443
+ self._json = response.json()
444
+
445
+ #Remove livestream references that are no longer listed
446
+ listed_ids = [json["id"] for json in self._json["livestreams"]]
447
+ for stream_id in self.__livestreams.copy():
448
+ if stream_id not in listed_ids:
449
+ self.__livestreams[stream_id].is_disappeared = True
450
+ del self.__livestreams[stream_id]
451
+
452
+ #Update livestream references' JSONs in-place
453
+ for json in self._json["livestreams"]:
454
+ try:
455
+ #Update the JSON of the stored livestream
456
+ self.__livestreams[json["id"]]._json = json
457
+
458
+ except KeyError: #The livestream has not been stored yet
459
+ self.__livestreams[json["id"]] = RumbleLivestream(json, self)
460
+
461
+ @property
462
+ def api_type(self):
463
+ """Type of API URL in use, user or channel"""
464
+ return self["type"]
465
+
466
+ @property
467
+ def user_id(self):
468
+ """The user ID"""
469
+ return self["user_id"]
470
+
471
+ @property
472
+ def username(self):
473
+ """The username"""
474
+ return self["username"]
475
+
476
+ @property
477
+ def channel_id(self):
478
+ """The channel ID, if we are a channel"""
479
+ return self["channel_id"]
480
+
481
+ @property
482
+ def channel_name(self):
483
+ """The channel name, if we are a channel"""
484
+ return self["channel_name"]
485
+
486
+ @property
487
+ def num_followers(self):
488
+ """The number of followers of this user or channel"""
489
+ return self["followers"]["num_followers"]
490
+
491
+ @property
492
+ def num_followers_total(self):
493
+ """The total number of followers of this account across all channels"""
494
+ return self["followers"]["num_followers_total"]
495
+
496
+ @property
497
+ def latest_follower(self):
498
+ """The latest follower of this user or channel"""
499
+ if not self["followers"]["latest_follower"]:
500
+ return None #No-one has followed this user or channel yet
501
+ return RumbleFollower(self["followers"]["latest_follower"])
502
+
503
+ @property
504
+ def recent_followers(self):
505
+ """A list (technically a shallow generator object) of recent followers"""
506
+ data = self["followers"]["recent_followers"].copy()
507
+ return [RumbleFollower(json_block) for json_block in data]
508
+
509
+ @property
510
+ def num_subscribers(self):
511
+ """The number of subscribers of this user or channel"""
512
+ return self["subscribers"]["num_subscribers"]
513
+
514
+ @property
515
+ def num_subscribers_total(self):
516
+ """The total number of subscribers of this account across all channels"""
517
+ return self["subscribers"]["num_subscribers_total"]
518
+
519
+ @property
520
+ def latest_subscriber(self):
521
+ """The latest subscriber of this user or channel"""
522
+ if not self["subscribers"]["latest_subscriber"]:
523
+ return None #No-one has subscribed to this user or channel yet
524
+ return RumbleSubscriber(self["subscribers"]["latest_subscriber"])
525
+
526
+ @property
527
+ def recent_subscribers(self):
528
+ """A list (technically a shallow generator object) of recent subscribers"""
529
+ data = self["subscribers"]["recent_subscribers"].copy()
530
+ return [RumbleSubscriber(json_block) for json_block in data]
531
+
532
+ @property
533
+ def livestreams(self):
534
+ """A dictionairy of our livestreams"""
535
+ self.check_refresh()
536
+ return self.__livestreams
537
+
538
+ @property
539
+ def latest_livestream(self):
540
+ """Return latest livestream to be created. Use this to get a single running livestream"""
541
+ if not self.livestreams:
542
+ return None #No livestreams are running
543
+ return max(self.livestreams.values(), key = lambda x: x.created_on)
cocorum/localvars.py ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env python3
2
+ """Cocorum local variable definitions
3
+
4
+ S.D.G."""
5
+
6
+ #Rumble timestamp format, not including the 6 TODO characters at the end
7
+ TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S"
8
+
9
+ #Headers for the Rumble Live Stream API request (currently must fake a User-Agent string)
10
+ HEADERS = {"User-Agent" : "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36"}
11
+
12
+ #Keys of the API JSON that should not change unless the API URL changes, and so do not trigger a refresh
13
+ STATIC_KEYS = [
14
+ "user_id",
15
+ "username",
16
+ "channel_id",
17
+ "channel_name",
18
+ ]
19
+
20
+ #Keys of the API JSON stream object that should not change unless the API URL changes, and so do not trigger a refresh
21
+ STATIC_KEYS_STREAM = [
22
+ "id",
23
+ "created_on"
24
+ ]
25
+
26
+ #API types, under JSON["type"]
27
+ API_TYPE_USER = "user"
28
+ API_TYPE_CHANNEL = "channel"
29
+
30
+ #Stream visibility possibilities, under JSON["livestreams"][0]["visibility"]
31
+ STREAM_VIS_PUBLIC = "public"
32
+ STREAM_VIS_UNLISTED = "unlisted"
33
+ STREAM_VIS_PRIVATE = "private"
34
+
35
+ #Base URL to Rumble's website, for URLs that are relative to it
36
+ RUMBLE_BASE_URL = "https://rumble.com"
37
+
38
+ #Numerical base that the stream ID is in
39
+ STREAM_ID_BASE = "0123456789abcdefghijklmnopqrstuvwxyz"
40
+
41
+ #Rumble's SSE chat display URL for a stream, format this string with a chat_id
42
+ SSE_CHAT_URL = "https://web7.rumble.com/chat/api/chat/{chat_id}/stream"
43
+
44
+ #Size of chat badge icons to retrieve, only valid one has long been the string 48
45
+ BADGE_ICON_SIZE = "48"
46
+
47
+ #How long to wait before giving up on a network request, in seconds
48
+ DEFAULT_TIMEOUT = 20
49
+
50
+ #How long to reuse old data from the API, in seconds
51
+ DEFAULT_REFRESH_RATE = 10